Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
package in.koreatech.koin.domain.team.recruitment.repository;

import in.koreatech.koin.domain.team.recruitment.model.TeamRecruitmentChatMember;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.Repository;
import org.springframework.data.repository.query.Param;

import java.util.Collection;
import java.util.List;
import java.util.Optional;

Expand All @@ -16,5 +19,24 @@ public interface TeamRecruitmentChatMemberRepository extends Repository<TeamRecr

List<TeamRecruitmentChatMember> findAllByChatRoom_Id(Integer chatRoomId);

@Query("""
SELECT member
FROM TeamRecruitmentChatMember member
JOIN FETCH member.chatRoom chatRoom
JOIN FETCH chatRoom.recruitment
WHERE member.user.id = :userId
""")
List<TeamRecruitmentChatMember> findAllByUserIdWithChatRoomAndRecruitment(@Param("userId") Integer userId);

@Query("""
SELECT member
FROM TeamRecruitmentChatMember member
JOIN FETCH member.user
WHERE member.chatRoom.id IN :chatRoomIds
""")
List<TeamRecruitmentChatMember> findAllWithUsersByChatRoomIds(
@Param("chatRoomIds") Collection<Integer> chatRoomIds
);

long countByChatRoom_Id(Integer chatRoomId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@

import in.koreatech.koin.domain.team.recruitment.model.TeamRecruitmentChatMessage;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.Repository;
import org.springframework.data.repository.query.Param;

import java.util.Collection;
import java.util.List;
import java.util.Optional;

Expand All @@ -13,6 +16,31 @@ public interface TeamRecruitmentChatMessageRepository extends Repository<TeamRec

TeamRecruitmentChatMessage save(TeamRecruitmentChatMessage message);

@Query("""
SELECT message
FROM TeamRecruitmentChatMessage message
WHERE message.chatRoom.id IN :chatRoomIds
AND message.id = (
SELECT MAX(latest.id)
FROM TeamRecruitmentChatMessage latest
WHERE latest.chatRoom.id = message.chatRoom.id
)
""")
List<TeamRecruitmentChatMessage> findLatestByChatRoomIds(
@Param("chatRoomIds") Collection<Integer> chatRoomIds
);

@Query("""
SELECT message.chatRoom.id AS chatRoomId, COUNT(message.id) AS unreadMessageCount
FROM TeamRecruitmentChatMessage message
JOIN message.chatRoom.members member
WHERE member.user.id = :userId
AND message.sender.id <> :userId
AND (member.lastReadMessageId IS NULL OR message.id > member.lastReadMessageId)
GROUP BY message.chatRoom.id
""")
List<ChatRoomUnreadCount> countUnreadMessagesByUserId(@Param("userId") Integer userId);

/** Returns the initial page in ascending message-id order. */
List<TeamRecruitmentChatMessage> findAllByChatRoom_IdOrderByIdAsc(
Integer chatRoomId,
Expand All @@ -32,4 +60,11 @@ List<TeamRecruitmentChatMessage> findAllByChatRoom_IdAndIdLessThanOrderByIdDesc(
Integer beforeMessageId,
Pageable pageable
);

interface ChatRoomUnreadCount {

Integer getChatRoomId();

long getUnreadMessageCount();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
import in.koreatech.koin.domain.teamrecruitment.dto.ChatRoomResponse;
import in.koreatech.koin.domain.teamrecruitment.dto.CreateChatMessageRequest;
import in.koreatech.koin.domain.teamrecruitment.dto.DirectChatRoomResponse;
import in.koreatech.koin.domain.teamrecruitment.dto.TeamRecruitmentChatRoomListItemResponse;
import in.koreatech.koin.global.code.ApiResponseCodes;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
Expand All @@ -35,6 +36,18 @@
@Tag(name = "(Normal) Team Recruitment Chat: 팀원 모집 채팅", description = "팀원 모집 채팅 API")
public interface TeamRecruitmentChatApi {

@ApiResponseCodes({
OK,
UNAUTHORIZED_USER,
FORBIDDEN_USER_TYPE,
})
@Operation(
summary = "내 팀원 모집 채팅방 목록 조회",
description = "현재 사용자가 멤버인 TEAM/DIRECT 채팅방을 최근 메시지순으로 반환합니다. "
+ "메시지가 없는 방과 READ_ONLY 방도 포함합니다."
)
ResponseEntity<List<TeamRecruitmentChatRoomListItemResponse>> getChatRooms(Integer userId);

@ApiResponseCodes({
OK,
TEAM_RECRUITMENT_CHAT_NOT_FOUND,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import in.koreatech.koin.domain.teamrecruitment.dto.CreateChatMessageRequest;
import in.koreatech.koin.domain.teamrecruitment.dto.DirectChatRoomCreationResult;
import in.koreatech.koin.domain.teamrecruitment.dto.DirectChatRoomResponse;
import in.koreatech.koin.domain.teamrecruitment.dto.TeamRecruitmentChatRoomListItemResponse;
import in.koreatech.koin.domain.teamrecruitment.service.TeamRecruitmentChatService;
import in.koreatech.koin.global.auth.Auth;

Expand All @@ -26,12 +27,19 @@

@RestController
@RequiredArgsConstructor
@RequestMapping("/chatroom/team-recruitment/{recruitmentId}")
@RequestMapping("/chatroom/team-recruitment")
public class TeamRecruitmentChatController implements TeamRecruitmentChatApi {

private final TeamRecruitmentChatService chatService;

@GetMapping("/{chatRoomId}")
@GetMapping
public ResponseEntity<List<TeamRecruitmentChatRoomListItemResponse>> getChatRooms(
@Auth(permit = {STUDENT}) Integer userId
) {
return ResponseEntity.ok(chatService.getChatRooms(userId));
}

@GetMapping("/{recruitmentId}/{chatRoomId}")
public ResponseEntity<ChatRoomResponse> getChatRoom(
@Auth(permit = {STUDENT}) Integer userId,
@PathVariable Integer recruitmentId,
Expand All @@ -40,7 +48,7 @@ public ResponseEntity<ChatRoomResponse> getChatRoom(
return ResponseEntity.ok(chatService.getChatRoom(userId, recruitmentId, chatRoomId));
}

@PostMapping("/applications/{applicationId}/direct")
@PostMapping("/{recruitmentId}/applications/{applicationId}/direct")
public ResponseEntity<DirectChatRoomResponse> getOrCreateDirectChatRoom(
@Auth(permit = {STUDENT}) Integer userId,
@PathVariable Integer recruitmentId,
Expand All @@ -51,7 +59,7 @@ public ResponseEntity<DirectChatRoomResponse> getOrCreateDirectChatRoom(
return ResponseEntity.status(status).body(result.response());
}

@GetMapping("/{chatRoomId}/messages")
@GetMapping("/{recruitmentId}/{chatRoomId}/messages")
public ResponseEntity<List<ChatMessageResponse>> getMessages(
@Auth(permit = {STUDENT}) Integer userId,
@PathVariable Integer recruitmentId,
Expand All @@ -63,7 +71,7 @@ public ResponseEntity<List<ChatMessageResponse>> getMessages(
return ResponseEntity.ok(chatService.getMessages(userId, recruitmentId, chatRoomId, afterMessageId, beforeMessageId, limit));
}

@PostMapping("/{chatRoomId}/messages")
@PostMapping("/{recruitmentId}/{chatRoomId}/messages")
public ResponseEntity<ChatMessageResponse> createMessage(
@Auth(permit = {STUDENT}) Integer userId,
@PathVariable Integer recruitmentId,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package in.koreatech.koin.domain.teamrecruitment.dto;

import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy;
import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED;

import java.time.LocalDateTime;

import com.fasterxml.jackson.databind.annotation.JsonNaming;

import in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentChatRoomType;
import in.koreatech.koin.domain.team.recruitment.model.TeamRecruitmentChatMessage;
import in.koreatech.koin.domain.team.recruitment.model.TeamRecruitmentChatRoom;
import in.koreatech.koin.domain.user.model.User;
import io.swagger.v3.oas.annotations.media.Schema;

@JsonNaming(SnakeCaseStrategy.class)
public record TeamRecruitmentChatRoomListItemResponse(
@Schema(description = "모집글 ID", example = "17", requiredMode = REQUIRED)
Integer recruitmentId,

@Schema(description = "채팅방 ID", example = "31", requiredMode = REQUIRED)
Integer chatRoomId,

@Schema(description = "채팅방 이름. TEAM은 모집글 제목, DIRECT는 상대방 닉네임입니다.",
example = "AI 아이디어 공모전 팀원 모집", requiredMode = REQUIRED)
String roomName,

@Schema(description = "채팅방 타입", example = "TEAM", requiredMode = REQUIRED)
String roomType,

@Schema(description = "채팅방 상태", example = "ACTIVE", requiredMode = REQUIRED)
String status,

@Schema(description = "DIRECT 상대방 유저 ID. TEAM은 null입니다.", example = "22",
nullable = true, requiredMode = REQUIRED)
Integer counterpartId,

@Schema(description = "DIRECT 상대방 닉네임. TEAM은 null입니다.", example = "김철수",
nullable = true, requiredMode = REQUIRED)
String counterpartNickname,

@Schema(description = "최근 메시지 ID. 메시지가 없으면 null입니다.", example = "901",
nullable = true, requiredMode = REQUIRED)
Integer lastMessageId,

@Schema(description = "최근 메시지 내용. 메시지가 없으면 null입니다.", example = "안녕하세요!",
nullable = true, requiredMode = REQUIRED)
String lastMessageContent,

@Schema(description = "최근 메시지 전송 시각. 메시지가 없으면 null입니다.",
example = "2026-08-26T11:20:30.123456", nullable = true, requiredMode = REQUIRED)
LocalDateTime lastMessageAt,

@Schema(description = "최근 메시지의 이미지 여부. 메시지가 없으면 null입니다.", example = "false",
nullable = true, requiredMode = REQUIRED)
Boolean lastMessageIsImage,

@Schema(description = "현재 사용자가 읽지 않은 메시지 수", example = "2", requiredMode = REQUIRED)
int unreadMessageCount
) {

public static TeamRecruitmentChatRoomListItemResponse of(
TeamRecruitmentChatRoom chatRoom,
User counterpart,
TeamRecruitmentChatMessage lastMessage,
int unreadMessageCount
) {
boolean direct = chatRoom.getRoomType() == TeamRecruitmentChatRoomType.DIRECT;
return new TeamRecruitmentChatRoomListItemResponse(
chatRoom.getRecruitment().getId(),
chatRoom.getId(),
direct && counterpart != null ? counterpart.getNickname() : chatRoom.getRecruitment().getTitle(),
chatRoom.getRoomType().name(),
chatRoom.getStatus().name(),
direct && counterpart != null ? counterpart.getId() : null,
direct && counterpart != null ? counterpart.getNickname() : null,
lastMessage == null ? null : lastMessage.getId(),
lastMessage == null ? null : lastMessage.getContent(),
lastMessage == null ? null : lastMessage.getCreatedAt(),
lastMessage == null ? null : lastMessage.getIsImage(),
unreadMessageCount
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,13 @@
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.Function;
import java.util.stream.Collectors;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
Expand All @@ -33,6 +36,7 @@
import in.koreatech.koin.domain.teamrecruitment.dto.CreateChatMessageRequest;
import in.koreatech.koin.domain.teamrecruitment.dto.DirectChatRoomCreationResult;
import in.koreatech.koin.domain.teamrecruitment.dto.DirectChatRoomResponse;
import in.koreatech.koin.domain.teamrecruitment.dto.TeamRecruitmentChatRoomListItemResponse;
import in.koreatech.koin.domain.user.model.User;
import in.koreatech.koin.global.exception.CustomException;
import lombok.RequiredArgsConstructor;
Expand Down Expand Up @@ -67,6 +71,64 @@ public class TeamRecruitmentChatService {
private final ObjectMapper objectMapper;
private final Clock clock;

public List<TeamRecruitmentChatRoomListItemResponse> getChatRooms(Integer userId) {
List<TeamRecruitmentChatMember> memberships =
memberRepository.findAllByUserIdWithChatRoomAndRecruitment(userId);
if (memberships.isEmpty()) {
return List.of();
}

List<TeamRecruitmentChatRoom> chatRooms = memberships.stream()
.map(TeamRecruitmentChatMember::getChatRoom)
.toList();
List<Integer> chatRoomIds = chatRooms.stream()
.map(TeamRecruitmentChatRoom::getId)
.toList();
List<Integer> directChatRoomIds = chatRooms.stream()
.filter(room -> room.getRoomType() == TeamRecruitmentChatRoomType.DIRECT)
.map(TeamRecruitmentChatRoom::getId)
.toList();

Map<Integer, User> counterpartsByChatRoomId = directChatRoomIds.isEmpty()
? Map.of()
: memberRepository.findAllWithUsersByChatRoomIds(directChatRoomIds).stream()
.filter(member -> !member.getUser().getId().equals(userId))
.collect(Collectors.toMap(
member -> member.getChatRoom().getId(),
TeamRecruitmentChatMember::getUser,
(first, ignored) -> first
));
Map<Integer, TeamRecruitmentChatMessage> latestMessagesByChatRoomId = messageRepository
.findLatestByChatRoomIds(chatRoomIds).stream()
.collect(Collectors.toMap(
message -> message.getChatRoom().getId(),
Function.identity()
));
Map<Integer, Long> unreadCountsByChatRoomId = messageRepository.countUnreadMessagesByUserId(userId).stream()
.collect(Collectors.toMap(
TeamRecruitmentChatMessageRepository.ChatRoomUnreadCount::getChatRoomId,
TeamRecruitmentChatMessageRepository.ChatRoomUnreadCount::getUnreadMessageCount
));

return chatRooms.stream()
.map(chatRoom -> TeamRecruitmentChatRoomListItemResponse.of(
chatRoom,
counterpartsByChatRoomId.get(chatRoom.getId()),
latestMessagesByChatRoomId.get(chatRoom.getId()),
Math.toIntExact(unreadCountsByChatRoomId.getOrDefault(chatRoom.getId(), 0L))
))
.sorted(Comparator
.comparing(
TeamRecruitmentChatRoomListItemResponse::lastMessageAt,
Comparator.nullsLast(Comparator.reverseOrder())
)
.thenComparing(
TeamRecruitmentChatRoomListItemResponse::chatRoomId,
Comparator.reverseOrder()
))
.toList();
}

public ChatRoomResponse getChatRoom(Integer userId, Integer recruitmentId, Integer chatRoomId) {
TeamRecruitmentChatRoom chatRoom = chatRoomRepository.findById(chatRoomId)
.orElseThrow(() -> CustomException.of(TEAM_RECRUITMENT_CHAT_NOT_FOUND));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,12 @@ public void customise(OpenAPI openApi) {
);
configureDateTime(schemas, "ApplicationCreatedResponse", "created_at", SPACE_LOCAL_DATE_TIME_PATTERN);
configureDateTime(schemas, "RecruitmentDetail", "created_at", SPACE_LOCAL_DATE_TIME_PATTERN);
configureDateTime(
schemas,
"TeamRecruitmentChatRoomListItemResponse",
"last_message_at",
ISO_LOCAL_DATE_TIME_PATTERN
);
configureChatMessageTimestamp(openApi, schemas);
}

Expand Down
Loading
Loading