diff --git a/backend/src/main/java/online/wesal/wesal/config/SecurityConfig.java b/backend/src/main/java/online/wesal/wesal/config/SecurityConfig.java index dededc8..7ccb541 100644 --- a/backend/src/main/java/online/wesal/wesal/config/SecurityConfig.java +++ b/backend/src/main/java/online/wesal/wesal/config/SecurityConfig.java @@ -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(); diff --git a/backend/src/main/java/online/wesal/wesal/controller/CommentController.java b/backend/src/main/java/online/wesal/wesal/controller/CommentController.java new file mode 100644 index 0000000..dda0124 --- /dev/null +++ b/backend/src/main/java/online/wesal/wesal/controller/CommentController.java @@ -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> 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>> getCommentsByPostId( + @RequestParam Long postId, + Authentication authentication) { + + try { + if (postId == null || postId <= 0) { + return ResponseEntity.badRequest().body(ApiResponse.error("Valid post ID is required")); + } + + List 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")); + } + } +} \ No newline at end of file diff --git a/backend/src/main/java/online/wesal/wesal/dto/CommentCreateRequestDTO.java b/backend/src/main/java/online/wesal/wesal/dto/CommentCreateRequestDTO.java new file mode 100644 index 0000000..f6874aa --- /dev/null +++ b/backend/src/main/java/online/wesal/wesal/dto/CommentCreateRequestDTO.java @@ -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; + } +} \ No newline at end of file diff --git a/backend/src/main/java/online/wesal/wesal/dto/CommentResponseDTO.java b/backend/src/main/java/online/wesal/wesal/dto/CommentResponseDTO.java new file mode 100644 index 0000000..5c8c92a --- /dev/null +++ b/backend/src/main/java/online/wesal/wesal/dto/CommentResponseDTO.java @@ -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 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 getReplies() { + return replies; + } + + public void setReplies(List replies) { + this.replies = replies; + } +} \ No newline at end of file diff --git a/backend/src/main/java/online/wesal/wesal/entity/Comment.java b/backend/src/main/java/online/wesal/wesal/entity/Comment.java new file mode 100644 index 0000000..6260389 --- /dev/null +++ b/backend/src/main/java/online/wesal/wesal/entity/Comment.java @@ -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; + } +} \ No newline at end of file diff --git a/backend/src/main/java/online/wesal/wesal/repository/CommentRepository.java b/backend/src/main/java/online/wesal/wesal/repository/CommentRepository.java new file mode 100644 index 0000000..a079f7c --- /dev/null +++ b/backend/src/main/java/online/wesal/wesal/repository/CommentRepository.java @@ -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 { + + List findByPostIdOrderByCreationDateDesc(Long postId); + + @Query("SELECT c FROM Comment c WHERE c.postId = :postId ORDER BY c.creationDate ASC") + List findByPostIdOrderByCreationDateAsc(@Param("postId") Long postId); + + Optional 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 findRepliesByReplyComment(@Param("replyComment") Long replyComment); +} \ No newline at end of file diff --git a/backend/src/main/java/online/wesal/wesal/service/CommentService.java b/backend/src/main/java/online/wesal/wesal/service/CommentService.java new file mode 100644 index 0000000..da084f6 --- /dev/null +++ b/backend/src/main/java/online/wesal/wesal/service/CommentService.java @@ -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 getCommentsByPostId(Long postId) { + List comments = commentRepository.findByPostIdOrderByCreationDateAsc(postId); + + List creatorIds = comments.stream() + .map(Comment::getCreatorId) + .distinct() + .collect(Collectors.toList()); + + List replyUserIds = comments.stream() + .map(Comment::getReplyUser) + .filter(id -> id != null) + .distinct() + .collect(Collectors.toList()); + + creatorIds.addAll(replyUserIds); + + Map users = userRepository.findAllById(creatorIds.stream().distinct().collect(Collectors.toList())).stream() + .collect(Collectors.toMap(User::getId, user -> user)); + + Map 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 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; + } +} \ No newline at end of file diff --git a/frontend/lib/main.dart b/frontend/lib/main.dart index 615882b..b96bcc2 100644 --- a/frontend/lib/main.dart +++ b/frontend/lib/main.dart @@ -7,6 +7,8 @@ import 'services/notification_service.dart'; import 'services/auth_service.dart'; import 'services/user_service.dart'; +final GlobalKey navigatorKey = GlobalKey(); + 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, ); } diff --git a/frontend/lib/services/auth_service.dart b/frontend/lib/services/auth_service.dart index c945135..276b0df 100644 --- a/frontend/lib/services/auth_service.dart +++ b/frontend/lib/services/auth_service.dart @@ -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 clearUserData() async { await _storage.delete(key: _userDataKey); } + + static Future handleAuthenticationError() async { + await logout(); + + final context = navigatorKey.currentContext; + if (context != null && context.mounted) { + Navigator.of(context).pushNamedAndRemoveUntil('/', (route) => false); + } + } } diff --git a/frontend/lib/services/invitations_service.dart b/frontend/lib/services/invitations_service.dart index fe1a3a2..3aadeab 100644 --- a/frontend/lib/services/invitations_service.dart +++ b/frontend/lib/services/invitations_service.dart @@ -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> 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, diff --git a/frontend/lib/services/post_service.dart b/frontend/lib/services/post_service.dart index e4e973d..9afe15a 100644 --- a/frontend/lib/services/post_service.dart +++ b/frontend/lib/services/post_service.dart @@ -1,15 +1,15 @@ import 'dart:convert'; import '../models/post_models.dart'; import 'http_service.dart'; +import 'auth_service.dart'; class PostService { static Future> 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': [], }; + } else { + try { + final responseData = jsonDecode(response.body); + return { + 'success': false, + 'message': responseData['message'] ?? 'Failed to fetch posts', + 'posts': [], + }; + } catch (e) { + return { + 'success': false, + 'message': 'Server error (${response.statusCode})', + 'posts': [], + }; + } } } catch (e) { print('Error fetching posts: $e'); diff --git a/frontend/lib/services/user_service.dart b/frontend/lib/services/user_service.dart index 5ec856d..d27c77a 100644 --- a/frontend/lib/services/user_service.dart +++ b/frontend/lib/services/user_service.dart @@ -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,