diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0d7f86d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,37 @@ +FROM maven:3.9.11-amazoncorretto-21 AS builder +WORKDIR /application + +COPY pom.xml ./ + +ENV MAVEN_OPTS="-Dmaven.repo.local=/app/.m2/repository" +RUN mvn dependency:go-offline -B +COPY src ./src + +RUN mvn clean package -DskipTests + +FROM amazoncorretto:21.0.8-alpine AS layers +WORKDIR /application +COPY --from=builder /application/target/*.jar app.jar +RUN java -Djarmode=tools -jar app.jar extract --layers --destination extracted + +FROM amazoncorretto:21.0.8-alpine +VOLUME /tmp +RUN adduser -S spring-user +USER spring-user + +WORKDIR /application + +COPY --from=layers /application/extracted/dependencies/ ./ +COPY --from=layers /application/extracted/spring-boot-loader/ ./ +COPY --from=layers /application/extracted/snapshot-dependencies/ ./ +COPY --from=layers /application/extracted/application/ ./ + +RUN java -XX:ArchiveClassesAtExit=app.jsa -Dspring.context.exit=onRefresh -jar app.jar & exit 0 + +ENV JAVA_CDS_OPTS="-XX:SharedArchiveFile=app.jsa -Xlog:class+load:file=/tmp/classload.log" +ENV JAVA_ERROR_FILE_OPTS="-XX:ErrorFile=/tmp/java_error.log" + +ENTRYPOINT java \ + $JAVA_ERROR_FILE_OPTS \ + $JAVA_CDS_OPTS \ + -jar app.jar diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..62c0038 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,41 @@ +services: + db: + image: postgres:17.7-alpine3.23 + container_name: postgres-db + restart: unless-stopped + ports: + - "5432:5432" + environment: + - POSTGRES_DB=shareit + - POSTGRES_USER=shareit_user + - POSTGRES_PASSWORD=secret + volumes: + - postgres-data:/var/lib/postgresql/data/ + healthcheck: + test: pg_isready -q -d $$POSTGRES_DB -U $$POSTGRES_USER + timeout: 5s + interval: 5s + retries: 10 + shareit: + image: shareit:latest + build: . + container_name: shareit-app + restart: unless-stopped + ports: + - "8080:8080" + environment: + SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/shareit + SPRING_DATASOURCE_USERNAME: shareit_user + SPRING_DATASOURCE_PASSWORD: secret + healthcheck: + test: wget --no-verbose --tries=1 --spider http://localhost:8080/actuator/health || exit 1 + interval: 30s + timeout: 5s + start_period: 30s + retries: 5 + depends_on: + db: + condition: service_healthy + +volumes: + postgres-data: diff --git a/pom.xml b/pom.xml index ac238ab..4c9d34f 100644 --- a/pom.xml +++ b/pom.xml @@ -24,16 +24,23 @@ org.springframework.boot spring-boot-starter-web + org.springframework.boot spring-boot-starter-actuator + org.springframework.boot spring-boot-configuration-processor true + + org.springframework.boot + spring-boot-starter-data-jpa + + org.postgresql postgresql diff --git a/src/main/java/ru/practicum/shareit/booking/Booking.java b/src/main/java/ru/practicum/shareit/booking/Booking.java deleted file mode 100644 index 2d9c666..0000000 --- a/src/main/java/ru/practicum/shareit/booking/Booking.java +++ /dev/null @@ -1,7 +0,0 @@ -package ru.practicum.shareit.booking; - -/** - * TODO Sprint add-bookings. - */ -public class Booking { -} diff --git a/src/main/java/ru/practicum/shareit/booking/BookingController.java b/src/main/java/ru/practicum/shareit/booking/BookingController.java index b94493d..e4bc428 100644 --- a/src/main/java/ru/practicum/shareit/booking/BookingController.java +++ b/src/main/java/ru/practicum/shareit/booking/BookingController.java @@ -1,12 +1,66 @@ package ru.practicum.shareit.booking; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import ru.practicum.shareit.booking.dto.BookingDto; +import ru.practicum.shareit.booking.dto.BookingResponseDto; -/** - * TODO Sprint add-bookings. - */ +import java.util.List; + +@Validated @RestController +@RequiredArgsConstructor @RequestMapping(path = "/bookings") public class BookingController { + private static final String SHARER_USER_ID_HEADER = "X-Sharer-User-Id"; + private final BookingService bookingService; + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public BookingResponseDto createBooking( + @RequestHeader(SHARER_USER_ID_HEADER) long bookerId, + @RequestBody @Valid BookingDto bookingDto + ) { + return bookingService.createBooking(bookerId, bookingDto); + } + + @PatchMapping("{bookingId}") + public BookingResponseDto approveBooking( + @PathVariable long bookingId, + @RequestParam boolean approved, + @RequestHeader(SHARER_USER_ID_HEADER) long ownerId + ) { + return bookingService.approveBooking(bookingId, approved, ownerId); + } + + @GetMapping("{bookingId}") + public BookingResponseDto getBooking( + @PathVariable long bookingId, + @RequestHeader(SHARER_USER_ID_HEADER) long userId + ) { + return bookingService.getBookingById(bookingId, userId); + } + + @GetMapping + public List getAllBookingsOfUser( + @RequestParam(defaultValue = "ALL") String state, + @RequestHeader(SHARER_USER_ID_HEADER) long userId + ) { + BookingState bookingState = BookingState.fromString(state) + .orElseThrow(() -> new IllegalArgumentException("Invalid booking state")); + return bookingService.getAllBookingsOfUser(userId, bookingState); + } + + @GetMapping("/owner") + public List getBookingsByOwner( + @RequestParam(defaultValue = "ALL") String state, + @RequestHeader(SHARER_USER_ID_HEADER) long ownerId + ) { + BookingState bookingState = BookingState.fromString(state) + .orElseThrow(() -> new IllegalArgumentException("Invalid booking state")); + return bookingService.getAllBookingsByOwner(ownerId, bookingState); + } } diff --git a/src/main/java/ru/practicum/shareit/booking/BookingMapper.java b/src/main/java/ru/practicum/shareit/booking/BookingMapper.java new file mode 100644 index 0000000..cf0d650 --- /dev/null +++ b/src/main/java/ru/practicum/shareit/booking/BookingMapper.java @@ -0,0 +1,68 @@ +package ru.practicum.shareit.booking; + +import lombok.experimental.UtilityClass; +import ru.practicum.shareit.booking.dto.BookingDto; +import ru.practicum.shareit.booking.dto.BookingInfoDto; +import ru.practicum.shareit.booking.dto.BookingResponseDto; +import ru.practicum.shareit.booking.model.Booking; +import ru.practicum.shareit.booking.model.BookingStatus; +import ru.practicum.shareit.item.dto.ItemDto; +import ru.practicum.shareit.item.model.Item; +import ru.practicum.shareit.user.dto.UserDto; +import ru.practicum.shareit.user.model.User; + +import java.time.LocalDateTime; +import java.util.Optional; + +@UtilityClass +public class BookingMapper { + public BookingResponseDto toBookingResponseDto(Booking booking) { + Long bookingId = booking.getId(); + LocalDateTime bookingStartTime = booking.getStartTime(); + LocalDateTime bookingEndTime = booking.getEndTime(); + Long itemId = booking.getItem().getId(); + String itemName = booking.getItem().getName(); + String itemDescription = booking.getItem().getDescription(); + boolean itemAvailable = booking.getItem().isAvailable(); + ItemDto itemDto = new ItemDto( + itemId, + itemName, + itemDescription, + itemAvailable + ); + Long userId = booking.getBooker().getId(); + String userName = booking.getBooker().getName(); + String userEmail = booking.getBooker().getEmail(); + UserDto userDto = new UserDto(userId, userName, userEmail); + BookingStatus bookingStatus = booking.getStatus(); + return new BookingResponseDto( + bookingId, + bookingStartTime, + bookingEndTime, + itemDto, + userDto, + bookingStatus + ); + } + + public Booking fromDto(BookingDto bookingDto, User booker, Item item) { + return new Booking(null, + bookingDto.start(), + bookingDto.end(), + item, + booker, + BookingStatus.WAITING); + } + + public Optional toBookingInfoDto(Booking booking) { + if (booking == null) { + return Optional.empty(); + } + return Optional.of(new BookingInfoDto( + booking.getId(), + booking.getBooker().getId(), + booking.getStartTime(), + booking.getEndTime() + )); + } +} diff --git a/src/main/java/ru/practicum/shareit/booking/BookingService.java b/src/main/java/ru/practicum/shareit/booking/BookingService.java new file mode 100644 index 0000000..4497a45 --- /dev/null +++ b/src/main/java/ru/practicum/shareit/booking/BookingService.java @@ -0,0 +1,18 @@ +package ru.practicum.shareit.booking; + +import ru.practicum.shareit.booking.dto.BookingDto; +import ru.practicum.shareit.booking.dto.BookingResponseDto; + +import java.util.List; + +public interface BookingService { + BookingResponseDto createBooking(long bookerId, BookingDto bookingDto); + + BookingResponseDto approveBooking(long bookingId, boolean approved, long ownerId); + + BookingResponseDto getBookingById(long bookingId, long userId); + + List getAllBookingsOfUser(long userId, BookingState bookingState); + + List getAllBookingsByOwner(long ownerId, BookingState bookingState); +} diff --git a/src/main/java/ru/practicum/shareit/booking/BookingServiceImpl.java b/src/main/java/ru/practicum/shareit/booking/BookingServiceImpl.java new file mode 100644 index 0000000..edb08a9 --- /dev/null +++ b/src/main/java/ru/practicum/shareit/booking/BookingServiceImpl.java @@ -0,0 +1,160 @@ +package ru.practicum.shareit.booking; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import ru.practicum.shareit.booking.dto.BookingDto; +import ru.practicum.shareit.booking.dto.BookingResponseDto; +import ru.practicum.shareit.booking.model.Booking; +import ru.practicum.shareit.booking.model.BookingStatus; +import ru.practicum.shareit.booking.repository.BookingRepository; +import ru.practicum.shareit.exception.BookingIntersectionException; +import ru.practicum.shareit.exception.ForbiddenAccessException; +import ru.practicum.shareit.exception.ItemUnavailableException; +import ru.practicum.shareit.exception.NotFoundException; +import ru.practicum.shareit.item.model.Item; +import ru.practicum.shareit.item.repository.ItemRepository; +import ru.practicum.shareit.user.model.User; +import ru.practicum.shareit.user.repository.UserRepository; + +import java.time.LocalDateTime; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class BookingServiceImpl implements BookingService { + private final BookingRepository bookingRepository; + private final ItemRepository itemRepository; + private final UserRepository userRepository; + + @Override + @Transactional + public BookingResponseDto createBooking(long bookerId, BookingDto bookingDto) { + User booker = getUserOrThrow(bookerId); + Item item = getItemOrThrow(bookingDto.itemId()); + + if (!item.isAvailable()) { + throw new ItemUnavailableException("Item with id %d is not available for booking"); + } + + checkBookingDates(bookingDto.start(), bookingDto.end()); + checkBookingIntersections(bookingDto); + + Booking booking = BookingMapper.fromDto(bookingDto, booker, item); + Booking savedBooking = bookingRepository.save(booking); + return BookingMapper.toBookingResponseDto(savedBooking); + } + + @Override + @Transactional + public BookingResponseDto approveBooking(long bookingId, boolean approved, long ownerId) { + Booking booking = getBookingOrThrow(bookingId); + if (booking.getItem().getOwner().getId() != ownerId) { + throw new ForbiddenAccessException("Booking can only be approved by owner of item"); + } + + getUserOrThrow(ownerId); + + booking.setStatus(approved ? BookingStatus.APPROVED : BookingStatus.REJECTED); + Booking saved = bookingRepository.save(booking); + return BookingMapper.toBookingResponseDto(saved); + } + + @Override + public BookingResponseDto getBookingById(long bookingId, long userId) { + Booking booking = getBookingOrThrow(bookingId); + getUserOrThrow(userId); + + if (booking.getItem().getOwner().getId() != userId + && booking.getBooker().getId() != userId) { + throw new ForbiddenAccessException("No access to booking"); + } + + return BookingMapper.toBookingResponseDto(booking); + } + + @Override + public List getAllBookingsOfUser(long userId, BookingState bookingState) { + getUserOrThrow(userId); + + List bookings = switch (bookingState) { + case ALL -> bookingRepository.findByBookerIdOrderByStartTimeDesc(userId); + case CURRENT -> bookingRepository.findCurrentByBooker(userId, LocalDateTime.now()); + case PAST -> bookingRepository.findByBookerIdAndEndTimeBeforeOrderByStartTimeDesc( + userId, LocalDateTime.now()); + case FUTURE -> bookingRepository.findByBookerIdAndStartTimeAfterOrderByStartTimeDesc( + userId, LocalDateTime.now()); + case WAITING -> bookingRepository.findByBookerIdAndStatusOrderByStartTimeDesc( + userId, BookingStatus.WAITING); + case REJECTED -> bookingRepository.findByBookerIdAndStatusOrderByStartTimeDesc( + userId, BookingStatus.REJECTED); + }; + return bookings.stream() + .map(BookingMapper::toBookingResponseDto) + .toList(); + } + + @Override + public List getAllBookingsByOwner(long ownerId, BookingState bookingState) { + getUserOrThrow(ownerId); + + List bookings = switch (bookingState) { + case ALL -> bookingRepository.findByItemOwnerIdOrderByStartTimeDesc(ownerId); + case CURRENT -> bookingRepository.findCurrentByOwner(ownerId, LocalDateTime.now()); + case PAST -> bookingRepository.findByItemOwnerIdAndEndTimeBeforeOrderByStartTimeDesc( + ownerId, LocalDateTime.now()); + case FUTURE -> bookingRepository.findByItemOwnerIdAndStartTimeAfterOrderByStartTimeDesc( + ownerId, LocalDateTime.now()); + case WAITING -> bookingRepository.findByItemOwnerIdAndStatusOrderByStartTimeDesc( + ownerId, BookingStatus.WAITING); + case REJECTED -> bookingRepository.findByItemOwnerIdAndStatusOrderByStartTimeDesc( + ownerId, BookingStatus.REJECTED); + }; + + return bookings.stream() + .map(BookingMapper::toBookingResponseDto) + .toList(); + } + + private User getUserOrThrow(long userId) { + return userRepository.findById(userId) + .orElseThrow(NotFoundException.supplier("User with id:%d not found", userId)); + } + + private Item getItemOrThrow(long itemId) { + return itemRepository.findById(itemId) + .orElseThrow(NotFoundException.supplier("Item with id:%d not found", itemId)); + } + + private Booking getBookingOrThrow(long bookingId) { + return bookingRepository.findById(bookingId) + .orElseThrow(NotFoundException.supplier("Booking with id:%d not found", bookingId)); + } + + private void checkBookingIntersections(BookingDto bookingDto) { + List intersections = + bookingRepository.findApprovedIntersectingBookings( + bookingDto.itemId(), + bookingDto.start(), + bookingDto.end() + ); + + if (!intersections.isEmpty()) { + throw new BookingIntersectionException( + "You can't book this item because booking dates intersect with another booking" + ); + + } + } + + private void checkBookingDates(LocalDateTime start, LocalDateTime end) { + if (start.isAfter(end)) { + throw new IllegalArgumentException("Start booking date is after end date"); + } + + if (start.equals(end)) { + throw new IllegalArgumentException("Booking start and end date must not be the same"); + } + } +} diff --git a/src/main/java/ru/practicum/shareit/booking/BookingState.java b/src/main/java/ru/practicum/shareit/booking/BookingState.java new file mode 100644 index 0000000..b59f4ff --- /dev/null +++ b/src/main/java/ru/practicum/shareit/booking/BookingState.java @@ -0,0 +1,25 @@ +package ru.practicum.shareit.booking; + +import java.util.Optional; + +public enum BookingState { + ALL, + CURRENT, + PAST, + FUTURE, + WAITING, + REJECTED; + + public static Optional fromString(String state) { + BookingState bookingState = switch (state.toUpperCase()) { + case "ALL" -> ALL; + case "CURRENT" -> CURRENT; + case "PAST" -> PAST; + case "FUTURE" -> FUTURE; + case "WAITING" -> WAITING; + case "REJECTED" -> REJECTED; + default -> null; + }; + return Optional.ofNullable(bookingState); + } +} diff --git a/src/main/java/ru/practicum/shareit/booking/dto/BookingDto.java b/src/main/java/ru/practicum/shareit/booking/dto/BookingDto.java index 861de9e..2e091b9 100644 --- a/src/main/java/ru/practicum/shareit/booking/dto/BookingDto.java +++ b/src/main/java/ru/practicum/shareit/booking/dto/BookingDto.java @@ -1,7 +1,14 @@ package ru.practicum.shareit.booking.dto; -/** - * TODO Sprint add-bookings. - */ -public class BookingDto { +import jakarta.validation.constraints.Future; +import jakarta.validation.constraints.FutureOrPresent; +import jakarta.validation.constraints.NotNull; + +import java.time.LocalDateTime; + +public record BookingDto( + long itemId, + @FutureOrPresent @NotNull LocalDateTime start, + @Future @NotNull LocalDateTime end +) { } diff --git a/src/main/java/ru/practicum/shareit/booking/dto/BookingInfoDto.java b/src/main/java/ru/practicum/shareit/booking/dto/BookingInfoDto.java new file mode 100644 index 0000000..9fa9780 --- /dev/null +++ b/src/main/java/ru/practicum/shareit/booking/dto/BookingInfoDto.java @@ -0,0 +1,11 @@ +package ru.practicum.shareit.booking.dto; + +import java.time.LocalDateTime; + +public record BookingInfoDto( + Long id, + Long bookerId, + LocalDateTime start, + LocalDateTime end +) { +} diff --git a/src/main/java/ru/practicum/shareit/booking/dto/BookingResponseDto.java b/src/main/java/ru/practicum/shareit/booking/dto/BookingResponseDto.java new file mode 100644 index 0000000..b705ae1 --- /dev/null +++ b/src/main/java/ru/practicum/shareit/booking/dto/BookingResponseDto.java @@ -0,0 +1,16 @@ +package ru.practicum.shareit.booking.dto; + +import ru.practicum.shareit.booking.model.BookingStatus; +import ru.practicum.shareit.item.dto.ItemDto; +import ru.practicum.shareit.user.dto.UserDto; + +import java.time.LocalDateTime; + +public record BookingResponseDto(Long id, + LocalDateTime start, + LocalDateTime end, + ItemDto item, + UserDto booker, + BookingStatus status +) { +} diff --git a/src/main/java/ru/practicum/shareit/booking/model/Booking.java b/src/main/java/ru/practicum/shareit/booking/model/Booking.java new file mode 100644 index 0000000..dd725f8 --- /dev/null +++ b/src/main/java/ru/practicum/shareit/booking/model/Booking.java @@ -0,0 +1,42 @@ +package ru.practicum.shareit.booking.model; + +import jakarta.persistence.*; +import lombok.*; +import lombok.experimental.FieldDefaults; +import ru.practicum.shareit.item.model.Item; +import ru.practicum.shareit.user.model.User; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Table(name = "bookings") +@FieldDefaults(level = AccessLevel.PRIVATE) +public class Booking { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "booking_id") + Long id; + + @Column(name = "start_time") + LocalDateTime startTime; + + @Column(name = "end_time") + LocalDateTime endTime; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "item_id") + Item item; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "booker_id") + User booker; + + @Column(name = "status") + @Enumerated(EnumType.STRING) + BookingStatus status = BookingStatus.WAITING; + +} diff --git a/src/main/java/ru/practicum/shareit/booking/model/BookingStatus.java b/src/main/java/ru/practicum/shareit/booking/model/BookingStatus.java new file mode 100644 index 0000000..adad7d1 --- /dev/null +++ b/src/main/java/ru/practicum/shareit/booking/model/BookingStatus.java @@ -0,0 +1,8 @@ +package ru.practicum.shareit.booking.model; + +public enum BookingStatus { + WAITING, + APPROVED, + REJECTED, + CANCELLED +} diff --git a/src/main/java/ru/practicum/shareit/booking/repository/BookingRepository.java b/src/main/java/ru/practicum/shareit/booking/repository/BookingRepository.java new file mode 100644 index 0000000..082c9d3 --- /dev/null +++ b/src/main/java/ru/practicum/shareit/booking/repository/BookingRepository.java @@ -0,0 +1,106 @@ +package ru.practicum.shareit.booking.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import ru.practicum.shareit.booking.model.Booking; +import ru.practicum.shareit.booking.model.BookingStatus; + +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +public interface BookingRepository extends JpaRepository { + + List findAllByItem_IdInAndStatus(Collection ids, BookingStatus status); + + @Query(""" + SELECT b FROM Booking b + WHERE b.item.id = :itemId + AND b.status = 'APPROVED' + AND :start < b.endTime + AND :end > b.startTime + """) + List findApprovedIntersectingBookings( + Long itemId, + LocalDateTime start, + LocalDateTime end + ); + + //booker methods + + Optional findByBookerIdAndItemIdAndStatusAndEndTimeIsBefore( + Long bookerId, + Long itemId, + BookingStatus status, + LocalDateTime endTime + ); + + // ALL + List findByBookerIdOrderByStartTimeDesc(Long bookerId); + + // CURRENT + @Query(""" + SELECT b FROM Booking b + WHERE b.booker.id = :userId + AND :now BETWEEN b.startTime AND b.endTime + ORDER BY b.startTime DESC + """) + List findCurrentByBooker( + Long userId, + LocalDateTime now + ); + + // PAST + List findByBookerIdAndEndTimeBeforeOrderByStartTimeDesc( + Long bookerId, + LocalDateTime now + ); + + // FUTURE + List findByBookerIdAndStartTimeAfterOrderByStartTimeDesc( + Long bookerId, + LocalDateTime now + ); + + // WAITING / REJECTED + List findByBookerIdAndStatusOrderByStartTimeDesc( + Long bookerId, + BookingStatus status + ); + + //owner methods + // ALL + List findByItemOwnerIdOrderByStartTimeDesc(Long ownerId); + + // CURRENT + @Query(""" + SELECT b FROM Booking b + WHERE b.item.owner.id = :ownerId + AND :now BETWEEN b.startTime AND b.endTime + ORDER BY b.startTime DESC + """) + List findCurrentByOwner( + Long ownerId, + LocalDateTime now + ); + + // PAST + List findByItemOwnerIdAndEndTimeBeforeOrderByStartTimeDesc( + Long ownerId, + LocalDateTime now + ); + + // FUTURE + List findByItemOwnerIdAndStartTimeAfterOrderByStartTimeDesc( + Long ownerId, + LocalDateTime now + ); + + // WAITING / REJECTED + List findByItemOwnerIdAndStatusOrderByStartTimeDesc( + Long ownerId, + BookingStatus status + ); + +} diff --git a/src/main/java/ru/practicum/shareit/exception/BookingIntersectionException.java b/src/main/java/ru/practicum/shareit/exception/BookingIntersectionException.java new file mode 100644 index 0000000..5381686 --- /dev/null +++ b/src/main/java/ru/practicum/shareit/exception/BookingIntersectionException.java @@ -0,0 +1,7 @@ +package ru.practicum.shareit.exception; + +public class BookingIntersectionException extends RuntimeException { + public BookingIntersectionException(String message) { + super(message); + } +} diff --git a/src/main/java/ru/practicum/shareit/exception/ItemCommentException.java b/src/main/java/ru/practicum/shareit/exception/ItemCommentException.java new file mode 100644 index 0000000..7ce178a --- /dev/null +++ b/src/main/java/ru/practicum/shareit/exception/ItemCommentException.java @@ -0,0 +1,7 @@ +package ru.practicum.shareit.exception; + +public class ItemCommentException extends RuntimeException { + public ItemCommentException(String message) { + super(message); + } +} diff --git a/src/main/java/ru/practicum/shareit/exception/ItemUnavailableException.java b/src/main/java/ru/practicum/shareit/exception/ItemUnavailableException.java new file mode 100644 index 0000000..5d97c04 --- /dev/null +++ b/src/main/java/ru/practicum/shareit/exception/ItemUnavailableException.java @@ -0,0 +1,7 @@ +package ru.practicum.shareit.exception; + +public class ItemUnavailableException extends RuntimeException { + public ItemUnavailableException(String message) { + super(message); + } +} diff --git a/src/main/java/ru/practicum/shareit/exception/handler/GlobalExceptionHandler.java b/src/main/java/ru/practicum/shareit/exception/handler/GlobalExceptionHandler.java index 8e95630..bb825a9 100644 --- a/src/main/java/ru/practicum/shareit/exception/handler/GlobalExceptionHandler.java +++ b/src/main/java/ru/practicum/shareit/exception/handler/GlobalExceptionHandler.java @@ -7,9 +7,7 @@ import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestControllerAdvice; -import ru.practicum.shareit.exception.DuplicateDataException; -import ru.practicum.shareit.exception.ForbiddenAccessException; -import ru.practicum.shareit.exception.NotFoundException; +import ru.practicum.shareit.exception.*; import ru.practicum.shareit.exception.dto.ErrorResponse; import ru.practicum.shareit.exception.dto.ValidationErrorResponse; import ru.practicum.shareit.exception.dto.Violation; @@ -65,6 +63,13 @@ public ErrorResponse onNotFoundException(NotFoundException ex) { return new ErrorResponse("not found", ex.getMessage()); } + @ExceptionHandler(ItemUnavailableException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ErrorResponse onItemUnavailableException(ItemUnavailableException ex) { + log.warn("Item unavailable exception occurred while processing request {}", ex.getMessage()); + return new ErrorResponse("unavailable", ex.getMessage()); + } + @ExceptionHandler(DuplicateDataException.class) @ResponseStatus(HttpStatus.CONFLICT) public ErrorResponse onDuplicateDataException(DuplicateDataException ex) { @@ -78,4 +83,18 @@ public ErrorResponse onForbiddenAccessException(ForbiddenAccessException ex) { log.warn("Forbidden access exception occurred while processing request {}", ex.getMessage()); return new ErrorResponse("forbidden", ex.getMessage()); } + + @ExceptionHandler(ItemCommentException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ErrorResponse onItemCommentException(ItemCommentException ex) { + log.warn("Item comment exception occurred while processing request {}", ex.getMessage()); + return new ErrorResponse("cant comment", ex.getMessage()); + } + + @ExceptionHandler(BookingIntersectionException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ErrorResponse onBookingIntersectionException(BookingIntersectionException ex) { + log.warn("Booking intersection exception occurred while processing request {}", ex.getMessage()); + return new ErrorResponse("booking intersection", ex.getMessage()); + } } diff --git a/src/main/java/ru/practicum/shareit/item/ItemController.java b/src/main/java/ru/practicum/shareit/item/ItemController.java index 36ff3be..7da600b 100644 --- a/src/main/java/ru/practicum/shareit/item/ItemController.java +++ b/src/main/java/ru/practicum/shareit/item/ItemController.java @@ -3,11 +3,10 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; -import ru.practicum.shareit.item.dto.ItemDto; -import ru.practicum.shareit.item.dto.NewItemDto; -import ru.practicum.shareit.item.dto.UpdateItemDto; +import ru.practicum.shareit.item.dto.*; import java.util.Collection; @@ -21,7 +20,7 @@ public class ItemController { private final ItemService itemService; @GetMapping("/{itemId}") - public ItemDto getItem( + public ItemWithBookingDto getItem( @RequestHeader(SHARER_USER_ID_HEADER) long userId, @PathVariable long itemId ) { @@ -29,7 +28,7 @@ public ItemDto getItem( } @GetMapping - public Collection getItems( + public Collection getItems( @RequestHeader(SHARER_USER_ID_HEADER) long userId ) { return itemService.getAllItemsOfUser(userId); @@ -58,4 +57,14 @@ public Collection searchItems( ) { return itemService.searchItems(query); } + + @PostMapping("/{itemId}/comment") + @ResponseStatus(HttpStatus.CREATED) + public CommentDto createComment( + @RequestHeader(SHARER_USER_ID_HEADER) long authorId, + @PathVariable long itemId, + @RequestBody @Valid NewCommentDto comment + ) { + return itemService.createComment(authorId, itemId, comment); + } } diff --git a/src/main/java/ru/practicum/shareit/item/ItemMapper.java b/src/main/java/ru/practicum/shareit/item/ItemMapper.java deleted file mode 100644 index 93d3883..0000000 --- a/src/main/java/ru/practicum/shareit/item/ItemMapper.java +++ /dev/null @@ -1,45 +0,0 @@ -package ru.practicum.shareit.item; - -import lombok.experimental.UtilityClass; -import ru.practicum.shareit.item.dto.ItemDto; -import ru.practicum.shareit.item.dto.NewItemDto; -import ru.practicum.shareit.item.dto.UpdateItemDto; -import ru.practicum.shareit.item.model.Item; - -@UtilityClass -public class ItemMapper { - - public ItemDto toItemDto(Item item) { - return new ItemDto( - item.getId(), - item.getName(), - item.getDescription(), - item.isAvailable(), - item.getRequest() != null ? item.getRequest().getId() : null - ); - } - - public Item toItem(NewItemDto newItemDto) { - Item item = new Item(); - item.setName(newItemDto.name()); - item.setDescription(newItemDto.description()); - item.setAvailable(newItemDto.available()); - return item; - } - - public Item updateItem(Item item, UpdateItemDto updateItemDto) { - if (updateItemDto.hasName()) { - item.setName(updateItemDto.name()); - } - - if (updateItemDto.hasDescription()) { - item.setDescription(updateItemDto.description()); - } - - if (updateItemDto.hasAvailable()) { - item.setAvailable(updateItemDto.available()); - } - - return item; - } -} diff --git a/src/main/java/ru/practicum/shareit/item/ItemService.java b/src/main/java/ru/practicum/shareit/item/ItemService.java index 86db07f..e7ad410 100644 --- a/src/main/java/ru/practicum/shareit/item/ItemService.java +++ b/src/main/java/ru/practicum/shareit/item/ItemService.java @@ -1,19 +1,19 @@ package ru.practicum.shareit.item; -import ru.practicum.shareit.item.dto.ItemDto; -import ru.practicum.shareit.item.dto.NewItemDto; -import ru.practicum.shareit.item.dto.UpdateItemDto; +import ru.practicum.shareit.item.dto.*; import java.util.Collection; public interface ItemService { - ItemDto getItemOfUserById(long userId, long itemId); + ItemWithBookingDto getItemOfUserById(long userId, long itemId); - Collection getAllItemsOfUser(long userId); + Collection getAllItemsOfUser(long userId); ItemDto saveItem(long userId, NewItemDto newItem); ItemDto updateItem(long userId, long itemId, UpdateItemDto updatedItem); Collection searchItems(String query); + + CommentDto createComment(long bookerId, long itemId, NewCommentDto newComment); } diff --git a/src/main/java/ru/practicum/shareit/item/ItemServiceImpl.java b/src/main/java/ru/practicum/shareit/item/ItemServiceImpl.java index 13609f2..7ed4a50 100644 --- a/src/main/java/ru/practicum/shareit/item/ItemServiceImpl.java +++ b/src/main/java/ru/practicum/shareit/item/ItemServiceImpl.java @@ -3,46 +3,80 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import ru.practicum.shareit.booking.model.Booking; +import ru.practicum.shareit.booking.model.BookingStatus; +import ru.practicum.shareit.booking.repository.BookingRepository; import ru.practicum.shareit.exception.ForbiddenAccessException; +import ru.practicum.shareit.exception.ItemCommentException; import ru.practicum.shareit.exception.NotFoundException; -import ru.practicum.shareit.item.dto.ItemDto; -import ru.practicum.shareit.item.dto.NewItemDto; -import ru.practicum.shareit.item.dto.UpdateItemDto; +import ru.practicum.shareit.item.dto.*; +import ru.practicum.shareit.item.mapper.CommentMapper; +import ru.practicum.shareit.item.mapper.ItemMapper; +import ru.practicum.shareit.item.model.Comment; import ru.practicum.shareit.item.model.Item; +import ru.practicum.shareit.item.repository.CommentRepository; import ru.practicum.shareit.item.repository.ItemRepository; import ru.practicum.shareit.user.model.User; import ru.practicum.shareit.user.repository.UserRepository; -import java.util.Collection; -import java.util.Collections; +import java.time.LocalDateTime; +import java.util.*; +import java.util.stream.Collectors; @Slf4j @Service +@Transactional(readOnly = true) @RequiredArgsConstructor public class ItemServiceImpl implements ItemService { private final ItemRepository itemRepository; private final UserRepository userRepository; + private final BookingRepository bookingRepository; + private final CommentRepository commentRepository; @Override - public ItemDto getItemOfUserById(long userId, long itemId) { + public ItemWithBookingDto getItemOfUserById(long userId, long itemId) { getUserOrThrow(userId); - return itemRepository.findById(itemId) - .map(ItemMapper::toItemDto) + Item item = itemRepository.findById(itemId) .orElseThrow(NotFoundException.supplier("item with id %d not found", itemId)); + List itemIdList = List.of(item.getId()); + Map> commentsOfItems = getCommentsOfItems(itemIdList); + return ItemMapper.toItemWithBookingDatesDto( + item, + null, + null, + commentsOfItems.get(itemId)); } @Override - public Collection getAllItemsOfUser(long userId) { + public Collection getAllItemsOfUser(long userId) { getUserOrThrow(userId); + List itemsByOwner = itemRepository.findAllByOwnerId(userId); + List itemsIds = itemsByOwner.stream() + .map(Item::getId) + .toList(); - return itemRepository.findAllByUserId(userId) + LocalDateTime currentTime = LocalDateTime.now(); + List bookingsOfItems = bookingRepository.findAllByItem_IdInAndStatus( + itemsIds, BookingStatus.APPROVED); + Map lastBookingsOfItemsById = getLastBookingsMap(bookingsOfItems, currentTime); + Map nextBookingsOfItemsById = getNextBookingsMap(bookingsOfItems, currentTime); + Map> commentsByItemId = getCommentsOfItems(itemsIds); + + return itemRepository.findAllByOwnerId(userId) .stream() - .map(ItemMapper::toItemDto) + .map(item -> ItemMapper.toItemWithBookingDatesDto( + item, + lastBookingsOfItemsById.get(item.getId()), + nextBookingsOfItemsById.get(item.getId()), + commentsByItemId.get(item.getId()) + )) .toList(); } @Override + @Transactional public ItemDto saveItem(long userId, NewItemDto newItem) { User owner = getUserOrThrow(userId); Item item = ItemMapper.toItem(newItem); @@ -52,8 +86,8 @@ public ItemDto saveItem(long userId, NewItemDto newItem) { } @Override + @Transactional public ItemDto updateItem(long userId, long itemId, UpdateItemDto newItem) { - getUserOrThrow(userId); Item item = getItemOrThrow(itemId); if (item.getOwner().getId() != userId) { throw new ForbiddenAccessException("Only owner of item can update it"); @@ -63,19 +97,81 @@ public ItemDto updateItem(long userId, long itemId, UpdateItemDto newItem) { return ItemMapper.toItemDto(updatedItem); } - @Override public Collection searchItems(String query) { if (query.isBlank()) { return Collections.emptyList(); } - return itemRepository.searchItems(query) + return itemRepository.search(query) .stream() .map(ItemMapper::toItemDto) .toList(); } + @Override + @Transactional + public CommentDto createComment(long authorId, long itemId, NewCommentDto newComment) { + User author = getUserOrThrow(authorId); + Item item = getItemOrThrow(itemId); + Optional bookingOptional = bookingRepository.findByBookerIdAndItemIdAndStatusAndEndTimeIsBefore( + authorId, itemId, BookingStatus.APPROVED, LocalDateTime.now() + ); + + if (bookingOptional.isEmpty()) { + throw new ItemCommentException("You can't comment item that you haven't booked"); + } + Comment comment = CommentMapper.toEntity(newComment, author, item, LocalDateTime.now()); + Comment savedComment = commentRepository.save(comment); + return CommentMapper.toDto(savedComment); + } + + private Map getNextBookingsMap(List bookings, LocalDateTime currentTime) { + Map bookingsByItemId = new HashMap<>(); + + for (Booking booking : bookings) { + if (!booking.getStartTime().isAfter(currentTime)) { + continue; + } + + Long itemId = booking.getItem().getId(); + Booking existing = bookingsByItemId.get(itemId); + + if (existing == null + || booking.getStartTime().isBefore(existing.getStartTime())) { + bookingsByItemId.put(itemId, booking); + } + } + + return bookingsByItemId; + } + + private Map> getCommentsOfItems(List itemsIds) { + List itemsComments = commentRepository.findAllByItem_IdInOrderByCreatedAtDesc(itemsIds); + return itemsComments.stream() + .collect(Collectors.groupingBy(comment -> comment.getItem().getId())); + } + + private Map getLastBookingsMap(List bookings, LocalDateTime currentTime) { + Map bookingsByItemId = new HashMap<>(); + + for (Booking booking : bookings) { + if (!booking.getEndTime().isAfter(currentTime)) { + continue; + } + + Long itemId = booking.getItem().getId(); + Booking existing = bookingsByItemId.get(itemId); + + if (existing == null + || booking.getEndTime().isAfter(existing.getEndTime())) { + bookingsByItemId.put(itemId, booking); + } + } + + return bookingsByItemId; + } + private Item getItemOrThrow(long itemId) { return itemRepository.findById(itemId).orElseThrow( NotFoundException.supplier("Item with id %d not found", itemId) diff --git a/src/main/java/ru/practicum/shareit/item/dto/CommentDto.java b/src/main/java/ru/practicum/shareit/item/dto/CommentDto.java new file mode 100644 index 0000000..5c2a30a --- /dev/null +++ b/src/main/java/ru/practicum/shareit/item/dto/CommentDto.java @@ -0,0 +1,11 @@ +package ru.practicum.shareit.item.dto; + +import java.time.LocalDateTime; + +public record CommentDto( + Long id, + String text, + String authorName, + LocalDateTime created +) { +} diff --git a/src/main/java/ru/practicum/shareit/item/dto/ItemDto.java b/src/main/java/ru/practicum/shareit/item/dto/ItemDto.java index 30863dd..004bf66 100644 --- a/src/main/java/ru/practicum/shareit/item/dto/ItemDto.java +++ b/src/main/java/ru/practicum/shareit/item/dto/ItemDto.java @@ -4,7 +4,6 @@ public record ItemDto( Long id, String name, String description, - boolean available, - Long requestId + boolean available ) { } diff --git a/src/main/java/ru/practicum/shareit/item/dto/ItemWithBookingDto.java b/src/main/java/ru/practicum/shareit/item/dto/ItemWithBookingDto.java new file mode 100644 index 0000000..f742247 --- /dev/null +++ b/src/main/java/ru/practicum/shareit/item/dto/ItemWithBookingDto.java @@ -0,0 +1,16 @@ +package ru.practicum.shareit.item.dto; + +import ru.practicum.shareit.booking.dto.BookingInfoDto; + +import java.util.List; + +public record ItemWithBookingDto( + Long id, + String name, + String description, + boolean available, + BookingInfoDto lastBooking, + BookingInfoDto nextBooking, + List comments +) { +} diff --git a/src/main/java/ru/practicum/shareit/item/dto/NewCommentDto.java b/src/main/java/ru/practicum/shareit/item/dto/NewCommentDto.java new file mode 100644 index 0000000..2eba2c9 --- /dev/null +++ b/src/main/java/ru/practicum/shareit/item/dto/NewCommentDto.java @@ -0,0 +1,6 @@ +package ru.practicum.shareit.item.dto; + +import jakarta.validation.constraints.NotBlank; + +public record NewCommentDto(@NotBlank String text) { +} diff --git a/src/main/java/ru/practicum/shareit/item/mapper/CommentMapper.java b/src/main/java/ru/practicum/shareit/item/mapper/CommentMapper.java new file mode 100644 index 0000000..bd4cca0 --- /dev/null +++ b/src/main/java/ru/practicum/shareit/item/mapper/CommentMapper.java @@ -0,0 +1,32 @@ +package ru.practicum.shareit.item.mapper; + +import lombok.experimental.UtilityClass; +import ru.practicum.shareit.item.dto.CommentDto; +import ru.practicum.shareit.item.dto.NewCommentDto; +import ru.practicum.shareit.item.model.Comment; +import ru.practicum.shareit.item.model.Item; +import ru.practicum.shareit.user.model.User; + +import java.time.LocalDateTime; + +@UtilityClass +public class CommentMapper { + public CommentDto toDto(Comment comment) { + return new CommentDto( + comment.getId(), + comment.getText(), + comment.getAuthor().getName(), + comment.getCreatedAt() + ); + } + + public Comment toEntity(NewCommentDto dto, User author, Item item, LocalDateTime now) { + return new Comment( + null, + dto.text(), + item, + author, + now + ); + } +} diff --git a/src/main/java/ru/practicum/shareit/item/mapper/ItemMapper.java b/src/main/java/ru/practicum/shareit/item/mapper/ItemMapper.java new file mode 100644 index 0000000..4020849 --- /dev/null +++ b/src/main/java/ru/practicum/shareit/item/mapper/ItemMapper.java @@ -0,0 +1,73 @@ +package ru.practicum.shareit.item.mapper; + +import lombok.experimental.UtilityClass; +import ru.practicum.shareit.booking.BookingMapper; +import ru.practicum.shareit.booking.dto.BookingInfoDto; +import ru.practicum.shareit.booking.model.Booking; +import ru.practicum.shareit.item.dto.ItemDto; +import ru.practicum.shareit.item.dto.ItemWithBookingDto; +import ru.practicum.shareit.item.dto.NewItemDto; +import ru.practicum.shareit.item.dto.UpdateItemDto; +import ru.practicum.shareit.item.model.Comment; +import ru.practicum.shareit.item.model.Item; + +import java.util.List; + +@UtilityClass +public class ItemMapper { + + public ItemDto toItemDto(Item item) { + return new ItemDto( + item.getId(), + item.getName(), + item.getDescription(), + item.isAvailable() + ); + } + + public Item toItem(NewItemDto newItemDto) { + Item item = new Item(); + item.setName(newItemDto.name()); + item.setDescription(newItemDto.description()); + item.setAvailable(newItemDto.available()); + return item; + } + + public Item updateItem(Item item, UpdateItemDto updateItemDto) { + if (updateItemDto.hasName()) { + item.setName(updateItemDto.name()); + } + + if (updateItemDto.hasDescription()) { + item.setDescription(updateItemDto.description()); + } + + if (updateItemDto.hasAvailable()) { + item.setAvailable(updateItemDto.available()); + } + + return item; + } + + public static ItemWithBookingDto toItemWithBookingDatesDto( + Item item, Booking nextBooking, Booking lastBooking, List comments) { + Long itemId = item.getId(); + String itemName = item.getName(); + String itemDescription = item.getDescription(); + boolean itemAvailable = item.isAvailable(); + BookingInfoDto lastBookingInfoDto = BookingMapper.toBookingInfoDto(lastBooking).orElse(null); + BookingInfoDto nextBookingInfoDto = BookingMapper.toBookingInfoDto(nextBooking).orElse(null); + return new ItemWithBookingDto( + itemId, + itemName, + itemDescription, + itemAvailable, + lastBookingInfoDto, + nextBookingInfoDto, + comments == null ? null : comments.stream() + .map(CommentMapper::toDto) + .toList() + ); + } + +} diff --git a/src/main/java/ru/practicum/shareit/item/model/Comment.java b/src/main/java/ru/practicum/shareit/item/model/Comment.java new file mode 100644 index 0000000..b347833 --- /dev/null +++ b/src/main/java/ru/practicum/shareit/item/model/Comment.java @@ -0,0 +1,41 @@ +package ru.practicum.shareit.item.model; + +import jakarta.persistence.*; +import lombok.*; +import lombok.experimental.FieldDefaults; +import org.hibernate.annotations.OnDelete; +import org.hibernate.annotations.OnDeleteAction; +import ru.practicum.shareit.user.model.User; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Table(name = "comments") +@FieldDefaults(level = AccessLevel.PRIVATE) +public class Comment { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "comment_id") + Long id; + + @Column(name = "text") + String text; + + @ManyToOne(fetch = FetchType.LAZY) + @OnDelete(action = OnDeleteAction.CASCADE) + @JoinColumn(name = "item_id") + Item item; + + @ManyToOne(fetch = FetchType.LAZY) + @OnDelete(action = OnDeleteAction.CASCADE) + @JoinColumn(name = "author_id") + User author; + + @Column(name = "created_at") + LocalDateTime createdAt; + +} diff --git a/src/main/java/ru/practicum/shareit/item/model/Item.java b/src/main/java/ru/practicum/shareit/item/model/Item.java index 775a4cd..379bd9f 100644 --- a/src/main/java/ru/practicum/shareit/item/model/Item.java +++ b/src/main/java/ru/practicum/shareit/item/model/Item.java @@ -1,23 +1,32 @@ package ru.practicum.shareit.item.model; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; +import jakarta.persistence.*; +import lombok.*; import lombok.experimental.FieldDefaults; -import ru.practicum.shareit.request.model.ItemRequest; import ru.practicum.shareit.user.model.User; -@Data -@FieldDefaults(level = AccessLevel.PRIVATE) +@Entity +@Getter +@Setter @NoArgsConstructor @AllArgsConstructor +@Table(name = "items") +@FieldDefaults(level = AccessLevel.PRIVATE) public class Item { + @Id + @Column(name = "item_id") + @GeneratedValue(strategy = GenerationType.IDENTITY) Long id; + String name; + String description; + boolean available; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "owner_id") User owner; - ItemRequest request; + // ItemRequest request; } diff --git a/src/main/java/ru/practicum/shareit/item/repository/CommentRepository.java b/src/main/java/ru/practicum/shareit/item/repository/CommentRepository.java new file mode 100644 index 0000000..9230136 --- /dev/null +++ b/src/main/java/ru/practicum/shareit/item/repository/CommentRepository.java @@ -0,0 +1,12 @@ +package ru.practicum.shareit.item.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import ru.practicum.shareit.item.model.Comment; + +import java.util.Collection; +import java.util.List; + +public interface CommentRepository extends JpaRepository { + + List findAllByItem_IdInOrderByCreatedAtDesc(Collection ids); +} diff --git a/src/main/java/ru/practicum/shareit/item/repository/InMemoryItemRepository.java b/src/main/java/ru/practicum/shareit/item/repository/InMemoryItemRepository.java deleted file mode 100644 index a74f783..0000000 --- a/src/main/java/ru/practicum/shareit/item/repository/InMemoryItemRepository.java +++ /dev/null @@ -1,56 +0,0 @@ -package ru.practicum.shareit.item.repository; - -import org.springframework.stereotype.Repository; -import ru.practicum.shareit.item.model.Item; - -import java.util.Collection; -import java.util.HashMap; -import java.util.Map; -import java.util.Optional; - -@Repository -public class InMemoryItemRepository implements ItemRepository { - private final Map items = new HashMap<>(); - - @Override - public Optional findById(long id) { - return Optional.ofNullable(items.get(id)); - } - - @Override - public Collection findAllByUserId(long userId) { - return items.values() - .stream() - .filter(item -> item.getOwner().getId() == userId) - .toList(); - } - - @Override - public Item save(Item item) { - Long id = generateNextId(); - item.setId(id); - - items.put(id, item); - - return item; - } - - @Override - public Collection searchItems(String query) { - String lowercaseQuery = query.trim().toLowerCase(); - - return items.values() - .stream() - .filter(Item::isAvailable) - .filter(item -> item.getName().toLowerCase().contains(lowercaseQuery) - || item.getDescription().toLowerCase().contains(lowercaseQuery)) - .toList(); - } - - private Long generateNextId() { - Long nextId = items.keySet().stream() - .max(Long::compareTo) - .orElse(0L); - return ++nextId; - } -} diff --git a/src/main/java/ru/practicum/shareit/item/repository/ItemRepository.java b/src/main/java/ru/practicum/shareit/item/repository/ItemRepository.java index 6426fb6..c911140 100644 --- a/src/main/java/ru/practicum/shareit/item/repository/ItemRepository.java +++ b/src/main/java/ru/practicum/shareit/item/repository/ItemRepository.java @@ -1,16 +1,19 @@ package ru.practicum.shareit.item.repository; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; import ru.practicum.shareit.item.model.Item; -import java.util.Collection; -import java.util.Optional; +import java.util.List; -public interface ItemRepository { - Optional findById(long id); +public interface ItemRepository extends JpaRepository { + List findAllByOwnerId(Long userId); - Collection findAllByUserId(long userId); - - Item save(Item item); - - Collection searchItems(String query); + @Query(""" + select i from Item i + where i.available = true + and (lower(i.name)) like lower(concat('%', :query, '%')) + or lower(i.description) like lower(concat('%', :query, '%')) + """) + List search(String query); } diff --git a/src/main/java/ru/practicum/shareit/user/UserServiceImpl.java b/src/main/java/ru/practicum/shareit/user/UserServiceImpl.java index bb48c34..d6df065 100644 --- a/src/main/java/ru/practicum/shareit/user/UserServiceImpl.java +++ b/src/main/java/ru/practicum/shareit/user/UserServiceImpl.java @@ -3,6 +3,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import ru.practicum.shareit.exception.NotFoundException; import ru.practicum.shareit.user.dto.NewUserDto; import ru.practicum.shareit.user.dto.UpdateUserDto; @@ -14,6 +15,7 @@ @Slf4j @Service +@Transactional(readOnly = true) @RequiredArgsConstructor public class UserServiceImpl implements UserService { private final UserRepository userRepository; @@ -32,6 +34,7 @@ public UserDto findById(long id) { } @Override + @Transactional public UserDto save(NewUserDto newUser) { User user = UserMapper.toUser(newUser); user = userRepository.save(user); @@ -39,17 +42,19 @@ public UserDto save(NewUserDto newUser) { } @Override + @Transactional public UserDto update(UpdateUserDto newUser) { User user = getUserOrThrow(newUser.getId()); User updatedUser = UserMapper.updateUser(user, newUser); - userRepository.update(updatedUser); + userRepository.save(updatedUser); return UserMapper.toUserDto(updatedUser); } @Override + @Transactional public void delete(long id) { getUserOrThrow(id); - userRepository.delete(id); + userRepository.deleteById(id); } private User getUserOrThrow(long id) { diff --git a/src/main/java/ru/practicum/shareit/user/model/User.java b/src/main/java/ru/practicum/shareit/user/model/User.java index 7cd8314..1b0b3dc 100644 --- a/src/main/java/ru/practicum/shareit/user/model/User.java +++ b/src/main/java/ru/practicum/shareit/user/model/User.java @@ -1,17 +1,23 @@ package ru.practicum.shareit.user.model; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; +import jakarta.persistence.*; +import lombok.*; import lombok.experimental.FieldDefaults; -@Data -@FieldDefaults(level = AccessLevel.PRIVATE) +@Entity +@Setter +@Getter @NoArgsConstructor @AllArgsConstructor +@Table(name = "users") +@FieldDefaults(level = AccessLevel.PRIVATE) public class User { + @Id + @Column(name = "user_id") + @GeneratedValue(strategy = GenerationType.IDENTITY) Long id; + String name; + String email; } diff --git a/src/main/java/ru/practicum/shareit/user/repository/InMemoryUserRepository.java b/src/main/java/ru/practicum/shareit/user/repository/InMemoryUserRepository.java deleted file mode 100644 index 7d7bd18..0000000 --- a/src/main/java/ru/practicum/shareit/user/repository/InMemoryUserRepository.java +++ /dev/null @@ -1,74 +0,0 @@ -package ru.practicum.shareit.user.repository; - -import org.springframework.stereotype.Repository; -import ru.practicum.shareit.exception.DuplicateDataException; -import ru.practicum.shareit.user.model.User; - -import java.util.*; - -@Repository -public class InMemoryUserRepository implements UserRepository { - private final Map users = new HashMap<>(); - private final Set userEmails = new HashSet<>(); - - @Override - public Collection findAll() { - return Collections.unmodifiableCollection(users.values()); - } - - @Override - public Optional findById(long id) { - User user = users.get(id); - if (user == null) { - return Optional.empty(); - } - - return Optional.of(new User( - user.getId(), - user.getName(), - user.getEmail())); - } - - @Override - public User save(User user) { - Long id = generateNextId(); - user.setId(id); - - if (userEmails.contains(user.getEmail())) { - throw new DuplicateDataException("email %s already exists".formatted(user.getEmail())); - } - - users.put(id, user); - userEmails.add(user.getEmail()); - return user; - } - - @Override - public void update(User user) { - User currentUser = users.get(user.getId()); - - if (!currentUser.getEmail().equals(user.getEmail())) { - if (userEmails.contains(user.getEmail())) { - throw new DuplicateDataException("email %s already exists".formatted(user.getEmail())); - } - - userEmails.remove(currentUser.getEmail()); - userEmails.add(user.getEmail()); - } - - users.put(user.getId(), user); - } - - @Override - public void delete(long id) { - User removedUser = users.remove(id); - userEmails.remove(removedUser.getEmail()); - } - - private Long generateNextId() { - Long nextId = users.keySet().stream() - .max(Long::compareTo) - .orElse(0L); - return ++nextId; - } -} diff --git a/src/main/java/ru/practicum/shareit/user/repository/UserRepository.java b/src/main/java/ru/practicum/shareit/user/repository/UserRepository.java index be812dc..ef10484 100644 --- a/src/main/java/ru/practicum/shareit/user/repository/UserRepository.java +++ b/src/main/java/ru/practicum/shareit/user/repository/UserRepository.java @@ -1,19 +1,8 @@ package ru.practicum.shareit.user.repository; +import org.springframework.data.jpa.repository.JpaRepository; import ru.practicum.shareit.user.model.User; -import java.util.Collection; -import java.util.Optional; +public interface UserRepository extends JpaRepository { -public interface UserRepository { - - Collection findAll(); - - Optional findById(long id); - - User save(User user); - - void update(User user); - - void delete(long id); } diff --git a/src/main/resources/application-test.properties b/src/main/resources/application-test.properties index 9e9bc4b..09fc2b7 100644 --- a/src/main/resources/application-test.properties +++ b/src/main/resources/application-test.properties @@ -1,13 +1,13 @@ -spring.jpa.hibernate.ddl-auto=none +spring.jpa.hibernate.ddl-auto=create-drop spring.jpa.properties.hibernate.format_sql=true -spring.sql.init.mode=always logging.level.org.springframework.orm.jpa=INFO logging.level.org.springframework.transaction=INFO logging.level.org.springframework.transaction.interceptor=TRACE logging.level.org.springframework.orm.jpa.JpaTransactionManager=DEBUG +spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.H2Dialect +spring.sql.init.mode=never -# TODO Append connection to H2 DB -#spring.datasource.driverClassName -#spring.datasource.url -#spring.datasource.username -#spring.datasource.password +spring.datasource.driver-class-name=org.h2.Driver +spring.datasource.url=jdbc:h2:mem:shareit +spring.datasource.username=sa +spring.datasource.password=password diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 51c5180..a627601 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,4 +1,4 @@ -spring.jpa.hibernate.ddl-auto=none +spring.jpa.hibernate.ddl-auto=validate spring.jpa.properties.hibernate.format_sql=true spring.sql.init.mode=always @@ -7,8 +7,7 @@ logging.level.org.springframework.transaction=INFO logging.level.org.springframework.transaction.interceptor=TRACE logging.level.org.springframework.orm.jpa.JpaTransactionManager=DEBUG -# TODO Append connection to Postgres DB -#spring.datasource.driverClassName -#spring.datasource.url -#spring.datasource.username -#spring.datasource.password +spring.datasource.driver-class-name=org.postgresql.Driver +spring.datasource.url=${SPRING_DATASOURCE_URL} +spring.datasource.username=${SPRING_DATASOURCE_USERNAME} +spring.datasource.password=${SPRING_DATASOURCE_PASSWORD} diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql new file mode 100644 index 0000000..67db65a --- /dev/null +++ b/src/main/resources/schema.sql @@ -0,0 +1,49 @@ +CREATE TABLE IF NOT EXISTS users +( + user_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + name VARCHAR(255) NOT NULL, + email VARCHAR(255) NOT NULL, + + CONSTRAINT unique_user_email UNIQUE (email) +); + +CREATE TABLE IF NOT EXISTS items +( + item_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + name VARCHAR(512) NOT NULL, + description VARCHAR(1024) NOT NULL, + available BOOLEAN NOT NULL, + owner_id BIGINT NOT NULL, + + CONSTRAINT fk_item_owner FOREIGN KEY (owner_id) REFERENCES users(user_id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS bookings +( + booking_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + start_time TIMESTAMP WITHOUT TIME ZONE NULL, + end_time TIMESTAMP WITHOUT TIME ZONE NOT NULL, + item_id BIGINT NOT NULL, + booker_id BIGINT NOT NULL, + status VARCHAR(20) NOT NULL CHECK (status IN ( + 'WAITING', + 'APPROVED', + 'REJECTED', + 'CANCELED')) + DEFAULT 'WAITING', + + CONSTRAINT fk_item FOREIGN KEY (item_id) REFERENCES items(item_id) ON DELETE CASCADE, + CONSTRAINT fk_booker FOREIGN KEY (booker_id) REFERENCES users(user_id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS comments +( + comment_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + text VARCHAR(1024) NOT NULL, + item_id BIGINT NOT NULL, + author_id BIGINT NOT NULL, + created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL, + + CONSTRAINT fk_item FOREIGN KEY (item_id) REFERENCES items(item_id) ON DELETE CASCADE, + CONSTRAINT fk_author FOREIGN KEY (author_id) REFERENCES users(user_id) ON DELETE CASCADE +); diff --git a/src/test/java/ru/practicum/shareit/ShareItTests.java b/src/test/java/ru/practicum/shareit/ShareItTests.java index 4d79052..06d43e9 100644 --- a/src/test/java/ru/practicum/shareit/ShareItTests.java +++ b/src/test/java/ru/practicum/shareit/ShareItTests.java @@ -2,12 +2,14 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; -@SpringBootTest +@SpringBootTest() +@TestPropertySource(locations = "classpath:application-test.properties") class ShareItTests { - @Test - void contextLoads() { - } + @Test + void contextLoads() { + } }