-
Notifications
You must be signed in to change notification settings - Fork 0
Implement private direct messaging between users #31
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
03a5e20
af1a9ae
f80667a
737c48e
4850296
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,162 @@ | ||||||
| package com.accord.controller; | ||||||
|
|
||||||
| import com.accord.model.DirectMessage; | ||||||
| import com.accord.model.User; | ||||||
| import com.accord.service.DirectMessageService; | ||||||
| import com.accord.service.UserService; | ||||||
| import com.accord.util.ValidationUtils; | ||||||
| import org.springframework.beans.factory.annotation.Autowired; | ||||||
| import org.springframework.beans.factory.annotation.Value; | ||||||
| import org.springframework.http.ResponseEntity; | ||||||
| import org.springframework.messaging.simp.SimpMessagingTemplate; | ||||||
| import org.springframework.web.bind.annotation.*; | ||||||
|
|
||||||
| import java.util.List; | ||||||
| import java.util.Map; | ||||||
| import java.util.Optional; | ||||||
|
|
||||||
| @RestController | ||||||
| @RequestMapping("/api/dm") | ||||||
| @CrossOrigin(origins = "${app.cors.allowed-origins}") | ||||||
| public class DirectMessageController { | ||||||
|
|
||||||
| private static final int MAX_LIMIT = 500; | ||||||
|
|
||||||
| @Value("${app.message.max-length}") | ||||||
| private int maxMessageLength; | ||||||
|
|
||||||
| @Autowired | ||||||
| private DirectMessageService directMessageService; | ||||||
|
|
||||||
| @Autowired | ||||||
| private UserService userService; | ||||||
|
|
||||||
| @Autowired | ||||||
| private SimpMessagingTemplate messagingTemplate; | ||||||
|
|
||||||
| @PostMapping("/send") | ||||||
| public ResponseEntity<?> sendDirectMessage(@RequestBody Map<String, Object> payload) { | ||||||
| String senderUsername = (String) payload.get("senderUsername"); | ||||||
| String recipientUsername = (String) payload.get("recipientUsername"); | ||||||
| String content = (String) payload.get("content"); | ||||||
|
|
||||||
| if (senderUsername == null || senderUsername.trim().isEmpty()) { | ||||||
| return ResponseEntity.badRequest().body(Map.of("error", "Sender username is required")); | ||||||
| } | ||||||
| if (recipientUsername == null || recipientUsername.trim().isEmpty()) { | ||||||
| return ResponseEntity.badRequest().body(Map.of("error", "Recipient username is required")); | ||||||
| } | ||||||
| if (!ValidationUtils.isValidContent(content, maxMessageLength)) { | ||||||
| return ResponseEntity.badRequest().body(Map.of("error", "Invalid message content")); | ||||||
| } | ||||||
| if (senderUsername.trim().equals(recipientUsername.trim())) { | ||||||
| return ResponseEntity.badRequest().body(Map.of("error", "Cannot send a message to yourself")); | ||||||
| } | ||||||
|
|
||||||
| Optional<User> sender = userService.findByUsername(senderUsername.trim()); | ||||||
| Optional<User> recipient = userService.findByUsername(recipientUsername.trim()); | ||||||
|
|
||||||
| if (sender.isEmpty()) { | ||||||
| return ResponseEntity.badRequest().body(Map.of("error", "Sender not found")); | ||||||
| } | ||||||
| if (recipient.isEmpty()) { | ||||||
| return ResponseEntity.badRequest().body(Map.of("error", "Recipient not found")); | ||||||
| } | ||||||
|
|
||||||
| DirectMessage message = directMessageService.sendMessage( | ||||||
| sender.get().getId(), recipient.get().getId(), content.trim()); | ||||||
|
|
||||||
| // Send real-time notification to recipient via WebSocket | ||||||
| messagingTemplate.convertAndSend( | ||||||
| "/user/" + recipient.get().getId() + "/queue/messages", message); | ||||||
|
||||||
| "/user/" + recipient.get().getId() + "/queue/messages", message); | |
| "/user/" + recipient.get().getUsername() + "/queue/messages", message); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -15,7 +15,10 @@ | |
| import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; | ||
| import org.springframework.web.bind.annotation.*; | ||
|
|
||
| import java.util.HashMap; | ||
| import java.util.List; | ||
| import java.util.Map; | ||
| import java.util.stream.Collectors; | ||
|
|
||
| /** | ||
| * User authentication controller handling registration and login. | ||
|
|
@@ -125,4 +128,17 @@ public ResponseEntity<Boolean> checkUsername(@PathVariable String username) { | |
| boolean exists = userService.userExists(username.trim()); | ||
| return ResponseEntity.ok(exists); | ||
| } | ||
|
|
||
| @GetMapping | ||
| public ResponseEntity<?> listUsers() { | ||
| List<Map<String, Object>> users = userService.findAllUsers().stream() | ||
| .map(user -> { | ||
| Map<String, Object> userMap = new HashMap<>(); | ||
| userMap.put("id", user.getId()); | ||
| userMap.put("username", user.getUsername()); | ||
| return userMap; | ||
| }) | ||
| .collect(Collectors.toList()); | ||
| return ResponseEntity.ok(users); | ||
| } | ||
|
Comment on lines
+132
to
+143
|
||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,90 @@ | ||||||||||||||||||||||||
| package com.accord.model; | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| import jakarta.persistence.*; | ||||||||||||||||||||||||
| import java.time.LocalDateTime; | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| @Entity | ||||||||||||||||||||||||
| @Table(name = "direct_messages") | ||||||||||||||||||||||||
| public class DirectMessage { | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| @Id | ||||||||||||||||||||||||
| @GeneratedValue(strategy = GenerationType.IDENTITY) | ||||||||||||||||||||||||
| private Long id; | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| @Column(nullable = false) | ||||||||||||||||||||||||
| private Long senderId; | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| @Column(nullable = false) | ||||||||||||||||||||||||
| private Long recipientId; | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
|
||||||||||||||||||||||||
| @ManyToOne(fetch = FetchType.LAZY) | |
| @JoinColumn(name = "sender_id", insertable = false, updatable = false, | |
| foreignKey = @ForeignKey(name = "fk_direct_message_sender")) | |
| private User sender; | |
| @ManyToOne(fetch = FetchType.LAZY) | |
| @JoinColumn(name = "recipient_id", insertable = false, updatable = false, | |
| foreignKey = @ForeignKey(name = "fk_direct_message_recipient")) | |
| private User recipient; |
Copilot
AI
Feb 15, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The DirectMessage entity lacks database indexes for frequently queried fields. The queries in DirectMessageRepository filter by senderId, recipientId, and read status, but these columns are not indexed. This will cause performance degradation as the number of direct messages grows.
Consider adding composite indexes:
- Index on (recipientId, senderId, timestamp) for conversation queries
- Index on (recipientId, read) for unread count queries
- Index on (recipientId, senderId, read) for the markConversationAsRead query
Add these using JPA annotations like @table(indexes = {@Index(columnList = "recipientId,senderId,timestamp")}) or create them via migration scripts.
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,37 @@ | ||||||||||||||||||
| package com.accord.repository; | ||||||||||||||||||
|
|
||||||||||||||||||
| import com.accord.model.DirectMessage; | ||||||||||||||||||
| import org.springframework.data.domain.Pageable; | ||||||||||||||||||
| import org.springframework.data.jpa.repository.JpaRepository; | ||||||||||||||||||
| import org.springframework.data.jpa.repository.Modifying; | ||||||||||||||||||
| import org.springframework.data.jpa.repository.Query; | ||||||||||||||||||
| import org.springframework.data.repository.query.Param; | ||||||||||||||||||
| import org.springframework.stereotype.Repository; | ||||||||||||||||||
|
|
||||||||||||||||||
| import java.util.List; | ||||||||||||||||||
|
|
||||||||||||||||||
| @Repository | ||||||||||||||||||
| public interface DirectMessageRepository extends JpaRepository<DirectMessage, Long> { | ||||||||||||||||||
|
|
||||||||||||||||||
| @Query("SELECT dm FROM DirectMessage dm WHERE " + | ||||||||||||||||||
| "(dm.senderId = :userId1 AND dm.recipientId = :userId2) OR " + | ||||||||||||||||||
| "(dm.senderId = :userId2 AND dm.recipientId = :userId1) " + | ||||||||||||||||||
| "ORDER BY dm.timestamp DESC") | ||||||||||||||||||
| List<DirectMessage> findConversation(@Param("userId1") Long userId1, | ||||||||||||||||||
| @Param("userId2") Long userId2, | ||||||||||||||||||
| Pageable pageable); | ||||||||||||||||||
|
|
||||||||||||||||||
| @Modifying | ||||||||||||||||||
| @Query("UPDATE DirectMessage dm SET dm.read = true WHERE dm.recipientId = :recipientId AND dm.senderId = :senderId AND dm.read = false") | ||||||||||||||||||
| int markConversationAsRead(@Param("recipientId") Long recipientId, @Param("senderId") Long senderId); | ||||||||||||||||||
|
|
||||||||||||||||||
| @Query("SELECT COUNT(dm) FROM DirectMessage dm WHERE dm.recipientId = :recipientId AND dm.senderId = :senderId AND dm.read = false") | ||||||||||||||||||
| long countUnreadFromSender(@Param("recipientId") Long recipientId, @Param("senderId") Long senderId); | ||||||||||||||||||
|
Comment on lines
+26
to
+29
|
||||||||||||||||||
| int markConversationAsRead(@Param("recipientId") Long recipientId, @Param("senderId") Long senderId); | |
| @Query("SELECT COUNT(dm) FROM DirectMessage dm WHERE dm.recipientId = :recipientId AND dm.senderId = :senderId AND dm.read = false") | |
| long countUnreadFromSender(@Param("recipientId") Long recipientId, @Param("senderId") Long senderId); | |
| int markConversationAsRead(@Param("recipientId") Long currentUserId, @Param("senderId") Long otherUserId); | |
| @Query("SELECT COUNT(dm) FROM DirectMessage dm WHERE dm.recipientId = :recipientId AND dm.senderId = :senderId AND dm.read = false") | |
| long countUnreadFromSender(@Param("recipientId") Long currentUserId, @Param("senderId") Long otherUserId); |
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,57 @@ | ||||||||||||
| package com.accord.service; | ||||||||||||
|
|
||||||||||||
| import com.accord.model.DirectMessage; | ||||||||||||
| import com.accord.repository.DirectMessageRepository; | ||||||||||||
| import org.springframework.beans.factory.annotation.Autowired; | ||||||||||||
| import org.springframework.data.domain.PageRequest; | ||||||||||||
| import org.springframework.data.domain.Pageable; | ||||||||||||
| import org.springframework.stereotype.Service; | ||||||||||||
| import org.springframework.transaction.annotation.Transactional; | ||||||||||||
|
|
||||||||||||
| import java.util.Collections; | ||||||||||||
| import java.util.List; | ||||||||||||
|
|
||||||||||||
| @Service | ||||||||||||
| public class DirectMessageService { | ||||||||||||
|
|
||||||||||||
| @Autowired | ||||||||||||
| private DirectMessageRepository directMessageRepository; | ||||||||||||
|
|
||||||||||||
| public DirectMessage sendMessage(Long senderId, Long recipientId, String content) { | ||||||||||||
|
||||||||||||
| public DirectMessage sendMessage(Long senderId, Long recipientId, String content) { | |
| public DirectMessage sendMessage(Long senderId, Long recipientId, String content) { | |
| if (senderId != null && senderId.equals(recipientId)) { | |
| throw new IllegalArgumentException("Cannot send message to self"); | |
| } |
Copilot
AI
Feb 15, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The findConversation query orders by dm.timestamp DESC to get the most recent messages, then the service reverses the list to show oldest-first. This approach is correct for pagination (getting the N most recent messages). However, the JPQL query could be optimized by adding a subquery or using a native query with ORDER BY timestamp DESC LIMIT :limit then reversing in SQL, depending on the database.
This is not a critical issue for the current implementation but could be optimized if performance becomes a concern with large conversation histories. The current approach is acceptable and readable.
Copilot
AI
Feb 15, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The markAsRead method silently does nothing if the message is not found or if the recipientId doesn't match. While this prevents errors, it makes it difficult to debug issues where read receipts aren't working. Consider logging a warning or debug message when a mark-as-read operation is skipped due to these conditions.
Example: logger.debug("Message {} not found or user {} is not the recipient", messageId, recipientId);
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The WebSocket configuration sets the user destination prefix to "/user" explicitly with
setUserDestinationPrefix("/user"), but this is actually the default value in Spring. While this is not incorrect, it's redundant. The configuration could be simplified by removing line 27, as Spring WebSocket already defaults to "/user" for user destinations.This is a minor code cleanliness issue and does not affect functionality.