diff --git a/.gitignore b/.gitignore index e85caac..2d026e6 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,4 @@ out/ .env .terraform *.pem +/src/main/resources/firebase diff --git a/src/main/java/ita/tinybite/domain/party/controller/PartyController.java b/src/main/java/ita/tinybite/domain/party/controller/PartyController.java index 512289f..78d2adf 100644 --- a/src/main/java/ita/tinybite/domain/party/controller/PartyController.java +++ b/src/main/java/ita/tinybite/domain/party/controller/PartyController.java @@ -10,6 +10,7 @@ import ita.tinybite.domain.auth.entity.JwtTokenProvider; import ita.tinybite.domain.chat.entity.ChatRoom; import ita.tinybite.domain.party.dto.request.PartyCreateRequest; +import ita.tinybite.domain.party.dto.request.PartyUpdateRequest; import ita.tinybite.domain.party.dto.response.ChatRoomResponse; import ita.tinybite.domain.party.dto.response.PartyDetailResponse; import ita.tinybite.domain.party.dto.response.PartyListResponse; @@ -19,14 +20,9 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestHeader; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.ErrorResponse; +import org.springframework.web.bind.annotation.*; import java.util.List; @@ -272,8 +268,6 @@ public ResponseEntity> getPendingParticipants( return ResponseEntity.ok(participants); } - - /** * 파티 목록 조회 (홈 화면) */ @@ -287,17 +281,12 @@ public ResponseEntity getPartyList( example = "ALL", schema = @Schema(allowableValues = {"ALL", "DELIVERY", "GROCERY", "HOUSEHOLD"}) ) - @RequestParam(defaultValue = "ALL") PartyCategory category, - - @Parameter(description = "사용자 위도", required = true, example = "37.4979") - @RequestParam String latitude, - - @Parameter(description = "사용자 경도", required = true, example = "127.0276") - @RequestParam String longitude) { + @RequestParam(defaultValue = "ALL") PartyCategory category + ) { Long userId = jwtTokenProvider.getUserId(token); PartyListResponse response = partyService.getPartyList( - userId, category, latitude, longitude); + userId, category); return ResponseEntity.ok(response); } @@ -330,12 +319,11 @@ public ResponseEntity getPartyList( @GetMapping("/{partyId}") public ResponseEntity getPartyDetail( @PathVariable Long partyId, - @RequestHeader("Authorization") String token, - @RequestParam Double latitude, - @RequestParam Double longitude) { + @RequestHeader("Authorization") String token + ) { Long userId = jwtTokenProvider.getUserId(token); - PartyDetailResponse response = partyService.getPartyDetail(partyId, userId, latitude, longitude); + PartyDetailResponse response = partyService.getPartyDetail(partyId, userId); return ResponseEntity.ok(response); } @@ -374,4 +362,90 @@ public ResponseEntity createParty( return ResponseEntity.ok(partyId); } + /** + * 파티 수정 + */ + @Operation( + summary = "파티 수정", + description = """ + 파티 정보를 수정합니다. + + **수정 권한** + - 파티 호스트만 수정 가능 + + **수정 가능 범위** + - 승인된 파티원이 없을 때: 모든 항목 수정 가능 + - 승인된 파티원이 있을 때: 설명, 이미지만 수정 가능 (가격, 인원, 수령 정보 수정 불가) + """ + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "파티 수정 성공" + ), + @ApiResponse( + responseCode = "400", + description = "잘못된 요청 (수정 권한 없음, 유효하지 않은 데이터)", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)) + ), + @ApiResponse( + responseCode = "404", + description = "파티를 찾을 수 없음", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)) + ) + }) + @PatchMapping("/{partyId}") + public ResponseEntity updateParty( + @PathVariable Long partyId, + @AuthenticationPrincipal Long userId, + @RequestBody PartyUpdateRequest request) { + + partyService.updateParty(partyId, userId, request); + return ResponseEntity.ok().build(); + } + + /** + * 파티 삭제 + */ + @Operation( + summary = "파티 삭제", + description = """ + 파티를 삭제합니다. + + **삭제 권한** + - 파티 호스트만 삭제 가능 + + **삭제 제한** + - 승인된 파티원이 있는 경우 삭제 불가능 + - 승인된 파티원이 없을 때만 삭제 가능 + + **삭제 시 처리** + - 관련 채팅방 비활성화 + - 대기 중인 참가 신청 모두 삭제 + """ + ) + @ApiResponses({ + @ApiResponse( + responseCode = "204", + description = "파티 삭제 성공" + ), + @ApiResponse( + responseCode = "400", + description = "잘못된 요청 (삭제 권한 없음, 승인된 파티원 존재)", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)) + ), + @ApiResponse( + responseCode = "404", + description = "파티를 찾을 수 없음", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)) + ) + }) + @DeleteMapping("/{partyId}") + public ResponseEntity deleteParty( + @PathVariable Long partyId, + @AuthenticationPrincipal Long userId) { + + partyService.deleteParty(partyId, userId); + return ResponseEntity.noContent().build(); + } } \ No newline at end of file diff --git a/src/main/java/ita/tinybite/domain/party/dto/request/PartyUpdateRequest.java b/src/main/java/ita/tinybite/domain/party/dto/request/PartyUpdateRequest.java index 30e8520..1a19b61 100644 --- a/src/main/java/ita/tinybite/domain/party/dto/request/PartyUpdateRequest.java +++ b/src/main/java/ita/tinybite/domain/party/dto/request/PartyUpdateRequest.java @@ -28,7 +28,7 @@ public class PartyUpdateRequest { private Double latitude; private Double longitude; - @Pattern(regexp = "^(https?://)?.*", message = "올바른 URL 형식으로 입력해주세요") +// @Pattern(regexp = "^(https?://)?.*", message = "올바른 URL 형식으로 입력해주세요") private String productLink; // 항상 수정 가능한 필드 diff --git a/src/main/java/ita/tinybite/domain/party/entity/Party.java b/src/main/java/ita/tinybite/domain/party/entity/Party.java index f71189d..819d350 100644 --- a/src/main/java/ita/tinybite/domain/party/entity/Party.java +++ b/src/main/java/ita/tinybite/domain/party/entity/Party.java @@ -76,7 +76,6 @@ public class Party { private LocalDateTime createdAt; // 등록시간 @UpdateTimestamp - @Column(nullable = false) private LocalDateTime updatedAt; private LocalDateTime closedAt; @@ -89,6 +88,16 @@ public class Party { @Builder.Default private List participants = new ArrayList<>(); // 파티 참여 유저 + /** + * 참여자 수 증가 + */ + public void incrementParticipants() { + if (this.currentParticipants >= this.maxParticipants) { + throw new IllegalStateException("파티 인원이 가득 찼습니다"); + } + this.currentParticipants++; + } + public String getTimeAgo() { LocalDateTime now = LocalDateTime.now(); diff --git a/src/main/java/ita/tinybite/domain/party/repository/PartyParticipantRepository.java b/src/main/java/ita/tinybite/domain/party/repository/PartyParticipantRepository.java index fbb9974..a03256b 100644 --- a/src/main/java/ita/tinybite/domain/party/repository/PartyParticipantRepository.java +++ b/src/main/java/ita/tinybite/domain/party/repository/PartyParticipantRepository.java @@ -6,6 +6,7 @@ import ita.tinybite.domain.party.entity.Party; import ita.tinybite.domain.party.entity.PartyParticipant; import ita.tinybite.domain.party.enums.ParticipantStatus; +import ita.tinybite.domain.party.enums.PartyStatus; import ita.tinybite.domain.user.entity.User; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; @@ -24,4 +25,23 @@ public interface PartyParticipantRepository extends JpaRepository findByPartyAndStatus(Party party, ParticipantStatus status); boolean existsByPartyAndUserAndStatus(Party party, User user, ParticipantStatus status); + + @Query("SELECT pp FROM PartyParticipant pp " + + "WHERE pp.user.id = :userId " + + "AND pp.party.status =:partyStatus " + + "AND pp.status = :participantStatus") + List findActivePartiesByUserId( + @Param("userId") Long userId, + @Param("partyStatuses") PartyStatus partyStatus, + @Param("participantStatus") ParticipantStatus participantStatus + ); + + @Query("SELECT COUNT(pp) FROM PartyParticipant pp " + + "WHERE pp.party.id = :partyId " + + "AND pp.status = :status") + int countByPartyIdAndStatus( + @Param("partyId") Long partyId, + @Param("status") ParticipantStatus status + ); + } \ No newline at end of file diff --git a/src/main/java/ita/tinybite/domain/party/service/PartyService.java b/src/main/java/ita/tinybite/domain/party/service/PartyService.java index 45b4137..4080a0d 100644 --- a/src/main/java/ita/tinybite/domain/party/service/PartyService.java +++ b/src/main/java/ita/tinybite/domain/party/service/PartyService.java @@ -61,13 +61,11 @@ public Long createParty(Long userId, PartyCreateRequest request) { .maxParticipants(request.getMaxParticipants()) .pickupLocation(PickupLocation.builder() .place(request.getPickupLocation().getPlace()) -// .pickupLatitude(request.getPickupLocation().getPickupLatitude()) -// .pickupLongitude(request.getPickupLocation().getPickupLongitude()) .build()) - .image(request.getImages().get(0)) - .thumbnailImage(thumbnailImage) - .link(request.getProductLink()) - .description(request.getDescription()) + .image(getImageIfPresent(request.getImages())) + .thumbnailImage(getThumbnailIfPresent(request.getImages(), request.getCategory())) + .link(getLinkIfValid(request.getProductLink(), request.getCategory())) + .description(getDescriptionIfPresent(request.getDescription())) .currentParticipants(1) .status(PartyStatus.RECRUITING) .isClosed(false) @@ -106,8 +104,7 @@ public Long createParty(Long userId, PartyCreateRequest request) { /** * 파티 목록 조회 (홈 화면) */ - public PartyListResponse getPartyList(Long userId, PartyCategory category, - String userLat, String userLon) { + public PartyListResponse getPartyList(Long userId, PartyCategory category) { User user = null; if (userId != null) { user = userRepository.findById(userId).orElse(null); @@ -185,7 +182,7 @@ public PartyListResponse getPartyList(Long userId, PartyCategory category, /** * 파티 상세 조회 */ - public PartyDetailResponse getPartyDetail(Long partyId, Long userId, Double userLat, Double userLon) { + public PartyDetailResponse getPartyDetail(Long partyId, Long userId) { Party party = partyRepository.findById(partyId) .orElseThrow(() -> new IllegalArgumentException("파티를 찾을 수 없습니다")); @@ -390,7 +387,7 @@ public void deleteParty(Long partyId, Long userId) { */ @Transactional public void approveParticipant(Long partyId, Long participantId, Long hostId) { - Party party = partyRepository.findById(partyId) + Party party = partyRepository.findByIdWithHost(partyId) .orElseThrow(() -> new IllegalArgumentException("파티를 찾을 수 없습니다")); // 파티장 권한 확인 @@ -401,9 +398,18 @@ public void approveParticipant(Long partyId, Long participantId, Long hostId) { PartyParticipant participant = partyParticipantRepository.findById(participantId) .orElseThrow(() -> new IllegalArgumentException("참여 신청을 찾을 수 없습니다")); + + // 현재 인원이 최대 인원을 초과하는지 검증 + if (party.getCurrentParticipants() >= party.getMaxParticipants()) { + throw new IllegalStateException("파티 인원이 가득 찼습니다"); + } + // 승인 처리 participant.approve(); + // 파티 현재 참여자 수 증가 + party.incrementParticipants(); + // 단체 채팅방 조회 또는 생성 ChatRoom groupChatRoom = getOrCreateGroupChatRoom(party); @@ -601,5 +607,29 @@ private void checkAndCloseIfFull(Party party) { party.close(); } } + + // 헬퍼 메서드들 + private String getImageIfPresent(List images) { + return (images != null && !images.isEmpty()) ? images.get(0) : null; + } + + private String getThumbnailIfPresent(List images, PartyCategory category) { + if (images != null && !images.isEmpty()) { + return images.get(0); + } + return null; + } + + private String getLinkIfValid(String link, PartyCategory category) { + if (link != null && !link.isBlank()) { + validateProductLink(category, link); + return link; + } + return null; + } + + private String getDescriptionIfPresent(String description) { + return (description != null && !description.isBlank()) ? description : null; + } } diff --git a/src/main/java/ita/tinybite/domain/user/controller/UserController.java b/src/main/java/ita/tinybite/domain/user/controller/UserController.java index d1d72a6..af7f0b5 100644 --- a/src/main/java/ita/tinybite/domain/user/controller/UserController.java +++ b/src/main/java/ita/tinybite/domain/user/controller/UserController.java @@ -1,11 +1,24 @@ package ita.tinybite.domain.user.controller; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import ita.tinybite.domain.auth.dto.response.UserDto; import ita.tinybite.domain.user.dto.req.UpdateUserReqDto; +import ita.tinybite.domain.user.dto.res.PartyResponse; +import ita.tinybite.domain.user.dto.res.UserResDto; import ita.tinybite.domain.user.service.UserService; import ita.tinybite.global.response.APIResponse; import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +import java.util.List; + import static ita.tinybite.global.response.APIResponse.success; @RestController @@ -18,17 +31,36 @@ public UserController(UserService userService) { this.userService = userService; } + @Operation(summary = "내 정보 조회", description = "현재 로그인한 사용자의 정보를 조회합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "조회 성공", + content = @Content(schema = @Schema(implementation = UserResDto.class))), + @ApiResponse(responseCode = "401", description = "인증 실패", + content = @Content(schema = @Schema(implementation = APIResponse.class))) + }) @GetMapping("/me") - public APIResponse getUser() { + public APIResponse getUser() { return success(userService.getUser()); } + @Operation(summary = "내 정보 수정", description = "현재 로그인한 사용자의 정보를 수정합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "수정 성공"), + @ApiResponse(responseCode = "400", description = "잘못된 요청", + content = @Content(schema = @Schema(implementation = APIResponse.class))), + @ApiResponse(responseCode = "401", description = "인증 실패") + }) @PatchMapping("/me") public APIResponse updateUser(@Valid @RequestBody UpdateUserReqDto req) { userService.updateUser(req); return success(); } + @Operation(summary = "위치 정보 수정", description = "사용자의 현재 위치(위도, 경도)를 업데이트합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "위치 업데이트 성공"), + @ApiResponse(responseCode = "401", description = "인증 실패") + }) @PatchMapping("/me/location") public APIResponse updateLocation(@RequestParam(defaultValue = "37.3623504988728") String latitude, @RequestParam(defaultValue = "127.117057453619") String longitude) { @@ -36,12 +68,36 @@ public APIResponse updateLocation(@RequestParam(defaultValue = "37.3623504988 return success(); } + @Operation(summary = "회원 탈퇴", description = "현재 로그인한 사용자를 삭제합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "탈퇴 성공"), + @ApiResponse(responseCode = "401", description = "인증 실패") + }) @DeleteMapping("/me") public APIResponse deleteUser() { userService.deleteUser(); return success(); } + @Operation(summary = "활성 파티 목록 조회", description = "사용자가 참여 중인 활성 파티 목록을 조회합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "조회 성공", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = PartyResponse.class)))), + @ApiResponse(responseCode = "401", description = "인증 실패") + }) + @GetMapping("/parties/active") + public ResponseEntity> getActiveParties( + @AuthenticationPrincipal Long userId) { + List response = userService.getActiveParties(userId); + return ResponseEntity.ok(response); + } + + @Operation(summary = "닉네임 중복 확인", description = "닉네임 사용 가능 여부를 확인합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "사용 가능한 닉네임"), + @ApiResponse(responseCode = "400", description = "이미 사용 중인 닉네임", + content = @Content(schema = @Schema(implementation = APIResponse.class))) + }) @GetMapping("/nickname/check") public APIResponse validateNickname(@RequestParam String nickname) { userService.validateNickname(nickname); diff --git a/src/main/java/ita/tinybite/domain/user/dto/res/PartyResponse.java b/src/main/java/ita/tinybite/domain/user/dto/res/PartyResponse.java new file mode 100644 index 0000000..6fd56df --- /dev/null +++ b/src/main/java/ita/tinybite/domain/user/dto/res/PartyResponse.java @@ -0,0 +1,45 @@ +package ita.tinybite.domain.user.dto.res; + +import ita.tinybite.domain.party.entity.Party; +import ita.tinybite.domain.party.enums.ParticipantStatus; +import ita.tinybite.domain.party.enums.PartyStatus; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@AllArgsConstructor +@Builder +public class PartyResponse { + private Long id; + private String title; + private String description; + private Integer maxParticipants; + private Integer currentParticipants; + private PartyStatus status; + private String hostUsername; + private LocalDateTime startDate; + private LocalDateTime endDate; + private LocalDateTime createdAt; + private boolean isHost; + private ParticipantStatus participantStatus; + + public static PartyResponse from(Party party, int currentParticipants, boolean isHost, ParticipantStatus participantStatus) { + return PartyResponse.builder() + .id(party.getId()) + .title(party.getTitle()) + .description(party.getDescription()) + .maxParticipants(party.getMaxParticipants()) + .currentParticipants(currentParticipants) + .status(party.getStatus()) + .hostUsername(party.getHost().getNickname()) + .startDate(party.getCreatedAt()) + .endDate(party.getClosedAt()) + .createdAt(party.getCreatedAt()) + .isHost(isHost) + .participantStatus(participantStatus) + .build(); + } +} diff --git a/src/main/java/ita/tinybite/domain/user/service/UserService.java b/src/main/java/ita/tinybite/domain/user/service/UserService.java index 11ccd54..95ff669 100644 --- a/src/main/java/ita/tinybite/domain/user/service/UserService.java +++ b/src/main/java/ita/tinybite/domain/user/service/UserService.java @@ -1,8 +1,14 @@ package ita.tinybite.domain.user.service; import ita.tinybite.domain.auth.service.SecurityProvider; +import ita.tinybite.domain.party.entity.Party; +import ita.tinybite.domain.party.entity.PartyParticipant; +import ita.tinybite.domain.party.enums.ParticipantStatus; +import ita.tinybite.domain.party.enums.PartyStatus; +import ita.tinybite.domain.party.repository.PartyParticipantRepository; import ita.tinybite.domain.user.constant.UserStatus; import ita.tinybite.domain.user.dto.req.UpdateUserReqDto; +import ita.tinybite.domain.user.dto.res.PartyResponse; import ita.tinybite.domain.user.dto.res.UserResDto; import ita.tinybite.domain.user.entity.User; import ita.tinybite.domain.user.repository.UserRepository; @@ -11,19 +17,25 @@ import ita.tinybite.global.location.LocationService; import org.springframework.stereotype.Service; +import java.util.List; +import java.util.stream.Collectors; + @Service public class UserService { private final SecurityProvider securityProvider; private final UserRepository userRepository; private final LocationService locationService; + private final PartyParticipantRepository participantRepository; public UserService(SecurityProvider securityProvider, UserRepository userRepository, - LocationService locationService) { + LocationService locationService, + PartyParticipantRepository participantRepository) { this.securityProvider = securityProvider; this.userRepository = userRepository; this.locationService = locationService; + this.participantRepository = participantRepository; } public UserResDto getUser() { @@ -50,4 +62,23 @@ public void validateNickname(String nickname) { if(userRepository.existsByNickname(nickname)) throw BusinessException.of(AuthErrorCode.DUPLICATED_NICKNAME); } + + public List getActiveParties(Long userId) { + List participants = participantRepository + .findActivePartiesByUserId( + userId, + PartyStatus.RECRUITING, + ParticipantStatus.APPROVED + ); + + return participants.stream() + .map(pp -> { + Party party = pp.getParty(); + int currentParticipants = participantRepository + .countByPartyIdAndStatus(party.getId(), ParticipantStatus.APPROVED); + boolean isHost = party.getHost().getUserId().equals(userId); + return PartyResponse.from(party, currentParticipants, isHost,pp.getStatus()); + }) + .collect(Collectors.toList()); + } } diff --git a/src/test/java/ita/tinybite/domain/user/service/UserServiceTest.java b/src/test/java/ita/tinybite/domain/user/service/UserServiceTest.java index b6bfdea..7a05fba 100644 --- a/src/test/java/ita/tinybite/domain/user/service/UserServiceTest.java +++ b/src/test/java/ita/tinybite/domain/user/service/UserServiceTest.java @@ -1,6 +1,8 @@ package ita.tinybite.domain.user.service; import ita.tinybite.domain.auth.service.AuthService; +import ita.tinybite.domain.party.entity.Party; +import ita.tinybite.domain.party.repository.PartyParticipantRepository; import ita.tinybite.domain.user.constant.LoginType; import ita.tinybite.domain.user.constant.UserStatus; import ita.tinybite.domain.user.dto.req.UpdateUserReqDto; @@ -23,6 +25,8 @@ class UserServiceTest { @Autowired private UserRepository userRepository; + private PartyParticipantRepository participantRepository; + @Autowired private AuthService authService; @@ -37,7 +41,7 @@ class UserServiceTest { void setUp() { securityProvider = new FakeSecurityProvider(userRepository); locationService = new FakeLocationService(); - userService = new UserService(securityProvider, userRepository, locationService); + userService = new UserService(securityProvider, userRepository, locationService,participantRepository); User user = User.builder() .email("yyytir777@gmail.com")