diff --git a/backend/src/main/java/woowacourse/touroot/global/exception/BadRequestException.java b/backend/src/main/java/woowacourse/touroot/global/exception/BadRequestException.java new file mode 100644 index 00000000..94306533 --- /dev/null +++ b/backend/src/main/java/woowacourse/touroot/global/exception/BadRequestException.java @@ -0,0 +1,8 @@ +package woowacourse.touroot.global.exception; + +public class BadRequestException extends RuntimeException { + + public BadRequestException(String message) { + super(message); + } +} diff --git a/backend/src/main/java/woowacourse/touroot/place/domain/Place.java b/backend/src/main/java/woowacourse/touroot/place/domain/Place.java index 5509ca4c..630c3a94 100644 --- a/backend/src/main/java/woowacourse/touroot/place/domain/Place.java +++ b/backend/src/main/java/woowacourse/touroot/place/domain/Place.java @@ -1,14 +1,15 @@ package woowacourse.touroot.place.domain; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; import lombok.Getter; +import lombok.NoArgsConstructor; import woowacourse.touroot.entity.BaseEntity; @Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) @Entity public class Place extends BaseEntity { @@ -26,4 +27,8 @@ public class Place extends BaseEntity { private String longitude; private String googlePlaceId; + + public Place(String name, String latitude, String longitude) { + this(null, name, latitude, longitude, null); + } } diff --git a/backend/src/main/java/woowacourse/touroot/place/repository/PlaceRepository.java b/backend/src/main/java/woowacourse/touroot/place/repository/PlaceRepository.java index a3933b86..4b0c8ce6 100644 --- a/backend/src/main/java/woowacourse/touroot/place/repository/PlaceRepository.java +++ b/backend/src/main/java/woowacourse/touroot/place/repository/PlaceRepository.java @@ -3,5 +3,9 @@ import org.springframework.data.jpa.repository.JpaRepository; import woowacourse.touroot.place.domain.Place; +import java.util.Optional; + public interface PlaceRepository extends JpaRepository { + + Optional findByNameAndLatitudeAndLongitude(String name, String lat, String lng); } diff --git a/backend/src/main/java/woowacourse/touroot/travelplan/controller/TravelPlanController.java b/backend/src/main/java/woowacourse/touroot/travelplan/controller/TravelPlanController.java new file mode 100644 index 00000000..de03821f --- /dev/null +++ b/backend/src/main/java/woowacourse/touroot/travelplan/controller/TravelPlanController.java @@ -0,0 +1,31 @@ +package woowacourse.touroot.travelplan.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +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 woowacourse.touroot.travelplan.dto.TravelPlanCreateRequest; +import woowacourse.touroot.travelplan.dto.TravelPlanCreateResponse; +import woowacourse.touroot.travelplan.service.TravelPlanService; + +@Tag(name = "여행기") +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/travel-plans") +public class TravelPlanController { + + private final TravelPlanService travelPlanService; + + @Operation(summary = "여행기 생성") + @PostMapping + public ResponseEntity createTravelPlan(@Valid @RequestBody TravelPlanCreateRequest request) { + TravelPlanCreateResponse data = travelPlanService.createTravelPlan(request); + return ResponseEntity.ok() + .body(data); + } +} diff --git a/backend/src/main/java/woowacourse/touroot/travelplan/domain/TravelPlan.java b/backend/src/main/java/woowacourse/touroot/travelplan/domain/TravelPlan.java new file mode 100644 index 00000000..4699b2bc --- /dev/null +++ b/backend/src/main/java/woowacourse/touroot/travelplan/domain/TravelPlan.java @@ -0,0 +1,42 @@ +package woowacourse.touroot.travelplan.domain; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import woowacourse.touroot.entity.BaseEntity; +import woowacourse.touroot.global.exception.BadRequestException; + +import java.time.LocalDate; +import java.util.List; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Entity +public class TravelPlan extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String title; + + @Column(nullable = false) + private LocalDate startDate; + + @OneToMany(mappedBy = "plan") + private List days; + + public TravelPlan(String title, LocalDate startDate) { + this(null, title, startDate, null); + } + + public void validateStartDate() { + if (startDate.isBefore(LocalDate.now())) { + throw new BadRequestException("지난 날짜에 대한 계획은 작성할 수 없습니다."); + } + } +} diff --git a/backend/src/main/java/woowacourse/touroot/travelplan/domain/TravelPlanDay.java b/backend/src/main/java/woowacourse/touroot/travelplan/domain/TravelPlanDay.java new file mode 100644 index 00000000..fe08d0d3 --- /dev/null +++ b/backend/src/main/java/woowacourse/touroot/travelplan/domain/TravelPlanDay.java @@ -0,0 +1,35 @@ +package woowacourse.touroot.travelplan.domain; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import woowacourse.touroot.entity.BaseEntity; + +import java.util.List; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Entity +public class TravelPlanDay extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "plan_day_order", nullable = false) + Integer order; + + @JoinColumn(name = "plan_id", nullable = false) + @ManyToOne(fetch = FetchType.LAZY) + private TravelPlan plan; + + @OneToMany(mappedBy = "day") + private List places; + + public TravelPlanDay(int order, TravelPlan plan) { + this(null, order, plan, null); + } +} diff --git a/backend/src/main/java/woowacourse/touroot/travelplan/domain/TravelPlanPlace.java b/backend/src/main/java/woowacourse/touroot/travelplan/domain/TravelPlanPlace.java new file mode 100644 index 00000000..71733dad --- /dev/null +++ b/backend/src/main/java/woowacourse/touroot/travelplan/domain/TravelPlanPlace.java @@ -0,0 +1,37 @@ +package woowacourse.touroot.travelplan.domain; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import woowacourse.touroot.entity.BaseEntity; +import woowacourse.touroot.place.domain.Place; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Entity +public class TravelPlanPlace extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String description; + + @Column(name = "plan_place_order", nullable = false) + private Integer order; + + @JoinColumn(name = "plan_day_id", nullable = false) + @ManyToOne(fetch = FetchType.LAZY) + private TravelPlanDay day; + + @JoinColumn(name = "place_id", nullable = false) + @ManyToOne(fetch = FetchType.LAZY) + private Place place; + + public TravelPlanPlace(String description, int order, TravelPlanDay day, Place place) { + this(null, description, order, day, place); + } +} diff --git a/backend/src/main/java/woowacourse/touroot/travelplan/dto/PlanDayCreateRequest.java b/backend/src/main/java/woowacourse/touroot/travelplan/dto/PlanDayCreateRequest.java new file mode 100644 index 00000000..99aedbca --- /dev/null +++ b/backend/src/main/java/woowacourse/touroot/travelplan/dto/PlanDayCreateRequest.java @@ -0,0 +1,23 @@ +package woowacourse.touroot.travelplan.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import woowacourse.touroot.travelplan.domain.TravelPlan; +import woowacourse.touroot.travelplan.domain.TravelPlanDay; + +import java.util.List; + +public record PlanDayCreateRequest( + @Schema(description = "여행 계획 날짜", example = "1") + @NotNull(message = "날짜는 비어있을 수 없습니다.") + @Min(value = 0, message = "날짜는 1 이상이어야 합니다.") + int day, + @Schema(description = "여행 장소 정보") + @NotNull(message = "여행 장소 정보는 비어있을 수 없습니다.") List places +) { + + public TravelPlanDay toPlanDay(TravelPlan plan) { + return new TravelPlanDay(day, plan); + } +} diff --git a/backend/src/main/java/woowacourse/touroot/travelplan/dto/PlanLocationCreateRequest.java b/backend/src/main/java/woowacourse/touroot/travelplan/dto/PlanLocationCreateRequest.java new file mode 100644 index 00000000..5ba1bd42 --- /dev/null +++ b/backend/src/main/java/woowacourse/touroot/travelplan/dto/PlanLocationCreateRequest.java @@ -0,0 +1,14 @@ +package woowacourse.touroot.travelplan.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +public record PlanLocationCreateRequest( + @Schema(description = "여행 장소 위도", example = "37.5175896") + @NotNull(message = "위도는 비어있을 수 없습니다.") + String lat, + @Schema(description = "여행 장소 경도", example = "127.0867236") + @NotNull(message = "경도는 비어있을 수 없습니다.") + String lng +) { +} diff --git a/backend/src/main/java/woowacourse/touroot/travelplan/dto/PlanPlaceCreateRequest.java b/backend/src/main/java/woowacourse/touroot/travelplan/dto/PlanPlaceCreateRequest.java new file mode 100644 index 00000000..182cf88b --- /dev/null +++ b/backend/src/main/java/woowacourse/touroot/travelplan/dto/PlanPlaceCreateRequest.java @@ -0,0 +1,30 @@ +package woowacourse.touroot.travelplan.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import woowacourse.touroot.place.domain.Place; +import woowacourse.touroot.travelplan.domain.TravelPlanDay; +import woowacourse.touroot.travelplan.domain.TravelPlanPlace; + +public record PlanPlaceCreateRequest( + @Schema(description = "여행 장소 이름", example = "신나는 여행 장소") + @NotBlank(message = "장소명은 비어있을 수 없습니다.") String placeName, + @Schema(description = "여행 장소 설명", example = "잠실한강공원") + String description, + @Schema(description = "여행 장소 순서", example = "1") + @NotNull + @Min(value = 0, message = "순서는 1 이상이어야 합니다.") + int order, + @NotNull PlanLocationCreateRequest location +) { + + public TravelPlanPlace toPlanPlace(TravelPlanDay day, Place place) { + return new TravelPlanPlace(description, order, day, place); + } + + public Place toPlace() { + return new Place(placeName, location.lat(), location.lng()); + } +} diff --git a/backend/src/main/java/woowacourse/touroot/travelplan/dto/TravelPlanCreateRequest.java b/backend/src/main/java/woowacourse/touroot/travelplan/dto/TravelPlanCreateRequest.java new file mode 100644 index 00000000..e88e2fce --- /dev/null +++ b/backend/src/main/java/woowacourse/touroot/travelplan/dto/TravelPlanCreateRequest.java @@ -0,0 +1,26 @@ +package woowacourse.touroot.travelplan.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import woowacourse.touroot.travelplan.domain.TravelPlan; + +import java.time.LocalDate; +import java.util.List; + +public record TravelPlanCreateRequest( + @Schema(description = "여행 계획 제목", example = "신나는 잠실 한강 여행") + @NotBlank(message = "여행 계획 제목은 비어있을 수 없습니다.") + String title, + @Schema(description = "여행 계획 시작일", example = "2024-11-16") + @NotNull(message = "시작일은 비어있을 수 없습니다.") + LocalDate startDate, + @Schema(description = "여행 날짜 정보") + @NotNull(message = "여행 날짜 정보는 비어있을 수 없습니다.") + List days +) { + + public TravelPlan toTravelPlan() { + return new TravelPlan(title, startDate); + } +} diff --git a/backend/src/main/java/woowacourse/touroot/travelplan/dto/TravelPlanCreateResponse.java b/backend/src/main/java/woowacourse/touroot/travelplan/dto/TravelPlanCreateResponse.java new file mode 100644 index 00000000..26b147be --- /dev/null +++ b/backend/src/main/java/woowacourse/touroot/travelplan/dto/TravelPlanCreateResponse.java @@ -0,0 +1,9 @@ +package woowacourse.touroot.travelplan.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record TravelPlanCreateResponse( + @Schema(description = "생성된 여행 계획 id") + Long id +) { +} diff --git a/backend/src/main/java/woowacourse/touroot/travelplan/repository/TravelPlanDayRepository.java b/backend/src/main/java/woowacourse/touroot/travelplan/repository/TravelPlanDayRepository.java new file mode 100644 index 00000000..18bf271f --- /dev/null +++ b/backend/src/main/java/woowacourse/touroot/travelplan/repository/TravelPlanDayRepository.java @@ -0,0 +1,7 @@ +package woowacourse.touroot.travelplan.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import woowacourse.touroot.travelplan.domain.TravelPlanDay; + +public interface TravelPlanDayRepository extends JpaRepository { +} diff --git a/backend/src/main/java/woowacourse/touroot/travelplan/repository/TravelPlanPlaceRepository.java b/backend/src/main/java/woowacourse/touroot/travelplan/repository/TravelPlanPlaceRepository.java new file mode 100644 index 00000000..66d04742 --- /dev/null +++ b/backend/src/main/java/woowacourse/touroot/travelplan/repository/TravelPlanPlaceRepository.java @@ -0,0 +1,7 @@ +package woowacourse.touroot.travelplan.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import woowacourse.touroot.travelplan.domain.TravelPlanPlace; + +public interface TravelPlanPlaceRepository extends JpaRepository { +} diff --git a/backend/src/main/java/woowacourse/touroot/travelplan/repository/TravelPlanRepository.java b/backend/src/main/java/woowacourse/touroot/travelplan/repository/TravelPlanRepository.java new file mode 100644 index 00000000..0665c7a6 --- /dev/null +++ b/backend/src/main/java/woowacourse/touroot/travelplan/repository/TravelPlanRepository.java @@ -0,0 +1,7 @@ +package woowacourse.touroot.travelplan.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import woowacourse.touroot.travelplan.domain.TravelPlan; + +public interface TravelPlanRepository extends JpaRepository { +} diff --git a/backend/src/main/java/woowacourse/touroot/travelplan/service/TravelPlanService.java b/backend/src/main/java/woowacourse/touroot/travelplan/service/TravelPlanService.java new file mode 100644 index 00000000..f962ea03 --- /dev/null +++ b/backend/src/main/java/woowacourse/touroot/travelplan/service/TravelPlanService.java @@ -0,0 +1,61 @@ +package woowacourse.touroot.travelplan.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import woowacourse.touroot.place.domain.Place; +import woowacourse.touroot.place.repository.PlaceRepository; +import woowacourse.touroot.travelplan.domain.TravelPlan; +import woowacourse.touroot.travelplan.domain.TravelPlanDay; +import woowacourse.touroot.travelplan.dto.PlanDayCreateRequest; +import woowacourse.touroot.travelplan.dto.PlanPlaceCreateRequest; +import woowacourse.touroot.travelplan.dto.TravelPlanCreateRequest; +import woowacourse.touroot.travelplan.dto.TravelPlanCreateResponse; +import woowacourse.touroot.travelplan.repository.TravelPlanDayRepository; +import woowacourse.touroot.travelplan.repository.TravelPlanPlaceRepository; +import woowacourse.touroot.travelplan.repository.TravelPlanRepository; + +import java.util.List; + +@RequiredArgsConstructor +@Service +public class TravelPlanService { + + private final TravelPlanRepository travelPlanRepository; + private final TravelPlanDayRepository travelPlanDayRepository; + private final TravelPlanPlaceRepository travelPlanPlaceRepository; + private final PlaceRepository placeRepository; + + @Transactional + public TravelPlanCreateResponse createTravelPlan(TravelPlanCreateRequest request) { + TravelPlan travelPlan = request.toTravelPlan(); + travelPlan.validateStartDate(); + + TravelPlan savedTravelPlan = travelPlanRepository.save(travelPlan); + createPlanDay(request, savedTravelPlan); + + return new TravelPlanCreateResponse(savedTravelPlan.getId()); + } + + private void createPlanDay(TravelPlanCreateRequest request, TravelPlan savedTravelPlan) { + for (PlanDayCreateRequest dayRequest : request.days()) { + TravelPlanDay travelPlanDay = travelPlanDayRepository.save(dayRequest.toPlanDay(savedTravelPlan)); + createPlanPlace(dayRequest.places(), travelPlanDay); + } + } + + private void createPlanPlace(List request, TravelPlanDay travelPlanDay) { + for (PlanPlaceCreateRequest planRequest : request) { + Place place = getPlace(planRequest); + travelPlanPlaceRepository.save(planRequest.toPlanPlace(travelPlanDay, place)); + } + } + + private Place getPlace(PlanPlaceCreateRequest planRequest) { + return placeRepository.findByNameAndLatitudeAndLongitude( + planRequest.placeName(), + planRequest.location().lat(), + planRequest.location().lng() + ).orElseGet(() -> placeRepository.save(planRequest.toPlace())); + } +} diff --git a/backend/src/test/java/woowacourse/touroot/travelplan/domain/TravelPlanTest.java b/backend/src/test/java/woowacourse/touroot/travelplan/domain/TravelPlanTest.java new file mode 100644 index 00000000..0548a6a6 --- /dev/null +++ b/backend/src/test/java/woowacourse/touroot/travelplan/domain/TravelPlanTest.java @@ -0,0 +1,25 @@ +package woowacourse.touroot.travelplan.domain; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import woowacourse.touroot.global.exception.BadRequestException; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThatCode; + +@DisplayName("여행 계획") +class TravelPlanTest { + + @DisplayName("여행 계획은 지난 날짜를 검증할 시 예외가 발생한다.") + @Test + void validateStartDate() { + // given + TravelPlan travelPlan = new TravelPlan("test", LocalDate.MIN); + + // when & then + assertThatCode(travelPlan::validateStartDate) + .isInstanceOf(BadRequestException.class) + .hasMessage("지난 날짜에 대한 계획은 작성할 수 없습니다."); + } +}