diff --git a/src/main/java/in/koreatech/koin/admin/land/controller/AdminLandApi.java b/src/main/java/in/koreatech/koin/admin/land/controller/AdminLandApi.java index 77bb055fe..5e0664ade 100644 --- a/src/main/java/in/koreatech/koin/admin/land/controller/AdminLandApi.java +++ b/src/main/java/in/koreatech/koin/admin/land/controller/AdminLandApi.java @@ -3,12 +3,16 @@ import static in.koreatech.koin.domain.user.model.UserType.ADMIN; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; 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.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; -import in.koreatech.koin.admin.land.dto.AdminLandsRequest; +import in.koreatech.koin.admin.land.dto.AdminLandResponse; +import in.koreatech.koin.admin.land.dto.AdminLandRequest; import in.koreatech.koin.admin.land.dto.AdminLandsResponse; import in.koreatech.koin.global.auth.Auth; @@ -53,8 +57,72 @@ ResponseEntity getLands( @SecurityRequirement(name = "Jwt Authentication") @PostMapping("/admin/lands") ResponseEntity postLands( - @RequestBody @Valid AdminLandsRequest adminLandsRequest, + @RequestBody @Valid AdminLandRequest adminLandRequest, @Auth(permit = {ADMIN}) Integer adminId ); + @ApiResponses( + value = { + @ApiResponse(responseCode = "201"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "복덕방 삭제") + @SecurityRequirement(name = "Jwt Authentication") + @DeleteMapping("/admin/lands/{id}") + ResponseEntity deleteLand( + @PathVariable("id") Integer id, + @Auth(permit = {ADMIN}) Integer adminId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "복덕방 조회") + @SecurityRequirement(name = "Jwt Authentication") + @GetMapping("/admin/lands/{id}") + ResponseEntity getLand( + @PathVariable("id") Integer id, + @Auth(permit = {ADMIN}) Integer adminId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "복덕방 수정") + @SecurityRequirement(name = "Jwt Authentication") + @PutMapping("/admin/lands/{id}") + ResponseEntity updateLand( + @PathVariable("id") Integer id, + @RequestBody @Valid AdminLandRequest request, + @Auth(permit = {ADMIN}) Integer adminId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "복덕방 삭제 취소") + @SecurityRequirement(name = "Jwt Authentication") + @PostMapping("/admin/lands/{id}/undelete") + ResponseEntity undeleteLand( + @PathVariable("id") Integer id, + @Auth(permit = {ADMIN}) Integer adminId + ); } diff --git a/src/main/java/in/koreatech/koin/admin/land/controller/AdminLandController.java b/src/main/java/in/koreatech/koin/admin/land/controller/AdminLandController.java index b50fccaec..8d67fdc7f 100644 --- a/src/main/java/in/koreatech/koin/admin/land/controller/AdminLandController.java +++ b/src/main/java/in/koreatech/koin/admin/land/controller/AdminLandController.java @@ -4,13 +4,17 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; 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.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import in.koreatech.koin.admin.land.dto.AdminLandsRequest; +import in.koreatech.koin.admin.land.dto.AdminLandResponse; +import in.koreatech.koin.admin.land.dto.AdminLandRequest; import in.koreatech.koin.admin.land.dto.AdminLandsResponse; import in.koreatech.koin.admin.land.service.AdminLandService; import in.koreatech.koin.global.auth.Auth; @@ -35,11 +39,47 @@ public ResponseEntity getLands( @PostMapping("/admin/lands") public ResponseEntity postLands( - @RequestBody @Valid AdminLandsRequest adminLandsRequest, + @RequestBody @Valid AdminLandRequest adminLandRequest, @Auth(permit = {ADMIN}) Integer adminId ) { - adminLandService.createLands(adminLandsRequest); + adminLandService.createLands(adminLandRequest); return ResponseEntity.status(HttpStatus.CREATED).build(); } + @DeleteMapping("/admin/lands/{id}") + public ResponseEntity deleteLand( + @PathVariable("id") Integer id, + @Auth(permit = {ADMIN}) Integer adminId + ) { + adminLandService.deleteLand(id); + return null; + } + + @GetMapping("/admin/lands/{id}") + public ResponseEntity getLand( + @PathVariable("id") Integer id, + @Auth(permit = {ADMIN}) Integer adminId + ) { + return ResponseEntity.ok().body(adminLandService.getLand(id)); + } + + @PutMapping("/admin/lands/{id}") + public ResponseEntity updateLand( + @PathVariable("id") Integer id, + @RequestBody @Valid AdminLandRequest request, + @Auth(permit = {ADMIN}) Integer adminId + ) { + adminLandService.updateLand(id, request); + return ResponseEntity.ok().build(); + } + + @PostMapping("/admin/lands/{id}/undelete") + public ResponseEntity undeleteLand( + @PathVariable("id") Integer id, + @Auth(permit = {ADMIN}) Integer adminId + ) { + adminLandService.undeleteLand(id); + return ResponseEntity.ok().build(); + } + } diff --git a/src/main/java/in/koreatech/koin/admin/land/dto/AdminLandsRequest.java b/src/main/java/in/koreatech/koin/admin/land/dto/AdminLandRequest.java similarity index 80% rename from src/main/java/in/koreatech/koin/admin/land/dto/AdminLandsRequest.java rename to src/main/java/in/koreatech/koin/admin/land/dto/AdminLandRequest.java index 376952656..a5523ec18 100644 --- a/src/main/java/in/koreatech/koin/admin/land/dto/AdminLandsRequest.java +++ b/src/main/java/in/koreatech/koin/admin/land/dto/AdminLandRequest.java @@ -1,6 +1,5 @@ package in.koreatech.koin.admin.land.dto; - import java.util.List; import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; @@ -17,29 +16,28 @@ import jakarta.validation.constraints.Size; @JsonNaming(SnakeCaseStrategy.class) -public record AdminLandsRequest( +public record AdminLandRequest( @Schema(description = "이름 - not null - 최대 255자", example = "금실타운", requiredMode = REQUIRED) @NotNull(message = "방이름은 필수입니다.") @Size(max = 255, message = "방이름의 최대 길이는 255자입니다.") String name, - @Schema(description = "이름 - not null - 최대 50자", example = "금실타운", requiredMode = REQUIRED) - @NotNull(message = "방이름은 필수입니다.") + @Schema(description = "이름 - not null - 최대 50자", example = "금실타운") @Size(max = 50, message = "방이름의 최대 길이는 50자입니다.") String internalName, @Schema(description = "크기", example = "9.0") - String size, + double size, @Schema(description = "종류 - 최대 20자", example = "원룸") @Size(max = 20, message = "방종류의 최대 길이는 20자입니다.") String roomType, @Schema(description = "위도", example = "36.766205") - String latitude, + double latitude, @Schema(description = "경도", example = "127.284638") - String longitude, + double longitude, @Schema(description = "전화번호 - 정규식 `^[0-9]{3}-[0-9]{3,4}-[0-9]{4}$` 을 만족해야함", example = "041-111-1111") @Pattern(regexp = "^[0-9]{3}-[0-9]{3,4}-[0-9]{4}$", message = "전화번호의 형식이 올바르지 않습니다.") @@ -101,16 +99,37 @@ public record AdminLandsRequest( boolean optAirConditioner, @Schema(description = "샤워기 보유 여부 - null일경우 false로 요청됨", example = "true") - boolean optWasher + boolean optWasher, + + @Schema(description = "침대 보유 여부", example = "false") + boolean optBed, + + @Schema(description = "책상 보유 여부", example = "true") + boolean optDesk, + + @Schema(description = "신발장 보유 여부", example = "true") + boolean optShoeCloset, + + @Schema(description = "전자 도어락 보유 여부", example = "true") + boolean optElectronicDoorLocks, + + @Schema(description = "비데 보유 여부", example = "false") + boolean optBidet, + + @Schema(description = "베란다 보유 여부", example = "false") + boolean optVeranda, + + @Schema(description = "엘리베이터 보유 여부", example = "true") + boolean optElevator ) { public Land toLand() { return Land.builder() .name(name) .internalName(internalName) - .size(size) + .size(String.valueOf(size)) .roomType(roomType) - .latitude(latitude) - .longitude(longitude) + .latitude(String.valueOf(latitude)) + .longitude(String.valueOf(longitude)) .phone(phone) .imageUrls(imageUrls) .address(address) @@ -129,6 +148,13 @@ public Land toLand() { .optWaterPurifier(optWaterPurifier) .optAirConditioner(optAirConditioner) .optWasher(optWasher) + .optBed(optBed) + .optDesk(optDesk) + .optShoeCloset(optShoeCloset) + .optElectronicDoorLocks(optElectronicDoorLocks) + .optBidet(optBidet) + .optVeranda(optVeranda) + .optElevator(optElevator) .build(); } } diff --git a/src/main/java/in/koreatech/koin/admin/land/dto/AdminLandResponse.java b/src/main/java/in/koreatech/koin/admin/land/dto/AdminLandResponse.java index 0c5e1d1cc..db96acb70 100644 --- a/src/main/java/in/koreatech/koin/admin/land/dto/AdminLandResponse.java +++ b/src/main/java/in/koreatech/koin/admin/land/dto/AdminLandResponse.java @@ -1,7 +1,8 @@ package in.koreatech.koin.admin.land.dto; import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; -import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.List; import com.fasterxml.jackson.databind.annotation.JsonNaming; @@ -10,32 +11,147 @@ @JsonNaming(value = SnakeCaseStrategy.class) public record AdminLandResponse( - @Schema(description = "고유 id", example = "1", requiredMode = REQUIRED) + @Schema(description = "고유 id", example = "1", requiredMode = Schema.RequiredMode.REQUIRED) Integer id, - @Schema(description = "이름", example = "금실타운", requiredMode = REQUIRED) + @Schema(description = "이름", example = "금실타운", requiredMode = Schema.RequiredMode.REQUIRED) String name, + @Schema(description = "내부 이름", example = "금실타운") + String internalName, + + @Schema(description = "크기", example = "9.0") + double size, + @Schema(description = "종류", example = "원룸") String roomType, + @Schema(description = "위도", example = "36.766205") + double latitude, + + @Schema(description = "경도", example = "127.284638") + double longitude, + + @Schema(description = "전화번호", example = "041-111-1111") + String phone, + + @Schema(description = "이미지 URL 리스트") + List imageUrls, + + @Schema(description = "주소", example = "충청남도 천안시 동남구 병천면") + String address, + + @Schema(description = "설명", example = "1년 계약시 20만원 할인") + String description, + + @Schema(description = "층수", example = "4") + Integer floor, + + @Schema(description = "보증금", example = "30") + String deposit, + @Schema(description = "월세", example = "200만원 (6개월)") String monthlyFee, @Schema(description = "전세", example = "3500") String charterFee, - @Schema(description = "삭제(soft delete) 여부", example = "false", requiredMode = REQUIRED) + @Schema(description = "관리비", example = "21(1인 기준)") + String managementFee, + + @Schema(description = "냉장고 보유 여부", example = "true") + boolean optRefrigerator, + + @Schema(description = "옷장 보유 여부", example = "true") + boolean optCloset, + + @Schema(description = "TV 보유 여부", example = "true") + boolean optTv, + + @Schema(description = "전자레인지 보유 여부", example = "true") + boolean optMicrowave, + + @Schema(description = "가스레인지 보유 여부", example = "false") + boolean optGasRange, + + @Schema(description = "인덕션 보유 여부", example = "true") + boolean optInduction, + + @Schema(description = "정수기 보유 여부", example = "true") + boolean optWaterPurifier, + + @Schema(description = "에어컨 보유 여부", example = "true") + boolean optAirConditioner, + + @Schema(description = "세탁기 보유 여부", example = "true") + boolean optWasher, + + @Schema(description = "침대 보유 여부", example = "false") + boolean optBed, + + @Schema(description = "책상 보유 여부", example = "true") + boolean optDesk, + + @Schema(description = "신발장 보유 여부", example = "true") + boolean optShoeCloset, + + @Schema(description = "전자 도어락 보유 여부", example = "true") + boolean optElectronicDoorLocks, + + @Schema(description = "비데 보유 여부", example = "false") + boolean optBidet, + + @Schema(description = "베란다 보유 여부", example = "false") + boolean optVeranda, + + @Schema(description = "엘리베이터 보유 여부", example = "true") + boolean optElevator, + + @Schema(description = "삭제(soft delete) 여부", example = "false", requiredMode = Schema.RequiredMode.REQUIRED) Boolean isDeleted ) { public static AdminLandResponse from(Land land) { return new AdminLandResponse( land.getId(), land.getName(), + land.getInternalName(), + land.getSize() == null ? null : land.getSize(), land.getRoomType(), + land.getLatitude() == null ? null : land.getLatitude(), + land.getLongitude() == null ? null : land.getLongitude(), + land.getPhone(), + convertToList(land.getImageUrls()), + land.getAddress(), + land.getDescription(), + land.getFloor(), + land.getDeposit(), land.getMonthlyFee(), land.getCharterFee(), + land.getManagementFee(), + land.isOptRefrigerator(), + land.isOptCloset(), + land.isOptTv(), + land.isOptMicrowave(), + land.isOptGasRange(), + land.isOptInduction(), + land.isOptWaterPurifier(), + land.isOptAirConditioner(), + land.isOptWasher(), + land.isOptBed(), + land.isOptDesk(), + land.isOptShoeCloset(), + land.isOptElectronicDoorLocks(), + land.isOptBidet(), + land.isOptVeranda(), + land.isOptElevator(), land.isDeleted() ); } + + private static List convertToList(String imageUrls) { + if (imageUrls == null || imageUrls.isEmpty()) { + return List.of(); + } + return List.of(imageUrls.replace("[", "").replace("]", "").replace("\"", "").split(",")); + } } diff --git a/src/main/java/in/koreatech/koin/admin/land/dto/AdminLandsResponse.java b/src/main/java/in/koreatech/koin/admin/land/dto/AdminLandsResponse.java index 5d2676a99..63d6e6836 100644 --- a/src/main/java/in/koreatech/koin/admin/land/dto/AdminLandsResponse.java +++ b/src/main/java/in/koreatech/koin/admin/land/dto/AdminLandsResponse.java @@ -29,7 +29,7 @@ public record AdminLandsResponse( Integer currentPage, @Schema(description = "집 정보 리스트", requiredMode = REQUIRED) - List lands + List lands ) { public static AdminLandsResponse of(Page pagedResult, Criteria criteria) { return new AdminLandsResponse( @@ -39,8 +39,41 @@ public static AdminLandsResponse of(Page pagedResult, Criteria criteria) { criteria.getPage() + 1, pagedResult.getContent() .stream() - .map(AdminLandResponse::from) + .map(SimpleLandInformation::from) .toList() ); } -} + + @JsonNaming(value = SnakeCaseStrategy.class) + private record SimpleLandInformation( + @Schema(description = "고유 id", example = "1", requiredMode = REQUIRED) + Integer id, + + @Schema(description = "이름", example = "금실타운", requiredMode = REQUIRED) + String name, + + @Schema(description = "종류", example = "원룸") + String roomType, + + @Schema(description = "월세", example = "200만원 (6개월)") + String monthlyFee, + + @Schema(description = "전세", example = "3500") + String charterFee, + + @Schema(description = "삭제(soft delete) 여부", example = "false", requiredMode = REQUIRED) + Boolean isDeleted + ) { + public static SimpleLandInformation from(Land land) { + return new SimpleLandInformation( + land.getId(), + land.getName(), + land.getRoomType(), + land.getMonthlyFee(), + land.getCharterFee(), + land.isDeleted() + ); + } + } + +} \ No newline at end of file diff --git a/src/main/java/in/koreatech/koin/admin/land/repository/AdminLandRepository.java b/src/main/java/in/koreatech/koin/admin/land/repository/AdminLandRepository.java index 9d3a6dc38..fd85268dd 100644 --- a/src/main/java/in/koreatech/koin/admin/land/repository/AdminLandRepository.java +++ b/src/main/java/in/koreatech/koin/admin/land/repository/AdminLandRepository.java @@ -23,4 +23,11 @@ default Land getByName(String name) { return findByName(name).orElseThrow(() -> LandNotFoundException.withDetail("name: " + name)); } + Optional findById(Integer id); + + default Land getById(Integer id) { + return findById(id) + .orElseThrow(() -> LandNotFoundException.withDetail("id: " + id)); + } + } diff --git a/src/main/java/in/koreatech/koin/admin/land/service/AdminLandService.java b/src/main/java/in/koreatech/koin/admin/land/service/AdminLandService.java index 4e765e8d9..0f82166ad 100644 --- a/src/main/java/in/koreatech/koin/admin/land/service/AdminLandService.java +++ b/src/main/java/in/koreatech/koin/admin/land/service/AdminLandService.java @@ -6,7 +6,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import in.koreatech.koin.admin.land.dto.AdminLandsRequest; +import in.koreatech.koin.admin.land.dto.AdminLandResponse; +import in.koreatech.koin.admin.land.dto.AdminLandRequest; import in.koreatech.koin.admin.land.dto.AdminLandsResponse; import in.koreatech.koin.admin.land.execption.LandNameDuplicationException; import in.koreatech.koin.admin.land.repository.AdminLandRepository; @@ -36,11 +37,67 @@ public AdminLandsResponse getLands(Integer page, Integer limit, Boolean isDelete } @Transactional - public void createLands(AdminLandsRequest adminLandsRequest) { - if (adminLandRepository.findByName(adminLandsRequest.name()).isPresent()) { - throw LandNameDuplicationException.withDetail("name: " + adminLandsRequest.name()); + public void createLands(AdminLandRequest adminLandRequest) { + if (adminLandRepository.findByName(adminLandRequest.name()).isPresent()) { + throw LandNameDuplicationException.withDetail("name: " + adminLandRequest.name()); } - Land land = adminLandsRequest.toLand(); + Land land = adminLandRequest.toLand(); adminLandRepository.save(land); } + + @Transactional + public void deleteLand(Integer id) { + Land land = adminLandRepository.getById(id); + land.delete(); + } + + public AdminLandResponse getLand(Integer id) { + Land land = adminLandRepository.getById(id); + return AdminLandResponse.from(land); + } + + @Transactional + public void updateLand(Integer id, AdminLandRequest request) { + Land land = adminLandRepository.getById(id); + land.update( + request.internalName(), + request.name(), + request.size(), + request.roomType(), + request.latitude(), + request.longitude(), + request.phone(), + request.imageUrls(), + request.address(), + request.description(), + request.floor(), + request.deposit(), + request.monthlyFee(), + request.charterFee(), + request.managementFee(), + request.optRefrigerator(), + request.optCloset(), + request.optTv(), + request.optMicrowave(), + request.optGasRange(), + request.optInduction(), + request.optWaterPurifier(), + request.optAirConditioner(), + request.optWasher(), + request.optBed(), + request.optDesk(), + request.optShoeCloset(), + request.optElectronicDoorLocks(), + request.optBidet(), + request.optVeranda(), + request.optElevator() + ); + + } + + @Transactional + public void undeleteLand(Integer id) { + Land land = adminLandRepository.getById(id); + land.undelete(); + } } diff --git a/src/main/java/in/koreatech/koin/admin/member/controller/AdminMemberApi.java b/src/main/java/in/koreatech/koin/admin/member/controller/AdminMemberApi.java index 5f83fbf19..b1f80f723 100644 --- a/src/main/java/in/koreatech/koin/admin/member/controller/AdminMemberApi.java +++ b/src/main/java/in/koreatech/koin/admin/member/controller/AdminMemberApi.java @@ -7,6 +7,7 @@ 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.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; @@ -89,4 +90,37 @@ ResponseEntity deleteMember( @PathVariable("id") Integer memberId, @Auth(permit = {ADMIN}) Integer adminId ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "BCSDLab 회원 수정") + @SecurityRequirement(name = "Jwt Authentication") + @PutMapping("/admin/members/{id}") + ResponseEntity updateMember( + @PathVariable("id") Integer memberId, + @RequestBody @Valid AdminMemberRequest request, + @Auth(permit = {ADMIN}) Integer adminId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "BCSDLab 회원 삭제 취소") + @SecurityRequirement(name = "Jwt Authentication") + @PostMapping("/admin/members/{id}/undelete") + ResponseEntity undeleteMember( + @PathVariable("id") Integer memberId, + @Auth(permit = {ADMIN}) Integer adminId + ); } diff --git a/src/main/java/in/koreatech/koin/admin/member/controller/AdminMemberController.java b/src/main/java/in/koreatech/koin/admin/member/controller/AdminMemberController.java index 0cd69b87c..4353fa0db 100644 --- a/src/main/java/in/koreatech/koin/admin/member/controller/AdminMemberController.java +++ b/src/main/java/in/koreatech/koin/admin/member/controller/AdminMemberController.java @@ -8,6 +8,7 @@ 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.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -47,7 +48,6 @@ public ResponseEntity createMember( return ResponseEntity.status(HttpStatus.CREATED).build(); } - @GetMapping("/admin/members/{id}") public ResponseEntity getMember( @PathVariable("id") Integer memberId, @@ -62,6 +62,25 @@ public ResponseEntity deleteMember( @Auth(permit = {ADMIN}) Integer adminId ) { adminMemberService.deleteMember(memberId); - return null; + return ResponseEntity.ok().build(); + } + + @PutMapping("/admin/members/{id}") + public ResponseEntity updateMember( + @PathVariable("id") Integer memberId, + @RequestBody @Valid AdminMemberRequest request, + @Auth(permit = {ADMIN}) Integer adminId + ) { + adminMemberService.updateMember(memberId, request); + return ResponseEntity.ok().build(); + } + + @PostMapping("/admin/members/{id}/undelete") + public ResponseEntity undeleteMember( + @PathVariable("id") Integer memberId, + @Auth(permit = {ADMIN}) Integer adminId + ) { + adminMemberService.undeleteMember(memberId); + return ResponseEntity.ok().build(); } } diff --git a/src/main/java/in/koreatech/koin/admin/member/controller/AdminTrackApi.java b/src/main/java/in/koreatech/koin/admin/member/controller/AdminTrackApi.java index 8afaff638..3a348bf3c 100644 --- a/src/main/java/in/koreatech/koin/admin/member/controller/AdminTrackApi.java +++ b/src/main/java/in/koreatech/koin/admin/member/controller/AdminTrackApi.java @@ -5,6 +5,7 @@ import java.util.List; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -14,7 +15,9 @@ import in.koreatech.koin.admin.member.dto.AdminTechStackRequest; import in.koreatech.koin.admin.member.dto.AdminTechStackResponse; +import in.koreatech.koin.admin.member.dto.AdminTrackRequest; import in.koreatech.koin.admin.member.dto.AdminTrackResponse; +import in.koreatech.koin.admin.member.dto.AdminTrackSingleResponse; import in.koreatech.koin.global.auth.Auth; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; @@ -43,6 +46,73 @@ ResponseEntity> getTracks( @Auth(permit = {ADMIN}) Integer adminId ); + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "409", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "트랙 생성") + @SecurityRequirement(name = "Jwt Authentication") + @PostMapping("/admin/tracks") + ResponseEntity createTrack( + @RequestBody @Valid AdminTrackRequest request, + @Auth(permit = {ADMIN}) Integer adminId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "트랙 단건 조회") + @SecurityRequirement(name = "Jwt Authentication") + @GetMapping("/admin/tracks/{id}") + ResponseEntity getTrack( + @PathVariable("id") Integer trackId, + @Auth(permit = {ADMIN}) Integer adminId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "409", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "트랙 수정") + @SecurityRequirement(name = "Jwt Authentication") + @PutMapping("/admin/tracks/{id}") + ResponseEntity updateTrack( + @PathVariable("id") Integer trackId, + @RequestBody @Valid AdminTrackRequest request, + @Auth(permit = {ADMIN}) Integer adminId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "204", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "트랙 삭제") + @SecurityRequirement(name = "Jwt Authentication") + @DeleteMapping("/admin/tracks/{id}") + ResponseEntity deleteTrack( + @PathVariable("id") Integer trackId, + @Auth(permit = {ADMIN}) Integer adminId + ); + @ApiResponses( value = { @ApiResponse(responseCode = "200"), @@ -77,4 +147,20 @@ ResponseEntity updateTechStack( @PathVariable("id") Integer techStackId, @Auth(permit = {ADMIN}) Integer adminId ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "204", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "기술스택 삭제") + @SecurityRequirement(name = "Jwt Authentication") + @DeleteMapping("/admin/techStacks/{id}") + ResponseEntity deleteTechStack( + @PathVariable("id") Integer techStackId, + @Auth(permit = {ADMIN}) Integer adminId + ); } diff --git a/src/main/java/in/koreatech/koin/admin/member/controller/AdminTrackController.java b/src/main/java/in/koreatech/koin/admin/member/controller/AdminTrackController.java index ebd90de3d..8e5e5132b 100644 --- a/src/main/java/in/koreatech/koin/admin/member/controller/AdminTrackController.java +++ b/src/main/java/in/koreatech/koin/admin/member/controller/AdminTrackController.java @@ -5,6 +5,7 @@ import java.util.List; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -15,7 +16,9 @@ import in.koreatech.koin.admin.member.dto.AdminTechStackRequest; import in.koreatech.koin.admin.member.dto.AdminTechStackResponse; +import in.koreatech.koin.admin.member.dto.AdminTrackRequest; import in.koreatech.koin.admin.member.dto.AdminTrackResponse; +import in.koreatech.koin.admin.member.dto.AdminTrackSingleResponse; import in.koreatech.koin.admin.member.service.AdminTrackService; import in.koreatech.koin.global.auth.Auth; import jakarta.validation.Valid; @@ -35,6 +38,43 @@ public ResponseEntity> getTracks( return ResponseEntity.ok(response); } + @PostMapping("/admin/tracks") + public ResponseEntity createTrack( + @RequestBody @Valid AdminTrackRequest request, + @Auth(permit = {ADMIN}) Integer adminId + ) { + var response = adminTrackService.createTrack(request); + return ResponseEntity.ok(response); + } + + @GetMapping("/admin/tracks/{id}") + public ResponseEntity getTrack( + @PathVariable("id") Integer trackId, + @Auth(permit = {ADMIN}) Integer adminId + ) { + var response = adminTrackService.getTrack(trackId); + return ResponseEntity.ok(response); + } + + @PutMapping("/admin/tracks/{id}") + public ResponseEntity updateTrack( + @PathVariable("id") Integer trackId, + @RequestBody @Valid AdminTrackRequest request, + @Auth(permit = {ADMIN}) Integer adminId + ) { + var response = adminTrackService.updateTrack(trackId, request); + return ResponseEntity.ok(response); + } + + @DeleteMapping("/admin/tracks/{id}") + public ResponseEntity deleteTrack( + @PathVariable("id") Integer trackId, + @Auth(permit = {ADMIN}) Integer adminId + ) { + adminTrackService.deleteTrack(trackId); + return ResponseEntity.ok().build(); + } + @PostMapping("/admin/techStacks") public ResponseEntity createTechStack( @RequestBody @Valid AdminTechStackRequest request, @@ -55,4 +95,13 @@ public ResponseEntity updateTechStack( var response = adminTrackService.updateTechStack(request, trackName, techStackId); return ResponseEntity.ok(response); } + + @DeleteMapping("/admin/techStacks/{id}") + public ResponseEntity deleteTechStack( + @PathVariable("id") Integer techStackId, + @Auth(permit = {ADMIN}) Integer adminId + ) { + adminTrackService.deleteTechStack(techStackId); + return ResponseEntity.ok().build(); + } } diff --git a/src/main/java/in/koreatech/koin/admin/member/dto/AdminTechStackRequest.java b/src/main/java/in/koreatech/koin/admin/member/dto/AdminTechStackRequest.java index 954cc107c..1643ef943 100644 --- a/src/main/java/in/koreatech/koin/admin/member/dto/AdminTechStackRequest.java +++ b/src/main/java/in/koreatech/koin/admin/member/dto/AdminTechStackRequest.java @@ -11,18 +11,14 @@ @JsonNaming(SnakeCaseStrategy.class) public record AdminTechStackRequest( - - @Schema(description = "기술스택 고유 ID", example = "1", requiredMode = REQUIRED) - Integer id, - - @Schema(description = "이미지 링크", example = "http://url.com", requiredMode = REQUIRED) + @Schema(description = "이미지 링크", example = "http://url.com") String imageUrl, @Schema(description = "기술 스택명", example = "Spring", requiredMode = REQUIRED) @NotBlank(message = "기술 스택명은 비워둘 수 없습니다.") String name, - @Schema(description = "기술 스택 설명", example = "스프링은 웹 프레임워크이다", requiredMode = REQUIRED) + @Schema(description = "기술 스택 설명", example = "스프링은 웹 프레임워크이다") String description, @Schema(description = "삭제 여부", example = "false") diff --git a/src/main/java/in/koreatech/koin/admin/member/dto/AdminTrackRequest.java b/src/main/java/in/koreatech/koin/admin/member/dto/AdminTrackRequest.java new file mode 100644 index 000000000..cbe9b6e1a --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/member/dto/AdminTrackRequest.java @@ -0,0 +1,34 @@ +package in.koreatech.koin.admin.member.dto; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.member.model.Track; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +@JsonNaming(SnakeCaseStrategy.class) +public record AdminTrackRequest( + @Schema(description = "트랙 명", example = "Backend", requiredMode = REQUIRED) + @NotBlank(message = "트랙명은 비워둘 수 없습니다.") + String name, + + @Schema(description = "인원 수", example = "15") + @NotNull(message = "인원 수는 비워둘 수 없습니다.") + Integer headcount, + + @Schema(description = "삭제 여부", example = "false") + boolean isDeleted +) { + + public Track toEntity() { + return Track.builder() + .name(name) + .headcount(headcount) + .isDeleted(isDeleted) + .build(); + } +} diff --git a/src/main/java/in/koreatech/koin/admin/member/dto/AdminTrackSingleResponse.java b/src/main/java/in/koreatech/koin/admin/member/dto/AdminTrackSingleResponse.java new file mode 100644 index 000000000..13329280e --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/member/dto/AdminTrackSingleResponse.java @@ -0,0 +1,136 @@ +package in.koreatech.koin.admin.member.dto; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.time.LocalDateTime; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.member.model.Member; +import in.koreatech.koin.domain.member.model.TechStack; +import in.koreatech.koin.domain.member.model.Track; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(SnakeCaseStrategy.class) +public record AdminTrackSingleResponse( + @JsonProperty("TrackName") + @Schema(description = "트랙 명", example = "Backend", requiredMode = REQUIRED) + String trackName, + + @JsonProperty("Members") + List innerMemberResponses, + + @JsonProperty("TechStacks") + List innerTechStackResponses +) { + + public static AdminTrackSingleResponse of(Track track, List members, List techStacks) { + return new AdminTrackSingleResponse( + track.getName(), + members.stream() + .map(member -> InnerMemberResponse.from(member, track.getName())) + .toList(), + techStacks.stream() + .map(InnerTechStackResponse::from) + .toList() + ); + } + + @JsonNaming(value = SnakeCaseStrategy.class) + private record InnerMemberResponse( + @Schema(description = "BCSD 회원 고유 ID", example = "1", requiredMode = REQUIRED) + Integer id, + + @Schema(description = "이름", example = "최준호", requiredMode = REQUIRED) + String name, + + @Schema(description = "학번", example = "2019136135", requiredMode = NOT_REQUIRED) + String studentNumber, + + @Schema(description = "동아리 포지션 `Beginner`, `Regular`, `Mentor`", example = "Regular", requiredMode = REQUIRED) + String position, + + @Schema(description = "트랙 명", example = "Backend", requiredMode = REQUIRED) + String track, + + @Schema(description = "이메일", example = "koin123@koreatech.ac.kr", requiredMode = NOT_REQUIRED) + String email, + + @Schema(description = "이미지 Url", example = "https://static.koreatech.in/example/image.png", requiredMode = NOT_REQUIRED) + String imageUrl, + + @Schema(description = "삭제 여부", example = "false", requiredMode = REQUIRED) + Boolean isDeleted, + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + @Schema(description = "생성 일자", example = "2023-01-04 12:00:01", requiredMode = REQUIRED) + LocalDateTime createdAt, + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + @Schema(description = "수정 일자", example = "2023-01-04 12:00:01", requiredMode = REQUIRED) + LocalDateTime updatedAt + ) { + + public static InnerMemberResponse from(Member member, String trackName) { + return new InnerMemberResponse( + member.getId(), + member.getName(), + member.getStudentNumber(), + member.getPosition(), + trackName, + member.getEmail(), + member.getImageUrl(), + member.isDeleted(), + member.getCreatedAt(), + member.getUpdatedAt() + ); + } + } + + @JsonNaming(value = SnakeCaseStrategy.class) + private record InnerTechStackResponse( + @Schema(description = "기술 스택 고유 ID", example = "1", requiredMode = REQUIRED) + Integer id, + + @Schema(description = "기술 이름", example = "Backend") + String name, + + @Schema(description = "기술 설명", example = "15") + String description, + + @Schema(description = "이미지 Url", example = "https://static.koreatech.in/example/image.png") + String imageUrl, + + @Schema(description = "트랙 ID", example = "1") + Integer trackId, + + @Schema(description = "삭제 여부", example = "false") + Boolean isDeleted, + + @Schema(description = "생성 일자", example = "2023-01-04 12:00:01") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime createdAt, + + @Schema(description = "수정 일자", example = "2023-01-04 12:00:01") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime updatedAt + ) { + + public static InnerTechStackResponse from(TechStack techStack) { + return new InnerTechStackResponse( + techStack.getId(), + techStack.getName(), + techStack.getDescription(), + techStack.getImageUrl(), + techStack.getTrackId(), + techStack.isDeleted(), + techStack.getCreatedAt(), + techStack.getUpdatedAt() + ); + } + } +} + diff --git a/src/main/java/in/koreatech/koin/admin/member/exception/TrackNameDuplicationException.java b/src/main/java/in/koreatech/koin/admin/member/exception/TrackNameDuplicationException.java new file mode 100644 index 000000000..a880c2f7c --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/member/exception/TrackNameDuplicationException.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.admin.member.exception; + +import in.koreatech.koin.global.exception.DuplicationException; + +public class TrackNameDuplicationException extends DuplicationException { + + private static final String DEFAULT_MESSAGE = "트랙명이 이미 존재합니다"; + + protected TrackNameDuplicationException(String message) { + super(message); + } + + protected TrackNameDuplicationException(String message, String detail) { + super(message, detail); + } + + public static TrackNameDuplicationException withDetail(String name) { + return new TrackNameDuplicationException(DEFAULT_MESSAGE, "name: " + name); + } +} diff --git a/src/main/java/in/koreatech/koin/admin/member/repository/AdminMemberRepository.java b/src/main/java/in/koreatech/koin/admin/member/repository/AdminMemberRepository.java index b0dc1f157..2a23b719c 100644 --- a/src/main/java/in/koreatech/koin/admin/member/repository/AdminMemberRepository.java +++ b/src/main/java/in/koreatech/koin/admin/member/repository/AdminMemberRepository.java @@ -1,5 +1,6 @@ package in.koreatech.koin.admin.member.repository; +import java.util.List; import java.util.Optional; import org.springframework.data.domain.Page; @@ -23,6 +24,8 @@ public interface AdminMemberRepository extends Repository { Member save(Member member); + List findByTrackId(Integer id); + @EntityGraph(attributePaths = {"track"}) Optional findByName(String name); diff --git a/src/main/java/in/koreatech/koin/admin/member/repository/AdminTechStackRepository.java b/src/main/java/in/koreatech/koin/admin/member/repository/AdminTechStackRepository.java index be9dae634..9ae814317 100644 --- a/src/main/java/in/koreatech/koin/admin/member/repository/AdminTechStackRepository.java +++ b/src/main/java/in/koreatech/koin/admin/member/repository/AdminTechStackRepository.java @@ -1,5 +1,6 @@ package in.koreatech.koin.admin.member.repository; +import java.util.List; import java.util.Optional; import org.springframework.data.repository.Repository; @@ -13,6 +14,8 @@ public interface AdminTechStackRepository extends Repository Optional findById(Integer id); + List findAllByTrackId(Integer id); + default TechStack getById(Integer id) { return findById(id) .orElseThrow(() -> TechStackNotFoundException.withDetail("id : " + id)); diff --git a/src/main/java/in/koreatech/koin/admin/member/repository/AdminTrackRepository.java b/src/main/java/in/koreatech/koin/admin/member/repository/AdminTrackRepository.java index cb993dda6..4477a2bbb 100644 --- a/src/main/java/in/koreatech/koin/admin/member/repository/AdminTrackRepository.java +++ b/src/main/java/in/koreatech/koin/admin/member/repository/AdminTrackRepository.java @@ -14,8 +14,15 @@ public interface AdminTrackRepository extends Repository { List findAll(); + Optional findById(Integer trackId); + Optional findByName(String trackName); + default Track getById(Integer trackId) { + return findById(trackId) + .orElseThrow(() -> TrackNotFoundException.withDetail("trackId: " + trackId)); + } + default Track getByName(String trackName) { return findByName(trackName) .orElseThrow(() -> TrackNotFoundException.withDetail("name: " + trackName)); diff --git a/src/main/java/in/koreatech/koin/admin/member/service/AdminMemberService.java b/src/main/java/in/koreatech/koin/admin/member/service/AdminMemberService.java index b153e0a37..d937fe1fd 100644 --- a/src/main/java/in/koreatech/koin/admin/member/service/AdminMemberService.java +++ b/src/main/java/in/koreatech/koin/admin/member/service/AdminMemberService.java @@ -54,4 +54,23 @@ public void deleteMember(Integer memberId) { Member member = adminMemberRepository.getById(memberId); member.delete(); } + + @Transactional + public void updateMember(Integer memberId, AdminMemberRequest request) { + Member member = adminMemberRepository.getById(memberId); + + String currentTrackName = member.getTrack().getName(); + String changedTrackName = request.track(); + if (!currentTrackName.equals(changedTrackName)) { + member.updateTrack(adminTrackRepository.getByName(request.track())); + } + + member.update(request.name(), request.studentNumber(), request.position(), request.email(), request.imageUrl()); + } + + @Transactional + public void undeleteMember(Integer memberId) { + Member member = adminMemberRepository.getById(memberId); + member.undelete(); + } } diff --git a/src/main/java/in/koreatech/koin/admin/member/service/AdminTrackService.java b/src/main/java/in/koreatech/koin/admin/member/service/AdminTrackService.java index 81b244463..57af6b991 100644 --- a/src/main/java/in/koreatech/koin/admin/member/service/AdminTrackService.java +++ b/src/main/java/in/koreatech/koin/admin/member/service/AdminTrackService.java @@ -7,9 +7,14 @@ import in.koreatech.koin.admin.member.dto.AdminTechStackRequest; import in.koreatech.koin.admin.member.dto.AdminTechStackResponse; +import in.koreatech.koin.admin.member.dto.AdminTrackRequest; import in.koreatech.koin.admin.member.dto.AdminTrackResponse; +import in.koreatech.koin.admin.member.dto.AdminTrackSingleResponse; +import in.koreatech.koin.admin.member.exception.TrackNameDuplicationException; +import in.koreatech.koin.admin.member.repository.AdminMemberRepository; import in.koreatech.koin.admin.member.repository.AdminTechStackRepository; import in.koreatech.koin.admin.member.repository.AdminTrackRepository; +import in.koreatech.koin.domain.member.model.Member; import in.koreatech.koin.domain.member.model.TechStack; import in.koreatech.koin.domain.member.model.Track; import lombok.RequiredArgsConstructor; @@ -20,6 +25,7 @@ public class AdminTrackService { private final AdminTrackRepository adminTrackRepository; + private final AdminMemberRepository adminMemberRepository; private final AdminTechStackRepository adminTechStackRepository; public List getTracks() { @@ -28,6 +34,39 @@ public List getTracks() { .toList(); } + @Transactional + public AdminTrackResponse createTrack(AdminTrackRequest request) { + if (adminTrackRepository.findByName(request.name()).isPresent()) { + throw TrackNameDuplicationException.withDetail("name: " + request.name()); + } + Track track = request.toEntity(); + Track savedTrack = adminTrackRepository.save(track); + return AdminTrackResponse.from(savedTrack); + } + + public AdminTrackSingleResponse getTrack(Integer trackId) { + Track track = adminTrackRepository.getById(trackId); + List members = adminMemberRepository.findByTrackId(trackId); + List techStacks = adminTechStackRepository.findAllByTrackId(trackId); + return AdminTrackSingleResponse.of(track, members, techStacks); + } + + @Transactional + public AdminTrackResponse updateTrack(Integer trackId, AdminTrackRequest request) { + if (adminTrackRepository.findByName(request.name()).isPresent()) { + throw TrackNameDuplicationException.withDetail("name: " + request.name()); + } + Track track = adminTrackRepository.getById(trackId); + track.update(request.name(), request.headcount(), request.isDeleted()); + return AdminTrackResponse.from(track); + } + + @Transactional + public void deleteTrack(Integer trackId) { + Track track = adminTrackRepository.getById(trackId); + track.delete(); + } + @Transactional public AdminTechStackResponse createTechStack(AdminTechStackRequest request, String trackName) { Track track = adminTrackRepository.getByName(trackName); @@ -36,18 +75,25 @@ public AdminTechStackResponse createTechStack(AdminTechStackRequest request, Str return AdminTechStackResponse.from(savedTechStack); } - public AdminTechStackResponse updateTechStack(AdminTechStackRequest request, String trackName, - Integer techStackId) { + @Transactional + public AdminTechStackResponse updateTechStack( + AdminTechStackRequest request, + String trackName, + Integer techStackId + ) { TechStack techStack = adminTechStackRepository.getById(techStackId); - Integer id = techStack.getTrackId(); if (trackName != null) { Track track = adminTrackRepository.getByName(trackName); id = track.getId(); } - techStack.update(id, request.imageUrl(), request.name(), request.description(), request.isDeleted()); - TechStack updatedTechStack = adminTechStackRepository.save(techStack); - return AdminTechStackResponse.from(updatedTechStack); + return AdminTechStackResponse.from(techStack); + } + + @Transactional + public void deleteTechStack(Integer techStackId) { + TechStack techStack = adminTechStackRepository.getById(techStackId); + techStack.delete(); } } diff --git a/src/main/java/in/koreatech/koin/admin/shop/controller/AdminShopApi.java b/src/main/java/in/koreatech/koin/admin/shop/controller/AdminShopApi.java new file mode 100644 index 000000000..377009135 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/shop/controller/AdminShopApi.java @@ -0,0 +1,356 @@ +package in.koreatech.koin.admin.shop.controller; + +import static in.koreatech.koin.domain.user.model.UserType.ADMIN; +import static io.swagger.v3.oas.annotations.enums.ParameterIn.PATH; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +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.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; + +import in.koreatech.koin.admin.shop.dto.AdminCreateMenuCategoryRequest; +import in.koreatech.koin.admin.shop.dto.AdminCreateMenuRequest; +import in.koreatech.koin.admin.shop.dto.AdminCreateShopCategoryRequest; +import in.koreatech.koin.admin.shop.dto.AdminCreateShopRequest; +import in.koreatech.koin.admin.shop.dto.AdminMenuCategoriesResponse; +import in.koreatech.koin.admin.shop.dto.AdminMenuDetailResponse; +import in.koreatech.koin.admin.shop.dto.AdminModifyMenuCategoryRequest; +import in.koreatech.koin.admin.shop.dto.AdminModifyMenuRequest; +import in.koreatech.koin.admin.shop.dto.AdminModifyShopCategoryRequest; +import in.koreatech.koin.admin.shop.dto.AdminModifyShopRequest; +import in.koreatech.koin.admin.shop.dto.AdminShopCategoriesResponse; +import in.koreatech.koin.admin.shop.dto.AdminShopCategoryResponse; +import in.koreatech.koin.admin.shop.dto.AdminShopMenuResponse; +import in.koreatech.koin.admin.shop.dto.AdminShopResponse; +import in.koreatech.koin.admin.shop.dto.AdminShopsResponse; +import in.koreatech.koin.global.auth.Auth; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +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 io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; + +@Tag(name = "(Admin) Shop: 상점", description = "관리자 권한으로 상점 정보를 관리한다") +public interface AdminShopApi { + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "모든 상점 조회") + @GetMapping("/admin/shops") + ResponseEntity getShops( + @RequestParam(name = "page", defaultValue = "1") Integer page, + @RequestParam(name = "limit", defaultValue = "10", required = false) Integer limit, + @RequestParam(name = "is_deleted", defaultValue = "false") Boolean isDeleted, + @Auth(permit = {ADMIN}) Integer adminId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "특정 상점 조회") + @GetMapping("/admin/shops/{id}") + ResponseEntity getShop( + @Parameter(in = PATH) @PathVariable Integer id, + @Auth(permit = {ADMIN}) Integer adminId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "모든 상점 카테고리 조회") + @GetMapping("/admin/shops/categories") + ResponseEntity getShopCategories( + @RequestParam(name = "page", defaultValue = "1") Integer page, + @RequestParam(name = "limit", defaultValue = "10", required = false) Integer limit, + @RequestParam(name = "is_deleted", defaultValue = "false") Boolean isDeleted, + @Auth(permit = {ADMIN}) Integer adminId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "상점 카테고리 조회") + @GetMapping("/admin/shops/categories/{id}") + ResponseEntity getShopCategory( + @Parameter(in = PATH) @PathVariable Integer id, + @Auth(permit = {ADMIN}) Integer adminId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "특정 상점의 모든 메뉴 조회") + @GetMapping("/admin/shops/{id}/menus") + ResponseEntity getAllMenus( + @Parameter(in = PATH) @PathVariable("id") Integer shopId, + @Auth(permit = {ADMIN}) Integer adminId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "특정 상점의 모든 메뉴 카테고리 조회") + @GetMapping("/admin/shops/{id}/menus/categories") + ResponseEntity getAllMenuCategories( + @Parameter(in = PATH) @PathVariable("id") Integer shopId, + @Auth(permit = {ADMIN}) Integer adminId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "특정 상점의 메뉴 조회") + @GetMapping("/admin/shops/{shopId}/menus/{menuId}") + ResponseEntity getMenu( + @Parameter(in = PATH) @PathVariable("shopId") Integer shopId, + @Parameter(in = PATH) @PathVariable("menuId") Integer menuId, + @Auth(permit = {ADMIN}) Integer adminId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "201"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "특정 상점의 메뉴 생성") + @PostMapping("/admin/shops/{id}/menus") + ResponseEntity createMenu( + @Parameter(in = PATH) @PathVariable("id") Integer shopId, + @RequestBody @Valid AdminCreateMenuRequest adminCreateMenuRequest, + @Auth(permit = {ADMIN}) Integer adminId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "201"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "특정 상점의 메뉴 카테고리 생성") + @PostMapping("/admin/shops/{id}/menus/categories") + ResponseEntity createMenuCategory( + @Parameter(in = PATH) @PathVariable("id") Integer shopId, + @RequestBody @Valid AdminCreateMenuCategoryRequest adminCreateMenuCategoryRequest, + @Auth(permit = {ADMIN}) Integer adminId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "상점 생성") + @PostMapping("/admin/shops") + ResponseEntity createShop( + @RequestBody @Valid AdminCreateShopRequest adminCreateShopRequest, + @Auth(permit = {ADMIN}) Integer adminId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "상점 카테고리 생성") + @PostMapping("/admin/shops/categories") + ResponseEntity createShopCategory( + @RequestBody @Valid AdminCreateShopCategoryRequest adminCreateShopCategoryRequest, + @Auth(permit = {ADMIN}) Integer adminId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "201"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "상점 삭제 해제") + @PostMapping("/admin/shops/{id}/undelete") + ResponseEntity cancelShopDelete( + @Parameter(in = PATH) @PathVariable("id") Integer shopId, + @Auth(permit = {ADMIN}) Integer adminId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "상점 수정") + @PutMapping("/admin/shops/{id}") + ResponseEntity modifyShop( + @Parameter(in = PATH) @PathVariable Integer id, + @RequestBody @Valid AdminModifyShopRequest adminModifyShopRequest, + @Auth(permit = {ADMIN}) Integer adminId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "상점 카테고리 수정") + @PutMapping("/admin/shops/categories/{id}") + ResponseEntity modifyShopCategory( + @Parameter(in = PATH) @PathVariable Integer id, + @RequestBody @Valid AdminModifyShopCategoryRequest adminModifyShopCategoryRequest, + @Auth(permit = {ADMIN}) Integer adminId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "특정 상점의 메뉴 카테고리 수정") + @PutMapping("/admin/shops/{shopId}/menus/categories") + ResponseEntity modifyMenuCategory( + @Parameter(in = PATH) @PathVariable("shopId") Integer shopId, + @RequestBody @Valid AdminModifyMenuCategoryRequest adminModifyMenuCategoryRequest, + @Auth(permit = {ADMIN}) Integer adminId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "특정 상점의 메뉴 수정") + @PutMapping("/admin/shops/{shopId}/menus/{menuId}") + ResponseEntity modifyMenu( + @Parameter(in = PATH) @PathVariable("shopId") Integer shopId, + @Parameter(in = PATH) @PathVariable("menuId") Integer menuId, + @RequestBody @Valid AdminModifyMenuRequest adminModifyMenuRequest, + @Auth(permit = {ADMIN}) Integer adminId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "상점 삭제") + @DeleteMapping("/admin/shops/{id}") + ResponseEntity deleteShop( + @Parameter(in = PATH) @PathVariable Integer id, + @Auth(permit = {ADMIN}) Integer adminId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "상점 카테고리 삭제") + @DeleteMapping("/admin/shops/categories/{id}") + ResponseEntity deleteShopCategory( + @Parameter(in = PATH) @PathVariable Integer id, + @Auth(permit = {ADMIN}) Integer adminId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "특정 상점의 메뉴카테고리 삭제") + @DeleteMapping("/admin/shops/{shopId}/menus/categories/{categoryId}") + ResponseEntity deleteMenuCategory( + @Parameter(in = PATH) @PathVariable("shopId") Integer shopId, + @Parameter(in = PATH) @PathVariable("categoryId") Integer categoryId, + @Auth(permit = {ADMIN}) Integer adminId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "특정 상점의 메뉴 삭제") + @DeleteMapping("/admin/shops/{shopId}/menus/{menuId}") + ResponseEntity deleteMenu( + @Parameter(in = PATH) @PathVariable("shopId") Integer shopId, + @Parameter(in = PATH) @PathVariable("menuId") Integer menuId, + @Auth(permit = {ADMIN}) Integer adminId + ); +} diff --git a/src/main/java/in/koreatech/koin/admin/shop/controller/AdminShopController.java b/src/main/java/in/koreatech/koin/admin/shop/controller/AdminShopController.java new file mode 100644 index 000000000..8a2d71a4a --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/shop/controller/AdminShopController.java @@ -0,0 +1,237 @@ +package in.koreatech.koin.admin.shop.controller; + +import static in.koreatech.koin.domain.user.model.UserType.ADMIN; +import static io.swagger.v3.oas.annotations.enums.ParameterIn.PATH; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +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.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import in.koreatech.koin.admin.shop.dto.AdminCreateMenuCategoryRequest; +import in.koreatech.koin.admin.shop.dto.AdminCreateMenuRequest; +import in.koreatech.koin.admin.shop.dto.AdminCreateShopCategoryRequest; +import in.koreatech.koin.admin.shop.dto.AdminCreateShopRequest; +import in.koreatech.koin.admin.shop.dto.AdminMenuCategoriesResponse; +import in.koreatech.koin.admin.shop.dto.AdminMenuDetailResponse; +import in.koreatech.koin.admin.shop.dto.AdminModifyMenuCategoryRequest; +import in.koreatech.koin.admin.shop.dto.AdminModifyMenuRequest; +import in.koreatech.koin.admin.shop.dto.AdminModifyShopCategoryRequest; +import in.koreatech.koin.admin.shop.dto.AdminModifyShopRequest; +import in.koreatech.koin.admin.shop.dto.AdminShopCategoriesResponse; +import in.koreatech.koin.admin.shop.dto.AdminShopCategoryResponse; +import in.koreatech.koin.admin.shop.dto.AdminShopMenuResponse; +import in.koreatech.koin.admin.shop.dto.AdminShopResponse; +import in.koreatech.koin.admin.shop.dto.AdminShopsResponse; +import in.koreatech.koin.admin.shop.service.AdminShopService; +import in.koreatech.koin.global.auth.Auth; +import io.swagger.v3.oas.annotations.Parameter; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +public class AdminShopController implements AdminShopApi { + + private final AdminShopService adminShopService; + + @GetMapping("/admin/shops") + public ResponseEntity getShops( + @RequestParam(name = "page", defaultValue = "1") Integer page, + @RequestParam(name = "limit", defaultValue = "10", required = false) Integer limit, + @RequestParam(name = "is_deleted", defaultValue = "false") Boolean isDeleted, + @Auth(permit = {ADMIN}) Integer adminId + ) { + AdminShopsResponse response = adminShopService.getShops(page, limit, isDeleted); + return ResponseEntity.ok(response); + } + + @GetMapping("/admin/shops/{id}") + public ResponseEntity getShop( + @Parameter(in = PATH) @PathVariable Integer id, + @Auth(permit = {ADMIN}) Integer adminId + ) { + AdminShopResponse response = adminShopService.getShop(id); + return ResponseEntity.ok(response); + } + + @GetMapping("/admin/shops/categories") + public ResponseEntity getShopCategories( + @RequestParam(name = "page", defaultValue = "1") Integer page, + @RequestParam(name = "limit", defaultValue = "10", required = false) Integer limit, + @RequestParam(name = "is_deleted", defaultValue = "false") Boolean isDeleted, + @Auth(permit = {ADMIN}) Integer adminId + ) { + AdminShopCategoriesResponse response = adminShopService.getShopCategories(page, limit, isDeleted); + return ResponseEntity.ok(response); + } + + @GetMapping("/admin/shops/categories/{id}") + public ResponseEntity getShopCategory( + @Parameter(in = PATH) @PathVariable Integer id, + @Auth(permit = {ADMIN}) Integer adminId + ) { + AdminShopCategoryResponse response = adminShopService.getShopCategory(id); + return ResponseEntity.ok(response); + } + + @GetMapping("/admin/shops/{id}/menus") + public ResponseEntity getAllMenus( + @Parameter(in = PATH) @PathVariable("id") Integer shopId, + @Auth(permit = {ADMIN}) Integer adminId + ) { + AdminShopMenuResponse adminShopMenuResponse = adminShopService.getAllMenus(shopId); + return ResponseEntity.ok(adminShopMenuResponse); + } + + @GetMapping("/admin/shops/{id}/menus/categories") + public ResponseEntity getAllMenuCategories( + @Parameter(in = PATH) @PathVariable("id") Integer shopId, + @Auth(permit = {ADMIN}) Integer adminId + ) { + AdminMenuCategoriesResponse adminMenuCategoriesResponse = adminShopService.getAllMenuCategories(shopId); + return ResponseEntity.ok(adminMenuCategoriesResponse); + } + + @GetMapping("/admin/shops/{shopId}/menus/{menuId}") + public ResponseEntity getMenu( + @Parameter(in = PATH) @PathVariable("shopId") Integer shopId, + @Parameter(in = PATH) @PathVariable("menuId") Integer menuId, + @Auth(permit = {ADMIN}) Integer adminId + ) { + AdminMenuDetailResponse adminMenuDetailResponse = adminShopService.getMenu(shopId, menuId); + return ResponseEntity.ok(adminMenuDetailResponse); + } + + @PostMapping("/admin/shops") + public ResponseEntity createShop( + @RequestBody @Valid AdminCreateShopRequest adminCreateShopRequest, + @Auth(permit = {ADMIN}) Integer adminId + ) { + adminShopService.createShop(adminCreateShopRequest); + return ResponseEntity.status(HttpStatus.CREATED).build(); + } + + @PostMapping("/admin/shops/categories") + public ResponseEntity createShopCategory( + @RequestBody @Valid AdminCreateShopCategoryRequest adminCreateShopCategoryRequest, + @Auth(permit = {ADMIN}) Integer adminId + ) { + adminShopService.createShopCategory(adminCreateShopCategoryRequest); + return ResponseEntity.status(HttpStatus.CREATED).build(); + } + + @PutMapping("/admin/shops/{id}") + public ResponseEntity modifyShop( + @Parameter(in = PATH) @PathVariable Integer id, + @RequestBody @Valid AdminModifyShopRequest adminModifyShopRequest, + @Auth(permit = {ADMIN}) Integer adminId + ) { + adminShopService.modifyShop(id, adminModifyShopRequest); + return ResponseEntity.ok().build(); + } + + @PutMapping("/admin/shops/categories/{id}") + public ResponseEntity modifyShopCategory( + @Parameter(in = PATH) @PathVariable Integer id, + @RequestBody @Valid AdminModifyShopCategoryRequest adminModifyShopCategoryRequest, + @Auth(permit = {ADMIN}) Integer adminId + ) { + adminShopService.modifyShopCategory(id, adminModifyShopCategoryRequest); + return ResponseEntity.ok().build(); + } + + @PutMapping("/admin/shops/{shopId}/menus/categories") + public ResponseEntity modifyMenuCategory( + @Parameter(in = PATH) @PathVariable("shopId") Integer shopId, + @RequestBody @Valid AdminModifyMenuCategoryRequest adminModifyMenuCategoryRequest, + @Auth(permit = {ADMIN}) Integer adminId + ) { + adminShopService.modifyMenuCategory(shopId, adminModifyMenuCategoryRequest); + return ResponseEntity.status(HttpStatus.CREATED).build(); + } + + @PutMapping("/admin/shops/{shopId}/menus/{menuId}") + public ResponseEntity modifyMenu( + @Parameter(in = PATH) @PathVariable("shopId") Integer shopId, + @Parameter(in = PATH) @PathVariable("menuId") Integer menuId, + @RequestBody @Valid AdminModifyMenuRequest adminModifyMenuRequest, + @Auth(permit = {ADMIN}) Integer adminId + ) { + adminShopService.modifyMenu(shopId, menuId, adminModifyMenuRequest); + return ResponseEntity.status(HttpStatus.CREATED).build(); + } + + @PostMapping("/admin/shops/{id}/menus") + public ResponseEntity createMenu( + @Parameter(in = PATH) @PathVariable("id") Integer shopId, + @RequestBody @Valid AdminCreateMenuRequest adminCreateMenuRequest, + @Auth(permit = {ADMIN}) Integer adminId + ) { + adminShopService.createMenu(shopId, adminCreateMenuRequest); + return ResponseEntity.status(HttpStatus.CREATED).build(); + } + + @PostMapping("/admin/shops/{id}/menus/categories") + public ResponseEntity createMenuCategory( + @Parameter(in = PATH) @PathVariable("id") Integer shopId, + @RequestBody @Valid AdminCreateMenuCategoryRequest adminCreateMenuCategoryRequest, + @Auth(permit = {ADMIN}) Integer adminId + ) { + adminShopService.createMenuCategory(shopId, adminCreateMenuCategoryRequest); + return ResponseEntity.status(HttpStatus.CREATED).build(); + } + + @PostMapping("/admin/shops/{id}/undelete") + public ResponseEntity cancelShopDelete( + @Parameter(in = PATH) @PathVariable("id") Integer shopId, + @Auth(permit = {ADMIN}) Integer adminId + ) { + adminShopService.cancelShopDelete(shopId); + return ResponseEntity.status(HttpStatus.OK).build(); + } + + @DeleteMapping("/admin/shops/{id}") + public ResponseEntity deleteShop( + @Parameter(in = PATH) @PathVariable Integer id, + @Auth(permit = {ADMIN}) Integer adminId + ) { + adminShopService.deleteShop(id); + return ResponseEntity.ok().build(); + } + + @DeleteMapping("/admin/shops/categories/{id}") + public ResponseEntity deleteShopCategory( + @Parameter(in = PATH) @PathVariable Integer id, + @Auth(permit = {ADMIN}) Integer adminId + ) { + adminShopService.deleteShopCategory(id); + return ResponseEntity.ok().build(); + } + + @DeleteMapping("/admin/shops/{shopId}/menus/categories/{categoryId}") + public ResponseEntity deleteMenuCategory( + @Parameter(in = PATH) @PathVariable("shopId") Integer shopId, + @Parameter(in = PATH) @PathVariable("categoryId") Integer categoryId, + @Auth(permit = {ADMIN}) Integer adminId + ) { + adminShopService.deleteMenuCategory(shopId, categoryId); + return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); + } + + @DeleteMapping("/admin/shops/{shopId}/menus/{menuId}") + public ResponseEntity deleteMenu( + @Parameter(in = PATH) @PathVariable("shopId") Integer shopId, + @Parameter(in = PATH) @PathVariable("menuId") Integer menuId, + @Auth(permit = {ADMIN}) Integer adminId + ) { + adminShopService.deleteMenu(shopId, menuId); + return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); + } +} diff --git a/src/main/java/in/koreatech/koin/admin/shop/dto/AdminCreateMenuCategoryRequest.java b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminCreateMenuCategoryRequest.java new file mode 100644 index 000000000..c7ee0f8c0 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminCreateMenuCategoryRequest.java @@ -0,0 +1,16 @@ +package in.koreatech.koin.admin.shop.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record AdminCreateMenuCategoryRequest( + @Schema(example = "사이드 메뉴", description = "카테고리명", requiredMode = REQUIRED) + @NotBlank(message = "카테고리명은 필수입니다.") + @Size(min = 1, max = 20, message = "카테고리명은 1자 이상 20자 이하로 입력해주세요.") + String name +) { + +} diff --git a/src/main/java/in/koreatech/koin/admin/shop/dto/AdminCreateMenuRequest.java b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminCreateMenuRequest.java new file mode 100644 index 000000000..cc084c620 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminCreateMenuRequest.java @@ -0,0 +1,80 @@ +package in.koreatech.koin.admin.shop.dto; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.List; + +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.shop.model.Menu; +import in.koreatech.koin.global.validation.UniqueId; +import in.koreatech.koin.global.validation.UniqueUrl; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.Size; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record AdminCreateMenuRequest( + @Schema(example = "[1, 2, 3]", description = "선택된 카테고리 고유 id 리스트", requiredMode = REQUIRED) + @NotNull(message = "카테고리는 필수입니다.") + @Size(min = 1, message = "최소 한 개의 카테고리가 필요합니다.") + @UniqueId(message = "카테고리 ID는 중복될 수 없습니다.") + List categoryIds, + + @Schema(example = "저희 가게의 대표 메뉴 짜장면입니다.", description = "메뉴 구성 설명", requiredMode = REQUIRED) + @Size(max = 80, message = "메뉴 구성 설명은 80자 이하로 입력해주세요.") + String description, + + @Schema(example = """ + [ "https://static.koreatech.in/example.png" ] + """, description = "이미지 URL 리스트", requiredMode = REQUIRED) + @Size(max = 3, message = "이미지는 최대 3개까지 입력 가능합니다.") + @UniqueUrl(message = "이미지 URL은 중복될 수 없습니다.") + List imageUrls, + + @Schema(example = "true", description = "단일 메뉴 여부", requiredMode = REQUIRED) + @NotNull(message = "단일 메뉴 여부는 필수입니다.") + boolean isSingle, + + @Schema(example = "짜장면", description = "메뉴명") + @NotNull(message = "메뉴명은 필수입니다.") + @Size(min = 1, max = 25, message = "메뉴명은 1자 이상 25자 이하로 입력해주세요.") + String name, + + @Schema(description = "단일 메뉴가 아닐때의 옵션에 따른 가격 리스트 / 단일 메뉴일 경우 null", requiredMode = NOT_REQUIRED) + List optionPrices, + + @Schema(description = "단일 메뉴일때의 가격 / 단일 메뉴가 아닐 경우 null", requiredMode = NOT_REQUIRED) + @PositiveOrZero(message = "가격은 0원 이상이어야 합니다.") + Integer singlePrice +) { + public AdminCreateMenuRequest { + if (imageUrls == null) { + imageUrls = List.of(); + } + } + public Menu toEntity(Integer shopId) { + return Menu.builder() + .name(name) + .shopId(shopId) + .description(description) + .build(); + } + + @JsonNaming(value = SnakeCaseStrategy.class) + @Valid + public record InnerOptionPrice( + @Schema(example = "대", description = "옵션명", requiredMode = REQUIRED) + @NotNull @Size(min = 1, max = 50) String option, + + @Schema(example = "26000", description = "가격", requiredMode = REQUIRED) + @PositiveOrZero(message = "가격은 0원 이상이어야 합니다.") + @NotNull Integer price + ) { + + } +} diff --git a/src/main/java/in/koreatech/koin/admin/shop/dto/AdminCreateShopCategoryRequest.java b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminCreateShopCategoryRequest.java new file mode 100644 index 000000000..196d7146c --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminCreateShopCategoryRequest.java @@ -0,0 +1,33 @@ +package in.koreatech.koin.admin.shop.dto; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.shop.model.ShopCategory; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +@JsonNaming(SnakeCaseStrategy.class) +public record AdminCreateShopCategoryRequest( + @Schema(description = "이미지 URL", example = "https://static.koreatech.in/test.png", requiredMode = RequiredMode.REQUIRED) + @NotBlank(message = "이미지 URL은 필수입니다.") + @Size(max = 100, message = "이미지 URL은 255자 이하로 입력해주세요.") + String imageUrl, + + @Schema(description = "이름", example = "햄버거", requiredMode = RequiredMode.REQUIRED) + @NotBlank(message = "카테고리명은 필수입니다.") + @Size(min = 1, max = 25, message = "이름은 1자 이상, 25자 이하로 입력해주세요.") + String name +) { + + public ShopCategory toShopCategory() { + return ShopCategory.builder() + .imageUrl(imageUrl) + .name(name) + .isDeleted(false) + .build(); + } +} + diff --git a/src/main/java/in/koreatech/koin/admin/shop/dto/AdminCreateShopRequest.java b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminCreateShopRequest.java new file mode 100644 index 000000000..cba9936df --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminCreateShopRequest.java @@ -0,0 +1,139 @@ +package in.koreatech.koin.admin.shop.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.time.LocalTime; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.shop.model.Shop; +import in.koreatech.koin.domain.shop.model.ShopOpen; +import in.koreatech.koin.global.validation.UniqueId; +import in.koreatech.koin.global.validation.UniqueUrl; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.Size; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record AdminCreateShopRequest( + @Schema(description = "주소", example = "충청남도 천안시 동남구 병천면", requiredMode = NOT_REQUIRED) + @NotNull(message = "주소는 필수입니다.") + @Size(min = 1, max = 100, message = "주소는 1자 이상, 100자 이하로 입력해주세요.") + String address, + + @Schema(description = "상점 카테고리 고유 id 리스트", example = "[1, 2]", requiredMode = REQUIRED) + @NotNull(message = "카테고리는 필수입니다.") + @UniqueId(message = "카테고리 ID는 중복될 수 없습니다.") + @Size(min = 1, message = "최소 한 개의 카테고리가 필요합니다.") + List categoryIds, + + @Schema(description = "배달 가능 여부", example = "true", requiredMode = REQUIRED) + @NotNull(message = "배달 가능 여부는 필수입니다.") + Boolean delivery, + + @Schema(description = "배달비", example = "1000", requiredMode = REQUIRED) + @NotNull(message = "배달비는 필수입니다.") + @PositiveOrZero(message = "배달비는 0원 이상이어야 합니다.") + Integer deliveryPrice, + + @Schema(description = "설명", example = "string", requiredMode = NOT_REQUIRED) + @NotNull(message = "상점 설명은 null일 수 없습니다.") + @Size(max = 200, message = "설명은 200자 이하로 입력해주세요.") + String description, + + @Schema(description = "이미지 URL 리스트", example = """ + [ "https://static.koreatech.in/example.png" ] + """, requiredMode = NOT_REQUIRED) + @UniqueUrl(message = "이미지 URL은 중복될 수 없습니다.") + List imageUrls, + + @Schema(description = "이름", example = "수신반점", requiredMode = REQUIRED) + @NotBlank(message = "이름은 필수입니다.") + @Size(min = 1, max = 100, message = "가게명은 1자 이상, 15자 이하로 입력해주세요.") + String name, + + @Schema(description = "요일별 휴무 여부 및 장사 시간", requiredMode = NOT_REQUIRED) + @NotNull + List open, + + @Schema(description = "계좌 이체 가능 여부", example = "true", requiredMode = REQUIRED) + @NotNull(message = "계좌 이체 가능 여부는 필수입니다.") + Boolean payBank, + + @Schema(description = "카드 계산 가능 여부", example = "false", requiredMode = REQUIRED) + @NotNull(message = "카드 계산 가능 여부는 필수입니다.") + Boolean payCard, + + @Schema(description = "전화번호", example = "041-000-0000", requiredMode = NOT_REQUIRED) + @NotBlank(message = "전화번호는 필수입니다.") + @Pattern(regexp = "^[0-9]{3}-[0-9]{3,4}-[0-9]{4}$", message = "전화번호 형식이 유효하지 않습니다.") + String phone +) { + public AdminCreateShopRequest { + if (imageUrls == null) { + imageUrls = List.of(); + } + } + + public Shop toShop() { + return Shop.builder() + .address(address) + .delivery(delivery) + .deliveryPrice(deliveryPrice) + .description(description) + .name(name) + .payBank(payBank) + .payCard(payCard) + .phone(phone) + .internalName(name) + .chosung(name.substring(0, 1)) + .isDeleted(false) + .isEvent(false) + .remarks("") + .hit(0) + .build(); + } + + @JsonNaming(value = SnakeCaseStrategy.class) + @Valid + public record InnerShopOpen( + @Schema(description = """ + 요일 = ['MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY', 'SUNDAY'] + """, example = "MONDAY", requiredMode = REQUIRED) + @NotNull(message = "요일은 필수입니다.") + String dayOfWeek, + + @Schema(description = "휴무 여부", example = "false", requiredMode = REQUIRED) + @NotNull(message = "휴무 여부는 필수입니다.") + boolean closed, + + @JsonFormat(pattern = "HH:mm") + @Schema(description = "오픈 시간", example = "02:00", requiredMode = NOT_REQUIRED) + @NotNull(message = "오픈 시간은 필수입니다.") + LocalTime openTime, + + @JsonFormat(pattern = "HH:mm") + @Schema(description = "마감 시간", example = "16:00", requiredMode = NOT_REQUIRED) + @NotNull(message = "닫는 시간은 필수입니다.") + LocalTime closeTime + ) { + + public ShopOpen toEntity(Shop shop) { + return ShopOpen.builder() + .shop(shop) + .closed(closed) + .openTime(openTime) + .closeTime(closeTime) + .dayOfWeek(dayOfWeek) + .build(); + } + } +} diff --git a/src/main/java/in/koreatech/koin/admin/shop/dto/AdminMenuCategoriesResponse.java b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminMenuCategoriesResponse.java new file mode 100644 index 000000000..b1a0508a9 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminMenuCategoriesResponse.java @@ -0,0 +1,42 @@ +package in.koreatech.koin.admin.shop.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.List; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.shop.model.MenuCategory; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record AdminMenuCategoriesResponse( + @Schema(description = "카테고리 수", example = "3") + Long count, + + @Schema(description = "카테고리 목록") + List menuCategories +) { + + public static AdminMenuCategoriesResponse from(List menuCategories) { + List categories = menuCategories.stream() + .map(menuCategory -> MenuCategoryResponse.of(menuCategory.getId(), menuCategory.getName())) + .toList(); + + return new AdminMenuCategoriesResponse((long)categories.size(), categories); + } + + private record MenuCategoryResponse( + @Schema(description = "카테고리 ID", example = "1", requiredMode = REQUIRED) + Integer id, + + @Schema(description = "카테고리 이름", example = "치킨", requiredMode = REQUIRED) + String name + ) { + + public static MenuCategoryResponse of(Integer id, String name) { + return new MenuCategoryResponse(id, name); + } + } +} diff --git a/src/main/java/in/koreatech/koin/admin/shop/dto/AdminMenuDetailResponse.java b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminMenuDetailResponse.java new file mode 100644 index 000000000..8942d7d91 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminMenuDetailResponse.java @@ -0,0 +1,112 @@ +package in.koreatech.koin.admin.shop.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.List; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.shop.model.Menu; +import in.koreatech.koin.domain.shop.model.MenuCategory; +import in.koreatech.koin.domain.shop.model.MenuImage; +import in.koreatech.koin.domain.shop.model.MenuOption; +import in.koreatech.koin.global.exception.KoinIllegalStateException; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@JsonNaming(value = SnakeCaseStrategy.class) +public record AdminMenuDetailResponse( + @Schema(example = "1", description = "고유id", requiredMode = REQUIRED) + Integer id, + + @Schema(example = "1", description = "메뉴가 소속된 상점의 고유 id", requiredMode = REQUIRED) + Integer shopId, + + @Schema(example = "탕수육", description = "이름", requiredMode = REQUIRED) + String name, + + @Schema(example = "false", description = "숨김 여부", requiredMode = REQUIRED) + Boolean isHidden, + + @Schema(example = "false", description = "단일 메뉴 여부", requiredMode = REQUIRED) + Boolean isSingle, + + @Schema(example = "7000", description = "단일 메뉴일때(is_single이 true일때)의 가격", requiredMode = REQUIRED) + Integer singlePrice, + + @Schema(description = "옵션이 있는 메뉴일때(is_single이 false일때)의 가격", requiredMode = NOT_REQUIRED) + List optionPrices, + + @Schema(example = "돼지고기 + 튀김", description = "구성 설명", requiredMode = REQUIRED) + String description, + + @Schema(description = "소속되어 있는 메뉴 카테고리 고유 id 리스트", requiredMode = REQUIRED) + List categoryIds, + + @Schema(description = "이미지 URL 리스트", requiredMode = NOT_REQUIRED) + List imageUrls +) { + + public static AdminMenuDetailResponse createForSingleOption(Menu menu, List shopMenuCategories) { + if (menu.hasMultipleOption()) { + log.warn("{}는 옵션이 하나 이상인 메뉴입니다. createForMultipleOption 메서드를 이용해야 합니다.", menu); + throw new KoinIllegalStateException("서버에 에러가 발생했습니다."); + } + + return new AdminMenuDetailResponse( + menu.getId(), + menu.getShopId(), + menu.getName(), + menu.isHidden(), + true, + menu.getMenuOptions().get(0).getPrice(), + null, + menu.getDescription(), + shopMenuCategories.stream().map(MenuCategory::getId).toList(), + menu.getMenuImages().stream().map(MenuImage::getImageUrl).toList() + ); + } + + public static AdminMenuDetailResponse createMenuDetailResponse(Menu menu, List menuCategories) { + if (menu.hasMultipleOption()) { + return AdminMenuDetailResponse.createForMultipleOption(menu, menuCategories); + } + return AdminMenuDetailResponse.createForSingleOption(menu, menuCategories); + } + + public static AdminMenuDetailResponse createForMultipleOption(Menu menu, List shopMenuCategories) { + if (!menu.hasMultipleOption()) { + log.error("{}는 옵션이 하나인 메뉴입니다. createForSingleOption 메서드를 이용해야 합니다.", menu); + throw new KoinIllegalStateException("서버에 에러가 발생했습니다."); + } + + return new AdminMenuDetailResponse( + menu.getId(), + menu.getShopId(), + menu.getName(), + menu.isHidden(), + false, + null, + menu.getMenuOptions().stream().map(InnerOptionPriceResponse::of).toList(), + menu.getDescription(), + shopMenuCategories.stream().map(MenuCategory::getId).toList(), + menu.getMenuImages().stream().map(MenuImage::getImageUrl).toList() + ); + } + + private record InnerOptionPriceResponse( + @Schema(example = "소", description = "옵션명", requiredMode = REQUIRED) + String option, + + @Schema(example = "10000", description = "옵션에 대한 가격", requiredMode = REQUIRED) + Integer price + ) { + + public static InnerOptionPriceResponse of(MenuOption menuOption) { + return new InnerOptionPriceResponse(menuOption.getOption(), menuOption.getPrice()); + } + } +} diff --git a/src/main/java/in/koreatech/koin/admin/shop/dto/AdminModifyMenuCategoryRequest.java b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminModifyMenuCategoryRequest.java new file mode 100644 index 000000000..0415769d9 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminModifyMenuCategoryRequest.java @@ -0,0 +1,18 @@ +package in.koreatech.koin.admin.shop.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +public record AdminModifyMenuCategoryRequest( + @Schema(example = "1", description = "상점 카테고리 고유 id", requiredMode = REQUIRED) + @NotNull(message = "카테고리 ID는 필수입니다.") + Integer id, + + @Schema(example = "사이드 메뉴", description = "카테고리 명", requiredMode = REQUIRED) + @NotNull(message = "카테고리 명은 필수입니다.") + String name +) { + +} diff --git a/src/main/java/in/koreatech/koin/admin/shop/dto/AdminModifyMenuRequest.java b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminModifyMenuRequest.java new file mode 100644 index 000000000..70f04dd30 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminModifyMenuRequest.java @@ -0,0 +1,72 @@ +package in.koreatech.koin.admin.shop.dto; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.List; + +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.global.validation.UniqueId; +import in.koreatech.koin.global.validation.UniqueUrl; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.Size; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record AdminModifyMenuRequest( + @Schema(example = "[1, 2, 3]", description = "선택된 카테고리 고유 id 리스트", requiredMode = REQUIRED) + @NotNull(message = "카테고리는 필수입니다.") + @Size(min = 1, message = "최소 한 개의 카테고리가 필요합니다.") + @UniqueId(message = "카테고리 ID는 중복될 수 없습니다.") + List categoryIds, + + @Schema(example = "저희 가게의 대표 메뉴 짜장면입니다.", description = "메뉴 구성 설명", requiredMode = REQUIRED) + @Size(max = 80, message = "메뉴 구성 설명은 80자 이하로 입력해주세요.") + String description, + + @Schema(example = """ + [ "https://static.koreatech.in/example.png" ] + """, description = "이미지 URL 리스트", requiredMode = NOT_REQUIRED) + @Size(max = 3, message = "이미지는 최대 3개까지 입력 가능합니다.") + @UniqueUrl(message = "이미지 URL은 중복될 수 없습니다.") + List imageUrls, + + @Schema(example = "true", description = "단일 메뉴 여부", requiredMode = REQUIRED) + @NotNull(message = "단일 메뉴 여부는 필수입니다.") + boolean isSingle, + + @Schema(example = "짜장면", description = "메뉴명", requiredMode = REQUIRED) + @NotNull(message = "메뉴명은 필수입니다.") + @Size(min = 1, max = 25, message = "메뉴명은 1자 이상 25자 이하로 입력해주세요.") + String name, + + @Schema(description = "단일 메뉴가 아닐때의 옵션에 따른 가격 리스트 / 단일 메뉴일 경우 null", requiredMode = NOT_REQUIRED) + List optionPrices, + + @Schema(description = "단일 메뉴일때의 가격 / 단일 메뉴가 아닐 경우 null", requiredMode = NOT_REQUIRED) + @PositiveOrZero(message = "가격은 0원 이상이어야 합니다.") + Integer singlePrice +) { + public AdminModifyMenuRequest { + if (imageUrls == null) { + imageUrls = List.of(); + } + } + @JsonNaming(value = SnakeCaseStrategy.class) + public record InnerOptionPrice( + @Schema(example = "대", description = "옵션명", requiredMode = REQUIRED) + @NotNull(message = "옵션명은 필수입니다.") + @Size(min = 1, max = 50, message = "옵션명은 1자 이상 50자 이하로 입력해주세요.") + String option, + + @Schema(example = "26000", description = "가격", requiredMode = REQUIRED) + @NotNull(message = "가격은 필수입니다.") + @PositiveOrZero(message = "가격은 0원 이상이어야 합니다.") + Integer price + ) { + + } +} diff --git a/src/main/java/in/koreatech/koin/admin/shop/dto/AdminModifyShopCategoryRequest.java b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminModifyShopCategoryRequest.java new file mode 100644 index 000000000..0ff53f5ce --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminModifyShopCategoryRequest.java @@ -0,0 +1,24 @@ +package in.koreatech.koin.admin.shop.dto; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +@JsonNaming(SnakeCaseStrategy.class) +public record AdminModifyShopCategoryRequest( + @Schema(description = "이미지 URL", example = "https://static.koreatech.in/test.png", requiredMode = RequiredMode.REQUIRED) + @NotBlank(message = "이미지 URL은 필수입니다.") + @Size(max = 100, message = "이미지 URL은 255자 이하로 입력해주세요.") + String imageUrl, + + @Schema(description = "이름", example = "햄버거", requiredMode = RequiredMode.REQUIRED) + @NotBlank(message = "카테고리명은 필수입니다.") + @Size(min = 1, max = 25, message = "이름은 1자 이상, 25자 이하로 입력해주세요.") + String name +) { + +} diff --git a/src/main/java/in/koreatech/koin/admin/shop/dto/AdminModifyShopRequest.java b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminModifyShopRequest.java new file mode 100644 index 000000000..69a7ce730 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminModifyShopRequest.java @@ -0,0 +1,119 @@ +package in.koreatech.koin.admin.shop.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.time.LocalTime; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.shop.model.Shop; +import in.koreatech.koin.domain.shop.model.ShopOpen; +import in.koreatech.koin.global.validation.UniqueId; +import in.koreatech.koin.global.validation.UniqueUrl; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.Size; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record AdminModifyShopRequest( + @Schema(description = "주소", example = "충청남도 천안시 동남구 병천면", requiredMode = NOT_REQUIRED) + @NotNull(message = "주소는 필수입니다.") + @Size(min = 1, max = 100, message = "주소는 1자 이상, 100자 이하로 입력해주세요.") + String address, + + @Schema(description = "상점 카테고리 고유 id 리스트", example = "[1, 2]", requiredMode = REQUIRED) + @NotNull(message = "카테고리는 필수입니다.") + @UniqueId(message = "카테고리 ID는 중복될 수 없습니다.") + @Size(min = 1, message = "최소 한 개의 카테고리가 필요합니다.") + List categoryIds, + + @Schema(description = "배달 가능 여부", example = "true", requiredMode = REQUIRED) + @NotNull(message = "배달 가능 여부는 필수입니다.") + Boolean delivery, + + @Schema(description = "배달비", example = "1000", requiredMode = REQUIRED) + @NotNull(message = "배달비는 필수입니다.") + @PositiveOrZero(message = "배달비는 0원 이상이어야 합니다.") + Integer deliveryPrice, + + @Schema(description = "설명", example = "string", requiredMode = NOT_REQUIRED) + @NotNull(message = "상점 설명은 null일 수 없습니다.") + @Size(max = 200, message = "설명은 200자 이하로 입력해주세요.") + String description, + + @Schema(description = "이미지 URL 리스트", example = """ + [ "https://static.koreatech.in/example.png" ] + """, requiredMode = NOT_REQUIRED) + @UniqueUrl(message = "이미지 URL은 중복될 수 없습니다.") + List imageUrls, + + @Schema(description = "이름", example = "수신반점", requiredMode = REQUIRED) + @NotBlank(message = "이름은 필수입니다.") + @Size(min = 1, max = 100, message = "가게명은 1자 이상, 15자 이하로 입력해주세요.") + String name, + + @Schema(description = "요일별 휴무 여부 및 장사 시간", requiredMode = NOT_REQUIRED) + @NotNull + List open, + + @Schema(description = "계좌 이체 가능 여부", example = "true", requiredMode = REQUIRED) + @NotNull(message = "계좌 이체 가능 여부는 필수입니다.") + Boolean payBank, + + @Schema(description = "카드 계산 가능 여부", example = "false", requiredMode = REQUIRED) + @NotNull(message = "카드 계산 가능 여부는 필수입니다.") + Boolean payCard, + + @Schema(description = "전화번호", example = "041-000-0000", requiredMode = NOT_REQUIRED) + @NotBlank(message = "전화번호는 필수입니다.") + @Pattern(regexp = "^[0-9]{3}-[0-9]{3,4}-[0-9]{4}$", message = "전화번호 형식이 유효하지 않습니다.") + String phone +) { + public AdminModifyShopRequest { + if (imageUrls == null) { + imageUrls = List.of(); + } + } + @JsonNaming(value = SnakeCaseStrategy.class) + @Valid + public record InnerShopOpen( + @Schema(description = """ + 요일 = ['MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY', 'SUNDAY'] + """, example = "MONDAY", requiredMode = REQUIRED) + @NotNull(message = "요일은 필수입니다.") + String dayOfWeek, + + @Schema(description = "휴무 여부", example = "false", requiredMode = REQUIRED) + @NotNull(message = "휴무 여부는 필수입니다.") + boolean closed, + + @JsonFormat(pattern = "HH:mm") + @Schema(description = "오픈 시간", example = "02:00", requiredMode = NOT_REQUIRED) + @NotNull(message = "오픈 시간은 필수입니다.") + LocalTime openTime, + + @JsonFormat(pattern = "HH:mm") + @Schema(description = "마감 시간", example = "16:00", requiredMode = NOT_REQUIRED) + @NotNull(message = "닫는 시간은 필수입니다.") + LocalTime closeTime + ) { + + public ShopOpen toEntity(Shop shop) { + return ShopOpen.builder() + .shop(shop) + .closed(closed) + .openTime(openTime) + .closeTime(closeTime) + .dayOfWeek(dayOfWeek) + .build(); + } + } +} diff --git a/src/main/java/in/koreatech/koin/admin/shop/dto/AdminShopCategoriesResponse.java b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminShopCategoriesResponse.java new file mode 100644 index 000000000..965e79cc8 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminShopCategoriesResponse.java @@ -0,0 +1,68 @@ +package in.koreatech.koin.admin.shop.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.List; + +import org.springframework.data.domain.Page; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.shop.model.ShopCategory; +import in.koreatech.koin.global.model.Criteria; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record AdminShopCategoriesResponse( + @Schema(description = "총 상점 카테고리 수", example = "57", requiredMode = REQUIRED) + Long totalCount, + + @Schema(description = "현재 페이지에서 조회된 상점 카테고리 수", example = "10", requiredMode = REQUIRED) + Integer currentCount, + + @Schema(description = "전체 페이지 수", example = "6", requiredMode = REQUIRED) + Integer totalPage, + + @Schema(description = "현재 페이지", example = "2", requiredMode = REQUIRED) + Integer currentPage, + + @Schema(description = "모든 상점 카테고리 리스트", requiredMode = NOT_REQUIRED) + List categories +) { + + public static AdminShopCategoriesResponse of(Page pagedResult, Criteria criteria) { + return new AdminShopCategoriesResponse( + pagedResult.getTotalElements(), + pagedResult.getContent().size(), + pagedResult.getTotalPages(), + criteria.getPage() + 1, + pagedResult.getContent() + .stream() + .map(InnerShopCategory::from) + .toList() + ); + } + + @JsonNaming(value = SnakeCaseStrategy.class) + public record InnerShopCategory( + @Schema(description = "고유 id", example = "0", requiredMode = REQUIRED) + Integer id, + + @Schema(description = "이미지 URL", example = "https://static.koreatech.in/test.png", requiredMode = NOT_REQUIRED) + String imageUrl, + + @Schema(description = "이름", example = "치킨", requiredMode = REQUIRED) + String name + ) { + + public static InnerShopCategory from(ShopCategory shopCategory) { + return new InnerShopCategory( + shopCategory.getId(), + shopCategory.getImageUrl(), + shopCategory.getName() + ); + } + } +} diff --git a/src/main/java/in/koreatech/koin/admin/shop/dto/AdminShopCategoryResponse.java b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminShopCategoryResponse.java new file mode 100644 index 000000000..c3b628a19 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminShopCategoryResponse.java @@ -0,0 +1,28 @@ +package in.koreatech.koin.admin.shop.dto; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.shop.model.ShopCategory; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record AdminShopCategoryResponse( + @Schema(description = "카테고리 고유 ID", example = "0") + int id, + + @Schema(description = "카테고리 이미지 URL", example = "string") + String imageUrl, + + @Schema(description = "카테고리 이름", example = "string") + String name +) { + + public static AdminShopCategoryResponse from(ShopCategory shopCategory) { + return new AdminShopCategoryResponse( + shopCategory.getId(), + shopCategory.getImageUrl(), + shopCategory.getName() + ); + } +} diff --git a/src/main/java/in/koreatech/koin/admin/shop/dto/AdminShopMenuResponse.java b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminShopMenuResponse.java new file mode 100644 index 000000000..cc8ff0d30 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminShopMenuResponse.java @@ -0,0 +1,141 @@ +package in.koreatech.koin.admin.shop.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.time.LocalDateTime; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.shop.model.Menu; +import in.koreatech.koin.domain.shop.model.MenuCategory; +import in.koreatech.koin.domain.shop.model.MenuCategoryMap; +import in.koreatech.koin.domain.shop.model.MenuImage; +import in.koreatech.koin.domain.shop.model.MenuOption; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record AdminShopMenuResponse( + @Schema(example = "20", description = "개수", requiredMode = REQUIRED) + Integer count, + + @Schema(description = "카테고리 별로 분류된 소속 메뉴 리스트") + List menuCategories, + + @JsonFormat(pattern = "yyyy-MM-dd") + @Schema(example = "2024-03-16", description = "해당 상점 마지막 메뉴 업데이트 날짜", requiredMode = REQUIRED) + LocalDateTime updatedAt +) { + + public static AdminShopMenuResponse from(List menuCategories) { + List filteredMenuCategories = menuCategories.stream() + .filter(menuCategory -> !menuCategory.getMenuCategoryMaps().isEmpty()) + .toList(); + + int totalMapsCount = filteredMenuCategories.stream() + .mapToInt(menuCategory -> menuCategory.getMenuCategoryMaps().size()) + .sum(); + + LocalDateTime lastUpdatedAt = filteredMenuCategories.stream() + .flatMap(menuCategory -> menuCategory.getMenuCategoryMaps().stream()) + .map(menuCategoryMap -> menuCategoryMap.getMenu().getUpdatedAt()) + .max(LocalDateTime::compareTo) + .orElse(LocalDateTime.MIN); + + List responses = filteredMenuCategories.stream() + .map(InnerMenuCategoriesResponse::from) + .toList(); + + return new AdminShopMenuResponse( + totalMapsCount, + responses, + lastUpdatedAt + ); + } + + @JsonNaming(value = SnakeCaseStrategy.class) + private record InnerMenuCategoriesResponse( + @Schema(example = "1", description = "카테고리 id", requiredMode = REQUIRED) + Integer id, + + @Schema(example = "중식", description = "카테고리 이름", requiredMode = REQUIRED) + String name, + + @Schema(description = "해당 상점의 모든 메뉴 리스트") + List menus + ) { + + public static InnerMenuCategoriesResponse from(MenuCategory menuCategory) { + return new InnerMenuCategoriesResponse( + menuCategory.getId(), + menuCategory.getName(), + menuCategory.getMenuCategoryMaps().stream().map(InnerMenuResponse::from).toList() + ); + } + + @JsonNaming(value = SnakeCaseStrategy.class) + private record InnerMenuResponse( + @Schema(example = "1", description = "고유 id", requiredMode = REQUIRED) + Integer id, + + @Schema(example = "탕수육", description = "이름", requiredMode = NOT_REQUIRED) + String name, + + @Schema(example = "false", description = "숨김 여부", requiredMode = REQUIRED) + Boolean isHidden, + + @Schema(example = "false", description = "단일 메뉴 여부", requiredMode = REQUIRED) + Boolean isSingle, + + @Schema(example = "10000", description = "단일 메뉴일때(is_single이 true일때)의 가격 / 단일 메뉴가 아니라면 null", requiredMode = NOT_REQUIRED) + Integer singlePrice, + + @Schema(description = "옵션이 있는 메뉴일때(is_single이 false일때)의 옵션에 따른 가격 리스트 / 단일 메뉴 라면 null", requiredMode = NOT_REQUIRED) + List optionPrices, + + @Schema(example = "저희 식당의 대표 메뉴 탕수육입니다.", description = "설명", requiredMode = NOT_REQUIRED) + String description, + + @Schema(description = "이미지 URL리스트", example = """ + [ "https://static.koreatech.in/example.png", "https://static.koreatech.in/example2.png" ] + """, requiredMode = NOT_REQUIRED) + List imageUrls + ) { + + public static InnerMenuResponse from(MenuCategoryMap menuCategoryMap) { + Menu menu = menuCategoryMap.getMenu(); + boolean isSingle = !menu.hasMultipleOption(); + return new InnerMenuResponse( + menu.getId(), + menu.getName(), + menu.isHidden(), + isSingle, + isSingle ? menu.getMenuOptions().get(0).getPrice() : null, + isSingle ? null : menu.getMenuOptions().stream().map(InnerOptionPrice::from).toList(), + menu.getDescription(), + menu.getMenuImages().stream().map(MenuImage::getImageUrl).toList() + ); + } + + @JsonNaming(value = SnakeCaseStrategy.class) + private record InnerOptionPrice( + @Schema(example = "대", description = "옵션명", requiredMode = REQUIRED) + String option, + + @Schema(example = "26000", description = "가격", requiredMode = REQUIRED) + Integer price + ) { + + public static InnerOptionPrice from(MenuOption menuOption) { + return new InnerOptionPrice( + menuOption.getOption(), + menuOption.getPrice() + ); + } + } + } + } +} diff --git a/src/main/java/in/koreatech/koin/admin/shop/dto/AdminShopResponse.java b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminShopResponse.java new file mode 100644 index 000000000..b292e9231 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminShopResponse.java @@ -0,0 +1,163 @@ +package in.koreatech.koin.admin.shop.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.Collections; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.shop.model.Shop; +import in.koreatech.koin.domain.shop.model.ShopCategory; +import in.koreatech.koin.domain.shop.model.ShopImage; +import in.koreatech.koin.domain.shop.model.ShopOpen; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record AdminShopResponse( + @Schema(description = "주소", example = "충청남도 천안시 동남구 병천면") + String address, + + @Schema(description = "배달 가능 여부", example = "true") + Boolean delivery, + + @Schema(description = "배달비", example = "1000", requiredMode = REQUIRED) + Integer deliveryPrice, + + @Schema(description = "설명", example = "string") + String description, + + @Schema(description = "고유 id", example = "1", requiredMode = REQUIRED) + Integer id, + + @Schema(description = "이미지 URL 리스트") + List imageUrls, + + @Schema(description = "상점에 있는 메뉴 카테고리 리스트") + List menuCategories, + + @Schema(description = "이름", example = "수신반점") + String name, + + @Schema(description = "요일별 휴무 여부 및 장사 시간") + List open, + + @Schema(description = "계좌 이체 가능 여부", example = "true", requiredMode = REQUIRED) + Boolean payBank, + + @Schema(description = "카드 계산 가능 여부", example = "false", requiredMode = REQUIRED) + Boolean payCard, + + @Schema(description = "전화번호", example = "041-000-0000", requiredMode = NOT_REQUIRED) + String phone, + + @Schema(description = "소속된 상점 카테고리 리스트") + List shopCategories, + + @JsonFormat(pattern = "yyyy-MM-dd") + @Schema(description = "업데이트 날짜", example = "2024-03-01", requiredMode = REQUIRED) + LocalDateTime updatedAt, + + @Schema(description = "삭제 여부", example = "false", requiredMode = REQUIRED) + Boolean isDeleted, + + @Schema(description = "상점 이벤트 진행 여부", example = "true", requiredMode = REQUIRED) + Boolean isEvent +) { + + public static AdminShopResponse from(Shop shop, Boolean isEvent) { + Collections.sort(shop.getMenuCategories()); + return new AdminShopResponse( + shop.getAddress(), + shop.isDelivery(), + shop.getDeliveryPrice(), + (shop.getDescription() == null || shop.getDescription().isBlank()) ? "-" : shop.getDescription(), + shop.getId(), + shop.getShopImages().stream() + .map(ShopImage::getImageUrl) + .toList(), + shop.getMenuCategories().stream().map(menuCategory -> + new InnerMenuCategory( + menuCategory.getId(), + menuCategory.getName() + ) + ).toList(), + shop.getName(), + shop.getShopOpens().stream().map(shopOpen -> + new InnerShopOpen( + shopOpen.getDayOfWeek(), + shopOpen.isClosed(), + shopOpen.getOpenTime(), + shopOpen.getCloseTime() + ) + ).toList(), + shop.isPayBank(), + shop.isPayCard(), + shop.getPhone(), + shop.getShopCategories().stream().map(shopCategoryMap -> { + ShopCategory shopCategory = shopCategoryMap.getShopCategory(); + return new InnerShopCategory( + shopCategory.getId(), + shopCategory.getName() + ); + }).toList(), + shop.getUpdatedAt(), + shop.isDeleted(), + isEvent + ); + } + + @JsonNaming(value = SnakeCaseStrategy.class) + public record InnerShopOpen( + @Schema(description = """ + 요일 = ['MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY', 'SUNDAY'] + """, example = "MONDAY", requiredMode = REQUIRED) + String dayOfWeek, + + @Schema(description = "휴무 여부", example = "false", requiredMode = REQUIRED) + Boolean closed, + + @JsonFormat(pattern = "HH:mm") + @Schema(description = "오픈 시간", example = "02:00", requiredMode = NOT_REQUIRED) + LocalTime openTime, + + @JsonFormat(pattern = "HH:mm") + @Schema(description = "마감 시간", example = "16:00", requiredMode = NOT_REQUIRED) + LocalTime closeTime + ) { + + public static InnerShopOpen from(ShopOpen shopOpen) { + return new InnerShopOpen( + shopOpen.getDayOfWeek(), + shopOpen.isClosed(), + shopOpen.getOpenTime(), + shopOpen.getCloseTime() + ); + } + } + + private record InnerShopCategory( + @Schema(description = "고유 id", example = "1", requiredMode = REQUIRED) + Integer id, + + @Schema(description = "이름", example = "중국집", requiredMode = REQUIRED) + String name + ) { + + } + + private record InnerMenuCategory( + @Schema(description = "고유 id", example = "1", requiredMode = REQUIRED) + Integer id, + + @Schema(description = "이름", example = "대표 메뉴", requiredMode = REQUIRED) + String name + ) { + + } +} diff --git a/src/main/java/in/koreatech/koin/admin/shop/dto/AdminShopsResponse.java b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminShopsResponse.java new file mode 100644 index 000000000..57bee7f33 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminShopsResponse.java @@ -0,0 +1,78 @@ +package in.koreatech.koin.admin.shop.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.List; + +import org.springframework.data.domain.Page; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.shop.model.Shop; +import in.koreatech.koin.global.model.Criteria; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record AdminShopsResponse( + @Schema(description = "총 상점의 수", example = "57", requiredMode = REQUIRED) + Long totalCount, + + @Schema(description = "현재 페이지에서 조회된 수", example = "10", requiredMode = REQUIRED) + Integer currentCount, + + @Schema(description = "조회할 수 있는 최대 페이지", example = "6", requiredMode = REQUIRED) + Integer totalPage, + + @Schema(description = "현재 페이지", example = "2", requiredMode = REQUIRED) + Integer currentPage, + + @Schema(description = "상점 정보") + List shops +) { + + public static AdminShopsResponse of(Page pagedResult, Criteria criteria) { + return new AdminShopsResponse( + pagedResult.getTotalElements(), + pagedResult.getContent().size(), + pagedResult.getTotalPages(), + criteria.getPage() + 1, + pagedResult.getContent() + .stream() + .map(InnerShopResponse::from) + .toList() + ); + } + + @JsonNaming(value = SnakeCaseStrategy.class) + public record InnerShopResponse( + @Schema(description = "속해있는 상점 카테고리 이름 리스트", example = "[\"string\"]", requiredMode = NOT_REQUIRED) + List categoryNames, + + @Schema(description = "고유 id", example = "1", requiredMode = REQUIRED) + Integer id, + + @Schema(description = "이름", example = "홉스", requiredMode = REQUIRED) + String name, + + @Schema(description = "전화번호", example = "041-000-0000", requiredMode = NOT_REQUIRED) + String phone, + + @Schema(description = "삭제 여부", example = "false", requiredMode = REQUIRED) + boolean isDeleted + ) { + + public static InnerShopResponse from(Shop shop) { + return new InnerShopResponse( + shop.getShopCategories().stream() + .map(shopCategoryMap -> shopCategoryMap.getShopCategory().getName()) + .toList(), + shop.getId(), + shop.getName(), + shop.getPhone(), + shop.isDeleted() + ); + } + } +} diff --git a/src/main/java/in/koreatech/koin/admin/shop/exception/ShopCategoryDuplicationException.java b/src/main/java/in/koreatech/koin/admin/shop/exception/ShopCategoryDuplicationException.java new file mode 100644 index 000000000..57f64f0b8 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/shop/exception/ShopCategoryDuplicationException.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.admin.shop.exception; + +import in.koreatech.koin.global.exception.DuplicationException; + +public class ShopCategoryDuplicationException extends DuplicationException { + + private static final String DEFAULT_MESSAGE = "카테고리명이 이미 존재합니다"; + + protected ShopCategoryDuplicationException(String message) { + super(message); + } + + protected ShopCategoryDuplicationException(String message, String detail) { + super(message, detail); + } + + public static ShopCategoryDuplicationException withDetail(String name) { + return new ShopCategoryDuplicationException(DEFAULT_MESSAGE, "name: " + name); + } +} diff --git a/src/main/java/in/koreatech/koin/admin/shop/repository/AdminEventArticleRepository.java b/src/main/java/in/koreatech/koin/admin/shop/repository/AdminEventArticleRepository.java new file mode 100644 index 000000000..400a59962 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/shop/repository/AdminEventArticleRepository.java @@ -0,0 +1,19 @@ +package in.koreatech.koin.admin.shop.repository; + +import java.time.LocalDate; + +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.Repository; +import org.springframework.data.repository.query.Param; + +import in.koreatech.koin.domain.shop.model.EventArticle; + +public interface AdminEventArticleRepository extends Repository { + + @Query(""" + SELECT COUNT(e) > 0 FROM EventArticle e + WHERE :now BETWEEN e.startDate AND e.endDate + AND e.shop.id = :shopId + """) + boolean isDurationEvent(@Param("shopId") Integer shopId, @Param("now") LocalDate now); +} diff --git a/src/main/java/in/koreatech/koin/admin/shop/repository/AdminMenuCategoryMapRepository.java b/src/main/java/in/koreatech/koin/admin/shop/repository/AdminMenuCategoryMapRepository.java new file mode 100644 index 000000000..d0f9c352f --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/shop/repository/AdminMenuCategoryMapRepository.java @@ -0,0 +1,10 @@ +package in.koreatech.koin.admin.shop.repository; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.shop.model.MenuCategoryMap; + +public interface AdminMenuCategoryMapRepository extends Repository { + + MenuCategoryMap save(MenuCategoryMap menuCategoryMap); +} diff --git a/src/main/java/in/koreatech/koin/admin/shop/repository/AdminMenuCategoryRepository.java b/src/main/java/in/koreatech/koin/admin/shop/repository/AdminMenuCategoryRepository.java new file mode 100644 index 000000000..ebc1b4abe --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/shop/repository/AdminMenuCategoryRepository.java @@ -0,0 +1,26 @@ +package in.koreatech.koin.admin.shop.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.shop.exception.MenuCategoryNotFoundException; +import in.koreatech.koin.domain.shop.model.MenuCategory; + +public interface AdminMenuCategoryRepository extends Repository { + + MenuCategory save(MenuCategory menuCategory); + + List findAllByShopId(Integer shopId); + + Optional findById(Integer id); + + List findAllByIdIn(List ids); + + Void deleteById(Integer id); + + default MenuCategory getById(Integer id) { + return findById(id).orElseThrow(() -> MenuCategoryNotFoundException.withDetail("categoryId: " + id)); + } +} diff --git a/src/main/java/in/koreatech/koin/admin/shop/repository/AdminMenuDetailRepository.java b/src/main/java/in/koreatech/koin/admin/shop/repository/AdminMenuDetailRepository.java new file mode 100644 index 000000000..09c910a08 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/shop/repository/AdminMenuDetailRepository.java @@ -0,0 +1,10 @@ +package in.koreatech.koin.admin.shop.repository; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.shop.model.MenuOption; + +public interface AdminMenuDetailRepository extends Repository { + + MenuOption save(MenuOption menuOption); +} diff --git a/src/main/java/in/koreatech/koin/admin/shop/repository/AdminMenuImageRepository.java b/src/main/java/in/koreatech/koin/admin/shop/repository/AdminMenuImageRepository.java new file mode 100644 index 000000000..8f9066321 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/shop/repository/AdminMenuImageRepository.java @@ -0,0 +1,10 @@ +package in.koreatech.koin.admin.shop.repository; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.shop.model.MenuImage; + +public interface AdminMenuImageRepository extends Repository { + + MenuImage save(MenuImage menuImage); +} diff --git a/src/main/java/in/koreatech/koin/admin/shop/repository/AdminMenuRepository.java b/src/main/java/in/koreatech/koin/admin/shop/repository/AdminMenuRepository.java new file mode 100644 index 000000000..61d773248 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/shop/repository/AdminMenuRepository.java @@ -0,0 +1,24 @@ +package in.koreatech.koin.admin.shop.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.shop.exception.MenuNotFoundException; +import in.koreatech.koin.domain.shop.model.Menu; + +public interface AdminMenuRepository extends Repository { + + Optional findById(Integer menuId); + + Menu save(Menu menu); + + void deleteById(Integer id); + + default Menu getById(Integer menuId) { + return findById(menuId).orElseThrow(() -> MenuNotFoundException.withDetail("menuId: " + menuId)); + } + + List findAllByShopId(Integer shopId); +} diff --git a/src/main/java/in/koreatech/koin/admin/shop/repository/AdminShopCategoryMapRepository.java b/src/main/java/in/koreatech/koin/admin/shop/repository/AdminShopCategoryMapRepository.java new file mode 100644 index 000000000..ba378ed9e --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/shop/repository/AdminShopCategoryMapRepository.java @@ -0,0 +1,14 @@ +package in.koreatech.koin.admin.shop.repository; + +import java.util.List; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.shop.model.ShopCategoryMap; + +public interface AdminShopCategoryMapRepository extends Repository { + + ShopCategoryMap save(ShopCategoryMap shopCategoryMap); + + List findAllByShopId(Integer shopId); +} diff --git a/src/main/java/in/koreatech/koin/admin/shop/repository/AdminShopCategoryRepository.java b/src/main/java/in/koreatech/koin/admin/shop/repository/AdminShopCategoryRepository.java new file mode 100644 index 000000000..53db5202e --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/shop/repository/AdminShopCategoryRepository.java @@ -0,0 +1,36 @@ +package in.koreatech.koin.admin.shop.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.domain.Page; +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 in.koreatech.koin.domain.shop.exception.ShopCategoryNotFoundException; +import in.koreatech.koin.domain.shop.model.ShopCategory; + +public interface AdminShopCategoryRepository extends Repository { + + Page findAllByIsDeleted(boolean isDeleted, Pageable pageable); + + Integer countAllByIsDeleted(boolean isDeleted); + + @Query(value = "SELECT * FROM shop_categories WHERE id = :shopCategoryId", nativeQuery = true) + Optional findById(@Param("shopCategoryId") Integer shopCategoryId); + + ShopCategory save(ShopCategory shopCategory); + + List findAllByIdIn(List ids); + + Optional findByName(String name); + + List findAll(); + + default ShopCategory getById(Integer shopCategoryId) { + return findById(shopCategoryId) + .orElseThrow(() -> ShopCategoryNotFoundException.withDetail("shopCategoryId: " + shopCategoryId)); + } +} diff --git a/src/main/java/in/koreatech/koin/admin/shop/repository/AdminShopImageRepository.java b/src/main/java/in/koreatech/koin/admin/shop/repository/AdminShopImageRepository.java new file mode 100644 index 000000000..1749d489b --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/shop/repository/AdminShopImageRepository.java @@ -0,0 +1,14 @@ +package in.koreatech.koin.admin.shop.repository; + +import java.util.List; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.shop.model.ShopImage; + +public interface AdminShopImageRepository extends Repository { + + ShopImage save(ShopImage shopImage); + + List findAllByShopId(Integer shopId); +} diff --git a/src/main/java/in/koreatech/koin/admin/shop/repository/AdminShopOpenRepository.java b/src/main/java/in/koreatech/koin/admin/shop/repository/AdminShopOpenRepository.java new file mode 100644 index 000000000..8b1fa5db0 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/shop/repository/AdminShopOpenRepository.java @@ -0,0 +1,14 @@ +package in.koreatech.koin.admin.shop.repository; + +import java.util.List; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.shop.model.ShopOpen; + +public interface AdminShopOpenRepository extends Repository { + + ShopOpen save(ShopOpen shopOpen); + + List findAllByShopId(Integer shopId); +} diff --git a/src/main/java/in/koreatech/koin/admin/shop/repository/AdminShopRepository.java b/src/main/java/in/koreatech/koin/admin/shop/repository/AdminShopRepository.java new file mode 100644 index 000000000..4eda89f19 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/shop/repository/AdminShopRepository.java @@ -0,0 +1,47 @@ +package in.koreatech.koin.admin.shop.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.Repository; +import org.springframework.data.repository.query.Param; + +import in.koreatech.koin.domain.shop.exception.ShopNotFoundException; +import in.koreatech.koin.domain.shop.model.Shop; + +public interface AdminShopRepository extends Repository { + + @Query(value = "SELECT * FROM shops WHERE is_deleted = :isDeleted", + countQuery = "SELECT count(*) FROM shops WHERE is_deleted = :isDeleted", + nativeQuery = true) + Page findAllByIsDeleted(@Param("isDeleted") boolean isDeleted, Pageable pageable); + + @Query(value = "SELECT COUNT(*) FROM shops WHERE is_deleted = :isDeleted", nativeQuery = true) + Integer countAllByIsDeleted(@Param("isDeleted") boolean isDeleted); + + Shop save(Shop shop); + + @Query(value = "SELECT * FROM shops WHERE id = :shopId", nativeQuery = true) + Optional findById(@Param("shopId") Integer shopId); + + @Query(value = "SELECT * FROM shops WHERE owner_id = :ownerId AND is_deleted = false", nativeQuery = true) + List findAllByOwnerId(@Param("ownerId") Integer ownerId); + + default Shop getById(Integer shopId) { + return findById(shopId) + .orElseThrow(() -> ShopNotFoundException.withDetail("shopId: " + shopId)); + } + + List findAll(); + + @Query(value = "SELECT * FROM shops WHERE id = :shopId AND is_deleted = true", nativeQuery = true) + Optional findDeletedShopById(@Param("shopId") Integer shopId); + + @Modifying + @Query(value = "UPDATE shops SET is_deleted = true WHERE id = :shopId", nativeQuery = true) + int deleteById(@Param("shopId") Integer shopId); +} diff --git a/src/main/java/in/koreatech/koin/admin/shop/service/AdminShopService.java b/src/main/java/in/koreatech/koin/admin/shop/service/AdminShopService.java new file mode 100644 index 000000000..4cb4189ac --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/shop/service/AdminShopService.java @@ -0,0 +1,324 @@ +package in.koreatech.koin.admin.shop.service; + +import java.time.Clock; +import java.time.LocalDate; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import in.koreatech.koin.admin.shop.dto.AdminCreateMenuCategoryRequest; +import in.koreatech.koin.admin.shop.dto.AdminCreateMenuRequest; +import in.koreatech.koin.admin.shop.dto.AdminCreateShopCategoryRequest; +import in.koreatech.koin.admin.shop.dto.AdminCreateShopRequest; +import in.koreatech.koin.admin.shop.dto.AdminCreateShopRequest.InnerShopOpen; +import in.koreatech.koin.admin.shop.dto.AdminMenuCategoriesResponse; +import in.koreatech.koin.admin.shop.dto.AdminMenuDetailResponse; +import in.koreatech.koin.admin.shop.dto.AdminModifyMenuCategoryRequest; +import in.koreatech.koin.admin.shop.dto.AdminModifyMenuRequest; +import in.koreatech.koin.admin.shop.dto.AdminModifyMenuRequest.InnerOptionPrice; +import in.koreatech.koin.admin.shop.dto.AdminModifyShopCategoryRequest; +import in.koreatech.koin.admin.shop.dto.AdminModifyShopRequest; +import in.koreatech.koin.admin.shop.dto.AdminShopCategoriesResponse; +import in.koreatech.koin.admin.shop.dto.AdminShopCategoryResponse; +import in.koreatech.koin.admin.shop.dto.AdminShopMenuResponse; +import in.koreatech.koin.admin.shop.dto.AdminShopResponse; +import in.koreatech.koin.admin.shop.dto.AdminShopsResponse; +import in.koreatech.koin.admin.shop.exception.ShopCategoryDuplicationException; +import in.koreatech.koin.admin.shop.repository.AdminEventArticleRepository; +import in.koreatech.koin.admin.shop.repository.AdminMenuCategoryMapRepository; +import in.koreatech.koin.admin.shop.repository.AdminMenuCategoryRepository; +import in.koreatech.koin.admin.shop.repository.AdminMenuDetailRepository; +import in.koreatech.koin.admin.shop.repository.AdminMenuImageRepository; +import in.koreatech.koin.admin.shop.repository.AdminMenuRepository; +import in.koreatech.koin.admin.shop.repository.AdminShopCategoryMapRepository; +import in.koreatech.koin.admin.shop.repository.AdminShopCategoryRepository; +import in.koreatech.koin.admin.shop.repository.AdminShopImageRepository; +import in.koreatech.koin.admin.shop.repository.AdminShopOpenRepository; +import in.koreatech.koin.admin.shop.repository.AdminShopRepository; +import in.koreatech.koin.domain.shop.model.Menu; +import in.koreatech.koin.domain.shop.model.MenuCategory; +import in.koreatech.koin.domain.shop.model.MenuCategoryMap; +import in.koreatech.koin.domain.shop.model.MenuImage; +import in.koreatech.koin.domain.shop.model.MenuOption; +import in.koreatech.koin.domain.shop.model.Shop; +import in.koreatech.koin.domain.shop.model.ShopCategory; +import in.koreatech.koin.domain.shop.model.ShopCategoryMap; +import in.koreatech.koin.domain.shop.model.ShopImage; +import in.koreatech.koin.domain.shop.model.ShopOpen; +import in.koreatech.koin.global.exception.KoinIllegalArgumentException; +import in.koreatech.koin.global.model.Criteria; +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class AdminShopService { + + private final Clock clock; + private final EntityManager entityManager; + private final AdminEventArticleRepository adminEventArticleRepository; + private final AdminMenuCategoryRepository adminMenuCategoryRepository; + private final AdminShopCategoryMapRepository adminShopCategoryMapRepository; + private final AdminShopCategoryRepository adminShopCategoryRepository; + private final AdminShopImageRepository adminShopImageRepository; + private final AdminShopOpenRepository adminShopOpenRepository; + private final AdminShopRepository adminShopRepository; + private final AdminMenuRepository adminMenuRepository; + private final AdminMenuCategoryMapRepository adminMenuCategoryMapRepository; + private final AdminMenuImageRepository adminMenuImageRepository; + private final AdminMenuDetailRepository adminMenuDetailRepository; + + public AdminShopsResponse getShops(Integer page, Integer limit, Boolean isDeleted) { + Integer total = adminShopRepository.countAllByIsDeleted(isDeleted); + Criteria criteria = Criteria.of(page, limit, total); + PageRequest pageRequest = PageRequest.of(criteria.getPage(), criteria.getLimit(), + Sort.by(Sort.Direction.ASC, "id")); + Page result = adminShopRepository.findAllByIsDeleted(isDeleted, pageRequest); + return AdminShopsResponse.of(result, criteria); + } + + public AdminShopResponse getShop(Integer shopId) { + Shop shop = adminShopRepository.getById(shopId); + boolean eventDuration = adminEventArticleRepository.isDurationEvent(shopId, LocalDate.now(clock)); + return AdminShopResponse.from(shop, eventDuration); + } + + public AdminShopCategoriesResponse getShopCategories(Integer page, Integer limit, Boolean isDeleted) { + Integer total = adminShopCategoryRepository.countAllByIsDeleted(isDeleted); + Criteria criteria = Criteria.of(page, limit, total); + PageRequest pageRequest = PageRequest.of(criteria.getPage(), criteria.getLimit(), + Sort.by(Sort.Direction.ASC, "id")); + Page result = adminShopCategoryRepository.findAllByIsDeleted(isDeleted, pageRequest); + return AdminShopCategoriesResponse.of(result, criteria); + } + + public AdminShopCategoryResponse getShopCategory(Integer categoryId) { + ShopCategory shopCategory = adminShopCategoryRepository.getById(categoryId); + return AdminShopCategoryResponse.from(shopCategory); + } + + public AdminShopMenuResponse getAllMenus(Integer shopId) { + Shop shop = adminShopRepository.getById(shopId); + List menuCategories = adminMenuCategoryRepository.findAllByShopId(shop.getId()); + Collections.sort(menuCategories); + return AdminShopMenuResponse.from(menuCategories); + } + + public AdminMenuCategoriesResponse getAllMenuCategories(Integer shopId) { + Shop shop = adminShopRepository.getById(shopId); + List menuCategories = adminMenuCategoryRepository.findAllByShopId(shop.getId()); + Collections.sort(menuCategories); + return AdminMenuCategoriesResponse.from(menuCategories); + } + + public AdminMenuDetailResponse getMenu(Integer shopId, Integer menuId) { + adminShopRepository.getById(shopId); + Menu menu = adminMenuRepository.getById(menuId); + List menuCategories = menu.getMenuCategoryMaps() + .stream() + .map(MenuCategoryMap::getMenuCategory) + .toList(); + return AdminMenuDetailResponse.createMenuDetailResponse(menu, menuCategories); + } + + @Transactional + public void createShop(AdminCreateShopRequest adminCreateShopRequest) { + Shop shop = adminCreateShopRequest.toShop(); + Shop savedShop = adminShopRepository.save(shop); + List categoryNames = List.of("추천 메뉴", "메인 메뉴", "세트 메뉴", "사이드 메뉴"); + for (String categoryName : categoryNames) { + MenuCategory menuCategory = MenuCategory.builder() + .shop(savedShop) + .name(categoryName) + .build(); + adminMenuCategoryRepository.save(menuCategory); + } + for (String imageUrl : adminCreateShopRequest.imageUrls()) { + ShopImage shopImage = ShopImage.builder() + .shop(savedShop) + .imageUrl(imageUrl) + .build(); + adminShopImageRepository.save(shopImage); + } + for (InnerShopOpen open : adminCreateShopRequest.open()) { + ShopOpen shopOpen = ShopOpen.builder() + .shop(savedShop) + .openTime(open.openTime()) + .closeTime(open.closeTime()) + .dayOfWeek(open.dayOfWeek()) + .closed(open.closed()) + .build(); + adminShopOpenRepository.save(shopOpen); + } + List categories = adminShopCategoryRepository.findAllByIdIn(adminCreateShopRequest.categoryIds()); + for (ShopCategory shopCategory : categories) { + ShopCategoryMap shopCategoryMap = ShopCategoryMap.builder() + .shopCategory(shopCategory) + .shop(savedShop) + .build(); + adminShopCategoryMapRepository.save(shopCategoryMap); + } + } + + @Transactional + public void createShopCategory(AdminCreateShopCategoryRequest adminCreateShopCategoryRequest) { + if (adminShopCategoryRepository.findByName(adminCreateShopCategoryRequest.name()).isPresent()) { + throw ShopCategoryDuplicationException.withDetail("name: " + adminCreateShopCategoryRequest.name()); + } + ShopCategory shopCategory = adminCreateShopCategoryRequest.toShopCategory(); + adminShopCategoryRepository.save(shopCategory); + } + + @Transactional + public void createMenu(Integer shopId, AdminCreateMenuRequest adminCreateMenuRequest) { + adminShopRepository.getById(shopId); + Menu menu = adminCreateMenuRequest.toEntity(shopId); + Menu savedMenu = adminMenuRepository.save(menu); + for (Integer categoryId : adminCreateMenuRequest.categoryIds()) { + MenuCategory menuCategory = adminMenuCategoryRepository.getById(categoryId); + MenuCategoryMap menuCategoryMap = MenuCategoryMap.builder() + .menuCategory(menuCategory) + .menu(savedMenu) + .build(); + adminMenuCategoryMapRepository.save(menuCategoryMap); + } + for (String imageUrl : adminCreateMenuRequest.imageUrls()) { + MenuImage menuImage = MenuImage.builder() + .imageUrl(imageUrl) + .menu(savedMenu) + .build(); + adminMenuImageRepository.save(menuImage); + } + if (adminCreateMenuRequest.optionPrices() == null) { + MenuOption menuOption = MenuOption.builder() + .option(savedMenu.getName()) + .price(adminCreateMenuRequest.singlePrice()) + .menu(menu) + .build(); + adminMenuDetailRepository.save(menuOption); + } else { + for (var option : adminCreateMenuRequest.optionPrices()) { + MenuOption menuOption = MenuOption.builder() + .option(option.option()) + .price(option.price()) + .menu(menu) + .build(); + adminMenuDetailRepository.save(menuOption); + } + } + } + + @Transactional + public void createMenuCategory(Integer shopId, AdminCreateMenuCategoryRequest adminCreateMenuCategoryRequest) { + Shop shop = adminShopRepository.getById(shopId); + MenuCategory menuCategory = MenuCategory.builder() + .shop(shop) + .name(adminCreateMenuCategoryRequest.name()) + .build(); + adminMenuCategoryRepository.save(menuCategory); + } + + @Transactional + public void cancelShopDelete(Integer shopId) { + Optional shop = adminShopRepository.findDeletedShopById(shopId); + if(shop.isPresent()) { + shop.get().cancelDelete(); + } + } + + @Transactional + public void modifyShop(Integer shopId, AdminModifyShopRequest adminModifyShopRequest) { + Shop shop = adminShopRepository.getById(shopId); + shop.modifyShop( + adminModifyShopRequest.name(), + adminModifyShopRequest.phone(), + adminModifyShopRequest.address(), + adminModifyShopRequest.description(), + adminModifyShopRequest.delivery(), + adminModifyShopRequest.deliveryPrice(), + adminModifyShopRequest.payCard(), + adminModifyShopRequest.payBank() + ); + shop.modifyShopCategories( + adminShopCategoryRepository.findAllByIdIn(adminModifyShopRequest.categoryIds()), + entityManager + ); + shop.modifyShopImages(adminModifyShopRequest.imageUrls(), entityManager); + shop.modifyAdminShopOpens(adminModifyShopRequest.open(), entityManager); + } + + @Transactional + public void modifyShopCategory(Integer categoryId, AdminModifyShopCategoryRequest adminModifyShopCategoryRequest) { + if (adminShopCategoryRepository.findByName(adminModifyShopCategoryRequest.name()).isPresent()) { + throw ShopCategoryDuplicationException.withDetail("name: " + adminModifyShopCategoryRequest.name()); + } + ShopCategory shopCategory = adminShopCategoryRepository.getById(categoryId); + shopCategory.modifyShopCategory( + adminModifyShopCategoryRequest.name(), + adminModifyShopCategoryRequest.imageUrl() + ); + } + + @Transactional + public void modifyMenuCategory(Integer shopId, AdminModifyMenuCategoryRequest adminModifyMenuCategoryRequest) { + adminShopRepository.getById(shopId); + MenuCategory menuCategory = adminMenuCategoryRepository.getById(adminModifyMenuCategoryRequest.id()); + menuCategory.modifyName(adminModifyMenuCategoryRequest.name()); + } + + @Transactional + public void modifyMenu(Integer shopId, Integer menuId, AdminModifyMenuRequest adminModifyMenuRequest) { + Menu menu = adminMenuRepository.getById(menuId); + adminShopRepository.getById(shopId); + menu.modifyMenu( + adminModifyMenuRequest.name(), + adminModifyMenuRequest.description() + ); + menu.modifyMenuImages(adminModifyMenuRequest.imageUrls(), entityManager); + menu.modifyMenuCategories(adminMenuCategoryRepository.findAllByIdIn(adminModifyMenuRequest.categoryIds()), entityManager); + if (adminModifyMenuRequest.isSingle()) { + menu.adminModifyMenuSingleOptions(adminModifyMenuRequest, entityManager); + } else { + List optionPrices = adminModifyMenuRequest.optionPrices(); + menu.adminModifyMenuMultipleOptions(optionPrices, entityManager); + } + } + + @Transactional + public void deleteShop(Integer shopId) { + Shop shop = adminShopRepository.getById(shopId); + shop.delete(); + } + + @Transactional + public void deleteShopCategory(Integer categoryId) { + ShopCategory shopCategory = adminShopCategoryRepository.getById(categoryId); + shopCategory.delete(); + } + + @Transactional + public void deleteMenuCategory(Integer shopId, Integer categoryId) { + MenuCategory menuCategory = adminMenuCategoryRepository.getById(categoryId); + if (!Objects.equals(menuCategory.getShop().getId(), shopId)) { + throw new KoinIllegalArgumentException("해당 상점의 카테고리가 아닙니다."); + } + adminMenuCategoryRepository.deleteById(categoryId); + } + + @Transactional + public void deleteMenu(Integer shopId, Integer menuId) { + Menu menu = adminMenuRepository.getById(menuId); + if (!Objects.equals(menu.getShopId(), shopId)) { + throw new KoinIllegalArgumentException("해당 상점의 카테고리가 아닙니다."); + } + adminMenuRepository.deleteById(menuId); + } +} diff --git a/src/main/java/in/koreatech/koin/admin/user/controller/AdminUserApi.java b/src/main/java/in/koreatech/koin/admin/user/controller/AdminUserApi.java index 94f125d3f..faaa39b66 100644 --- a/src/main/java/in/koreatech/koin/admin/user/controller/AdminUserApi.java +++ b/src/main/java/in/koreatech/koin/admin/user/controller/AdminUserApi.java @@ -2,19 +2,32 @@ import static in.koreatech.koin.domain.user.model.UserType.ADMIN; +import org.springdoc.core.annotations.ParameterObject; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; +import in.koreatech.koin.admin.user.dto.AdminOwnerUpdateRequest; +import in.koreatech.koin.admin.user.dto.AdminOwnerUpdateResponse; +import in.koreatech.koin.admin.user.dto.AdminOwnersResponse; +import in.koreatech.koin.admin.user.dto.AdminLoginRequest; +import in.koreatech.koin.admin.user.dto.AdminLoginResponse; import in.koreatech.koin.admin.user.dto.AdminStudentResponse; import in.koreatech.koin.admin.user.dto.AdminNewOwnersResponse; import in.koreatech.koin.admin.user.dto.AdminOwnerResponse; import in.koreatech.koin.admin.user.dto.AdminStudentUpdateRequest; import in.koreatech.koin.admin.user.dto.AdminStudentUpdateResponse; -import in.koreatech.koin.admin.user.dto.NewOwnersCondition; +import in.koreatech.koin.admin.user.dto.OwnersCondition; +import in.koreatech.koin.domain.user.model.User; +import in.koreatech.koin.admin.user.dto.AdminStudentsResponse; +import in.koreatech.koin.admin.user.dto.AdminTokenRefreshRequest; +import in.koreatech.koin.admin.user.dto.AdminTokenRefreshResponse; import in.koreatech.koin.global.auth.Auth; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; @@ -27,6 +40,85 @@ @Tag(name = "(Admin) User: 회원", description = "관리자 권한으로 회원 정보를 관리한다") public interface AdminUserApi { + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "학생 리스트 조회(페이지네이션)") + @SecurityRequirement(name = "Jwt Authentication") + @GetMapping("/admin/students") + public ResponseEntity getStudents( + @RequestParam(required = false) Integer page, + @RequestParam(required = false) Integer limit, + @RequestParam(required = false) Boolean isAuthed, + @RequestParam(required = false) String nickname, + @RequestParam(required = false) String email, + @Auth(permit = {ADMIN}) Integer adminId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "201"), + @ApiResponse(responseCode = "400", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "어드민 로그인") + @PostMapping("/admin/user/login") + ResponseEntity adminLogin( + @RequestBody @Valid AdminLoginRequest request + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "201"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "어드민 로그아웃") + @SecurityRequirement(name = "Jwt Authentication") + @PostMapping("admin/user/logout") + ResponseEntity logout( + @Auth(permit = {ADMIN}) Integer adminId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "201"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "어드민 액세스 토큰 재발급") + @PostMapping("/admin/user/refresh") + public ResponseEntity refresh( + @RequestBody @Valid AdminTokenRefreshRequest request + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "사장님 권한 요청 허용") + @SecurityRequirement(name = "Jwt Authentication") + @PutMapping("/admin/owner/{id}/authed") + ResponseEntity allowOwnerPermission( + @PathVariable Integer id, + @Auth(permit = {ADMIN}) Integer adminId + ); + @ApiResponses( value = { @ApiResponse(responseCode = "200"), @@ -76,6 +168,23 @@ ResponseEntity getOwner( @Auth(permit = {ADMIN}) Integer adminId ); + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "특정 사장님 수정") + @SecurityRequirement(name = "Jwt Authentication") + @PutMapping("/admin/users/owner/{id}") + ResponseEntity updateOwner( + @PathVariable Integer id, + @RequestBody @Valid AdminOwnerUpdateRequest request, + @Auth(permit = {ADMIN}) Integer adminId + ); + @ApiResponses( value = { @ApiResponse(responseCode = "200"), @@ -88,7 +197,71 @@ ResponseEntity getOwner( @SecurityRequirement(name = "Jwt Authentication") @GetMapping("/admin/users/new-owners") ResponseEntity getNewOwners( - @ModelAttribute NewOwnersCondition newOwnersCondition, + @ParameterObject @ModelAttribute OwnersCondition ownersCondition, + @Auth(permit = {ADMIN}) Integer adminId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))) + } + ) + @Operation(summary = "사장 리스트 조회 (페이지네이션)") + @SecurityRequirement(name = "Jwt Authentication") + @GetMapping("/admin/users/owners") + ResponseEntity getOwners( + @ParameterObject @ModelAttribute OwnersCondition ownersCondition, + @Auth(permit = {ADMIN}) Integer adminId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "회원 정보 조회") + @SecurityRequirement(name = "Jwt Authentication") + @GetMapping("/admin/users/{id}") + ResponseEntity getUser( + @PathVariable Integer id, + @Auth(permit = {ADMIN}) Integer adminId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "회원 삭제 (탈퇴 처리)") + @SecurityRequirement(name = "Jwt Authentication") + @DeleteMapping("/admin/users/{id}") + ResponseEntity deleteUser( + @PathVariable Integer id, + @Auth(permit = {ADMIN}) Integer adminId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "회원 삭제 해제 (탈퇴 상태를 해제 처리)") + @SecurityRequirement(name = "Jwt Authentication") + @PostMapping("/admin/users/{id}/undelete") + ResponseEntity undeleteUser( + @PathVariable Integer id, @Auth(permit = {ADMIN}) Integer adminId ); } diff --git a/src/main/java/in/koreatech/koin/admin/user/controller/AdminUserController.java b/src/main/java/in/koreatech/koin/admin/user/controller/AdminUserController.java index 90870be29..600756488 100644 --- a/src/main/java/in/koreatech/koin/admin/user/controller/AdminUserController.java +++ b/src/main/java/in/koreatech/koin/admin/user/controller/AdminUserController.java @@ -2,21 +2,37 @@ import static in.koreatech.koin.domain.user.model.UserType.ADMIN; +import org.springdoc.core.annotations.ParameterObject; +import java.net.URI; + import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import in.koreatech.koin.admin.user.dto.AdminLoginRequest; +import in.koreatech.koin.admin.user.dto.AdminLoginResponse; import in.koreatech.koin.admin.user.dto.AdminNewOwnersResponse; import in.koreatech.koin.admin.user.dto.AdminOwnerResponse; +import in.koreatech.koin.admin.user.dto.AdminOwnerUpdateRequest; +import in.koreatech.koin.admin.user.dto.AdminOwnerUpdateResponse; +import in.koreatech.koin.admin.user.dto.AdminOwnersResponse; import in.koreatech.koin.admin.user.dto.AdminStudentResponse; import in.koreatech.koin.admin.user.dto.AdminStudentUpdateRequest; import in.koreatech.koin.admin.user.dto.AdminStudentUpdateResponse; -import in.koreatech.koin.admin.user.dto.NewOwnersCondition; +import in.koreatech.koin.admin.user.dto.OwnersCondition; +import in.koreatech.koin.admin.user.dto.AdminStudentsResponse; +import in.koreatech.koin.admin.user.dto.AdminTokenRefreshRequest; +import in.koreatech.koin.admin.user.dto.AdminTokenRefreshResponse; +import in.koreatech.koin.admin.user.dto.StudentsCondition; import in.koreatech.koin.admin.user.service.AdminUserService; +import in.koreatech.koin.domain.user.model.User; import in.koreatech.koin.global.auth.Auth; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -27,13 +43,62 @@ public class AdminUserController implements AdminUserApi{ private final AdminUserService adminUserService; + @PutMapping("/admin/owner/{id}/authed") + public ResponseEntity allowOwnerPermission( + @PathVariable Integer id, + @Auth(permit = {ADMIN}) Integer adminId) { + adminUserService.allowOwnerPermission(id); + return ResponseEntity.ok().build(); + } + + @GetMapping("/admin/students") + public ResponseEntity getStudents( + @RequestParam(required = false) Integer page, + @RequestParam(required = false) Integer limit, + @RequestParam(required = false) Boolean isAuthed, + @RequestParam(required = false) String nickname, + @RequestParam(required = false) String email, + @Auth(permit = {ADMIN}) Integer adminId + ) { + StudentsCondition studentsCondition = new StudentsCondition(page, limit, isAuthed, nickname, email); + AdminStudentsResponse adminStudentsResponse = adminUserService.getStudents(studentsCondition); + return ResponseEntity.ok().body(adminStudentsResponse); + } + + @PostMapping("/admin/user/login") + public ResponseEntity adminLogin( + @RequestBody @Valid AdminLoginRequest request + ) { + AdminLoginResponse response = adminUserService.adminLogin(request); + return ResponseEntity.created(URI.create("/")) + .body(response); + } + + @PostMapping("admin/user/logout") + public ResponseEntity logout( + @Auth(permit = {ADMIN}) Integer adminId + ) { + adminUserService.adminLogout(adminId); + return ResponseEntity.ok().build(); + } + + @PostMapping("/admin/user/refresh") + public ResponseEntity refresh( + @RequestBody @Valid AdminTokenRefreshRequest request + ) { + AdminTokenRefreshResponse tokenGroupResponse = adminUserService.adminRefresh(request); + return ResponseEntity.created(URI.create("/")) + .body(tokenGroupResponse); + } + + @GetMapping("/admin/users/student/{id}") public ResponseEntity getStudent( @PathVariable Integer id, @Auth(permit = {ADMIN}) Integer adminId ) { AdminStudentResponse adminStudentResponse = adminUserService.getStudent(id); - return ResponseEntity.ok(adminStudentResponse); + return ResponseEntity.ok().body(adminStudentResponse); } @PutMapping("/admin/users/student/{id}") @@ -43,7 +108,7 @@ public ResponseEntity updateStudent( @Auth(permit = {ADMIN}) Integer adminId ) { AdminStudentUpdateResponse adminStudentUpdateResponse = adminUserService.updateStudent(id, adminRequest); - return ResponseEntity.ok(adminStudentUpdateResponse); + return ResponseEntity.ok().body(adminStudentUpdateResponse); } @GetMapping("/admin/users/owner/{id}") @@ -52,14 +117,58 @@ public ResponseEntity getOwner( @Auth(permit = {ADMIN}) Integer adminId ) { AdminOwnerResponse adminOwnerResponse = adminUserService.getOwner(id); - return ResponseEntity.ok(adminOwnerResponse); + return ResponseEntity.ok().body(adminOwnerResponse); + } + + @PutMapping("/admin/users/owner/{id}") + public ResponseEntity updateOwner( + @PathVariable Integer id, + @RequestBody @Valid AdminOwnerUpdateRequest request, + @Auth(permit = {ADMIN}) Integer adminId + ) { + AdminOwnerUpdateResponse adminOwnerUpdateResponse = adminUserService.updateOwner(id, request); + return ResponseEntity.ok().body(adminOwnerUpdateResponse); } @GetMapping("/admin/users/new-owners") public ResponseEntity getNewOwners( - @ModelAttribute NewOwnersCondition newOwnersCondition, + @ParameterObject @ModelAttribute OwnersCondition ownersCondition, + @Auth(permit = {ADMIN}) Integer adminId + ) { + return ResponseEntity.ok().body(adminUserService.getNewOwners(ownersCondition)); + } + + @GetMapping("/admin/users/owners") + public ResponseEntity getOwners( + @ParameterObject @ModelAttribute OwnersCondition ownersCondition, + @Auth(permit = {ADMIN}) Integer adminId + ) { + return ResponseEntity.ok().body(adminUserService.getOwners(ownersCondition)); + } + + @GetMapping("/admin/users/{id}") + public ResponseEntity getUser( + @PathVariable Integer id, + @Auth(permit = {ADMIN}) Integer adminId + ) { + return ResponseEntity.ok().body(adminUserService.getUser(id)); + } + + @DeleteMapping("/admin/users/{id}") + public ResponseEntity deleteUser( + @PathVariable Integer id, + @Auth(permit = {ADMIN}) Integer adminId + ) { + adminUserService.deleteUser(id); + return ResponseEntity.ok().build(); + } + + @PostMapping("/admin/users/{id}/undelete") + public ResponseEntity undeleteUser( + @PathVariable Integer id, @Auth(permit = {ADMIN}) Integer adminId ) { - return ResponseEntity.ok().body(adminUserService.getNewOwners(newOwnersCondition)); + adminUserService.undeleteUser(id); + return ResponseEntity.ok().build(); } } diff --git a/src/main/java/in/koreatech/koin/admin/user/dto/AdminLoginRequest.java b/src/main/java/in/koreatech/koin/admin/user/dto/AdminLoginRequest.java new file mode 100644 index 000000000..cd1e38b84 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/user/dto/AdminLoginRequest.java @@ -0,0 +1,22 @@ +package in.koreatech.koin.admin.user.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +public record AdminLoginRequest( + @Schema(description = "이메일", example = "abc@koreatech.ac.kr", requiredMode = REQUIRED) + @NotBlank(message = "이메일을 입력해주세요.") + String email, + + @Schema( + description = "SHA 256 해시 알고리즘으로 암호화된 비밀번호", + example = "cd06f8c2b0dd065faf6ef910c7f15934363df71c33740fd245590665286ed268", + requiredMode = REQUIRED + ) + @NotBlank(message = "비밀번호를 입력해주세요") + String password +) { + +} diff --git a/src/main/java/in/koreatech/koin/admin/user/dto/AdminLoginResponse.java b/src/main/java/in/koreatech/koin/admin/user/dto/AdminLoginResponse.java new file mode 100644 index 000000000..3cfd0ad15 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/user/dto/AdminLoginResponse.java @@ -0,0 +1,26 @@ +package in.koreatech.koin.admin.user.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record AdminLoginResponse ( + @Schema( + description = "Jwt accessToken", + example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c", + requiredMode = REQUIRED + ) + @JsonProperty("token") + String accessToken, + + @Schema(description = "Random UUID refresh token", example = "RANDOM-KEY-VALUE", requiredMode = REQUIRED) + @JsonProperty("refresh_token") + String refreshToken +) { + + public static AdminLoginResponse of(String token, String refreshToken) { + return new AdminLoginResponse(token, refreshToken); + } +} diff --git a/src/main/java/in/koreatech/koin/admin/user/dto/AdminOwnerResponse.java b/src/main/java/in/koreatech/koin/admin/user/dto/AdminOwnerResponse.java index 17eb9e3e0..75c3003a1 100644 --- a/src/main/java/in/koreatech/koin/admin/user/dto/AdminOwnerResponse.java +++ b/src/main/java/in/koreatech/koin/admin/user/dto/AdminOwnerResponse.java @@ -7,12 +7,16 @@ import java.util.List; import java.util.stream.Collectors; +import org.springframework.data.domain.Page; + import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; import com.fasterxml.jackson.databind.annotation.JsonNaming; import in.koreatech.koin.domain.owner.model.Owner; import in.koreatech.koin.domain.owner.model.OwnerAttachment; +import in.koreatech.koin.domain.owner.model.OwnerIncludingShop; +import in.koreatech.koin.global.model.Criteria; import io.swagger.v3.oas.annotations.media.Schema; @JsonNaming(value = SnakeCaseStrategy.class) diff --git a/src/main/java/in/koreatech/koin/admin/user/dto/AdminOwnerUpdateRequest.java b/src/main/java/in/koreatech/koin/admin/user/dto/AdminOwnerUpdateRequest.java new file mode 100644 index 000000000..237ddb8d7 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/user/dto/AdminOwnerUpdateRequest.java @@ -0,0 +1,46 @@ +package in.koreatech.koin.admin.user.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Size; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record AdminOwnerUpdateRequest ( + @Schema(description = "사업자 등록 번호", example = "012-34-56789", requiredMode = NOT_REQUIRED) + @Size(max = 12, message = "사업자 등록 번호는 12자 이하로 입력해주세요.") + String companyRegistrationNumber, + + @Schema(description = "이메일 주소", example = "koin123@koreatech.ac.kr", requiredMode = NOT_REQUIRED) + @Size(max = 100, message = "이메일의 길이는 최대 100자입니다") + String email, + + @Schema(description = "성별(남:0, 여:1)", example = "1", requiredMode = NOT_REQUIRED) + Integer gender, + + @Schema(description = "상점 수정 권한", example = "false", requiredMode = NOT_REQUIRED) + Boolean grantShop, + + @Schema(description = "이벤트 수정 권한", example = "false", requiredMode = NOT_REQUIRED) + Boolean grantEvent, + + @Schema(description = "이름", example = "최준호", requiredMode = NOT_REQUIRED) + @Size(max = 50, message = "이름의 길이는 최대 50자 입니다.") + String name, + + @Schema(description = "닉네임", example = "juno", requiredMode = NOT_REQUIRED) + @Size(max = 10, message = "닉네임은 10자 이내여야 합니다.") + String nickname, + + @Schema(description = "사장님 전화번호", example = "01012345678", requiredMode = NOT_REQUIRED) + @Size(max = 20, message = "휴대전화의 길이는 최대 20자 입니다") + String phoneNumber, + + @Schema(description = "비밀번호", example = "a0240120305812krlakdsflsa;1235", requiredMode = NOT_REQUIRED) + String password +) { + +} diff --git a/src/main/java/in/koreatech/koin/admin/user/dto/AdminOwnerUpdateResponse.java b/src/main/java/in/koreatech/koin/admin/user/dto/AdminOwnerUpdateResponse.java new file mode 100644 index 000000000..f288e6fa3 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/user/dto/AdminOwnerUpdateResponse.java @@ -0,0 +1,49 @@ +package in.koreatech.koin.admin.user.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.owner.model.Owner; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record AdminOwnerUpdateResponse ( + @Schema(description = "사업자 등록 번호", example = "012-34-56789", requiredMode = NOT_REQUIRED) + String companyRegistrationNumber, + + @Schema(description = "이메일 주소", example = "koin123@koreatech.ac.kr", requiredMode = NOT_REQUIRED) + String email, + + @Schema(description = "성별(남:0, 여:1)", example = "1", requiredMode = NOT_REQUIRED) + Integer gender, + + @Schema(description = "상점 수정 권한", example = "false", requiredMode = NOT_REQUIRED) + Boolean grantShop, + + @Schema(description = "이벤트 수정 권한", example = "false", requiredMode = NOT_REQUIRED) + Boolean grantEvent, + + @Schema(description = "이름", example = "최준호", requiredMode = NOT_REQUIRED) + String name, + + @Schema(description = "닉네임", example = "juno", requiredMode = NOT_REQUIRED) + String nickname, + + @Schema(description = "사장님 전화번호", example = "01012345678", requiredMode = NOT_REQUIRED) + String phoneNumber +) { + public static AdminOwnerUpdateResponse from(Owner owner) { + return new AdminOwnerUpdateResponse( + owner.getCompanyRegistrationNumber(), + owner.getUser().getEmail(), + owner.getUser().getGender().ordinal(), + owner.isGrantShop(), + owner.isGrantEvent(), + owner.getUser().getName(), + owner.getUser().getNickname(), + owner.getAccount() + ); + } +} diff --git a/src/main/java/in/koreatech/koin/admin/user/dto/AdminOwnersResponse.java b/src/main/java/in/koreatech/koin/admin/user/dto/AdminOwnersResponse.java new file mode 100644 index 000000000..8d2f112bc --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/user/dto/AdminOwnersResponse.java @@ -0,0 +1,79 @@ +package in.koreatech.koin.admin.user.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.data.domain.Page; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.owner.model.OwnerIncludingShop; +import in.koreatech.koin.domain.user.model.User; +import in.koreatech.koin.global.model.Criteria; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(value = PropertyNamingStrategies.SnakeCaseStrategy.class) +public record AdminOwnersResponse( + @Schema(description = "조건에 해당하는 총 사장님의 수", example = "57", requiredMode = REQUIRED) + Long totalCount, + + @Schema(description = "조건에 해당하는 사장님중에 현재 페이지에서 조회된 수", example = "10", requiredMode = REQUIRED) + Integer currentCount, + + @Schema(description = "조건에 해당하는 사장님들을 조회할 수 있는 최대 페이지", example = "6", requiredMode = REQUIRED) + Integer totalPage, + + @Schema(description = "현재 페이지", example = "2", requiredMode = REQUIRED) + Integer currentPage, + + @Schema(description = "사장님 리스트", requiredMode = REQUIRED) + List owners +) { + @JsonNaming(value = PropertyNamingStrategies.SnakeCaseStrategy.class) + public record InnerOwnersResponse( + @Schema(description = "고유 id", requiredMode = REQUIRED) + Integer id, + + @Schema(description = "이메일", requiredMode = REQUIRED) + String email, + + @Schema(description = "이름", requiredMode = NOT_REQUIRED) + String name, + + @Schema(description = "전화번호", requiredMode = NOT_REQUIRED) + String phoneNumber, + + @Schema(description = "가입 신청 일자", requiredMode = REQUIRED) + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + LocalDateTime createdAt + ) { + public static InnerOwnersResponse from(OwnerIncludingShop ownerIncludingShop) { + User user = ownerIncludingShop.getOwner().getUser(); + return new InnerOwnersResponse( + user.getId(), + user.getEmail(), + user.getName(), + user.getPhoneNumber(), + user.getCreatedAt() + ); + } + } + + public static AdminOwnersResponse of(Page pagedResult, Criteria criteria) { + return new AdminOwnersResponse( + pagedResult.getTotalElements(), + pagedResult.getContent().size(), + pagedResult.getTotalPages(), + criteria.getPage() + 1, + pagedResult.getContent().stream() + .map(InnerOwnersResponse::from) + .collect(Collectors.toList()) + ); + } +} diff --git a/src/main/java/in/koreatech/koin/admin/user/dto/AdminStudentUpdateRequest.java b/src/main/java/in/koreatech/koin/admin/user/dto/AdminStudentUpdateRequest.java index 238f47cba..e21faf23f 100644 --- a/src/main/java/in/koreatech/koin/admin/user/dto/AdminStudentUpdateRequest.java +++ b/src/main/java/in/koreatech/koin/admin/user/dto/AdminStudentUpdateRequest.java @@ -14,7 +14,7 @@ public record AdminStudentUpdateRequest( Integer gender, @Schema(description = "[NOT UPDATE]신원(학생, 사장님)", example = "학생", requiredMode = NOT_REQUIRED) - Integer userIdentity, + Integer identity, @Schema(description = "[NOT UPDATE]졸업 여부(true, false)", example = "false", requiredMode = NOT_REQUIRED) Boolean isGraduated, @@ -47,6 +47,7 @@ public record AdminStudentUpdateRequest( String nickname, @Schema(description = "휴대폰 번호", example = "010-0000-0000", requiredMode = NOT_REQUIRED) + @Size(max = 20, message = "휴대전화의 길이는 최대 20자 입니다") String phoneNumber, @Size(min = 10, max = 10, message = "학번은 10자여야 합니다.") diff --git a/src/main/java/in/koreatech/koin/admin/user/dto/AdminStudentUpdateResponse.java b/src/main/java/in/koreatech/koin/admin/user/dto/AdminStudentUpdateResponse.java index 50de2fc79..a1fc6a3b1 100644 --- a/src/main/java/in/koreatech/koin/admin/user/dto/AdminStudentUpdateResponse.java +++ b/src/main/java/in/koreatech/koin/admin/user/dto/AdminStudentUpdateResponse.java @@ -48,6 +48,7 @@ public record AdminStudentUpdateResponse( @Schema(description = "학번", example = "2029136012", requiredMode = NOT_REQUIRED) String studentNumber ) { + public static AdminStudentUpdateResponse from(Student student) { User user = student.getUser(); diff --git a/src/main/java/in/koreatech/koin/admin/user/dto/AdminStudentsResponse.java b/src/main/java/in/koreatech/koin/admin/user/dto/AdminStudentsResponse.java new file mode 100644 index 000000000..3dbb2734e --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/user/dto/AdminStudentsResponse.java @@ -0,0 +1,53 @@ +package in.koreatech.koin.admin.user.dto; + +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.data.domain.Page; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.user.model.Student; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record AdminStudentsResponse( + Integer currentCount, + Integer currentPage, + List students, + Long totalCount, + Integer totalPage +) { + + @JsonNaming(value = SnakeCaseStrategy.class) + public record StudentInfo( + String email, + Integer id, + String major, + String name, + String nickname, + String studentNumber + ) { + + public static StudentInfo fromStudent(Student student) { + return new StudentInfo( + student.getUser().getEmail(), + student.getUser().getId(), + student.getDepartment(), + student.getUser().getName(), + student.getUser().getNickname(), + student.getStudentNumber() + ); + } + } + + public static AdminStudentsResponse from(Page studentsPage) { + return new AdminStudentsResponse( + studentsPage.getNumberOfElements(), + studentsPage.getNumber() + 1, + studentsPage.getContent().stream().map(StudentInfo::fromStudent).collect(Collectors.toList()), + studentsPage.getTotalElements(), + studentsPage.getTotalPages() + ); + } +} diff --git a/src/main/java/in/koreatech/koin/admin/user/dto/AdminTokenRefreshRequest.java b/src/main/java/in/koreatech/koin/admin/user/dto/AdminTokenRefreshRequest.java new file mode 100644 index 000000000..aac0790f4 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/user/dto/AdminTokenRefreshRequest.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.admin.user.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record AdminTokenRefreshRequest ( + @Schema(description = "refresh_token", example = "eyJhbGciOiJIUzI1NiJ9", requiredMode = REQUIRED) + @NotNull(message = "refresh_token을 입력해주세요.") + @JsonProperty("refresh_token") + String refreshToken +) { + +} diff --git a/src/main/java/in/koreatech/koin/admin/user/dto/AdminTokenRefreshResponse.java b/src/main/java/in/koreatech/koin/admin/user/dto/AdminTokenRefreshResponse.java new file mode 100644 index 000000000..41a8ad45a --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/user/dto/AdminTokenRefreshResponse.java @@ -0,0 +1,26 @@ +package in.koreatech.koin.admin.user.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record AdminTokenRefreshResponse ( + @Schema( + description = "Jwt accessToken", + example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c", + requiredMode = REQUIRED + ) + @JsonProperty("token") + String accessToken, + + @Schema(description = "Random UUID refreshToken", example = "RANDOM-KEY-VALUE", requiredMode = REQUIRED) + @JsonProperty("refresh_token") + String refreshToken +) { + + public static AdminTokenRefreshResponse of(String accessToken, String refreshToken) { + return new AdminTokenRefreshResponse(accessToken, refreshToken); + } +} diff --git a/src/main/java/in/koreatech/koin/admin/user/dto/NewOwnersCondition.java b/src/main/java/in/koreatech/koin/admin/user/dto/OwnersCondition.java similarity index 97% rename from src/main/java/in/koreatech/koin/admin/user/dto/NewOwnersCondition.java rename to src/main/java/in/koreatech/koin/admin/user/dto/OwnersCondition.java index d11bdb2c5..12a3ebace 100644 --- a/src/main/java/in/koreatech/koin/admin/user/dto/NewOwnersCondition.java +++ b/src/main/java/in/koreatech/koin/admin/user/dto/OwnersCondition.java @@ -17,7 +17,7 @@ import io.swagger.v3.oas.annotations.media.Schema; @JsonNaming(value = SnakeCaseStrategy.class) -public record NewOwnersCondition( +public record OwnersCondition( @Schema(description = "페이지", example = "1", defaultValue = "1", requiredMode = NOT_REQUIRED) Integer page, @@ -33,7 +33,7 @@ public record NewOwnersCondition( @Schema(description = "정렬 기준['CREATED_AT_ASC` (오래된순), 'CREATED_AT_DESC` (최신순)]", example = "CREATED_AT_ASC", defaultValue = "CREATED_AT_ASC", requiredMode = NOT_REQUIRED) Sort sort ) { - public NewOwnersCondition { + public OwnersCondition { if (Objects.isNull(page)) { page = Criteria.DEFAULT_PAGE; } diff --git a/src/main/java/in/koreatech/koin/admin/user/dto/StudentsCondition.java b/src/main/java/in/koreatech/koin/admin/user/dto/StudentsCondition.java new file mode 100644 index 000000000..9da9fd9e4 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/user/dto/StudentsCondition.java @@ -0,0 +1,39 @@ +package in.koreatech.koin.admin.user.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; + +import java.util.Objects; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.global.model.Criteria; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record StudentsCondition ( + @Schema(description = "페이지", example = "1", defaultValue = "1", requiredMode = NOT_REQUIRED) + Integer page, + + @Schema(description = "페이지당 조회할 최대 개수", example = "10", defaultValue = "10", requiredMode = NOT_REQUIRED) + Integer limit, + + @Schema(description = "인증 되었는지 여부", requiredMode = NOT_REQUIRED) + Boolean isAuthed, + + @Schema(description = "닉네임", requiredMode = NOT_REQUIRED) + String nickname, + + @Schema(description = "이메일", requiredMode = NOT_REQUIRED) + String email +) { + + public StudentsCondition { + if (Objects.isNull(page)) { + page = Criteria.DEFAULT_PAGE; + } + if (Objects.isNull(limit)) { + limit = Criteria.DEFAULT_LIMIT; + } + } +} diff --git a/src/main/java/in/koreatech/koin/admin/user/repository/AdminOwnerRepository.java b/src/main/java/in/koreatech/koin/admin/user/repository/AdminOwnerRepository.java index 2d7ae918e..d42c6d94b 100644 --- a/src/main/java/in/koreatech/koin/admin/user/repository/AdminOwnerRepository.java +++ b/src/main/java/in/koreatech/koin/admin/user/repository/AdminOwnerRepository.java @@ -10,6 +10,7 @@ import in.koreatech.koin.domain.owner.exception.OwnerNotFoundException; import in.koreatech.koin.domain.owner.model.Owner; import in.koreatech.koin.domain.owner.model.OwnerIncludingShop; +import in.koreatech.koin.domain.user.model.UserType; import io.lettuce.core.dynamic.annotation.Param; public interface AdminOwnerRepository extends Repository { @@ -18,6 +19,8 @@ public interface AdminOwnerRepository extends Repository { Owner save(Owner owner); + void deleteById(Integer ownerId); + @Query(""" SELECT COUNT(o) FROM Owner o WHERE o.user.userType = 'OWNER' @@ -25,6 +28,8 @@ SELECT COUNT(o) FROM Owner o """) Integer findUnauthenticatedOwnersCount(); + Integer countByUserUserType(UserType userType); + @Query(""" SELECT new in.koreatech.koin.domain.owner.model.OwnerIncludingShop(o, s.id, s.name) FROM Owner o diff --git a/src/main/java/in/koreatech/koin/admin/user/repository/AdminOwnerShopRedisRepository.java b/src/main/java/in/koreatech/koin/admin/user/repository/AdminOwnerShopRedisRepository.java new file mode 100644 index 000000000..7b95e3371 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/user/repository/AdminOwnerShopRedisRepository.java @@ -0,0 +1,14 @@ +package in.koreatech.koin.admin.user.repository; + +import java.util.Optional; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.owner.model.OwnerShop; + +public interface AdminOwnerShopRedisRepository extends Repository { + + Optional findById(Integer ownerId); + + void deleteById(Integer ownerId); +} diff --git a/src/main/java/in/koreatech/koin/admin/user/repository/AdminShopRepository.java b/src/main/java/in/koreatech/koin/admin/user/repository/AdminShopRepository.java deleted file mode 100644 index 98b6823af..000000000 --- a/src/main/java/in/koreatech/koin/admin/user/repository/AdminShopRepository.java +++ /dev/null @@ -1,12 +0,0 @@ -package in.koreatech.koin.admin.user.repository; - -import java.util.List; - -import org.springframework.data.repository.Repository; - -import in.koreatech.koin.domain.shop.model.Shop; - -public interface AdminShopRepository extends Repository { - - List findAllByOwnerId(Integer ownerId); -} diff --git a/src/main/java/in/koreatech/koin/admin/user/repository/AdminStudentRepository.java b/src/main/java/in/koreatech/koin/admin/user/repository/AdminStudentRepository.java index 30b108fd2..cfeaf8657 100644 --- a/src/main/java/in/koreatech/koin/admin/user/repository/AdminStudentRepository.java +++ b/src/main/java/in/koreatech/koin/admin/user/repository/AdminStudentRepository.java @@ -2,8 +2,13 @@ import java.util.Optional; +import org.springframework.data.domain.Page; +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 in.koreatech.koin.admin.user.dto.StudentsCondition; import in.koreatech.koin.domain.user.exception.UserNotFoundException; import in.koreatech.koin.domain.user.model.Student; @@ -11,10 +16,24 @@ public interface AdminStudentRepository extends Repository { Student save(Student student); + void deleteById(Integer userId); + Optional findById(Integer userId); default Student getById(Integer userId) { return findById(userId) .orElseThrow(() -> UserNotFoundException.withDetail("userId: " + userId)); } + + @Query(" SELECT COUNT(s) FROM Student s ") + Integer findAllStudentCount(); + + @Query( + """ + SELECT s FROM Student s WHERE\s + (:#{#condition.isAuthed} IS NULL OR s.user.isAuthed = :#{#condition.isAuthed}) AND + (:#{#condition.nickname} IS NULL OR s.user.nickname LIKE CONCAT('%', :#{#condition.nickname}, '%')) AND + (:#{#condition.email} IS NULL OR s.user.email LIKE CONCAT('%', :#{#condition.email}, '%')) + """) + Page findByConditions(@Param("condition") StudentsCondition condition, Pageable pageable); } diff --git a/src/main/java/in/koreatech/koin/admin/user/repository/AdminTokenRepository.java b/src/main/java/in/koreatech/koin/admin/user/repository/AdminTokenRepository.java new file mode 100644 index 000000000..e1b961d24 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/user/repository/AdminTokenRepository.java @@ -0,0 +1,22 @@ +package in.koreatech.koin.admin.user.repository; + +import java.util.Optional; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.user.model.UserToken; +import in.koreatech.koin.global.exception.KoinIllegalArgumentException; + +public interface AdminTokenRepository extends Repository { + + UserToken save(UserToken userToken); + + Optional findById(Integer userId); + + void deleteById(Integer id); + + default UserToken getById(Integer userId) { + return findById(userId) + .orElseThrow(() -> new KoinIllegalArgumentException("refresh token이 존재하지 않습니다. ", "userId: " + userId)); + } +} diff --git a/src/main/java/in/koreatech/koin/admin/user/repository/AdminUserRepository.java b/src/main/java/in/koreatech/koin/admin/user/repository/AdminUserRepository.java index 7718b52f3..83796bcb0 100644 --- a/src/main/java/in/koreatech/koin/admin/user/repository/AdminUserRepository.java +++ b/src/main/java/in/koreatech/koin/admin/user/repository/AdminUserRepository.java @@ -11,8 +11,17 @@ public interface AdminUserRepository extends Repository { User save(User user); + Optional findByEmail(String Email); + Optional findById(Integer id); + default User getByEmail(String email) { + return findByEmail(email) + .orElseThrow(() -> UserNotFoundException.withDetail("email: " + email)); + } + + void delete(User user); + default User getById(Integer userId) { return findById(userId) .orElseThrow(() -> UserNotFoundException.withDetail("userId: " + userId)); diff --git a/src/main/java/in/koreatech/koin/admin/user/service/AdminUserService.java b/src/main/java/in/koreatech/koin/admin/user/service/AdminUserService.java index 272c0fd22..636fce11f 100644 --- a/src/main/java/in/koreatech/koin/admin/user/service/AdminUserService.java +++ b/src/main/java/in/koreatech/koin/admin/user/service/AdminUserService.java @@ -1,34 +1,59 @@ package in.koreatech.koin.admin.user.service; +import static in.koreatech.koin.domain.user.model.UserType.ADMIN; + +import java.util.List; + import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; -import java.util.List; -import java.util.stream.Collectors; + +import java.time.LocalDateTime; +import java.util.Optional; +import java.util.Objects; +import java.util.UUID; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import in.koreatech.koin.admin.shop.repository.AdminShopRepository; +import in.koreatech.koin.admin.user.dto.AdminLoginRequest; +import in.koreatech.koin.admin.user.dto.AdminLoginResponse; import in.koreatech.koin.admin.user.dto.AdminNewOwnersResponse; import in.koreatech.koin.admin.user.dto.AdminOwnerResponse; +import in.koreatech.koin.admin.user.dto.AdminOwnerUpdateRequest; +import in.koreatech.koin.admin.user.dto.AdminOwnerUpdateResponse; +import in.koreatech.koin.admin.user.dto.AdminOwnersResponse; import in.koreatech.koin.admin.user.dto.AdminStudentResponse; import in.koreatech.koin.admin.user.dto.AdminStudentUpdateRequest; import in.koreatech.koin.admin.user.dto.AdminStudentUpdateResponse; -import in.koreatech.koin.admin.user.dto.NewOwnersCondition; +import in.koreatech.koin.admin.user.dto.OwnersCondition; +import in.koreatech.koin.admin.user.dto.AdminStudentsResponse; +import in.koreatech.koin.admin.user.dto.AdminTokenRefreshRequest; +import in.koreatech.koin.admin.user.dto.AdminTokenRefreshResponse; +import in.koreatech.koin.admin.user.dto.StudentsCondition; import in.koreatech.koin.admin.user.repository.AdminOwnerRepository; -import in.koreatech.koin.admin.user.repository.AdminShopRepository; +import in.koreatech.koin.admin.user.repository.AdminOwnerShopRedisRepository; import in.koreatech.koin.admin.user.repository.AdminStudentRepository; +import in.koreatech.koin.admin.user.repository.AdminTokenRepository; import in.koreatech.koin.admin.user.repository.AdminUserRepository; -import in.koreatech.koin.domain.owner.model.OwnerIncludingShop; import in.koreatech.koin.domain.owner.model.Owner; +import in.koreatech.koin.domain.owner.model.OwnerIncludingShop; +import in.koreatech.koin.domain.owner.model.OwnerShop; import in.koreatech.koin.domain.shop.model.Shop; import in.koreatech.koin.domain.user.exception.DuplicationNicknameException; import in.koreatech.koin.domain.user.exception.StudentDepartmentNotValidException; +import in.koreatech.koin.domain.user.exception.UserNotFoundException; import in.koreatech.koin.domain.user.model.Student; import in.koreatech.koin.domain.user.model.StudentDepartment; import in.koreatech.koin.domain.user.model.User; import in.koreatech.koin.domain.user.model.UserGender; +import in.koreatech.koin.domain.user.model.UserToken; +import in.koreatech.koin.domain.user.model.UserType; +import in.koreatech.koin.global.auth.JwtProvider; +import in.koreatech.koin.global.auth.exception.AuthorizationException; +import in.koreatech.koin.global.exception.KoinIllegalArgumentException; import in.koreatech.koin.global.model.Criteria; import lombok.RequiredArgsConstructor; @@ -37,11 +62,86 @@ @Transactional(readOnly = true) public class AdminUserService { + private final JwtProvider jwtProvider; private final AdminStudentRepository adminStudentRepository; private final AdminOwnerRepository adminOwnerRepository; + private final AdminOwnerShopRedisRepository adminOwnerShopRedisRepository; private final AdminUserRepository adminUserRepository; private final AdminShopRepository adminShopRepository; private final PasswordEncoder passwordEncoder; + private final AdminTokenRepository adminTokenRepository; + + public AdminStudentsResponse getStudents(StudentsCondition studentsCondition) { + Integer totalStudents = adminStudentRepository.findAllStudentCount(); + Criteria criteria = Criteria.of(studentsCondition.page(), studentsCondition.limit(), totalStudents); + + PageRequest pageRequest = PageRequest.of(criteria.getPage(), criteria.getLimit()); + Page studentsPage = adminStudentRepository.findByConditions(studentsCondition, pageRequest); + + return AdminStudentsResponse.from(studentsPage); + } + + @Transactional + public AdminLoginResponse adminLogin(AdminLoginRequest request) { + User user = adminUserRepository.getByEmail(request.email()); + + /* 어드민 권한이 없으면 없는 회원으로 간주 */ + if(user.getUserType() != ADMIN) { + throw UserNotFoundException.withDetail("email" + request.email()); + } + + if (!user.isSamePassword(passwordEncoder, request.password())) { + throw new KoinIllegalArgumentException("비밀번호가 틀렸습니다."); + } + + String accessToken = jwtProvider.createToken(user); + String refreshToken = String.format("%s-%d", UUID.randomUUID(), user.getId()); + UserToken savedtoken = adminTokenRepository.save(UserToken.create(user.getId(), refreshToken)); + user.updateLastLoggedTime(LocalDateTime.now()); + + return AdminLoginResponse.of(accessToken, savedtoken.getRefreshToken()); + } + + @Transactional + public void adminLogout(Integer adminId) { + adminTokenRepository.deleteById(adminId); + } + + public AdminTokenRefreshResponse adminRefresh(AdminTokenRefreshRequest request) { + String adminId = getAdminId(request.refreshToken()); + UserToken userToken = adminTokenRepository.getById(Integer.parseInt(adminId)); + if (!Objects.equals(userToken.getRefreshToken(), request.refreshToken())) { + throw new KoinIllegalArgumentException("refresh token이 일치하지 않습니다.", "request: " + request); + } + User user = adminUserRepository.getById(userToken.getId()); + + String accessToken = jwtProvider.createToken(user); + return AdminTokenRefreshResponse.of(accessToken, userToken.getRefreshToken()); + } + + private String getAdminId(String refreshToken) { + String[] split = refreshToken.split("-"); + if (split.length == 0) { + throw new AuthorizationException("올바르지 않은 인증 토큰입니다. refreshToken: " + refreshToken); + } + return split[split.length - 1]; + } + + @Transactional + public void allowOwnerPermission(Integer id) { + Owner owner = adminOwnerRepository.getById(id); + owner.getUser().auth(); + Optional ownerShop = adminOwnerShopRedisRepository.findById(id); + if (ownerShop.isPresent()) { + Integer shopId = ownerShop.get().getShopId(); + if (shopId != null) { + Shop shop = adminShopRepository.getById(shopId); + shop.updateOwner(owner); + owner.setGrantShop(true); + } + adminOwnerShopRedisRepository.deleteById(id); + } + } public AdminStudentResponse getStudent(Integer userId) { Student student = adminStudentRepository.getById(userId); @@ -63,30 +163,48 @@ public AdminStudentUpdateResponse updateStudent(Integer id, AdminStudentUpdateRe return AdminStudentUpdateResponse.from(student); } - public AdminNewOwnersResponse getNewOwners(NewOwnersCondition newOwnersCondition) { - newOwnersCondition.checkDataConstraintViolation(); + public AdminNewOwnersResponse getNewOwners(OwnersCondition ownersCondition) { + ownersCondition.checkDataConstraintViolation(); - // page > totalPage인 경우 totalPage로 조회하기 위해 Integer totalOwners = adminOwnerRepository.findUnauthenticatedOwnersCount(); - Criteria criteria = Criteria.of(newOwnersCondition.page(), newOwnersCondition.limit(), totalOwners); - Sort.Direction direction = newOwnersCondition.getDirection(); + Criteria criteria = Criteria.of(ownersCondition.page(), ownersCondition.limit(), totalOwners); + Sort.Direction direction = ownersCondition.getDirection(); + + Page result = getResultPage(ownersCondition, criteria, direction); + + return AdminNewOwnersResponse.of(result, criteria); + } + public AdminOwnersResponse getOwners(OwnersCondition ownersCondition) { + ownersCondition.checkDataConstraintViolation(); + + Integer totalOwners = adminOwnerRepository.countByUserUserType(UserType.OWNER); + Criteria criteria = Criteria.of(ownersCondition.page(), ownersCondition.limit(), totalOwners); + Sort.Direction direction = ownersCondition.getDirection(); + + Page result = getResultPage(ownersCondition, criteria, direction); + + return AdminOwnersResponse.of(result, criteria); + } + + private Page getResultPage(OwnersCondition ownersCondition, Criteria criteria, Sort.Direction direction) { PageRequest pageRequest = PageRequest.of(criteria.getPage(), criteria.getLimit(), Sort.by(direction, "user.createdAt")); Page result; - if (newOwnersCondition.searchType() == NewOwnersCondition.SearchType.EMAIL) { - result = adminOwnerRepository.findPageUnauthenticatedOwnersByEmail(newOwnersCondition.query(), pageRequest); - } else if (newOwnersCondition.searchType() == NewOwnersCondition.SearchType.NAME) { - result = adminOwnerRepository.findPageUnauthenticatedOwnersByName(newOwnersCondition.query(), pageRequest); + if (ownersCondition.searchType() == OwnersCondition.SearchType.EMAIL) { + result = adminOwnerRepository.findPageUnauthenticatedOwnersByEmail(ownersCondition.query(), pageRequest); + } else if (ownersCondition.searchType() == OwnersCondition.SearchType.NAME) { + result = adminOwnerRepository.findPageUnauthenticatedOwnersByName(ownersCondition.query(), pageRequest); } else { result = adminOwnerRepository.findPageUnauthenticatedOwners(pageRequest); } - return AdminNewOwnersResponse.of(result, criteria); + return result; } + private void validateNicknameDuplication(String nickname, Integer userId) { if (nickname != null && adminUserRepository.existsByNicknameAndIdNot(nickname, userId)) { @@ -106,8 +224,37 @@ public AdminOwnerResponse getOwner(Integer ownerId) { List shopsId = adminShopRepository.findAllByOwnerId(ownerId) .stream() .map(Shop::getId) - .collect(Collectors.toList()); + .toList(); return AdminOwnerResponse.of(owner, shopsId); } + + @Transactional + public AdminOwnerUpdateResponse updateOwner(Integer ownerId, AdminOwnerUpdateRequest request) { + Owner owner = adminOwnerRepository.getById(ownerId); + owner.update(request); + return AdminOwnerUpdateResponse.from(owner); + } + + @Transactional + public User getUser(Integer userId) { + return adminUserRepository.getById(userId); + } + + @Transactional + public void deleteUser(Integer userId) { + User user = adminUserRepository.getById(userId); + if (user.getUserType() == UserType.STUDENT) { + adminStudentRepository.deleteById(userId); + } else if (user.getUserType() == UserType.OWNER) { + adminOwnerRepository.deleteById(userId); + } + adminUserRepository.delete(user); + } + + @Transactional + public void undeleteUser(Integer id) { + User user = adminUserRepository.getById(id); + user.undelete(); + } } diff --git a/src/main/java/in/koreatech/koin/domain/bus/model/city/CityBusRoute.java b/src/main/java/in/koreatech/koin/domain/bus/model/city/CityBusRoute.java new file mode 100644 index 000000000..c5edee27b --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/model/city/CityBusRoute.java @@ -0,0 +1,14 @@ +package in.koreatech.koin.domain.bus.model.city; + +import lombok.Builder; + +@Builder +public record CityBusRoute( + String endnodenm, // 종점, 병천3리 + String routeid, // 노선 ID, CAB285000142 + Long routeno, // 노선 번호, 400 + String routetp, // 노선 유형, 일반버스 + String startnodenm // 기점, 종합터미널 +) { + +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/model/city/CityBusRouteCache.java b/src/main/java/in/koreatech/koin/domain/bus/model/city/CityBusRouteCache.java new file mode 100644 index 000000000..57fbb35fd --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/model/city/CityBusRouteCache.java @@ -0,0 +1,46 @@ +package in.koreatech.koin.domain.bus.model.city; + +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +import org.springframework.data.redis.core.RedisHash; +import org.springframework.data.redis.core.TimeToLive; + +import jakarta.persistence.Id; +import lombok.Builder; +import lombok.Getter; + +@Getter +@RedisHash("CityBusRoute") +public class CityBusRouteCache { + + private static final long CACHE_EXPIRE_MINUTE = 2L; + + @Id + private final String id; + + private final Set busNumbers = new HashSet<>(); + + @TimeToLive(unit = TimeUnit.MINUTES) + private final Long expiration; + + @Builder + private CityBusRouteCache(String id, Set busNumbers, Long expiration) { + this.id = id; + this.busNumbers.addAll(busNumbers); + this.expiration = expiration; + } + + public static CityBusRouteCache of(String nodeId, Set busRoutes) { + return CityBusRouteCache.builder() + .id(nodeId) + .busNumbers(busRoutes.stream() + .map(CityBusRoute::routeno) + .collect(Collectors.toSet()) + ) + .expiration(CACHE_EXPIRE_MINUTE) + .build(); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/model/enums/BusStation.java b/src/main/java/in/koreatech/koin/domain/bus/model/enums/BusStation.java index 616ceac1b..684b286f0 100644 --- a/src/main/java/in/koreatech/koin/domain/bus/model/enums/BusStation.java +++ b/src/main/java/in/koreatech/koin/domain/bus/model/enums/BusStation.java @@ -41,7 +41,7 @@ public static BusDirection getDirection(BusStation depart, BusStation arrival) { return BusDirection.NORTH; } - public String getNodeId(BusDirection direction) { + public List getNodeId(BusDirection direction) { return node.getId(direction); } diff --git a/src/main/java/in/koreatech/koin/domain/bus/model/enums/BusStationNode.java b/src/main/java/in/koreatech/koin/domain/bus/model/enums/BusStationNode.java index 4edb945fe..cd84ec1df 100644 --- a/src/main/java/in/koreatech/koin/domain/bus/model/enums/BusStationNode.java +++ b/src/main/java/in/koreatech/koin/domain/bus/model/enums/BusStationNode.java @@ -15,24 +15,24 @@ */ @Getter public enum BusStationNode { - TERMINAL(Map.of(NORTH, "CAB285000686", SOUTH, "CAB285000685")), // 종합터미널 - KOREATECH(Map.of(NORTH, "CAB285000406", SOUTH, "CAB285000405")), // 코리아텍 - STATION(Map.of(NORTH, "CAB285000655", SOUTH, "CAB285000656")), // 천안역 동부광장 + TERMINAL(Map.of(NORTH, List.of("CAB285000686"), SOUTH, List.of("CAB285000685", "CAB285010125"))), // 종합터미널 + KOREATECH(Map.of(NORTH, List.of("CAB285000406"), SOUTH, List.of("CAB285000405"))), // 코리아텍 + STATION(Map.of(NORTH, List.of("CAB285000655"), SOUTH, List.of("CAB285000656"))), // 천안역 동부광장 ; - private final Map node; + private final Map> node; - BusStationNode(Map node) { + BusStationNode(Map> node) { this.node = node; } - public String getId(BusDirection direction) { + public List getId(BusDirection direction) { return node.get(direction); } public static List getNodeIds() { return Arrays.stream(values()) - .flatMap(station -> station.node.values().stream()) + .flatMap(station -> station.node.values().stream().flatMap(List::stream)) .toList(); } } diff --git a/src/main/java/in/koreatech/koin/domain/bus/repository/CityBusRouteCacheRepository.java b/src/main/java/in/koreatech/koin/domain/bus/repository/CityBusRouteCacheRepository.java new file mode 100644 index 000000000..38c11e51e --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/repository/CityBusRouteCacheRepository.java @@ -0,0 +1,22 @@ +package in.koreatech.koin.domain.bus.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.bus.exception.BusCacheNotFoundException; +import in.koreatech.koin.domain.bus.model.city.CityBusRouteCache; + +public interface CityBusRouteCacheRepository extends Repository { + + CityBusRouteCache save(CityBusRouteCache cityBusRouteCache); + + List findAll(); + + Optional findById(String nodeId); + + default CityBusRouteCache getById(String nodeId) { + return findById(nodeId).orElseThrow(() -> BusCacheNotFoundException.withDetail("nodeId: " + nodeId)); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/service/BusService.java b/src/main/java/in/koreatech/koin/domain/bus/service/BusService.java index 240aa0569..ed2a3afc5 100644 --- a/src/main/java/in/koreatech/koin/domain/bus/service/BusService.java +++ b/src/main/java/in/koreatech/koin/domain/bus/service/BusService.java @@ -12,6 +12,7 @@ import java.util.Comparator; import java.util.List; import java.util.Locale; +import java.util.Set; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -33,6 +34,7 @@ import in.koreatech.koin.domain.bus.model.mongo.Route; import in.koreatech.koin.domain.bus.repository.BusRepository; import in.koreatech.koin.domain.bus.util.CityBusClient; +import in.koreatech.koin.domain.bus.util.CityBusRouteClient; import in.koreatech.koin.domain.bus.util.TmoneyExpressBusClient; import in.koreatech.koin.domain.version.dto.VersionResponse; import in.koreatech.koin.domain.version.service.VersionService; @@ -48,6 +50,7 @@ public class BusService { private final BusRepository busRepository; private final CityBusClient cityBusClient; private final TmoneyExpressBusClient tmoneyExpressBusClient; + private final CityBusRouteClient cityBusRouteClient; private final VersionService versionService; @Transactional @@ -57,7 +60,20 @@ public BusRemainTimeResponse getBusRemainTime(BusType busType, BusStation depart if (busType == BusType.CITY) { // 시내버스에서 상행, 하행 구분할때 사용하는 로직 BusDirection direction = getDirection(depart, arrival); + + Set departAvailableBusNumbers = cityBusRouteClient.getAvailableCityBus(depart.getNodeId(direction)); + Set arrivalAvailableBusNumbers = cityBusRouteClient.getAvailableCityBus(arrival.getNodeId(direction)); + + departAvailableBusNumbers.retainAll(arrivalAvailableBusNumbers); + var remainTimes = cityBusClient.getBusRemainTime(depart.getNodeId(direction)); + + remainTimes = remainTimes.stream() + .filter(remainTime -> + departAvailableBusNumbers.contains(remainTime.getBusNumber()) + ) + .toList(); + return toResponse(busType, remainTimes); } diff --git a/src/main/java/in/koreatech/koin/domain/bus/util/BusScheduler.java b/src/main/java/in/koreatech/koin/domain/bus/util/BusScheduler.java index b5c712051..f000eeb61 100644 --- a/src/main/java/in/koreatech/koin/domain/bus/util/BusScheduler.java +++ b/src/main/java/in/koreatech/koin/domain/bus/util/BusScheduler.java @@ -13,11 +13,13 @@ public class BusScheduler { private final CityBusClient cityBusClient; private final TmoneyExpressBusClient tmoneyExpressBusClient; + private final CityBusRouteClient cityBusRouteClient; @Scheduled(cron = "0 * * * * *") public void cacheCityBusByOpenApi() { try { cityBusClient.storeRemainTimeByOpenApi(); + cityBusRouteClient.storeCityBusRoute(); } catch (Exception e) { log.warn("시내버스 스케줄링 과정에서 오류가 발생했습니다."); } diff --git a/src/main/java/in/koreatech/koin/domain/bus/util/CityBusClient.java b/src/main/java/in/koreatech/koin/domain/bus/util/CityBusClient.java index 9dfdc95ad..69fa7b492 100644 --- a/src/main/java/in/koreatech/koin/domain/bus/util/CityBusClient.java +++ b/src/main/java/in/koreatech/koin/domain/bus/util/CityBusClient.java @@ -12,7 +12,6 @@ import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; -import java.util.Objects; import java.util.Optional; import org.springframework.beans.factory.annotation.Value; @@ -47,7 +46,6 @@ public class CityBusClient { private static final String ENCODE_TYPE = "UTF-8"; private static final String CHEONAN_CITY_CODE = "34010"; - private static final List AVAILABLE_CITY_BUS = List.of(400L, 402L, 405L); private static final Type arrivalInfoType = new TypeToken>() { }.getType(); @@ -71,10 +69,15 @@ public CityBusClient( this.cityBusCacheRepository = cityBusCacheRepository; } - public List getBusRemainTime(String nodeId) { - Optional cityBusCache = cityBusCacheRepository.findById(nodeId); - return cityBusCache.map(busCache -> busCache.getBusInfos().stream().map(CityBusRemainTime::from).toList()) - .orElseGet(ArrayList::new); + public List getBusRemainTime(List nodeIds) { + List result = new ArrayList<>(); + nodeIds.forEach(nodeId -> { + Optional cityBusCache = cityBusCacheRepository.findById(nodeId); + if (cityBusCache.isPresent()) { + result.addAll(cityBusCache.map(busCache -> busCache.getBusInfos().stream().map(CityBusRemainTime::from).toList()).get()); + } + }); + return result; } @Transactional @@ -82,12 +85,7 @@ public void storeRemainTimeByOpenApi() { List> arrivalInfosList = BusStationNode.getNodeIds().stream() .map(this::getOpenApiResponse) .map(this::extractBusArrivalInfo) - .map(cityBusArrivals -> cityBusArrivals.stream() - .filter(cityBusArrival -> - AVAILABLE_CITY_BUS.stream().anyMatch(busNumber -> - Objects.equals(busNumber, cityBusArrival.routeno())) - ).toList() - ).toList(); + .toList(); LocalDateTime updatedAt = LocalDateTime.now(clock); diff --git a/src/main/java/in/koreatech/koin/domain/bus/util/CityBusRouteClient.java b/src/main/java/in/koreatech/koin/domain/bus/util/CityBusRouteClient.java new file mode 100644 index 000000000..2f24a71ca --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/util/CityBusRouteClient.java @@ -0,0 +1,155 @@ +package in.koreatech.koin.domain.bus.util; + +import static java.net.URLEncoder.encode; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.io.UnsupportedEncodingException; +import java.lang.reflect.Type; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.gson.JsonSyntaxException; +import com.google.gson.reflect.TypeToken; + +import in.koreatech.koin.domain.bus.exception.BusOpenApiException; +import in.koreatech.koin.domain.bus.model.city.CityBusRoute; +import in.koreatech.koin.domain.bus.model.city.CityBusRouteCache; +import in.koreatech.koin.domain.bus.model.enums.BusOpenApiResultCode; +import in.koreatech.koin.domain.bus.model.enums.BusStationNode; +import in.koreatech.koin.domain.bus.repository.CityBusRouteCacheRepository; + +/** + * OpenApi 상세: 국토교통부_(TAGO)_버스정류소정보 - 정류소별경유노선 목록조회 + * https://www.data.go.kr/tcs/dss/selectApiDataDetailView.do?publicDataPk=15098534 + */ +@Component +@Transactional(readOnly = true) +public class CityBusRouteClient { + + private static final Set AVAILABLE_CITY_BUS = Set.of(400L, 402L, 405L); + + private static final String ENCODE_TYPE = "UTF-8"; + private static final String CHEONAN_CITY_CODE = "34010"; + private static final Type availableCityBusType = new TypeToken>() { + }.getType(); + + private final String openApiKey; + private final Gson gson; + private final CityBusRouteCacheRepository cityBusRouteCacheRepository; + + public CityBusRouteClient( + @Value("${OPEN_API_KEY_PUBLIC}") String openApiKey, + Gson gson, + CityBusRouteCacheRepository cityBusRouteCacheRepository + ) { + this.openApiKey = openApiKey; + this.gson = gson; + this.cityBusRouteCacheRepository = cityBusRouteCacheRepository; + } + + public Set getAvailableCityBus(List nodeIds) { + Set busNumbers = new HashSet<>(); + nodeIds.forEach(nodeId -> { + Optional routeCache = cityBusRouteCacheRepository.findById(nodeId); + routeCache.ifPresent(cityBusRouteCache -> busNumbers.addAll(cityBusRouteCache.getBusNumbers())); + }); + + if (busNumbers.isEmpty()) { + return new HashSet<>(AVAILABLE_CITY_BUS); + } + + return busNumbers; + } + + @Transactional + public void storeCityBusRoute() { + List nodeIds = BusStationNode.getNodeIds(); + + for (String node : nodeIds) { + Set routes = Set.copyOf(extractBusRouteInfo(getOpenApiResponse(node))); + if (routes.isEmpty()) { continue; } + + cityBusRouteCacheRepository.save(CityBusRouteCache.of(node, routes)); + } + } + + public String getOpenApiResponse(String nodeId) { + try { + URL url = new URL(getRequestURL(CHEONAN_CITY_CODE, nodeId)); + HttpURLConnection conn = (HttpURLConnection)url.openConnection(); + conn.setRequestMethod("GET"); + conn.setRequestProperty("Content-type", "application/json"); + + BufferedReader input; + if (conn.getResponseCode() >= 200 && conn.getResponseCode() <= 300) { + input = new BufferedReader(new InputStreamReader(conn.getInputStream())); + } else { + input = new BufferedReader(new InputStreamReader(conn.getErrorStream())); + } + + StringBuilder response = new StringBuilder(); + String line; + while ((line = input.readLine()) != null) { + response.append(line); + } + input.close(); + conn.disconnect(); + return response.toString(); + } catch (Exception e) { + throw BusOpenApiException.withDetail("nodeId: " + nodeId); + } + } + + private String getRequestURL(String cityCode, String nodeId) throws UnsupportedEncodingException { + String url = "https://apis.data.go.kr/1613000/BusSttnInfoInqireService/getSttnThrghRouteList"; + String contentCount = "50"; + StringBuilder urlBuilder = new StringBuilder(url); + urlBuilder.append("?" + encode("serviceKey", ENCODE_TYPE) + "=" + encode(openApiKey, ENCODE_TYPE)); + urlBuilder.append("&" + encode("numOfRows", ENCODE_TYPE) + "=" + encode(contentCount, ENCODE_TYPE)); + urlBuilder.append("&" + encode("cityCode", ENCODE_TYPE) + "=" + encode(cityCode, ENCODE_TYPE)); + urlBuilder.append("&" + encode("nodeid", ENCODE_TYPE) + "=" + encode(nodeId, ENCODE_TYPE)); + urlBuilder.append("&_type=json"); + return urlBuilder.toString(); + } + + private List extractBusRouteInfo(String jsonResponse) { + List result = new ArrayList<>(); + try { + JsonObject response = JsonParser.parseString(jsonResponse) + .getAsJsonObject() + .get("response") + .getAsJsonObject(); + BusOpenApiResultCode.validateResponse(response); + JsonObject body = response.get("body").getAsJsonObject(); + + if (body.get("totalCount").getAsLong() == 0) { + return result; + } + + JsonElement item = body.get("items").getAsJsonObject().get("item"); + if (item.isJsonArray()) { + return gson.fromJson(item, availableCityBusType); + } + if (item.isJsonObject()) { + result.add(gson.fromJson(item, CityBusRoute.class)); + } + return result; + } catch (JsonSyntaxException e) { + return result; + } + } +} diff --git a/src/main/java/in/koreatech/koin/domain/coop/controller/CoopApi.java b/src/main/java/in/koreatech/koin/domain/coop/controller/CoopApi.java index d37084333..a5425a7c5 100644 --- a/src/main/java/in/koreatech/koin/domain/coop/controller/CoopApi.java +++ b/src/main/java/in/koreatech/koin/domain/coop/controller/CoopApi.java @@ -4,9 +4,12 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import in.koreatech.koin.domain.coop.dto.CoopLoginRequest; +import in.koreatech.koin.domain.coop.dto.CoopLoginResponse; import in.koreatech.koin.domain.coop.dto.DiningImageRequest; import in.koreatech.koin.domain.coop.dto.SoldOutRequest; import in.koreatech.koin.global.auth.Auth; @@ -53,4 +56,18 @@ ResponseEntity saveDiningImage( @Auth(permit = {COOP}) Integer userId, @RequestBody @Valid DiningImageRequest imageRequest ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "201"), + @ApiResponse(responseCode = "400", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "영양사 로그인") + @PostMapping("/coop/login") + ResponseEntity coopLogin( + @RequestBody @Valid CoopLoginRequest request + ); } diff --git a/src/main/java/in/koreatech/koin/domain/coop/controller/CoopController.java b/src/main/java/in/koreatech/koin/domain/coop/controller/CoopController.java index b673e06c0..6790b9b44 100644 --- a/src/main/java/in/koreatech/koin/domain/coop/controller/CoopController.java +++ b/src/main/java/in/koreatech/koin/domain/coop/controller/CoopController.java @@ -2,12 +2,17 @@ import static in.koreatech.koin.domain.user.model.UserType.COOP; +import java.net.URI; + import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import in.koreatech.koin.domain.coop.dto.CoopLoginRequest; +import in.koreatech.koin.domain.coop.dto.CoopLoginResponse; import in.koreatech.koin.domain.coop.dto.DiningImageRequest; import in.koreatech.koin.domain.coop.dto.SoldOutRequest; import in.koreatech.koin.domain.coop.service.CoopService; @@ -39,4 +44,13 @@ public ResponseEntity saveDiningImage( coopService.saveDiningImage(imageRequest); return ResponseEntity.ok().build(); } + + @PostMapping("/login") + public ResponseEntity coopLogin( + @RequestBody @Valid CoopLoginRequest request + ) { + CoopLoginResponse response = coopService.coopLogin(request); + return ResponseEntity.created(URI.create("/")) + .body(response); + } } diff --git a/src/main/java/in/koreatech/koin/domain/coop/dto/CoopLoginRequest.java b/src/main/java/in/koreatech/koin/domain/coop/dto/CoopLoginRequest.java new file mode 100644 index 000000000..717cb6c82 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/coop/dto/CoopLoginRequest.java @@ -0,0 +1,23 @@ +package in.koreatech.koin.domain.coop.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +public record CoopLoginRequest( + @Schema(description = "아이디", example = "koin123", requiredMode = REQUIRED) + @NotBlank(message = "아이디를 입력해주세요.") + String id, + + @Schema( + description = "SHA 256 해시 알고리즘으로 암호화된 비밀번호", + example = "cd06f8c2b0dd065faf6ef910c7f15934363df71c33740fd245590665286ed268", + requiredMode = REQUIRED + ) + @NotBlank(message = "비밀번호를 입력해주세요.") + String password +) { + +} diff --git a/src/main/java/in/koreatech/koin/domain/coop/dto/CoopLoginResponse.java b/src/main/java/in/koreatech/koin/domain/coop/dto/CoopLoginResponse.java new file mode 100644 index 000000000..d26976bff --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/coop/dto/CoopLoginResponse.java @@ -0,0 +1,26 @@ +package in.koreatech.koin.domain.coop.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record CoopLoginResponse( + @Schema( + description = "Jwt accessToken", + example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c", + requiredMode = REQUIRED + ) + @JsonProperty("token") + String accessToken, + + @Schema(description = "Random UUID refresh token", example = "RANDOM-KEY-VALUE", requiredMode = REQUIRED) + @JsonProperty("refresh_token") + String refreshToken + ) { + + public static CoopLoginResponse of(String token, String refreshToken) { + return new CoopLoginResponse(token, refreshToken); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/coop/exception/CoopNotFoundException.java b/src/main/java/in/koreatech/koin/domain/coop/exception/CoopNotFoundException.java new file mode 100644 index 000000000..62135561f --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/coop/exception/CoopNotFoundException.java @@ -0,0 +1,19 @@ +package in.koreatech.koin.domain.coop.exception; + +import in.koreatech.koin.global.exception.DataNotFoundException; + +public class CoopNotFoundException extends DataNotFoundException { + private static final String DEFAULT_MESSAGE = "영양사님 계정이 존재하지 않습니다."; + + public CoopNotFoundException(String message) { + super(message); + } + + public CoopNotFoundException(String message, String detail) { + super(message, detail); + } + + public static DiningCacheNotFoundException withDetail(String detail) { + return new DiningCacheNotFoundException(DEFAULT_MESSAGE, detail); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/coop/model/Coop.java b/src/main/java/in/koreatech/koin/domain/coop/model/Coop.java new file mode 100644 index 000000000..7a917bc85 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/coop/model/Coop.java @@ -0,0 +1,43 @@ +package in.koreatech.koin.domain.coop.model; + +import static lombok.AccessLevel.PROTECTED; + +import in.koreatech.koin.domain.user.model.User; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.MapsId; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import jakarta.validation.constraints.Size; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "coop") +@NoArgsConstructor(access = PROTECTED) +public class Coop { + + @Id + @Column(name = "user_id") + private Integer id; + + @Size(max = 255) + @Column(name = "coop_id") + private String coopId; + + @OneToOne + @MapsId + private User user; + + @Builder + private Coop( + String coopId, + User user + ) { + this.coopId = coopId; + this.user = user; + } +} diff --git a/src/main/java/in/koreatech/koin/domain/coop/model/CoopEventListener.java b/src/main/java/in/koreatech/koin/domain/coop/model/CoopEventListener.java index 64cfcf37c..7efaebf87 100644 --- a/src/main/java/in/koreatech/koin/domain/coop/model/CoopEventListener.java +++ b/src/main/java/in/koreatech/koin/domain/coop/model/CoopEventListener.java @@ -28,6 +28,7 @@ public class CoopEventListener { @TransactionalEventListener(phase = AFTER_COMMIT) public void onDiningSoldOutRequest(DiningSoldOutEvent event) { + diningSoldOutCacheRepository.save(DiningSoldOutCache.from(event.place())); NotificationDetailSubscribeType detailType = NotificationDetailSubscribeType.from(event.diningType()); var notifications = notificationSubscribeRepository .findAllBySubscribeTypeAndDetailType(DINING_SOLD_OUT, null).stream() @@ -40,8 +41,6 @@ public void onDiningSoldOutRequest(DiningSoldOutEvent event) { event.place(), subscribe.getUser() )).toList(); - notificationService.push(notifications); - diningSoldOutCacheRepository.save(DiningSoldOutCache.from(event.place())); } } diff --git a/src/main/java/in/koreatech/koin/domain/coop/repository/CoopRepository.java b/src/main/java/in/koreatech/koin/domain/coop/repository/CoopRepository.java new file mode 100644 index 000000000..2f7c34a51 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/coop/repository/CoopRepository.java @@ -0,0 +1,18 @@ +package in.koreatech.koin.domain.coop.repository; + +import java.util.Optional; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.coop.exception.CoopNotFoundException; +import in.koreatech.koin.domain.coop.model.Coop; + +public interface CoopRepository extends Repository { + Optional findByCoopId(String coopId); + + default Coop getByCoopId(String coopId){ + return findByCoopId(coopId).orElseThrow(() -> CoopNotFoundException.withDetail("CoopId : " + coopId)); + } + + Coop save(Coop coop); +} diff --git a/src/main/java/in/koreatech/koin/domain/coop/service/CoopService.java b/src/main/java/in/koreatech/koin/domain/coop/service/CoopService.java index 4317e914c..b1c57f506 100644 --- a/src/main/java/in/koreatech/koin/domain/coop/service/CoopService.java +++ b/src/main/java/in/koreatech/koin/domain/coop/service/CoopService.java @@ -2,17 +2,30 @@ import java.time.Clock; import java.time.LocalDateTime; +import java.util.UUID; import org.springframework.context.ApplicationEventPublisher; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import in.koreatech.koin.domain.coop.dto.CoopLoginRequest; +import in.koreatech.koin.domain.coop.dto.CoopLoginResponse; import in.koreatech.koin.domain.coop.dto.DiningImageRequest; import in.koreatech.koin.domain.coop.dto.SoldOutRequest; +import in.koreatech.koin.domain.coop.model.Coop; import in.koreatech.koin.domain.coop.model.DiningSoldOutEvent; +import in.koreatech.koin.domain.coop.repository.CoopRepository; import in.koreatech.koin.domain.coop.repository.DiningSoldOutCacheRepository; import in.koreatech.koin.domain.dining.model.Dining; import in.koreatech.koin.domain.dining.repository.DiningRepository; +import in.koreatech.koin.domain.user.model.User; +import in.koreatech.koin.domain.user.model.UserToken; +import in.koreatech.koin.domain.user.model.UserType; +import in.koreatech.koin.domain.user.repository.UserRepository; +import in.koreatech.koin.domain.user.repository.UserTokenRepository; +import in.koreatech.koin.global.auth.JwtProvider; +import in.koreatech.koin.global.exception.KoinIllegalArgumentException; import lombok.RequiredArgsConstructor; @Service @@ -24,6 +37,10 @@ public class CoopService { private final ApplicationEventPublisher eventPublisher; private final DiningRepository diningRepository; private final DiningSoldOutCacheRepository diningSoldOutCacheRepository; + private final CoopRepository coopRepository; + private final UserTokenRepository userTokenRepository; + private final PasswordEncoder passwordEncoder; + private final JwtProvider jwtProvider; @Transactional public void changeSoldOut(SoldOutRequest soldOutRequest) { @@ -48,4 +65,21 @@ public void saveDiningImage(DiningImageRequest imageRequest) { Dining dining = diningRepository.getById(imageRequest.menuId()); dining.setImageUrl(imageRequest.imageUrl()); } + + @Transactional + public CoopLoginResponse coopLogin(CoopLoginRequest request) { + Coop coop = coopRepository.getByCoopId(request.id()); + User user = coop.getUser(); + + if (!user.isSamePassword(passwordEncoder, request.password())) { + throw new KoinIllegalArgumentException("비밀번호가 틀렸습니다."); + } + + String accessToken = jwtProvider.createToken(user); + String refreshToken = String.format("%s-%d", UUID.randomUUID(), user.getId()); + UserToken savedToken = userTokenRepository.save(UserToken.create(user.getId(), refreshToken)); + user.updateLastLoggedTime(LocalDateTime.now()); + + return CoopLoginResponse.of(accessToken, savedToken.getRefreshToken()); + } } diff --git a/src/main/java/in/koreatech/koin/domain/dining/dto/DiningResponse.java b/src/main/java/in/koreatech/koin/domain/dining/dto/DiningResponse.java index 81fbc3f24..7174503bf 100644 --- a/src/main/java/in/koreatech/koin/domain/dining/dto/DiningResponse.java +++ b/src/main/java/in/koreatech/koin/domain/dining/dto/DiningResponse.java @@ -70,8 +70,8 @@ public static DiningResponse from(Dining dining) { dining.getDate(), dining.getType().name(), dining.getPlace(), - dining.getPriceCard() != null ? dining.getPriceCard() : 0, - dining.getPriceCash() != null ? dining.getPriceCash() : 0, + dining.getPriceCard(), + dining.getPriceCash(), dining.getKcal() != null ? dining.getKcal() : 0, dining.getMenu(), dining.getImageUrl(), diff --git a/src/main/java/in/koreatech/koin/domain/land/dto/LandResponse.java b/src/main/java/in/koreatech/koin/domain/land/dto/LandResponse.java index e296cbdcf..0735ec7ed 100644 --- a/src/main/java/in/koreatech/koin/domain/land/dto/LandResponse.java +++ b/src/main/java/in/koreatech/koin/domain/land/dto/LandResponse.java @@ -158,7 +158,7 @@ public static LandResponse of(Land land, List imageUrls, String permalin land.getLongitude(), land.getAddress(), land.isOptBed(), - land.getSize(), + String.valueOf(land.getSize()), land.getPhone(), land.isOptAirConditioner(), land.getName(), diff --git a/src/main/java/in/koreatech/koin/domain/land/model/Land.java b/src/main/java/in/koreatech/koin/domain/land/model/Land.java index ffc9f2a73..31f1fa325 100644 --- a/src/main/java/in/koreatech/koin/domain/land/model/Land.java +++ b/src/main/java/in/koreatech/koin/domain/land/model/Land.java @@ -235,6 +235,13 @@ public Double getLongitude() { return Double.parseDouble(longitude); } + public Double getSize() { + if (this.size == null) { + return null; + } + return Double.parseDouble(size); + } + private String convertToSting(List imageUrls) { if (imageUrls == null || imageUrls.isEmpty()) { return null; @@ -243,4 +250,53 @@ private String convertToSting(List imageUrls) { .map(url -> "\"" + url + "\"") .collect(Collectors.joining(","))); } + + public void delete() { + this.isDeleted = true; + } + + public void undelete() { + this.isDeleted = false; + } + + public void update(String internalName, String name, double size, String roomType, double latitude, + double longitude, + String phone, List imageUrls, String address, String description, Integer floor, + String deposit, String monthlyFee, String charterFee, String managementFee, boolean optRefrigerator, + boolean optCloset, boolean optTv, boolean optMicrowave, boolean optGasRange, boolean optInduction, + boolean optWaterPurifier, boolean optAirConditioner, boolean optWasher, boolean optBed, boolean optDesk, + boolean optShoeCloset, boolean optElectronicDoorLocks, boolean optBidet, boolean optVeranda, + boolean optElevator) { + this.internalName = internalName; + this.name = name; + this.size = String.valueOf(size); + this.roomType = roomType; + this.latitude = String.valueOf(latitude); + this.longitude = String.valueOf(longitude); + this.phone = phone; + this.imageUrls = convertToSting(imageUrls); + this.address = address; + this.description = description; + this.floor = floor; + this.deposit = deposit; + this.monthlyFee = monthlyFee; + this.charterFee = charterFee; + this.managementFee = managementFee; + this.optRefrigerator = optRefrigerator; + this.optCloset = optCloset; + this.optTv = optTv; + this.optMicrowave = optMicrowave; + this.optGasRange = optGasRange; + this.optInduction = optInduction; + this.optWaterPurifier = optWaterPurifier; + this.optAirConditioner = optAirConditioner; + this.optWasher = optWasher; + this.optBed = optBed; + this.optDesk = optDesk; + this.optShoeCloset = optShoeCloset; + this.optElectronicDoorLocks = optElectronicDoorLocks; + this.optBidet = optBidet; + this.optVeranda = optVeranda; + this.optElevator = optElevator; + } } diff --git a/src/main/java/in/koreatech/koin/domain/member/model/Member.java b/src/main/java/in/koreatech/koin/domain/member/model/Member.java index c890224d8..87276b423 100644 --- a/src/main/java/in/koreatech/koin/domain/member/model/Member.java +++ b/src/main/java/in/koreatech/koin/domain/member/model/Member.java @@ -4,6 +4,7 @@ import static jakarta.persistence.GenerationType.IDENTITY; import static lombok.AccessLevel.PROTECTED; +import in.koreatech.koin.admin.member.dto.AdminMemberRequest; import in.koreatech.koin.global.domain.BaseEntity; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -83,4 +84,20 @@ private Member( public void delete() { this.isDeleted = true; } + + public void undelete() { + this.isDeleted = false; + } + + public void update(String name, String studentNumber, String position, String email, String imageUrl) { + this.name = name; + this.studentNumber = studentNumber; + this.position = position; + this.email = email; + this.imageUrl = imageUrl; + } + + public void updateTrack(Track track) { + this.track = track; + } } diff --git a/src/main/java/in/koreatech/koin/domain/member/model/TechStack.java b/src/main/java/in/koreatech/koin/domain/member/model/TechStack.java index a3035bd22..facea5698 100644 --- a/src/main/java/in/koreatech/koin/domain/member/model/TechStack.java +++ b/src/main/java/in/koreatech/koin/domain/member/model/TechStack.java @@ -67,4 +67,8 @@ public void update(Integer trackId, String imageUrl, String name, String descrip this.description = description; this.isDeleted = isDeleted; } + + public void delete() { + this.isDeleted = true; + } } diff --git a/src/main/java/in/koreatech/koin/domain/member/model/Track.java b/src/main/java/in/koreatech/koin/domain/member/model/Track.java index 423d9bed4..e545cd372 100644 --- a/src/main/java/in/koreatech/koin/domain/member/model/Track.java +++ b/src/main/java/in/koreatech/koin/domain/member/model/Track.java @@ -39,8 +39,19 @@ public class Track extends BaseEntity { private boolean isDeleted = false; @Builder - private Track(String name) { + private Track(String name, Integer headcount, boolean isDeleted) { this.name = name; - this.headcount = 0; + this.headcount = headcount != null ? headcount : 0; + this.isDeleted = isDeleted; + } + + public void update(String name, Integer headcount, boolean isDeleted) { + this.name = name; + this.headcount = headcount; + this.isDeleted = isDeleted; + } + + public void delete() { + this.isDeleted = true; } } diff --git a/src/main/java/in/koreatech/koin/domain/member/repository/MemberRepository.java b/src/main/java/in/koreatech/koin/domain/member/repository/MemberRepository.java index 07ecc1f9a..cde93f1cf 100644 --- a/src/main/java/in/koreatech/koin/domain/member/repository/MemberRepository.java +++ b/src/main/java/in/koreatech/koin/domain/member/repository/MemberRepository.java @@ -16,7 +16,7 @@ public interface MemberRepository extends Repository { Member save(Member member); - List findByTrackIdAndIsDeletedFalse(Integer id); + List findAllByTrackIdAndIsDeletedFalse(Integer id); List findAll(); diff --git a/src/main/java/in/koreatech/koin/domain/member/service/TrackService.java b/src/main/java/in/koreatech/koin/domain/member/service/TrackService.java index 6872505da..fe23ba50a 100644 --- a/src/main/java/in/koreatech/koin/domain/member/service/TrackService.java +++ b/src/main/java/in/koreatech/koin/domain/member/service/TrackService.java @@ -30,7 +30,7 @@ public List getTracks() { public TrackSingleResponse getTrack(Integer id) { Track track = trackRepository.getById(id); - List member = memberRepository.findByTrackIdAndIsDeletedFalse(id); + List member = memberRepository.findAllByTrackIdAndIsDeletedFalse(id); List techStacks = techStackRepository.findAllByTrackId(id); return TrackSingleResponse.of(track, member, techStacks); diff --git a/src/main/java/in/koreatech/koin/domain/owner/controller/OwnerApi.java b/src/main/java/in/koreatech/koin/domain/owner/controller/OwnerApi.java index 66d18b1d6..c2d685d80 100644 --- a/src/main/java/in/koreatech/koin/domain/owner/controller/OwnerApi.java +++ b/src/main/java/in/koreatech/koin/domain/owner/controller/OwnerApi.java @@ -4,21 +4,26 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; +import in.koreatech.koin.domain.owner.dto.CompanyNumberCheckRequest; +import in.koreatech.koin.domain.owner.dto.OwnerAccountCheckExistsRequest; import in.koreatech.koin.domain.owner.dto.OwnerEmailVerifyRequest; +import in.koreatech.koin.domain.owner.dto.OwnerLoginRequest; +import in.koreatech.koin.domain.owner.dto.OwnerLoginResponse; import in.koreatech.koin.domain.owner.dto.OwnerPasswordResetVerifyEmailRequest; import in.koreatech.koin.domain.owner.dto.OwnerPasswordResetVerifySmsRequest; import in.koreatech.koin.domain.owner.dto.OwnerPasswordUpdateEmailRequest; import in.koreatech.koin.domain.owner.dto.OwnerPasswordUpdateSmsRequest; import in.koreatech.koin.domain.owner.dto.OwnerRegisterByPhoneRequest; -import in.koreatech.koin.domain.owner.dto.OwnerSmsVerifyRequest; import in.koreatech.koin.domain.owner.dto.OwnerRegisterRequest; import in.koreatech.koin.domain.owner.dto.OwnerResponse; import in.koreatech.koin.domain.owner.dto.OwnerSendEmailRequest; import in.koreatech.koin.domain.owner.dto.OwnerSendSmsRequest; +import in.koreatech.koin.domain.owner.dto.OwnerSmsVerifyRequest; import in.koreatech.koin.domain.owner.dto.OwnerVerifyResponse; import in.koreatech.koin.domain.owner.dto.VerifyEmailRequest; import in.koreatech.koin.domain.owner.dto.VerifySmsRequest; @@ -50,6 +55,20 @@ ResponseEntity getOwner( @Auth(permit = {OWNER}) Integer userId ); + @ApiResponses( + value = { + @ApiResponse(responseCode = "201"), + @ApiResponse(responseCode = "400", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "사장님 로그인") + @PostMapping("/owner/login") + ResponseEntity ownerLogin( + @RequestBody @Valid OwnerLoginRequest request + ); + @ApiResponses( value = { @ApiResponse(responseCode = "200"), @@ -263,4 +282,33 @@ ResponseEntity updatePasswordByEmail( ResponseEntity updatePasswordBySms( @Valid @RequestBody OwnerPasswordUpdateSmsRequest request ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "400", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "409", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "사업자 등록번호 중복 검증") + @SecurityRequirement(name = "Jwt Authentication") + @GetMapping("/owners/exists/company-number") + ResponseEntity checkCompanyNumber( + @ModelAttribute("company_number") + @Valid CompanyNumberCheckRequest request + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "400", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "409", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "전화번호 중복 체크") + @GetMapping("/owners/exists/account") + ResponseEntity checkDuplicationOfPhoneNumber( + @ModelAttribute("account") + @Valid OwnerAccountCheckExistsRequest request + ); } diff --git a/src/main/java/in/koreatech/koin/domain/owner/controller/OwnerController.java b/src/main/java/in/koreatech/koin/domain/owner/controller/OwnerController.java index 308b085e3..82504e901 100644 --- a/src/main/java/in/koreatech/koin/domain/owner/controller/OwnerController.java +++ b/src/main/java/in/koreatech/koin/domain/owner/controller/OwnerController.java @@ -2,24 +2,31 @@ import static in.koreatech.koin.domain.user.model.UserType.OWNER; +import java.net.URI; + import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; +import in.koreatech.koin.domain.owner.dto.CompanyNumberCheckRequest; +import in.koreatech.koin.domain.owner.dto.OwnerAccountCheckExistsRequest; import in.koreatech.koin.domain.owner.dto.OwnerEmailVerifyRequest; +import in.koreatech.koin.domain.owner.dto.OwnerLoginRequest; +import in.koreatech.koin.domain.owner.dto.OwnerLoginResponse; import in.koreatech.koin.domain.owner.dto.OwnerPasswordResetVerifyEmailRequest; import in.koreatech.koin.domain.owner.dto.OwnerPasswordResetVerifySmsRequest; import in.koreatech.koin.domain.owner.dto.OwnerPasswordUpdateEmailRequest; import in.koreatech.koin.domain.owner.dto.OwnerPasswordUpdateSmsRequest; import in.koreatech.koin.domain.owner.dto.OwnerRegisterByPhoneRequest; -import in.koreatech.koin.domain.owner.dto.OwnerSmsVerifyRequest; import in.koreatech.koin.domain.owner.dto.OwnerRegisterRequest; import in.koreatech.koin.domain.owner.dto.OwnerResponse; import in.koreatech.koin.domain.owner.dto.OwnerSendEmailRequest; import in.koreatech.koin.domain.owner.dto.OwnerSendSmsRequest; +import in.koreatech.koin.domain.owner.dto.OwnerSmsVerifyRequest; import in.koreatech.koin.domain.owner.dto.OwnerVerifyResponse; import in.koreatech.koin.domain.owner.dto.VerifyEmailRequest; import in.koreatech.koin.domain.owner.dto.VerifySmsRequest; @@ -66,6 +73,15 @@ public ResponseEntity register( return ResponseEntity.ok().build(); } + @PostMapping("/owner/login") + public ResponseEntity ownerLogin( + @RequestBody @Valid OwnerLoginRequest request + ) { + OwnerLoginResponse response = ownerService.ownerLogin(request); + return ResponseEntity.created(URI.create("/")) + .body(response); + } + @PostMapping("/owners/register/phone") public ResponseEntity registerByPhone( @Valid @RequestBody OwnerRegisterByPhoneRequest request @@ -137,4 +153,22 @@ public ResponseEntity updatePasswordBySms( ownerService.updatePasswordBySms(request); return ResponseEntity.ok().build(); } + + @GetMapping("/owners/exists/company-number") + public ResponseEntity checkCompanyNumber( + @ModelAttribute("company_number") + @Valid CompanyNumberCheckRequest request + ) { + ownerService.checkCompanyNumber(request); + return ResponseEntity.ok().build(); + } + + @GetMapping("/owners/exists/account") + public ResponseEntity checkDuplicationOfPhoneNumber( + @ModelAttribute("account") + @Valid OwnerAccountCheckExistsRequest request + ) { + ownerService.checkExistsAccount(request); + return ResponseEntity.ok().build(); + } } diff --git a/src/main/java/in/koreatech/koin/domain/owner/dto/CompanyNumberCheckRequest.java b/src/main/java/in/koreatech/koin/domain/owner/dto/CompanyNumberCheckRequest.java new file mode 100644 index 000000000..e663cec58 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/owner/dto/CompanyNumberCheckRequest.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.domain.owner.dto; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +@JsonNaming(SnakeCaseStrategy.class) +public record CompanyNumberCheckRequest( + @Pattern(regexp = "^\\d{3}-\\d{2}-\\d{5}", message = "사업자 등록 번호 형식이 올바르지 않습니다. ${validatedValue}") + @Schema(description = "사업자 등록 번호", example = "012-34-56789", requiredMode = REQUIRED) + @NotBlank(message = "사업자 등록 번호를 입력해주세요.") + String companyNumber +) { + +} diff --git a/src/main/java/in/koreatech/koin/domain/owner/dto/OwnerAccountCheckExistsRequest.java b/src/main/java/in/koreatech/koin/domain/owner/dto/OwnerAccountCheckExistsRequest.java new file mode 100644 index 000000000..bac5c369f --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/owner/dto/OwnerAccountCheckExistsRequest.java @@ -0,0 +1,18 @@ +package in.koreatech.koin.domain.owner.dto; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +@JsonNaming(SnakeCaseStrategy.class) +public record OwnerAccountCheckExistsRequest( + @Pattern(regexp = "^\\d{11}$", message = "전화번호 형식이 올바르지 않습니다. 11자리 숫자로 입력해 주세요.") + @NotBlank(message = "아이디를 입력해주세요.") + @Schema(description = "아이디(전화번호)", example = "01012345678") + String account +) { + +} diff --git a/src/main/java/in/koreatech/koin/domain/owner/dto/OwnerLoginRequest.java b/src/main/java/in/koreatech/koin/domain/owner/dto/OwnerLoginRequest.java new file mode 100644 index 000000000..3ba27201a --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/owner/dto/OwnerLoginRequest.java @@ -0,0 +1,25 @@ +package in.koreatech.koin.domain.owner.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +public record OwnerLoginRequest( + @Schema(description = "전화번호", example = "01012345678", requiredMode = REQUIRED) + @NotBlank(message = "전화번호를 입력해주세요.") + @Pattern(regexp = "^\\d{11}$", message = "전화번호 형식이 올바르지 않습니다. 11자리 숫자로 입력해 주세요.") + String account, + + @Schema + ( + description = "SHA 256 해시 알고리즘으로 암호화된 비밀번호", + example = "cd06f8c2b0dd065faf6ef910c7f15934363df71c33740fd245590665286ed268", + requiredMode = REQUIRED + ) + @NotBlank(message = "비밀번호를 입력해주세요.") + String password +) { + +} diff --git a/src/main/java/in/koreatech/koin/domain/owner/dto/OwnerLoginResponse.java b/src/main/java/in/koreatech/koin/domain/owner/dto/OwnerLoginResponse.java new file mode 100644 index 000000000..bd031e926 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/owner/dto/OwnerLoginResponse.java @@ -0,0 +1,26 @@ +package in.koreatech.koin.domain.owner.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record OwnerLoginResponse( + @Schema( + description = "Jwt accessToken", + example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c", + requiredMode = REQUIRED + ) + @JsonProperty("token") + String accessToken, + + @Schema(description = "Random UUID refresh token", example = "RANDOM-KEY-VALUE", requiredMode = REQUIRED) + @JsonProperty("refresh_token") + String refreshToken +) { + + public static OwnerLoginResponse of(String token, String refreshToken) { + return new OwnerLoginResponse(token, refreshToken); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/owner/dto/OwnerRegisterByPhoneRequest.java b/src/main/java/in/koreatech/koin/domain/owner/dto/OwnerRegisterByPhoneRequest.java index 634c038d3..b31cce063 100644 --- a/src/main/java/in/koreatech/koin/domain/owner/dto/OwnerRegisterByPhoneRequest.java +++ b/src/main/java/in/koreatech/koin/domain/owner/dto/OwnerRegisterByPhoneRequest.java @@ -59,7 +59,7 @@ public Owner toOwner(PasswordEncoder passwordEncoder) { User user = User.builder() .password(passwordEncoder.encode(password)) .name(name) - .email(phoneNumber) + .email(null) .phoneNumber(phoneNumber) .userType(OWNER) .isAuthed(false) @@ -71,6 +71,7 @@ public Owner toOwner(PasswordEncoder passwordEncoder) { .attachments(new ArrayList<>()) .grantShop(false) .grantEvent(false) + .account(phoneNumber) .build(); List attachments = attachmentUrls.stream() .map(OwnerRegisterByPhoneInnerAttachmentUrl::fileUrl) diff --git a/src/main/java/in/koreatech/koin/domain/owner/dto/OwnerRegisterRequest.java b/src/main/java/in/koreatech/koin/domain/owner/dto/OwnerRegisterRequest.java index 1555a62ff..8464d9971 100644 --- a/src/main/java/in/koreatech/koin/domain/owner/dto/OwnerRegisterRequest.java +++ b/src/main/java/in/koreatech/koin/domain/owner/dto/OwnerRegisterRequest.java @@ -44,7 +44,6 @@ public record OwnerRegisterRequest( @Schema(description = "비밀번호", example = "password", requiredMode = REQUIRED) String password, - @Pattern(regexp = "^\\d{3}-\\d{3,4}-\\d{4}", message = "전화번호 형식이 올바르지 않습니다.") @Schema(description = "휴대폰 번호", example = "010-0000-0000", requiredMode = REQUIRED) String phoneNumber, @@ -72,6 +71,7 @@ public Owner toOwner(PasswordEncoder passwordEncoder) { .build(); Owner owner = Owner.builder() .user(user) + .account(phoneNumber) .companyRegistrationNumber(companyNumber) .attachments(new ArrayList<>()) .grantShop(false) diff --git a/src/main/java/in/koreatech/koin/domain/owner/dto/OwnerResponse.java b/src/main/java/in/koreatech/koin/domain/owner/dto/OwnerResponse.java index d0d2180c9..ea67d8e45 100644 --- a/src/main/java/in/koreatech/koin/domain/owner/dto/OwnerResponse.java +++ b/src/main/java/in/koreatech/koin/domain/owner/dto/OwnerResponse.java @@ -23,6 +23,9 @@ public record OwnerResponse( @Schema(description = "사업자 등록 번호", example = "123-45-67890", requiredMode = REQUIRED) String company_number, + @Schema(description = "저장 된 사장님 전화번호", example = "01012345678", requiredMode = REQUIRED) + String account, + @Schema(description = "첨부 파일 목록", requiredMode = NOT_REQUIRED) List attachments, @@ -35,6 +38,7 @@ public static OwnerResponse of(Owner owner, List attachments, L owner.getUser().getEmail(), owner.getUser().getName(), owner.getCompanyRegistrationNumber(), + owner.getAccount(), attachments.stream() .map(InnerAttachmentResponse::from) .toList(), diff --git a/src/main/java/in/koreatech/koin/domain/owner/model/Owner.java b/src/main/java/in/koreatech/koin/domain/owner/model/Owner.java index d506da0c5..22c380241 100644 --- a/src/main/java/in/koreatech/koin/domain/owner/model/Owner.java +++ b/src/main/java/in/koreatech/koin/domain/owner/model/Owner.java @@ -9,6 +9,7 @@ import java.util.ArrayList; import java.util.List; +import in.koreatech.koin.admin.user.dto.AdminOwnerUpdateRequest; import in.koreatech.koin.domain.user.model.User; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -50,6 +51,10 @@ public class Owner { @Column(name = "grant_event", columnDefinition = "TINYINT") private boolean grantEvent; + @Size(max = 255) + @Column(name = "account") + private String account; + @OneToMany(cascade = {PERSIST, MERGE, REMOVE}, orphanRemoval = true) @JoinColumn(name = "owner_id", updatable = false) private List attachments = new ArrayList<>(); @@ -60,12 +65,24 @@ private Owner( String companyRegistrationNumber, List attachments, Boolean grantShop, - Boolean grantEvent + Boolean grantEvent, + String account ) { this.user = user; this.companyRegistrationNumber = companyRegistrationNumber; this.attachments = attachments; this.grantShop = grantShop; this.grantEvent = grantEvent; + this.account = account; + } + + public void setGrantShop(boolean grantShop) { + this.grantShop = grantShop; + } + + public void update(AdminOwnerUpdateRequest request) { + this.companyRegistrationNumber = request.companyRegistrationNumber(); + this.grantShop = request.grantShop(); + this.grantEvent = request.grantEvent(); } } diff --git a/src/main/java/in/koreatech/koin/domain/owner/model/OwnerEventListener.java b/src/main/java/in/koreatech/koin/domain/owner/model/OwnerEventListener.java index eb3fff055..f96d90279 100644 --- a/src/main/java/in/koreatech/koin/domain/owner/model/OwnerEventListener.java +++ b/src/main/java/in/koreatech/koin/domain/owner/model/OwnerEventListener.java @@ -56,4 +56,17 @@ public void onOwnerRegister(OwnerRegisterEvent event) { ); slackClient.sendMessage(notification); } + + @TransactionalEventListener(phase = AFTER_COMMIT) + public void onOwnerRegisterBySms(OwnerRegisterBySmsEvent event) { + Owner owner = event.owner(); + ownerInVerificationRedisRepository.deleteByVerify(owner.getAccount()); + String shopsName = shopRepository.findAllByOwnerId(owner.getId()) + .stream().map(Shop::getName).collect(Collectors.joining(", ")); + var notification = slackNotificationFactory.generateOwnerRegisterRequestNotification( + owner.getUser().getName(), + shopsName + ); + slackClient.sendMessage(notification); + } } diff --git a/src/main/java/in/koreatech/koin/domain/owner/model/OwnerRegisterBySmsEvent.java b/src/main/java/in/koreatech/koin/domain/owner/model/OwnerRegisterBySmsEvent.java new file mode 100644 index 000000000..5e1a7d87b --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/owner/model/OwnerRegisterBySmsEvent.java @@ -0,0 +1,7 @@ +package in.koreatech.koin.domain.owner.model; + +public record OwnerRegisterBySmsEvent( + Owner owner +) { + +} diff --git a/src/main/java/in/koreatech/koin/domain/owner/repository/OwnerRepository.java b/src/main/java/in/koreatech/koin/domain/owner/repository/OwnerRepository.java index fba5c750b..50f346134 100644 --- a/src/main/java/in/koreatech/koin/domain/owner/repository/OwnerRepository.java +++ b/src/main/java/in/koreatech/koin/domain/owner/repository/OwnerRepository.java @@ -15,6 +15,12 @@ default Owner getById(Integer ownerId) { return findById(ownerId).orElseThrow(() -> OwnerNotFoundException.withDetail("ownerId: " + ownerId)); } + Optional findByAccount(String account); + + default Owner getByAccount(String account) { + return findByAccount(account).orElseThrow(() -> OwnerNotFoundException.withDetail("ownerAccount : " + account)); + } + Owner save(Owner owner); Optional findByCompanyRegistrationNumber(String companyRegistrationNumber); diff --git a/src/main/java/in/koreatech/koin/domain/owner/service/OwnerService.java b/src/main/java/in/koreatech/koin/domain/owner/service/OwnerService.java index 5e433dd2e..07998b12a 100644 --- a/src/main/java/in/koreatech/koin/domain/owner/service/OwnerService.java +++ b/src/main/java/in/koreatech/koin/domain/owner/service/OwnerService.java @@ -2,16 +2,22 @@ import static in.koreatech.koin.domain.user.model.UserType.OWNER; +import java.time.LocalDateTime; import java.util.List; import java.util.Objects; import java.util.Optional; +import java.util.UUID; import org.springframework.context.ApplicationEventPublisher; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import in.koreatech.koin.domain.owner.dto.CompanyNumberCheckRequest; +import in.koreatech.koin.domain.owner.dto.OwnerAccountCheckExistsRequest; import in.koreatech.koin.domain.owner.dto.OwnerEmailVerifyRequest; +import in.koreatech.koin.domain.owner.dto.OwnerLoginRequest; +import in.koreatech.koin.domain.owner.dto.OwnerLoginResponse; import in.koreatech.koin.domain.owner.dto.OwnerPasswordResetVerifyEmailRequest; import in.koreatech.koin.domain.owner.dto.OwnerPasswordResetVerifySmsRequest; import in.koreatech.koin.domain.owner.dto.OwnerPasswordUpdateEmailRequest; @@ -29,6 +35,7 @@ import in.koreatech.koin.domain.owner.exception.DuplicationPhoneNumberException; import in.koreatech.koin.domain.owner.model.Owner; import in.koreatech.koin.domain.owner.model.OwnerEmailRequestEvent; +import in.koreatech.koin.domain.owner.model.OwnerRegisterBySmsEvent; import in.koreatech.koin.domain.owner.model.OwnerRegisterEvent; import in.koreatech.koin.domain.owner.model.OwnerShop; import in.koreatech.koin.domain.owner.model.OwnerSmsRequestEvent; @@ -41,7 +48,9 @@ import in.koreatech.koin.domain.shop.model.Shop; import in.koreatech.koin.domain.shop.repository.ShopRepository; import in.koreatech.koin.domain.user.model.User; +import in.koreatech.koin.domain.user.model.UserToken; import in.koreatech.koin.domain.user.repository.UserRepository; +import in.koreatech.koin.domain.user.repository.UserTokenRepository; import in.koreatech.koin.global.auth.JwtProvider; import in.koreatech.koin.global.domain.email.exception.DuplicationEmailException; import in.koreatech.koin.global.domain.email.form.OwnerRegistrationData; @@ -67,6 +76,7 @@ public class OwnerService { private final OwnerVerificationStatusRepository ownerVerificationStatusRepository; private final DailyVerificationLimitRepository dailyVerificationLimitRedisRepository; private final NaverSmsService naverSmsService; + private final UserTokenRepository userTokenRepository; public OwnerResponse getOwner(Integer ownerId) { Owner foundOwner = ownerRepository.getById(ownerId); @@ -74,6 +84,27 @@ public OwnerResponse getOwner(Integer ownerId) { return OwnerResponse.of(foundOwner, foundOwner.getAttachments(), shops); } + @Transactional + public OwnerLoginResponse ownerLogin(OwnerLoginRequest request) { + Owner owner = ownerRepository.getByAccount(request.account()); + User user = owner.getUser(); + + if (!user.isSamePassword(passwordEncoder, request.password())) { + throw new KoinIllegalArgumentException("비밀번호가 틀렸습니다."); + } + + if (!user.isAuthed()) { + throw new KoinIllegalArgumentException("미인증 상태입니다. 인증을 진행해주세요."); + } + + String accessToken = jwtProvider.createToken(user); + String refreshToken = String.format("%s-%d", UUID.randomUUID(), user.getId()); + UserToken savedToken = userTokenRepository.save(UserToken.create(user.getId(), refreshToken)); + user.updateLastLoggedTime(LocalDateTime.now()); + + return OwnerLoginResponse.of(accessToken, savedToken.getRefreshToken()); + } + @Transactional public void requestSignUpEmailVerification(VerifyEmailRequest request) { userRepository.findByEmail(request.address()).ifPresent(user -> { @@ -85,7 +116,7 @@ public void requestSignUpEmailVerification(VerifyEmailRequest request) { @Transactional public void requestSignUpSmsVerification(VerifySmsRequest request) { userRepository.findByPhoneNumberAndUserType(request.phoneNumber(), OWNER).ifPresent(user -> { - throw DuplicationPhoneNumberException.withDetail("phoneNumber: " + request.phoneNumber()); + throw DuplicationPhoneNumberException.withDetail("account: " + request.phoneNumber()); }); sendCertificationSms(request.phoneNumber()); } @@ -112,6 +143,9 @@ public void register(OwnerRegisterRequest request) { if (ownerRepository.findByCompanyRegistrationNumber(request.companyNumber()).isPresent()) { throw DuplicationCompanyNumberException.withDetail("companyNumber: " + request.companyNumber()); } + if (ownerRepository.findByAccount(request.phoneNumber()).isPresent()) { + throw DuplicationPhoneNumberException.withDetail("account: " + request.phoneNumber()); + } Owner owner = request.toOwner(passwordEncoder); Owner saved = ownerRepository.save(owner); OwnerShop.OwnerShopBuilder ownerShopBuilder = OwnerShop.builder().ownerId(owner.getId()); @@ -126,7 +160,7 @@ public void register(OwnerRegisterRequest request) { @Transactional public void registerByPhone(OwnerRegisterByPhoneRequest request) { if (userRepository.findByPhoneNumberAndUserType(request.phoneNumber(), OWNER).isPresent()) { - throw DuplicationPhoneNumberException.withDetail("phoneNumber: " + request.phoneNumber()); + throw DuplicationPhoneNumberException.withDetail("account: " + request.phoneNumber()); } if (ownerRepository.findByCompanyRegistrationNumber(request.companyNumber()).isPresent()) { throw DuplicationCompanyNumberException.withDetail("companyNumber: " + request.companyNumber()); @@ -139,7 +173,7 @@ public void registerByPhone(OwnerRegisterByPhoneRequest request) { ownerShopBuilder.shopId(shop.getId()); } ownerShopRedisRepository.save(ownerShopBuilder.build()); - eventPublisher.publishEvent(new OwnerRegisterEvent(saved)); + eventPublisher.publishEvent(new OwnerRegisterBySmsEvent(saved)); } @Transactional @@ -218,4 +252,16 @@ private void verifyCode(String key, String code) { } ownerVerificationStatusRepository.deleteById(key); } + + public void checkCompanyNumber(CompanyNumberCheckRequest request) { + if (ownerRepository.findByCompanyRegistrationNumber(request.companyNumber()).isPresent()) { + throw DuplicationCompanyNumberException.withDetail("companyNumber: " + request.companyNumber()); + } + } + + public void checkExistsAccount(OwnerAccountCheckExistsRequest request) { + ownerRepository.findByAccount(request.account()).ifPresent(user -> { + throw DuplicationPhoneNumberException.withDetail("account: " + request.account()); + }); + } } diff --git a/src/main/java/in/koreatech/koin/domain/ownershop/controller/OwnerShopController.java b/src/main/java/in/koreatech/koin/domain/ownershop/controller/OwnerShopController.java index dcbe1696e..c7423a914 100644 --- a/src/main/java/in/koreatech/koin/domain/ownershop/controller/OwnerShopController.java +++ b/src/main/java/in/koreatech/koin/domain/ownershop/controller/OwnerShopController.java @@ -153,7 +153,7 @@ public ResponseEntity modifyMenuCategory( public ResponseEntity modifyOwnerShop( @Auth(permit = {OWNER}) Integer ownerId, @PathVariable("id") Integer shopId, - @RequestBody @Valid ModifyShopRequest modifyShopRequest + @Valid @RequestBody ModifyShopRequest modifyShopRequest ) { ownerShopService.modifyShop(ownerId, shopId, modifyShopRequest); return ResponseEntity.status(HttpStatus.CREATED).build(); diff --git a/src/main/java/in/koreatech/koin/domain/ownershop/dto/OwnerShopsRequest.java b/src/main/java/in/koreatech/koin/domain/ownershop/dto/OwnerShopsRequest.java index b62f209e2..2664c5ad1 100644 --- a/src/main/java/in/koreatech/koin/domain/ownershop/dto/OwnerShopsRequest.java +++ b/src/main/java/in/koreatech/koin/domain/ownershop/dto/OwnerShopsRequest.java @@ -12,7 +12,9 @@ import in.koreatech.koin.domain.shop.model.Shop; import in.koreatech.koin.global.validation.UniqueId; import in.koreatech.koin.global.validation.UniqueUrl; +import in.koreatech.koin.global.validation.ValidDayOfWeek; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Pattern; @@ -55,6 +57,9 @@ public record OwnerShopsRequest( String name, @Schema(description = "요일별 운영 시간과 휴무 여부", requiredMode = REQUIRED) + @Size(min = 7, max = 7, message = "The list must contain exactly 7 elements.") + @NotNull + @Valid List open, @Schema(description = "계좌 이체 가능 여부", example = "true", requiredMode = REQUIRED) @@ -101,6 +106,7 @@ public record InnerOpenRequest( @Schema(description = "요일", example = "MONDAY", requiredMode = REQUIRED) @NotBlank(message = "영업 요일을 입력해주세요.") + @ValidDayOfWeek String dayOfWeek, @Schema(description = "여는 시간", example = "10:00", requiredMode = REQUIRED) diff --git a/src/main/java/in/koreatech/koin/domain/shop/dto/CreateMenuRequest.java b/src/main/java/in/koreatech/koin/domain/shop/dto/CreateMenuRequest.java index 39f6d96e3..59bf3e9a8 100644 --- a/src/main/java/in/koreatech/koin/domain/shop/dto/CreateMenuRequest.java +++ b/src/main/java/in/koreatech/koin/domain/shop/dto/CreateMenuRequest.java @@ -12,6 +12,7 @@ import in.koreatech.koin.global.validation.UniqueId; import in.koreatech.koin.global.validation.UniqueUrl; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.PositiveOrZero; import jakarta.validation.constraints.Size; @@ -45,6 +46,7 @@ public record CreateMenuRequest( String name, @Schema(description = "단일 메뉴가 아닐때의 옵션에 따른 가격 리스트 / 단일 메뉴일 경우 null", requiredMode = NOT_REQUIRED) + @Valid List optionPrices, @Schema(description = "단일 메뉴일때의 가격 / 단일 메뉴가 아닐 경우 null", requiredMode = NOT_REQUIRED) diff --git a/src/main/java/in/koreatech/koin/domain/shop/dto/ModifyMenuRequest.java b/src/main/java/in/koreatech/koin/domain/shop/dto/ModifyMenuRequest.java index dd760a907..8f64856e1 100644 --- a/src/main/java/in/koreatech/koin/domain/shop/dto/ModifyMenuRequest.java +++ b/src/main/java/in/koreatech/koin/domain/shop/dto/ModifyMenuRequest.java @@ -11,6 +11,7 @@ import in.koreatech.koin.global.validation.UniqueId; import in.koreatech.koin.global.validation.UniqueUrl; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.PositiveOrZero; import jakarta.validation.constraints.Size; @@ -44,6 +45,7 @@ public record ModifyMenuRequest( String name, @Schema(description = "단일 메뉴가 아닐때의 옵션에 따른 가격 리스트 / 단일 메뉴일 경우 null", requiredMode = NOT_REQUIRED) + @Valid List optionPrices, @Schema(description = "단일 메뉴일때의 가격 / 단일 메뉴가 아닐 경우 null", requiredMode = NOT_REQUIRED) diff --git a/src/main/java/in/koreatech/koin/domain/shop/dto/ModifyShopRequest.java b/src/main/java/in/koreatech/koin/domain/shop/dto/ModifyShopRequest.java index 746dc5ce0..78933ba2d 100644 --- a/src/main/java/in/koreatech/koin/domain/shop/dto/ModifyShopRequest.java +++ b/src/main/java/in/koreatech/koin/domain/shop/dto/ModifyShopRequest.java @@ -14,7 +14,9 @@ import in.koreatech.koin.domain.shop.model.ShopOpen; import in.koreatech.koin.global.validation.UniqueId; import in.koreatech.koin.global.validation.UniqueUrl; +import in.koreatech.koin.global.validation.ValidDayOfWeek; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.PositiveOrZero; @@ -55,6 +57,7 @@ public record ModifyShopRequest( String name, @Schema(description = "요일별 휴무 여부 및 장사 시간", requiredMode = NOT_REQUIRED) + @Valid List open, @Schema(example = "true", description = "계좌 이체 가능 여부", requiredMode = REQUIRED) @@ -75,6 +78,7 @@ public record InnerShopOpen( @Schema(example = "MONDAY", description = """ 요일 = ['MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY', 'SUNDAY'] """, requiredMode = REQUIRED) + @ValidDayOfWeek String dayOfWeek, @Schema(example = "false", description = "휴무 여부", requiredMode = REQUIRED) diff --git a/src/main/java/in/koreatech/koin/domain/shop/model/Menu.java b/src/main/java/in/koreatech/koin/domain/shop/model/Menu.java index 481990e50..666be8bd1 100644 --- a/src/main/java/in/koreatech/koin/domain/shop/model/Menu.java +++ b/src/main/java/in/koreatech/koin/domain/shop/model/Menu.java @@ -7,6 +7,7 @@ import java.util.ArrayList; import java.util.List; +import in.koreatech.koin.admin.shop.dto.AdminModifyMenuRequest; import in.koreatech.koin.domain.shop.dto.ModifyMenuRequest; import in.koreatech.koin.domain.shop.dto.ModifyMenuRequest.InnerOptionPrice; import in.koreatech.koin.global.domain.BaseEntity; @@ -127,6 +128,17 @@ public void modifyMenuSingleOptions(ModifyMenuRequest modifyMenuRequest, EntityM this.menuOptions.add(menuOption); } + public void adminModifyMenuSingleOptions(AdminModifyMenuRequest adminModifyMenuRequest, + EntityManager entityManager) { + this.menuOptions.clear(); + entityManager.flush(); + MenuOption menuOption = MenuOption.builder() + .price(adminModifyMenuRequest.singlePrice()) + .menu(this) + .build(); + this.menuOptions.add(menuOption); + } + public void modifyMenuMultipleOptions(List innerOptionPrice, EntityManager entityManager) { this.menuOptions.clear(); entityManager.flush(); @@ -139,4 +151,18 @@ public void modifyMenuMultipleOptions(List innerOptionPrice, E this.menuOptions.add(menuOption); } } + + public void adminModifyMenuMultipleOptions(List innerOptionPrice, + EntityManager entityManager) { + this.menuOptions.clear(); + entityManager.flush(); + for (var option : innerOptionPrice) { + MenuOption menuOption = MenuOption.builder() + .option(option.option()) + .price(option.price()) + .menu(this) + .build(); + this.menuOptions.add(menuOption); + } + } } diff --git a/src/main/java/in/koreatech/koin/domain/shop/model/Shop.java b/src/main/java/in/koreatech/koin/domain/shop/model/Shop.java index 22e94d826..87549b328 100644 --- a/src/main/java/in/koreatech/koin/domain/shop/model/Shop.java +++ b/src/main/java/in/koreatech/koin/domain/shop/model/Shop.java @@ -1,9 +1,6 @@ package in.koreatech.koin.domain.shop.model; -import static jakarta.persistence.CascadeType.MERGE; -import static jakarta.persistence.CascadeType.PERSIST; -import static jakarta.persistence.CascadeType.REFRESH; -import static jakarta.persistence.CascadeType.REMOVE; +import static jakarta.persistence.CascadeType.*; import static jakarta.persistence.GenerationType.IDENTITY; import static lombok.AccessLevel.PROTECTED; @@ -16,6 +13,7 @@ import org.hibernate.annotations.Where; +import in.koreatech.koin.admin.shop.dto.AdminModifyShopRequest; import in.koreatech.koin.domain.owner.model.Owner; import in.koreatech.koin.domain.shop.dto.ModifyShopRequest.InnerShopOpen; import in.koreatech.koin.global.domain.BaseEntity; @@ -195,6 +193,18 @@ public void modifyShopOpens(List innerShopOpens, EntityManager en } } + public void modifyAdminShopOpens( + List innerShopOpens, + EntityManager entityManager + ) { + this.shopOpens.clear(); + entityManager.flush(); + for (var open : innerShopOpens) { + ShopOpen shopOpen = open.toEntity(this); + this.shopOpens.add(shopOpen); + } + } + public void modifyShopCategories(List shopCategories, EntityManager entityManager) { this.shopCategories.clear(); entityManager.flush(); @@ -215,7 +225,8 @@ public boolean isOpen(LocalDateTime now) { return true; } if ( - shopOpen.getDayOfWeek().equals(prevDayOfWeek) && isBetweenDate(now, shopOpen, now.minusDays(1).toLocalDate()) + shopOpen.getDayOfWeek().equals(prevDayOfWeek) && isBetweenDate(now, shopOpen, + now.minusDays(1).toLocalDate()) ) { return true; } @@ -231,4 +242,16 @@ private boolean isBetweenDate(LocalDateTime now, ShopOpen shopOpen, LocalDate cr } return !start.isAfter(now) && !end.isBefore(now); } + + public void updateOwner(Owner owner) { + this.owner = owner; + } + + public void delete() { + this.isDeleted = true; + } + + public void cancelDelete() { + this.isDeleted = false; + } } diff --git a/src/main/java/in/koreatech/koin/domain/shop/model/ShopCategory.java b/src/main/java/in/koreatech/koin/domain/shop/model/ShopCategory.java index a290eed14..2375d5dc6 100644 --- a/src/main/java/in/koreatech/koin/domain/shop/model/ShopCategory.java +++ b/src/main/java/in/koreatech/koin/domain/shop/model/ShopCategory.java @@ -55,4 +55,13 @@ private ShopCategory(String name, String imageUrl, Boolean isDeleted) { this.imageUrl = imageUrl; this.isDeleted = isDeleted; } + + public void modifyShopCategory(String name, String imageUrl) { + this.name = name; + this.imageUrl = imageUrl; + } + + public void delete() { + this.isDeleted = true; + } } diff --git a/src/main/java/in/koreatech/koin/domain/timetable/controller/TimetableApi.java b/src/main/java/in/koreatech/koin/domain/timetable/controller/TimetableApi.java index d821e1133..0994b0140 100644 --- a/src/main/java/in/koreatech/koin/domain/timetable/controller/TimetableApi.java +++ b/src/main/java/in/koreatech/koin/domain/timetable/controller/TimetableApi.java @@ -5,6 +5,7 @@ import java.util.List; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; @@ -14,9 +15,9 @@ import in.koreatech.koin.domain.timetable.dto.LectureResponse; import in.koreatech.koin.domain.timetable.dto.SemesterCheckResponse; import in.koreatech.koin.domain.timetable.dto.SemesterResponse; -import in.koreatech.koin.domain.timetable.dto.TimeTableCreateRequest; -import in.koreatech.koin.domain.timetable.dto.TimeTableResponse; -import in.koreatech.koin.domain.timetable.dto.TimeTableUpdateRequest; +import in.koreatech.koin.domain.timetable.dto.TimetableCreateRequest; +import in.koreatech.koin.domain.timetable.dto.TimetableResponse; +import in.koreatech.koin.domain.timetable.dto.TimetableUpdateRequest; import in.koreatech.koin.global.auth.Auth; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; @@ -79,7 +80,7 @@ ResponseEntity getStudentSemesters( @Operation(summary = "시간표 정보 조회") @SecurityRequirement(name = "Jwt Authentication") @GetMapping("/timetables") - ResponseEntity getTimeTables( + ResponseEntity getTimetables( @RequestParam(value = "semester") String semester, @Auth(permit = {STUDENT}) Integer userId ); @@ -95,8 +96,8 @@ ResponseEntity getTimeTables( @Operation(summary = "시간표 정보 생성") @SecurityRequirement(name = "Jwt Authentication") @PostMapping("/timetables") - ResponseEntity createTimeTables( - @RequestBody TimeTableCreateRequest timeTableCreateRequest, + ResponseEntity createTimetables( + @RequestBody TimetableCreateRequest request, @Auth(permit = {STUDENT}) Integer userId ); @@ -111,8 +112,8 @@ ResponseEntity createTimeTables( @Operation(summary = "시간표 정보 수정") @SecurityRequirement(name = "Jwt Authentication") @PutMapping("/timetables") - ResponseEntity updateTimeTable( - @RequestBody TimeTableUpdateRequest request, + ResponseEntity updateTimetable( + @RequestBody TimetableUpdateRequest request, @Auth(permit = {STUDENT}) Integer userId ); @@ -126,8 +127,8 @@ ResponseEntity updateTimeTable( ) @Operation(summary = "시간표 삭제") @SecurityRequirement(name = "Jwt Authentication") - @PutMapping("/timetables") - ResponseEntity deleteTimeTableById( + @DeleteMapping("/timetable") + ResponseEntity deleteTimetableById( @RequestParam(value = "id") Integer id, @Auth(permit = {STUDENT}) Integer userId ); diff --git a/src/main/java/in/koreatech/koin/domain/timetable/controller/TimetableController.java b/src/main/java/in/koreatech/koin/domain/timetable/controller/TimetableController.java index db9225872..e9ebdf4ec 100644 --- a/src/main/java/in/koreatech/koin/domain/timetable/controller/TimetableController.java +++ b/src/main/java/in/koreatech/koin/domain/timetable/controller/TimetableController.java @@ -16,9 +16,9 @@ import in.koreatech.koin.domain.timetable.dto.LectureResponse; import in.koreatech.koin.domain.timetable.dto.SemesterCheckResponse; import in.koreatech.koin.domain.timetable.dto.SemesterResponse; -import in.koreatech.koin.domain.timetable.dto.TimeTableCreateRequest; -import in.koreatech.koin.domain.timetable.dto.TimeTableResponse; -import in.koreatech.koin.domain.timetable.dto.TimeTableUpdateRequest; +import in.koreatech.koin.domain.timetable.dto.TimetableCreateRequest; +import in.koreatech.koin.domain.timetable.dto.TimetableResponse; +import in.koreatech.koin.domain.timetable.dto.TimetableUpdateRequest; import in.koreatech.koin.domain.timetable.service.SemesterService; import in.koreatech.koin.domain.timetable.service.TimetableService; import in.koreatech.koin.global.auth.Auth; @@ -55,38 +55,39 @@ public ResponseEntity getStudentSemesters( } @GetMapping("/timetables") - public ResponseEntity getTimeTables( + public ResponseEntity getTimetables( @RequestParam(name = "semester") String semester, @Auth(permit = {STUDENT}) Integer userId ) { - TimeTableResponse timeTableResponse = timetableService.getTimeTables(userId, semester); - return ResponseEntity.ok(timeTableResponse); + TimetableResponse timetableResponse = timetableService.getTimetables(userId, semester); + return ResponseEntity.ok(timetableResponse); } @PostMapping("/timetables") - public ResponseEntity createTimeTables( - @Valid @RequestBody TimeTableCreateRequest request, + public ResponseEntity createTimetables( + @Valid @RequestBody TimetableCreateRequest request, @Auth(permit = {STUDENT}) Integer userId ) { - TimeTableResponse timeTableResponse = timetableService.createTimeTables(userId, request); + TimetableResponse timeTableResponse = timetableService.createTimetables(userId, request); return ResponseEntity.ok(timeTableResponse); } @PutMapping("/timetables") - public ResponseEntity updateTimeTable( - @Valid @RequestBody TimeTableUpdateRequest request, + public ResponseEntity updateTimetable( + @Valid @RequestBody TimetableUpdateRequest request, @Auth(permit = {STUDENT}) Integer userId ) { - TimeTableResponse timeTableResponse = timetableService.updateTimeTables(userId, request); - return ResponseEntity.ok(timeTableResponse); + TimetableResponse timetableResponse = timetableService.updateTimetables(userId, request); + return ResponseEntity.ok(timetableResponse); } @DeleteMapping("/timetable") - public ResponseEntity deleteTimeTableById( - @RequestParam(name = "id") Integer id, + public ResponseEntity deleteTimetableById( + @RequestParam(name = "id") Integer timetableId, @Auth(permit = {STUDENT}) Integer userId ) { - timetableService.deleteTimeTable(id); + timetableService.deleteTimetableLecture(userId, timetableId); return ResponseEntity.ok().build(); } + } diff --git a/src/main/java/in/koreatech/koin/domain/timetable/dto/LectureResponse.java b/src/main/java/in/koreatech/koin/domain/timetable/dto/LectureResponse.java index 3d2136a18..1c0f8b586 100644 --- a/src/main/java/in/koreatech/koin/domain/timetable/dto/LectureResponse.java +++ b/src/main/java/in/koreatech/koin/domain/timetable/dto/LectureResponse.java @@ -15,6 +15,9 @@ @JsonNaming(value = SnakeCaseStrategy.class) public record LectureResponse( + @Schema(name = "과목 id", example = "1", requiredMode = REQUIRED) + Integer id, + @Schema(name = "과목 코드", example = "ARB244", requiredMode = REQUIRED) String code, @@ -54,6 +57,7 @@ public record LectureResponse( public static LectureResponse from(Lecture lecture) { return new LectureResponse( + lecture.getId(), lecture.getCode(), lecture.getName(), lecture.getGrades(), diff --git a/src/main/java/in/koreatech/koin/domain/timetable/dto/TimeTableCreateRequest.java b/src/main/java/in/koreatech/koin/domain/timetable/dto/TimetableCreateRequest.java similarity index 76% rename from src/main/java/in/koreatech/koin/domain/timetable/dto/TimeTableCreateRequest.java rename to src/main/java/in/koreatech/koin/domain/timetable/dto/TimetableCreateRequest.java index b61fc7dfc..8471c9a9e 100644 --- a/src/main/java/in/koreatech/koin/domain/timetable/dto/TimeTableCreateRequest.java +++ b/src/main/java/in/koreatech/koin/domain/timetable/dto/TimetableCreateRequest.java @@ -11,7 +11,7 @@ import com.fasterxml.jackson.databind.annotation.JsonNaming; import in.koreatech.koin.domain.timetable.model.Semester; -import in.koreatech.koin.domain.timetable.model.TimeTable; +import in.koreatech.koin.domain.timetable.model.Timetable; import in.koreatech.koin.domain.user.model.User; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; @@ -20,11 +20,11 @@ import jakarta.validation.constraints.Size; @JsonNaming(value = SnakeCaseStrategy.class) -public record TimeTableCreateRequest( +public record TimetableCreateRequest( @Valid @Schema(description = "시간표 정보", requiredMode = REQUIRED) @NotNull(message = "시간표 정보를 입력해주세요.") - List timetable, + List timetable, @Schema(description = "학기 정보", example = "20192", requiredMode = REQUIRED) @NotBlank(message = "학기 정보를 입력해주세요.") @@ -32,7 +32,7 @@ public record TimeTableCreateRequest( ) { @JsonNaming(value = SnakeCaseStrategy.class) - public record InnerTimeTableRequest( + public record InnerTimetableRequest( @Schema(description = "과목 코드", example = "CPC490", requiredMode = NOT_REQUIRED) String code, @@ -77,30 +77,10 @@ public record InnerTimeTableRequest( @Size(max = 200, message = "메모는 200자 이하로 입력해주세요.") String memo ) { - public InnerTimeTableRequest { + public InnerTimetableRequest { if (Objects.isNull(grades)) { grades = "0"; } } - - public TimeTable toTimeTable(User user, Semester semester) { - return TimeTable.builder() - .user(user) - .semester(semester) - .code(this.code) - .classTitle(this.classTitle()) - .classTime(Arrays.toString(this.classTime().stream().toArray())) - .classPlace(this.classPlace()) - .professor(this.professor()) - .grades(this.grades()) - .lectureClass(this.lectureClass()) - .target(this.target()) - .regularNumber(this.regularNumber()) - .designScore(this.designScore()) - .department(this.department()) - .memo(this.memo()) - .isDeleted(false) - .build(); - } } } diff --git a/src/main/java/in/koreatech/koin/domain/timetable/dto/TimeTableResponse.java b/src/main/java/in/koreatech/koin/domain/timetable/dto/TimetableResponse.java similarity index 62% rename from src/main/java/in/koreatech/koin/domain/timetable/dto/TimeTableResponse.java rename to src/main/java/in/koreatech/koin/domain/timetable/dto/TimetableResponse.java index 9a9969b81..5befc62c5 100644 --- a/src/main/java/in/koreatech/koin/domain/timetable/dto/TimeTableResponse.java +++ b/src/main/java/in/koreatech/koin/domain/timetable/dto/TimetableResponse.java @@ -10,16 +10,17 @@ import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; import com.fasterxml.jackson.databind.annotation.JsonNaming; -import in.koreatech.koin.domain.timetable.model.TimeTable; +import in.koreatech.koin.domain.timetableV2.model.TimetableFrame; +import in.koreatech.koin.domain.timetableV2.model.TimetableLecture; import io.swagger.v3.oas.annotations.media.Schema; @JsonNaming(value = SnakeCaseStrategy.class) -public record TimeTableResponse( +public record TimetableResponse( @Schema(name = "학기", example = "20241", requiredMode = REQUIRED) String semester, @Schema(name = "시간표 상세정보") - List timetable, + List timetable, @Schema(name = "해당 학기 학점", example = "21") Integer grades, @@ -29,11 +30,11 @@ public record TimeTableResponse( ) { @JsonNaming(value = SnakeCaseStrategy.class) - public record InnerTimeTableResponse( - @Schema(name = "시간표 ID", example = "1", requiredMode = REQUIRED) + public record InnerTimetableResponse( + @Schema(name = "시간표 id", example = "1", requiredMode = REQUIRED) Integer id, - @Schema(name = "과목 코드", example = "ARB244", requiredMode = NOT_REQUIRED) + @Schema(name = "수강 정원", example = "40", requiredMode = NOT_REQUIRED) String regularNumber, @Schema(name = "과목 코드", example = "ARB244", requiredMode = NOT_REQUIRED) @@ -70,34 +71,33 @@ public record InnerTimeTableResponse( String department ) { - public static List from(List timeTables) { - return timeTables.stream() - .map(it -> new InnerTimeTableResponse( - it.getId(), - it.getRegularNumber(), - it.getCode(), - it.getDesignScore(), - parseIntegerClassTimesFromString(it.getClassTime()), - it.getClassPlace(), - it.getMemo(), - it.getGrades(), - it.getClassTitle(), - it.getLectureClass(), - it.getTarget(), - it.getProfessor(), - it.getDepartment() + public static List from(List timetableLectures) { + return timetableLectures.stream() + .map(timeTableLecture -> new InnerTimetableResponse( + timeTableLecture.getId(), + timeTableLecture.getLecture().getRegularNumber(), + timeTableLecture.getLecture().getCode(), + timeTableLecture.getLecture().getDesignScore(), + parseIntegerClassTimesFromString(timeTableLecture.getLecture().getClassTime()), + timeTableLecture.getClassPlace(), + timeTableLecture.getMemo(), + timeTableLecture.getLecture().getGrades(), + timeTableLecture.getLecture().getName(), + timeTableLecture.getLecture().getLectureClass(), + timeTableLecture.getLecture().getTarget(), + timeTableLecture.getLecture().getProfessor(), + timeTableLecture.getLecture().getDepartment() ) ) .toList(); } - } - public static TimeTableResponse of(String semester, List timeTables, Integer grades, - Integer totalGrades) { - return new TimeTableResponse( - semester, - InnerTimeTableResponse.from(timeTables), + public static TimetableResponse of(List timetableLectures, TimetableFrame timetableFrame, + Integer grades, Integer totalGrades) { + return new TimetableResponse( + timetableFrame.getSemester().getSemester(), + InnerTimetableResponse.from(timetableLectures), grades, totalGrades ); diff --git a/src/main/java/in/koreatech/koin/domain/timetable/dto/TimeTableUpdateRequest.java b/src/main/java/in/koreatech/koin/domain/timetable/dto/TimetableUpdateRequest.java similarity index 95% rename from src/main/java/in/koreatech/koin/domain/timetable/dto/TimeTableUpdateRequest.java rename to src/main/java/in/koreatech/koin/domain/timetable/dto/TimetableUpdateRequest.java index dffc70dc1..39e75178a 100644 --- a/src/main/java/in/koreatech/koin/domain/timetable/dto/TimeTableUpdateRequest.java +++ b/src/main/java/in/koreatech/koin/domain/timetable/dto/TimetableUpdateRequest.java @@ -17,11 +17,11 @@ import lombok.Builder; @JsonNaming(value = SnakeCaseStrategy.class) -public record TimeTableUpdateRequest( +public record TimetableUpdateRequest( @Valid @Schema(description = "시간표 정보", requiredMode = NOT_REQUIRED) @NotNull(message = "시간표 정보를 입력해주세요.") - List timetable, + List timetable, @Schema(description = "학기 정보", example = "20192", requiredMode = NOT_REQUIRED) @NotBlank(message = "학기 정보를 입력해주세요.") @@ -29,7 +29,7 @@ public record TimeTableUpdateRequest( ) { @JsonNaming(value = SnakeCaseStrategy.class) - public record InnerTimeTableRequest( + public record InnerTimetableRequest( @Schema(description = "시간표 식별 번호", example = "1", requiredMode = REQUIRED) @NotNull(message = "시간표 식별 번호를 입력해주세요.") Integer id, @@ -79,7 +79,7 @@ public record InnerTimeTableRequest( String memo ) { @Builder - public InnerTimeTableRequest { + public InnerTimetableRequest { if (Objects.isNull(grades)) { grades = "0"; } diff --git a/src/main/java/in/koreatech/koin/domain/timetable/exception/LectureNotFoundException.java b/src/main/java/in/koreatech/koin/domain/timetable/exception/LectureNotFoundException.java new file mode 100644 index 000000000..fd2cdc65a --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/timetable/exception/LectureNotFoundException.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.domain.timetable.exception; + +import in.koreatech.koin.global.exception.DataNotFoundException; + +public class LectureNotFoundException extends DataNotFoundException { + + private static final String DEFAULT_MESSAGE = "존재하지 않는 강의입니다."; + + public LectureNotFoundException(String message) { + super(message); + } + + public LectureNotFoundException(String message, String detail) { + super(message, detail); + } + + public static LectureNotFoundException withDetail(String detail) { + return new LectureNotFoundException(DEFAULT_MESSAGE, detail); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/timetable/exception/TimeTableNotFoundException.java b/src/main/java/in/koreatech/koin/domain/timetable/exception/TimetableNotFoundException.java similarity index 51% rename from src/main/java/in/koreatech/koin/domain/timetable/exception/TimeTableNotFoundException.java rename to src/main/java/in/koreatech/koin/domain/timetable/exception/TimetableNotFoundException.java index e76c9a3e8..5c50ce84c 100644 --- a/src/main/java/in/koreatech/koin/domain/timetable/exception/TimeTableNotFoundException.java +++ b/src/main/java/in/koreatech/koin/domain/timetable/exception/TimetableNotFoundException.java @@ -2,19 +2,19 @@ import in.koreatech.koin.global.exception.DataNotFoundException; -public class TimeTableNotFoundException extends DataNotFoundException { +public class TimetableNotFoundException extends DataNotFoundException { private static final String DEFAULT_MESSAGE = "존재하지 않는 시간표입니다."; - public TimeTableNotFoundException(String message) { + public TimetableNotFoundException(String message) { super(message); } - public TimeTableNotFoundException(String message, String detail) { + public TimetableNotFoundException(String message, String detail) { super(message, detail); } - public static TimeTableNotFoundException withDetail(String detail) { - return new TimeTableNotFoundException(DEFAULT_MESSAGE, detail); + public static TimetableNotFoundException withDetail(String detail) { + return new TimetableNotFoundException(DEFAULT_MESSAGE, detail); } } diff --git a/src/main/java/in/koreatech/koin/domain/timetable/model/TimeTable.java b/src/main/java/in/koreatech/koin/domain/timetable/model/Timetable.java similarity index 94% rename from src/main/java/in/koreatech/koin/domain/timetable/model/TimeTable.java rename to src/main/java/in/koreatech/koin/domain/timetable/model/Timetable.java index 6f929fcb4..3576f5a36 100644 --- a/src/main/java/in/koreatech/koin/domain/timetable/model/TimeTable.java +++ b/src/main/java/in/koreatech/koin/domain/timetable/model/Timetable.java @@ -5,7 +5,7 @@ import org.hibernate.annotations.Where; -import in.koreatech.koin.domain.timetable.dto.TimeTableUpdateRequest; +import in.koreatech.koin.domain.timetable.dto.TimetableUpdateRequest; import in.koreatech.koin.domain.user.model.User; import in.koreatech.koin.global.domain.BaseEntity; import jakarta.persistence.Column; @@ -27,7 +27,7 @@ @Table(name = "timetables") @Where(clause = "is_deleted=0") @NoArgsConstructor(access = PROTECTED) -public class TimeTable extends BaseEntity { +public class Timetable extends BaseEntity { @Id @GeneratedValue(strategy = IDENTITY) @@ -97,7 +97,7 @@ public class TimeTable extends BaseEntity { private boolean isDeleted = false; @Builder - private TimeTable(User user, Semester semester, String code, String classTitle, String classTime, + private Timetable(User user, Semester semester, String code, String classTitle, String classTime, String classPlace, String professor, String grades, String lectureClass, String target, String regularNumber, String designScore, String department, String memo, boolean isDeleted) { @@ -118,7 +118,7 @@ private TimeTable(User user, Semester semester, String code, String classTitle, this.isDeleted = isDeleted; } - public void update(TimeTableUpdateRequest.InnerTimeTableRequest timeTableRequest) { + public void update(TimetableUpdateRequest.InnerTimetableRequest timeTableRequest) { this.code = timeTableRequest.code(); this.classTitle = timeTableRequest.classTitle(); this.classTime = timeTableRequest.classTime().toString(); diff --git a/src/main/java/in/koreatech/koin/domain/timetable/repository/LectureRepository.java b/src/main/java/in/koreatech/koin/domain/timetable/repository/LectureRepository.java index 65c0b708b..02a163dec 100644 --- a/src/main/java/in/koreatech/koin/domain/timetable/repository/LectureRepository.java +++ b/src/main/java/in/koreatech/koin/domain/timetable/repository/LectureRepository.java @@ -1,9 +1,12 @@ package in.koreatech.koin.domain.timetable.repository; import java.util.List; +import java.util.Optional; import org.springframework.data.repository.Repository; +import in.koreatech.koin.domain.timetable.exception.LectureNotFoundException; +import in.koreatech.koin.domain.timetable.exception.SemesterNotFoundException; import in.koreatech.koin.domain.timetable.model.Lecture; public interface LectureRepository extends Repository { @@ -11,4 +14,20 @@ public interface LectureRepository extends Repository { List findBySemester(String semesterDate); Lecture save(Lecture lecture); + + Optional findById(Integer id); + + Optional findBySemesterAndNameAndLectureClass(String semesterDate, String name, String classLecture); + + default Lecture getBySemesterAndNameAndLectureClass(String semesterDate, String name, String classLecture) { + return findBySemesterAndNameAndLectureClass(semesterDate, name, classLecture) + .orElseThrow(() -> SemesterNotFoundException.withDetail("semester: " + semesterDate + " name: " + name + " classLecture: " + classLecture)); + } + + default Lecture getLectureById(Integer id) { + return findById(id) + .orElseThrow(() -> LectureNotFoundException.withDetail("lecture_id: " + id)); + } + + List findByIdIn(List lectureIds); } diff --git a/src/main/java/in/koreatech/koin/domain/timetable/repository/TimeTableRepository.java b/src/main/java/in/koreatech/koin/domain/timetable/repository/TimeTableRepository.java deleted file mode 100644 index 22e3e2e8c..000000000 --- a/src/main/java/in/koreatech/koin/domain/timetable/repository/TimeTableRepository.java +++ /dev/null @@ -1,27 +0,0 @@ -package in.koreatech.koin.domain.timetable.repository; - -import java.util.List; -import java.util.Optional; - -import org.springframework.data.repository.Repository; - -import in.koreatech.koin.domain.timetable.exception.TimeTableNotFoundException; -import in.koreatech.koin.domain.timetable.model.TimeTable; - -public interface TimeTableRepository extends Repository { - - TimeTable save(TimeTable timeTable); - - List findAllByUserId(Integer id); - - List findAllByUserIdAndSemesterId(Integer userId, Integer semesterId); - - Optional findById(Integer id); - - void deleteByUserIdAndSemesterId(Integer userId, Integer semesterId); - - default TimeTable getById(Integer id) { - return findById(id) - .orElseThrow(() -> TimeTableNotFoundException.withDetail("id: " + id)); - } -} diff --git a/src/main/java/in/koreatech/koin/domain/timetable/repository/TimetableRepository.java b/src/main/java/in/koreatech/koin/domain/timetable/repository/TimetableRepository.java new file mode 100644 index 000000000..1daf16aa5 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/timetable/repository/TimetableRepository.java @@ -0,0 +1,27 @@ +package in.koreatech.koin.domain.timetable.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.timetable.exception.TimetableNotFoundException; +import in.koreatech.koin.domain.timetable.model.Timetable; + +public interface TimetableRepository extends Repository { + + Timetable save(Timetable timeTable); + + List findAllByUserId(Integer id); + + List findAllByUserIdAndSemesterId(Integer userId, Integer semesterId); + + Optional findById(Integer id); + + void deleteByUserIdAndSemesterId(Integer userId, Integer semesterId); + + default Timetable getById(Integer id) { + return findById(id) + .orElseThrow(() -> TimetableNotFoundException.withDetail("id: " + id)); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/timetable/service/SemesterService.java b/src/main/java/in/koreatech/koin/domain/timetable/service/SemesterService.java index b1891794a..047fb9aaf 100644 --- a/src/main/java/in/koreatech/koin/domain/timetable/service/SemesterService.java +++ b/src/main/java/in/koreatech/koin/domain/timetable/service/SemesterService.java @@ -7,9 +7,9 @@ import in.koreatech.koin.domain.timetable.dto.SemesterCheckResponse; import in.koreatech.koin.domain.timetable.dto.SemesterResponse; -import in.koreatech.koin.domain.timetable.model.TimeTable; import in.koreatech.koin.domain.timetable.repository.SemesterRepository; -import in.koreatech.koin.domain.timetable.repository.TimeTableRepository; +import in.koreatech.koin.domain.timetableV2.model.TimetableFrame; +import in.koreatech.koin.domain.timetableV2.repository.TimetableFrameRepositoryV2; import lombok.RequiredArgsConstructor; @Service @@ -18,7 +18,7 @@ public class SemesterService { private final SemesterRepository semesterRepository; - private final TimeTableRepository timetableRepository; + private final TimetableFrameRepositoryV2 timetableFrameRepositoryV2; public List getSemesters() { return semesterRepository.findAllByOrderBySemesterDesc().stream() @@ -27,9 +27,9 @@ public List getSemesters() { } public SemesterCheckResponse getStudentSemesters(Integer userId) { - List timetables = timetableRepository.findAllByUserId(userId); - List semesters = timetables.stream() - .map(timetable -> timetable.getSemester().getSemester()) + List timeTableFrames = timetableFrameRepositoryV2.findByUserIdAndIsMainTrue(userId); + List semesters = timeTableFrames.stream() + .map(timeTableFrame -> timeTableFrame.getSemester().getSemester()) .distinct() .toList(); return SemesterCheckResponse.of(userId, semesters); diff --git a/src/main/java/in/koreatech/koin/domain/timetable/service/TimetableService.java b/src/main/java/in/koreatech/koin/domain/timetable/service/TimetableService.java index bc222c4a4..41aacf4bb 100644 --- a/src/main/java/in/koreatech/koin/domain/timetable/service/TimetableService.java +++ b/src/main/java/in/koreatech/koin/domain/timetable/service/TimetableService.java @@ -1,23 +1,26 @@ package in.koreatech.koin.domain.timetable.service; +import java.util.ArrayList; import java.util.List; +import java.util.Objects; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import in.koreatech.koin.domain.timetable.dto.LectureResponse; -import in.koreatech.koin.domain.timetable.dto.TimeTableCreateRequest; -import in.koreatech.koin.domain.timetable.dto.TimeTableResponse; -import in.koreatech.koin.domain.timetable.dto.TimeTableUpdateRequest; +import in.koreatech.koin.domain.timetable.dto.TimetableCreateRequest; +import in.koreatech.koin.domain.timetable.dto.TimetableResponse; +import in.koreatech.koin.domain.timetable.dto.TimetableUpdateRequest; import in.koreatech.koin.domain.timetable.exception.SemesterNotFoundException; import in.koreatech.koin.domain.timetable.model.Lecture; import in.koreatech.koin.domain.timetable.model.Semester; -import in.koreatech.koin.domain.timetable.model.TimeTable; import in.koreatech.koin.domain.timetable.repository.LectureRepository; import in.koreatech.koin.domain.timetable.repository.SemesterRepository; -import in.koreatech.koin.domain.timetable.repository.TimeTableRepository; -import in.koreatech.koin.domain.user.model.User; -import in.koreatech.koin.domain.user.repository.UserRepository; +import in.koreatech.koin.domain.timetableV2.model.TimetableFrame; +import in.koreatech.koin.domain.timetableV2.model.TimetableLecture; +import in.koreatech.koin.domain.timetableV2.repository.TimetableFrameRepositoryV2; +import in.koreatech.koin.domain.timetableV2.repository.TimetableLectureRepositoryV2; +import in.koreatech.koin.global.auth.exception.AuthorizationException; import lombok.RequiredArgsConstructor; @Service @@ -26,9 +29,9 @@ public class TimetableService { private final LectureRepository lectureRepository; + private final TimetableLectureRepositoryV2 timetableLectureRepositoryV2; + private final TimetableFrameRepositoryV2 timetableFrameRepositoryV2; private final SemesterRepository semesterRepository; - private final TimeTableRepository timeTableRepository; - private final UserRepository userRepository; public List getLecturesBySemester(String semester) { List lectures = lectureRepository.findBySemester(semester); @@ -40,58 +43,99 @@ public List getLecturesBySemester(String semester) { .toList(); } - public TimeTableResponse getTimeTables(Integer userId, String semesterRequest) { - Semester semester = semesterRepository.getBySemester(semesterRequest); - return getTimeTableResponse(userId, semester); - } - @Transactional - public TimeTableResponse createTimeTables(Integer userId, TimeTableCreateRequest request) { - User user = userRepository.getById(userId); + public TimetableResponse createTimetables(Integer userId, TimetableCreateRequest request) { Semester semester = semesterRepository.getBySemester(request.semester()); - for (TimeTableCreateRequest.InnerTimeTableRequest timeTableRequest : request.timetable()) { - TimeTable timeTable = timeTableRequest.toTimeTable(user, semester); - timeTableRepository.save(timeTable); + List timetableLectures = new ArrayList<>(); + TimetableFrame timetableFrame = timetableFrameRepositoryV2.getMainTimetableByUserIdAndSemesterId(userId, + semester.getId()); + + for (TimetableCreateRequest.InnerTimetableRequest timeTable : request.timetable()) { + Lecture lecture = lectureRepository.getBySemesterAndNameAndLectureClass(request.semester(), + timeTable.classTitle(), timeTable.lectureClass()); + TimetableLecture timetableLecture = TimetableLecture.builder() + .classPlace(timeTable.classPlace()) + .grades("0") + .memo(timeTable.memo()) + .lecture(lecture) + .timetableFrame(timetableFrame) + .build(); + + timetableLectures.add(timetableLectureRepositoryV2.save(timetableLecture)); } - return getTimeTableResponse(userId, semester); + + return getTimetableResponse(userId, timetableFrame, timetableLectures); } @Transactional - public TimeTableResponse updateTimeTables(Integer userId, TimeTableUpdateRequest request) { + public TimetableResponse updateTimetables(Integer userId, TimetableUpdateRequest request) { Semester semester = semesterRepository.getBySemester(request.semester()); - for (TimeTableUpdateRequest.InnerTimeTableRequest timeTableRequest : request.timetable()) { - TimeTable timeTable = timeTableRepository.getById(timeTableRequest.id()); - timeTable.update(timeTableRequest); + TimetableFrame timetableFrame = timetableFrameRepositoryV2.getMainTimetableByUserIdAndSemesterId(userId, + semester.getId()); + for (TimetableUpdateRequest.InnerTimetableRequest timetableRequest : request.timetable()) { + TimetableLecture timetableLecture = timetableLectureRepositoryV2.getById(timetableRequest.id()); + timetableLecture.update(timetableRequest); + timetableLectureRepositoryV2.save(timetableLecture); } - return getTimeTableResponse(userId, semester); + return getTimetableResponse(userId, timetableFrame); + } + + public TimetableResponse getTimetables(Integer userId, String semesterRequest) { + Semester semester = semesterRepository.getBySemester(semesterRequest); + TimetableFrame timetableFrame = timetableFrameRepositoryV2.getMainTimetableByUserIdAndSemesterId(userId, + semester.getId()); + return getTimetableResponse(userId, timetableFrame); } @Transactional - public void deleteTimeTable(Integer id) { - TimeTable timeTable = timeTableRepository.getById(id); - timeTable.updateIsDeleted(true); + public void deleteTimetableLecture(Integer userId, Integer timetableLectureId) { + TimetableLecture timetableLecture = timetableLectureRepositoryV2.getById(timetableLectureId); + TimetableFrame frame = timetableFrameRepositoryV2.getById(timetableLecture.getTimetableFrame().getId()); + if (!Objects.equals(frame.getUser().getId(), userId)) { + throw AuthorizationException.withDetail("userId: " + userId); + } + + timetableLectureRepositoryV2.deleteById(timetableLectureId); } - private TimeTableResponse getTimeTableResponse(Integer userId, Semester semester) { - List timeTables = timeTableRepository.findAllByUserIdAndSemesterId(userId, semester.getId()); - Integer grades = timeTables.stream() - .mapToInt(timeTable -> Integer.parseInt(timeTable.getGrades())) + private TimetableResponse getTimetableResponse(Integer userId, TimetableFrame timetableFrame) { + int grades = 0; + int totalGrades = 0; + + List timetableLectures = timetableLectureRepositoryV2.findAllByTimetableFrameId( + timetableFrame.getId()); + grades = timetableLectures.stream() + .mapToInt(lecture -> Integer.parseInt(lecture.getLecture().getGrades())) .sum(); - Integer totalGrades = calculateTotalGrades(userId); - return TimeTableResponse.of(semester.getSemester(), timeTables, grades, totalGrades); + for (TimetableFrame timetableFrames : timetableFrameRepositoryV2.findByUserIdAndIsMainTrue(userId)) { + totalGrades += timetableLectureRepositoryV2.findAllByTimetableFrameId(timetableFrames.getId()).stream() + .filter(lecture -> lecture.getLecture() != null) + .mapToInt(lecture -> Integer.parseInt(lecture.getLecture().getGrades())) + .sum(); + } + + return TimetableResponse.of(timetableLectures, timetableFrame, grades, totalGrades); } - private int calculateTotalGrades(Integer userId) { + private TimetableResponse getTimetableResponse(Integer userId, TimetableFrame timetableFrame, + List timetableLectures) { + int grades = 0; int totalGrades = 0; - List semesters = semesterRepository.findAllByOrderBySemesterDesc(); - for (Semester semester : semesters) { - totalGrades += timeTableRepository.findAllByUserIdAndSemesterId(userId, semester.getId()).stream() - .mapToInt(timeTable -> Integer.parseInt(timeTable.getGrades())) + if (timetableFrame.isMain()) { + grades = timetableLectures.stream() + .filter(lecture -> lecture.getLecture() != null) + .mapToInt(lecture -> Integer.parseInt(lecture.getLecture().getGrades())) + .sum(); + } + for (TimetableFrame timetableFrames : timetableFrameRepositoryV2.findByUserIdAndIsMainTrue(userId)) { + totalGrades += timetableLectureRepositoryV2.findAllByTimetableFrameId(timetableFrames.getId()).stream() + .filter(lecture -> lecture.getLecture() != null) + .mapToInt(lecture -> Integer.parseInt(lecture.getLecture().getGrades())) .sum(); } - return totalGrades; + return TimetableResponse.of(timetableLectures, timetableFrame, grades, totalGrades); } } diff --git a/src/main/java/in/koreatech/koin/domain/timetableV2/controller/TimetableApiV2.java b/src/main/java/in/koreatech/koin/domain/timetableV2/controller/TimetableApiV2.java new file mode 100644 index 000000000..ea0f7a352 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/timetableV2/controller/TimetableApiV2.java @@ -0,0 +1,163 @@ +package in.koreatech.koin.domain.timetableV2.controller; + +import static in.koreatech.koin.domain.user.model.UserType.STUDENT; + +import java.util.List; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +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.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; + +import in.koreatech.koin.domain.timetableV2.dto.TimetableFrameCreateRequest; +import in.koreatech.koin.domain.timetableV2.dto.TimetableFrameResponse; +import in.koreatech.koin.domain.timetableV2.dto.TimetableFrameUpdateRequest; +import in.koreatech.koin.domain.timetableV2.dto.TimetableFrameUpdateResponse; +import in.koreatech.koin.domain.timetableV2.dto.TimetableLectureCreateRequest; +import in.koreatech.koin.domain.timetableV2.dto.TimetableLectureResponse; +import in.koreatech.koin.domain.timetableV2.dto.TimetableLectureUpdateRequest; +import in.koreatech.koin.global.auth.Auth; +import io.swagger.v3.oas.annotations.Operation; +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 io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; + +@Tag(name = "(Normal) Timetable: V2-시간표", description = "시간표 정보를 관리한다") +public interface TimetableApiV2 { + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))) + } + ) + @Operation(summary = "시간표 프레임 생성") + @SecurityRequirement(name = "Jwt Authentication") + @PostMapping("/v2/timetables/frame") + ResponseEntity createTimetablesFrame( + @Valid @RequestBody TimetableFrameCreateRequest request, + @Auth(permit = {STUDENT}) Integer userId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))) + } + ) + @Operation(summary = "시간표 프레임 수정") + @SecurityRequirement(name = "Jwt Authentication") + @PutMapping("/v2/timetables/frame/{id}") + ResponseEntity updateTimetableFrame( + @PathVariable(value = "id") Integer timetableFrameId, + @Valid @RequestBody TimetableFrameUpdateRequest timetableFrameUpdateRequest, + @Auth(permit = {STUDENT}) Integer userId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))) + } + ) + @Operation(summary = "시간표 프레임 조회") + @SecurityRequirement(name = "Jwt Authentication") + @GetMapping("/v2/timetables/frame") + ResponseEntity> getTimetablesFrame( + @RequestParam(name = "semester") String semester, + @Auth(permit = {STUDENT}) Integer userId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))) + } + ) + @Operation(summary = "시간표 프레임 삭제") + @SecurityRequirement(name = "Jwt Authentication") + @DeleteMapping("/v2/timetables/frame") + ResponseEntity deleteTimetablesFrame( + @RequestParam(name = "id") Integer frameId, + @Auth(permit = {STUDENT}) Integer userId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))) + } + ) + @Operation(summary = "시간표에 강의 정보 추가") + @SecurityRequirement(name = "Jwt Authentication") + @PostMapping("/v2/timetables/lecture") + ResponseEntity createTimetableLecture( + @RequestBody TimetableLectureCreateRequest request, + @Auth(permit = {STUDENT}) Integer userId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))) + } + ) + @Operation(summary = "시간표에서 강의 정보 수정") + @SecurityRequirement(name = "Jwt Authentication") + @PutMapping("/v2/timetables/lecture") + ResponseEntity updateTimetableLecture( + @RequestBody TimetableLectureUpdateRequest request, + @Auth(permit = {STUDENT}) Integer userId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))) + } + ) + @Operation(summary = "시간표에서 강의 정보 조회") + @SecurityRequirement(name = "Jwt Authentication") + @GetMapping("/v2/timetables/lecture") + ResponseEntity getTimetableLecture( + @RequestParam(value = "timetable_frame_id") Integer timetableFrameId, + @Auth(permit = {STUDENT}) Integer userId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "204"), + @ApiResponse(responseCode = "400", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))) + } + ) + @Operation(summary = "시간표에서 강의 정보 삭제") + @SecurityRequirement(name = "Jwt Authentication") + @DeleteMapping("/v2/timetables/lecture/{id}") + ResponseEntity deleteTimetableLecture( + @PathVariable(value = "id") Integer timetableLectureId, + @Auth(permit = {STUDENT}) Integer userId + ); +} diff --git a/src/main/java/in/koreatech/koin/domain/timetableV2/controller/TimetableControllerV2.java b/src/main/java/in/koreatech/koin/domain/timetableV2/controller/TimetableControllerV2.java new file mode 100644 index 000000000..8d35b1536 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/timetableV2/controller/TimetableControllerV2.java @@ -0,0 +1,109 @@ +package in.koreatech.koin.domain.timetableV2.controller; + +import static in.koreatech.koin.domain.user.model.UserType.STUDENT; + +import java.util.List; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +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.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import in.koreatech.koin.domain.timetableV2.dto.TimetableFrameCreateRequest; +import in.koreatech.koin.domain.timetableV2.dto.TimetableFrameResponse; +import in.koreatech.koin.domain.timetableV2.dto.TimetableFrameUpdateRequest; +import in.koreatech.koin.domain.timetableV2.dto.TimetableFrameUpdateResponse; +import in.koreatech.koin.domain.timetableV2.dto.TimetableLectureCreateRequest; +import in.koreatech.koin.domain.timetableV2.dto.TimetableLectureResponse; +import in.koreatech.koin.domain.timetableV2.dto.TimetableLectureUpdateRequest; +import in.koreatech.koin.domain.timetableV2.service.TimetableServiceV2; +import in.koreatech.koin.global.auth.Auth; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +public class TimetableControllerV2 implements TimetableApiV2 { + + private final TimetableServiceV2 timetableServiceV2; + + @PostMapping("/v2/timetables/frame") + public ResponseEntity createTimetablesFrame( + @Valid @RequestBody TimetableFrameCreateRequest request, + @Auth(permit = {STUDENT}) Integer userId + ) { + TimetableFrameResponse response = timetableServiceV2.createTimetablesFrame(userId, request); + return ResponseEntity.ok(response); + } + + @PutMapping("/v2/timetables/frame/{id}") + public ResponseEntity updateTimetableFrame( + @PathVariable(value = "id") Integer timetableFrameId, + @Valid @RequestBody TimetableFrameUpdateRequest timetableFrameUpdateRequest, + @Auth(permit = {STUDENT}) Integer userId + ) { + TimetableFrameUpdateResponse timetableFrameUpdateResponse = + timetableServiceV2.updateTimetableFrame(timetableFrameId, timetableFrameUpdateRequest, userId); + return ResponseEntity.ok(timetableFrameUpdateResponse); + } + + @GetMapping("/v2/timetables/frames") + public ResponseEntity> getTimetablesFrame( + @RequestParam(name = "semester") String semester, + @Auth(permit = {STUDENT}) Integer userId + ) { + List timeTableFrameResponse = timetableServiceV2.getTimetablesFrame(userId, semester); + return ResponseEntity.ok(timeTableFrameResponse); + } + + @DeleteMapping("/v2/timetables/frame") + public ResponseEntity deleteTimetablesFrame( + @RequestParam(name = "id") Integer frameId, + @Auth(permit = {STUDENT}) Integer userId + ) { + timetableServiceV2.deleteTimetablesFrame(userId, frameId); + return ResponseEntity.noContent().build(); + } + + @PostMapping("/v2/timetables/lecture") + public ResponseEntity createTimetableLecture( + @Valid @RequestBody TimetableLectureCreateRequest request, + @Auth(permit = {STUDENT}) Integer userId + ) { + TimetableLectureResponse timeTableLectureResponse = timetableServiceV2.createTimetableLectures(userId, request); + return ResponseEntity.ok(timeTableLectureResponse); + } + + @PutMapping("/v2/timetables/lecture") + public ResponseEntity updateTimetableLecture( + @Valid @RequestBody TimetableLectureUpdateRequest request, + @Auth(permit = {STUDENT}) Integer userId + ) { + TimetableLectureResponse timetableLectureResponse = timetableServiceV2.updateTimetablesLectures(userId, request); + return ResponseEntity.ok(timetableLectureResponse); + } + + @GetMapping("/v2/timetables/lecture") + public ResponseEntity getTimetableLecture( + @RequestParam(name = "timetable_frame_id") Integer timetableFrameId, + @Auth(permit = {STUDENT}) Integer userId + ) { + TimetableLectureResponse timetableLectureResponse = timetableServiceV2.getTimetableLectures(userId, + timetableFrameId); + return ResponseEntity.ok(timetableLectureResponse); + } + + @DeleteMapping("/v2/timetables/lecture/{id}") + public ResponseEntity deleteTimetableLecture( + @PathVariable(value = "id") Integer timetableLectureId, + @Auth(permit = {STUDENT}) Integer userId + ) { + timetableServiceV2.deleteTimetableLecture(userId, timetableLectureId); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/timetableV2/dto/TimetableFrameCreateRequest.java b/src/main/java/in/koreatech/koin/domain/timetableV2/dto/TimetableFrameCreateRequest.java new file mode 100644 index 000000000..cb23713c1 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/timetableV2/dto/TimetableFrameCreateRequest.java @@ -0,0 +1,24 @@ +package in.koreatech.koin.domain.timetableV2.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import in.koreatech.koin.domain.timetable.model.Semester; +import in.koreatech.koin.domain.timetableV2.model.TimetableFrame; +import in.koreatech.koin.domain.user.model.User; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +public record TimetableFrameCreateRequest( + @Schema(description = "학기 정보", example = "20192", requiredMode = REQUIRED) + @NotBlank(message = "학기 정보를 입력해주세요") + String semester +) { + public TimetableFrame toTimetablesFrame(User user, Semester semester, String name, boolean isMain) { + return TimetableFrame.builder() + .user(user) + .semester(semester) + .name(name) + .isMain(isMain) + .build(); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/timetableV2/dto/TimetableFrameResponse.java b/src/main/java/in/koreatech/koin/domain/timetableV2/dto/TimetableFrameResponse.java new file mode 100644 index 000000000..1a2403f78 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/timetableV2/dto/TimetableFrameResponse.java @@ -0,0 +1,29 @@ +package in.koreatech.koin.domain.timetableV2.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.timetableV2.model.TimetableFrame; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record TimetableFrameResponse( + @Schema(description = "id", example = "1", requiredMode = REQUIRED) + Integer id, + + @Schema(description = "시간표 이름", example = "시간표1", requiredMode = REQUIRED) + String timetableName, + + @Schema(description = "메인 시간표 여부", example = "false", requiredMode = REQUIRED) + Boolean isMain +) { + public static TimetableFrameResponse from(TimetableFrame timetableFrame) { + return new TimetableFrameResponse( + timetableFrame.getId(), + timetableFrame.getName(), + timetableFrame.isMain() + ); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/timetableV2/dto/TimetableFrameUpdateRequest.java b/src/main/java/in/koreatech/koin/domain/timetableV2/dto/TimetableFrameUpdateRequest.java new file mode 100644 index 000000000..12ece7a7f --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/timetableV2/dto/TimetableFrameUpdateRequest.java @@ -0,0 +1,25 @@ +package in.koreatech.koin.domain.timetableV2.dto; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record TimetableFrameUpdateRequest( + @Schema(description = "시간표 이름", example = "시간표1", requiredMode = REQUIRED) + @Size(max = 255, message = "시간표 이름의 최대 길이는 255자입니다.") + @NotBlank(message = "시간표 이름을 입력해주세요.") + String name, + + @Schema(description = "메인 시간표 여부", example = "false", requiredMode = REQUIRED) + @NotNull(message = "시간표 메인 여부를 입력해주세요.") + Boolean isMain +) { + +} diff --git a/src/main/java/in/koreatech/koin/domain/timetableV2/dto/TimetableFrameUpdateResponse.java b/src/main/java/in/koreatech/koin/domain/timetableV2/dto/TimetableFrameUpdateResponse.java new file mode 100644 index 000000000..17245cad4 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/timetableV2/dto/TimetableFrameUpdateResponse.java @@ -0,0 +1,29 @@ +package in.koreatech.koin.domain.timetableV2.dto; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.timetableV2.model.TimetableFrame; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record TimetableFrameUpdateResponse( + @Schema(description = "id", example = "1", requiredMode = REQUIRED) + Integer id, + + @Schema(description = "시간표 이름", example = "시간표1", requiredMode = REQUIRED) + String name, + + @Schema(description = "메인 시간표 여부", example = "false", requiredMode = REQUIRED) + Boolean isMain +) { + + public static TimetableFrameUpdateResponse from(TimetableFrame timetableFrame) { + return new TimetableFrameUpdateResponse( + timetableFrame.getId(), + timetableFrame.getName(), + timetableFrame.isMain()); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/timetableV2/dto/TimetableLectureCreateRequest.java b/src/main/java/in/koreatech/koin/domain/timetableV2/dto/TimetableLectureCreateRequest.java new file mode 100644 index 000000000..97d3f11b4 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/timetableV2/dto/TimetableLectureCreateRequest.java @@ -0,0 +1,87 @@ +package in.koreatech.koin.domain.timetableV2.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.Arrays; +import java.util.List; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.timetable.model.Lecture; +import in.koreatech.koin.domain.timetableV2.model.TimetableFrame; +import in.koreatech.koin.domain.timetableV2.model.TimetableLecture; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record TimetableLectureCreateRequest( + @Schema(description = "시간표 프레임 id", example = "1", requiredMode = REQUIRED) + Integer timetableFrameId, + + @Valid + @Schema(description = "강의 시간표 정보", requiredMode = REQUIRED) + @NotNull(message = "시간표 정보를 입력해주세요.") + List timetableLecture +) { + @JsonNaming(value = SnakeCaseStrategy.class) + public record InnerTimeTableLectureRequest( + @Schema(description = "강의 이름", example = "기상분석", requiredMode = REQUIRED) + String classTitle, + + @Schema(description = "강의 시간", example = "[210, 211]", requiredMode = REQUIRED) + List classTime, + + @Schema(description = "강의 장소", example = "도서관", requiredMode = NOT_REQUIRED) + String classPlace, + + @Schema(description = "교수명", example = "이강환", requiredMode = NOT_REQUIRED) + String professor, + + @Schema(description = "학점", example = "3", requiredMode = NOT_REQUIRED) + String grades, + + @Schema(description = "메모", example = "메모메모", requiredMode = NOT_REQUIRED) + @Size(max = 200, message = "메모는 200자 이하로 입력해주세요.") + String memo, + + @Schema(description = "강의 고유 번호", example = "1", requiredMode = NOT_REQUIRED) + Integer lectureId + ){ + public InnerTimeTableLectureRequest { + if (grades == null) { + grades = "0"; + } + } + public TimetableLecture toTimetableLecture(TimetableFrame timetableFrame) { + return new TimetableLecture( + classTitle, + Arrays.toString(classTime().stream().toArray()), + classPlace, + professor, + grades, + memo, + false, + null, + timetableFrame + ); + } + + public TimetableLecture toTimetableLecture(TimetableFrame timetableFrame, Lecture lecture) { + return new TimetableLecture( + classTitle, + Arrays.toString(classTime().stream().toArray()), + classPlace, + professor, + grades, + memo, + false, + lecture, + timetableFrame + ); + } + } +} diff --git a/src/main/java/in/koreatech/koin/domain/timetableV2/dto/TimetableLectureResponse.java b/src/main/java/in/koreatech/koin/domain/timetableV2/dto/TimetableLectureResponse.java new file mode 100644 index 000000000..332acc9dd --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/timetableV2/dto/TimetableLectureResponse.java @@ -0,0 +1,138 @@ +package in.koreatech.koin.domain.timetableV2.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.timetableV2.model.TimetableLecture; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record TimetableLectureResponse( + @Schema(name = "시간표 프레임 id", example = "1") + Integer timetableFrameId, + + @Schema(name = "시간표 상세정보") + List timetable, + + @Schema(name = "해당 학기 학점", example = "21") + Integer grades, + + @Schema(name = "전체 학기 학점", example = "121") + Integer totalGrades +) { + @JsonNaming(value = SnakeCaseStrategy.class) + public record InnerTimetableLectureResponse( + @Schema(name = "시간표 id", example = "1", requiredMode = REQUIRED) + Integer id, + + @Schema(name = "수강 정원", example = "38", requiredMode = NOT_REQUIRED) + String regularNumber, + + @Schema(name = "과목 코드", example = "ARB244", requiredMode = NOT_REQUIRED) + String code, + + @Schema(description = "설계 학점", example = "0", requiredMode = NOT_REQUIRED) + String designScore, + + @Schema(description = "강의(커스텀) 시간", example = "[204, 205, 206, 207, 302, 303]", requiredMode = REQUIRED) + List classTime, + + @Schema(description = "강의 장소", example = "2 공학관", requiredMode = NOT_REQUIRED) + String classPlace, + + @Schema(description = "메모", example = "null", requiredMode = NOT_REQUIRED) + String memo, + + @Schema(name = "학점", example = "3", requiredMode = REQUIRED) + String grades, + + @Schema(name = "강의(커스텀) 이름", example = "한국사", requiredMode = REQUIRED) + String classTitle, + + @Schema(name = "분반", example = "01", requiredMode = NOT_REQUIRED) + String lectureClass, + + @Schema(name = "대상", example = "디자 1 건축", requiredMode = NOT_REQUIRED) + String target, + + @Schema(name = "강의 교수", example = "이돈우", requiredMode = NOT_REQUIRED) + String professor, + + @Schema(name = "학부", example = "디자인ㆍ건축공학부", requiredMode = NOT_REQUIRED) + String department + ) { + + public static List from(List timetableLectures) { + List timetableLectureList = new ArrayList<>(); + + for (TimetableLecture timetableLecture : timetableLectures) { + InnerTimetableLectureResponse response; + if (timetableLecture.getLecture() == null) { + response = new InnerTimetableLectureResponse( + timetableLecture.getId(), + null, + null, + null, + parseIntegerClassTimesFromString(timetableLecture.getClassTime()), + timetableLecture.getClassPlace(), + timetableLecture.getMemo(), + timetableLecture.getGrades(), + timetableLecture.getClassTitle(), + null, + null, + timetableLecture.getProfessor(), + null + ); + } else { + response = new InnerTimetableLectureResponse( + timetableLecture.getId(), + timetableLecture.getLecture().getRegularNumber(), + timetableLecture.getLecture().getCode(), + timetableLecture.getLecture().getDesignScore(), + parseIntegerClassTimesFromString(timetableLecture.getLecture().getClassTime()), + timetableLecture.getClassPlace(), + timetableLecture.getMemo(), + timetableLecture.getLecture().getGrades(), + timetableLecture.getLecture().getName(), + timetableLecture.getLecture().getLectureClass(), + timetableLecture.getLecture().getTarget(), + timetableLecture.getLecture().getProfessor(), + timetableLecture.getLecture().getDepartment() + ); + } + timetableLectureList.add(response); + } + return timetableLectureList; + } + } + + public static TimetableLectureResponse of(Integer timetableFrameId, List timetableLectures, + Integer grades, Integer totalGrades) { + return new TimetableLectureResponse(timetableFrameId, InnerTimetableLectureResponse.from(timetableLectures), + grades, totalGrades); + } + + private static final int INITIAL_BRACE_INDEX = 1; + + private static List parseIntegerClassTimesFromString(String classTime) { + String classTimeWithoutBrackets = classTime.substring(INITIAL_BRACE_INDEX, classTime.length() - 1); + + if (!classTimeWithoutBrackets.isEmpty()) { + return Arrays.stream(classTimeWithoutBrackets.split(",")) + .map(String::strip) + .map(Integer::parseInt) + .toList(); + } else { + return Collections.emptyList(); + } + } +} + diff --git a/src/main/java/in/koreatech/koin/domain/timetableV2/dto/TimetableLectureUpdateRequest.java b/src/main/java/in/koreatech/koin/domain/timetableV2/dto/TimetableLectureUpdateRequest.java new file mode 100644 index 000000000..41f66e6d4 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/timetableV2/dto/TimetableLectureUpdateRequest.java @@ -0,0 +1,56 @@ +package in.koreatech.koin.domain.timetableV2.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.List; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record TimetableLectureUpdateRequest( + @Schema(description = "시간표 프레임 id", example = "1", requiredMode = REQUIRED) + @NotNull(message = "시간표 식별 번호를 입력해주세요.") + Integer timetableFrameId, + + @Valid + @Schema(description = "시간표 정보", requiredMode = NOT_REQUIRED) + @NotNull(message = "시간표 정보를 입력해주세요.") + List timetableLecture +) { + @JsonNaming(value = SnakeCaseStrategy.class) + public record InnerTimetableLectureRequest( + @Schema(description = "시간표 강의 id", example = "1", requiredMode = REQUIRED) + Integer id, + + @Schema(description = "강의 id", example = "1", requiredMode = NOT_REQUIRED) + Integer lectureId, + + @Schema(description = "강의 이름", example = "운영체제", requiredMode = NOT_REQUIRED) + String classTitle, + + @Schema(description = "강의 시간", example = "[210, 211]", requiredMode = NOT_REQUIRED) + List classTime, + + @Schema(description = "강의 장소", example = "null", requiredMode = NOT_REQUIRED) + String classPlace, + + @Schema(name = "강의 교수", example = "이돈우", requiredMode = NOT_REQUIRED) + String professor, + + @Schema(description = "학점", example = "3", requiredMode = NOT_REQUIRED) + String grades, + + @Schema(name = "memo", example = "메모메모", requiredMode = NOT_REQUIRED) + @Size(max = 200, message = "메모는 200자 이하로 입력해주세요.") + String memo + ) { + + } +} diff --git a/src/main/java/in/koreatech/koin/domain/timetableV2/exception/TimetableFrameNotFoundException.java b/src/main/java/in/koreatech/koin/domain/timetableV2/exception/TimetableFrameNotFoundException.java new file mode 100644 index 000000000..45caab340 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/timetableV2/exception/TimetableFrameNotFoundException.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.domain.timetableV2.exception; + +import in.koreatech.koin.global.exception.DataNotFoundException; + +public class TimetableFrameNotFoundException extends DataNotFoundException { + + private static final String DEFAULT_MESSAGE = "존재하지 않는 시간표 프레임입니다."; + + public TimetableFrameNotFoundException(String message) { + super(message); + } + + public TimetableFrameNotFoundException(String message, String detail) { + super(message, detail); + } + + public static TimetableFrameNotFoundException withDetail(String detail) { + return new TimetableFrameNotFoundException(DEFAULT_MESSAGE, detail); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/timetableV2/exception/TimetableLectureNotFoundException.java b/src/main/java/in/koreatech/koin/domain/timetableV2/exception/TimetableLectureNotFoundException.java new file mode 100644 index 000000000..e450c3890 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/timetableV2/exception/TimetableLectureNotFoundException.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.domain.timetableV2.exception; + +import in.koreatech.koin.global.exception.DataNotFoundException; + +public class TimetableLectureNotFoundException extends DataNotFoundException { + + private static final String DEFAULT_MESSAGE = "존재하지 않는 수업입니다."; + + public TimetableLectureNotFoundException(String message) { + super(message); + } + + public TimetableLectureNotFoundException(String message, String detail) { + super(message, detail); + } + + public static TimetableLectureNotFoundException withDetail(String detail) { + return new TimetableLectureNotFoundException(DEFAULT_MESSAGE, detail); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/timetableV2/model/TimetableFrame.java b/src/main/java/in/koreatech/koin/domain/timetableV2/model/TimetableFrame.java new file mode 100644 index 000000000..fc5b2fce7 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/timetableV2/model/TimetableFrame.java @@ -0,0 +1,100 @@ +package in.koreatech.koin.domain.timetableV2.model; + +import static jakarta.persistence.CascadeType.ALL; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +import java.util.List; + +import org.hibernate.annotations.Where; + +import in.koreatech.koin.domain.timetable.model.Semester; +import in.koreatech.koin.domain.user.model.User; +import in.koreatech.koin.global.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table( + name = "timetable_frame", + indexes = @Index(name = "timetable_frame_INDEX", columnList = "user_id, semester_id") +) +@Where(clause = "is_deleted=0") +@NoArgsConstructor(access = PROTECTED) +public class TimetableFrame extends BaseEntity { + + @Id + @GeneratedValue(strategy = IDENTITY) + private Integer id; + + @NotNull + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @NotNull + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "semester_id", nullable = false) + private Semester semester; + + @NotNull + @Size(max = 255) + @Column(name = "name", length = 255, nullable = false) + private String name; + + @NotNull + @Column(name = "is_main", nullable = false) + private boolean isMain = false; + + @NotNull + @Column(name = "is_deleted", nullable = false) + private boolean isDeleted = false; + + @OneToMany(mappedBy = "timetableFrame", orphanRemoval = true, cascade = ALL) + private List timetableLectures; + + public void updateStatusMain(boolean isMain) { + this.isMain = isMain; + } + + @Builder + private TimetableFrame( + User user, + Semester semester, + String name, + boolean isMain, + List timetableLectures, + boolean isDeleted + ) { + this.user = user; + this.semester = semester; + this.name = name; + this.isMain = isMain; + this.timetableLectures = timetableLectures; + this.isDeleted = isDeleted; + } + + public void updateTimetableFrame(Semester semester, String name, boolean isMain) { + this.semester = semester; + this.name = name; + this.isMain = isMain; + } + + public void cancelMain() { + isMain = false; + } +} diff --git a/src/main/java/in/koreatech/koin/domain/timetableV2/model/TimetableLecture.java b/src/main/java/in/koreatech/koin/domain/timetableV2/model/TimetableLecture.java new file mode 100644 index 000000000..2b43ac90f --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/timetableV2/model/TimetableLecture.java @@ -0,0 +1,106 @@ +package in.koreatech.koin.domain.timetableV2.model; + +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +import org.hibernate.annotations.Where; + +import in.koreatech.koin.domain.timetable.dto.TimetableUpdateRequest; +import in.koreatech.koin.domain.timetable.model.Lecture; +import in.koreatech.koin.domain.timetableV2.dto.TimetableLectureUpdateRequest; +import in.koreatech.koin.global.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "timetable_lecture") +@Where(clause = "is_deleted=0") +@NoArgsConstructor(access = PROTECTED) +public class TimetableLecture extends BaseEntity { + + @Id + @GeneratedValue(strategy = IDENTITY) + private Integer id; + + @Size(max = 100) + @Column(name = "class_title", length = 100) + private String classTitle; + + @Size(max = 100) + @Column(name = "class_time", length = 100) + private String classTime; + + @Size(max = 30) + @Column(name = "class_place", length = 30) + private String classPlace; + + @Size(max = 30) + @Column(name = "professor", length = 30) + private String professor; + + @Size(max = 255) + @NotNull + @Column(name = "grades", nullable = false) + private String grades = "0"; + + @Size(max = 200) + @Column(name = "memo", length = 200) + private String memo; + + @NotNull + @Column(name = "is_deleted", nullable = false) + private boolean isDeleted = false; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "lectures_id") + private Lecture lecture; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "frame_id") + private TimetableFrame timetableFrame; + + @Builder + public TimetableLecture(String classTitle, String classTime, String classPlace, String professor, + String grades, String memo, boolean isDeleted, Lecture lecture, TimetableFrame timetableFrame) { + this.classTitle = classTitle; + this.classTime = classTime; + this.classPlace = classPlace; + this.professor = professor; + this.grades = grades; + this.memo = memo; + this.isDeleted = isDeleted; + this.lecture = lecture; + this.timetableFrame = timetableFrame; + } + + public void update(String classTitle, String classTime, String classPlace, String professor, + String grades, String memo) { + this.classTitle = classTitle; + this.classTime = classTime; + this.classPlace = classPlace; + this.professor = professor; + this.grades = grades; + this.memo = memo; + } + + public void update(TimetableUpdateRequest.InnerTimetableRequest request) { + this.classTitle = request.classTitle(); + this.classTime = request.classTime().toString(); + this.classPlace = request.classPlace(); + this.professor = request.professor(); + this.grades = grades; + this.memo = request.memo(); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/timetableV2/repository/LectureRepositoryV2.java b/src/main/java/in/koreatech/koin/domain/timetableV2/repository/LectureRepositoryV2.java new file mode 100644 index 000000000..ec735bba5 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/timetableV2/repository/LectureRepositoryV2.java @@ -0,0 +1,22 @@ +package in.koreatech.koin.domain.timetableV2.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.timetable.exception.LectureNotFoundException; +import in.koreatech.koin.domain.timetable.exception.SemesterNotFoundException; +import in.koreatech.koin.domain.timetable.model.Lecture; + +public interface LectureRepositoryV2 extends Repository { + + Lecture save(Lecture lecture); + + Optional findById(Integer id); + + default Lecture getLectureById(Integer id) { + return findById(id) + .orElseThrow(() -> LectureNotFoundException.withDetail("lecture_id: " + id)); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/timetableV2/repository/SemesterRepositoryV2.java b/src/main/java/in/koreatech/koin/domain/timetableV2/repository/SemesterRepositoryV2.java new file mode 100644 index 000000000..ee3e1367c --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/timetableV2/repository/SemesterRepositoryV2.java @@ -0,0 +1,21 @@ +package in.koreatech.koin.domain.timetableV2.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.timetable.exception.SemesterNotFoundException; +import in.koreatech.koin.domain.timetable.model.Semester; + +public interface SemesterRepositoryV2 extends Repository { + + Semester save(Semester semester); + + Optional findBySemester(String semester); + + default Semester getBySemester(String semester) { + return findBySemester(semester) + .orElseThrow(() -> SemesterNotFoundException.withDetail("semester: " + semester)); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/timetableV2/repository/TimetableFrameRepositoryV2.java b/src/main/java/in/koreatech/koin/domain/timetableV2/repository/TimetableFrameRepositoryV2.java new file mode 100644 index 000000000..8962bdd69 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/timetableV2/repository/TimetableFrameRepositoryV2.java @@ -0,0 +1,65 @@ +package in.koreatech.koin.domain.timetableV2.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.Repository; +import org.springframework.data.repository.query.Param; + +import in.koreatech.koin.domain.timetable.exception.TimetableNotFoundException; +import in.koreatech.koin.domain.timetableV2.exception.TimetableFrameNotFoundException; +import in.koreatech.koin.domain.timetableV2.model.TimetableFrame; +import in.koreatech.koin.domain.user.model.User; + +public interface TimetableFrameRepositoryV2 extends Repository { + + Optional findById(Integer id); + + List findByUserIdAndIsMainTrue(Integer userId); + + Optional findByUserIdAndSemesterIdAndIsMainTrue(Integer userId, Integer semesterId); + + default TimetableFrame getById(Integer id) { + return findById(id) + .orElseThrow(() -> TimetableNotFoundException.withDetail("id: " + id)); + } + + default TimetableFrame getMainTimetableByUserIdAndSemesterId(Integer userId, Integer semesterId) { + return findByUserIdAndSemesterIdAndIsMainTrue(userId, semesterId) + .orElseThrow( + () -> TimetableFrameNotFoundException.withDetail("userId: " + userId + ", semesterId: " + semesterId)); + } + + List findAllByUserIdAndSemesterId(Integer userId, Integer semesterId); + + TimetableFrame save(TimetableFrame timetableFrame); + + Optional findByUser(User user); + + default TimetableFrame getByUser(User user) { + return findByUser(user) + .orElseThrow(() -> TimetableFrameNotFoundException.withDetail("userId: " + user.getId())); + } + + @Query( + """ + SELECT tf FROM TimetableFrame tf + WHERE tf.user.id = :userId + AND tf.semester.id = :semesterId + AND tf.isMain = false ORDER BY tf.createdAt ASC + """) + TimetableFrame findFirstNonMainFrame(@Param("userId") Integer userId, @Param("semesterId") Integer semesterId); + + @Query( + """ + SELECT COUNT(t) FROM TimetableFrame t + WHERE t.user.id = :userId + AND t.semester.id = :semesterId + """) + int countByUserIdAndSemesterId(@Param("userId") Integer userId, @Param("semesterId") Integer semesterId); + + void deleteById(Integer id); + + void deleteAllByUser(User user); +} diff --git a/src/main/java/in/koreatech/koin/domain/timetableV2/repository/TimetableLectureRepositoryV2.java b/src/main/java/in/koreatech/koin/domain/timetableV2/repository/TimetableLectureRepositoryV2.java new file mode 100644 index 000000000..ee0c82047 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/timetableV2/repository/TimetableLectureRepositoryV2.java @@ -0,0 +1,24 @@ +package in.koreatech.koin.domain.timetableV2.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.timetableV2.exception.TimetableLectureNotFoundException; +import in.koreatech.koin.domain.timetableV2.model.TimetableLecture; + +public interface TimetableLectureRepositoryV2 extends Repository { + + Optional findById(Integer id); + + List findAllByTimetableFrameId(Integer id); + + void deleteById(Integer id); + + default TimetableLecture getById(Integer id) { + return findById(id) + .orElseThrow(() -> TimetableLectureNotFoundException.withDetail("id: " + id)); + } + TimetableLecture save(TimetableLecture timetableLecture); +} diff --git a/src/main/java/in/koreatech/koin/domain/timetableV2/service/TimetableServiceV2.java b/src/main/java/in/koreatech/koin/domain/timetableV2/service/TimetableServiceV2.java new file mode 100644 index 000000000..e4675b340 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/timetableV2/service/TimetableServiceV2.java @@ -0,0 +1,188 @@ +package in.koreatech.koin.domain.timetableV2.service; + +import static in.koreatech.koin.domain.timetableV2.dto.TimetableLectureCreateRequest.*; +import static in.koreatech.koin.domain.timetableV2.dto.TimetableLectureUpdateRequest.*; + +import java.util.List; +import java.util.Objects; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import in.koreatech.koin.domain.timetable.model.Lecture; +import in.koreatech.koin.domain.timetable.model.Semester; +import in.koreatech.koin.domain.timetableV2.dto.TimetableFrameCreateRequest; +import in.koreatech.koin.domain.timetableV2.dto.TimetableFrameResponse; +import in.koreatech.koin.domain.timetableV2.dto.TimetableFrameUpdateRequest; +import in.koreatech.koin.domain.timetableV2.dto.TimetableFrameUpdateResponse; +import in.koreatech.koin.domain.timetableV2.dto.TimetableLectureCreateRequest; +import in.koreatech.koin.domain.timetableV2.dto.TimetableLectureResponse; +import in.koreatech.koin.domain.timetableV2.dto.TimetableLectureUpdateRequest; +import in.koreatech.koin.domain.timetableV2.model.TimetableFrame; +import in.koreatech.koin.domain.timetableV2.model.TimetableLecture; +import in.koreatech.koin.domain.timetableV2.repository.LectureRepositoryV2; +import in.koreatech.koin.domain.timetableV2.repository.SemesterRepositoryV2; +import in.koreatech.koin.domain.timetableV2.repository.TimetableFrameRepositoryV2; +import in.koreatech.koin.domain.timetableV2.repository.TimetableLectureRepositoryV2; +import in.koreatech.koin.domain.user.model.User; +import in.koreatech.koin.domain.user.repository.UserRepository; +import in.koreatech.koin.global.auth.exception.AuthorizationException; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class TimetableServiceV2 { + + private final LectureRepositoryV2 lectureRepositoryV2; + private final TimetableLectureRepositoryV2 timetableLectureRepositoryV2; + private final TimetableFrameRepositoryV2 timetableFrameRepositoryV2; + private final UserRepository userRepository; + private final SemesterRepositoryV2 semesterRepositoryV2; + + @Transactional + public TimetableFrameResponse createTimetablesFrame(Integer userId, TimetableFrameCreateRequest request) { + Semester semester = semesterRepositoryV2.getBySemester(request.semester()); + User user = userRepository.getById(userId); + int currentFrameCount = timetableFrameRepositoryV2.countByUserIdAndSemesterId(userId, semester.getId()); + boolean isMain = (currentFrameCount == 0); + + TimetableFrame timetableFrame = request.toTimetablesFrame(user, semester, "시간표" + (currentFrameCount+1), isMain); + TimetableFrame savedTimetableFrame = timetableFrameRepositoryV2.save(timetableFrame); + return TimetableFrameResponse.from(savedTimetableFrame); + } + + @Transactional + public TimetableFrameUpdateResponse updateTimetableFrame(Integer timetableFrameId, + TimetableFrameUpdateRequest timetableFrameUpdateRequest, Integer userId) { + TimetableFrame timeTableFrame = timetableFrameRepositoryV2.getById(timetableFrameId); + Semester semester = timeTableFrame.getSemester(); + boolean isMain = timetableFrameUpdateRequest.isMain(); + if (isMain) { + cancelMainTimetable(userId, semester.getId()); + } + timeTableFrame.updateTimetableFrame(semester, timetableFrameUpdateRequest.name(), isMain); + return TimetableFrameUpdateResponse.from(timeTableFrame); + } + + public List getTimetablesFrame(Integer userId, String semesterRequest) { + Semester semester = semesterRepositoryV2.getBySemester(semesterRequest); + return timetableFrameRepositoryV2.findAllByUserIdAndSemesterId(userId, semester.getId()).stream() + .map(TimetableFrameResponse::from) + .toList(); + } + + @Transactional + public void deleteTimetablesFrame(Integer userId, Integer frameId) { + TimetableFrame frame = timetableFrameRepositoryV2.getById(frameId); + if (!Objects.equals(frame.getUser().getId(), userId)) { + throw AuthorizationException.withDetail("userId: " + userId); + } + if (frame.isMain()) { + TimetableFrame nextMainFrame = + timetableFrameRepositoryV2.findFirstNonMainFrame(userId, frame.getSemester().getId()); + if (nextMainFrame != null) { + nextMainFrame.updateStatusMain(true); + timetableFrameRepositoryV2.save(nextMainFrame); + } + } + timetableFrameRepositoryV2.deleteById(frameId); + } + + @Transactional + public TimetableLectureResponse createTimetableLectures(Integer userId, TimetableLectureCreateRequest request) { + TimetableFrame timetableFrame = timetableFrameRepositoryV2.getById(request.timetableFrameId()); + if (!Objects.equals(timetableFrame.getUser().getId(), userId)) { + throw AuthorizationException.withDetail("userId: " + userId); + } + + for (InnerTimeTableLectureRequest timetableLectureRequest : request.timetableLecture()) { + Lecture lecture = timetableLectureRequest.lectureId() == null ? + null : lectureRepositoryV2.getLectureById(timetableLectureRequest.lectureId()); + TimetableLecture timetableLecture = timetableLectureRequest.toTimetableLecture(timetableFrame, lecture); + timetableLectureRepositoryV2.save(timetableLecture); + } + + List timetableLectures = timetableLectureRepositoryV2.findAllByTimetableFrameId( + timetableFrame.getId()); + return getTimetableLectureResponse(userId, timetableFrame, timetableLectures); + } + + @Transactional + public TimetableLectureResponse updateTimetablesLectures(Integer userId, TimetableLectureUpdateRequest request) { + TimetableFrame timetableFrame = timetableFrameRepositoryV2.getById(request.timetableFrameId()); + if (!Objects.equals(timetableFrame.getUser().getId(), userId)) { + throw AuthorizationException.withDetail("userId: " + userId); + } + + for (InnerTimetableLectureRequest timetableRequest : request.timetableLecture()) { + TimetableLecture timetableLecture = timetableLectureRepositoryV2.getById(timetableRequest.id()); + if (timetableRequest.lectureId() == null) { + timetableLecture.update( + timetableRequest.classTitle(), + timetableRequest.classTime().toString(), + timetableRequest.classPlace(), + timetableRequest.professor(), + timetableRequest.grades(), + timetableRequest.memo()); + } + } + List timetableLectures = timetableFrame.getTimetableLectures(); + return getTimetableLectureResponse(userId, timetableFrame, timetableLectures); + } + + @Transactional + public TimetableLectureResponse getTimetableLectures(Integer userId, Integer timetableFrameId) { + TimetableFrame frame = timetableFrameRepositoryV2.getById(timetableFrameId); + List timetableLectures = frame.getTimetableLectures(); + if (!Objects.equals(frame.getUser().getId(), userId)) { + throw AuthorizationException.withDetail("userId: " + userId); + } + return getTimetableLectureResponse(userId, frame, timetableLectures); + } + + @Transactional + public void deleteTimetableLecture(Integer userId, Integer timetableLectureId) { + TimetableLecture timetableLecture = timetableLectureRepositoryV2.getById(timetableLectureId); + TimetableFrame frame = timetableLecture.getTimetableFrame(); + if (!Objects.equals(frame.getUser().getId(), userId)) { + throw AuthorizationException.withDetail("userId: " + userId); + } + timetableLectureRepositoryV2.deleteById(timetableLectureId); + } + + private TimetableLectureResponse getTimetableLectureResponse(Integer userId, TimetableFrame timetableFrame, + List timetableLectures) { + int grades = 0; + int totalGrades = 0; + + if (timetableFrame.isMain()) { + grades = calculateGrades(timetableLectures); + } + + for (TimetableFrame timetableFrames : timetableFrameRepositoryV2.findByUserIdAndIsMainTrue(userId)) { + totalGrades += calculateGrades( + timetableLectureRepositoryV2.findAllByTimetableFrameId(timetableFrames.getId())); + } + + return TimetableLectureResponse.of(timetableFrame.getId(), timetableLectures, grades, totalGrades); + } + + private int calculateGrades(List timetableLectures) { + return timetableLectures.stream() + .mapToInt(lecture -> { + if (lecture.getLecture() != null) { + return Integer.parseInt(lecture.getLecture().getGrades()); + } else { + return Integer.parseInt(lecture.getGrades()); + } + }) + .sum(); + } + + private void cancelMainTimetable(Integer userId, Integer semesterId) { + TimetableFrame mainTimetableFrame = timetableFrameRepositoryV2.getMainTimetableByUserIdAndSemesterId(userId, + semesterId); + mainTimetableFrame.cancelMain(); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/user/controller/UserApi.java b/src/main/java/in/koreatech/koin/domain/user/controller/UserApi.java index 81784aca4..ebd6e3bba 100644 --- a/src/main/java/in/koreatech/koin/domain/user/controller/UserApi.java +++ b/src/main/java/in/koreatech/koin/domain/user/controller/UserApi.java @@ -17,6 +17,8 @@ import in.koreatech.koin.domain.user.dto.EmailCheckExistsRequest; import in.koreatech.koin.domain.user.dto.FindPasswordRequest; import in.koreatech.koin.domain.user.dto.NicknameCheckExistsRequest; +import in.koreatech.koin.domain.user.dto.StudentLoginRequest; +import in.koreatech.koin.domain.user.dto.StudentLoginResponse; import in.koreatech.koin.domain.user.dto.StudentRegisterRequest; import in.koreatech.koin.domain.user.dto.StudentResponse; import in.koreatech.koin.domain.user.dto.StudentUpdateRequest; @@ -101,6 +103,20 @@ ResponseEntity login( @RequestBody @Valid UserLoginRequest request ); + @ApiResponses( + value = { + @ApiResponse(responseCode = "201"), + @ApiResponse(responseCode = "400", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "학생 로그인") + @PostMapping("/student/login") + ResponseEntity studentLogin( + @RequestBody @Valid StudentLoginRequest request + ); + @ApiResponses( value = { @ApiResponse(responseCode = "201"), @@ -226,6 +242,7 @@ ResponseEntity findPassword( @ApiResponses( value = { @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "400", content = @Content(schema = @Schema(hidden = true))), @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), } diff --git a/src/main/java/in/koreatech/koin/domain/user/controller/UserController.java b/src/main/java/in/koreatech/koin/domain/user/controller/UserController.java index 6c12e3e2a..53c4e14ce 100644 --- a/src/main/java/in/koreatech/koin/domain/user/controller/UserController.java +++ b/src/main/java/in/koreatech/koin/domain/user/controller/UserController.java @@ -24,6 +24,8 @@ import in.koreatech.koin.domain.user.dto.EmailCheckExistsRequest; import in.koreatech.koin.domain.user.dto.FindPasswordRequest; import in.koreatech.koin.domain.user.dto.NicknameCheckExistsRequest; +import in.koreatech.koin.domain.user.dto.StudentLoginRequest; +import in.koreatech.koin.domain.user.dto.StudentLoginResponse; import in.koreatech.koin.domain.user.dto.StudentRegisterRequest; import in.koreatech.koin.domain.user.dto.StudentResponse; import in.koreatech.koin.domain.user.dto.StudentUpdateRequest; @@ -83,6 +85,15 @@ public ResponseEntity login( .body(response); } + @PostMapping("/student/login") + public ResponseEntity studentLogin( + @RequestBody @Valid StudentLoginRequest request + ) { + StudentLoginResponse response = studentService.studentLogin(request); + return ResponseEntity.created(URI.create("/")) + .body(response); + } + @PostMapping("/user/logout") public ResponseEntity logout( @Auth(permit = {STUDENT, OWNER, COOP}) Integer userId diff --git a/src/main/java/in/koreatech/koin/domain/user/dto/StudentLoginRequest.java b/src/main/java/in/koreatech/koin/domain/user/dto/StudentLoginRequest.java new file mode 100644 index 000000000..0d08a3021 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/user/dto/StudentLoginRequest.java @@ -0,0 +1,23 @@ +package in.koreatech.koin.domain.user.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +public record StudentLoginRequest( + @Schema(description = "이메일", example = "koin123@koreatech.ac.kr", requiredMode = REQUIRED) + @NotBlank(message = "이메일을 입력해주세요.") + String email, + + @Schema( + description = "SHA 256 해시 알고리즘으로 암호화된 비밀번호", + example = "cd06f8c2b0dd065faf6ef910c7f15934363df71c33740fd245590665286ed268", + requiredMode = REQUIRED + ) + @NotBlank(message = "비밀번호를 입력해주세요.") + String password +) { + +} diff --git a/src/main/java/in/koreatech/koin/domain/user/dto/StudentLoginResponse.java b/src/main/java/in/koreatech/koin/domain/user/dto/StudentLoginResponse.java new file mode 100644 index 000000000..232c63753 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/user/dto/StudentLoginResponse.java @@ -0,0 +1,26 @@ +package in.koreatech.koin.domain.user.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record StudentLoginResponse( + @Schema( + description = "Jwt accessToken", + example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c", + requiredMode = REQUIRED + ) + @JsonProperty("token") + String accessToken, + + @Schema(description = "Random UUID refresh token", example = "RANDOM-KEY-VALUE", requiredMode = REQUIRED) + @JsonProperty("refresh_token") + String refreshToken +) { + + public static StudentLoginResponse of(String token, String refreshToken) { + return new StudentLoginResponse(token, refreshToken); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/user/dto/StudentRegisterRequest.java b/src/main/java/in/koreatech/koin/domain/user/dto/StudentRegisterRequest.java index 3c11dae6a..8c3693ec7 100644 --- a/src/main/java/in/koreatech/koin/domain/user/dto/StudentRegisterRequest.java +++ b/src/main/java/in/koreatech/koin/domain/user/dto/StudentRegisterRequest.java @@ -74,7 +74,7 @@ public record StudentRegisterRequest( String studentNumber, @Schema(description = "휴대폰 번호", example = "010-1234-5678 또는 01012345678", requiredMode = NOT_REQUIRED) - @Pattern(regexp = "^(\\d{3}-\\d{3,4}-\\d{4}|\\d{10}|\\d{11})$", message = "전화번호 형식이 올바르지 않습니다.") + @Pattern(regexp = "^\\d{11}$", message = "전화번호 형식이 올바르지 않습니다.") String phoneNumber ) { diff --git a/src/main/java/in/koreatech/koin/domain/user/dto/StudentUpdateRequest.java b/src/main/java/in/koreatech/koin/domain/user/dto/StudentUpdateRequest.java index b2ce2c7e3..b02904cae 100644 --- a/src/main/java/in/koreatech/koin/domain/user/dto/StudentUpdateRequest.java +++ b/src/main/java/in/koreatech/koin/domain/user/dto/StudentUpdateRequest.java @@ -47,7 +47,7 @@ public record StudentUpdateRequest @Schema(description = "닉네임", example = "juno", requiredMode = NOT_REQUIRED) String nickname, - @Schema(description = "휴대폰 번호", example = "010-0000-0000", requiredMode = NOT_REQUIRED) + @Schema(description = "휴대폰 번호", example = "01000000000", requiredMode = NOT_REQUIRED) String phoneNumber, @Size(min = 10, max = 10, message = "학번은 10자여야 합니다.") diff --git a/src/main/java/in/koreatech/koin/domain/user/dto/StudentUpdateResponse.java b/src/main/java/in/koreatech/koin/domain/user/dto/StudentUpdateResponse.java index 12d995f7a..3191d815e 100644 --- a/src/main/java/in/koreatech/koin/domain/user/dto/StudentUpdateResponse.java +++ b/src/main/java/in/koreatech/koin/domain/user/dto/StudentUpdateResponse.java @@ -42,7 +42,7 @@ public record StudentUpdateResponse( @Schema(description = "닉네임", example = "juno", requiredMode = NOT_REQUIRED) String nickname, - @Schema(description = "휴대폰 번호", example = "010-0000-0000", requiredMode = NOT_REQUIRED) + @Schema(description = "휴대폰 번호", example = "01000000000", requiredMode = NOT_REQUIRED) String phoneNumber, @Schema(description = "학번", example = "2029136012", requiredMode = NOT_REQUIRED) @@ -51,11 +51,10 @@ public record StudentUpdateResponse( public static StudentUpdateResponse from(Student student) { User user = student.getUser(); - Integer userGender = user.getGender() != null ? user.getGender().ordinal() : null; return new StudentUpdateResponse( student.getAnonymousNickname(), user.getEmail(), - userGender, + user.getGender() != null ? user.getGender().ordinal() : null, student.getDepartment(), user.getName(), user.getNickname(), diff --git a/src/main/java/in/koreatech/koin/domain/user/dto/UserLoginResponse.java b/src/main/java/in/koreatech/koin/domain/user/dto/UserLoginResponse.java index f72752527..ef9c92951 100644 --- a/src/main/java/in/koreatech/koin/domain/user/dto/UserLoginResponse.java +++ b/src/main/java/in/koreatech/koin/domain/user/dto/UserLoginResponse.java @@ -24,6 +24,7 @@ public record UserLoginResponse( 로그인한 회원의 신원 - `STUDENT`: 학생 - `OWNER`: 사장님 + - `COOP` : 영양사 """, example = "STUDENT", requiredMode = REQUIRED ) @JsonProperty("user_type") diff --git a/src/main/java/in/koreatech/koin/domain/user/model/User.java b/src/main/java/in/koreatech/koin/domain/user/model/User.java index c86c0f513..54ca3ab5c 100644 --- a/src/main/java/in/koreatech/koin/domain/user/model/User.java +++ b/src/main/java/in/koreatech/koin/domain/user/model/User.java @@ -59,8 +59,7 @@ public class User extends BaseEntity { private UserType userType; @Size(max = 100) - @NotNull - @Column(name = "email", nullable = false, length = 100) + @Column(name = "email", length = 100) private String email; @Column(name = "gender", columnDefinition = "INT") @@ -184,4 +183,8 @@ public void validateResetToken() { throw UserResetTokenExpiredException.withDetail("resetToken: " + resetToken); } } + + public void undelete() { + this.isDeleted = false; + } } diff --git a/src/main/java/in/koreatech/koin/domain/user/repository/UserRepository.java b/src/main/java/in/koreatech/koin/domain/user/repository/UserRepository.java index 6cc88bd7b..0b858b179 100644 --- a/src/main/java/in/koreatech/koin/domain/user/repository/UserRepository.java +++ b/src/main/java/in/koreatech/koin/domain/user/repository/UserRepository.java @@ -15,6 +15,8 @@ public interface UserRepository extends Repository { Optional findByEmail(String email); + Optional findByEmailAndUserType(String email, UserType userType); + Optional findByPhoneNumberAndUserType(String phoneNumber, UserType userType); Optional findById(Integer id); @@ -32,7 +34,7 @@ default User getByEmail(String email) { default User getByPhoneNumber(String phoneNumber, UserType userType) { return findByPhoneNumberAndUserType(phoneNumber, userType) - .orElseThrow(() -> UserNotFoundException.withDetail("phoneNumber: " + phoneNumber)); + .orElseThrow(() -> UserNotFoundException.withDetail("account: " + phoneNumber)); } default User getById(Integer userId) { @@ -40,6 +42,11 @@ default User getById(Integer userId) { .orElseThrow(() -> UserNotFoundException.withDetail("userId: " + userId)); } + default User getById(String id, UserType userType) { + return findByEmailAndUserType(id, userType) + .orElseThrow(() -> UserNotFoundException.withDetail("id: " + id)); + } + default User getByNickname(String nickname) { return findByNickname(nickname) .orElseThrow(() -> UserNotFoundException.withDetail("nickname: " + nickname)); @@ -55,4 +62,6 @@ default User getByResetToken(String resetToken) { void delete(User user); List findAllByDeviceTokenIsNotNull(); + + Optional findByPhoneNumber(String phoneNumber); } diff --git a/src/main/java/in/koreatech/koin/domain/user/service/StudentService.java b/src/main/java/in/koreatech/koin/domain/user/service/StudentService.java index 230b550cb..900476852 100644 --- a/src/main/java/in/koreatech/koin/domain/user/service/StudentService.java +++ b/src/main/java/in/koreatech/koin/domain/user/service/StudentService.java @@ -2,6 +2,7 @@ import java.time.Clock; import java.util.Optional; +import java.util.UUID; import org.joda.time.LocalDateTime; import org.springframework.context.ApplicationEventPublisher; @@ -13,6 +14,8 @@ import in.koreatech.koin.domain.user.dto.AuthTokenRequest; import in.koreatech.koin.domain.user.dto.FindPasswordRequest; +import in.koreatech.koin.domain.user.dto.StudentLoginRequest; +import in.koreatech.koin.domain.user.dto.StudentLoginResponse; import in.koreatech.koin.domain.user.dto.StudentRegisterRequest; import in.koreatech.koin.domain.user.dto.StudentResponse; import in.koreatech.koin.domain.user.dto.StudentUpdateRequest; @@ -27,8 +30,12 @@ import in.koreatech.koin.domain.user.model.StudentEmailRequestEvent; import in.koreatech.koin.domain.user.model.User; import in.koreatech.koin.domain.user.model.UserGender; +import in.koreatech.koin.domain.user.model.UserToken; import in.koreatech.koin.domain.user.repository.StudentRepository; import in.koreatech.koin.domain.user.repository.UserRepository; +import in.koreatech.koin.domain.user.repository.UserTokenRepository; +import in.koreatech.koin.global.auth.JwtProvider; +import in.koreatech.koin.global.auth.exception.AuthorizationException; import in.koreatech.koin.global.domain.email.exception.DuplicationEmailException; import in.koreatech.koin.global.domain.email.form.StudentPasswordChangeData; import in.koreatech.koin.global.domain.email.form.StudentRegistrationData; @@ -48,12 +55,34 @@ public class StudentService { private final MailService mailService; private final ApplicationEventPublisher eventPublisher; private final Clock clock; + private final UserTokenRepository userTokenRepository; + private final JwtProvider jwtProvider; public StudentResponse getStudent(Integer userId) { Student student = studentRepository.getById(userId); return StudentResponse.from(student); } + @Transactional + public StudentLoginResponse studentLogin(StudentLoginRequest request) { + User user = userRepository.getByEmail(request.email()); + + if (!user.isSamePassword(passwordEncoder, request.password())) { + throw new KoinIllegalArgumentException("비밀번호가 틀렸습니다."); + } + + if (!user.isAuthed()) { + throw new AuthorizationException("미인증 상태입니다. 아우누리에서 인증메일을 확인해주세요"); + } + + String accessToken = jwtProvider.createToken(user); + String refreshToken = String.format("%s-%d", UUID.randomUUID(), user.getId()); + UserToken savedToken = userTokenRepository.save(UserToken.create(user.getId(), refreshToken)); + user.updateLastLoggedTime(java.time.LocalDateTime.now()); + + return StudentLoginResponse.of(accessToken, savedToken.getRefreshToken()); + } + @Transactional public StudentUpdateResponse updateStudent(Integer userId, StudentUpdateRequest request) { Student student = studentRepository.getById(userId); diff --git a/src/main/java/in/koreatech/koin/domain/user/service/UserService.java b/src/main/java/in/koreatech/koin/domain/user/service/UserService.java index a7f46063a..e0e73d27f 100644 --- a/src/main/java/in/koreatech/koin/domain/user/service/UserService.java +++ b/src/main/java/in/koreatech/koin/domain/user/service/UserService.java @@ -9,9 +9,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import in.koreatech.koin.domain.owner.repository.OwnerAttachmentRepository; import in.koreatech.koin.domain.owner.repository.OwnerRepository; -import in.koreatech.koin.domain.shop.repository.ShopRepository; +import in.koreatech.koin.domain.timetableV2.repository.TimetableFrameRepositoryV2; import in.koreatech.koin.domain.user.dto.AuthResponse; import in.koreatech.koin.domain.user.dto.CoopResponse; import in.koreatech.koin.domain.user.dto.EmailCheckExistsRequest; @@ -30,7 +29,6 @@ import in.koreatech.koin.domain.user.repository.UserRepository; import in.koreatech.koin.domain.user.repository.UserTokenRepository; import in.koreatech.koin.global.auth.JwtProvider; -import in.koreatech.koin.global.auth.exception.AuthenticationException; import in.koreatech.koin.global.auth.exception.AuthorizationException; import in.koreatech.koin.global.domain.email.exception.DuplicationEmailException; import in.koreatech.koin.global.exception.KoinIllegalArgumentException; @@ -45,11 +43,10 @@ public class UserService { private final UserRepository userRepository; private final StudentRepository studentRepository; private final OwnerRepository ownerRepository; - private final ShopRepository shopRepository; - private final OwnerAttachmentRepository ownerAttachmentRepository; private final PasswordEncoder passwordEncoder; private final UserTokenRepository userTokenRepository; private final ApplicationEventPublisher eventPublisher; + private final TimetableFrameRepositoryV2 timetableFrameRepositoryV2; @Transactional public UserLoginResponse login(UserLoginRequest request) { @@ -101,6 +98,7 @@ private String getUserId(String refreshToken) { public void withdraw(Integer userId) { User user = userRepository.getById(userId); if (user.getUserType() == UserType.STUDENT) { + timetableFrameRepositoryV2.deleteAllByUser(user); studentRepository.deleteByUserId(userId); } else if (user.getUserType() == UserType.OWNER) { ownerRepository.deleteByUserId(userId); @@ -112,7 +110,7 @@ public void withdraw(Integer userId) { public void checkPassword(UserPasswordCheckRequest request, Integer userId) { User user = userRepository.getById(userId); if (!user.isSamePassword(passwordEncoder, request.password())) { - throw new AuthenticationException("올바르지 않은 비밀번호입니다."); + throw new KoinIllegalArgumentException("올바르지 않은 비밀번호입니다."); } } diff --git a/src/main/java/in/koreatech/koin/global/domain/notification/model/NotificationFactory.java b/src/main/java/in/koreatech/koin/global/domain/notification/model/NotificationFactory.java index e9c9131c6..5e1554da3 100644 --- a/src/main/java/in/koreatech/koin/global/domain/notification/model/NotificationFactory.java +++ b/src/main/java/in/koreatech/koin/global/domain/notification/model/NotificationFactory.java @@ -32,8 +32,8 @@ public Notification generateSoldOutNotification( ) { return new Notification( path, - "%s 품절되었습니다.".formatted(getPostposition(place, "이", "가")), - "다른 식단 보러 가기.", + "%s 품절되었습니다".formatted(getPostposition(place, "이", "가")), + "다른 식단 보러 가기", null, NotificationType.MESSAGE, target diff --git a/src/main/java/in/koreatech/koin/global/domain/upload/controller/UploadApi.java b/src/main/java/in/koreatech/koin/global/domain/upload/controller/UploadApi.java index 050d73a4c..6d45a6724 100644 --- a/src/main/java/in/koreatech/koin/global/domain/upload/controller/UploadApi.java +++ b/src/main/java/in/koreatech/koin/global/domain/upload/controller/UploadApi.java @@ -1,5 +1,6 @@ package in.koreatech.koin.global.domain.upload.controller; +import static in.koreatech.koin.domain.user.model.UserType.ADMIN; import static in.koreatech.koin.domain.user.model.UserType.COOP; import static in.koreatech.koin.domain.user.model.UserType.OWNER; import static in.koreatech.koin.domain.user.model.UserType.STUDENT; @@ -58,7 +59,7 @@ public interface UploadApi { ResponseEntity getPresignedUrl( @PathVariable ImageUploadDomain domain, @RequestBody @Valid UploadUrlRequest request, - @Auth(permit = {OWNER, STUDENT, COOP}, anonymous = true) Integer memberId + @Auth(permit = {OWNER, STUDENT, COOP, ADMIN}, anonymous = true) Integer memberId ); @ApiResponses( @@ -89,7 +90,7 @@ ResponseEntity getPresignedUrl( ResponseEntity uploadFile( @Parameter(in = PATH) @PathVariable ImageUploadDomain domain, @RequestPart MultipartFile multipartFile, - @Auth(permit = {OWNER, STUDENT, COOP}, anonymous = true) Integer memberId + @Auth(permit = {OWNER, STUDENT, COOP, ADMIN}, anonymous = true) Integer memberId ); @ApiResponses( @@ -120,6 +121,6 @@ ResponseEntity uploadFile( ResponseEntity uploadFiles( @Parameter(in = PATH) @PathVariable ImageUploadDomain domain, @RequestPart List files, - @Auth(permit = {OWNER, STUDENT, COOP}, anonymous = true) Integer memberId + @Auth(permit = {OWNER, STUDENT, COOP, ADMIN}, anonymous = true) Integer memberId ); } diff --git a/src/main/java/in/koreatech/koin/global/domain/upload/controller/UploadController.java b/src/main/java/in/koreatech/koin/global/domain/upload/controller/UploadController.java index 09e6749c0..6ddfcf870 100644 --- a/src/main/java/in/koreatech/koin/global/domain/upload/controller/UploadController.java +++ b/src/main/java/in/koreatech/koin/global/domain/upload/controller/UploadController.java @@ -1,5 +1,6 @@ package in.koreatech.koin.global.domain.upload.controller; +import static in.koreatech.koin.domain.user.model.UserType.ADMIN; import static in.koreatech.koin.domain.user.model.UserType.COOP; import static in.koreatech.koin.domain.user.model.UserType.OWNER; import static in.koreatech.koin.domain.user.model.UserType.STUDENT; @@ -36,7 +37,7 @@ public class UploadController implements UploadApi { public ResponseEntity getPresignedUrl( @PathVariable ImageUploadDomain domain, @RequestBody @Valid UploadUrlRequest request, - @Auth(permit = {OWNER, STUDENT, COOP}, anonymous = true) Integer memberId + @Auth(permit = {OWNER, STUDENT, COOP, ADMIN}, anonymous = true) Integer memberId ) { var response = uploadService.getPresignedUrl(domain, request); return ResponseEntity.ok(response); @@ -50,7 +51,7 @@ public ResponseEntity getPresignedUrl( public ResponseEntity uploadFile( @PathVariable ImageUploadDomain domain, @RequestPart MultipartFile multipartFile, - @Auth(permit = {OWNER, STUDENT, COOP}, anonymous = true) Integer memberId + @Auth(permit = {OWNER, STUDENT, COOP, ADMIN}, anonymous = true) Integer memberId ) { var response = uploadService.uploadFile(domain, multipartFile); return new ResponseEntity<>(response, HttpStatus.CREATED); @@ -64,7 +65,7 @@ public ResponseEntity uploadFile( public ResponseEntity uploadFiles( @PathVariable ImageUploadDomain domain, @RequestPart List files, - @Auth(permit = {OWNER, STUDENT, COOP}, anonymous = true) Integer memberId + @Auth(permit = {OWNER, STUDENT, COOP, ADMIN}, anonymous = true) Integer memberId ) { var response = uploadService.uploadFiles(domain, files); return new ResponseEntity<>(response, HttpStatus.CREATED); diff --git a/src/main/java/in/koreatech/koin/global/validation/DayOfWeekValidator.java b/src/main/java/in/koreatech/koin/global/validation/DayOfWeekValidator.java new file mode 100644 index 000000000..06e2a4f5a --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/validation/DayOfWeekValidator.java @@ -0,0 +1,25 @@ +package in.koreatech.koin.global.validation; + +import java.util.Set; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +public class DayOfWeekValidator implements ConstraintValidator { + private final Set validDays = Set.of( + "MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY", "SUNDAY" + ); + + @Override + public void initialize(ValidDayOfWeek constraintAnnotation) { + ConstraintValidator.super.initialize(constraintAnnotation); + } + + @Override + public boolean isValid(String dayOfWeek, ConstraintValidatorContext context) { + if (dayOfWeek == null) { + return false; + } + return validDays.contains(dayOfWeek.toUpperCase()); + } +} diff --git a/src/main/java/in/koreatech/koin/global/validation/UniqueIdValidator.java b/src/main/java/in/koreatech/koin/global/validation/UniqueIdValidator.java index 3ba338312..3553fa74f 100644 --- a/src/main/java/in/koreatech/koin/global/validation/UniqueIdValidator.java +++ b/src/main/java/in/koreatech/koin/global/validation/UniqueIdValidator.java @@ -14,6 +14,9 @@ public void initialize(UniqueId constraintAnnotation) { @Override public boolean isValid(List elements, ConstraintValidatorContext context) { + if (elements == null) { + elements = List.of(); + } return elements.stream().distinct().count() == elements.size(); } } diff --git a/src/main/java/in/koreatech/koin/global/validation/UniqueUrlsValidator.java b/src/main/java/in/koreatech/koin/global/validation/UniqueUrlsValidator.java index 1bd00639b..669684eaa 100644 --- a/src/main/java/in/koreatech/koin/global/validation/UniqueUrlsValidator.java +++ b/src/main/java/in/koreatech/koin/global/validation/UniqueUrlsValidator.java @@ -14,6 +14,9 @@ public void initialize(UniqueUrl constraintAnnotation) { @Override public boolean isValid(List elements, ConstraintValidatorContext context) { + if (elements == null) { + elements = List.of(); + } return elements.stream().distinct().count() == elements.size(); } } diff --git a/src/main/java/in/koreatech/koin/global/validation/ValidDayOfWeek.java b/src/main/java/in/koreatech/koin/global/validation/ValidDayOfWeek.java new file mode 100644 index 000000000..88611850a --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/validation/ValidDayOfWeek.java @@ -0,0 +1,25 @@ +package in.koreatech.koin.global.validation; + +import static java.lang.annotation.ElementType.ANNOTATION_TYPE; +import static java.lang.annotation.ElementType.FIELD; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +@Documented +@Constraint(validatedBy = DayOfWeekValidator.class) +@Target({FIELD, ANNOTATION_TYPE}) +@Retention(RUNTIME) +public @interface ValidDayOfWeek { + + String message() default "요일 형식을 확인해주세요."; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/src/main/resources/db/migration/V16__add_account_column_to_owners_table.sql b/src/main/resources/db/migration/V16__add_account_column_to_owners_table.sql new file mode 100644 index 000000000..1aa295181 --- /dev/null +++ b/src/main/resources/db/migration/V16__add_account_column_to_owners_table.sql @@ -0,0 +1,7 @@ +ALTER TABLE `koin`.`owners` + ADD COLUMN `account` VARCHAR(255) NULL COMMENT '사장님 전화번호(“-“ 제거, unique)', + ADD UNIQUE INDEX `account_UNIQUE` (`account`); + +UPDATE `koin`.`owners` o + JOIN `koin`.`users` u ON o.user_id = u.id + SET o.account = REPLACE(u.phone_number, '-', '') diff --git a/src/main/resources/db/migration/V17__create_coop_table_and_insert_data.sql b/src/main/resources/db/migration/V17__create_coop_table_and_insert_data.sql new file mode 100644 index 000000000..1641445c1 --- /dev/null +++ b/src/main/resources/db/migration/V17__create_coop_table_and_insert_data.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS `coop` +( + `user_id` INT UNSIGNED NOT NULL COMMENT '유저 id, user_type COOP으로 가져옴', + `coop_id` VARCHAR(255) COMMENT '영양사 id, 일반 로그인 형식', + PRIMARY KEY (`user_id`), + CONSTRAINT `FK_COOP_ON_USER` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE ON UPDATE RESTRICT +); + +INSERT INTO `coop` (`user_id`, `coop_id`) + SELECT `id`, '' FROM `users` WHERE `user_type` = 'COOP'; diff --git a/src/main/resources/db/migration/V18__alter_user_email_column_nullable.sql b/src/main/resources/db/migration/V18__alter_user_email_column_nullable.sql new file mode 100644 index 000000000..822306bdd --- /dev/null +++ b/src/main/resources/db/migration/V18__alter_user_email_column_nullable.sql @@ -0,0 +1,2 @@ +ALTER TABLE `koin`.`users` + CHANGE COLUMN `email` `email` VARCHAR(100) CHARACTER SET 'utf8mb3' NULL COMMENT '학교 email' ; diff --git a/src/main/resources/db/migration/V19__update_coop_id_with_user_email.sql b/src/main/resources/db/migration/V19__update_coop_id_with_user_email.sql new file mode 100644 index 000000000..cd00a55af --- /dev/null +++ b/src/main/resources/db/migration/V19__update_coop_id_with_user_email.sql @@ -0,0 +1,3 @@ +UPDATE coop + JOIN users ON coop.user_id = users.id + SET coop.coop_id = users.email; diff --git a/src/main/resources/db/migration/V20__alter_coop_id_column_not_null.sql b/src/main/resources/db/migration/V20__alter_coop_id_column_not_null.sql new file mode 100644 index 000000000..5534b212f --- /dev/null +++ b/src/main/resources/db/migration/V20__alter_coop_id_column_not_null.sql @@ -0,0 +1,2 @@ +ALTER TABLE `koin`.`coop` + CHANGE COLUMN `coop_id` `coop_id` VARCHAR(255) NOT NULL COMMENT '영양사 id, 일반 로그인 형식' ; diff --git a/src/main/resources/db/migration/V21__add_timetable_frame.sql b/src/main/resources/db/migration/V21__add_timetable_frame.sql new file mode 100644 index 000000000..7969945e5 --- /dev/null +++ b/src/main/resources/db/migration/V21__add_timetable_frame.sql @@ -0,0 +1,12 @@ +CREATE TABLE timetable_frame ( + id INT UNSIGNED AUTO_INCREMENT NOT NULL comment '고유 id', + user_id INT UNSIGNED NOT NULL comment '유저 id', + semester_id INT UNSIGNED NOT NULL comment '학기 id', + name VARCHAR(255) NOT NULL comment '시간표 이름', + is_main TINYINT(1) NOT NULL DEFAULT 0 comment '메인 시간표 여부', + is_deleted TINYINT(1) NOT NULL DEFAULT 0 comment '시간표 삭제 여부', + created_at timestamp default CURRENT_TIMESTAMP not null comment '생성 일자', + updated_at timestamp default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '수정 일자', + PRIMARY KEY (`id`), + INDEX timetable_frame_INDEX (user_id, semester_id) USING BTREE +); diff --git a/src/main/resources/db/migration/V22__insert_timetable_frame.sql b/src/main/resources/db/migration/V22__insert_timetable_frame.sql new file mode 100644 index 000000000..87db47cfc --- /dev/null +++ b/src/main/resources/db/migration/V22__insert_timetable_frame.sql @@ -0,0 +1,3 @@ +INSERT INTO timetable_frame (user_id, semester_id, name, is_main, is_deleted) +SELECT user_id, semester_id, '시간표1', 1, is_deleted +FROM timetables GROUP BY user_id, semester_id, is_deleted; diff --git a/src/main/resources/db/migration/V23__add_timetable_lecture.sql b/src/main/resources/db/migration/V23__add_timetable_lecture.sql new file mode 100644 index 000000000..ce30cb694 --- /dev/null +++ b/src/main/resources/db/migration/V23__add_timetable_lecture.sql @@ -0,0 +1,17 @@ +CREATE TABLE `timetable_lecture` ( + id INT UNSIGNED AUTO_INCREMENT NOT NULL comment '고유 id' primary key, + class_title VARCHAR(255) NULL comment '수업 이름', + class_time VARCHAR(255) NULL comment '강의 시간', + class_place VARCHAR(255) NULL comment '수업 장소', + professor VARCHAR(255) NULL comment '교수', + grades VARCHAR(2) not null comment '학점' default '0', + memo VARCHAR(255) NULL comment '메모', + is_deleted TINYINT(1) NULL DEFAULT 0 comment '삭제 여부', + created_at timestamp default CURRENT_TIMESTAMP not null comment '생성 일자', + updated_at timestamp default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '업데이트 일자', + lectures_id INT UNSIGNED NULL comment '강의_id', + frame_id INT UNSIGNED NULL comment '시간표 프레임 id', + user_id INT UNSIGNED NULL comment '유저 id', + semester_id INT UNSIGNED NULL comment '학기 id', + CONSTRAINT FK_TIMETABLE_FRAME_ON_TIMETABLE_LECTURE FOREIGN KEY (frame_id) references `koin`.`timetable_frame`(id) on delete cascade +); diff --git a/src/main/resources/db/migration/V24__insert_timetable_lecture.sql b/src/main/resources/db/migration/V24__insert_timetable_lecture.sql new file mode 100644 index 000000000..ecd67503d --- /dev/null +++ b/src/main/resources/db/migration/V24__insert_timetable_lecture.sql @@ -0,0 +1,3 @@ +INSERT INTO timetable_lecture (class_place, memo, is_deleted, user_id, semester_id) +SELECT t.class_place, t.memo, is_deleted, t.user_id, t.semester_id +FROM timetables t; diff --git a/src/main/resources/db/migration/V25__alter_timetable_lecture_lectures_id.sql b/src/main/resources/db/migration/V25__alter_timetable_lecture_lectures_id.sql new file mode 100644 index 000000000..c87bda421 --- /dev/null +++ b/src/main/resources/db/migration/V25__alter_timetable_lecture_lectures_id.sql @@ -0,0 +1,6 @@ +UPDATE timetable_lecture tl + JOIN timetables t ON tl.id = t.id + JOIN lectures l ON t.class_title = l.name + AND t.class_time = l.class_time + JOIN semester s ON t.semester_id = s.id + SET tl.lectures_id = l.id; diff --git a/src/main/resources/db/migration/V26__alter_timetable_lecture_timetable_id.sql b/src/main/resources/db/migration/V26__alter_timetable_lecture_timetable_id.sql new file mode 100644 index 000000000..ef28cceb2 --- /dev/null +++ b/src/main/resources/db/migration/V26__alter_timetable_lecture_timetable_id.sql @@ -0,0 +1,3 @@ +UPDATE timetable_lecture t + JOIN timetable_frame f ON t.user_id = f.user_id AND t.semester_id = f.semester_id + SET t.frame_id = f.id; diff --git a/src/main/resources/db/migration/V27__delete_timetable_lecture_user_id_and_semester_id.sql b/src/main/resources/db/migration/V27__delete_timetable_lecture_user_id_and_semester_id.sql new file mode 100644 index 000000000..857e29d56 --- /dev/null +++ b/src/main/resources/db/migration/V27__delete_timetable_lecture_user_id_and_semester_id.sql @@ -0,0 +1,2 @@ +ALTER TABLE timetable_lecture DROP user_id; +ALTER TABLE timetable_lecture DROP semester_id; diff --git a/src/test/java/in/koreatech/koin/AcceptanceTest.java b/src/test/java/in/koreatech/koin/AcceptanceTest.java index 940db583b..bd1779dc6 100644 --- a/src/test/java/in/koreatech/koin/AcceptanceTest.java +++ b/src/test/java/in/koreatech/koin/AcceptanceTest.java @@ -22,6 +22,7 @@ import in.koreatech.koin.config.TestJpaConfiguration; import in.koreatech.koin.config.TestTimeConfig; import in.koreatech.koin.domain.bus.util.CityBusClient; +import in.koreatech.koin.domain.bus.util.CityBusRouteClient; import in.koreatech.koin.domain.coop.model.CoopEventListener; import in.koreatech.koin.domain.owner.model.OwnerEventListener; import in.koreatech.koin.domain.shop.model.ShopEventListener; @@ -45,6 +46,9 @@ public abstract class AcceptanceTest { @SpyBean protected CityBusClient cityBusClient; + @SpyBean + protected CityBusRouteClient cityBusRouteClient; + @MockBean protected OwnerEventListener ownerEventListener; diff --git a/src/test/java/in/koreatech/koin/acceptance/BusApiTest.java b/src/test/java/in/koreatech/koin/acceptance/BusApiTest.java index f853cd7c0..570561d5e 100644 --- a/src/test/java/in/koreatech/koin/acceptance/BusApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/BusApiTest.java @@ -104,6 +104,104 @@ void setup() { } } """); + + when(cityBusRouteClient.getOpenApiResponse("CAB285000686")).thenReturn(""" + { + "response": { + "header": { + "resultCode": "00", + "resultMsg": "NORMAL SERVICE." + }, + "body": { + "items": { + "item": [ + { + "endnodenm": "황사동", + "routeid": "CAB285000146", + "routeno": 402, + "routetp": "일반버스", + "startnodenm": "종합터미널" + }, + { + "endnodenm": "방아다리공원", + "routeid": "CAB285000331", + "routeno": 9, + "routetp": "일반버스", + "startnodenm": "종합터미널" + }, + { + "endnodenm": "유관순열사유적지", + "routeid": "CAB285000407", + "routeno": 405, + "routetp": "일반버스", + "startnodenm": "종합터미널" + }, + { + "endnodenm": "병천3리", + "routeid": "CAB285000142", + "routeno": 400, + "routetp": "일반버스", + "startnodenm": "종합터미널" + } + ] + }, + "numOfRows": 50, + "pageNo": 1, + "totalCount": 4 + } + } + } + """ + ); + + when(cityBusRouteClient.getOpenApiResponse("CAB285000406")).thenReturn(""" + { + "response": { + "header": { + "resultCode": "00", + "resultMsg": "NORMAL SERVICE." + }, + "body": { + "items": { + "item": [ + { + "endnodenm": "황사동", + "routeid": "CAB285000146", + "routeno": 402, + "routetp": "일반버스", + "startnodenm": "종합터미널" + }, + { + "endnodenm": "병천중고등학교", + "routeid": "CAB285000049", + "routeno": 95, + "routetp": "일반버스", + "startnodenm": "월봉청솔아파트" + }, + { + "endnodenm": "유관순열사유적지", + "routeid": "CAB285000407", + "routeno": 405, + "routetp": "일반버스", + "startnodenm": "종합터미널" + }, + { + "endnodenm": "병천3리", + "routeid": "CAB285000142", + "routeno": 400, + "routetp": "일반버스", + "startnodenm": "종합터미널" + } + ] + }, + "numOfRows": 50, + "pageNo": 1, + "totalCount": 4 + } + } + } + """ + ); } @Test @@ -152,7 +250,7 @@ void getNextCityBusRemainTimeRedis() { cityBusCacheRepository.save( CityBusCache.of( - depart.getNodeId(direction), + depart.getNodeId(direction).get(0), List.of(CityBusCacheInfo.of( CityBusArrival.builder() .routeno(busNumber) diff --git a/src/test/java/in/koreatech/koin/acceptance/DiningApiTest.java b/src/test/java/in/koreatech/koin/acceptance/DiningApiTest.java index a4d99a3b6..a0d3c7935 100644 --- a/src/test/java/in/koreatech/koin/acceptance/DiningApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/DiningApiTest.java @@ -49,7 +49,7 @@ class DiningApiTest extends AcceptanceTest { @BeforeEach void setUp() { - coop_준기 = userFixture.준기_영양사(); + coop_준기 = userFixture.준기_영양사().getUser(); token_준기 = userFixture.getToken(coop_준기); owner_현수 = userFixture.현수_사장님().getUser(); token_현수 = userFixture.getToken(owner_현수); diff --git a/src/test/java/in/koreatech/koin/acceptance/OwnerApiTest.java b/src/test/java/in/koreatech/koin/acceptance/OwnerApiTest.java index a54f4ce61..608962a86 100644 --- a/src/test/java/in/koreatech/koin/acceptance/OwnerApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/OwnerApiTest.java @@ -18,9 +18,9 @@ import in.koreatech.koin.AcceptanceTest; import in.koreatech.koin.domain.owner.model.Owner; import in.koreatech.koin.domain.owner.model.redis.OwnerVerificationStatus; -import in.koreatech.koin.domain.owner.repository.redis.OwnerVerificationStatusRepository; import in.koreatech.koin.domain.owner.repository.OwnerRepository; import in.koreatech.koin.domain.owner.repository.OwnerShopRedisRepository; +import in.koreatech.koin.domain.owner.repository.redis.OwnerVerificationStatusRepository; import in.koreatech.koin.domain.shop.model.Shop; import in.koreatech.koin.domain.user.model.User; import in.koreatech.koin.domain.user.repository.UserRepository; @@ -57,6 +57,29 @@ class OwnerApiTest extends AcceptanceTest { @Autowired private PasswordEncoder passwordEncoder; + @Test + @DisplayName("사장님이 로그인을 진행한다") + void ownerLogin() { + Owner owner = userFixture.원경_사장님(); + String phoneNumber = owner.getAccount(); + String password = "1234"; + + var response = RestAssured + .given() + .contentType(ContentType.JSON) + .body(""" + { + "account" : "%s", + "password" : "%s" + } + """.formatted(phoneNumber, password)) + .when() + .post("/owner/login") + .then() + .statusCode(HttpStatus.CREATED.value()) + .extract(); + } + @Test @DisplayName("로그인된 사장님 정보를 조회한다.") void getOwner() { @@ -81,6 +104,7 @@ void getOwner() { "email": "hysoo@naver.com", "name": "테스트용_현수", "company_number": "123-45-67190", + "account" : "01098765432", "attachments": [ { "id": 1, @@ -250,15 +274,16 @@ void registerByPhoneNumber() { softly -> { softly.assertThat(owner).isNotNull(); softly.assertThat(owner.getUser().getName()).isEqualTo("최준호"); - softly.assertThat(owner.getUser().getEmail()).isEqualTo("01012341234"); + softly.assertThat(owner.getUser().getEmail()).isEqualTo(null); softly.assertThat(owner.getUser().getPhoneNumber()).isEqualTo("01012341234"); softly.assertThat(owner.getCompanyRegistrationNumber()).isEqualTo("012-34-56789"); + softly.assertThat(owner.getAccount()).isEqualTo("01012341234"); softly.assertThat(owner.getAttachments().size()).isEqualTo(1); softly.assertThat(owner.getAttachments().get(0).getUrl()) .isEqualTo("https://static.koreatech.in/testimage.png"); softly.assertThat(owner.getUser().isAuthed()).isFalse(); softly.assertThat(owner.getUser().isDeleted()).isFalse(); - verify(ownerEventListener).onOwnerRegister(any()); + verify(ownerEventListener).onOwnerRegisterBySms(any()); } ); } @@ -311,7 +336,7 @@ void registerNotAllowedCompanyNumber() { "email": "helloworld@koreatech.ac.kr", "name": "최준호", "password": "a0240120305812krlakdsflsa;1235", - "phone_number": "010-0000-0000", + "phone_number": "01000000000", "shop_id": null, "shop_name": "기분좋은 뷔짱" } @@ -340,7 +365,7 @@ void registerWithoutName() { "email": "helloworld@koreatech.ac.kr", "name": "", "password": "a0240120305812krlakdsflsa;1235", - "phone_number": "010-0000-0000", + "phone_number": "01000000000", "shop_id": null, "shop_name": "기분좋은 뷔짱" } @@ -679,4 +704,116 @@ void ownerDelete() { // then assertThat(userRepository.findById(owner.getId())).isNotPresent(); } + + @Test + @DisplayName("사업자 등록번호 중복 검증 - 존재하지 않으면 200") + void checkDuplicateCompanyNumber() { + // when & then + RestAssured + .given() + .queryParam("company_number", "123-45-67190") + .when() + .get("/owners/exists/company-number") + .then() + .statusCode(HttpStatus.OK.value()); + } + + @Test + @DisplayName("사업자 등록번호 중복 검증 - 이미 존재하면 409") + void checkDuplicateCompanyNumberExists() { + // given + Owner owner = userFixture.현수_사장님(); + // when & then + var response = RestAssured + .given() + .queryParam("company_number", owner.getCompanyRegistrationNumber()) + .when() + .get("/owners/exists/company-number") + .then() + .statusCode(HttpStatus.CONFLICT.value()) + .extract(); + + assertThat(response.body().jsonPath().getString("message")) + .isEqualTo("이미 존재하는 사업자 등록번호입니다."); + } + + @Test + @DisplayName("사업자 등록번호 중복 검증 - 값이 존재하지 않으면 400") + void checkDuplicateCompanyNumberNotAccept() { + // when & then + RestAssured + .given() + .when() + .get("/owners/exists/company-number") + .then() + .statusCode(HttpStatus.BAD_REQUEST.value()); + } + + @Test + @DisplayName("사업자 등록번호 중복 검증 - 값이 올바르지 않으면 400") + void checkDuplicateCompanyNumberNotMatchedPattern() { + // when & then + RestAssured + .given() + .queryParam("company_number", "1234567890") + .when() + .get("/owners/exists/company-number") + .then() + .statusCode(HttpStatus.BAD_REQUEST.value()); + } + + @Test + @DisplayName("사장님 아이디(전화번호) 중복 검증 - 존재하지 않으면 200") + void checkExistsPhoneNumber() { + RestAssured + .given() + .param("account", "01012345678") + .when() + .get("/owners/exists/account") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + } + + @Test + @DisplayName("사장님 아이디(전화번호) 중복 검증 - 이미 존재하면 409") + void checkExistsPhoneNumberConflict() { + Owner owner = userFixture.현수_사장님(); + var response = RestAssured + .given() + .param("account", owner.getAccount()) + .when() + .get("/owners/exists/account") + .then() + .statusCode(HttpStatus.CONFLICT.value()) + .extract(); + + assertThat(response.body().jsonPath().getString("message")) + .contains("이미 존재하는 휴대폰번호입니다."); + } + + @Test + @DisplayName("사장님 아이디(전화번호) 중복 검증 - 파라미터에 전화번호를 포함하지 않으면 400") + void checkExistsPhoneNumberNull() { + RestAssured + .when() + .get("/owners/exists/account") + .then() + .statusCode(HttpStatus.BAD_REQUEST.value()) + .extract(); + } + + @Test + @DisplayName("사장님 아이디(전화번호) 중복 검증 - 잘못된 전화번호 형식이면 400") + void checkExistsPhoneNumberWrongFormat() { + String phoneNumber = "123123123123"; + RestAssured + .given() + .param("phone_number", phoneNumber) + .when() + .get("/owners/exists/account") + .then() + .statusCode(HttpStatus.BAD_REQUEST.value()) + .extract(); + } } diff --git a/src/test/java/in/koreatech/koin/acceptance/OwnerShopApiTest.java b/src/test/java/in/koreatech/koin/acceptance/OwnerShopApiTest.java index 72dcbcc56..b0990df6c 100644 --- a/src/test/java/in/koreatech/koin/acceptance/OwnerShopApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/OwnerShopApiTest.java @@ -162,31 +162,50 @@ void createOwnerShop() { "https://test.com/test2.jpg", "https://test.com/test3.jpg" ], + "name": "테스트 상점2", "open": [ { - "close_time": [ - 21, - 0 - ], + "close_time": "21:00", "closed": false, "day_of_week": "MONDAY", - "open_time": [ - 9, - 0 - ] + "open_time": "09:00" }, { - "close_time": [ - 21, - 0 - ], + "close_time": "21:00", + "closed": false, + "day_of_week": "TUESDAY", + "open_time": "09:00" + }, + { + "close_time": "21:00", "closed": false, "day_of_week": "WEDNESDAY", - "open_time": [ - 9, - 0 - ] + "open_time": "09:00" + }, + { + "close_time": "21:00", + "closed": false, + "day_of_week": "THURSDAY", + "open_time": "09:00" + }, + { + "close_time": "21:00", + "closed": false, + "day_of_week": "FRIDAY", + "open_time": "09:00" + }, + { + "close_time": "21:00", + "closed": false, + "day_of_week": "SATURDAY", + "open_time": "09:00" + }, + { + "close_time": "21:00", + "closed": false, + "day_of_week": "SUNDAY", + "open_time": "09:00" } ], "pay_bank": true, @@ -198,6 +217,7 @@ void createOwnerShop() { .when() .post("/owner/shops") .then() + .log().all() .statusCode(HttpStatus.CREATED.value()) .extract(); @@ -211,7 +231,7 @@ void createOwnerShop() { softly.assertThat(result.getDescription()).isEqualTo("테스트 상점2입니다."); softly.assertThat(result.getName()).isEqualTo("테스트 상점2"); softly.assertThat(result.getShopImages()).hasSize(3); - softly.assertThat(result.getShopOpens()).hasSize(2); + softly.assertThat(result.getShopOpens()).hasSize(7); softly.assertThat(result.getShopCategories()).hasSize(1); } ); diff --git a/src/test/java/in/koreatech/koin/acceptance/TimetableApiTest.java b/src/test/java/in/koreatech/koin/acceptance/TimetableApiTest.java index c1f405608..ddac42b52 100644 --- a/src/test/java/in/koreatech/koin/acceptance/TimetableApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/TimetableApiTest.java @@ -8,13 +8,13 @@ import org.springframework.http.HttpStatus; import in.koreatech.koin.AcceptanceTest; +import in.koreatech.koin.domain.timetable.model.Lecture; import in.koreatech.koin.domain.timetable.model.Semester; -import in.koreatech.koin.domain.timetable.model.TimeTable; -import in.koreatech.koin.domain.timetable.repository.TimeTableRepository; +import in.koreatech.koin.domain.timetable.repository.TimetableRepository; import in.koreatech.koin.domain.user.model.User; import in.koreatech.koin.fixture.LectureFixture; import in.koreatech.koin.fixture.SemesterFixture; -import in.koreatech.koin.fixture.TimeTableFixture; +import in.koreatech.koin.fixture.TimeTableV2Fixture; import in.koreatech.koin.fixture.UserFixture; import in.koreatech.koin.support.JsonAssertions; import io.restassured.RestAssured; @@ -24,7 +24,10 @@ class TimetableApiTest extends AcceptanceTest { @Autowired - private TimeTableRepository timeTableRepository; + private TimeTableV2Fixture timetableV2Fixture; + + @Autowired + private TimetableRepository timetableRepository; @Autowired private UserFixture userFixture; @@ -35,9 +38,6 @@ class TimetableApiTest extends AcceptanceTest { @Autowired private SemesterFixture semesterFixture; - @Autowired - private TimeTableFixture timeTableFixture; - @Test @DisplayName("특정 학기 강의를 조회한다") void getSemesterLecture() { @@ -58,6 +58,7 @@ void getSemesterLecture() { .isEqualTo(""" [ { + "id": 1, "code": "BSM590", "name": "컴퓨팅사고", "grades": "3", @@ -98,6 +99,7 @@ void getSemesterLectures() { .isEqualTo(""" [ { + "id": 1, "code": "BSM590", "name": "컴퓨팅사고", "grades": "3", @@ -114,6 +116,7 @@ void getSemesterLectures() { ] }, { + "id": 2, "code": "ARB244", "name": "건축구조의 이해 및 실습", "grades": "3", @@ -130,6 +133,7 @@ void getSemesterLectures() { ] }, { + "id": 3, "code": "MEB311", "name": "재료역학", "grades": "3", @@ -212,9 +216,11 @@ void getTimeTables() { User user = userFixture.준호_학생().getUser(); String token = userFixture.getToken(user); Semester semester = semesterFixture.semester("20192"); - TimeTable 이산수학 = timeTableFixture.이산수학(user, semester); - TimeTable 알고리즘및실습 = timeTableFixture.알고리즘및실습(user, semester); - TimeTable 컴퓨터구조 = timeTableFixture.컴퓨터구조(user, semester); + + Lecture 건축구조의_이해_및_실습 = lectureFixture.건축구조의_이해_및_실습(semester.getSemester()); + Lecture HRD_개론 = lectureFixture.HRD_개론(semester.getSemester()); + + timetableV2Fixture.시간표6(user, semester, 건축구조의_이해_및_실습, HRD_개론); // when & then var response = RestAssured @@ -233,61 +239,40 @@ void getTimeTables() { "semester": "20192", "timetable": [ { - "id": %d, - "regular_number": "40", - "code": "CSE125", + "id" : 1, + "regular_number": "25", + "code": "ARB244", "design_score": "0", - "class_time": [ - 14, 15, 16, 17, 312, 313 - ], + "class_time": [200, 201, 202, 203, 204, 205, 206, 207], "class_place": null, "memo": null, "grades": "3", - "class_title": "이산수학", + "class_title": "건축구조의 이해 및 실습", "lecture_class": "01", - "target": "컴부전체", - "professor": "서정빈", - "department": "컴퓨터공학부" - }, - { - "id": %d, - "regular_number": "32", - "code": "CSE130", - "design_score": "0", - "class_time": [ - 14, 15, 16, 17, 310, 311, 312, 313 - ], - "class_place": null, - "memo": null, - "grades": "3", - "class_title": "알고리즘및실습", - "lecture_class": "03", - "target": "컴부전체", - "professor": "박다희", - "department": "컴퓨터공학부" + "target": "디자 1 건축", + "professor": "황현식", + "department": "디자인ㆍ건축공학부" }, { - "id": %d, - "regular_number": "28", - "code": "CS101", + "id": 2, + "regular_number": "22", + "code": "BSM590", "design_score": "0", - "class_time": [ - 14, 15, 16, 17, 204, 205, 206, 207 - ], + "class_time": [12, 13, 14, 15, 210, 211, 212, 213], "class_place": null, "memo": null, "grades": "3", - "class_title": "컴퓨터 구조", - "lecture_class": "02", - "target": "컴부전체", - "professor": "김성재", - "department": "컴퓨터공학부" + "class_title": "컴퓨팅사고", + "lecture_class": "06", + "target": "기공1", + "professor": "박한수,최준호", + "department": "기계공학부" } ], - "grades": 9, - "total_grades": 9 + "grades": 6, + "total_grades": 6 } - """, 이산수학.getId(), 알고리즘및실습.getId(), 컴퓨터구조.getId() + """ )); } @@ -298,8 +283,11 @@ void getStudentCheckSemester() { String token = userFixture.getToken(user); Semester semester1 = semesterFixture.semester("20192"); Semester semester2 = semesterFixture.semester("20201"); - TimeTable 이산수학 = timeTableFixture.이산수학(user, semester1); - TimeTable 알고리즘및실습 = timeTableFixture.알고리즘및실습(user, semester2); + Lecture HRD_개론 = lectureFixture.HRD_개론(semester1.getSemester()); + Lecture 건축구조의_이해_및_실습 = lectureFixture.건축구조의_이해_및_실습(semester2.getSemester()); + timetableV2Fixture.시간표6(user, semester1, HRD_개론, null); + timetableV2Fixture.시간표6(user, semester2, 건축구조의_이해_및_실습, null); + var response = RestAssured .given() @@ -323,27 +311,6 @@ void getStudentCheckSemester() { ); } - @Test - @DisplayName("조회된 시간표가 없으면 404에러를 반환한다.") - void getTimeTablesNotFound() { - User user = userFixture.준호_학생().getUser(); - String token = userFixture.getToken(user); - Semester semester = semesterFixture.semester("20192"); - timeTableFixture.이산수학(user, semester); - timeTableFixture.알고리즘및실습(user, semester); - timeTableFixture.컴퓨터구조(user, semester); - - RestAssured - .given() - .header("Authorization", "Bearer " + token) - .when() - .param("semester", "20231") - .get("/timetables") - .then() - .statusCode(HttpStatus.NOT_FOUND.value()) - .extract(); - } - @Test @DisplayName("시간표를 생성한다.") void createTimeTables() { @@ -351,288 +318,120 @@ void createTimeTables() { String token = userFixture.getToken(user); Semester semester = semesterFixture.semester("20192"); - var response = RestAssured - .given() - .header("Authorization", "Bearer " + token) - .contentType(ContentType.JSON) - .body(String.format(""" - { - "timetable": [ - { - "code": "CPC490", - "class_title": "운영체제", - "class_time": [ - 210, - 211 - ], - "class_place": null, - "professor": "이돈우", - "grades": null, - "lecture_class": "01", - "target": "디자 1 건축", - "regular_number": "25", - "design_score": "0", - "department": "디자인ㆍ건축공학부", - "memo": null - }, - { - "code": "CSE201", - "class_title": "컴퓨터구조", - "class_time": [ - ], - "class_place": null, - "professor": "이강환", - "grades": "1", - "lecture_class": "02", - "target": "컴퓨 3", - "regular_number": "38", - "design_score": "0", - "department": "컴퓨터공학부", - "memo": null - } - ], - "semester": "%s" - } - """, semester.getSemester() - )) - .when() - .post("/timetables") - .then() - .statusCode(HttpStatus.OK.value()) - .extract(); - - JsonAssertions.assertThat(response.asPrettyString()) - .isEqualTo(""" - { - "semester": "20192", - "timetable": [ - { - "id": 1, - "regular_number": "25", - "code": "CPC490", - "design_score": "0", - "class_time": [ - 210, 211 - ], - "class_place": null, - "memo": null, - "grades": "0", - "class_title": "운영체제", - "lecture_class": "01", - "target": "디자 1 건축", - "professor": "이돈우", - "department": "디자인ㆍ건축공학부" - }, - { - "id": 2, - "regular_number": "38", - "code": "CSE201", - "design_score": "0", - "class_time": [ - - ], - "class_place": null, - "memo": null, - "grades": "1", - "class_title": "컴퓨터구조", - "lecture_class": "02", - "target": "컴퓨 3", - "professor": "이강환", - "department": "컴퓨터공학부" - } - ], - "grades": 1, - "total_grades": 1 - } - """); - } + lectureFixture.건축구조의_이해_및_실습(semester.getSemester()); + lectureFixture.HRD_개론(semester.getSemester()); - @Test - @DisplayName("시간표 생성시 필수 필드를 안넣을때 에러코드 400을 반환한다.") - void createTimeTablesBadRequest() { - User user = userFixture.준호_학생().getUser(); - String token = userFixture.getToken(user); - Semester semester = semesterFixture.semester("20192"); + timetableV2Fixture.시간표1(user, semester); var response = RestAssured .given() .header("Authorization", "Bearer " + token) .contentType(ContentType.JSON) - .body(String.format(""" - { - "timetable": [ - { - "code": "CPC490", - "class_title": null, - "class_time": [ - 210, - 211 - ], - "class_place": null, - "professor": null, - "grades": null, - "lecture_class": "01", - "target": "디자 1 건축", - "regular_number": "25", - "design_score": "0", - "department": "디자인ㆍ건축공학부", - "memo": null - } - ], - "semester": "%s" - } - """, semester.getSemester() - )) + .body(""" + { + "timetable": [ + { + "regular_number": "25", + "code": "ARB244", + "design_score": "0", + "class_time": [200, 201, 202, 203, 204, 205, 206, 207], + "class_place": null, + "memo": null, + "grades": "3", + "class_title": "건축구조의 이해 및 실습", + "lecture_class": "01", + "target": "디자 1 건축", + "professor": "황현식", + "department": "디자인ㆍ건축공학부" + }, + { + "regular_number": "22", + "code": "BSM590", + "design_score": "0", + "class_time": [12, 13, 14, 15, 210, 211, 212, 213], + "class_place": null, + "memo": null, + "grades": "3", + "class_title": "컴퓨팅사고", + "lecture_class": "06", + "target": "기공1", + "professor": "박한수,최준호", + "department": "기계공학부" + } + ], + "semester": "20192" + } + """) .when() .post("/timetables") .then() - .statusCode(HttpStatus.BAD_REQUEST.value()) - .extract(); - } - - @Test - @DisplayName("시간표 수정한다.") - void updateTimeTables() { - User user = userFixture.준호_학생().getUser(); - String token = userFixture.getToken(user); - Semester semester = semesterFixture.semester("20192"); - TimeTable timeTable1 = timeTableFixture.이산수학(user, semester); - TimeTable timeTable2 = timeTableFixture.알고리즘및실습(user, semester); - - var response = RestAssured - .given() - .header("Authorization", "Bearer " + token) - .contentType(ContentType.JSON) - .body(String.format(""" - { - "timetable": [ - { - "id": %d, - "code": "CPC999", - "class_title": "안녕체제", - "class_time": [ - 210, 211 - ], - "class_place": null, - "professor": "차은우", - "grades": "1", - "lecture_class": "01", - "target": "전체", - "regular_number": "25", - "design_score": "0", - "department": "교양학부", - "memo": null - }, - { - "id": %d, - "code": "CSE777", - "class_title": "구조화된컴퓨터", - "class_time": [ - ], - "class_place": null, - "professor": "장원영", - "grades": "1", - "lecture_class": "02", - "target": "컴퓨 3", - "regular_number": "38", - "design_score": "0", - "department": "컴퓨터공학부", - "memo": null - } - ], - "semester": "%s" - } - """, timeTable1.getId(), timeTable2.getId(), semester.getSemester() - )) - .when() - .put("/timetables") - .then() .statusCode(HttpStatus.OK.value()) - .extract(); + .extract() + .response(); JsonAssertions.assertThat(response.asPrettyString()) .isEqualTo(""" + { + "semester": "20192", + "timetable": [ { - "semester": "20192", - "timetable": [ - { - "id": 1, - "regular_number": "25", - "code": "CPC999", - "design_score": "0", - "class_time": [ - 210, 211 - ], - "class_place": null, - "memo": null, - "grades": "1", - "class_title": "안녕체제", - "lecture_class": "01", - "target": "전체", - "professor": "차은우", - "department": "교양학부" - }, - { - "id": 2, - "regular_number": "38", - "code": "CSE777", - "design_score": "0", - "class_time": [ - - ], - "class_place": null, - "memo": null, - "grades": "1", - "class_title": "구조화된컴퓨터", - "lecture_class": "02", - "target": "컴퓨 3", - "professor": "장원영", - "department": "컴퓨터공학부" - } - ], - "grades": 2, - "total_grades": 2 + "id": 1, + "regular_number": "25", + "code": "ARB244", + "design_score": "0", + "class_time": [200, 201, 202, 203, 204, 205, 206, 207], + "class_place": null, + "memo": null, + "grades": "3", + "class_title": "건축구조의 이해 및 실습", + "lecture_class": "01", + "target": "디자 1 건축", + "professor": "황현식", + "department": "디자인ㆍ건축공학부" + }, + { + "id": 2, + "regular_number": "22", + "code": "BSM590", + "design_score": "0", + "class_time": [12, 13, 14, 15, 210, 211, 212, 213], + "class_place": null, + "memo": null, + "grades": "3", + "class_title": "컴퓨팅사고", + "lecture_class": "06", + "target": "기공1", + "professor": "박한수,최준호", + "department": "기계공학부" } - """); + ], + "grades": 6, + "total_grades": 6 + } + """); } @Test @DisplayName("시간표를 삭제한다.") - void deleteTimeTable() { + void deleteTimetable() { User user = userFixture.준호_학생().getUser(); String token = userFixture.getToken(user); Semester semester = semesterFixture.semester("20192"); - TimeTable timeTable = timeTableFixture.이산수학(user, semester); - RestAssured - .given() - .header("Authorization", "Bearer " + token) - .when() - .param("id", timeTable.getId()) - .delete("/timetable") - .then() - .statusCode(HttpStatus.OK.value()); + Lecture 건축구조의_이해_및_실습 = lectureFixture.건축구조의_이해_및_실습(semester.getSemester()); + Lecture HRD_개론 = lectureFixture.HRD_개론(semester.getSemester()); - assertThat(timeTableRepository.findById(timeTable.getId())).isNotPresent(); - } - - @Test - @DisplayName("시간표 삭제 실패시(=조회 실패시) 404 에러코드를 반환한다.") - void deleteTimeTableNotFound() { - User user = userFixture.준호_학생().getUser(); - String token = userFixture.getToken(user); - Semester semester = semesterFixture.semester("20192"); - timeTableFixture.이산수학(user, semester); - timeTableFixture.알고리즘및실습(user, semester); - timeTableFixture.컴퓨터구조(user, semester); + timetableV2Fixture.시간표6(user, semester, 건축구조의_이해_및_실습, HRD_개론); RestAssured .given() .header("Authorization", "Bearer " + token) .when() - .param("id", 999) + .param("id", 2) .delete("/timetable") .then() - .statusCode(HttpStatus.NOT_FOUND.value()); + .statusCode(HttpStatus.OK.value()); + + assertThat(timetableRepository.findById(2)).isNotPresent(); } } diff --git a/src/test/java/in/koreatech/koin/acceptance/TimetableV2ApiTest.java b/src/test/java/in/koreatech/koin/acceptance/TimetableV2ApiTest.java new file mode 100644 index 000000000..f565a61d2 --- /dev/null +++ b/src/test/java/in/koreatech/koin/acceptance/TimetableV2ApiTest.java @@ -0,0 +1,449 @@ +package in.koreatech.koin.acceptance; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; + +import in.koreatech.koin.AcceptanceTest; +import in.koreatech.koin.domain.timetable.model.Lecture; +import in.koreatech.koin.domain.timetable.model.Semester; +import in.koreatech.koin.domain.timetableV2.model.TimetableFrame; +import in.koreatech.koin.domain.timetableV2.repository.TimetableFrameRepositoryV2; +import in.koreatech.koin.domain.timetableV2.repository.TimetableLectureRepositoryV2; +import in.koreatech.koin.domain.user.model.User; +import in.koreatech.koin.fixture.LectureFixture; +import in.koreatech.koin.fixture.SemesterFixture; +import in.koreatech.koin.fixture.TimeTableV2Fixture; +import in.koreatech.koin.fixture.UserFixture; +import in.koreatech.koin.support.JsonAssertions; +import io.restassured.RestAssured; +import io.restassured.http.ContentType; + +@SuppressWarnings("NonAsciiCharacters") +public class TimetableV2ApiTest extends AcceptanceTest { + + @Autowired + private TimeTableV2Fixture timetableV2Fixture; + + @Autowired + private UserFixture userFixture; + + @Autowired + private SemesterFixture semesterFixture; + + @Autowired + private LectureFixture lectureFixture; + + @Autowired + private TimetableFrameRepositoryV2 timetableFrameRepositoryV2; + + @Autowired + private TimetableLectureRepositoryV2 timetableLectureRepositoryV2; + + @Test + @DisplayName("특정 시간표 frame을 생성한다") + void createTimeTablesFrame() { + User user = userFixture.준호_학생().getUser(); + String token = userFixture.getToken(user); + Semester semester = semesterFixture.semester("20192"); + + var response = RestAssured + .given() + .header("Authorization", "Bearer " + token) + .contentType(ContentType.JSON) + .body(String.format(""" + { + "semester": "%s" + } + """, semester.getSemester() + )) + .when() + .post("/v2/timetables/frame") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(""" + { + "id": 1, + "timetable_name": "시간표1", + "is_main": true + } + """); + } + + @Test + @DisplayName("특정 시간표 frame을 수정한다") + void updateTimetableFrame() { + User user = userFixture.준호_학생().getUser(); + String token = userFixture.getToken(user); + Semester semester = semesterFixture.semester("20192"); + TimetableFrame frame = timetableV2Fixture.시간표1(user, semester); + Integer frameId = frame.getId(); + + var response = RestAssured + .given() + .header("Authorization", "Bearer " + token) + .contentType(ContentType.JSON) + .body(String.format(""" + { + "name": "새로운 이름", + "is_main": true + } + """ + )) + .when() + .put("/v2/timetables/frame/{id}", frameId) + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(""" + { + "id": 1, + "name": "새로운 이름", + "is_main": true + } + """); + } + + @Test + @DisplayName("모든 시간표 frame을 조회한다") + void getAllTimeTablesFrame() { + User user = userFixture.준호_학생().getUser(); + String token = userFixture.getToken(user); + Semester semester = semesterFixture.semester("20192"); + + timetableV2Fixture.시간표1(user, semester); + timetableV2Fixture.시간표2(user, semester); + + var response = RestAssured + .given() + .header("Authorization", "Bearer " + token) + .when() + .param("semester", semester.getSemester()) + .get("/v2/timetables/frames") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(""" + [ + { + "id": 1, + "timetable_name": "시간표1", + "is_main": true + }, + { + "id": 2, + "timetable_name": "시간표2", + "is_main": false + } + ] + """); + } + + @Test + @DisplayName("강의를 담고 있는 특정 시간표 frame을 삭제한다") + void deleteTimeTablesFrame() { + User user = userFixture.준호_학생().getUser(); + String token = userFixture.getToken(user); + Semester semester = semesterFixture.semester("20192"); + Lecture lecture = lectureFixture.HRD_개론(semester.getSemester()); + + TimetableFrame frame1 = timetableV2Fixture.시간표5(user, semester, lecture); + + RestAssured + .given() + .header("Authorization", "Bearer " + token) + .when() + .param("id", frame1.getId()) + .delete("/v2/timetables/frame") + .then() + .statusCode(HttpStatus.NO_CONTENT.value()); + + assertThat(timetableFrameRepositoryV2.findById(frame1.getId())).isNotPresent(); + assertThat(timetableLectureRepositoryV2.findById(frame1.getTimetableLectures().get(1).getId())).isNotPresent(); + } + + @Test + @DisplayName("특정 시간표 frame을 삭제한다 - 본인 삭제가 아니면 403 반환") + void deleteTimeTablesFrameNoAuth() { + User user1 = userFixture.준호_학생().getUser(); + User user2 = userFixture.성빈_학생().getUser(); + String token = userFixture.getToken(user2); + Semester semester = semesterFixture.semester("20192"); + + TimetableFrame frame1 = timetableV2Fixture.시간표1(user1, semester); + + var response = RestAssured + .given() + .header("Authorization", "Bearer " + token) + .when() + .param("id", frame1.getId()) + .delete("/v2/timetables/frame") + .then() + .statusCode(HttpStatus.FORBIDDEN.value()); + } + + @Test + @DisplayName("시간표를 생성한다 - TimetableLecture") + void createTimetableLecture() { + User user = userFixture.준호_학생().getUser(); + String token = userFixture.getToken(user); + Semester semester = semesterFixture.semester("20192"); + timetableV2Fixture.시간표1(user, semester); + + var response = RestAssured + .given() + .header("Authorization", "Bearer " + token) + .contentType("application/json") + .body(""" + { + "timetable_frame_id" : 1, + "timetable_lecture": [ + { + "class_title": "커스텀생성1", + "class_time" : [200, 201], + "class_place" : "한기대", + "professor" : "서정빈", + "grades": "2", + "memo" : "메모" + }, + { + "class_title": "커스텀생성2", + "class_time" : [202, 203], + "class_place" : "참빛관 편의점", + "professor" : "감사 서정빈", + "grades": "1", + "memo" : "메모" + } + ] + } + """) + .when() + .post("/v2/timetables/lecture") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(""" + { + "timetable_frame_id": 1, + "timetable": [ + { + "id": 1, + "regular_number": null, + "code": null, + "design_score": null, + "class_time": [200, 201], + "class_place": "한기대", + "memo": "메모", + "grades": "2", + "class_title": "커스텀생성1", + "lecture_class": null, + "target": null, + "professor": "서정빈", + "department": null + }, + { + "id": 2, + "regular_number": null, + "code": null, + "design_score": null, + "class_time": [202, 203], + "class_place": "참빛관 편의점", + "memo": "메모", + "grades": "1", + "class_title": "커스텀생성2", + "lecture_class": null, + "target": null, + "professor": "감사 서정빈", + "department": null + } + ], + "grades": 3, + "total_grades": 3 + } + """); + } + + @Test + @DisplayName("시간표를 수정한다 - TimetableLecture") + void updateTimetableLecture() { + User user = userFixture.준호_학생().getUser(); + String token = userFixture.getToken(user); + Semester semester = semesterFixture.semester("20192"); + TimetableFrame frame = timetableV2Fixture.시간표3(user, semester); + Integer frameId = frame.getId(); + + var response = RestAssured + .given() + .header("Authorization", "Bearer " + token) + .contentType("application/json") + .body(""" + { + "timetable_frame_id" : 1, + "timetable_lecture": [ + { + "id": 1, + "class_title": "커스텀바꿔요1", + "class_time" : [200, 201], + "class_place" : "한기대", + "professor" : "서정빈", + "grades" : "0", + "memo" : "메모한당 히히" + }, + { + "id": 2, + "class_title": "커스텀바꿔요2", + "class_time" : [202, 203], + "class_place" : "참빛관 편의점", + "professor" : "알바 서정빈", + "grades" : "0", + "memo" : "메모한당 히히" + } + ] + } + """) + .when() + .put("/v2/timetables/lecture") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(""" + { + "timetable_frame_id": 1, + "timetable": [ + { + "id": 1, + "regular_number": null, + "code": null, + "design_score": null, + "class_time": [200, 201], + "class_place": "한기대", + "memo": "메모한당 히히", + "grades": "0", + "class_title": "커스텀바꿔요1", + "lecture_class": null, + "target": null, + "professor": "서정빈", + "department": null + }, + { + "id": 2, + "regular_number": null, + "code": null, + "design_score": null, + "class_time": [202, 203], + "class_place": "참빛관 편의점", + "memo": "메모한당 히히", + "grades": "0", + "class_title": "커스텀바꿔요2", + "lecture_class": null, + "target": null, + "professor": "알바 서정빈", + "department": null + } + ], + "grades": 0, + "total_grades": 0 + } + """); + } + + @Test + @DisplayName("시간표를 조회한다 - TimetableLecture") + void getTimetableLecture() { + User user = userFixture.준호_학생().getUser(); + String token = userFixture.getToken(user); + Semester semester = semesterFixture.semester("20192"); + + Lecture 건축구조의_이해_및_실습 = lectureFixture.건축구조의_이해_및_실습(semester.getSemester()); + Lecture HRD_개론 = lectureFixture.HRD_개론(semester.getSemester()); + + TimetableFrame frame = timetableV2Fixture.시간표6(user, semester, 건축구조의_이해_및_실습, HRD_개론); + + var response = RestAssured + .given() + .header("Authorization", "Bearer " + token) + .contentType("application/json") + .when() + .param("timetable_frame_id", frame.getId()) + .get("/v2/timetables/lecture") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(""" + { + "timetable_frame_id": 1, + "timetable": [ + { + "id" : 1, + "regular_number": "25", + "code": "ARB244", + "design_score": "0", + "class_time": [200, 201, 202, 203, 204, 205, 206, 207], + "class_place": null, + "memo": null, + "grades": "3", + "class_title": "건축구조의 이해 및 실습", + "lecture_class": "01", + "target": "디자 1 건축", + "professor": "황현식", + "department": "디자인ㆍ건축공학부" + }, + { + "id": 2, + "regular_number": "22", + "code": "BSM590", + "design_score": "0", + "class_time": [12, 13, 14, 15, 210, 211, 212, 213], + "class_place": null, + "memo": null, + "grades": "3", + "class_title": "컴퓨팅사고", + "lecture_class": "06", + "target": "기공1", + "professor": "박한수,최준호", + "department": "기계공학부" + } + ], + "grades": 6, + "total_grades": 6 + } + """); + } + + @Test + @DisplayName("시간표에서 특정 강의를 삭제한다") + void deleteTimetableLecture() { + User user1 = userFixture.준호_학생().getUser(); + String token = userFixture.getToken(user1); + Semester semester = semesterFixture.semester("20192"); + Lecture lecture1 = lectureFixture.HRD_개론("20192"); + Lecture lecture2 = lectureFixture.영어청해("20192"); + TimetableFrame frame = timetableV2Fixture.시간표4(user1, semester, lecture1, lecture2); + + Integer lectureId = lecture1.getId(); + + var response = RestAssured + .given() + .header("Authorization", "Bearer " + token) + .when() + .delete("/v2/timetables/lecture/{id}", lectureId) + .then() + .statusCode(HttpStatus.NO_CONTENT.value()); + } +} diff --git a/src/test/java/in/koreatech/koin/acceptance/TrackApiTest.java b/src/test/java/in/koreatech/koin/acceptance/TrackApiTest.java index 5a01cee9f..bb724fa27 100644 --- a/src/test/java/in/koreatech/koin/acceptance/TrackApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/TrackApiTest.java @@ -73,7 +73,7 @@ void findTracks() { @Test @DisplayName("BCSDLab 트랙 정보 단건 조회 - 삭제된 멤버는 조회하지 않는다.") - void findingTracksExcludingDeletedMember() { + void findTrackWithoutDeletedMember() { Track track = trackFixture.backend(); memberFixture.배진호(track); // 삭제된 멤버 memberFixture.최준호(track); diff --git a/src/test/java/in/koreatech/koin/acceptance/UserApiTest.java b/src/test/java/in/koreatech/koin/acceptance/UserApiTest.java index f76ecda03..bda450b83 100644 --- a/src/test/java/in/koreatech/koin/acceptance/UserApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/UserApiTest.java @@ -24,6 +24,7 @@ import org.springframework.transaction.support.TransactionTemplate; import in.koreatech.koin.AcceptanceTest; +import in.koreatech.koin.domain.coop.model.Coop; import in.koreatech.koin.domain.dept.model.Dept; import in.koreatech.koin.domain.user.model.Student; import in.koreatech.koin.domain.user.model.User; @@ -55,11 +56,80 @@ class UserApiTest extends AcceptanceTest { @Autowired private UserFixture userFixture; + @Test + @DisplayName("학생이 로그인을 진행한다(구 API(/user/login))") + void login() { + Student student = userFixture.성빈_학생(); + String email = student.getUser().getEmail(); + String password = "1234"; + + var response = RestAssured + .given() + .contentType(ContentType.JSON) + .body(""" + { + "email" : "%s", + "password" : "%s" + } + """.formatted(email, password)) + .when() + .post("/user/login") + .then() + .statusCode(HttpStatus.CREATED.value()) + .extract(); + } + + @Test + @DisplayName("학생이 로그인을 진행한다(신규 API(/student/login))") + void studentLogin() { + Student student = userFixture.성빈_학생(); + String email = student.getUser().getEmail(); + String password = "1234"; + + var response = RestAssured + .given() + .contentType(ContentType.JSON) + .body(""" + { + "email" : "%s", + "password" : "%s" + } + """.formatted(email, password)) + .when() + .post("/student/login") + .then() + .statusCode(HttpStatus.CREATED.value()) + .extract(); + } + + @Test + @DisplayName("영양사가 로그인을 진행한다") + void coopLogin() { + Coop coop = userFixture.준기_영양사(); + String id = coop.getCoopId(); + String password = "1234"; + + var response = RestAssured + .given() + .contentType(ContentType.JSON) + .body(""" + { + "id" : "%s", + "password" : "%s" + } + """.formatted(id, password)) + .when() + .post("/coop/login") + .then() + .statusCode(HttpStatus.CREATED.value()) + .extract(); + } + @Test @DisplayName("올바른 영양사 계정인지 확인한다") void coopCheckMe() { - User user = userFixture.준기_영양사(); - String token = userFixture.getToken(user); + Coop coop = userFixture.준기_영양사(); + String token = userFixture.getToken(coop.getUser()); var response = RestAssured .given() @@ -95,7 +165,7 @@ void studentCheckMe() { "major": "컴퓨터공학부", "name": "테스트용_준호", "nickname": "준호", - "phone_number": "010-1234-5678", + "phone_number": "01012345678", "student_number": "2019136135" } """); @@ -153,7 +223,7 @@ void studentUpdateMe() { "name" : "서정빈", "password" : "0c4be6acaba1839d3433c1ccf04e1eec4d1fa841ee37cb019addc269e8bc1b77", "nickname" : "duehee", - "phone_number" : "010-2345-6789", + "phone_number" : "01023456789", "student_number" : "2019136136" } """) @@ -185,7 +255,7 @@ void studentUpdateMe() { "major": "기계공학부", "name": "서정빈", "nickname": "duehee", - "phone_number": "010-2345-6789", + "phone_number": "01023456789", "student_number": "2019136136" } """); @@ -207,7 +277,7 @@ void studentUpdateMeNotValidStudentNumber() { "major" : "메카트로닉스공학부", "name" : "최주노", "nickname" : "juno", - "phone_number" : "010-2345-6789", + "phone_number" : "01023456789", "student_number" : "201913613" } """) @@ -234,7 +304,7 @@ void studentUpdateMeNotValidDepartment() { "major" : "경영학과", "name" : "최주노", "nickname" : "juno", - "phone_number" : "010-2345-6789", + "phone_number" : "01023456789", "student_number" : "2019136136" } """) @@ -261,7 +331,7 @@ void studentUpdateMeUnAuthorized() { "major" : "메카트로닉스공학부", "name" : "최주노", "nickname" : "juno", - "phone_number" : "010-2345-6789", + "phone_number" : "01023456789", "student_number" : "2019136136" } """) @@ -291,7 +361,7 @@ void studentUpdateMeNotFound() { "major" : "메카트로닉스공학부", "name" : "최주노", "nickname" : "juno", - "phone_number" : "010-2345-6789", + "phone_number" : "01023456789", "student_number" : "2019136136" } """) @@ -319,7 +389,7 @@ void studentUpdateMeDuplicationNickname() { "major" : "테스트학과", "name" : "최주노", "nickname" : "%s", - "phone_number" : "010-2345-6789", + "phone_number" : "01023456789", "student_number" : "2019136136" } """, 성빈.getUser().getNickname())) @@ -539,7 +609,7 @@ void studentRegister() { "gender": "0", "is_graduated": false, "student_number": "2021136012", - "phone_number": "010-0000-0000" + "phone_number": "01000000000" } """) .contentType(ContentType.JSON) @@ -559,7 +629,7 @@ protected void doInTransactionWithoutResult(TransactionStatus status) { softly.assertThat(student).isNotNull(); softly.assertThat(student.getUser().getNickname()).isEqualTo("koko"); softly.assertThat(student.getUser().getName()).isEqualTo("김철수"); - softly.assertThat(student.getUser().getPhoneNumber()).isEqualTo("010-0000-0000"); + softly.assertThat(student.getUser().getPhoneNumber()).isEqualTo("01000000000"); softly.assertThat(student.getUser().getUserType()).isEqualTo(STUDENT); softly.assertThat(student.getUser().getEmail()).isEqualTo("koko123@koreatech.ac.kr"); softly.assertThat(student.getUser().isAuthed()).isEqualTo(false); @@ -588,7 +658,7 @@ void authenticate() { "gender": "0", "is_graduated": false, "student_number": "2021136012", - "phone_number": "010-0000-0000" + "phone_number": "01000000000" } """) .contentType(ContentType.JSON) @@ -626,7 +696,7 @@ void studentRegisterBadRequest() { "gender": "0", "is_graduated": false, "student_number": "2021136012", - "phone_number": "010-0000-0000" + "phone_number": "01000000000" } """) .contentType(ContentType.JSON) @@ -651,7 +721,7 @@ void studentRegisterInvalid() { "gender": "0", "is_graduated": false, "student_number": "2021136012", - "phone_number": "010-0000-0000" + "phone_number": "01000000000" } """) .contentType(ContentType.JSON) @@ -676,7 +746,7 @@ void studentRegisterStudentNumberInvalid() { "gender": "0", "is_graduated": false, "student_number": "20211360123324231", - "phone_number": "010-0000-0000" + "phone_number": "01000000000" } """) .contentType(ContentType.JSON) @@ -697,7 +767,7 @@ void studentRegisterStudentNumberInvalid() { "gender": "0", "is_graduated": false, "student_number": "19911360123", - "phone_number": "010-0000-0000" + "phone_number": "01000000000" } """) .contentType(ContentType.JSON) @@ -728,7 +798,7 @@ void concurrencyStudentRegister(CapturedOutput capturedOutput) throws Interrupte "gender": "0", "is_graduated": false, "student_number": "2022136012", - "phone_number": "010-0000-0000" + "phone_number": "01000000000" } """) .contentType(ContentType.JSON) @@ -765,7 +835,7 @@ void userCheckPassword() { } @Test - @DisplayName("사용자가 비밀번호를 통해 자신이 맞는지 인증한다. - 비밀번호가 다르면 401 반환") + @DisplayName("사용자가 비밀번호를 통해 자신이 맞는지 인증한다. - 비밀번호가 다르면 400 반환") void userCheckPasswordInvalid() { Student student = userFixture.준호_학생(); String token = userFixture.getToken(student.getUser()); @@ -782,6 +852,6 @@ void userCheckPasswordInvalid() { .when() .post("/user/check/password") .then() - .statusCode(HttpStatus.UNAUTHORIZED.value()); + .statusCode(HttpStatus.BAD_REQUEST.value()); } } diff --git a/src/test/java/in/koreatech/koin/admin/acceptance/AdminLandApiTest.java b/src/test/java/in/koreatech/koin/admin/acceptance/AdminLandApiTest.java index ac5ab4687..cad868d9d 100644 --- a/src/test/java/in/koreatech/koin/admin/acceptance/AdminLandApiTest.java +++ b/src/test/java/in/koreatech/koin/admin/acceptance/AdminLandApiTest.java @@ -1,14 +1,10 @@ package in.koreatech.koin.admin.acceptance; -import static in.koreatech.koin.support.JsonAssertions.assertThat; import static org.assertj.core.api.SoftAssertions.assertSoftly; import static org.hibernate.validator.internal.util.Contracts.assertNotNull; import static org.junit.Assert.assertEquals; import static org.junit.jupiter.api.Assertions.assertAll; -import java.util.List; -import java.util.Optional; - import org.assertj.core.api.SoftAssertions; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -16,11 +12,12 @@ import org.springframework.http.HttpStatus; import in.koreatech.koin.AcceptanceTest; -import in.koreatech.koin.admin.land.dto.AdminLandsRequest; import in.koreatech.koin.admin.land.repository.AdminLandRepository; import in.koreatech.koin.domain.land.model.Land; import in.koreatech.koin.domain.user.model.User; +import in.koreatech.koin.fixture.LandFixture; import in.koreatech.koin.fixture.UserFixture; +import in.koreatech.koin.support.JsonAssertions; import io.restassured.RestAssured; @SuppressWarnings("NonAsciiCharacters") @@ -29,6 +26,9 @@ class AdminLandApiTest extends AcceptanceTest { @Autowired private AdminLandRepository adminLandRepository; + @Autowired + private LandFixture landFixture; + @Autowired private UserFixture userFixture; @@ -78,32 +78,32 @@ void getLands() { @DisplayName("관리자 권한으로 복덕방을 추가한다.") void postLands() { String jsonBody = """ - { - "name": "금실타운", - "internal_name": "금실타운", - "size": "9.0", - "room_type": "원룸", - "latitude": "37.555", - "longitude": "126.555", - "phone": "041-111-1111", - "image_urls": ["http://image1.com", "http://image2.com"], - "address": "충청남도 천안시 동남구 병천면", - "description": "1년 계약시 20만원 할인", - "floor": 4, - "deposit": "30", - "monthly_fee": "200만원 (6개월)", - "charter_fee": "3500", - "management_fee": "21(1인 기준)", - "opt_closet": true, - "opt_tv": true, - "opt_microwave": true, - "opt_gas_range": false, - "opt_induction": true, - "opt_water_purifier": true, - "opt_air_conditioner": true, - "opt_washer": true - } - """; + { + "name": "금실타운", + "internal_name": "금실타운", + "size": "9.0", + "room_type": "원룸", + "latitude": "37.555", + "longitude": "126.555", + "phone": "041-111-1111", + "image_urls": ["http://image1.com", "http://image2.com"], + "address": "충청남도 천안시 동남구 병천면", + "description": "1년 계약시 20만원 할인", + "floor": 4, + "deposit": "30", + "monthly_fee": "200만원 (6개월)", + "charter_fee": "3500", + "management_fee": "21(1인 기준)", + "opt_closet": true, + "opt_tv": true, + "opt_microwave": true, + "opt_gas_range": false, + "opt_induction": true, + "opt_water_purifier": true, + "opt_air_conditioner": true, + "opt_washer": true + } + """; User adminUser = userFixture.코인_운영자(); String token = userFixture.getToken(adminUser); @@ -130,4 +130,219 @@ void postLands() { softly.assertThat(savedLand.isOptRefrigerator()).as("opt_refrigerator가 누락될 경우 false 반환여부").isEqualTo(false); }); } + + @Test + @DisplayName("관리자 권한으로 복덕방을 삭제한다.") + void deleteLand() { + // 복덕방 생성 + Land request = Land.builder() + .internalName("금실타운") + .name("금실타운") + .roomType("원룸") + .latitude("37.555") + .longitude("126.555") + .monthlyFee("100") + .charterFee("1000") + .build(); + + Land savedLand = adminLandRepository.save(request); + Integer landId = savedLand.getId(); + + User adminUser = userFixture.코인_운영자(); + String token = userFixture.getToken(adminUser); + + RestAssured + .given() + .header("Authorization", "Bearer " + token) + .when() + .delete("/admin/lands/{id}", landId) + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + Land deletedLand = adminLandRepository.getById(landId); + + SoftAssertions.assertSoftly(softly -> { + softly.assertThat(deletedLand.getName()).isEqualTo("금실타운"); + softly.assertThat(deletedLand.isDeleted()).isEqualTo(true); + }); + } + + @Test + @DisplayName("관리자의 권한으로 특정 복덕방 정보를 조회한다.") + void getLand() { + // 복덕방 생성 + Land request = Land.builder() + .internalName("금실타운") + .name("금실타운") + .roomType("원룸") + .latitude("37.555") + .longitude("126.555") + .size("9.0") + .monthlyFee("100") + .charterFee("1000") + .address("가전리 123") + .description("테스트용 복덕방") + .build(); + + Land savedLand = adminLandRepository.save(request); + Integer landId = savedLand.getId(); + + User adminUser = userFixture.코인_운영자(); + String token = userFixture.getToken(adminUser); + + var response = RestAssured + .given() + .header("Authorization", "Bearer " + token) + .when() + .get("/admin/lands/{id}", landId) + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(String.format(""" + { + "id": %d, + "name": "금실타운", + "internal_name": "금실타운", + "size": 9.0, + "room_type": "원룸", + "latitude": 37.555, + "longitude": 126.555, + "phone": null, + "image_urls": [], + "address": "가전리 123", + "description": "테스트용 복덕방", + "floor": null, + "deposit": null, + "monthly_fee": "100", + "charter_fee": "1000", + "management_fee": null, + "opt_closet": false, + "opt_tv": false, + "opt_microwave": false, + "opt_gas_range": false, + "opt_induction": false, + "opt_water_purifier": false, + "opt_air_conditioner": false, + "opt_washer": false, + "opt_bed": false, + "opt_bidet": false, + "opt_desk": false, + "opt_electronic_door_locks": false, + "opt_elevator": false, + "opt_refrigerator": false, + "opt_shoe_closet": false, + "opt_veranda": false, + "is_deleted": false + } + """, landId)); + } + + @Test + @DisplayName("관리자 권한으로 복덕방 정보를 수정한다.") + void updateLand() { + Land land = landFixture.신안빌(); + Integer landId = land.getId(); + + User adminUser = userFixture.코인_운영자(); + String token = userFixture.getToken(adminUser); + + String jsonBody = """ + { + "name": "신안빌 수정", + "internal_name": "신안빌", + "size": "110.0", + "room_type": "투룸", + "latitude": "37.556", + "longitude": "126.556", + "phone": "010-1234-5679", + "image_urls": ["http://newimage1.com", "http://newimage2.com"], + "address": "서울시 강남구 신사동", + "description": "신안빌 수정 설명", + "floor": 5, + "deposit": "50", + "monthly_fee": "150만원", + "charter_fee": "5000", + "management_fee": "150", + "opt_closet": true, + "opt_tv": false, + "opt_microwave": true, + "opt_gas_range": false, + "opt_induction": true, + "opt_water_purifier": false, + "opt_air_conditioner": true, + "opt_washer": true + } + """; + + RestAssured + .given() + .header("Authorization", "Bearer " + token) + .contentType("application/json") + .body(jsonBody) + .when() + .put("/admin/lands/{id}", landId) + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + Land updatedLand = adminLandRepository.getById(landId); + + assertSoftly(softly -> { + softly.assertThat(updatedLand.getName()).isEqualTo("신안빌 수정"); + softly.assertThat(updatedLand.getInternalName()).isEqualTo("신안빌"); + softly.assertThat(updatedLand.getSize()).isEqualTo(110.0); + softly.assertThat(updatedLand.getRoomType()).isEqualTo("투룸"); + softly.assertThat(updatedLand.getLatitude()).isEqualTo(37.556); + softly.assertThat(updatedLand.getLongitude()).isEqualTo(126.556); + softly.assertThat(updatedLand.getPhone()).isEqualTo("010-1234-5679"); + softly.assertThat(updatedLand.getImageUrls()).containsAnyOf("http://newimage1.com", "http://newimage2.com"); + softly.assertThat(updatedLand.getAddress()).isEqualTo("서울시 강남구 신사동"); + softly.assertThat(updatedLand.getDescription()).isEqualTo("신안빌 수정 설명"); + softly.assertThat(updatedLand.getFloor()).isEqualTo(5); + softly.assertThat(updatedLand.getDeposit()).isEqualTo("50"); + softly.assertThat(updatedLand.getMonthlyFee()).isEqualTo("150만원"); + softly.assertThat(updatedLand.getCharterFee()).isEqualTo("5000"); + softly.assertThat(updatedLand.getManagementFee()).isEqualTo("150"); + softly.assertThat(updatedLand.isOptCloset()).isTrue(); + softly.assertThat(updatedLand.isOptTv()).isFalse(); + softly.assertThat(updatedLand.isOptMicrowave()).isTrue(); + softly.assertThat(updatedLand.isOptGasRange()).isFalse(); + softly.assertThat(updatedLand.isOptInduction()).isTrue(); + softly.assertThat(updatedLand.isOptWaterPurifier()).isFalse(); + softly.assertThat(updatedLand.isOptAirConditioner()).isTrue(); + softly.assertThat(updatedLand.isOptWasher()).isTrue(); + softly.assertThat(updatedLand.isDeleted()).isEqualTo(false); + }); + } + + @Test + @DisplayName("관리자 권한으로 복덕방 삭제를 취소한다.") + void undeleteLand() { + Land deletedLand = landFixture.삭제된_복덕방(); + Integer landId = deletedLand.getId(); + + User adminUser = userFixture.코인_운영자(); + String token = userFixture.getToken(adminUser); + + RestAssured + .given() + .header("Authorization", "Bearer " + token) + .when() + .post("/admin/lands/{id}/undelete", landId) + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + Land undeletedLand = adminLandRepository.getById(landId); + + SoftAssertions.assertSoftly(softly -> { + softly.assertThat(undeletedLand).isNotNull(); + softly.assertThat(undeletedLand.getName()).isEqualTo("삭제된 복덕방"); + softly.assertThat(undeletedLand.isDeleted()).isFalse(); + }); + } + } diff --git a/src/test/java/in/koreatech/koin/admin/acceptance/AdminMemberApiTest.java b/src/test/java/in/koreatech/koin/admin/acceptance/AdminMemberApiTest.java index 01920ece7..981b28a34 100644 --- a/src/test/java/in/koreatech/koin/admin/acceptance/AdminMemberApiTest.java +++ b/src/test/java/in/koreatech/koin/admin/acceptance/AdminMemberApiTest.java @@ -166,7 +166,7 @@ void deleteMember() { User adminUser = userFixture.코인_운영자(); String token = userFixture.getToken(adminUser); - var response = RestAssured + RestAssured .given() .header("Authorization", "Bearer " + token) .when() @@ -175,7 +175,6 @@ void deleteMember() { .statusCode(HttpStatus.OK.value()) .extract(); - Member savedMember = adminMemberRepository.getById(memberId); SoftAssertions.assertSoftly(softly -> { @@ -188,4 +187,124 @@ void deleteMember() { softly.assertThat(savedMember.isDeleted()).isEqualTo(true); }); } + + @Test + @DisplayName("BCSDLab 회원 정보를 수정한다") + void updateMember() { + Member member = memberFixture.최준호(trackFixture.backend()); + Integer memberId = member.getId(); + + User adminUser = userFixture.코인_운영자(); + String token = userFixture.getToken(adminUser); + + String jsonBody = """ + { + "name": "최준호", + "student_number": "2019136135", + "track": "BackEnd", + "position": "Mentor", + "email": "testjuno@gmail.com", + "image_url": "https://imagetest.com/juno.jpg" + } + """; + + RestAssured + .given() + .header("Authorization", "Bearer " + token) + .contentType("application/json") + .body(jsonBody) + .when() + .put("/admin/members/{id}", memberId) + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + Member updatedMember = adminMemberRepository.getById(memberId); + + SoftAssertions.assertSoftly(softly -> { + softly.assertThat(updatedMember.getName()).isEqualTo("최준호"); + softly.assertThat(updatedMember.getStudentNumber()).isEqualTo("2019136135"); + softly.assertThat(updatedMember.getTrack().getName()).isEqualTo("BackEnd"); + softly.assertThat(updatedMember.getPosition()).isEqualTo("Mentor"); + softly.assertThat(updatedMember.getEmail()).isEqualTo("testjuno@gmail.com"); + softly.assertThat(updatedMember.getImageUrl()).isEqualTo("https://imagetest.com/juno.jpg"); + softly.assertThat(updatedMember.isDeleted()).isEqualTo(false); + }); + } + + @Test + @DisplayName("BCSDLab 회원 정보를 트랙과 함께 수정한다") + void updateMemberWithTrack() { + Member member = memberFixture.최준호(trackFixture.backend()); + trackFixture.frontend(); + Integer memberId = member.getId(); + + User adminUser = userFixture.코인_운영자(); + String token = userFixture.getToken(adminUser); + + String jsonBody = """ + { + "name": "최준호", + "student_number": "2019136135", + "track": "FrontEnd", + "position": "Mentor", + "email": "testjuno@gmail.com", + "image_url": "https://imagetest.com/juno.jpg" + } + """; + + RestAssured + .given() + .header("Authorization", "Bearer " + token) + .contentType("application/json") + .body(jsonBody) + .when() + .put("/admin/members/{id}", memberId) + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + Member updatedMember = adminMemberRepository.getById(memberId); + + SoftAssertions.assertSoftly(softly -> { + softly.assertThat(updatedMember.getName()).isEqualTo("최준호"); + softly.assertThat(updatedMember.getStudentNumber()).isEqualTo("2019136135"); + softly.assertThat(updatedMember.getTrack().getName()).isEqualTo("FrontEnd"); + softly.assertThat(updatedMember.getPosition()).isEqualTo("Mentor"); + softly.assertThat(updatedMember.getEmail()).isEqualTo("testjuno@gmail.com"); + softly.assertThat(updatedMember.getImageUrl()).isEqualTo("https://imagetest.com/juno.jpg"); + softly.assertThat(updatedMember.isDeleted()).isEqualTo(false); + }); + } + + @Test + @DisplayName("BCSDLab 회원 정보를 삭제를 취소한다") + void undeleteMember() { + Member member = memberFixture.최준호_삭제(trackFixture.backend()); + Integer memberId = member.getId(); + + User adminUser = userFixture.코인_운영자(); + String token = userFixture.getToken(adminUser); + + RestAssured + .given() + .header("Authorization", "Bearer " + token) + .when() + .post("/admin/members/{id}/undelete", memberId) + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + Member savedMember = adminMemberRepository.getById(memberId); + + SoftAssertions.assertSoftly(softly -> { + softly.assertThat(savedMember.getName()).isEqualTo("최준호"); + softly.assertThat(savedMember.getStudentNumber()).isEqualTo("2019136135"); + softly.assertThat(savedMember.getTrack().getName()).isEqualTo("BackEnd"); + softly.assertThat(savedMember.getPosition()).isEqualTo("Regular"); + softly.assertThat(savedMember.getEmail()).isEqualTo("testjuno@gmail.com"); + softly.assertThat(savedMember.getImageUrl()).isEqualTo("https://imagetest.com/juno.jpg"); + softly.assertThat(savedMember.isDeleted()).isEqualTo(false); + }); + } } diff --git a/src/test/java/in/koreatech/koin/admin/acceptance/AdminShopApiTest.java b/src/test/java/in/koreatech/koin/admin/acceptance/AdminShopApiTest.java new file mode 100644 index 000000000..75219c54d --- /dev/null +++ b/src/test/java/in/koreatech/koin/admin/acceptance/AdminShopApiTest.java @@ -0,0 +1,996 @@ +package in.koreatech.koin.admin.acceptance; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.transaction.support.TransactionTemplate; + +import in.koreatech.koin.AcceptanceTest; +import in.koreatech.koin.admin.shop.repository.AdminMenuCategoryRepository; +import in.koreatech.koin.admin.shop.repository.AdminMenuRepository; +import in.koreatech.koin.admin.shop.repository.AdminShopCategoryRepository; +import in.koreatech.koin.admin.shop.repository.AdminShopRepository; +import in.koreatech.koin.domain.owner.model.Owner; +import in.koreatech.koin.domain.shop.model.Menu; +import in.koreatech.koin.domain.shop.model.MenuCategory; +import in.koreatech.koin.domain.shop.model.MenuCategoryMap; +import in.koreatech.koin.domain.shop.model.MenuImage; +import in.koreatech.koin.domain.shop.model.MenuOption; +import in.koreatech.koin.domain.shop.model.Shop; +import in.koreatech.koin.domain.shop.model.ShopCategory; +import in.koreatech.koin.domain.shop.model.ShopCategoryMap; +import in.koreatech.koin.domain.shop.model.ShopImage; +import in.koreatech.koin.domain.shop.model.ShopOpen; +import in.koreatech.koin.domain.user.model.User; +import in.koreatech.koin.fixture.MenuCategoryFixture; +import in.koreatech.koin.fixture.MenuFixture; +import in.koreatech.koin.fixture.ShopCategoryFixture; +import in.koreatech.koin.fixture.ShopFixture; +import in.koreatech.koin.fixture.UserFixture; +import in.koreatech.koin.support.JsonAssertions; +import io.restassured.RestAssured; +import io.restassured.http.ContentType; + +@SuppressWarnings("NonAsciiCharacters") +class AdminShopApiTest extends AcceptanceTest { + + @Autowired + private TransactionTemplate transactionTemplate; + + @Autowired + private AdminShopCategoryRepository adminShopCategoryRepository; + + @Autowired + private AdminShopRepository adminShopRepository; + + @Autowired + private AdminMenuRepository adminMenuRepository; + + @Autowired + private AdminMenuCategoryRepository adminMenuCategoryRepository; + + @Autowired + private MenuFixture menuFixture; + + @Autowired + private UserFixture userFixture; + + @Autowired + private ShopFixture shopFixture; + + @Autowired + private ShopCategoryFixture shopCategoryFixture; + + @Autowired + private MenuCategoryFixture menuCategoryFixture; + + private Owner owner_현수; + private Owner owner_준영; + private Shop shop_마슬랜; + private User admin; + private String token_admin; + private ShopCategory shopCategory_치킨; + private ShopCategory shopCategory_일반; + private MenuCategory menuCategory_메인; + private MenuCategory menuCategory_사이드; + + @BeforeEach + void setUp() { + admin = userFixture.코인_운영자(); + token_admin = userFixture.getToken(admin); + owner_현수 = userFixture.현수_사장님(); + owner_준영 = userFixture.준영_사장님(); + shop_마슬랜 = shopFixture.마슬랜(owner_현수); + shopCategory_치킨 = shopCategoryFixture.카테고리_치킨(); + shopCategory_일반 = shopCategoryFixture.카테고리_일반음식(); + menuCategory_메인 = menuCategoryFixture.메인메뉴(shop_마슬랜); + menuCategory_사이드 = menuCategoryFixture.사이드메뉴(shop_마슬랜); + } + + @Test + @DisplayName("어드민이 모든 상점을 조회한다.") + void findAllShops() { + for (int i = 0; i < 12; i++) { + Shop request = shopFixture.builder() + .owner(owner_현수) + .name("상점" + i) + .internalName("상점" + i) + .phone("010-1234-567" + i) + .address("주소" + i) + .description("설명" + i) + .delivery(true) + .deliveryPrice(1000 + i) + .payCard(true) + .payBank(true) + .isDeleted(false) + .isEvent(false) + .remarks("비고" + i) + .hit(0) + .build(); + adminShopRepository.save(request); + } + + var response = RestAssured + .given() + .header("Authorization", "Bearer " + token_admin) + .when() + .param("page", 1) + .param("is_deleted", false) + .get("/admin/shops") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + assertSoftly( + softly -> { + softly.assertThat(response.body().jsonPath().getInt("total_count")).isEqualTo(13); + softly.assertThat(response.body().jsonPath().getInt("current_count")).isEqualTo(10); + softly.assertThat(response.body().jsonPath().getInt("total_page")).isEqualTo(2); + softly.assertThat(response.body().jsonPath().getInt("current_page")).isEqualTo(1); + softly.assertThat(response.body().jsonPath().getList("shops").size()).isEqualTo(10); + } + ); + } + + @Test + @DisplayName("어드민이 특정 상점을 조회한다.") + void findShop() { + var response = RestAssured + .given() + .header("Authorization", "Bearer " + token_admin) + .when() + .get("/admin/shops/{shopId}", shop_마슬랜.getId()) + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(""" + { + "address": "천안시 동남구 병천면 1600", + "delivery": true, + "delivery_price": 3000, + "description": "마슬랜 치킨입니다.", + "id": 1, + "image_urls": [ + "https://test-image.com/마슬랜.png", + "https://test-image.com/마슬랜2.png" + ], + "menu_categories": [ + { + "id": 1, + "name": "메인 메뉴" + }, + { + "id": 2, + "name": "사이드 메뉴" + } + ], + "name": "마슬랜 치킨", + "open": [ + { + "day_of_week": "MONDAY", + "closed": false, + "open_time": "00:00", + "close_time": "21:00" + }, + { + "day_of_week": "FRIDAY", + "closed": false, + "open_time": "00:00", + "close_time": "00:00" + } + ], + "pay_bank": true, + "pay_card": true, + "phone": "010-7574-1212", + "shop_categories": [ + + ], + "updated_at": "2024-01-15", + "is_deleted": false, + "is_event": false + } + """); + } + + @Test + @DisplayName("어드민이 상점의 모든 카테고리를 조회한다.") + void findShopCategories() { + for (int i = 0; i < 12; i++) { + ShopCategory request = ShopCategory.builder() + .name("카테고리" + i) + .isDeleted(false) + .build(); + adminShopCategoryRepository.save(request); + System.out.println(i); + } + + var response = RestAssured + .given() + .header("Authorization", "Bearer " + token_admin) + .when() + .param("page", 1) + .param("is_deleted", false) + .get("/admin/shops/categories") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + assertSoftly( + softly -> { + softly.assertThat(response.body().jsonPath().getInt("total_count")).isEqualTo(14); + softly.assertThat(response.body().jsonPath().getInt("current_count")).isEqualTo(10); + softly.assertThat(response.body().jsonPath().getInt("total_page")).isEqualTo(2); + softly.assertThat(response.body().jsonPath().getInt("current_page")).isEqualTo(1); + softly.assertThat(response.body().jsonPath().getList("categories").size()).isEqualTo(10); + } + ); + } + + @Test + @DisplayName("어드민이 상점의 특정 카테고리를 조회한다.") + void findShopCategory() { + ShopCategory shopCategory = shopCategoryFixture.카테고리_치킨(); + + var response = RestAssured + .given() + .header("Authorization", "Bearer " + token_admin) + .pathParam("id", shopCategory.getId()) + .when() + .get("/admin/shops/categories/{id}") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(""" + { + "id": 3, + "image_url": "https://test-image.com/ckicken.jpg", + "name": "치킨" + } + """); + } + + @Test + @DisplayName("어드민이 특정 상점의 모든 메뉴를 조회한다.") + void findShopMenus() { + // given + menuFixture.짜장면_옵션메뉴(shop_마슬랜, menuCategory_메인); + var response = RestAssured + .given() + .header("Authorization", "Bearer " + token_admin) + .pathParam("id", shop_마슬랜.getId()) + .when() + .get("/admin/shops/{id}/menus") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(""" + { + "count": 1, + "menu_categories": [ + { + "id": 1, + "name": "메인 메뉴", + "menus": [ + { + "id": 1, + "name": "짜장면", + "is_hidden": false, + "is_single": false, + "single_price": null, + "option_prices": [ + { + "option": "곱빼기", + "price": 7500 + }, + { + "option": "일반", + "price": 7000 + } + ], + "description": "맛있는 짜장면", + "image_urls": [ + "https://test.com/짜장면.jpg", + "https://test.com/짜장면22.jpg" + ] + } + ] + } + ], + "updated_at": "2024-01-15" + } + """); + } + + @Test + @DisplayName("어드민이 특정 상점의 메뉴 카테고리들을 조회한다.") + void findShopMenuCategories() { + // given + menuFixture.짜장면_단일메뉴(shop_마슬랜, menuCategory_메인); + var response = RestAssured + .given() + .pathParam("id", shop_마슬랜.getId()) + .header("Authorization", "Bearer " + token_admin) + .when() + .get("/admin/shops/{id}/menus/categories") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(""" + { + "count": 2, + "menu_categories": [ + { + "id": 1, + "name": "메인 메뉴" + }, + { + "id": 2, + "name": "사이드 메뉴" + } + ] + } + """); + } + + @Test + @DisplayName("어드민이 특정 상점의 특정 메뉴를 조회한다.") + void findShopMenu() { + // given + Menu menu = menuFixture.짜장면_옵션메뉴(shop_마슬랜, menuCategory_메인); + var response = RestAssured + .given() + .header("Authorization", "Bearer " + token_admin) + .pathParam("shopId", shop_마슬랜.getId()) + .pathParam("menuId", menu.getId()) + .when() + .get("/admin/shops/{shopId}/menus/{menuId}", menu.getId()) + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + System.out.println(JsonAssertions.assertThat(response.asPrettyString())); + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(""" + { + "category_ids": [1], + "description": "맛있는 짜장면", + "id": 1, + "image_urls": [ + "https://test.com/짜장면.jpg", + "https://test.com/짜장면22.jpg" + ], + "is_hidden": false, + "is_single": false, + "name": "짜장면", + "option_prices": [ + { + "option": "곱빼기", + "price": 7500 + }, + { + "option": "일반", + "price": 7000 + } + ], + "shop_id": 1, + "single_price": null + } + """); + } + + @Test + @DisplayName("어드민이 상점을 생성한다.") + void createShop() { + RestAssured + .given() + .header("Authorization", "Bearer " + token_admin) + .contentType(ContentType.JSON) + .body(String.format(""" + { + "address": "대전광역시 유성구 대학로 291", + "category_ids": [ + %d + ], + "delivery": true, + "delivery_price": 4000, + "description": "테스트 상점2입니다.", + "image_urls": [ + "https://test.com/test1.jpg", + "https://test.com/test2.jpg", + "https://test.com/test3.jpg" + ], + + "name": "테스트 상점2", + "open": [ + { + "close_time": "21:00", + "closed": false, + "day_of_week": "MONDAY", + "open_time": "09:00" + }, + { + "close_time": "21:00", + "closed": false, + "day_of_week": "TUESDAY", + "open_time": "09:00" + }, + { + "close_time": "21:00", + "closed": false, + "day_of_week": "WEDNESDAY", + "open_time": "09:00" + }, + { + "close_time": "21:00", + "closed": false, + "day_of_week": "THURSDAY", + "open_time": "09:00" + }, + { + "close_time": "21:00", + "closed": false, + "day_of_week": "FRIDAY", + "open_time": "09:00" + }, + { + "close_time": "21:00", + "closed": false, + "day_of_week": "SATURDAY", + "open_time": "09:00" + }, + { + "close_time": "21:00", + "closed": false, + "day_of_week": "SUNDAY", + "open_time": "09:00" + } + ], + "pay_bank": true, + "pay_card": true, + "phone": "010-1234-5678" + } + """, shopCategory_치킨.getId()) + ) + .when() + .post("/admin/shops") + .then() + .log().all() + .statusCode(HttpStatus.CREATED.value()) + .extract(); + + transactionTemplate.executeWithoutResult(status -> { + Shop result = adminShopRepository.getById(2); + assertSoftly( + softly -> { + softly.assertThat(result.getAddress()).isEqualTo("대전광역시 유성구 대학로 291"); + softly.assertThat(result.getDeliveryPrice()).isEqualTo(4000); + softly.assertThat(result.getDescription()).isEqualTo("테스트 상점2입니다."); + softly.assertThat(result.getName()).isEqualTo("테스트 상점2"); + softly.assertThat(result.getShopImages()).hasSize(3); + softly.assertThat(result.getShopOpens()).hasSize(7); + softly.assertThat(result.getShopCategories()).hasSize(1); + } + ); + }); + } + + @Test + @DisplayName("어드민이 상점 카테고리를 생성한다.") + void createShopCategory() { + var response = RestAssured + .given() + .header("Authorization", "Bearer " + token_admin) + .contentType(ContentType.JSON) + .body(""" + { + "image_url": "https://image.png", + "name": "새로운 카테고리" + } + """) + .when() + .post("/admin/shops/categories") + .then() + .statusCode(HttpStatus.CREATED.value()) + .extract(); + + transactionTemplate.executeWithoutResult(status -> { + ShopCategory result = adminShopCategoryRepository.getById(3); + assertSoftly( + softly -> { + softly.assertThat(result.getImageUrl()).isEqualTo("https://image.png"); + softly.assertThat(result.getName()).isEqualTo("새로운 카테고리"); + softly.assertThat(result.isDeleted()).isEqualTo(false); + } + ); + }); + } + + @Test + @DisplayName("어드민이 옵션이 여러개인 메뉴를 추가한다.") + void createManyOptionMenu() { + // given + MenuCategory menuCategory = menuCategory_메인; + var response = RestAssured + .given() + .header("Authorization", "Bearer " + token_admin) + .contentType(ContentType.JSON) + .body(String.format(""" + { + "category_ids": [ + %s + ], + "description": "테스트메뉴입니다.", + "image_urls": [ + "https://test-image.com/짜장면.jpg" + ], + "is_single": false, + "name": "짜장면", + "option_prices": [ + { + "option": "중", + "price": 10000 + }, + { + "option": "소", + "price": 5000 + } + ] + } + """, menuCategory.getId())) + .when() + .post("/admin/shops/{id}/menus", shop_마슬랜.getId()) + .then() + .statusCode(HttpStatus.CREATED.value()) + .extract(); + + transactionTemplate.executeWithoutResult(status -> { + Menu menu = adminMenuRepository.getById(1); + assertSoftly( + softly -> { + List menuCategoryMaps = menu.getMenuCategoryMaps(); + List menuOptions = menu.getMenuOptions(); + List menuImages = menu.getMenuImages(); + softly.assertThat(menu.getDescription()).isEqualTo("테스트메뉴입니다."); + softly.assertThat(menu.getName()).isEqualTo("짜장면"); + softly.assertThat(menuImages.get(0).getImageUrl()).isEqualTo("https://test-image.com/짜장면.jpg"); + softly.assertThat(menuCategoryMaps.get(0).getMenuCategory().getId()).isEqualTo(1); + softly.assertThat(menuOptions).hasSize(2); + } + ); + }); + } + + @Test + @DisplayName("어드민이 옵션이 한개인 메뉴를 추가한다.") + void createOneOptionMenu() { + // given + MenuCategory menuCategory = menuCategory_메인; + var response = RestAssured + .given() + .header("Authorization", "Bearer " + token_admin) + .contentType(ContentType.JSON) + .body(String.format(""" + { + "category_ids": [ + %s + ], + "description": "테스트메뉴입니다.", + "image_urls": [ + "https://test-image.com/짜장면.jpg" + ], + "is_single": true, + "name": "짜장면", + "option_prices": null, + "single_price": 10000 + } + """, menuCategory.getId())) + .when() + .post("/admin/shops/{id}/menus", shop_마슬랜.getId()) + .then() + .statusCode(HttpStatus.CREATED.value()) + .extract(); + + transactionTemplate.executeWithoutResult(status -> { + Menu menu = adminMenuRepository.getById(1); + assertSoftly( + softly -> { + List menuCategoryMaps = menu.getMenuCategoryMaps(); + List menuOptions = menu.getMenuOptions(); + List menuImages = menu.getMenuImages(); + softly.assertThat(menu.getDescription()).isEqualTo("테스트메뉴입니다."); + softly.assertThat(menu.getName()).isEqualTo("짜장면"); + + softly.assertThat(menuImages.get(0).getImageUrl()).isEqualTo("https://test-image.com/짜장면.jpg"); + softly.assertThat(menuCategoryMaps.get(0).getMenuCategory().getId()).isEqualTo(1); + + softly.assertThat(menuOptions.get(0).getPrice()).isEqualTo(10000); + } + ); + }); + } + + @Test + @DisplayName("어드민이 메뉴 카테고리를 추가한다.") + void createMenuCategory() { + // given + RestAssured + .given() + .header("Authorization", "Bearer " + token_admin) + .pathParam("id", shop_마슬랜.getId()) + .contentType(ContentType.JSON) + .body(String.format(""" + { + "name": "대박메뉴" + } + """)) + .when() + .post("/admin/shops/{id}/menus/categories") + .then() + .statusCode(HttpStatus.CREATED.value()) + .extract(); + + var menuCategories = adminMenuCategoryRepository.findAllByShopId(shop_마슬랜.getId()); + + assertThat(menuCategories).anyMatch(menuCategory -> "대박메뉴".equals(menuCategory.getName())); + } + + @Test + @DisplayName("어드민이 상점 삭제를 해제한다.") + void cancelShopDeleted() { + // given + System.out.println("qwe"); + adminShopRepository.deleteById(shop_마슬랜.getId()); + RestAssured + .given() + .header("Authorization", "Bearer " + token_admin) + .pathParam("id", shop_마슬랜.getId()) + .contentType(ContentType.JSON) + .when() + .post("/admin/shops/{id}/undelete") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + var shop = adminShopRepository.getById(shop_마슬랜.getId()); + assertSoftly(softly -> softly.assertThat(shop.isDeleted()).isFalse()); + } + + @Test + @DisplayName("어드민이 상점을 수정한다.") + void modifyShop() { + RestAssured + .given() + .header("Authorization", "Bearer " + token_admin) + .contentType(ContentType.JSON) + .body(String.format(""" + { + "address": "충청남도 천안시 동남구 병천면 충절로 1600", + "category_ids": [ + %d, %d + ], + "delivery": false, + "delivery_price": 1000, + "description": "이번주 전 메뉴 10%% 할인 이벤트합니다.", + "image_urls": [ + "https://fixed-shopimage.com/수정된_상점_이미지.png" + ], + "name": "써니 숯불 도시락", + "open": [ + { + "close_time": "22:30", + "closed": false, + "day_of_week": "MONDAY", + "open_time": "10:00" + }, + { + "close_time": "23:30", + "closed": true, + "day_of_week": "SUNDAY", + "open_time": "11:00" + } + ], + "pay_bank": true, + "pay_card": true, + "phone": "041-123-4567" + } + """, shopCategory_일반.getId(), shopCategory_치킨.getId() + )) + .when() + .put("/admin/shops/{id}", shop_마슬랜.getId()) + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + transactionTemplate.executeWithoutResult(status -> { + Shop result = adminShopRepository.getById(1); + List shopImages = result.getShopImages(); + List shopOpens = result.getShopOpens(); + List shopCategoryMaps = result.getShopCategories(); + assertSoftly( + softly -> { + softly.assertThat(result.getAddress()).isEqualTo("충청남도 천안시 동남구 병천면 충절로 1600"); + softly.assertThat(result.isDeleted()).isFalse(); + softly.assertThat(result.getDeliveryPrice()).isEqualTo(1000); + softly.assertThat(result.getDescription()).isEqualTo("이번주 전 메뉴 10% 할인 이벤트합니다."); + softly.assertThat(result.getName()).isEqualTo("써니 숯불 도시락"); + softly.assertThat(result.isPayBank()).isTrue(); + softly.assertThat(result.isPayCard()).isTrue(); + softly.assertThat(result.getPhone()).isEqualTo("041-123-4567"); + + softly.assertThat(shopCategoryMaps.get(0).getShopCategory().getId()).isEqualTo(1); + softly.assertThat(shopCategoryMaps.get(1).getShopCategory().getId()).isEqualTo(2); + + softly.assertThat(shopImages.get(0).getImageUrl()) + .isEqualTo("https://fixed-shopimage.com/수정된_상점_이미지.png"); + + softly.assertThat(shopOpens.get(0).getCloseTime()).isEqualTo("22:30"); + softly.assertThat(shopOpens.get(0).getOpenTime()).isEqualTo("10:00"); + + softly.assertThat(shopOpens.get(0).getDayOfWeek()).isEqualTo("MONDAY"); + softly.assertThat(shopOpens.get(0).isClosed()).isFalse(); + + softly.assertThat(shopOpens.get(1).getCloseTime()).isEqualTo("23:30"); + softly.assertThat(shopOpens.get(1).getOpenTime()).isEqualTo("11:00"); + + softly.assertThat(shopOpens.get(1).getDayOfWeek()).isEqualTo("SUNDAY"); + softly.assertThat(shopOpens.get(1).isClosed()).isTrue(); + } + ); + }); + } + + @Test + @DisplayName("어드민이 상점 카테고리를 수정한다.") + void modifyShopCategory() { + ShopCategory shopCategory = shopCategoryFixture.카테고리_일반음식(); + + RestAssured + .given() + .header("Authorization", "Bearer " + token_admin) + .contentType(ContentType.JSON) + .pathParam("id", shopCategory.getId()) + .body(""" + { + "image_url": "http://image.png", + "name": "수정된 카테고리 이름" + } + """) + .when() + .put("/admin/shops/categories/{id}") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + transactionTemplate.executeWithoutResult(status -> { + ShopCategory updatedCategory = adminShopCategoryRepository.getById(shopCategory.getId()); + assertSoftly( + softly -> { + softly.assertThat(updatedCategory.getId()).isEqualTo(shopCategory.getId()); + softly.assertThat(updatedCategory.getImageUrl()).isEqualTo("http://image.png"); + softly.assertThat(updatedCategory.getName()).isEqualTo("수정된 카테고리 이름"); + } + ); + }); + } + + @Test + @DisplayName("어드민이 특점 상점의 메뉴 카테고리를 수정한다.") + void modifyMenuCategory() { + // given + Menu menu = menuFixture.짜장면_단일메뉴(shop_마슬랜, menuCategory_메인); + RestAssured + .given() + .header("Authorization", "Bearer " + token_admin) + .contentType(ContentType.JSON) + .pathParam("shopId", shop_마슬랜.getId()) + .body(String.format(""" + { + "id": %s, + "name": "사이드 메뉴" + } + """, menuCategory_메인.getId())) + .when() + .put("/admin/shops/{shopId}/menus/categories") + .then() + .statusCode(HttpStatus.CREATED.value()) + .extract(); + + MenuCategory menuCategory = adminMenuCategoryRepository.getById(menuCategory_메인.getId()); + assertSoftly(softly -> softly.assertThat(menuCategory.getName()).isEqualTo("사이드 메뉴")); + } + + @Test + @DisplayName("어드민이 특정 삼점의 메뉴를 단일 메뉴로 수정한다.") + void modifyOneMenu() { + // given + Menu menu = menuFixture.짜장면_단일메뉴(shop_마슬랜, menuCategory_메인); + + RestAssured + .given() + .header("Authorization", "Bearer " + token_admin) + .contentType(ContentType.JSON) + .pathParam("shopId", shop_마슬랜.getId()) + .pathParam("menuId", menu.getId()) + .body(String.format(""" + { + "category_ids": [ + %d + ], + "description": "테스트메뉴수정", + "image_urls": [ + "https://test-image.net/테스트메뉴.jpeg" + ], + "is_single": true, + "name": "짜장면2", + "single_price": 10000 + } + """, shopCategory_일반.getId())) + .when() + .put("/admin/shops/{shopId}/menus/{menuId}") + .then() + .statusCode(HttpStatus.CREATED.value()) + .extract(); + + transactionTemplate.executeWithoutResult(status -> { + Menu result = adminMenuRepository.getById(1); + assertSoftly( + softly -> { + List menuCategoryMaps = result.getMenuCategoryMaps(); + List menuOptions = result.getMenuOptions(); + List menuImages = result.getMenuImages(); + softly.assertThat(result.getDescription()).isEqualTo("테스트메뉴수정"); + softly.assertThat(result.getName()).isEqualTo("짜장면2"); + + softly.assertThat(menuImages.get(0).getImageUrl()).isEqualTo("https://test-image.net/테스트메뉴.jpeg"); + softly.assertThat(menuCategoryMaps.get(0).getMenuCategory().getId()).isEqualTo(2); + + softly.assertThat(menuOptions.get(0).getPrice()).isEqualTo(10000); + + } + ); + }); + } + + @Test + @DisplayName("어드민이 특정 상점의 메뉴를 여러옵션을 가진 메뉴로 수정한다.") + void modifyManyOptionMenu() { + // given + Menu menu = menuFixture.짜장면_옵션메뉴(shop_마슬랜, menuCategory_메인); + RestAssured + .given() + .header("Authorization", "Bearer " + token_admin) + .pathParam("shopId", shop_마슬랜.getId()) + .pathParam("menuId", menu.getId()) + .contentType(ContentType.JSON) + .body(String.format(""" + { + "category_ids": [ + %d, %d + ], + "description": "테스트메뉴입니다.", + "image_urls": [ + "https://fixed-testimage.com/수정된짜장면.png" + ], + "is_single": false, + "name": "짜장면", + "option_prices": [ + { + "option": "중", + "price": 10000 + }, + { + "option": "소", + "price": 5000 + } + ] + } + """, menuCategory_메인.getId(), menuCategory_사이드.getId()) + ) + .when() + .put("/admin/shops/{shopId}/menus/{menuId}") + .then() + .statusCode(HttpStatus.CREATED.value()) + .extract(); + + transactionTemplate.executeWithoutResult(status -> { + Menu result = adminMenuRepository.getById(1); + assertSoftly( + softly -> { + List menuCategoryMaps = result.getMenuCategoryMaps(); + List menuOptions = result.getMenuOptions(); + List menuImages = result.getMenuImages(); + softly.assertThat(result.getDescription()).isEqualTo("테스트메뉴입니다."); + softly.assertThat(result.getName()).isEqualTo("짜장면"); + softly.assertThat(menuImages.get(0).getImageUrl()) + .isEqualTo("https://fixed-testimage.com/수정된짜장면.png"); + softly.assertThat(menuCategoryMaps).hasSize(2); + softly.assertThat(menuOptions).hasSize(2); + } + ); + }); + } + + @Test + @DisplayName("어드민이 상점을 삭제한다.") + void deleteShop() { + Shop shop = shopFixture.신전_떡볶이(owner_현수); + + RestAssured + .given() + .header("Authorization", "Bearer " + token_admin) + .when() + .delete("/admin/shops/{id}", shop.getId()) + .then() + .statusCode(HttpStatus.OK.value()); + + Shop deletedShop = adminShopRepository.getById(shop.getId()); + assertSoftly(softly -> softly.assertThat(deletedShop.isDeleted()).isTrue()); + } + + @Test + @DisplayName("어드민이 상점 카테고리를 삭제한다.") + void deleteShopCategory() { + ShopCategory shopCategory = shopCategoryFixture.카테고리_일반음식(); + + RestAssured + .given() + .header("Authorization", "Bearer " + token_admin) + .when() + .delete("/admin/shops/categories/{id}", shopCategory.getId()) + .then() + .statusCode(HttpStatus.OK.value()); + + ShopCategory deletedCategory = adminShopCategoryRepository.getById(shopCategory.getId()); + assertSoftly(softly -> softly.assertThat(deletedCategory.isDeleted()).isTrue()); + } + + @Test + @DisplayName("어드민이 특정 상점의 메뉴 카테고리를 삭제한다.") + void deleteMenuCategory() { + // when & then + RestAssured + .given() + .header("Authorization", "Bearer " + token_admin) + .pathParam("shopId", shop_마슬랜.getId()) + .pathParam("categoryId", menuCategory_메인.getId()) + .when() + .delete("/admin/shops/{shopId}/menus/categories/{categoryId}") + .then() + .statusCode(HttpStatus.NO_CONTENT.value()) + .extract(); + + assertThat(adminMenuCategoryRepository.findById(menuCategory_메인.getId())).isNotPresent(); + } + + @Test + @DisplayName("어드민이 메뉴를 삭제한다.") + void deleteMenu() { + // given + Menu menu = menuFixture.짜장면_단일메뉴(shop_마슬랜, menuCategory_메인); + + RestAssured + .given() + .header("Authorization", "Bearer " + token_admin) + .pathParam("shopId", shop_마슬랜.getId()) + .pathParam("menuId", menu.getId()) + .when() + .delete("/admin/shops/{shopId}/menus/{menuId}", menu.getId()) + .then() + .statusCode(HttpStatus.NO_CONTENT.value()) + .extract(); + + assertThat(adminMenuRepository.findById(menu.getId())).isNotPresent(); + } +} diff --git a/src/test/java/in/koreatech/koin/admin/acceptance/AdminTrackApiTest.java b/src/test/java/in/koreatech/koin/admin/acceptance/AdminTrackApiTest.java index c525bc61e..c9ea7ecc9 100644 --- a/src/test/java/in/koreatech/koin/admin/acceptance/AdminTrackApiTest.java +++ b/src/test/java/in/koreatech/koin/admin/acceptance/AdminTrackApiTest.java @@ -1,15 +1,19 @@ package in.koreatech.koin.admin.acceptance; +import org.assertj.core.api.SoftAssertions; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import in.koreatech.koin.AcceptanceTest; +import in.koreatech.koin.admin.member.repository.AdminTechStackRepository; +import in.koreatech.koin.admin.member.repository.AdminTrackRepository; import in.koreatech.koin.domain.member.model.TechStack; import in.koreatech.koin.domain.member.model.Track; import in.koreatech.koin.domain.user.model.Student; import in.koreatech.koin.domain.user.model.User; +import in.koreatech.koin.fixture.MemberFixture; import in.koreatech.koin.fixture.TechStackFixture; import in.koreatech.koin.fixture.TrackFixture; import in.koreatech.koin.fixture.UserFixture; @@ -22,19 +26,28 @@ public class AdminTrackApiTest extends AcceptanceTest { @Autowired private TrackFixture trackFixture; + @Autowired + private MemberFixture memberFixture; + @Autowired private TechStackFixture techStackFixture; @Autowired private UserFixture userFixture; + @Autowired + private AdminTrackRepository adminTrackRepository; + + @Autowired + private AdminTechStackRepository adminTechStackRepository; + @Test - @DisplayName("관리자가 BCSDLab 트랙 정보를 조회한다 - 관리자가 아니면 403 반환") + @DisplayName("관리자가 BCSDLab 트랙 정보를 조회한다. - 관리자가 아니면 403 반환") void findTracksAdminNoAuth() { Student student = userFixture.준호_학생(); String token = userFixture.getToken(student.getUser()); - var response = RestAssured + RestAssured .given() .header("Authorization", "Bearer " + token) .when() @@ -45,7 +58,7 @@ void findTracksAdminNoAuth() { } @Test - @DisplayName("관리자가 BCSDLab 트랙 정보를 조회한다") + @DisplayName("관리자가 BCSDLab 트랙 정보를 조회한다.") void findTracks() { User adminUser = userFixture.코인_운영자(); String token = userFixture.getToken(adminUser); @@ -95,7 +108,233 @@ void findTracks() { } @Test - @DisplayName("관리자가 BCSDLab 기술스택 정보를 생성한다") + @DisplayName("관리자가 BCSDLab 트랙 정보를 생성한다.") + void createTrack() { + User adminUser = userFixture.코인_운영자(); + String token = userFixture.getToken(adminUser); + + var response = RestAssured + .given() + .header("Authorization", "Bearer " + token) + .contentType("application/json") + .body(""" + { + "name": "BackEnd", + "headcount": 20 + } + """) + .when() + .post("/admin/tracks") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(""" + { + "id": 1, + "name": "BackEnd", + "headcount": 20, + "is_deleted": false, + "created_at": "2024-01-15 12:00:00", + "updated_at": "2024-01-15 12:00:00" + } + """); + } + + @Test + @DisplayName("관리자가 BCSDLab 트랙 정보를 생성한다. - 이미 있는 트랙명이면 409반환") + void createTrackDuplication() { + User adminUser = userFixture.코인_운영자(); + String token = userFixture.getToken(adminUser); + + trackFixture.backend(); + + RestAssured + .given() + .header("Authorization", "Bearer " + token) + .contentType("application/json") + .body(""" + { + "name": "BackEnd", + "headcount": 20 + } + """) + .when() + .post("/admin/tracks") + .then() + .statusCode(HttpStatus.CONFLICT.value()) + .extract(); + } + + @Test + @DisplayName("관리자가 BCSDLab 트랙 단건 정보를 조회한다.") + void findTrack() { + User adminUser = userFixture.코인_운영자(); + String token = userFixture.getToken(adminUser); + + Track backend = trackFixture.backend(); + trackFixture.ai(); // 삭제된 트랙 + memberFixture.배진호(backend); // 삭제된 멤버 + memberFixture.최준호(backend); + techStackFixture.java(backend); + techStackFixture.adobeFlash(backend); //삭제된 기술스택 + + var response = RestAssured + .given() + .header("Authorization", "Bearer " + token) + .when() + .get("/admin/tracks/{id}", backend.getId()) + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(""" + { + "TrackName": "BackEnd", + "Members": [ + { + "id": 1, + "name": "배진호", + "student_number": "2020136061", + "position": "Regular", + "track": "BackEnd", + "email": "testjhb@gmail.com", + "image_url": "https://imagetest.com/jino.jpg", + "is_deleted": true, + "created_at": "2024-01-15 12:00:00", + "updated_at": "2024-01-15 12:00:00" + }, + { + "id": 2, + "name": "최준호", + "student_number": "2019136135", + "position": "Regular", + "track": "BackEnd", + "email": "testjuno@gmail.com", + "image_url": "https://imagetest.com/juno.jpg", + "is_deleted": false, + "created_at": "2024-01-15 12:00:00", + "updated_at": "2024-01-15 12:00:00" + } + ], + "TechStacks": [ + { + "id": 1, + "name": "Java", + "description": "Language", + "image_url": "https://testimageurl.com", + "track_id": 1, + "is_deleted": false, + "created_at": "2024-01-15 12:00:00", + "updated_at": "2024-01-15 12:00:00" + }, + { + "id": 2, + "name": "AdobeFlash", + "description": "deleted", + "image_url": "https://testimageurl.com", + "track_id": 1, + "is_deleted": true, + "created_at": "2024-01-15 12:00:00", + "updated_at": "2024-01-15 12:00:00" + } + ] + } + """); + } + + @Test + @DisplayName("관리자가 BCSDLab 트랙 정보를 수정한다.") + void updateTrack() { + User adminUser = userFixture.코인_운영자(); + String token = userFixture.getToken(adminUser); + + Track backEnd = trackFixture.backend(); + + var response = RestAssured + .given() + .header("Authorization", "Bearer " + token) + .contentType("application/json") + .body(""" + { + "name": "frontEnd", + "headcount": 20 + } + """) + .when() + .put("/admin/tracks/{id}", backEnd.getId()) + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(""" + { + "id": 1, + "name": "frontEnd", + "headcount": 20, + "is_deleted": false, + "created_at": "2024-01-15 12:00:00", + "updated_at": "2024-01-15 12:00:00" + } + """); + } + + @Test + @DisplayName("관리자가 BCSDLab 트랙 정보를 수정한다. - 이미 있는 트랙명이면 409반환") + void updateTrackDuplication() { + User adminUser = userFixture.코인_운영자(); + String token = userFixture.getToken(adminUser); + + Track backEnd = trackFixture.backend(); + + RestAssured + .given() + .header("Authorization", "Bearer " + token) + .contentType("application/json") + .body(""" + { + "name": "BackEnd", + "headcount": 20 + } + """) + .when() + .put("/admin/tracks/{id}", backEnd.getId()) + .then() + .statusCode(HttpStatus.CONFLICT.value()) + .extract(); + } + + @Test + @DisplayName("관리자가 BCSDLab 트랙 정보를 삭제한다.") + void deleteTrack() { + User adminUser = userFixture.코인_운영자(); + String token = userFixture.getToken(adminUser); + + Track backEnd = trackFixture.backend(); + + RestAssured + .given() + .header("Authorization", "Bearer " + token) + .when() + .delete("/admin/tracks/{id}", backEnd.getId()) + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + Track updatedTrack = adminTrackRepository.getById(backEnd.getId()); + + SoftAssertions.assertSoftly(softly -> { + softly.assertThat(updatedTrack.getName()).isEqualTo(backEnd.getName()); + softly.assertThat(updatedTrack.getHeadcount()).isEqualTo(backEnd.getHeadcount()); + softly.assertThat(updatedTrack.isDeleted()).isEqualTo(true); + }); + } + + @Test + @DisplayName("관리자가 BCSDLab 기술스택 정보를 생성한다.") void createTechStack() { User adminUser = userFixture.코인_운영자(); String token = userFixture.getToken(adminUser); @@ -109,8 +348,7 @@ void createTechStack() { .contentType("application/json") .body(""" { - "id": 3, - "image_url": "http://url.com", + "image_url": "https://url.com", "name": "Spring", "description": "스프링은 웹 프레임워크이다" } @@ -126,7 +364,7 @@ void createTechStack() { .isEqualTo(""" { "id": 1, - "image_url": "http://url.com", + "image_url": "https://url.com", "name": "Spring", "description": "스프링은 웹 프레임워크이다", "track_id": 2, @@ -138,7 +376,7 @@ void createTechStack() { } @Test - @DisplayName("관리자가 BCSDLab 기술스택 정보를 수정한다") + @DisplayName("관리자가 BCSDLab 기술스택 정보를 수정한다.") void updateTechStack() { User adminUser = userFixture.코인_운영자(); String token = userFixture.getToken(adminUser); @@ -152,7 +390,7 @@ void updateTechStack() { .contentType("application/json") .body(""" { - "image_url": "http://java.com", + "image_url": "https://java.com", "name": "JAVA", "description": "java의 TrackID를 BackEnd로 수정한다.", "is_deleted": true @@ -169,7 +407,7 @@ void updateTechStack() { .isEqualTo(""" { "id": 1, - "image_url": "http://java.com", + "image_url": "https://java.com", "name": "JAVA", "description": "java의 TrackID를 BackEnd로 수정한다.", "track_id": 2, @@ -179,4 +417,33 @@ void updateTechStack() { } """); } + + @Test + @DisplayName("관리자가 기술스택 정보를 삭제한다.") + void deleteTechStack() { + User adminUser = userFixture.코인_운영자(); + String token = userFixture.getToken(adminUser); + + Track backEnd = trackFixture.backend(); + TechStack java = techStackFixture.java(backEnd); + + RestAssured + .given() + .header("Authorization", "Bearer " + token) + .when() + .delete("/admin/techStacks/{id}", java.getId()) + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + TechStack updatedtechStack = adminTechStackRepository.getById(java.getId()); + + SoftAssertions.assertSoftly(softly -> { + softly.assertThat(updatedtechStack.getImageUrl()).isEqualTo(java.getImageUrl()); + softly.assertThat(updatedtechStack.getName()).isEqualTo(java.getName()); + softly.assertThat(updatedtechStack.getDescription()).isEqualTo(java.getDescription()); + softly.assertThat(updatedtechStack.getTrackId()).isEqualTo(backEnd.getId()); + softly.assertThat(updatedtechStack.isDeleted()).isEqualTo(true); + }); + } } diff --git a/src/test/java/in/koreatech/koin/admin/acceptance/AdminUserApiTest.java b/src/test/java/in/koreatech/koin/admin/acceptance/AdminUserApiTest.java index 13dce5919..d82f37a68 100644 --- a/src/test/java/in/koreatech/koin/admin/acceptance/AdminUserApiTest.java +++ b/src/test/java/in/koreatech/koin/admin/acceptance/AdminUserApiTest.java @@ -1,11 +1,13 @@ package in.koreatech.koin.admin.acceptance; import static in.koreatech.koin.domain.user.model.UserGender.MAN; +import static in.koreatech.koin.domain.user.model.UserIdentity.UNDERGRADUATE; import static in.koreatech.koin.domain.user.model.UserType.OWNER; +import static org.assertj.core.api.Assertions.assertThat; +import static in.koreatech.koin.domain.user.model.UserType.STUDENT; import static org.assertj.core.api.SoftAssertions.assertSoftly; import java.util.ArrayList; -import java.util.List; import org.assertj.core.api.SoftAssertions; import org.junit.jupiter.api.DisplayName; @@ -17,9 +19,13 @@ import in.koreatech.koin.AcceptanceTest; import in.koreatech.koin.admin.user.repository.AdminOwnerRepository; +import in.koreatech.koin.admin.user.repository.AdminOwnerShopRedisRepository; import in.koreatech.koin.admin.user.repository.AdminStudentRepository; +import in.koreatech.koin.admin.user.repository.AdminUserRepository; import in.koreatech.koin.domain.owner.model.Owner; import in.koreatech.koin.domain.owner.model.OwnerAttachment; +import in.koreatech.koin.domain.owner.model.OwnerShop; +import in.koreatech.koin.domain.owner.repository.OwnerShopRedisRepository; import in.koreatech.koin.domain.shop.model.Shop; import in.koreatech.koin.domain.user.model.Student; import in.koreatech.koin.domain.user.model.User; @@ -39,6 +45,15 @@ public class AdminUserApiTest extends AcceptanceTest { @Autowired private AdminOwnerRepository adminOwnerRepository; + @Autowired + private AdminUserRepository adminUserRepository; + + @Autowired + private AdminOwnerShopRedisRepository adminOwnerShopRedisRepository; + + @Autowired + private OwnerShopRedisRepository ownerShopRedisRepository; + @Autowired private TransactionTemplate transactionTemplate; @@ -51,6 +66,298 @@ public class AdminUserApiTest extends AcceptanceTest { @Autowired private PasswordEncoder passwordEncoder; + @Test + @DisplayName("관리자가 학생 리스트를 파라미터가 없이 조회한다.(페이지네이션)") + void getStudentsWithoutParameterAdmin() { + Student student = userFixture.준호_학생(); + User adminUser = userFixture.코인_운영자(); + + String token = userFixture.getToken(adminUser); + + var response = RestAssured + .given() + .header("Authorization", "Bearer " + token) + .contentType(ContentType.JSON) + .when() + .get("/admin/students") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(""" + { + "current_count": 1, + "current_page": 1, + "students": [ + { + "email": "juno@koreatech.ac.kr", + "id": 1, + "major": "컴퓨터공학부", + "name": "테스트용_준호", + "nickname": "준호", + "student_number": "2019136135" + } + ], + "total_count": 1, + "total_page": 1 + } + """); + } + + @Test + @DisplayName("관리자가 학생 리스트를 페이지 수와 limit으로 조회한다.(페이지네이션)") + void getStudentsWithPageAndLimitAdmin() { + for (int i = 0; i < 11; i++) { + Student student = Student.builder() + .studentNumber("2019136135") + .anonymousNickname("익명" + i) + .department("컴퓨터공학부") + .userIdentity(UNDERGRADUATE) + .isGraduated(false) + .user( + User.builder() + .password(passwordEncoder.encode("1234")) + .nickname("성재" + i) + .name("테스트용_성재" + i) + .phoneNumber("01012345670") + .userType(STUDENT) + .gender(MAN) + .email("seongjae@koreatech.ac.kr") + .isAuthed(true) + .isDeleted(false) + .build() + ) + .build(); + + adminStudentRepository.save(student); + } + + User adminUser = userFixture.코인_운영자(); + + String token = userFixture.getToken(adminUser); + + var response = RestAssured + .given() + .header("Authorization", "Bearer " + token) + .contentType(ContentType.JSON) + .queryParam("page", 2) + .queryParam("limit", 10) + .when() + .get("/admin/students") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(""" + { + "current_count": 1, + "current_page": 2, + "students": [ + { + "email": "seongjae@koreatech.ac.kr", + "id": 11, + "major": "컴퓨터공학부", + "name": "테스트용_성재10", + "nickname": "성재10", + "student_number": "2019136135" + } + ], + "total_count": 11, + "total_page": 2 + } + """); + } + + @Test + @DisplayName("관리자가 학생 리스트를 닉네임으로 조회한다.(페이지네이션)") + void getStudentsWithNicknameAdmin() { + Student student1 = userFixture.성빈_학생(); + Student student2 = userFixture.준호_학생(); + User adminUser = userFixture.코인_운영자(); + + String token = userFixture.getToken(adminUser); + + var response = RestAssured + .given() + .header("Authorization", "Bearer " + token) + .contentType(ContentType.JSON) + .when() + .queryParam("nickname", "준호") + .get("/admin/students") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(""" + { + "current_count": 1, + "current_page": 1, + "students": [ + { + "email": "juno@koreatech.ac.kr", + "id": 2, + "major": "컴퓨터공학부", + "name": "테스트용_준호", + "nickname": "준호", + "student_number": "2019136135" + } + ], + "total_count": 1, + "total_page": 1 + } + """); + } + + @Test + @DisplayName("관리자가 로그인 한다.") + void adminLogin() { + User adminUser = userFixture.코인_운영자(); + String email = adminUser.getEmail(); + String password = "1234"; + + var response = RestAssured + .given() + .contentType(ContentType.JSON) + .body(""" + { + "email" : "%s", + "password" : "%s" + } + """.formatted(email, password)) + .when() + .post("/admin/user/login") + .then() + .statusCode(HttpStatus.CREATED.value()) + .extract(); + } + + @Test + @DisplayName("관리자가 로그인 한다. - 관리자가 아니면 404 반환") + void adminLoginNoAuth() { + Student student = userFixture.준호_학생(); + String email = student.getUser().getEmail(); + String password = "1234"; + + var response = RestAssured + .given() + .contentType(ContentType.JSON) + .body(""" + { + "email" : "%s", + "password" : "%s" + } + """.formatted(email, password)) + .when() + .post("/admin/user/login") + .then() + .statusCode(HttpStatus.NOT_FOUND.value()) + .extract(); + } + + @Test + @DisplayName("관리자가 로그아웃한다") + void adminLogout() { + User adminUser = userFixture.코인_운영자(); + + String token = userFixture.getToken(adminUser); + + var response = RestAssured + .given() + .header("Authorization", "Bearer " + token) + .contentType(ContentType.JSON) + .when() + .post("/admin/user/logout") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + } + + @Test + @DisplayName("관리자가 액세스 토큰 재발급 한다") + void adminRefresh() { + User adminUser = userFixture.코인_운영자(); + String email = adminUser.getEmail(); + String password = "1234"; + + String token = userFixture.getToken(adminUser); + + var loginResponse = RestAssured + .given() + .contentType(ContentType.JSON) + .body(""" + { + "email" : "%s", + "password" : "%s" + } + """.formatted(email, password)) + .when() + .post("/admin/user/login") + .then() + .statusCode(HttpStatus.CREATED.value()) + .extract() + .response(); + + String refreshToken = loginResponse.jsonPath().getString("refresh_token"); + + var refreshResponse = RestAssured + .given() + .header("Authorization", "Bearer " + token) + .contentType(ContentType.JSON) + .body(""" + { + "refresh_token" : "%s" + } + """.formatted(refreshToken)) + .when() + .post("/admin/user/refresh") + .then() + .statusCode(HttpStatus.CREATED.value()) + .extract(); + } + + + @Test + @DisplayName("관리자가 사장님 권한 요청을 허용한다.") + void allowOwnerPermission() { + Owner owner = userFixture.철수_사장님(); + Shop shop = shopFixture.마슬랜(null); + + User adminUser = userFixture.코인_운영자(); + String token = userFixture.getToken(adminUser); + + OwnerShop ownerShop = OwnerShop.builder() + .ownerId(owner.getId()) + .shopId(shop.getId()) + .build(); + + ownerShopRedisRepository.save(ownerShop); + + var response = RestAssured + .given() + .header("Authorization", "Bearer " + token) + .when() + .pathParam("id", owner.getUser().getId()) + .put("/admin/owner/{id}/authed") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + //영속성 컨테스트 동기화 + Owner updatedOwner = adminOwnerRepository.getById(owner.getId()); + var resultOwnerShop = adminOwnerShopRedisRepository.findById(owner.getId()); + + assertSoftly( + softly -> { + softly.assertThat(updatedOwner.getUser().isAuthed()).isEqualTo(true); + softly.assertThat(updatedOwner.isGrantShop()).isEqualTo(true); + softly.assertThat(resultOwnerShop).isEmpty(); + } + ); + } + @Test @DisplayName("관리자가 특정 학생 정보를 조회한다. - 관리자가 아니면 403 반환") void studentUpdateAdminNoAuth() { @@ -102,7 +409,7 @@ void studentGetAdmin() { "major": "컴퓨터공학부", "name": "테스트용_준호", "nickname": "준호", - "phone_number": "010-1234-5678", + "phone_number": "01012345678", "student_number": "2019136135", "updated_at": "2024-01-15 12:00:00", "user_type": "STUDENT" @@ -129,7 +436,7 @@ void studentUpdateAdmin() { "name" : "서정빈", "password" : "0c4be6acaba1839d3433c1ccf04e1eec4d1fa841ee37cb019addc269e8bc1b77", "nickname" : "duehee", - "phone_number" : "010-2345-6789", + "phone_number" : "01023456789", "student_number" : "2019136136" } """) @@ -161,7 +468,7 @@ void studentUpdateAdmin() { "major": "기계공학부", "name": "서정빈", "nickname": "duehee", - "phone_number": "010-2345-6789", + "phone_number": "01023456789", "student_number": "2019136136" } """); @@ -201,7 +508,7 @@ void getOwnerAdmin() { "shops_id": [ %d ], - "phone_number": "010-9876-5432", + "phone_number": "01098765432", "is_authed": true, "user_type": "OWNER", "gender": 0, @@ -213,6 +520,48 @@ void getOwnerAdmin() { )); } + @Test + @DisplayName("관리자가 특정 사장을 수정한다.") + void updateOwner() { + Owner owner = userFixture.현수_사장님(); + Shop shop = shopFixture.마슬랜(owner); + + User adminUser = userFixture.코인_운영자(); + String token = userFixture.getToken(adminUser); + + var response = RestAssured + .given() + .header("Authorization", "Bearer " + token) + .contentType(ContentType.JSON) + .body(""" + { + "company_registration_number" : "123-45-67190", + "grant_shop" : "false", + "grant_event" : "false" + } + """) + .when() + .pathParam("id", owner.getUser().getId()) + .put("/admin/users/owner/{id}") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(""" + { + "company_registration_number" : "123-45-67190", + "email" : "hysoo@naver.com", + "gender" : 0, + "grant_shop" : false, + "grant_event" : false, + "name" : "테스트용_현수", + "nickname" : "현수", + "phone_number" : "01098765432" + } + """); + } + @Test @DisplayName("관리자가 가입 신청한 사장님 리스트 조회한다.") void getNewOwnersAdmin() { @@ -249,7 +598,7 @@ void getNewOwnersAdmin() { "id": 1, "email": "testchulsu@gmail.com", "name": "테스트용_철수(인증X)", - "phone_number": "010-9776-5112", + "phone_number": "01097765112", "shop_id": 1, "shop_name": "마슬랜 치킨", "created_at" : "2024-01-15 12:00:00" @@ -258,7 +607,7 @@ void getNewOwnersAdmin() { "id": 1, "email": "testchulsu@gmail.com", "name": "테스트용_철수(인증X)", - "phone_number": "010-9776-5112", + "phone_number": "01097765112", "shop_id": 2, "shop_name": "신전 떡볶이", "created_at" : "2024-01-15 12:00:00" @@ -278,7 +627,7 @@ void getNewOwnersAdminV2() { .password(passwordEncoder.encode("1234")) .nickname("사장님" + i) .name("테스트용(인증X)" + i) - .phoneNumber("010-9776-511" + i) + .phoneNumber("0109776511" + i) .userType(OWNER) .gender(MAN) .email("testchulsu@gmail.com" + i) @@ -334,4 +683,119 @@ void getNewOwnersAdminV2() { } ); } + + @Test + @DisplayName("관리자가 가입 사장님 리스트 조회한다") + void getOwnersAdmin() { + for (int i = 0; i < 11; i++) { + User user = User.builder() + .password(passwordEncoder.encode("1234")) + .nickname("사장님" + i) + .name("테스트용(인증X)" + i) + .phoneNumber("0109776511" + i) + .userType(OWNER) + .gender(MAN) + .email("testchulsu@gmail.com" + i) + .isAuthed(true) + .isDeleted(false) + .build(); + + Owner owner = Owner.builder() + .user(user) + .companyRegistrationNumber("118-80-567" + i) + .grantShop(true) + .grantEvent(true) + .attachments(new ArrayList<>()) + .build(); + + OwnerAttachment attachment1 = OwnerAttachment.builder() + .url("https://test.com/사장님_인증사진_1" + i + ".jpg") + .isDeleted(false) + .owner(owner) + .build(); + + OwnerAttachment attachment2 = OwnerAttachment.builder() + .url("https://test.com/사장님_인증사진_2" + i + ".jpg") + .isDeleted(false) + .owner(owner) + .build(); + + owner.getAttachments().add(attachment1); + owner.getAttachments().add(attachment2); + + adminOwnerRepository.save(owner); + } + + User adminUser = userFixture.코인_운영자(); + String token = userFixture.getToken(adminUser); + + var response = RestAssured + .given() + .header("Authorization", "Bearer " + token) + .when() + .get("/admin/users/owners") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + assertSoftly( + softly -> { + softly.assertThat(response.body().jsonPath().getInt("total_count")).isEqualTo(11); + softly.assertThat(response.body().jsonPath().getInt("current_count")).isEqualTo(10); + softly.assertThat(response.body().jsonPath().getInt("total_page")).isEqualTo(2); + softly.assertThat(response.body().jsonPath().getInt("current_page")).isEqualTo(1); + softly.assertThat(response.body().jsonPath().getList("owners").size()).isEqualTo(10); + } + ); + } + + @Test + @DisplayName("관리자가 회원을 조회한다.") + void getUser() { + Student student = userFixture.준호_학생(); + + User adminUser = userFixture.코인_운영자(); + String token = userFixture.getToken(adminUser); + + var response = RestAssured + .given() + .header("Authorization", "Bearer " + token) + .when() + .pathParam("id", student.getUser().getId()) + .get("/admin/users/{id}") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + assertSoftly( + softly -> { + softly.assertThat(response.body().jsonPath().getString("nickname")).isEqualTo("준호"); + softly.assertThat(response.body().jsonPath().getString("name")).isEqualTo("테스트용_준호"); + softly.assertThat(response.body().jsonPath().getString("phoneNumber")).isEqualTo("01012345678"); + softly.assertThat(response.body().jsonPath().getString("email")).isEqualTo("juno@koreatech.ac.kr"); + } + ); + } + + @Test + @DisplayName("관리자가 회원을 삭제한다.") + void deleteUser() { + Student student = userFixture.준호_학생(); + + User adminUser = userFixture.코인_운영자(); + String token = userFixture.getToken(adminUser); + + RestAssured + .given() + .header("Authorization", "Bearer " + token) + .contentType(ContentType.JSON) + .when() + .pathParam("id", student.getUser().getId()) + .delete("/admin/users/{id}") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + assertThat(adminUserRepository.findById(student.getId())).isNotPresent(); + } } diff --git a/src/test/java/in/koreatech/koin/fixture/LandFixture.java b/src/test/java/in/koreatech/koin/fixture/LandFixture.java index a6cea411a..3e7aa6c8a 100644 --- a/src/test/java/in/koreatech/koin/fixture/LandFixture.java +++ b/src/test/java/in/koreatech/koin/fixture/LandFixture.java @@ -66,4 +66,24 @@ public LandFixture(LandRepository landRepository) { .build() ); } + + public Land 삭제된_복덕방() { + List imageUrls = List.of( + "https://example1.test.com/image.jpeg", + "https://example2.test.com/image.jpeg" + ); + return landRepository.save( + Land.builder() + .internalName("삭제된 복덕방") + .name("삭제된 복덕방") + .roomType("원룸") + .latitude("37.555") + .longitude("126.555") + .monthlyFee("100") + .charterFee("1000") + .isDeleted(true) + .imageUrls(imageUrls) + .build() + ); + } } diff --git a/src/test/java/in/koreatech/koin/fixture/MemberFixture.java b/src/test/java/in/koreatech/koin/fixture/MemberFixture.java index eff9e3ea6..31f340fe0 100644 --- a/src/test/java/in/koreatech/koin/fixture/MemberFixture.java +++ b/src/test/java/in/koreatech/koin/fixture/MemberFixture.java @@ -63,4 +63,18 @@ public MemberFixture( .build() ); } + + public Member 최준호_삭제(Track track) { + return memberRepository.save( + Member.builder() + .isDeleted(true) + .studentNumber("2019136135") + .imageUrl("https://imagetest.com/juno.jpg") + .name("최준호") + .position("Regular") + .track(track) + .email("testjuno@gmail.com") + .build() + ); + } } diff --git a/src/test/java/in/koreatech/koin/fixture/TechStackFixture.java b/src/test/java/in/koreatech/koin/fixture/TechStackFixture.java index a8b833531..fcf49b5b8 100644 --- a/src/test/java/in/koreatech/koin/fixture/TechStackFixture.java +++ b/src/test/java/in/koreatech/koin/fixture/TechStackFixture.java @@ -25,4 +25,16 @@ public TechStack java(Track track) { .build() ); } + + public TechStack adobeFlash(Track track) { + return techStackRepository.save( + TechStack.builder() + .imageUrl("https://testimageurl.com") + .trackId(track.getId()) + .name("AdobeFlash") + .description("deleted") + .isDeleted(true) + .build() + ); + } } diff --git a/src/test/java/in/koreatech/koin/fixture/TimeTableV2Fixture.java b/src/test/java/in/koreatech/koin/fixture/TimeTableV2Fixture.java new file mode 100644 index 000000000..a18efc808 --- /dev/null +++ b/src/test/java/in/koreatech/koin/fixture/TimeTableV2Fixture.java @@ -0,0 +1,174 @@ +package in.koreatech.koin.fixture; + +import java.util.ArrayList; + +import org.springframework.stereotype.Component; + +import in.koreatech.koin.domain.timetable.model.Lecture; +import in.koreatech.koin.domain.timetable.model.Semester; +import in.koreatech.koin.domain.timetableV2.model.TimetableFrame; +import in.koreatech.koin.domain.timetableV2.model.TimetableLecture; +import in.koreatech.koin.domain.timetableV2.repository.TimetableFrameRepositoryV2; +import in.koreatech.koin.domain.timetableV2.repository.TimetableLectureRepositoryV2; +import in.koreatech.koin.domain.user.model.User; + +@Component +@SuppressWarnings("NonAsciiCharacters") +public class TimeTableV2Fixture { + + private final TimetableFrameRepositoryV2 timetableFrameRepositoryV2; + + private final TimetableLectureRepositoryV2 timetableLectureRepositoryV2; + + public TimeTableV2Fixture( + TimetableFrameRepositoryV2 timetableFrameRepositoryV2, + TimetableLectureRepositoryV2 timetableLectureRepositoryV2 + ) { + this.timetableFrameRepositoryV2 = timetableFrameRepositoryV2; + this.timetableLectureRepositoryV2 = timetableLectureRepositoryV2; + } + + public TimetableFrame 시간표1(User user, Semester semester) { + return timetableFrameRepositoryV2.save( + TimetableFrame.builder() + .user(user) + .semester(semester) + .name("시간표1") + .isMain(true) + .build() + ); + } + + public TimetableFrame 시간표2(User user, Semester semester) { + return timetableFrameRepositoryV2.save( + TimetableFrame.builder() + .user(user) + .semester(semester) + .name("시간표2") + .isMain(false) + .build() + ); + } + + public TimetableFrame 시간표3(User user, Semester semester) { + TimetableFrame frame = TimetableFrame.builder() + .user(user) + .semester(semester) + .name("시간표3") + .isMain(false) + .timetableLectures(new ArrayList<>()) + .build(); + + TimetableLecture timetableLecture1 = TimetableLecture.builder() + .grades("0") + .classTitle("커스텀1") + .classTime("[932]") + .isDeleted(false) + .timetableFrame(frame) + .build(); + + TimetableLecture timetableLecture2 = TimetableLecture.builder() + .grades("0") + .classTitle("커스텀2") + .classTime("[933]") + .isDeleted(false) + .timetableFrame(frame) + .build(); + + frame.getTimetableLectures().add(timetableLecture1); + frame.getTimetableLectures().add(timetableLecture2); + + return timetableFrameRepositoryV2.save(frame); + } + + public TimetableFrame 시간표4(User user, Semester semester, Lecture lecture1, Lecture lecture2) { + TimetableFrame frame = TimetableFrame.builder() + .user(user) + .semester(semester) + .name("시간표4") + .isMain(false) + .timetableLectures(new ArrayList<>()) + .build(); + + TimetableLecture timetableLecture1 = TimetableLecture.builder() + .grades("0") + .isDeleted(false) + .lecture(lecture1) + .timetableFrame(frame) + .build(); + + TimetableLecture timetableLecture2 = TimetableLecture.builder() + .grades("0") + .isDeleted(false) + .lecture(lecture2) + .timetableFrame(frame) + .build(); + + frame.getTimetableLectures().add(timetableLecture1); + frame.getTimetableLectures().add(timetableLecture2); + + return timetableFrameRepositoryV2.save(frame); + } + + public TimetableFrame 시간표5(User user, Semester semester, Lecture lecture1) { + TimetableFrame frame = TimetableFrame.builder() + .user(user) + .semester(semester) + .name("시간표4") + .isMain(false) + .timetableLectures(new ArrayList<>()) + .build(); + + TimetableLecture timetableLecture1 = TimetableLecture.builder() + .grades("0") + .isDeleted(false) + .lecture(lecture1) + .timetableFrame(frame) + .build(); + + TimetableLecture timetableLecture2 = TimetableLecture.builder() + .classTitle("커스텀1") + .classTime("[933]") + .classPlace("2공") + .professor("김성재") + .grades("0") + .memo("공부 하기 싫다") + .isDeleted(false) + .timetableFrame(frame) + .build(); + + frame.getTimetableLectures().add(timetableLecture1); + frame.getTimetableLectures().add(timetableLecture2); + + return timetableFrameRepositoryV2.save(frame); + } + + public TimetableFrame 시간표6(User user, Semester semester, Lecture lecture1, Lecture lecture2) { + TimetableFrame frame = TimetableFrame.builder() + .user(user) + .semester(semester) + .name("시간표6") + .isMain(true) + .timetableLectures(new ArrayList<>()) + .build(); + + TimetableLecture timetableLecture1 = TimetableLecture.builder() + .grades("0") + .isDeleted(false) + .lecture(lecture1) + .timetableFrame(frame) + .build(); + + TimetableLecture timetableLecture2 = TimetableLecture.builder() + .grades("0") + .isDeleted(false) + .lecture(lecture2) + .timetableFrame(frame) + .build(); + + frame.getTimetableLectures().add(timetableLecture1); + frame.getTimetableLectures().add(timetableLecture2); + + return timetableFrameRepositoryV2.save(frame); + } +} diff --git a/src/test/java/in/koreatech/koin/fixture/TimeTableFixture.java b/src/test/java/in/koreatech/koin/fixture/TimetableFixture.java similarity index 81% rename from src/test/java/in/koreatech/koin/fixture/TimeTableFixture.java rename to src/test/java/in/koreatech/koin/fixture/TimetableFixture.java index b2c94ebf3..ac8fd71d3 100644 --- a/src/test/java/in/koreatech/koin/fixture/TimeTableFixture.java +++ b/src/test/java/in/koreatech/koin/fixture/TimetableFixture.java @@ -3,23 +3,23 @@ import org.springframework.stereotype.Component; import in.koreatech.koin.domain.timetable.model.Semester; -import in.koreatech.koin.domain.timetable.model.TimeTable; -import in.koreatech.koin.domain.timetable.repository.TimeTableRepository; +import in.koreatech.koin.domain.timetable.model.Timetable; +import in.koreatech.koin.domain.timetable.repository.TimetableRepository; import in.koreatech.koin.domain.user.model.User; @Component @SuppressWarnings("NonAsciiCharacters") -public class TimeTableFixture { +public class TimetableFixture { - private final TimeTableRepository timeTableRepository; + private final TimetableRepository timeTableRepository; - public TimeTableFixture(TimeTableRepository timeTableRepository) { + public TimetableFixture(TimetableRepository timeTableRepository) { this.timeTableRepository = timeTableRepository; } - public TimeTable 컴퓨터구조(User user, Semester semester) { + public Timetable 컴퓨터구조(User user, Semester semester) { return timeTableRepository.save( - TimeTable.builder() + Timetable.builder() .user(user) .semester(semester) .code("CS101") @@ -39,9 +39,9 @@ public TimeTableFixture(TimeTableRepository timeTableRepository) { ); } - public TimeTable 운영체제(User user, Semester semester) { + public Timetable 운영체제(User user, Semester semester) { return timeTableRepository.save( - TimeTable.builder() + Timetable.builder() .user(user) .semester(semester) .code("CS102") @@ -61,9 +61,9 @@ public TimeTableFixture(TimeTableRepository timeTableRepository) { ); } - public TimeTable 이산수학(User user, Semester semester) { + public Timetable 이산수학(User user, Semester semester) { return timeTableRepository.save( - TimeTable.builder() + Timetable.builder() .user(user) .semester(semester) .code("CSE125") @@ -83,9 +83,9 @@ public TimeTableFixture(TimeTableRepository timeTableRepository) { ); } - public TimeTable 알고리즘및실습(User user, Semester semester) { + public Timetable 알고리즘및실습(User user, Semester semester) { return timeTableRepository.save( - TimeTable.builder() + Timetable.builder() .user(user) .semester(semester) .code("CSE130") diff --git a/src/test/java/in/koreatech/koin/fixture/TrackFixture.java b/src/test/java/in/koreatech/koin/fixture/TrackFixture.java index 5b669dcd9..14e2234ae 100644 --- a/src/test/java/in/koreatech/koin/fixture/TrackFixture.java +++ b/src/test/java/in/koreatech/koin/fixture/TrackFixture.java @@ -37,4 +37,13 @@ public Track ios() { .build() ); } + + public Track ai() { + return trackRepository.save( + Track.builder() + .name("AI") + .isDeleted(true) + .build() + ); + } } diff --git a/src/test/java/in/koreatech/koin/fixture/UserFixture.java b/src/test/java/in/koreatech/koin/fixture/UserFixture.java index 9d7126e79..4d4521c72 100644 --- a/src/test/java/in/koreatech/koin/fixture/UserFixture.java +++ b/src/test/java/in/koreatech/koin/fixture/UserFixture.java @@ -2,16 +2,20 @@ import static in.koreatech.koin.domain.user.model.UserGender.MAN; import static in.koreatech.koin.domain.user.model.UserIdentity.UNDERGRADUATE; -import static in.koreatech.koin.domain.user.model.UserType.*; +import static in.koreatech.koin.domain.user.model.UserType.ADMIN; +import static in.koreatech.koin.domain.user.model.UserType.COOP; +import static in.koreatech.koin.domain.user.model.UserType.OWNER; +import static in.koreatech.koin.domain.user.model.UserType.STUDENT; import java.time.LocalDateTime; import java.util.ArrayList; -import java.util.List; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Component; +import in.koreatech.koin.domain.coop.model.Coop; +import in.koreatech.koin.domain.coop.repository.CoopRepository; import in.koreatech.koin.domain.owner.model.Owner; import in.koreatech.koin.domain.owner.model.OwnerAttachment; import in.koreatech.koin.domain.owner.repository.OwnerRepository; @@ -31,20 +35,24 @@ public final class UserFixture { private final UserRepository userRepository; private final OwnerRepository ownerRepository; private final StudentRepository studentRepository; + private final CoopRepository coopRepository; private final JwtProvider jwtProvider; + @Autowired public UserFixture( PasswordEncoder passwordEncoder, UserRepository userRepository, OwnerRepository ownerRepository, StudentRepository studentRepository, + CoopRepository coopRepository, JwtProvider jwtProvider ) { this.passwordEncoder = passwordEncoder; this.userRepository = userRepository; this.ownerRepository = ownerRepository; this.studentRepository = studentRepository; + this.coopRepository = coopRepository; this.jwtProvider = jwtProvider; } @@ -54,7 +62,7 @@ public UserFixture( .password(passwordEncoder.encode("1234")) .nickname("코인운영자") .name("테스트용_코인운영자") - .phoneNumber("010-1234-2344") + .phoneNumber("01012342344") .userType(ADMIN) .gender(MAN) .email("juno@koreatech.ac.kr") @@ -77,7 +85,7 @@ public UserFixture( .password(passwordEncoder.encode("1234")) .nickname("준호") .name("테스트용_준호") - .phoneNumber("010-1234-5678") + .phoneNumber("01012345678") .userType(STUDENT) .gender(MAN) .email("juno@koreatech.ac.kr") @@ -102,7 +110,7 @@ public UserFixture( .password(passwordEncoder.encode("1234")) .nickname("성빈") .name("테스트용_성빈") - .phoneNumber("010-9941-1123") + .phoneNumber("01099411123") .userType(STUDENT) .gender(MAN) .email("testsungbeen@koreatech.ac.kr") @@ -119,7 +127,7 @@ public UserFixture( .password(passwordEncoder.encode("1234")) .nickname("현수") .name("테스트용_현수") - .phoneNumber("010-9876-5432") + .phoneNumber("01098765432") .userType(OWNER) .gender(MAN) .email("hysoo@naver.com") @@ -128,10 +136,12 @@ public UserFixture( .build(); Owner owner = Owner.builder() + .account("01098987979") .user(user) .companyRegistrationNumber("123-45-67190") .grantShop(true) .grantEvent(true) + .account("01098765432") .attachments(new ArrayList<>()) .build(); @@ -158,7 +168,7 @@ public UserFixture( .password(passwordEncoder.encode("1234")) .nickname("준영") .name("테스트용_준영") - .phoneNumber("010-9776-5112") + .phoneNumber("01097765112") .userType(OWNER) .gender(MAN) .email("testjoonyoung@gmail.com") @@ -171,6 +181,7 @@ public UserFixture( .companyRegistrationNumber("112-80-56789") .grantShop(true) .grantEvent(true) + .account("01097765112") .attachments(new ArrayList<>()) .build(); @@ -198,7 +209,7 @@ public UserFixture( .password(passwordEncoder.encode("1234")) .nickname("철수") .name("테스트용_철수(인증X)") - .phoneNumber("010-9776-5112") + .phoneNumber("01097765112") .userType(OWNER) .gender(MAN) .email("testchulsu@gmail.com") @@ -209,8 +220,9 @@ public UserFixture( Owner owner = Owner.builder() .user(user) .companyRegistrationNumber("118-80-56789") - .grantShop(true) - .grantEvent(true) + .grantShop(false) + .grantEvent(false) + .account("01097765112") .attachments(new ArrayList<>()) .build(); @@ -232,20 +244,65 @@ public UserFixture( return ownerRepository.save(owner); } - public User 준기_영양사() { - return userRepository.save( - User.builder() - .password(passwordEncoder.encode("1234")) - .nickname("준기") - .name("허준기") - .phoneNumber("010-1122-5678") - .userType(COOP) - .gender(MAN) - .email("coop@koreatech.ac.kr") - .isAuthed(true) - .isDeleted(false) - .build() - ); + public Owner 원경_사장님() { + User user = User.builder() + .password(passwordEncoder.encode("1234")) + .nickname("원경") + .name("테스트용_원경(전화번호 - 없음") + .phoneNumber("01024607469") + .userType(OWNER) + .gender(MAN) + .email("wongyeong@naver.com") + .isAuthed(true) + .isDeleted(false) + .build(); + + Owner owner = Owner.builder() + .user(user) + .companyRegistrationNumber("123-45-67890") + .grantShop(true) + .grantEvent(true) + .account("01024607469") + .attachments(new ArrayList<>()) + .build(); + + OwnerAttachment attachment1 = OwnerAttachment.builder() + .url("https://test.com/원경_사장님_인증사진_1.jpg") + .isDeleted(false) + .owner(owner) + .build(); + + OwnerAttachment attachment2 = OwnerAttachment.builder() + .url("https://test.com/원경_사장님_인증사진_2.jpg") + .isDeleted(false) + .owner(owner) + .build(); + + owner.getAttachments().add(attachment1); + owner.getAttachments().add(attachment2); + + return ownerRepository.save(owner); + } + + public Coop 준기_영양사() { + User user = User.builder() + .password(passwordEncoder.encode("1234")) + .nickname("준기") + .name("허준기") + .phoneNumber("01011225678") + .userType(COOP) + .gender(MAN) + .email("coop@koreatech.ac.kr") + .isAuthed(true) + .isDeleted(false) + .build(); + + Coop coop = Coop.builder() + .user(user) + .coopId("coop") + .build(); + + return coopRepository.save(coop); } public String getToken(User user) {