diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..45dada4 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1 @@ +Комментарии в Pull Request обязательно должны быть на русском языке! diff --git a/.run/Deployment compose-local.run.xml b/.run/Deployment compose-local.run.xml new file mode 100644 index 0000000..4f2962b --- /dev/null +++ b/.run/Deployment compose-local.run.xml @@ -0,0 +1,11 @@ + + + + + + + + + \ No newline at end of file diff --git a/.run/MainApplication (local).run.xml b/.run/MainApplication (local).run.xml new file mode 100644 index 0000000..bd3928c --- /dev/null +++ b/.run/MainApplication (local).run.xml @@ -0,0 +1,11 @@ + + + + \ No newline at end of file diff --git a/.run/MainApplication.run.xml b/.run/MainApplication.run.xml new file mode 100644 index 0000000..d0e71e8 --- /dev/null +++ b/.run/MainApplication.run.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/.run/StatApplication (local).run.xml b/.run/StatApplication (local).run.xml new file mode 100644 index 0000000..ed683b0 --- /dev/null +++ b/.run/StatApplication (local).run.xml @@ -0,0 +1,11 @@ + + + + \ No newline at end of file diff --git a/.run/StatApplication.run.xml b/.run/StatApplication.run.xml new file mode 100644 index 0000000..65d72ff --- /dev/null +++ b/.run/StatApplication.run.xml @@ -0,0 +1,11 @@ + + + + \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..3671fb5 --- /dev/null +++ b/README.md @@ -0,0 +1,57 @@ +# Комментарии — API (техническое задание) + +Краткое описание: набор REST-эндпоинтов для управления комментариями. Три уровня доступа: +- Admin — администраторские операции (удаление комментария по его идентификатору). +- Private — операции пользователя над собственными комментариями (создать, изменить, удалить, получить все свои комментарии). +- Public — публичный доступ (просмотр комментариев конкретного события). + +--- + +## AdminCommentController +Цель: операции управления всеми комментариями. + +- DELETE `/admin/comments/{commentId}` + - Описание: удалить комментарий (жёсткое удаление) по его идентификатору. + - Ограничения: без модерации комментариев. + - Права: только админ. + - Ответ: `204 No Content` при успехе. + +--- + +## PrivateCommentController +Цель: пользовательские операции над собственными комментариями. + +- POST `/users/{userId}/comments/{eventId}` + - Описание: создать новый комментарий от имени пользователя `userId`. + - Ограничения: длина комментария 5000 символов, проверка существования идентификаторов события и пользователя. + - Права: только аутентифицированный пользователь. + - Ответ: `201 Created` с созданным объектом. + +- PATCH `/users/{userId}/comments/{eventId}/{commentId}` + - Описание: обновить собственный комментарий. + - Ограничения: длина комментария 5000 символов, проверка существования идентификаторов комментария, события и пользователя. + - Права: только автор комментария. + - Ответ: обновлённый объект комментария. + +- DELETE `/users/{userId}/comments/{commentId}` + - Описание: удалить собственный комментарий (жёсткое удаление). + - Ограничения: проверка существования идентификаторов комментария и пользователя. + - Права: только автор комментария. + - Ответ: `204 No Content`. + +- GET `/users/{userId}/comments` + - Описание: получить список собственных комментариев. + - Ограничения: организовать пагинацию (по умолчанию по 10 комментариев на странице). + - Права: только аутентифицированный пользователь. + - Ответ: список комментариев. + +--- + +## PublicCommentController +Цель: публичный просмотр комментариев. + +- GET `/comments/event/{eventId}` + - Описание: получить публичный список комментариев к событию. + - Ограничения: организовать пагинацию (по умолчанию по 10 комментариев на странице). + - Доступ: публичный (без авторизации). + - Ответ: список публичных комментариев. \ No newline at end of file diff --git a/core/main-service/Dockerfile b/core/main-service/Dockerfile new file mode 100644 index 0000000..0ff1817 --- /dev/null +++ b/core/main-service/Dockerfile @@ -0,0 +1,5 @@ +FROM eclipse-temurin:21-jre-jammy +VOLUME /tmp +ARG JAR_FILE=target/*.jar +COPY ${JAR_FILE} app.jar +ENTRYPOINT ["sh", "-c", "java ${JAVA_OPTS} -jar /app.jar"] \ No newline at end of file diff --git a/core/main-service/pom.xml b/core/main-service/pom.xml new file mode 100644 index 0000000..1452bad --- /dev/null +++ b/core/main-service/pom.xml @@ -0,0 +1,106 @@ + + + 4.0.0 + + + ru.yandex.practicum + core + 0.0.1-SNAPSHOT + + + main-service + + + 21 + 21 + UTF-8 + + + + + org.springframework.boot + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-starter-actuator + + + + org.springframework.boot + spring-boot-starter-validation + + + + ru.yandex.practicum + stats-client + 0.0.1-SNAPSHOT + + + + ru.yandex.practicum + stats-dto + 0.0.1-SNAPSHOT + + + + org.springframework.cloud + spring-cloud-starter-netflix-eureka-client + + + + org.springframework.cloud + spring-cloud-starter-config + + + + org.springframework.cloud + spring-cloud-starter-openfeign + + + + org.postgresql + postgresql + + + + com.h2database + h2 + runtime + + + + org.projectlombok + lombok + provided + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + org.mapstruct + mapstruct + ${mapstruct.version} + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + org.apache.maven.plugins + maven-compiler-plugin + + + + + \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/yandex/practicum/MainApplication.java b/core/main-service/src/main/java/ru/yandex/practicum/MainApplication.java new file mode 100644 index 0000000..17b4ca6 --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/MainApplication.java @@ -0,0 +1,15 @@ +package ru.yandex.practicum; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import org.springframework.cloud.openfeign.EnableFeignClients; + +@ConfigurationPropertiesScan +@EnableFeignClients +@SpringBootApplication +public class MainApplication { + public static void main(String[] args) { + SpringApplication.run(MainApplication.class, args); + } +} diff --git a/core/main-service/src/main/java/ru/yandex/practicum/category/controller/AdminCategoryController.java b/core/main-service/src/main/java/ru/yandex/practicum/category/controller/AdminCategoryController.java new file mode 100644 index 0000000..d09cc16 --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/category/controller/AdminCategoryController.java @@ -0,0 +1,42 @@ +package ru.yandex.practicum.category.controller; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.Positive; +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.yandex.practicum.category.dto.CategoryDto; +import ru.yandex.practicum.category.dto.NewCategoryDto; +import ru.yandex.practicum.category.service.CategoryService; + +@Slf4j +@Validated +@RestController +@RequestMapping("/admin/categories") +@RequiredArgsConstructor +public class AdminCategoryController { + private final CategoryService categoryService; + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public CategoryDto createCategory(@RequestBody @Valid NewCategoryDto newCategoryDto) { + log.debug("Controller: createCategory data={}", newCategoryDto); + return categoryService.createCategory(newCategoryDto); + } + + @DeleteMapping("/{categoryId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void deleteCategory(@PathVariable @Positive Long categoryId) { + log.debug("Controller: deleteCategory categoryId={}", categoryId); + categoryService.deleteCategory(categoryId); + } + + @PatchMapping("/{categoryId}") + public CategoryDto updateCategory(@PathVariable @Positive Long categoryId, + @RequestBody @Valid NewCategoryDto newCategoryDto) { + log.debug("Controller: updateCategory categoryId={}, data={}", categoryId, newCategoryDto); + return categoryService.updateCategory(categoryId, newCategoryDto); + } +} diff --git a/core/main-service/src/main/java/ru/yandex/practicum/category/controller/PublicCategoryController.java b/core/main-service/src/main/java/ru/yandex/practicum/category/controller/PublicCategoryController.java new file mode 100644 index 0000000..39a6fc9 --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/category/controller/PublicCategoryController.java @@ -0,0 +1,38 @@ +package ru.yandex.practicum.category.controller; + +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import ru.yandex.practicum.category.dto.CategoryDto; +import ru.yandex.practicum.category.service.CategoryService; + +import java.util.List; + +@Slf4j +@Validated +@RestController +@RequestMapping("/categories") +@RequiredArgsConstructor +public class PublicCategoryController { + private final CategoryService categoryService; + + @GetMapping + public List getCategories( + @RequestParam(defaultValue = "0") @PositiveOrZero Integer from, + @RequestParam(defaultValue = "10") @Positive Integer size) { + Pageable pageable = PageRequest.of(from / size, size); + log.debug("Controller: getCategories from={}, size={}", from, size); + return categoryService.getCategories(pageable); + } + + @GetMapping("/{categoryId}") + public CategoryDto getCategoryById(@PathVariable @Positive Long categoryId) { + log.debug("Controller: getCategoryById categoryId={}", categoryId); + return categoryService.getCategoryById(categoryId); + } +} diff --git a/core/main-service/src/main/java/ru/yandex/practicum/category/dto/CategoryDto.java b/core/main-service/src/main/java/ru/yandex/practicum/category/dto/CategoryDto.java new file mode 100644 index 0000000..fdf9a17 --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/category/dto/CategoryDto.java @@ -0,0 +1,13 @@ +package ru.yandex.practicum.category.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record CategoryDto( + Long id, + + @NotBlank(message = "Название категории не может быть пустым") + @Size(min = 1, max = 50, message = "Название категории должно содержать от {min} до {max} символов") + String name +) { +} diff --git a/core/main-service/src/main/java/ru/yandex/practicum/category/dto/NewCategoryDto.java b/core/main-service/src/main/java/ru/yandex/practicum/category/dto/NewCategoryDto.java new file mode 100644 index 0000000..971c5cf --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/category/dto/NewCategoryDto.java @@ -0,0 +1,11 @@ +package ru.yandex.practicum.category.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record NewCategoryDto( + @NotBlank(message = "Название категории не может быть пустым") + @Size(min = 1, max = 50, message = "Название категории должно содержать от {min} до {max} символов") + String name +) { +} diff --git a/core/main-service/src/main/java/ru/yandex/practicum/category/mapper/CategoryMapper.java b/core/main-service/src/main/java/ru/yandex/practicum/category/mapper/CategoryMapper.java new file mode 100644 index 0000000..c62518c --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/category/mapper/CategoryMapper.java @@ -0,0 +1,22 @@ +package ru.yandex.practicum.category.mapper; + +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.MappingTarget; +import ru.yandex.practicum.category.dto.CategoryDto; +import ru.yandex.practicum.category.dto.NewCategoryDto; +import ru.yandex.practicum.category.model.Category; + +@Mapper(componentModel = "spring") +public interface CategoryMapper { + + @Mapping(target = "id", ignore = true) + Category toEntity(NewCategoryDto newCategoryDto); + + CategoryDto toDto(Category category); + + @Mapping(target = "id", ignore = true) + void updateCategoryFromDto(NewCategoryDto dto, @MappingTarget Category category); + + Category toEntityFromDto(CategoryDto dto); +} diff --git a/core/main-service/src/main/java/ru/yandex/practicum/category/model/Category.java b/core/main-service/src/main/java/ru/yandex/practicum/category/model/Category.java new file mode 100644 index 0000000..0d13352 --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/category/model/Category.java @@ -0,0 +1,23 @@ +package ru.yandex.practicum.category.model; + +import jakarta.persistence.*; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +@Entity +@Table(name = "categories") +@Getter +@Setter +@ToString +@EqualsAndHashCode(onlyExplicitlyIncluded = true) +public class Category { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @EqualsAndHashCode.Include + private Long id; + + @Column(name = "name", unique = true, nullable = false, length = 50) + private String name; +} diff --git a/core/main-service/src/main/java/ru/yandex/practicum/category/repository/CategoryRepository.java b/core/main-service/src/main/java/ru/yandex/practicum/category/repository/CategoryRepository.java new file mode 100644 index 0000000..6744789 --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/category/repository/CategoryRepository.java @@ -0,0 +1,20 @@ +package ru.yandex.practicum.category.repository; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import ru.yandex.practicum.category.model.Category; + +import java.util.List; +import java.util.Optional; + +public interface CategoryRepository extends JpaRepository { + + Optional findByName(@Param("name") String name); + + Optional findByNameAndIdNot(@Param("name") String name, @Param("id") Long id); + + @Query("SELECT c FROM Category c") + List findAllList(Pageable pageable); +} diff --git a/core/main-service/src/main/java/ru/yandex/practicum/category/service/CategoryService.java b/core/main-service/src/main/java/ru/yandex/practicum/category/service/CategoryService.java new file mode 100644 index 0000000..5e398ca --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/category/service/CategoryService.java @@ -0,0 +1,22 @@ +package ru.yandex.practicum.category.service; + +import org.springframework.data.domain.Pageable; +import ru.yandex.practicum.category.dto.CategoryDto; +import ru.yandex.practicum.category.dto.NewCategoryDto; +import ru.yandex.practicum.category.model.Category; + +import java.util.List; + +public interface CategoryService { + CategoryDto createCategory(NewCategoryDto newCategoryDto); + + void deleteCategory(Long categoryId); + + CategoryDto updateCategory(Long categoryId, NewCategoryDto newCategoryDto); + + List getCategories(Pageable pageable); + + CategoryDto getCategoryById(Long categoryId); + + Category getCategoryByIdOrThrow(Long categoryId); +} diff --git a/core/main-service/src/main/java/ru/yandex/practicum/category/service/CategoryServiceImpl.java b/core/main-service/src/main/java/ru/yandex/practicum/category/service/CategoryServiceImpl.java new file mode 100644 index 0000000..4aca28d --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/category/service/CategoryServiceImpl.java @@ -0,0 +1,106 @@ +package ru.yandex.practicum.category.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import ru.yandex.practicum.category.dto.CategoryDto; +import ru.yandex.practicum.category.dto.NewCategoryDto; +import ru.yandex.practicum.category.mapper.CategoryMapper; +import ru.yandex.practicum.category.model.Category; +import ru.yandex.practicum.category.repository.CategoryRepository; +import ru.yandex.practicum.event.repository.EventRepository; +import ru.yandex.practicum.exception.ConflictException; +import ru.yandex.practicum.exception.NotFoundException; + +import java.util.List; +import java.util.Optional; + +@Slf4j +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class CategoryServiceImpl implements CategoryService { + private final CategoryRepository categoryRepository; + private final CategoryMapper categoryMapper; + private final EventRepository eventRepository; + + @Override + @Transactional + public CategoryDto createCategory(NewCategoryDto newCategoryDto) { + checkCategoryNameUnique(newCategoryDto.name(), null); + + Category category = categoryMapper.toEntity(newCategoryDto); + + Category savedCategory = categoryRepository.save(category); + + log.info("Создана категория: {}", savedCategory); + return categoryMapper.toDto(savedCategory); + } + + @Override + @Transactional + public void deleteCategory(Long categoryId) { + Category category = getCategoryByIdOrThrow(categoryId); + + if (eventRepository.existsByCategoryId(categoryId)) { + throw new ConflictException("Невозможно удалить категорию с существующими событиями"); + } + + categoryRepository.delete(category); + log.info("Удалена категория с ID: {}", categoryId); + } + + @Override + @Transactional + public CategoryDto updateCategory(Long categoryId, NewCategoryDto newCategoryDto) { + Category category = getCategoryByIdOrThrow(categoryId); + + checkCategoryNameUnique(newCategoryDto.name(), categoryId); + + categoryMapper.updateCategoryFromDto(newCategoryDto, category); + Category updatedCategory = categoryRepository.save(category); + + log.info("Обновлена категория: {}", updatedCategory); + return categoryMapper.toDto(updatedCategory); + } + + @Override + public List getCategories(Pageable pageable) { + List categoriesPage = categoryRepository.findAllList(pageable); + + if (categoriesPage.isEmpty()) { + return List.of(); + } + + List result = categoriesPage.stream() + .map(categoryMapper::toDto) + .toList(); + + log.info("Найдено {} категорий", result.size()); + return result; + } + + @Override + public CategoryDto getCategoryById(Long categoryId) { + Category category = getCategoryByIdOrThrow(categoryId); + return categoryMapper.toDto(category); + } + + @Override + public Category getCategoryByIdOrThrow(Long categoryId) { + return categoryRepository.findById(categoryId) + .orElseThrow(() -> new NotFoundException("Категория с id " + categoryId + " не найдена")); + } + + private void checkCategoryNameUnique(String name, Long excludedId) { + Optional existingCategory = (excludedId == null) + ? categoryRepository.findByName(name) + : categoryRepository.findByNameAndIdNot(name, excludedId); + + existingCategory.ifPresent(category -> { + throw new ConflictException("Категория с названием '" + name + "' уже существует"); + }); + } +} diff --git a/core/main-service/src/main/java/ru/yandex/practicum/comment/controller/AdminCommentController.java b/core/main-service/src/main/java/ru/yandex/practicum/comment/controller/AdminCommentController.java new file mode 100644 index 0000000..cd5a919 --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/comment/controller/AdminCommentController.java @@ -0,0 +1,25 @@ +package ru.yandex.practicum.comment.controller; + +import jakarta.validation.constraints.Positive; +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.yandex.practicum.comment.service.CommentService; + +@Slf4j +@Validated +@RestController +@RequestMapping("/admin/comments") +@RequiredArgsConstructor +public class AdminCommentController { + private final CommentService commentService; + + @DeleteMapping("/{commentId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void deleteCommentAdmin(@PathVariable @Positive Long commentId) { + log.debug("Controller: deleteCommentAdmin commentId={}", commentId); + commentService.deleteCommentAdmin(commentId); + } +} diff --git a/core/main-service/src/main/java/ru/yandex/practicum/comment/controller/PrivateCommentController.java b/core/main-service/src/main/java/ru/yandex/practicum/comment/controller/PrivateCommentController.java new file mode 100644 index 0000000..8e68f7a --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/comment/controller/PrivateCommentController.java @@ -0,0 +1,65 @@ +package ru.yandex.practicum.comment.controller; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.HttpStatus; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import ru.yandex.practicum.comment.dto.CommentDto; +import ru.yandex.practicum.comment.dto.NewCommentDto; +import ru.yandex.practicum.comment.dto.UpdateCommentDto; +import ru.yandex.practicum.comment.service.CommentService; + +import java.util.List; + +@Slf4j +@Validated +@RestController +@RequestMapping("/users/{userId}/comments") +@RequiredArgsConstructor +public class PrivateCommentController { + private final CommentService commentService; + + @PostMapping("/{eventId}") + @ResponseStatus(HttpStatus.CREATED) + public CommentDto createComment(@PathVariable @Positive Long userId, + @PathVariable @Positive Long eventId, + @RequestBody @Valid NewCommentDto newCommentDto + ) { + log.debug("Controller: createComment userId={}, eventId={}, data={}", userId, eventId, newCommentDto); + return commentService.createComment(userId, eventId, newCommentDto); + } + + @PatchMapping("/{eventId}/{commentId}") + public CommentDto updateComment(@PathVariable @Positive Long userId, + @PathVariable @Positive Long eventId, + @PathVariable @Positive Long commentId, + @RequestBody @Valid UpdateCommentDto updateCommentDto + ) { + log.debug("Controller: updateComment userId={}, eventId={}, commentId={}, data={}", userId, eventId, commentId, + updateCommentDto); + return commentService.updateComment(userId, eventId, commentId, updateCommentDto); + } + + @DeleteMapping("/{commentId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void deleteComment(@PathVariable @Positive Long userId, + @PathVariable @Positive Long commentId + ) { + log.debug("Controller: deleteComment userId={}, commentId={}", userId, commentId); + commentService.deleteComment(userId, commentId); + } + + @GetMapping + public List getCommentsForUser(@PathVariable @Positive Long userId, + @RequestParam(defaultValue = "0") @PositiveOrZero Integer from, + @RequestParam(defaultValue = "10") @Positive Integer size + ) { + log.debug("Controller: getCommentsForUser userId={}, from={}, size={}", userId, from, size); + return commentService.getCommentsForUser(userId, PageRequest.of(from / size, size)); + } +} diff --git a/core/main-service/src/main/java/ru/yandex/practicum/comment/controller/PublicCommentController.java b/core/main-service/src/main/java/ru/yandex/practicum/comment/controller/PublicCommentController.java new file mode 100644 index 0000000..9561fba --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/comment/controller/PublicCommentController.java @@ -0,0 +1,31 @@ +package ru.yandex.practicum.comment.controller; + +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import ru.yandex.practicum.comment.dto.CommentDto; +import ru.yandex.practicum.comment.service.CommentService; + +import java.util.List; + +@Slf4j +@Validated +@RestController +@RequestMapping("/comments") +@RequiredArgsConstructor +public class PublicCommentController { + private final CommentService commentService; + + @GetMapping("/event/{eventId}") + public List getCommentsForEvent(@PathVariable @Positive Long eventId, + @RequestParam(defaultValue = "0") @PositiveOrZero Integer from, + @RequestParam(defaultValue = "10") @Positive Integer size + ) { + log.debug("Controller: getCommentsForEvent eventId={}, from={}, size={}", eventId, from, size); + return commentService.getCommentsForEvent(eventId, PageRequest.of(from / size, size)); + } +} diff --git a/core/main-service/src/main/java/ru/yandex/practicum/comment/dto/CommentDto.java b/core/main-service/src/main/java/ru/yandex/practicum/comment/dto/CommentDto.java new file mode 100644 index 0000000..4e8b862 --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/comment/dto/CommentDto.java @@ -0,0 +1,21 @@ +package ru.yandex.practicum.comment.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; + +import java.time.LocalDateTime; + +import static ru.yandex.practicum.constants.DateTimeConstants.DATE_TIME_PATTERN; + +public record CommentDto( + Long id, + + String text, + + Long userId, + + Long eventId, + + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = DATE_TIME_PATTERN) + LocalDateTime createdDate +) { +} diff --git a/core/main-service/src/main/java/ru/yandex/practicum/comment/dto/NewCommentDto.java b/core/main-service/src/main/java/ru/yandex/practicum/comment/dto/NewCommentDto.java new file mode 100644 index 0000000..805e2d3 --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/comment/dto/NewCommentDto.java @@ -0,0 +1,11 @@ +package ru.yandex.practicum.comment.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record NewCommentDto( + @NotBlank(message = "Комментарий не может быть пустым") + @Size(min = 1, max = 5000, message = "Комментарий должен содержать от {min} до {max} символов") + String text +) { +} diff --git a/core/main-service/src/main/java/ru/yandex/practicum/comment/dto/UpdateCommentDto.java b/core/main-service/src/main/java/ru/yandex/practicum/comment/dto/UpdateCommentDto.java new file mode 100644 index 0000000..da8f1db --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/comment/dto/UpdateCommentDto.java @@ -0,0 +1,11 @@ +package ru.yandex.practicum.comment.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record UpdateCommentDto( + @NotBlank(message = "Комментарий не может быть пустым") + @Size(min = 1, max = 5000, message = "Комментарий должен содержать от {min} до {max} символов") + String text +) { +} diff --git a/core/main-service/src/main/java/ru/yandex/practicum/comment/mapper/CommentMapper.java b/core/main-service/src/main/java/ru/yandex/practicum/comment/mapper/CommentMapper.java new file mode 100644 index 0000000..56a943a --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/comment/mapper/CommentMapper.java @@ -0,0 +1,26 @@ +package ru.yandex.practicum.comment.mapper; + +import org.mapstruct.*; +import ru.yandex.practicum.comment.dto.CommentDto; +import ru.yandex.practicum.comment.dto.NewCommentDto; +import ru.yandex.practicum.comment.dto.UpdateCommentDto; +import ru.yandex.practicum.comment.model.Comment; +import ru.yandex.practicum.event.model.Event; +import ru.yandex.practicum.user.model.User; + +@Mapper(componentModel = "spring") +public interface CommentMapper { + @Mapping(target = "id", ignore = true) + @Mapping(target = "createdDate", ignore = true) + Comment toComment(NewCommentDto newCommentDto, User user, Event event); + + @Mapping(target = "userId", source = "user.id") + @Mapping(target = "eventId", source = "event.id") + CommentDto toDto(Comment comment); + + @Mapping(target = "id", ignore = true) + @Mapping(target = "user", ignore = true) + @Mapping(target = "event", ignore = true) + @Mapping(target = "createdDate", ignore = true) + void updateCommentFromDto(UpdateCommentDto dto, @MappingTarget Comment comment); +} diff --git a/core/main-service/src/main/java/ru/yandex/practicum/comment/model/Comment.java b/core/main-service/src/main/java/ru/yandex/practicum/comment/model/Comment.java new file mode 100644 index 0000000..e3ee4d7 --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/comment/model/Comment.java @@ -0,0 +1,38 @@ +package ru.yandex.practicum.comment.model; + +import jakarta.persistence.*; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; +import org.hibernate.annotations.CreationTimestamp; +import ru.yandex.practicum.event.model.Event; +import ru.yandex.practicum.user.model.User; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "comments") +@Getter +@Setter +@EqualsAndHashCode(onlyExplicitlyIncluded = true) +public class Comment { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @EqualsAndHashCode.Include + private Long id; + + @Column(nullable = false, length = 5000) + private String text; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "event_id", nullable = false) + private Event event; + + @Column(name = "created_date", nullable = false) + @CreationTimestamp + private LocalDateTime createdDate; +} diff --git a/core/main-service/src/main/java/ru/yandex/practicum/comment/repository/CommentRepository.java b/core/main-service/src/main/java/ru/yandex/practicum/comment/repository/CommentRepository.java new file mode 100644 index 0000000..8b8cadd --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/comment/repository/CommentRepository.java @@ -0,0 +1,47 @@ +package ru.yandex.practicum.comment.repository; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import ru.yandex.practicum.comment.model.Comment; + +import java.util.List; +import java.util.Optional; + +public interface CommentRepository extends JpaRepository { + + @Query(""" + SELECT c FROM Comment c + LEFT JOIN FETCH c.user + LEFT JOIN FETCH c.event + WHERE c.id = :id AND c.user.id = :userId AND c.event.id = :eventId""" + ) + Optional findByIdAndUserIdAndEventId(@Param("id") Long id, @Param("userId") Long userId, @Param("eventId") Long eventId); + + @Query(""" + SELECT c FROM Comment c + LEFT JOIN FETCH c.user + LEFT JOIN FETCH c.event + WHERE c.id = :id AND c.user.id = :userId""" + ) + Optional findByIdAndUserId(@Param("id") Long id, @Param("userId") Long userId); + + @Query(""" + SELECT c FROM Comment c + LEFT JOIN FETCH c.user + LEFT JOIN FETCH c.event + WHERE c.event.id = :eventId + ORDER BY c.createdDate DESC""" + ) + List findByEventId(@Param("eventId") Long eventId, Pageable pageable); + + @Query(""" + SELECT c FROM Comment c + LEFT JOIN FETCH c.user + LEFT JOIN FETCH c.event + WHERE c.user.id = :userId + ORDER BY c.createdDate DESC""" + ) + List findByUserId(@Param("userId") Long userId, Pageable pageable); +} diff --git a/core/main-service/src/main/java/ru/yandex/practicum/comment/service/CommentService.java b/core/main-service/src/main/java/ru/yandex/practicum/comment/service/CommentService.java new file mode 100644 index 0000000..10468b1 --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/comment/service/CommentService.java @@ -0,0 +1,22 @@ +package ru.yandex.practicum.comment.service; + +import org.springframework.data.domain.Pageable; +import ru.yandex.practicum.comment.dto.CommentDto; +import ru.yandex.practicum.comment.dto.NewCommentDto; +import ru.yandex.practicum.comment.dto.UpdateCommentDto; + +import java.util.List; + +public interface CommentService { + CommentDto createComment(Long userId, Long eventId, NewCommentDto newCommentDto); + + CommentDto updateComment(Long userId, Long eventId, Long commentId, UpdateCommentDto updateCommentDto); + + void deleteComment(Long userId, Long commentId); + + void deleteCommentAdmin(Long commentId); + + List getCommentsForEvent(Long eventId, Pageable pageable); + + List getCommentsForUser(Long userId, Pageable pageable); +} diff --git a/core/main-service/src/main/java/ru/yandex/practicum/comment/service/CommentServiceImpl.java b/core/main-service/src/main/java/ru/yandex/practicum/comment/service/CommentServiceImpl.java new file mode 100644 index 0000000..7fa5eff --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/comment/service/CommentServiceImpl.java @@ -0,0 +1,106 @@ +package ru.yandex.practicum.comment.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import ru.yandex.practicum.comment.dto.CommentDto; +import ru.yandex.practicum.comment.dto.NewCommentDto; +import ru.yandex.practicum.comment.dto.UpdateCommentDto; +import ru.yandex.practicum.comment.mapper.CommentMapper; +import ru.yandex.practicum.comment.model.Comment; +import ru.yandex.practicum.comment.repository.CommentRepository; +import ru.yandex.practicum.event.model.Event; +import ru.yandex.practicum.event.model.EventState; +import ru.yandex.practicum.event.service.EventService; +import ru.yandex.practicum.exception.ConflictException; +import ru.yandex.practicum.exception.NotFoundException; +import ru.yandex.practicum.user.model.User; +import ru.yandex.practicum.user.service.UserService; + +import java.util.List; + +@Slf4j +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class CommentServiceImpl implements CommentService { + private final CommentRepository commentRepository; + private final EventService eventService; + private final UserService userService; + private final CommentMapper commentMapper; + + @Override + @Transactional + public CommentDto createComment(Long userId, Long eventId, NewCommentDto newCommentDto) { + User user = userService.getUserByIdOrThrow(userId); + Event event = eventService.getEventOrThrow(eventId); + + if (EventState.PUBLISHED != event.getState()) + throw new ConflictException("Нельзя комментировать неопубликованное событие"); + + Comment comment = commentMapper.toComment(newCommentDto, user, event); + + log.info("Создан новый комментарий: authorId={}, eventId={}, newCommentDto={}", userId, eventId, newCommentDto); + return commentMapper.toDto(commentRepository.save(comment)); + } + + @Override + @Transactional + public CommentDto updateComment(Long userId, Long eventId, Long commentId, UpdateCommentDto updateCommentDto) { + Comment comment = commentRepository.findByIdAndUserIdAndEventId(commentId, userId, eventId) + .orElseThrow(() -> new NotFoundException("Комментарий с id=" + commentId + + " от пользователя с id=" + userId + + " к событию с id=" + eventId + " не найден")); + + commentMapper.updateCommentFromDto(updateCommentDto, comment); + + log.info("Комментарий обновлен: commentId={}, userId={}, eventId={}, updateCommentDto={}", + commentId, userId, eventId, updateCommentDto); + return commentMapper.toDto(commentRepository.save(comment)); + } + + @Override + @Transactional + public void deleteComment(Long userId, Long commentId) { + commentRepository.findByIdAndUserId(commentId, userId) + .orElseThrow(() -> new NotFoundException("Комментарий с id=" + commentId + + " от пользователя с id=" + userId + " не найден")); + + log.info("Комментарий удален: commentId={}, userId={}", commentId, userId); + commentRepository.deleteById(commentId); + } + + @Override + @Transactional + public void deleteCommentAdmin(Long commentId) { + commentRepository.findById(commentId) + .orElseThrow(() -> new NotFoundException("Комментарий с id=" + commentId + " не найден")); + + log.info("Комментарий удален администратором: commentId={}", commentId); + commentRepository.deleteById(commentId); + } + + @Override + public List getCommentsForEvent(Long eventId, Pageable pageable) { + eventService.getEventOrThrow(eventId); + + log.info("Получен список комментариев для события: eventId={}", eventId); + return commentRepository.findByEventId(eventId, pageable) + .stream() + .map(commentMapper::toDto) + .toList(); + } + + @Override + public List getCommentsForUser(Long userId, Pageable pageable) { + userService.getUserByIdOrThrow(userId); + + log.info("Получен список комментариев для пользователя: userId={}", userId); + return commentRepository.findByUserId(userId, pageable) + .stream() + .map(commentMapper::toDto) + .toList(); + } +} diff --git a/core/main-service/src/main/java/ru/yandex/practicum/compilation/controller/AdminCompilationController.java b/core/main-service/src/main/java/ru/yandex/practicum/compilation/controller/AdminCompilationController.java new file mode 100644 index 0000000..4a50145 --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/compilation/controller/AdminCompilationController.java @@ -0,0 +1,43 @@ +package ru.yandex.practicum.compilation.controller; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.Positive; +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.yandex.practicum.compilation.dto.CompilationDto; +import ru.yandex.practicum.compilation.dto.NewCompilationDto; +import ru.yandex.practicum.compilation.dto.UpdateCompilationRequest; +import ru.yandex.practicum.compilation.service.CompilationService; + +@Slf4j +@Validated +@RestController +@RequestMapping("/admin/compilations") +@RequiredArgsConstructor +public class AdminCompilationController { + private final CompilationService compilationService; + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public CompilationDto createCompilation(@RequestBody @Valid NewCompilationDto request) { + log.debug("Controller: createCompilation data={}", request); + return compilationService.createCompilation(request); + } + + @DeleteMapping("/{compId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void deleteCompilation(@PathVariable @Positive Long compId) { + log.debug("Controller: deleteCompilation compId={}", compId); + compilationService.deleteCompilation(compId); + } + + @PatchMapping("/{compId}") + public CompilationDto updateCompilation(@PathVariable @Positive Long compId, + @RequestBody @Valid UpdateCompilationRequest request) { + log.debug("Controller: updateCompilation compId={}, data={}", compId, request); + return compilationService.updateCompilation(compId, request); + } +} diff --git a/core/main-service/src/main/java/ru/yandex/practicum/compilation/controller/PublicCompilationController.java b/core/main-service/src/main/java/ru/yandex/practicum/compilation/controller/PublicCompilationController.java new file mode 100644 index 0000000..c902fae --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/compilation/controller/PublicCompilationController.java @@ -0,0 +1,38 @@ +package ru.yandex.practicum.compilation.controller; + +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import ru.yandex.practicum.compilation.dto.CompilationDto; +import ru.yandex.practicum.compilation.service.CompilationService; + +import java.util.List; + +@Slf4j +@Validated +@RestController +@RequestMapping("/compilations") +@RequiredArgsConstructor +public class PublicCompilationController { + private final CompilationService compilationService; + + @GetMapping + public List getCompilations(@RequestParam(required = false) Boolean pinned, + @RequestParam(defaultValue = "0") @PositiveOrZero Integer from, + @RequestParam(defaultValue = "10") @Positive Integer size) { + Pageable pageable = PageRequest.of(from / size, size); + log.debug("Controller: getCompilations pinned={}, from={}, size={}", pinned, from, size); + return compilationService.getCompilations(pinned, pageable); + } + + @GetMapping("/{compId}") + public CompilationDto getCompilation(@PathVariable @Positive Long compId) { + log.debug("Controller: getCompilation compId={}", compId); + return compilationService.getCompilationById(compId); + } +} diff --git a/core/main-service/src/main/java/ru/yandex/practicum/compilation/dto/CompilationDto.java b/core/main-service/src/main/java/ru/yandex/practicum/compilation/dto/CompilationDto.java new file mode 100644 index 0000000..a978e5a --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/compilation/dto/CompilationDto.java @@ -0,0 +1,13 @@ +package ru.yandex.practicum.compilation.dto; + +import ru.yandex.practicum.event.dto.EventShortDto; + +import java.util.Set; + +public record CompilationDto( + Long id, + Set events, + boolean pinned, + String title +) { +} diff --git a/core/main-service/src/main/java/ru/yandex/practicum/compilation/dto/NewCompilationDto.java b/core/main-service/src/main/java/ru/yandex/practicum/compilation/dto/NewCompilationDto.java new file mode 100644 index 0000000..00b509d --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/compilation/dto/NewCompilationDto.java @@ -0,0 +1,17 @@ +package ru.yandex.practicum.compilation.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +import java.util.Set; + +public record NewCompilationDto( + Set events, + + boolean pinned, + + @NotBlank(message = "Заголовок подборки не может быть пустым") + @Size(min = 1, max = 50, message = "Заголовок должен содержать от {min} до {max} символов") + String title +) { +} diff --git a/core/main-service/src/main/java/ru/yandex/practicum/compilation/dto/UpdateCompilationRequest.java b/core/main-service/src/main/java/ru/yandex/practicum/compilation/dto/UpdateCompilationRequest.java new file mode 100644 index 0000000..5aeeef1 --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/compilation/dto/UpdateCompilationRequest.java @@ -0,0 +1,15 @@ +package ru.yandex.practicum.compilation.dto; + +import jakarta.validation.constraints.Size; + +import java.util.Set; + +public record UpdateCompilationRequest( + Set events, + + Boolean pinned, + + @Size(min = 1, max = 50, message = "Заголовок должен содержать от {min} до {max} символов") + String title +) { +} diff --git a/core/main-service/src/main/java/ru/yandex/practicum/compilation/mapper/CompilationMapper.java b/core/main-service/src/main/java/ru/yandex/practicum/compilation/mapper/CompilationMapper.java new file mode 100644 index 0000000..64b6e42 --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/compilation/mapper/CompilationMapper.java @@ -0,0 +1,24 @@ +package ru.yandex.practicum.compilation.mapper; + +import org.mapstruct.*; +import ru.yandex.practicum.compilation.dto.CompilationDto; +import ru.yandex.practicum.compilation.dto.NewCompilationDto; +import ru.yandex.practicum.compilation.dto.UpdateCompilationRequest; +import ru.yandex.practicum.compilation.model.Compilation; +import ru.yandex.practicum.event.mapper.EventMapper; + +@Mapper(componentModel = "spring", uses = EventMapper.class) +public interface CompilationMapper { + + @Mapping(target = "id", ignore = true) + @Mapping(target = "events", ignore = true) + Compilation toEntity(NewCompilationDto request); + + @Mapping(target = "events", qualifiedByName = "toEventShortWithoutStats") + CompilationDto toDto(Compilation compilation); + + @Mapping(target = "id", ignore = true) + @Mapping(target = "events", ignore = true) + @BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE) + void updateCompilationFromRequest(UpdateCompilationRequest request, @MappingTarget Compilation compilation); +} diff --git a/core/main-service/src/main/java/ru/yandex/practicum/compilation/model/Compilation.java b/core/main-service/src/main/java/ru/yandex/practicum/compilation/model/Compilation.java new file mode 100644 index 0000000..1e50a3f --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/compilation/model/Compilation.java @@ -0,0 +1,35 @@ +package ru.yandex.practicum.compilation.model; + +import jakarta.persistence.*; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; +import ru.yandex.practicum.event.model.Event; + +import java.util.Set; + +@Entity +@Table(name = "compilations") +@Getter +@Setter +@EqualsAndHashCode(onlyExplicitlyIncluded = true) +public class Compilation { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @EqualsAndHashCode.Include + private Long id; + + @Column(nullable = false, length = 50) + private String title; + + @Column(nullable = false) + private boolean pinned; + + @ManyToMany(fetch = FetchType.LAZY) + @JoinTable( + name = "compilation_events", + joinColumns = @JoinColumn(name = "compilation_id"), + inverseJoinColumns = @JoinColumn(name = "event_id") + ) + private Set events; +} diff --git a/core/main-service/src/main/java/ru/yandex/practicum/compilation/repository/CompilationRepository.java b/core/main-service/src/main/java/ru/yandex/practicum/compilation/repository/CompilationRepository.java new file mode 100644 index 0000000..ef90d2c --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/compilation/repository/CompilationRepository.java @@ -0,0 +1,37 @@ +package ru.yandex.practicum.compilation.repository; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import ru.yandex.practicum.compilation.model.Compilation; + +import java.util.List; +import java.util.Optional; + +public interface CompilationRepository extends JpaRepository { + + @Query(""" + SELECT c FROM Compilation c + LEFT JOIN FETCH c.events e + LEFT JOIN FETCH e.category + LEFT JOIN FETCH e.initiator + WHERE c.id = :id""") + Optional findByIdWithEvents(@Param("id") Long id); + + boolean existsByTitle(String title); + + @Query(""" + SELECT c.id FROM Compilation c + WHERE (:pinned IS NULL OR c.pinned = :pinned) + ORDER BY c.id ASC""") + List findIdsByPinned(@Param("pinned") Boolean pinned, Pageable pageable); + + @Query(""" + SELECT DISTINCT c FROM Compilation c + LEFT JOIN FETCH c.events e + LEFT JOIN FETCH e.category + LEFT JOIN FETCH e.initiator + WHERE c.id IN :ids""") + List findAllByIdInWithEvents(@Param("ids") List ids); +} diff --git a/core/main-service/src/main/java/ru/yandex/practicum/compilation/service/CompilationService.java b/core/main-service/src/main/java/ru/yandex/practicum/compilation/service/CompilationService.java new file mode 100644 index 0000000..103ff8a --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/compilation/service/CompilationService.java @@ -0,0 +1,20 @@ +package ru.yandex.practicum.compilation.service; + +import org.springframework.data.domain.Pageable; +import ru.yandex.practicum.compilation.dto.CompilationDto; +import ru.yandex.practicum.compilation.dto.NewCompilationDto; +import ru.yandex.practicum.compilation.dto.UpdateCompilationRequest; + +import java.util.List; + +public interface CompilationService { + CompilationDto createCompilation(NewCompilationDto request); + + void deleteCompilation(Long compId); + + CompilationDto updateCompilation(Long compId, UpdateCompilationRequest request); + + List getCompilations(Boolean pinned, Pageable pageable); + + CompilationDto getCompilationById(Long compId); +} diff --git a/core/main-service/src/main/java/ru/yandex/practicum/compilation/service/CompilationServiceImpl.java b/core/main-service/src/main/java/ru/yandex/practicum/compilation/service/CompilationServiceImpl.java new file mode 100644 index 0000000..4364de1 --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/compilation/service/CompilationServiceImpl.java @@ -0,0 +1,137 @@ +package ru.yandex.practicum.compilation.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import ru.yandex.practicum.compilation.dto.CompilationDto; +import ru.yandex.practicum.compilation.dto.NewCompilationDto; +import ru.yandex.practicum.compilation.dto.UpdateCompilationRequest; +import ru.yandex.practicum.compilation.mapper.CompilationMapper; +import ru.yandex.practicum.compilation.model.Compilation; +import ru.yandex.practicum.compilation.repository.CompilationRepository; +import ru.yandex.practicum.event.model.Event; +import ru.yandex.practicum.event.repository.EventRepository; +import ru.yandex.practicum.exception.ConflictException; +import ru.yandex.practicum.exception.NotFoundException; + +import java.util.*; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CompilationServiceImpl implements CompilationService { + private final CompilationRepository compilationRepository; + private final EventRepository eventRepository; + private final CompilationMapper compilationMapper; + + @Override + @Transactional + public CompilationDto createCompilation(NewCompilationDto request) { + if (compilationRepository.existsByTitle(request.title())) + throw new ConflictException("Подборка с таким названием (" + request.title() + ") уже существует"); + + Compilation compilation = compilationMapper.toEntity(request); + + if (request.events() != null && !request.events().isEmpty()) { + List events = eventRepository.findAllByEventIds(new ArrayList<>(request.events())); + + if (events.size() != request.events().size()) throw new NotFoundException("Не все события найдены"); + compilation.setEvents(new HashSet<>(events)); + + } else compilation.setEvents(new HashSet<>()); + Compilation savedCompilation = compilationRepository.save(compilation); + + log.info("Создана подборка: {}", request); + return compilationMapper.toDto(savedCompilation); + } + + @Override + @Transactional + public void deleteCompilation(Long compId) { + if (!compilationRepository.existsById(compId)) + throw new NotFoundException("Подборка с идентификатором " + compId + " не найдена"); + + log.info("Удалена подборка с id={}", compId); + compilationRepository.deleteById(compId); + } + + @Override + @Transactional + public CompilationDto updateCompilation(Long compId, UpdateCompilationRequest request) { + Compilation compilation = getCompilationOrThrow(compId); + + if (request.title() != null + && !request.title().equals(compilation.getTitle()) + && compilationRepository.existsByTitle(request.title())) { + throw new ConflictException("Подборка с названием \"" + request.title() + "\" уже существует"); + } + + if (request.events() != null) { + if (request.events().isEmpty()) { + compilation.setEvents(new HashSet<>()); + } else { + List events = eventRepository.findAllByEventIds(new ArrayList<>(request.events())); + if (events.size() != request.events().size()) { + throw new NotFoundException("Некоторые события не найдены"); + } + compilation.setEvents(new HashSet<>(events)); + } + } + + compilationMapper.updateCompilationFromRequest(request, compilation); + + log.info("Обновлена подборка с id={}", compId); + return compilationMapper.toDto(compilationRepository.save(compilation)); + } + + @Override + public List getCompilations(Boolean pinned, Pageable pageable) { + Pageable sortedPageable = PageRequest.of( + pageable.getPageNumber(), + pageable.getPageSize(), + Sort.by("id").ascending() + ); + + List ids = compilationRepository.findIdsByPinned(pinned, sortedPageable); + + if (ids == null || ids.isEmpty()) { + log.info("Список подборок пуст pinned={}, pageable={}", pinned, pageable); + return List.of(); + } + + List compilationsWithEvents = compilationRepository.findAllByIdInWithEvents(ids); + + Map byId = compilationsWithEvents.stream() + .collect(Collectors.toMap(Compilation::getId, Function.identity())); + + List ordered = ids.stream() + .map(byId::get) + .filter(Objects::nonNull) + .toList(); + + log.info("Получен список подборок pinned={}, pageable={}", pinned, pageable); + return ordered.stream() + .map(compilationMapper::toDto) + .toList(); + } + + @Override + public CompilationDto getCompilationById(Long compId) { + Compilation compilation = getCompilationOrThrow(compId); + + log.info("Получена подборка с id={}", compId); + return compilationMapper.toDto(compilation); + } + + private Compilation getCompilationOrThrow(Long compId) { + return compilationRepository.findByIdWithEvents(compId) + .orElseThrow(() -> new NotFoundException("Подборка с идентификатором " + compId + " не найдена")); + } +} diff --git a/core/main-service/src/main/java/ru/yandex/practicum/constants/DateTimeConstants.java b/core/main-service/src/main/java/ru/yandex/practicum/constants/DateTimeConstants.java new file mode 100644 index 0000000..10d19ff --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/constants/DateTimeConstants.java @@ -0,0 +1,9 @@ +package ru.yandex.practicum.constants; + +public final class DateTimeConstants { + + private DateTimeConstants() { + } + + public static final String DATE_TIME_PATTERN = "yyyy-MM-dd HH:mm:ss"; +} diff --git a/core/main-service/src/main/java/ru/yandex/practicum/event/controller/AdminEventController.java b/core/main-service/src/main/java/ru/yandex/practicum/event/controller/AdminEventController.java new file mode 100644 index 0000000..92f3945 --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/event/controller/AdminEventController.java @@ -0,0 +1,41 @@ +package ru.yandex.practicum.event.controller; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.Positive; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import ru.yandex.practicum.event.dto.EventFullDto; +import ru.yandex.practicum.event.dto.SearchEventAdminRequest; +import ru.yandex.practicum.event.dto.UpdateEventAdminRequest; +import ru.yandex.practicum.event.service.EventService; + +import java.util.List; + +@Slf4j +@Validated +@RestController +@RequiredArgsConstructor +@RequestMapping("/admin/events") +public class AdminEventController { + private final EventService eventService; + + @GetMapping + public List getEventsAdmin(@ModelAttribute @Valid SearchEventAdminRequest request) { + int size = (request.size() != null && request.size() > 0) ? request.size() : 10; + int from = request.from() != null ? request.from() : 0; + PageRequest pageRequest = PageRequest.of(from / size, size); + log.debug("Controller: getEventAdmin filters={}", request); + return eventService.getEventsAdmin(request, pageRequest); + } + + @PatchMapping("/{eventId}") + public EventFullDto updateEventAdmin(@PathVariable @Positive Long eventId, + @RequestBody @Valid UpdateEventAdminRequest request + ) { + log.debug("Controller: updateEventAdmin eventId={}, data={}", eventId, request); + return eventService.updateEventAdmin(eventId, request); + } +} diff --git a/core/main-service/src/main/java/ru/yandex/practicum/event/controller/PrivateEventController.java b/core/main-service/src/main/java/ru/yandex/practicum/event/controller/PrivateEventController.java new file mode 100644 index 0000000..f089a95 --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/event/controller/PrivateEventController.java @@ -0,0 +1,88 @@ +package ru.yandex.practicum.event.controller; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpStatus; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import ru.yandex.practicum.event.dto.EventFullDto; +import ru.yandex.practicum.event.dto.EventShortDto; +import ru.yandex.practicum.event.dto.NewEventDto; +import ru.yandex.practicum.event.dto.UpdateEventUserRequest; +import ru.yandex.practicum.event.service.EventService; +import ru.yandex.practicum.request.dto.EventRequestStatusUpdateRequest; +import ru.yandex.practicum.request.dto.EventRequestStatusUpdateResult; +import ru.yandex.practicum.request.dto.ParticipationRequestDto; +import ru.yandex.practicum.request.service.RequestService; + +import java.util.List; + +@Slf4j +@Validated +@RestController +@RequestMapping("/users/{userId}/events") +@RequiredArgsConstructor +public class PrivateEventController { + private final EventService eventService; + private final RequestService requestService; + + @GetMapping + public List getEvents(@PathVariable("userId") @Positive Long userId, + @RequestParam(defaultValue = "0") @PositiveOrZero Integer from, + @RequestParam(defaultValue = "10") @Positive Integer size + ) { + Pageable pageable = PageRequest.of(from / size, size); + log.debug("Controller: getEvents userId={}, pageable={}", userId, pageable); + return eventService.getEvents(userId, pageable); + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public EventFullDto createEvent(@PathVariable("userId") @Positive Long userId, + @RequestBody @Valid NewEventDto newEventDto + ) { + log.debug("Controller: createEvent userId={}, data={}", userId, newEventDto); + return eventService.createEvent(userId, newEventDto); + } + + @GetMapping("/{eventId}") + public EventFullDto getEvent(@PathVariable("userId") @Positive Long userId, + @PathVariable("eventId") @Positive Long eventId, + HttpServletRequest request + ) { + log.debug("Controller: getEvent userId={}, eventId={}", userId, eventId); + return eventService.getEvent(userId, eventId, request.getRemoteAddr()); + } + + @PatchMapping("/{eventId}") + public EventFullDto updateEvent(@PathVariable("userId") @Positive Long userId, + @PathVariable("eventId") @Positive Long eventId, + @RequestBody @Valid UpdateEventUserRequest request + ) { + log.debug("Controller: updateEvent userId={}, eventId={}, data={}", userId, eventId, request); + return eventService.updateEvent(userId, eventId, request); + } + + @GetMapping("/{eventId}/requests") + public List getRequestsByEvent(@PathVariable("userId") @Positive Long userId, + @PathVariable("eventId") @Positive Long eventId + ) { + log.debug("Controller: getRequestsByEvent userId={}, eventId={}", userId, eventId); + return requestService.getRequestsByEvent(userId, eventId); + } + + @PatchMapping("/{eventId}/requests") + public EventRequestStatusUpdateResult updateRequestStatus(@PathVariable("userId") @Positive Long userId, + @PathVariable("eventId") @Positive Long eventId, + @RequestBody EventRequestStatusUpdateRequest request + ) { + log.debug("Controller: updateRequestStatus userId={}, eventId={}, data={}", userId, eventId, request); + return requestService.updateRequestStatus(userId, eventId, request); + } +} diff --git a/core/main-service/src/main/java/ru/yandex/practicum/event/controller/PublicEventController.java b/core/main-service/src/main/java/ru/yandex/practicum/event/controller/PublicEventController.java new file mode 100644 index 0000000..dcbec45 --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/event/controller/PublicEventController.java @@ -0,0 +1,43 @@ +package ru.yandex.practicum.event.controller; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Positive; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import ru.yandex.practicum.event.dto.EventFullDto; +import ru.yandex.practicum.event.dto.EventShortDto; +import ru.yandex.practicum.event.dto.SearchEventPublicRequest; +import ru.yandex.practicum.event.service.EventService; + +import java.util.List; + +@Slf4j +@Validated +@RestController +@RequestMapping("/events") +@RequiredArgsConstructor +public class PublicEventController { + private final EventService eventService; + + @GetMapping + public List getEventsPublic(@ModelAttribute @Valid SearchEventPublicRequest request, + HttpServletRequest httpRequest + ) { + int size = (request.size() != null && request.size() > 0) ? request.size() : 10; + int from = request.from() != null ? request.from() : 0; + PageRequest pageRequest = PageRequest.of(from / size, size); + log.debug("Controller: getEventsPublic filters={}", request); + return eventService.getEventsPublic(request, pageRequest, httpRequest.getRemoteAddr()); + } + + @GetMapping("/{id}") + public EventFullDto getEventByIdPublic(@PathVariable("id") @Positive Long eventId, + HttpServletRequest httpRequest) { + log.debug("Controller: getEventByIdPublic eventId={}", eventId); + return eventService.getEventByIdPublic(eventId, httpRequest.getRemoteAddr()); + } +} diff --git a/core/main-service/src/main/java/ru/yandex/practicum/event/dto/EventFullDto.java b/core/main-service/src/main/java/ru/yandex/practicum/event/dto/EventFullDto.java new file mode 100644 index 0000000..a7331a5 --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/event/dto/EventFullDto.java @@ -0,0 +1,48 @@ +package ru.yandex.practicum.event.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import ru.yandex.practicum.category.dto.CategoryDto; +import ru.yandex.practicum.event.model.EventState; +import ru.yandex.practicum.user.dto.UserShortDto; + +import java.time.LocalDateTime; + +import static ru.yandex.practicum.constants.DateTimeConstants.DATE_TIME_PATTERN; + +public record EventFullDto( + Long id, + + String annotation, + + CategoryDto category, + + Long confirmedRequests, + + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = DATE_TIME_PATTERN) + LocalDateTime createdOn, + + String description, + + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = DATE_TIME_PATTERN) + LocalDateTime eventDate, + + UserShortDto initiator, + + LocationDto location, + + Boolean paid, + + Integer participantLimit, + + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = DATE_TIME_PATTERN) + LocalDateTime publishedOn, + + Boolean requestModeration, + + EventState state, + + String title, + + Long views +) { +} diff --git a/core/main-service/src/main/java/ru/yandex/practicum/event/dto/EventShortDto.java b/core/main-service/src/main/java/ru/yandex/practicum/event/dto/EventShortDto.java new file mode 100644 index 0000000..c70ff6f --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/event/dto/EventShortDto.java @@ -0,0 +1,31 @@ +package ru.yandex.practicum.event.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import ru.yandex.practicum.category.dto.CategoryDto; +import ru.yandex.practicum.user.dto.UserShortDto; + +import java.time.LocalDateTime; + +import static ru.yandex.practicum.constants.DateTimeConstants.DATE_TIME_PATTERN; + +public record EventShortDto( + Long id, + + String annotation, + + CategoryDto category, + + Long confirmedRequests, + + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = DATE_TIME_PATTERN) + LocalDateTime eventDate, + + UserShortDto initiator, + + boolean paid, + + String title, + + Long views +) { +} diff --git a/core/main-service/src/main/java/ru/yandex/practicum/event/dto/EventStatistics.java b/core/main-service/src/main/java/ru/yandex/practicum/event/dto/EventStatistics.java new file mode 100644 index 0000000..6ee33b0 --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/event/dto/EventStatistics.java @@ -0,0 +1,9 @@ +package ru.yandex.practicum.event.dto; + +import java.util.Map; + +public record EventStatistics( + Map confirmedRequests, + Map views +) { +} diff --git a/core/main-service/src/main/java/ru/yandex/practicum/event/dto/EventWithCountConfirmedRequests.java b/core/main-service/src/main/java/ru/yandex/practicum/event/dto/EventWithCountConfirmedRequests.java new file mode 100644 index 0000000..1480e23 --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/event/dto/EventWithCountConfirmedRequests.java @@ -0,0 +1,16 @@ +package ru.yandex.practicum.event.dto; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class EventWithCountConfirmedRequests { + private Long eventId; + private int countConfirmedRequests; + + public EventWithCountConfirmedRequests(Long eventId, Long countConfirmedRequestsLong) { + this.eventId = eventId; + this.countConfirmedRequests = (countConfirmedRequestsLong == null) ? 0 : countConfirmedRequestsLong.intValue(); + } +} diff --git a/core/main-service/src/main/java/ru/yandex/practicum/event/dto/LocationDto.java b/core/main-service/src/main/java/ru/yandex/practicum/event/dto/LocationDto.java new file mode 100644 index 0000000..e6cfcd1 --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/event/dto/LocationDto.java @@ -0,0 +1,12 @@ +package ru.yandex.practicum.event.dto; + +import jakarta.validation.constraints.NotNull; + +public record LocationDto( + @NotNull(message = "Широта не может быть null") + Float lat, + + @NotNull(message = "Долгота не может быть null") + Float lon +) { +} diff --git a/core/main-service/src/main/java/ru/yandex/practicum/event/dto/NewEventDto.java b/core/main-service/src/main/java/ru/yandex/practicum/event/dto/NewEventDto.java new file mode 100644 index 0000000..c7563b1 --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/event/dto/NewEventDto.java @@ -0,0 +1,52 @@ +package ru.yandex.practicum.event.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import jakarta.validation.constraints.*; + +import java.time.LocalDateTime; + +import static ru.yandex.practicum.constants.DateTimeConstants.DATE_TIME_PATTERN; + +public record NewEventDto( + @NotBlank(message = "Краткое описание не может быть пустым") + @Size(min = 20, max = 2000, message = "Аннотация должна содержать от {min} до {max} символов") + String annotation, + + @NotNull(message = "Id категории не может быть null") + Long category, + + @NotBlank(message = "Описание события не может быть пустым") + @Size(min = 20, max = 7000, message = "Описание должно содержать от {min} до {max} символов") + String description, + + @NotNull(message = "Дата события не может быть null") + @Future(message = "Дата события должна быть в будущем") + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = DATE_TIME_PATTERN) + LocalDateTime eventDate, + + @NotNull(message = "Место проведения события не может быть null") + LocationDto location, + + Boolean paid, + + @PositiveOrZero(message = "Лимит участников события должен быть нулевым или больше нуля") + Integer participantLimit, + + Boolean requestModeration, + + @NotBlank(message = "Название события не может быть пустым") + @Size(min = 3, max = 120, message = "Заголовок должен содержать от {min} до {max} символов") + String title +) { + public NewEventDto { + if (paid == null) { + paid = Boolean.FALSE; + } + if (participantLimit == null) { + participantLimit = 0; + } + if (requestModeration == null) { + requestModeration = Boolean.TRUE; + } + } +} diff --git a/core/main-service/src/main/java/ru/yandex/practicum/event/dto/SearchEventAdminRequest.java b/core/main-service/src/main/java/ru/yandex/practicum/event/dto/SearchEventAdminRequest.java new file mode 100644 index 0000000..8bc26e4 --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/event/dto/SearchEventAdminRequest.java @@ -0,0 +1,28 @@ +package ru.yandex.practicum.event.dto; + +import org.springframework.format.annotation.DateTimeFormat; +import ru.yandex.practicum.event.model.EventState; + +import java.time.LocalDateTime; +import java.util.List; + +import static ru.yandex.practicum.constants.DateTimeConstants.DATE_TIME_PATTERN; + +public record SearchEventAdminRequest( + List users, + + List states, + + List categories, + + @DateTimeFormat(pattern = DATE_TIME_PATTERN) + LocalDateTime rangeStart, + + @DateTimeFormat(pattern = DATE_TIME_PATTERN) + LocalDateTime rangeEnd, + + Integer from, + + Integer size +) { +} diff --git a/core/main-service/src/main/java/ru/yandex/practicum/event/dto/SearchEventPublicRequest.java b/core/main-service/src/main/java/ru/yandex/practicum/event/dto/SearchEventPublicRequest.java new file mode 100644 index 0000000..7602285 --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/event/dto/SearchEventPublicRequest.java @@ -0,0 +1,46 @@ +package ru.yandex.practicum.event.dto; + +import org.springframework.format.annotation.DateTimeFormat; +import ru.yandex.practicum.event.model.SortState; + +import java.time.LocalDateTime; +import java.util.List; + +import static ru.yandex.practicum.constants.DateTimeConstants.DATE_TIME_PATTERN; + +public record SearchEventPublicRequest( + String text, + + List categories, + + Boolean paid, + + @DateTimeFormat(pattern = DATE_TIME_PATTERN) + LocalDateTime rangeStart, + + @DateTimeFormat(pattern = DATE_TIME_PATTERN) + LocalDateTime rangeEnd, + + Boolean onlyAvailable, + + SortState sort, + + Integer from, + + Integer size +) { + public SearchEventPublicRequest { + if (onlyAvailable == null) { + onlyAvailable = false; + } + if (sort == null) { + sort = SortState.EVENT_DATE; + } + if (from == null) { + from = 0; + } + if (size == null) { + size = 10; + } + } +} diff --git a/core/main-service/src/main/java/ru/yandex/practicum/event/dto/UpdateEventAdminRequest.java b/core/main-service/src/main/java/ru/yandex/practicum/event/dto/UpdateEventAdminRequest.java new file mode 100644 index 0000000..ec136dc --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/event/dto/UpdateEventAdminRequest.java @@ -0,0 +1,40 @@ +package ru.yandex.practicum.event.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import jakarta.validation.constraints.Future; +import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.Size; +import ru.yandex.practicum.event.model.StateActionAdmin; + +import java.time.LocalDateTime; + +import static ru.yandex.practicum.constants.DateTimeConstants.DATE_TIME_PATTERN; + +public record UpdateEventAdminRequest( + @Size(min = 20, max = 2000, message = "Недопустимое количество символов") + String annotation, + + Long category, + + @Size(min = 20, max = 7000, message = "Описание должно содержать от {min} до {max} символов") + String description, + + @Future(message = "Дата события должна быть в будущем") + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = DATE_TIME_PATTERN) + LocalDateTime eventDate, + + LocationDto location, + + Boolean paid, + + @PositiveOrZero(message = "Лимит участников события должен быть нулевым или больше нуля") + Integer participantLimit, + + Boolean requestModeration, + + StateActionAdmin stateAction, + + @Size(min = 3, max = 120, message = "Заголовок должен содержать от {min} до {max} символов") + String title +) { +} diff --git a/core/main-service/src/main/java/ru/yandex/practicum/event/dto/UpdateEventUserRequest.java b/core/main-service/src/main/java/ru/yandex/practicum/event/dto/UpdateEventUserRequest.java new file mode 100644 index 0000000..a2045fd --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/event/dto/UpdateEventUserRequest.java @@ -0,0 +1,40 @@ +package ru.yandex.practicum.event.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import jakarta.validation.constraints.Future; +import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.Size; +import ru.yandex.practicum.event.model.StateActionUser; + +import java.time.LocalDateTime; + +import static ru.yandex.practicum.constants.DateTimeConstants.DATE_TIME_PATTERN; + +public record UpdateEventUserRequest( + @Size(min = 20, max = 2000, message = "Недопустимое количество символов") + String annotation, + + Long category, + + @Size(min = 20, max = 7000, message = "Описание должно содержать от {min} до {max} символов") + String description, + + @Future(message = "Дата события должна быть в будущем") + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = DATE_TIME_PATTERN) + LocalDateTime eventDate, + + LocationDto location, + + Boolean paid, + + @PositiveOrZero(message = "Лимит участников события должен быть нулевым или больше нуля") + Integer participantLimit, + + Boolean requestModeration, + + StateActionUser stateAction, + + @Size(min = 3, max = 120, message = "Заголовок должен содержать от {min} до {max} символов") + String title +) { +} diff --git a/core/main-service/src/main/java/ru/yandex/practicum/event/mapper/EventMapper.java b/core/main-service/src/main/java/ru/yandex/practicum/event/mapper/EventMapper.java new file mode 100644 index 0000000..d3d046e --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/event/mapper/EventMapper.java @@ -0,0 +1,45 @@ +package ru.yandex.practicum.event.mapper; + +import org.mapstruct.*; +import ru.yandex.practicum.category.model.Category; +import ru.yandex.practicum.event.dto.*; +import ru.yandex.practicum.event.model.Event; +import ru.yandex.practicum.event.model.EventState; +import ru.yandex.practicum.user.model.User; + +@Mapper(componentModel = "spring", uses = {LocationMapper.class}) +public interface EventMapper { + + @Mapping(target = "id", ignore = true) + @Mapping(target = "publishedOn", ignore = true) + @Mapping(target = "createdOn", ignore = true) + @Mapping(target = "category", source = "category") + Event fromNewEvent(NewEventDto dto, User initiator, Category category, EventState state); + + EventShortDto toEventShortDto(Event event, Integer confirmedRequests, Long views); + + @Named("toEventShortWithoutStats") + @Mapping(target = "confirmedRequests", ignore = true) + @Mapping(target = "views", ignore = true) + EventShortDto toEventShortWithoutStats(Event event); + + EventFullDto toEventFullDto(Event event, Integer confirmedRequests, Long views); + + @Mapping(target = "id", ignore = true) + @Mapping(target = "initiator", ignore = true) + @Mapping(target = "createdOn", ignore = true) + @Mapping(target = "publishedOn", ignore = true) + @Mapping(target = "state", ignore = true) + @Mapping(target = "category", source = "category") + @BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE) + void updateEventFromUserRequest(UpdateEventUserRequest request, @MappingTarget Event event, Category category); + + @Mapping(target = "id", ignore = true) + @Mapping(target = "initiator", ignore = true) + @Mapping(target = "createdOn", ignore = true) + @Mapping(target = "publishedOn", ignore = true) + @Mapping(target = "state", ignore = true) + @Mapping(target = "category", source = "category") + @BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE) + void updateEventFromAdminRequest(UpdateEventAdminRequest request, @MappingTarget Event event, Category category); +} diff --git a/core/main-service/src/main/java/ru/yandex/practicum/event/mapper/LocationMapper.java b/core/main-service/src/main/java/ru/yandex/practicum/event/mapper/LocationMapper.java new file mode 100644 index 0000000..d0297fd --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/event/mapper/LocationMapper.java @@ -0,0 +1,12 @@ +package ru.yandex.practicum.event.mapper; + +import org.mapstruct.Mapper; +import ru.yandex.practicum.event.dto.LocationDto; +import ru.yandex.practicum.event.model.EventLocation; + +@Mapper(componentModel = "spring") +public interface LocationMapper { + EventLocation toEventLocation(LocationDto dto); + + LocationDto toDto(EventLocation entity); +} diff --git a/core/main-service/src/main/java/ru/yandex/practicum/event/model/Event.java b/core/main-service/src/main/java/ru/yandex/practicum/event/model/Event.java new file mode 100644 index 0000000..0ab14ac --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/event/model/Event.java @@ -0,0 +1,83 @@ +package ru.yandex.practicum.event.model; + +import jakarta.persistence.*; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; +import org.hibernate.annotations.CreationTimestamp; +import ru.yandex.practicum.category.model.Category; +import ru.yandex.practicum.user.model.User; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "events") +@Getter +@Setter +@EqualsAndHashCode(onlyExplicitlyIncluded = true) +public class Event { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @EqualsAndHashCode.Include + private Long id; + + @Column(nullable = false, length = 120) + private String title; + + @Column(nullable = false, length = 2000) + private String annotation; + + @Column(nullable = false, length = 7000) + private String description; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "category_id") + private Category category; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "initiator_id") + private User initiator; + + @Column(name = "created_on", nullable = false) + @CreationTimestamp + private LocalDateTime createdOn; + + @Column(name = "event_date", nullable = false) + private LocalDateTime eventDate; + + @Column(name = "published_on") + private LocalDateTime publishedOn; + + @Embedded + @AttributeOverrides({ + @AttributeOverride(name = "lat", column = @Column(name = "lat")), + @AttributeOverride(name = "lon", column = @Column(name = "lon")) + }) + private EventLocation location; + + @Column(nullable = false) + private boolean paid; + + @Column(name = "participant_limit", nullable = false) + private Integer participantLimit; + + @Column(name = "request_moderation", nullable = false) + private boolean requestModeration; + + @Column(nullable = false, length = 20) + @Enumerated(EnumType.STRING) + private EventState state; + + public void pending() { + this.state = EventState.PENDING; + } + + public void canceled() { + this.state = EventState.CANCELED; + } + + public void publish() { + this.state = EventState.PUBLISHED; + this.publishedOn = LocalDateTime.now(); + } +} diff --git a/core/main-service/src/main/java/ru/yandex/practicum/event/model/EventLocation.java b/core/main-service/src/main/java/ru/yandex/practicum/event/model/EventLocation.java new file mode 100644 index 0000000..670d836 --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/event/model/EventLocation.java @@ -0,0 +1,17 @@ +package ru.yandex.practicum.event.model; + +import jakarta.persistence.Embeddable; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.Setter; + +@Embeddable +@Getter +@Setter +public class EventLocation { + @NotNull(message = "Широта не может быть null") + private Float lat; + + @NotNull(message = "Долгота не может быть null") + private Float lon; +} diff --git a/core/main-service/src/main/java/ru/yandex/practicum/event/model/EventState.java b/core/main-service/src/main/java/ru/yandex/practicum/event/model/EventState.java new file mode 100644 index 0000000..ddcae09 --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/event/model/EventState.java @@ -0,0 +1,7 @@ +package ru.yandex.practicum.event.model; + +public enum EventState { + PENDING, + PUBLISHED, + CANCELED +} diff --git a/core/main-service/src/main/java/ru/yandex/practicum/event/model/SortState.java b/core/main-service/src/main/java/ru/yandex/practicum/event/model/SortState.java new file mode 100644 index 0000000..8a35d2d --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/event/model/SortState.java @@ -0,0 +1,6 @@ +package ru.yandex.practicum.event.model; + +public enum SortState { + EVENT_DATE, + VIEWS +} diff --git a/core/main-service/src/main/java/ru/yandex/practicum/event/model/StateActionAdmin.java b/core/main-service/src/main/java/ru/yandex/practicum/event/model/StateActionAdmin.java new file mode 100644 index 0000000..27a800c --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/event/model/StateActionAdmin.java @@ -0,0 +1,6 @@ +package ru.yandex.practicum.event.model; + +public enum StateActionAdmin { + PUBLISH_EVENT, + REJECT_EVENT +} diff --git a/core/main-service/src/main/java/ru/yandex/practicum/event/model/StateActionUser.java b/core/main-service/src/main/java/ru/yandex/practicum/event/model/StateActionUser.java new file mode 100644 index 0000000..0cc9766 --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/event/model/StateActionUser.java @@ -0,0 +1,6 @@ +package ru.yandex.practicum.event.model; + +public enum StateActionUser { + SEND_TO_REVIEW, + CANCEL_REVIEW +} diff --git a/core/main-service/src/main/java/ru/yandex/practicum/event/repository/EventRepository.java b/core/main-service/src/main/java/ru/yandex/practicum/event/repository/EventRepository.java new file mode 100644 index 0000000..7038c64 --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/event/repository/EventRepository.java @@ -0,0 +1,36 @@ +package ru.yandex.practicum.event.repository; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import ru.yandex.practicum.event.model.Event; + +import java.util.List; +import java.util.Optional; + +public interface EventRepository extends JpaRepository, JpaSpecificationExecutor { + + List findAllByInitiatorIdOrderByCreatedOnDesc(Long initiatorId, Pageable pageable); + + Event findFirstByOrderByCreatedOnAsc(); + + @Query(""" + SELECT e FROM Event e + LEFT JOIN FETCH e.category + LEFT JOIN FETCH e.initiator + WHERE e.id IN :eventIds""" + ) + List findAllByEventIds(@Param("eventIds") List eventIds); + + @Query(""" + SELECT e FROM Event e + LEFT JOIN FETCH e.category + LEFT JOIN FETCH e.initiator + WHERE e.id = :eventId""" + ) + Optional findByIdNew(@Param("eventId") Long eventId); + + boolean existsByCategoryId(@Param("categoryId") Long categoryId); +} diff --git a/core/main-service/src/main/java/ru/yandex/practicum/event/repository/SearchEventSpecifications.java b/core/main-service/src/main/java/ru/yandex/practicum/event/repository/SearchEventSpecifications.java new file mode 100644 index 0000000..ef62273 --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/event/repository/SearchEventSpecifications.java @@ -0,0 +1,93 @@ +package ru.yandex.practicum.event.repository; + +import jakarta.persistence.criteria.Root; +import jakarta.persistence.criteria.Subquery; +import org.springframework.data.jpa.domain.Specification; +import ru.yandex.practicum.event.model.Event; +import ru.yandex.practicum.event.model.EventState; +import ru.yandex.practicum.request.model.Request; +import ru.yandex.practicum.request.model.RequestStatus; + +import java.time.LocalDateTime; +import java.util.List; + +public class SearchEventSpecifications { + + public static Specification addWhereNull() { + return (root, query, criteriaBuilder) -> null; + } + + public static Specification addWhereUsers(List userIds) { + return (root, query, criteriaBuilder) -> { + if (userIds == null || userIds.isEmpty()) return criteriaBuilder.conjunction(); + return root.get("initiator").get("id").in(userIds); + }; + } + + public static Specification addWhereStates(List states) { + return (root, query, criteriaBuilder) -> { + if (states == null || states.isEmpty()) return criteriaBuilder.conjunction(); + return root.get("state").in(states); + }; + } + + public static Specification addWhereCategories(List categoryIds) { + return (root, query, criteriaBuilder) -> { + if (categoryIds == null || categoryIds.isEmpty()) return criteriaBuilder.conjunction(); + return root.get("category").get("id").in(categoryIds); + }; + } + + public static Specification addWhereStartsBefore(LocalDateTime dateTime) { + return (root, query, criteriaBuilder) -> { + if (dateTime == null) return criteriaBuilder.conjunction(); + return criteriaBuilder.greaterThanOrEqualTo(root.get("eventDate"), dateTime); + }; + } + + public static Specification addWhereEndsAfter(LocalDateTime dateTime) { + return (root, query, criteriaBuilder) -> { + if (dateTime == null) return criteriaBuilder.conjunction(); + return criteriaBuilder.lessThanOrEqualTo(root.get("eventDate"), dateTime); + }; + } + + public static Specification addLikeText(String text) { + return (root, query, criteriaBuilder) -> { + if (text == null || text.trim().isEmpty()) return criteriaBuilder.conjunction(); + String searchText = "%" + text.toLowerCase() + "%"; + return criteriaBuilder.or( + criteriaBuilder.like(criteriaBuilder.lower(root.get("annotation")), searchText), + criteriaBuilder.like(criteriaBuilder.lower(root.get("description")), searchText) + ); + }; + } + + public static Specification isPaid(Boolean paid) { + return (root, query, criteriaBuilder) -> { + if (paid == null) return criteriaBuilder.conjunction(); + return criteriaBuilder.equal(root.get("paid"), paid); + }; + } + + public static Specification addWhereAvailableSlots() { + return (root, query, criteriaBuilder) -> { + assert query != null; + Subquery subquery = query.subquery(Long.class); + Root requestRoot = subquery.from(Request.class); + + subquery.select(criteriaBuilder.count(requestRoot)); + subquery.where( + criteriaBuilder.and( + criteriaBuilder.equal(requestRoot.get("event"), root), + criteriaBuilder.equal(requestRoot.get("status"), RequestStatus.CONFIRMED) + ) + ); + + return criteriaBuilder.or( + criteriaBuilder.equal(root.get("participantLimit"), 0), + criteriaBuilder.lessThan(subquery, root.get("participantLimit")) + ); + }; + } +} diff --git a/core/main-service/src/main/java/ru/yandex/practicum/event/service/EventService.java b/core/main-service/src/main/java/ru/yandex/practicum/event/service/EventService.java new file mode 100644 index 0000000..ba9cc3a --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/event/service/EventService.java @@ -0,0 +1,27 @@ +package ru.yandex.practicum.event.service; + +import org.springframework.data.domain.Pageable; +import ru.yandex.practicum.event.dto.*; +import ru.yandex.practicum.event.model.Event; + +import java.util.List; + +public interface EventService { + List getEvents(Long userId, Pageable pageable); + + EventFullDto createEvent(Long userId, NewEventDto newEventDto); + + EventFullDto getEvent(Long userId, Long eventId, String ip); + + EventFullDto updateEvent(Long userId, Long eventId, UpdateEventUserRequest request); + + Event getEventOrThrow(Long eventId); + + List getEventsAdmin(SearchEventAdminRequest request, Pageable pageable); + + EventFullDto updateEventAdmin(Long eventId, UpdateEventAdminRequest request); + + List getEventsPublic(SearchEventPublicRequest requestParams, Pageable pageable, String ip); + + EventFullDto getEventByIdPublic(Long eventId, String ip); +} diff --git a/core/main-service/src/main/java/ru/yandex/practicum/event/service/EventServiceImpl.java b/core/main-service/src/main/java/ru/yandex/practicum/event/service/EventServiceImpl.java new file mode 100644 index 0000000..565f009 --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/event/service/EventServiceImpl.java @@ -0,0 +1,362 @@ +package ru.yandex.practicum.event.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import ru.yandex.practicum.CreateEndpointHitDto; +import ru.yandex.practicum.StatsClientApp; +import ru.yandex.practicum.ViewStatsDto; +import ru.yandex.practicum.category.dto.CategoryDto; +import ru.yandex.practicum.category.mapper.CategoryMapper; +import ru.yandex.practicum.category.model.Category; +import ru.yandex.practicum.category.service.CategoryService; +import ru.yandex.practicum.event.dto.*; +import ru.yandex.practicum.event.mapper.EventMapper; +import ru.yandex.practicum.event.model.*; +import ru.yandex.practicum.event.repository.EventRepository; +import ru.yandex.practicum.event.repository.SearchEventSpecifications; +import ru.yandex.practicum.exception.BadRequestException; +import ru.yandex.practicum.exception.ConflictException; +import ru.yandex.practicum.exception.NotFoundException; +import ru.yandex.practicum.exception.ValidationException; +import ru.yandex.practicum.request.repository.RequestRepository; +import ru.yandex.practicum.user.model.User; +import ru.yandex.practicum.user.service.UserService; + +import java.time.LocalDateTime; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Slf4j +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class EventServiceImpl implements EventService { + private final EventRepository eventRepository; + private final EventMapper eventMapper; + private final UserService userService; + private final RequestRepository requestRepository; + private final StatsClientApp statsClient; + private final CategoryService categoryService; + private final CategoryMapper categoryMapper; + + @Override + @Transactional + public EventFullDto createEvent(Long userId, NewEventDto newEventDto) { + validateDateEvent(newEventDto.eventDate(), 2); + + User user = userService.getUserById(userId); + + CategoryDto categoryDto = categoryService.getCategoryById(newEventDto.category()); + Category category = categoryMapper.toEntityFromDto(categoryDto); + + Event event = eventMapper.fromNewEvent(newEventDto, user, category, EventState.PENDING); + event = eventRepository.save(event); + + log.info("Создано событие с id={}, title={}, initiatorId={}", event.getId(), event.getTitle(), userId); + return eventMapper.toEventFullDto(event, 0, 0L); + } + + @Override + public List getEvents(Long userId, Pageable pageable) { + userService.getUserById(userId); + List events = eventRepository.findAllByInitiatorIdOrderByCreatedOnDesc(userId, pageable); + + if (events.isEmpty()) return List.of(); + + List eventIds = events.stream() + .map(Event::getId) + .toList(); + + EventStatistics stats = getEventStatistics(eventIds); + + return events.stream() + .map(event -> eventMapper.toEventShortDto( + event, + stats.confirmedRequests().getOrDefault(event.getId(), 0), + stats.views().getOrDefault(event.getId(), 0L) + )) + .toList(); + } + + @Override + public EventFullDto getEvent(Long userId, Long eventId, String ip) { + userService.getUserByIdOrThrow(userId); + Event event = getEventOrThrow(eventId); + + if (!event.getInitiator().getId().equals(userId)) { + throw new NotFoundException("Событие c id " + eventId + " не найдено у пользователя с id " + userId); + } + + saveHit("/events/" + eventId, ip); + + return buildFullDto(event); + } + + @Override + @Transactional + public EventFullDto updateEvent(Long userId, Long eventId, UpdateEventUserRequest request) { + userService.getUserByIdOrThrow(userId); + Event event = getEventOrThrow(eventId); + + if (!event.getInitiator().getId().equals(userId)) + throw new ValidationException("Пользователь не является инициатором события и не может его редактировать"); + + if (event.getState() == EventState.PUBLISHED) + throw new ValidationException("Изменять можно только не опубликованные события"); + + validateDateEvent(request.eventDate(), 2); + + Category category = null; + if (request.category() != null) { + category = categoryService.getCategoryByIdOrThrow(request.category()); + } + eventMapper.updateEventFromUserRequest(request, event, category); + + if (request.stateAction() != null) { + if (request.stateAction() == StateActionUser.SEND_TO_REVIEW) + event.pending(); + if (request.stateAction() == StateActionUser.CANCEL_REVIEW) + event.canceled(); + } + + Event updatedEvent = eventRepository.save(event); + return buildFullDto(updatedEvent); + } + + @Override + public Event getEventOrThrow(Long eventId) { + return eventRepository.findById(eventId) + .orElseThrow(() -> new NotFoundException("Событие c id " + eventId + " не найдено")); + } + + @Override + public List getEventsAdmin(SearchEventAdminRequest request, Pageable pageable) { + validateRangeStartAndEnd(request.rangeStart(), request.rangeEnd()); + + Specification specification = SearchEventSpecifications.addWhereNull(); + if (request.users() != null && !request.users().isEmpty()) + specification = specification.and(SearchEventSpecifications.addWhereUsers(request.users())); + if (request.states() != null && !request.states().isEmpty()) + specification = specification.and(SearchEventSpecifications.addWhereStates(request.states())); + if (request.categories() != null && !request.categories().isEmpty()) + specification = specification.and(SearchEventSpecifications.addWhereCategories(request.categories())); + if (request.rangeStart() != null) + specification = specification.and(SearchEventSpecifications.addWhereStartsBefore(request.rangeStart())); + if (request.rangeEnd() != null) + specification = specification.and(SearchEventSpecifications.addWhereEndsAfter(request.rangeEnd())); + if (request.rangeStart() == null && request.rangeEnd() == null) + specification = specification.and(SearchEventSpecifications.addWhereStartsBefore(LocalDateTime.now())); + + Page eventIdsPage = eventRepository.findAll(specification, pageable).map(Event::getId); + List eventIds = eventIdsPage.getContent(); + + if (eventIds.isEmpty()) return List.of(); + List events = eventRepository.findAllByEventIds(eventIds); + + if (events.isEmpty()) return List.of(); + + List searchEventIds = events.stream() + .map(Event::getId) + .toList(); + + EventStatistics stats = getEventStatistics(searchEventIds); + + return events.stream() + .map(event -> eventMapper.toEventFullDto( + event, + stats.confirmedRequests().getOrDefault(event.getId(), 0), + stats.views().getOrDefault(event.getId(), 0L) + )) + .toList(); + } + + @Override + @Transactional + public EventFullDto updateEventAdmin(Long eventId, UpdateEventAdminRequest request) { + Event event = eventRepository.findByIdNew(eventId) + .orElseThrow(() -> new NotFoundException("Событие c id " + eventId + " не найдено")); + + if (request.stateAction() != null) { + StateActionAdmin stateActionAdmin = request.stateAction(); + EventState currentState = event.getState(); + + if (stateActionAdmin == StateActionAdmin.PUBLISH_EVENT) { + if (currentState != EventState.PENDING) + throw new ConflictException("Событие можно опубликовать только если оно в состоянии ожидания публикации"); + validateDateEvent(event.getEventDate(), 1); + event.publish(); + } + if (stateActionAdmin == StateActionAdmin.REJECT_EVENT) { + if (currentState == EventState.PUBLISHED) + throw new ConflictException("Событие можно отклонить пока оно не опубликовано"); + event.canceled(); + } + } + + Category category = null; + if (request.category() != null) { + category = categoryService.getCategoryByIdOrThrow(request.category()); + } + + eventMapper.updateEventFromAdminRequest(request, event, category); + Event updatedEvent = eventRepository.save(event); + + log.info("Обновлено событие с id={}, title={}, initiatorId={}", + updatedEvent.getId(), updatedEvent.getTitle(), updatedEvent.getInitiator().getId()); + return buildFullDto(updatedEvent); + } + + @Override + public List getEventsPublic(SearchEventPublicRequest request, Pageable pageable, String ip) { + validateRangeStartAndEnd(request.rangeStart(), request.rangeEnd()); + + Specification specification = SearchEventSpecifications.addWhereNull(); + if (request.text() != null && !request.text().trim().isEmpty()) + specification = specification.and(SearchEventSpecifications.addLikeText(request.text())); + if (request.categories() != null && !request.categories().isEmpty()) + specification = specification.and(SearchEventSpecifications.addWhereCategories(request.categories())); + if (request.paid() != null) + specification = specification.and(SearchEventSpecifications.isPaid(request.paid())); + LocalDateTime rangeStart = (request.rangeStart() == null && request.rangeEnd() == null) ? + LocalDateTime.now() : request.rangeStart(); + if (rangeStart != null) + specification = specification.and(SearchEventSpecifications.addWhereStartsBefore(rangeStart)); + if (request.rangeEnd() != null) + specification = specification.and(SearchEventSpecifications.addWhereEndsAfter(request.rangeEnd())); + if (request.onlyAvailable()) + specification = specification.and(SearchEventSpecifications.addWhereAvailableSlots()); + + List events = eventRepository.findAll(specification, pageable).getContent(); + + if (events.isEmpty()) return List.of(); + + List eventIds = events.stream() + .map(Event::getId) + .toList(); + + EventStatistics stats = getEventStatistics(eventIds); + + List result = events.stream() + .map(event -> eventMapper.toEventShortDto( + event, + stats.confirmedRequests().getOrDefault(event.getId(), 0), + stats.views().getOrDefault(event.getId(), 0L) + )) + .toList(); + + saveHit("/events", ip); + + if (SortState.VIEWS.equals(request.sort())) { + return result.stream() + .sorted(Comparator.comparing(EventShortDto::views).reversed()) + .toList(); + } else if (SortState.EVENT_DATE.equals(request.sort())) { + return result.stream() + .sorted(Comparator.comparing(EventShortDto::eventDate)) + .toList(); + } + + return result; + } + + @Override + public EventFullDto getEventByIdPublic(Long eventId, String ip) { + Event event = eventRepository.findByIdNew(eventId) + .filter(ev -> ev.getState() == EventState.PUBLISHED) + .orElseThrow(() -> new NotFoundException("Событие c id " + eventId + " не найдено")); + + saveHit("/events/" + eventId, ip); + + return buildFullDto(event); + } + + private EventFullDto buildFullDto(Event event) { + Map confirmedRequests = getConfirmedRequests(List.of(event.getId())); + Map views = getViewsForEvents(List.of(event.getId())); + + return eventMapper.toEventFullDto( + event, + confirmedRequests.getOrDefault(event.getId(), 0), + views.getOrDefault(event.getId(), 0L) + ); + } + + private void validateRangeStartAndEnd(LocalDateTime rangeStart, LocalDateTime rangeEnd) { + if (rangeStart != null && rangeEnd != null && rangeStart.isAfter(rangeEnd)) + throw new BadRequestException("Дата начала не может быть позже даты окончания"); + } + + private void saveHit(String path, String ip) { + CreateEndpointHitDto endpointHitDto = new CreateEndpointHitDto("main-service", path, ip, LocalDateTime.now()); + statsClient.saveHit(endpointHitDto); + } + + private void validateDateEvent(LocalDateTime eventDate, long minHoursBeforeStartEvent) { + if (eventDate != null && eventDate.isBefore(LocalDateTime.now().plusHours(minHoursBeforeStartEvent))) + throw new ValidationException("Дата начала события не может быть ранее чем через " + minHoursBeforeStartEvent + " часа(ов)"); + } + + private Map getConfirmedRequests(List eventIds) { + if (eventIds.isEmpty()) return Map.of(); + + List events = requestRepository.findCountConfirmedRequestsByEventIds(eventIds); + Map confirmedRequests = eventIds.stream() + .collect(Collectors.toMap(id -> id, id -> 0)); + + events.forEach(dto -> confirmedRequests.put(dto.getEventId(), dto.getCountConfirmedRequests())); + + return confirmedRequests; + } + + private Map getViewsForEvents(List eventIds) { + if (eventIds.isEmpty()) return Map.of(); + + List uris = eventIds.stream() + .map(id -> "/events/" + id) + .toList(); + + LocalDateTime start = eventRepository.findFirstByOrderByCreatedOnAsc().getCreatedOn(); + LocalDateTime end = LocalDateTime.now(); + + List stats = statsClient.getStats(start, end, uris, true); + + Map views = eventIds.stream() + .collect(Collectors.toMap(id -> id, id -> 0L)); + + if (stats != null && !stats.isEmpty()) { + stats.forEach(stat -> { + Long eventId = getEventIdFromUri(stat.uri()); + if (eventId > -1L) { + views.put(eventId, stat.hits()); + } + }); + } + return views; + } + + private Long getEventIdFromUri(String uri) { + try { + return Long.parseLong(uri.substring("/events".length() + 1)); + } catch (Exception e) { + return -1L; + } + } + + private EventStatistics getEventStatistics(List eventIds) { + if (eventIds.isEmpty()) { + return new EventStatistics(Map.of(), Map.of()); + } + + Map confirmedRequests = getConfirmedRequests(eventIds); + Map views = getViewsForEvents(eventIds); + + return new EventStatistics(confirmedRequests, views); + } +} diff --git a/core/main-service/src/main/java/ru/yandex/practicum/exception/BadRequestException.java b/core/main-service/src/main/java/ru/yandex/practicum/exception/BadRequestException.java new file mode 100644 index 0000000..4340bf4 --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/exception/BadRequestException.java @@ -0,0 +1,7 @@ +package ru.yandex.practicum.exception; + +public class BadRequestException extends RuntimeException { + public BadRequestException(String message) { + super(message); + } +} diff --git a/core/main-service/src/main/java/ru/yandex/practicum/exception/ConflictException.java b/core/main-service/src/main/java/ru/yandex/practicum/exception/ConflictException.java new file mode 100644 index 0000000..0f391d4 --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/exception/ConflictException.java @@ -0,0 +1,7 @@ +package ru.yandex.practicum.exception; + +public class ConflictException extends RuntimeException { + public ConflictException(String message) { + super(message); + } +} diff --git a/core/main-service/src/main/java/ru/yandex/practicum/exception/ErrorHandler.java b/core/main-service/src/main/java/ru/yandex/practicum/exception/ErrorHandler.java new file mode 100644 index 0000000..3dfd3d2 --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/exception/ErrorHandler.java @@ -0,0 +1,139 @@ +package ru.yandex.practicum.exception; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.ConstraintViolationException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.MessageSourceResolvable; +import org.springframework.http.HttpStatus; +import org.springframework.web.HttpMediaTypeNotSupportedException; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.ServletRequestBindingException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.HandlerMethodValidationException; +import org.springframework.web.servlet.NoHandlerFoundException; + +import java.util.stream.Collectors; + +@Slf4j +@RestControllerAdvice +public class ErrorHandler { + + @ExceptionHandler(NotFoundException.class) + @ResponseStatus(HttpStatus.NOT_FOUND) + public ErrorResponse handleNotFoundException(NotFoundException exception, HttpServletRequest request) { + return buildResponse(HttpStatus.NOT_FOUND, exception, request); + } + + @ExceptionHandler(BadRequestException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ErrorResponse handleBadRequestException(BadRequestException exception, HttpServletRequest request) { + return buildResponse(HttpStatus.BAD_REQUEST, exception, request); + } + + @ExceptionHandler(ForbiddenException.class) + @ResponseStatus(HttpStatus.FORBIDDEN) + public ErrorResponse handleForbiddenException(ForbiddenException exception, HttpServletRequest request) { + return buildResponse(HttpStatus.FORBIDDEN, exception, request); + } + + @ExceptionHandler(ConflictException.class) + @ResponseStatus(HttpStatus.CONFLICT) + public ErrorResponse handleConflictException(ConflictException exception, HttpServletRequest request) { + return buildResponse(HttpStatus.CONFLICT, exception, request); + } + + @ExceptionHandler(ValidationException.class) + @ResponseStatus(HttpStatus.CONFLICT) + public ErrorResponse handleValidationException(ValidationException exception, HttpServletRequest request) { + return buildResponse(HttpStatus.CONFLICT, exception, request); + } + + @ExceptionHandler(ConstraintViolationException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ErrorResponse handleConstraintViolation(ConstraintViolationException ex, HttpServletRequest request) { + String violations = ex.getConstraintViolations().stream() + .map(violation -> violation.getPropertyPath() + ": " + violation.getMessage()) + .collect(Collectors.joining("; ")); + + String message = violations.isEmpty() ? "Constraint violation" : violations; + return buildResponse(HttpStatus.BAD_REQUEST, message, null, request); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ErrorResponse handleMethodArgumentNotValid(MethodArgumentNotValidException ex, HttpServletRequest request) { + String validationErrors = ex.getBindingResult().getFieldErrors().stream() + .map(error -> error.getField() + ": " + error.getDefaultMessage()) + .collect(Collectors.joining("; ")); + + String message = validationErrors.isEmpty() ? "Validation failed" : validationErrors; + + return buildResponse(HttpStatus.BAD_REQUEST, message, null, request); + } + + @ExceptionHandler(HandlerMethodValidationException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ErrorResponse handleHandlerMethodValidation(HandlerMethodValidationException ex, HttpServletRequest request) { + String validationErrors = ex.getAllErrors().stream() + .map(MessageSourceResolvable::getDefaultMessage) + .collect(Collectors.joining("; ")); + + String message = validationErrors.isEmpty() ? "Validation failed" : validationErrors; + + return buildResponse(HttpStatus.BAD_REQUEST, message, null, request); + } + + @ExceptionHandler(ServletRequestBindingException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ErrorResponse handleServletRequestBindingException(ServletRequestBindingException ex, HttpServletRequest request) { + return buildResponse(HttpStatus.BAD_REQUEST, ex.getMessage(), null, request); + } + + @ExceptionHandler(HttpRequestMethodNotSupportedException.class) + @ResponseStatus(HttpStatus.METHOD_NOT_ALLOWED) + public ErrorResponse handleHttpRequestMethodNotSupported(HttpRequestMethodNotSupportedException ex, HttpServletRequest request) { + return buildResponse(HttpStatus.METHOD_NOT_ALLOWED, ex.getMessage(), null, request); + } + + @ExceptionHandler(HttpMediaTypeNotSupportedException.class) + @ResponseStatus(HttpStatus.UNSUPPORTED_MEDIA_TYPE) + public ErrorResponse handleHttpMediaTypeNotSupported(HttpMediaTypeNotSupportedException ex, HttpServletRequest request) { + return buildResponse(HttpStatus.UNSUPPORTED_MEDIA_TYPE, ex.getMessage(), null, request); + } + + @ExceptionHandler(NoHandlerFoundException.class) + @ResponseStatus(HttpStatus.NOT_FOUND) + public ErrorResponse handleNoHandlerFoundException(NoHandlerFoundException ex, HttpServletRequest request) { + return buildResponse(HttpStatus.NOT_FOUND, ex.getMessage(), null, request); + } + + @ExceptionHandler(Exception.class) + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + public ErrorResponse handleUnexpected(Exception exception, HttpServletRequest request) { + return buildResponse(HttpStatus.INTERNAL_SERVER_ERROR, exception, request); + } + + private ErrorResponse buildResponse(HttpStatus httpStatus, Exception exception, HttpServletRequest request) { + return buildResponse(httpStatus, exception.getMessage(), exception, request); + } + + private ErrorResponse buildResponse(HttpStatus httpStatus, String message, Exception exception, HttpServletRequest request) { + String path = request.getRequestURI(); + String httpMethod = request.getMethod(); + int statusCode = httpStatus.value(); + String error = httpStatus.getReasonPhrase(); + + String logMessage = String.format("Path: %s %s, '%d' %s — %s", httpMethod, path, statusCode, error, message); + + if (statusCode >= 500) { + log.error(logMessage, exception); + } else { + log.warn(logMessage); + } + + return new ErrorResponse(path, httpMethod, statusCode, error, message); + } +} diff --git a/core/main-service/src/main/java/ru/yandex/practicum/exception/ErrorResponse.java b/core/main-service/src/main/java/ru/yandex/practicum/exception/ErrorResponse.java new file mode 100644 index 0000000..e110861 --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/exception/ErrorResponse.java @@ -0,0 +1,10 @@ +package ru.yandex.practicum.exception; + +public record ErrorResponse( + String path, + String httpMethod, + int statusCode, + String error, + String message +) { +} diff --git a/core/main-service/src/main/java/ru/yandex/practicum/exception/ForbiddenException.java b/core/main-service/src/main/java/ru/yandex/practicum/exception/ForbiddenException.java new file mode 100644 index 0000000..cf1bf94 --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/exception/ForbiddenException.java @@ -0,0 +1,7 @@ +package ru.yandex.practicum.exception; + +public class ForbiddenException extends RuntimeException { + public ForbiddenException(String message) { + super(message); + } +} diff --git a/core/main-service/src/main/java/ru/yandex/practicum/exception/NotFoundException.java b/core/main-service/src/main/java/ru/yandex/practicum/exception/NotFoundException.java new file mode 100644 index 0000000..95de328 --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/exception/NotFoundException.java @@ -0,0 +1,7 @@ +package ru.yandex.practicum.exception; + +public class NotFoundException extends RuntimeException { + public NotFoundException(String message) { + super(message); + } +} diff --git a/core/main-service/src/main/java/ru/yandex/practicum/exception/ValidationException.java b/core/main-service/src/main/java/ru/yandex/practicum/exception/ValidationException.java new file mode 100644 index 0000000..a7897c0 --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/exception/ValidationException.java @@ -0,0 +1,7 @@ +package ru.yandex.practicum.exception; + +public class ValidationException extends RuntimeException { + public ValidationException(String message) { + super(message); + } +} diff --git a/core/main-service/src/main/java/ru/yandex/practicum/request/controller/PrivateRequestController.java b/core/main-service/src/main/java/ru/yandex/practicum/request/controller/PrivateRequestController.java new file mode 100644 index 0000000..4dede69 --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/request/controller/PrivateRequestController.java @@ -0,0 +1,42 @@ +package ru.yandex.practicum.request.controller; + +import jakarta.validation.constraints.Positive; +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.yandex.practicum.request.dto.ParticipationRequestDto; +import ru.yandex.practicum.request.service.RequestService; + +import java.util.List; + +@Slf4j +@Validated +@RestController +@RequestMapping("/users/{userId}/requests") +@RequiredArgsConstructor +public class PrivateRequestController { + private final RequestService requestService; + + @GetMapping + public List getRequestsByUserId(@PathVariable("userId") @Positive Long userId) { + log.debug("Controller: getRequestsByUserId userId={}", userId); + return requestService.getRequestsByUserId(userId); + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public ParticipationRequestDto addRequest(@PathVariable("userId") @Positive Long userId, + @RequestParam("eventId") @Positive Long eventId) { + log.debug("Controller: addRequest userId={}, eventId={}", userId, eventId); + return requestService.addRequest(userId, eventId); + } + + @PatchMapping("/{requestId}/cancel") + public ParticipationRequestDto cancelRequest(@PathVariable("userId") @Positive Long userId, + @PathVariable("requestId") @Positive Long requestId) { + log.debug("Controller: cancelRequest userId={}, requestId={}", userId, requestId); + return requestService.cancelRequest(userId, requestId); + } +} diff --git a/core/main-service/src/main/java/ru/yandex/practicum/request/dto/EventRequestStatusUpdateRequest.java b/core/main-service/src/main/java/ru/yandex/practicum/request/dto/EventRequestStatusUpdateRequest.java new file mode 100644 index 0000000..d8a18d5 --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/request/dto/EventRequestStatusUpdateRequest.java @@ -0,0 +1,11 @@ +package ru.yandex.practicum.request.dto; + +import ru.yandex.practicum.request.model.RequestStatus; + +import java.util.List; + +public record EventRequestStatusUpdateRequest( + List requestIds, + RequestStatus status +) { +} diff --git a/core/main-service/src/main/java/ru/yandex/practicum/request/dto/EventRequestStatusUpdateResult.java b/core/main-service/src/main/java/ru/yandex/practicum/request/dto/EventRequestStatusUpdateResult.java new file mode 100644 index 0000000..81b52b4 --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/request/dto/EventRequestStatusUpdateResult.java @@ -0,0 +1,9 @@ +package ru.yandex.practicum.request.dto; + +import java.util.List; + +public record EventRequestStatusUpdateResult( + List confirmedRequests, + List rejectedRequests +) { +} diff --git a/core/main-service/src/main/java/ru/yandex/practicum/request/dto/ParticipationRequestDto.java b/core/main-service/src/main/java/ru/yandex/practicum/request/dto/ParticipationRequestDto.java new file mode 100644 index 0000000..b9b4264 --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/request/dto/ParticipationRequestDto.java @@ -0,0 +1,22 @@ +package ru.yandex.practicum.request.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import ru.yandex.practicum.request.model.RequestStatus; + +import java.time.LocalDateTime; + +import static ru.yandex.practicum.constants.DateTimeConstants.DATE_TIME_PATTERN; + +public record ParticipationRequestDto( + Long id, + + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = DATE_TIME_PATTERN) + LocalDateTime created, + + Long event, + + Long requester, + + RequestStatus status +) { +} diff --git a/core/main-service/src/main/java/ru/yandex/practicum/request/mapper/RequestMapper.java b/core/main-service/src/main/java/ru/yandex/practicum/request/mapper/RequestMapper.java new file mode 100644 index 0000000..68ae2fe --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/request/mapper/RequestMapper.java @@ -0,0 +1,29 @@ +package ru.yandex.practicum.request.mapper; + +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import ru.yandex.practicum.event.model.Event; +import ru.yandex.practicum.request.dto.EventRequestStatusUpdateResult; +import ru.yandex.practicum.request.dto.ParticipationRequestDto; +import ru.yandex.practicum.request.model.Request; +import ru.yandex.practicum.request.model.RequestStatus; +import ru.yandex.practicum.user.model.User; + +import java.util.List; + +@Mapper(componentModel = "spring") +public interface RequestMapper { + + @Mapping(target = "id", ignore = true) + @Mapping(target = "created", ignore = true) + Request toEntity(Event event, User requester, RequestStatus status); + + @Mapping(source = "event.id", target = "event") + @Mapping(source = "requester.id", target = "requester") + ParticipationRequestDto toDto(Request request); + + EventRequestStatusUpdateResult toEventRequestStatusUpdateResultDto( + List confirmedRequests, + List rejectedRequests + ); +} \ No newline at end of file diff --git a/core/main-service/src/main/java/ru/yandex/practicum/request/model/Request.java b/core/main-service/src/main/java/ru/yandex/practicum/request/model/Request.java new file mode 100644 index 0000000..1c69cf6 --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/request/model/Request.java @@ -0,0 +1,49 @@ +package ru.yandex.practicum.request.model; + +import jakarta.persistence.*; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; +import org.hibernate.annotations.CreationTimestamp; +import ru.yandex.practicum.event.model.Event; +import ru.yandex.practicum.user.model.User; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "requests") +@Getter +@Setter +@EqualsAndHashCode(onlyExplicitlyIncluded = true) +public class Request { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @EqualsAndHashCode.Include + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "event_id") + private Event event; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "requester_id") + private User requester; + + @CreationTimestamp + private LocalDateTime created; + + @Enumerated(EnumType.STRING) + private RequestStatus status; + + public void confirmed() { + this.status = RequestStatus.CONFIRMED; + } + + public void rejected() { + this.status = RequestStatus.REJECTED; + } + + public void canceled() { + this.status = RequestStatus.CANCELED; + } +} diff --git a/core/main-service/src/main/java/ru/yandex/practicum/request/model/RequestStatus.java b/core/main-service/src/main/java/ru/yandex/practicum/request/model/RequestStatus.java new file mode 100644 index 0000000..d25345a --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/request/model/RequestStatus.java @@ -0,0 +1,8 @@ +package ru.yandex.practicum.request.model; + +public enum RequestStatus { + PENDING, + CONFIRMED, + REJECTED, + CANCELED +} diff --git a/core/main-service/src/main/java/ru/yandex/practicum/request/repository/RequestRepository.java b/core/main-service/src/main/java/ru/yandex/practicum/request/repository/RequestRepository.java new file mode 100644 index 0000000..b450636 --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/request/repository/RequestRepository.java @@ -0,0 +1,56 @@ +package ru.yandex.practicum.request.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import ru.yandex.practicum.event.dto.EventWithCountConfirmedRequests; +import ru.yandex.practicum.request.dto.ParticipationRequestDto; +import ru.yandex.practicum.request.model.Request; +import ru.yandex.practicum.request.model.RequestStatus; + +import java.util.List; + +public interface RequestRepository extends JpaRepository { + + @Query(""" + SELECT NEW ru.yandex.practicum.event.dto.EventWithCountConfirmedRequests(r.event.id, COUNT(r)) + FROM Request r + WHERE r.event.id IN :eventIds AND r.status = ru.yandex.practicum.request.model.RequestStatus.CONFIRMED + GROUP BY r.event.id""" + ) + List findCountConfirmedRequestsByEventIds(@Param("eventIds") List eventIds); + + @Query(""" + SELECT r FROM Request r + LEFT JOIN FETCH r.event + LEFT JOIN FETCH r.requester + WHERE r.event.id = :eventId""" + ) + List findAllParticipationRequestByEventId(@Param("eventId") Long eventId); + + @Query(""" + SELECT r FROM Request r + LEFT JOIN FETCH r.event + LEFT JOIN FETCH r.requester + WHERE r.id IN :requestIds""" + ) + List findAllRequestById(@Param("requestIds") List requestIds); + + @Query(""" + SELECT NEW ru.yandex.practicum.request.dto.ParticipationRequestDto( + r.id, + r.created, + r.event.id, + r.requester.id, + r.status) + FROM Request r + WHERE r.requester.id = :userId""" + ) + List findAllRequestsByUserId(@Param("userId") Long userId); + + boolean existsByEventIdAndRequesterId(Long eventId, Long userId); + + int countByEventIdAndStatus(Long id, RequestStatus requestStatus); + + List findAllByEventIdAndStatus(Long eventId, RequestStatus requestStatus); +} diff --git a/core/main-service/src/main/java/ru/yandex/practicum/request/service/RequestService.java b/core/main-service/src/main/java/ru/yandex/practicum/request/service/RequestService.java new file mode 100644 index 0000000..7c0e8c7 --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/request/service/RequestService.java @@ -0,0 +1,19 @@ +package ru.yandex.practicum.request.service; + +import ru.yandex.practicum.request.dto.EventRequestStatusUpdateRequest; +import ru.yandex.practicum.request.dto.EventRequestStatusUpdateResult; +import ru.yandex.practicum.request.dto.ParticipationRequestDto; + +import java.util.List; + +public interface RequestService { + List getRequestsByEvent(Long userId, Long eventId); + + EventRequestStatusUpdateResult updateRequestStatus(Long userId, Long eventId, EventRequestStatusUpdateRequest request); + + List getRequestsByUserId(Long userId); + + ParticipationRequestDto addRequest(Long userId, Long eventId); + + ParticipationRequestDto cancelRequest(Long userId, Long requestId); +} diff --git a/core/main-service/src/main/java/ru/yandex/practicum/request/service/RequestServiceImpl.java b/core/main-service/src/main/java/ru/yandex/practicum/request/service/RequestServiceImpl.java new file mode 100644 index 0000000..2f7470c --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/request/service/RequestServiceImpl.java @@ -0,0 +1,212 @@ +package ru.yandex.practicum.request.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import ru.yandex.practicum.event.model.Event; +import ru.yandex.practicum.event.model.EventState; +import ru.yandex.practicum.event.service.EventService; +import ru.yandex.practicum.exception.ConflictException; +import ru.yandex.practicum.exception.NotFoundException; +import ru.yandex.practicum.exception.ValidationException; +import ru.yandex.practicum.request.dto.EventRequestStatusUpdateRequest; +import ru.yandex.practicum.request.dto.EventRequestStatusUpdateResult; +import ru.yandex.practicum.request.dto.ParticipationRequestDto; +import ru.yandex.practicum.request.mapper.RequestMapper; +import ru.yandex.practicum.request.model.Request; +import ru.yandex.practicum.request.model.RequestStatus; +import ru.yandex.practicum.request.repository.RequestRepository; +import ru.yandex.practicum.user.model.User; +import ru.yandex.practicum.user.service.UserService; + +import java.util.ArrayList; +import java.util.List; + +@Slf4j +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class RequestServiceImpl implements RequestService { + private final EventService eventService; + private final RequestRepository requestRepository; + private final RequestMapper requestMapper; + private final UserService userService; + + @Override + @Transactional + public ParticipationRequestDto addRequest(Long userId, Long eventId) { + User user = userService.getUserByIdOrThrow(userId); + Event event = eventService.getEventOrThrow(eventId); + + if (requestRepository.existsByEventIdAndRequesterId(eventId, userId)) + throw new ConflictException("Нельзя добавить повторный запрос"); + + if (event.getInitiator().getId().equals(userId)) + throw new ConflictException("Инициатор события не может добавить запрос на участие в своём событии"); + + if (event.getState() != EventState.PUBLISHED) + throw new ConflictException("Нельзя участвовать в неопубликованном событии"); + + if (!hasSlots(event)) { + throw new ConflictException("Достигнут лимит по количеству участников события с id=" + eventId); + } + + Request request = requestMapper.toEntity(event, user, RequestStatus.PENDING); + + if (!event.isRequestModeration() || event.getParticipantLimit() == 0) { + log.debug("Модерация заявок на участие в событии с id={} не требуется", eventId); + request.confirmed(); + } + + request = requestRepository.save(request); + + log.info("Добавление нового запроса на участие в событии с id={} от пользователя с id={}", eventId, userId); + return requestMapper.toDto(request); + } + + @Override + public List getRequestsByUserId(Long userId) { + userService.getUserByIdOrThrow(userId); + + log.info("Получение информации о заявках на участие пользователя с id={}", userId); + return requestRepository.findAllRequestsByUserId(userId); + } + + @Override + public ParticipationRequestDto cancelRequest(Long userId, Long requestId) { + userService.getUserByIdOrThrow(userId); + Request request = getRequestOrThrow(requestId); + + if (!request.getRequester().getId().equals(userId)) { + throw new ConflictException("Запрос с id=" + requestId + " не принадлежит пользователю с id=" + userId); + } + + request.canceled(); + request = requestRepository.save(request); + log.info("Отмена запроса на участие с id={} пользователя с id={}", requestId, userId); + return requestMapper.toDto(request); + } + + @Override + public List getRequestsByEvent(Long userId, Long eventId) { + Event event = eventService.getEventOrThrow(eventId); + + if (!event.getInitiator().getId().equals(userId)) + throw new ValidationException("Пользователь с id=" + userId + " не является создателем события"); + + log.info("Получение информации о запросах на участие в событии с id={}", eventId); + return requestRepository.findAllParticipationRequestByEventId(eventId).stream() + .map(requestMapper::toDto) + .toList(); + } + + @Override + @Transactional + public EventRequestStatusUpdateResult updateRequestStatus(Long userId, Long eventId, EventRequestStatusUpdateRequest request) { + Event event = eventService.getEventOrThrow(eventId); + + if (!event.getInitiator().getId().equals(userId)) + throw new ValidationException("Пользователь с id=" + userId + " не является создателем события"); + + if (!event.isRequestModeration() || event.getParticipantLimit() == 0) + throw new ValidationException("Для данного события подтверждение заявок не требуется"); + + RequestStatus newStatus = request.status(); + if (newStatus == RequestStatus.PENDING) + throw new ValidationException("Устанавливать можно только статусы CONFIRMED или REJECTED"); + + List requestsForUpdate = requestRepository.findAllRequestById(request.requestIds()); + + validateAllRequestsExist(request.requestIds(), requestsForUpdate); + validateRequestsState(requestsForUpdate, eventId); + + int currentConfirmedCount = requestRepository.countByEventIdAndStatus(eventId, RequestStatus.CONFIRMED); + int availableSlots = event.getParticipantLimit() - currentConfirmedCount; + + List confirmedRequests = new ArrayList<>(); + List rejectedRequests = new ArrayList<>(); + + if (newStatus == RequestStatus.CONFIRMED) { + if (availableSlots <= 0) throw new ConflictException("Свободных мест больше нет"); + + int confirmedCount = 0; + for (Request req : requestsForUpdate) { + if (confirmedCount < availableSlots) { + req.confirmed(); + confirmedRequests.add(requestMapper.toDto(req)); + confirmedCount++; + } else { + req.rejected(); + rejectedRequests.add(requestMapper.toDto(req)); + } + } + + if (currentConfirmedCount + confirmedCount >= event.getParticipantLimit()) { + List pendingRequests = requestRepository + .findAllByEventIdAndStatus(eventId, RequestStatus.PENDING); + + for (Request pendingReq : pendingRequests) { + pendingReq.rejected(); + rejectedRequests.add(requestMapper.toDto(pendingReq)); + } + + if (!pendingRequests.isEmpty()) { + requestRepository.saveAll(pendingRequests); + log.info("Автоматически отклонено {} заявок из-за исчерпания лимита на событие с id={}", + pendingRequests.size(), eventId); + } + } + } else { + for (Request req : requestsForUpdate) { + req.rejected(); + rejectedRequests.add(requestMapper.toDto(req)); + } + } + + requestRepository.saveAll(requestsForUpdate); + + log.info("Обновление статусов заявок на участие в событии с id={}: подтверждено={}, отклонено={}", + eventId, confirmedRequests.size(), rejectedRequests.size()); + + return requestMapper.toEventRequestStatusUpdateResultDto(confirmedRequests, rejectedRequests); + } + + private Request getRequestOrThrow(Long requestId) { + return requestRepository.findById(requestId) + .orElseThrow(() -> new NotFoundException("Запрос с id=" + requestId + " не найден")); + } + + private boolean hasSlots(Event event) { + Integer limit = event.getParticipantLimit(); + if (limit == null || limit == 0) return true; + long confirmed = requestRepository.countByEventIdAndStatus(event.getId(), RequestStatus.CONFIRMED); + return confirmed < limit; + } + + private void validateAllRequestsExist(List requestedIds, List foundRequests) { + List foundIds = foundRequests.stream() + .map(Request::getId) + .toList(); + + List missingIds = requestedIds.stream() + .filter(id -> !foundIds.contains(id)) + .toList(); + + if (!missingIds.isEmpty()) { + throw new NotFoundException("Запрос(ы) с id=" + missingIds + " не найден(ы)"); + } + } + + private void validateRequestsState(List requests, Long eventId) { + for (Request req : requests) { + if (req.getStatus() != RequestStatus.PENDING) { + throw new ConflictException("Можно изменять только запросы в статусе PENDING"); + } + + if (!req.getEvent().getId().equals(eventId)) { + throw new ConflictException("Запрос с id=" + req.getId() + " не относится к событию с id=" + eventId); + } + } + } +} diff --git a/core/main-service/src/main/java/ru/yandex/practicum/user/controller/AdminUserController.java b/core/main-service/src/main/java/ru/yandex/practicum/user/controller/AdminUserController.java new file mode 100644 index 0000000..995a796 --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/user/controller/AdminUserController.java @@ -0,0 +1,50 @@ +package ru.yandex.practicum.user.controller; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpStatus; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import ru.yandex.practicum.user.dto.NewUserRequest; +import ru.yandex.practicum.user.dto.UserDto; +import ru.yandex.practicum.user.service.UserService; + +import java.util.List; + +@Slf4j +@Validated +@RestController +@RequestMapping("/admin/users") +@RequiredArgsConstructor +public class AdminUserController { + private final UserService userService; + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public UserDto createUser(@RequestBody @Valid NewUserRequest userRequest) { + log.debug("Controller: createUser data={}", userRequest); + return userService.createUser(userRequest); + } + + @GetMapping + public List getUsers(@RequestParam(required = false) List ids, + @RequestParam(defaultValue = "0") @PositiveOrZero int from, + @RequestParam(defaultValue = "10") @Positive int size + ) { + Pageable pageable = PageRequest.of(from / size, size); + log.debug("Controller: getUsers ids={}", ids); + return userService.getUsers(ids, pageable); + } + + @DeleteMapping("/{userId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void deleteUser(@PathVariable @Positive Long userId) { + log.debug("Controller: deleteUser userId={}", userId); + userService.deleteUser(userId); + } +} diff --git a/core/main-service/src/main/java/ru/yandex/practicum/user/dto/NewUserRequest.java b/core/main-service/src/main/java/ru/yandex/practicum/user/dto/NewUserRequest.java new file mode 100644 index 0000000..2cd824c --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/user/dto/NewUserRequest.java @@ -0,0 +1,17 @@ +package ru.yandex.practicum.user.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record NewUserRequest( + @NotBlank(message = "Имя пользователя не может быть пустым") + @Size(min = 2, max = 250) + String name, + + @NotBlank(message = "Email пользователя не может быть пустой") + @Email(message = "Email пользователя введен не верно") + @Size(min = 6, max = 254) + String email +) { +} diff --git a/core/main-service/src/main/java/ru/yandex/practicum/user/dto/UserDto.java b/core/main-service/src/main/java/ru/yandex/practicum/user/dto/UserDto.java new file mode 100644 index 0000000..778ac5e --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/user/dto/UserDto.java @@ -0,0 +1,8 @@ +package ru.yandex.practicum.user.dto; + +public record UserDto( + Long id, + String name, + String email +) { +} diff --git a/core/main-service/src/main/java/ru/yandex/practicum/user/dto/UserShortDto.java b/core/main-service/src/main/java/ru/yandex/practicum/user/dto/UserShortDto.java new file mode 100644 index 0000000..525a4d7 --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/user/dto/UserShortDto.java @@ -0,0 +1,7 @@ +package ru.yandex.practicum.user.dto; + +public record UserShortDto( + Long id, + String name +) { +} diff --git a/core/main-service/src/main/java/ru/yandex/practicum/user/mapper/UserMapper.java b/core/main-service/src/main/java/ru/yandex/practicum/user/mapper/UserMapper.java new file mode 100644 index 0000000..3143b3e --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/user/mapper/UserMapper.java @@ -0,0 +1,19 @@ +package ru.yandex.practicum.user.mapper; + +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import ru.yandex.practicum.user.dto.NewUserRequest; +import ru.yandex.practicum.user.dto.UserDto; +import ru.yandex.practicum.user.dto.UserShortDto; +import ru.yandex.practicum.user.model.User; + +@Mapper(componentModel = "spring") +public interface UserMapper { + + @Mapping(target = "id", ignore = true) + User toEntity(NewUserRequest dto); + + UserDto toDto(User user); + + UserShortDto toShortDto(User user); +} diff --git a/core/main-service/src/main/java/ru/yandex/practicum/user/model/User.java b/core/main-service/src/main/java/ru/yandex/practicum/user/model/User.java new file mode 100644 index 0000000..50270d1 --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/user/model/User.java @@ -0,0 +1,26 @@ +package ru.yandex.practicum.user.model; + +import jakarta.persistence.*; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +@Entity +@Table(name = "users") +@Getter +@Setter +@ToString +@EqualsAndHashCode(onlyExplicitlyIncluded = true) +public class User { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @EqualsAndHashCode.Include + private Long id; + + @Column(nullable = false) + private String name; + + @Column(nullable = false, unique = true) + private String email; +} diff --git a/core/main-service/src/main/java/ru/yandex/practicum/user/repository/UserRepository.java b/core/main-service/src/main/java/ru/yandex/practicum/user/repository/UserRepository.java new file mode 100644 index 0000000..45a807b --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/user/repository/UserRepository.java @@ -0,0 +1,17 @@ +package ru.yandex.practicum.user.repository; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import ru.yandex.practicum.user.model.User; + +import java.util.List; + +public interface UserRepository extends JpaRepository { + boolean existsByEmail(String email); + + @Query("SELECT u FROM User u") + List findAllList(Pageable pageable); + + List findByIdIn(List ids, Pageable pageable); +} diff --git a/core/main-service/src/main/java/ru/yandex/practicum/user/service/UserService.java b/core/main-service/src/main/java/ru/yandex/practicum/user/service/UserService.java new file mode 100644 index 0000000..ad636a1 --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/user/service/UserService.java @@ -0,0 +1,20 @@ +package ru.yandex.practicum.user.service; + +import org.springframework.data.domain.Pageable; +import ru.yandex.practicum.user.dto.NewUserRequest; +import ru.yandex.practicum.user.dto.UserDto; +import ru.yandex.practicum.user.model.User; + +import java.util.List; + +public interface UserService { + UserDto createUser(NewUserRequest userRequest); + + List getUsers(List ids, Pageable pageable); + + void deleteUser(Long userId); + + User getUserById(Long userId); + + User getUserByIdOrThrow(Long userId); +} diff --git a/core/main-service/src/main/java/ru/yandex/practicum/user/service/UserServiceImpl.java b/core/main-service/src/main/java/ru/yandex/practicum/user/service/UserServiceImpl.java new file mode 100644 index 0000000..d8984d2 --- /dev/null +++ b/core/main-service/src/main/java/ru/yandex/practicum/user/service/UserServiceImpl.java @@ -0,0 +1,76 @@ +package ru.yandex.practicum.user.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import ru.yandex.practicum.exception.ConflictException; +import ru.yandex.practicum.exception.NotFoundException; +import ru.yandex.practicum.user.dto.NewUserRequest; +import ru.yandex.practicum.user.dto.UserDto; +import ru.yandex.practicum.user.mapper.UserMapper; +import ru.yandex.practicum.user.model.User; +import ru.yandex.practicum.user.repository.UserRepository; + +import java.util.List; + +import static org.springframework.data.domain.Sort.Direction.ASC; + +@Slf4j +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class UserServiceImpl implements UserService { + private final UserRepository userRepository; + private final UserMapper userMapper; + + @Override + @Transactional + public UserDto createUser(NewUserRequest userRequest) { + if (userRepository.existsByEmail(userRequest.email())) { + throw new ConflictException("Пользователь с таким e-mail уже существует: " + userRequest.email()); + } + + User user = userMapper.toEntity(userRequest); + user = userRepository.save(user); + log.info("Добавлен новый пользователь {}", user); + return userMapper.toDto(user); + } + + @Override + public List getUsers(List ids, Pageable pageable) { + Pageable sortedPageable = PageRequest.of( + pageable.getPageNumber(), + pageable.getPageSize(), + Sort.by(ASC, "id") + ); + List users = (ids == null || ids.isEmpty()) + ? userRepository.findAllList(sortedPageable) + : userRepository.findByIdIn(ids, sortedPageable); + return users.stream() + .map(userMapper::toDto) + .toList(); + } + + @Override + @Transactional + public void deleteUser(Long userId) { + getUserByIdOrThrow(userId); + log.info("Удален пользователь с id {}", userId); + userRepository.deleteById(userId); + } + + @Override + public User getUserById(Long userId) { + return getUserByIdOrThrow(userId); + } + + @Override + public User getUserByIdOrThrow(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new NotFoundException("Пользователь с id=" + userId + " не найден")); + } +} diff --git a/core/main-service/src/main/resources/application.yaml b/core/main-service/src/main/resources/application.yaml new file mode 100644 index 0000000..9574515 --- /dev/null +++ b/core/main-service/src/main/resources/application.yaml @@ -0,0 +1,21 @@ +spring: + application: + name: main-service + config: + import: "configserver:" + cloud: + config: + discovery: + enabled: true + service-id: config-server + fail-fast: true + +server: + port: 0 + +eureka: + client: + serviceUrl: + defaultZone: http://localhost:8761/eureka/ + instance: + prefer-ip-address: true \ No newline at end of file diff --git a/core/main-service/src/main/resources/schema.sql b/core/main-service/src/main/resources/schema.sql new file mode 100644 index 0000000..59365c2 --- /dev/null +++ b/core/main-service/src/main/resources/schema.sql @@ -0,0 +1,59 @@ +create TABLE IF NOT EXISTS categories ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + name VARCHAR(50) NOT NULL UNIQUE +); + +create TABLE IF NOT EXISTS users ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + name VARCHAR(255) NOT NULL, + email VARCHAR(255) NOT NULL UNIQUE +); + +create TABLE IF NOT EXISTS events ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + title VARCHAR(120) NOT NULL, + annotation VARCHAR(2000) NOT NULL, + description VARCHAR(7000) NOT NULL, + category_id BIGINT REFERENCES categories(id), + initiator_id BIGINT REFERENCES users (id), + created_on TIMESTAMP WITHOUT TIME ZONE NOT NULL, + event_date TIMESTAMP WITHOUT TIME ZONE NOT NULL, + published_on TIMESTAMP WITHOUT TIME ZONE, + paid BOOLEAN NOT NULL, + participant_limit INTEGER NOT NULL, + request_moderation BOOLEAN NOT NULL, + state VARCHAR(20) NOT NULL, + lat FLOAT, + lon FLOAT +); + +create TABLE IF NOT EXISTS requests ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + event_id BIGINT REFERENCES events(id), + requester_id BIGINT REFERENCES users(id), + created TIMESTAMP WITHOUT TIME ZONE, + status VARCHAR(255) +); + +create TABLE IF NOT EXISTS compilations ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + title VARCHAR(50) NOT NULL, + pinned BOOLEAN NOT NULL +); + +create TABLE IF NOT EXISTS compilation_events ( + compilation_id BIGINT NOT NULL REFERENCES compilations (id), + event_id BIGINT NOT NULL REFERENCES events (id), + CONSTRAINT pk_compilation_events PRIMARY KEY (compilation_id, event_id) +); + +create TABLE IF NOT EXISTS comments ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + text TEXT NOT NULL, + user_id BIGINT NOT NULL REFERENCES users (id), + event_id BIGINT NOT NULL REFERENCES events (id), + created_date TIMESTAMP WITHOUT TIME ZONE NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_comments_event_id ON comments(event_id); +CREATE INDEX IF NOT EXISTS idx_comments_user_id ON comments(user_id); \ No newline at end of file diff --git a/core/pom.xml b/core/pom.xml new file mode 100644 index 0000000..18aa957 --- /dev/null +++ b/core/pom.xml @@ -0,0 +1,26 @@ + + + 4.0.0 + + ru.practicum + explore-with-me + 0.0.1-SNAPSHOT + + + ru.yandex.practicum + core + pom + + + main-service + + + + 21 + 21 + UTF-8 + + + \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index be96142..8252b6d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,14 +1,39 @@ services: - stats-server: - ports: - - "9090:9090" stats-db: image: postgres:16.1 - - ewm-service: + container_name: ewm-stats-db-compose ports: - - "8080:8080" + - "6542:5432" + environment: + POSTGRES_USER: stat_user + POSTGRES_PASSWORD: stat_password + POSTGRES_DB: ewm_stats_db + volumes: + - ./stats/stats-server/src/main/resources/schema.sql:/docker-entrypoint-initdb.d/01-schema.sql + + healthcheck: + test: [ "CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB} -p 5432" ] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s ewm-db: image: postgres:16.1 + container_name: ewm-main-db-compose + ports: + - "5432:5432" + environment: + POSTGRES_USER: ewm_user + POSTGRES_PASSWORD: ewm_password + POSTGRES_DB: ewm_main_db + + volumes: + - ./core/main-service/src/main/resources/schema.sql:/docker-entrypoint-initdb.d/01-schema.sql + healthcheck: + test: [ "CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB} -p 5432" ] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s \ No newline at end of file diff --git a/infra/config-server/pom.xml b/infra/config-server/pom.xml new file mode 100644 index 0000000..2bc2fd4 --- /dev/null +++ b/infra/config-server/pom.xml @@ -0,0 +1,41 @@ + + + 4.0.0 + + ru.yandex.practicum + infra + 0.0.1-SNAPSHOT + + + config-server + + + 21 + 21 + UTF-8 + + + + + org.springframework.cloud + spring-cloud-config-server + + + + org.springframework.cloud + spring-cloud-starter-netflix-eureka-client + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + \ No newline at end of file diff --git a/infra/config-server/src/main/java/ru/yandex/practicum/ConfigServer.java b/infra/config-server/src/main/java/ru/yandex/practicum/ConfigServer.java new file mode 100644 index 0000000..2fa15d6 --- /dev/null +++ b/infra/config-server/src/main/java/ru/yandex/practicum/ConfigServer.java @@ -0,0 +1,13 @@ +package ru.yandex.practicum; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cloud.config.server.EnableConfigServer; + +@EnableConfigServer +@SpringBootApplication +public class ConfigServer { + public static void main(String[] args) { + SpringApplication.run(ConfigServer.class, args); + } +} diff --git a/infra/config-server/src/main/resources/application.yaml b/infra/config-server/src/main/resources/application.yaml new file mode 100644 index 0000000..84fdf6a --- /dev/null +++ b/infra/config-server/src/main/resources/application.yaml @@ -0,0 +1,21 @@ +spring: + application: + name: config-server + profiles: + active: native + cloud: + config: + server: + native: + searchLocations: + - classpath:config/main-service/ + - classpath:config/stats-server/ + - classpath:config/gateway-server/ + +server: + port: 0 + +eureka: + client: + serviceUrl: + defaultZone: http://localhost:8761/eureka/ \ No newline at end of file diff --git a/infra/config-server/src/main/resources/config/gateway-server/gateway-server.yaml b/infra/config-server/src/main/resources/config/gateway-server/gateway-server.yaml new file mode 100644 index 0000000..b084036 --- /dev/null +++ b/infra/config-server/src/main/resources/config/gateway-server/gateway-server.yaml @@ -0,0 +1,21 @@ +spring: + cloud: + gateway: + discovery: + locator: + enabled: false + routes: + - id: main_service_route + uri: lb://main-service + predicates: + - Path=/** + +server: + port: 8080 + +logging: + level: + org: + springframework: + cloud: + gateway: DEBUG \ No newline at end of file diff --git a/infra/config-server/src/main/resources/config/main-service/main-service.yaml b/infra/config-server/src/main/resources/config/main-service/main-service.yaml new file mode 100644 index 0000000..64f11b1 --- /dev/null +++ b/infra/config-server/src/main/resources/config/main-service/main-service.yaml @@ -0,0 +1,26 @@ +server: + port: 0 + +spring: + jpa: + show-sql: true + hibernate: + ddl-auto: none + properties: + hibernate: + format_sql: true + sql: + init: + mode: always + datasource: + url: jdbc:postgresql://localhost:5432/ewm_main_db + username: ewm_user + password: ewm_password + +stats-server: + url: http://localhost:9090 + +logging: + level: + root: INFO + ru.practicum.explorewithme.main: DEBUG \ No newline at end of file diff --git a/infra/config-server/src/main/resources/config/stats-server/stats-server.yaml b/infra/config-server/src/main/resources/config/stats-server/stats-server.yaml new file mode 100644 index 0000000..fdc6de4 --- /dev/null +++ b/infra/config-server/src/main/resources/config/stats-server/stats-server.yaml @@ -0,0 +1,19 @@ +server: + port: 9090 + +spring: + datasource: + url: jdbc:postgresql://localhost:6542/ewm_stats_db + username: stat_user + password: stat_password + jpa: + hibernate: + ddl-auto: update + show-sql: true + properties: + hibernate.format_sql: true + dialect: org.hibernate.dialect.PostgreSQLDialect +logging: + level: + org.hibernate.SQL: DEBUG + org.hibernate.type.descriptor.sql.BasicBinder: TRACE \ No newline at end of file diff --git a/infra/discovery-server/pom.xml b/infra/discovery-server/pom.xml new file mode 100644 index 0000000..f6afefa --- /dev/null +++ b/infra/discovery-server/pom.xml @@ -0,0 +1,41 @@ + + + 4.0.0 + + ru.yandex.practicum + infra + 0.0.1-SNAPSHOT + + + discovery-server + + + 21 + 21 + UTF-8 + + + + + org.springframework.cloud + spring-cloud-starter-netflix-eureka-server + + + + org.springframework.boot + spring-boot-starter-actuator + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + \ No newline at end of file diff --git a/infra/discovery-server/src/main/java/ru/yandex/practicum/DiscoveryServer.java b/infra/discovery-server/src/main/java/ru/yandex/practicum/DiscoveryServer.java new file mode 100644 index 0000000..8cdd423 --- /dev/null +++ b/infra/discovery-server/src/main/java/ru/yandex/practicum/DiscoveryServer.java @@ -0,0 +1,13 @@ +package ru.yandex.practicum; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cloud.netflix.eureka.server.EnableEurekaServer; + +@EnableEurekaServer +@SpringBootApplication +public class DiscoveryServer { + public static void main(String[] args) { + SpringApplication.run(DiscoveryServer.class, args); + } +} diff --git a/infra/discovery-server/src/main/resources/application.yaml b/infra/discovery-server/src/main/resources/application.yaml new file mode 100644 index 0000000..592353b --- /dev/null +++ b/infra/discovery-server/src/main/resources/application.yaml @@ -0,0 +1,15 @@ +spring: + application: + name: discovery-server + +server: + port: 8761 + +eureka: + instance: + hostname: localhost + client: + registerWithEureka: false + fetchRegistry: false + serviceUrl: + defaultZone: http://${eureka.instance.hostname}:${server.port}/eureka/ \ No newline at end of file diff --git a/infra/gateway-server/pom.xml b/infra/gateway-server/pom.xml new file mode 100644 index 0000000..3f2ea22 --- /dev/null +++ b/infra/gateway-server/pom.xml @@ -0,0 +1,51 @@ + + + 4.0.0 + + ru.yandex.practicum + infra + 0.0.1-SNAPSHOT + + + gateway-server + + + 21 + 21 + UTF-8 + + + + + org.springframework.cloud + spring-cloud-starter-gateway + + + + org.springframework.cloud + spring-cloud-starter-netflix-eureka-client + + + + org.springframework.cloud + spring-cloud-starter-config + + + + org.springframework.boot + spring-boot-starter-actuator + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + \ No newline at end of file diff --git a/infra/gateway-server/src/main/java/ru/yandex/practicum/GatewayApp.java b/infra/gateway-server/src/main/java/ru/yandex/practicum/GatewayApp.java new file mode 100644 index 0000000..4093ab0 --- /dev/null +++ b/infra/gateway-server/src/main/java/ru/yandex/practicum/GatewayApp.java @@ -0,0 +1,13 @@ +package ru.yandex.practicum; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cloud.client.discovery.EnableDiscoveryClient; + +@EnableDiscoveryClient +@SpringBootApplication +public class GatewayApp { + public static void main(String[] args) { + SpringApplication.run(GatewayApp.class, args); + } +} diff --git a/infra/gateway-server/src/main/resources/application.yaml b/infra/gateway-server/src/main/resources/application.yaml new file mode 100644 index 0000000..eb4c837 --- /dev/null +++ b/infra/gateway-server/src/main/resources/application.yaml @@ -0,0 +1,24 @@ +spring: + application: + name: gateway-server + config: + import: "configserver:" + cloud: + config: + fail-fast: true + retry: + useRandomPolicy: true + max-interval: 6000 + discovery: + enabled: true + service-id: config-server + +eureka: + client: + serviceUrl: + defaultZone: http://localhost:8761/eureka/ + instance: + preferIpAddress: true + hostname: localhost + instance-id: "${spring.application.name}:${random.value}" + leaseRenewalIntervalInSeconds: 10 \ No newline at end of file diff --git a/infra/pom.xml b/infra/pom.xml new file mode 100644 index 0000000..990ddf6 --- /dev/null +++ b/infra/pom.xml @@ -0,0 +1,28 @@ + + + 4.0.0 + + ru.practicum + explore-with-me + 0.0.1-SNAPSHOT + + + ru.yandex.practicum + infra + pom + + + config-server + discovery-server + gateway-server + + + + 21 + 21 + UTF-8 + + + \ No newline at end of file diff --git a/pom.xml b/pom.xml index 5b3a851..d9ce4a2 100644 --- a/pom.xml +++ b/pom.xml @@ -1,187 +1,330 @@ - 4.0.0 - - - org.springframework.boot - spring-boot-starter-parent - 3.3.4 - - - - Explore With Me - - ru.practicum - explore-with-me - 0.0.1-SNAPSHOT - pom - - - 21 - UTF-8 - - - - - - - org.apache.maven.plugins - maven-surefire-plugin - - - test - - - - - org.apache.maven.plugins - maven-checkstyle-plugin - 3.1.2 - - checkstyle.xml - true - true - true - - - - - check - - compile - - - - - com.puppycrawl.tools - checkstyle - 10.3 - - - - - com.github.spotbugs - spotbugs-maven-plugin - 4.8.5.0 - - Max - High - - - - - check - - - - - - org.jacoco - jacoco-maven-plugin - 0.8.12 - - file - - - - jacoco-initialize - - prepare-agent - - - - jacoco-check - - check - - - - - BUNDLE - - - INSTRUCTION - COVEREDRATIO - 0.01 - - - LINE - COVEREDRATIO - 0.2 - - - BRANCH - COVEREDRATIO - 0.2 - - - COMPLEXITY - COVEREDRATIO - 0.2 - - - METHOD - COVEREDRATIO - 0.2 - - - CLASS - MISSEDCOUNT - 1 - - - - - - - - jacoco-site - install - - report - - - - - - - - - - check - - - - org.apache.maven.plugins - maven-checkstyle-plugin - - - com.github.spotbugs - spotbugs-maven-plugin - - - - - - - com.github.spotbugs - spotbugs-maven-plugin - - - - - - coverage - - - - org.jacoco - jacoco-maven-plugin - - - - - + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.3.2 + + + + Explore With Me + + ru.practicum + explore-with-me + 0.0.1-SNAPSHOT + pom + + + stats + infra + core + + + + 21 + UTF-8 + 1.6.3 + 1.11.3 + 3.6.1 + 2.6.0 + 3.1.0.RELEASE + 3.23.4 + 1.63.0 + 2023.0.3 + 1.18.30 + + + 3.11.0 + ${avro.version} + 2.4.0 + + + + + + org.springframework.cloud + spring-cloud-dependencies + ${spring-cloud-dependencies.version} + pom + import + + + + org.apache.avro + avro + ${avro.version} + + + + org.apache.kafka + kafka-clients + ${kafka-clients.version} + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + ${springdoc-openapi-starter.version} + + + + net.devh + grpc-spring-boot-starter + ${grpc-spring-boot-starter.version} + + + + net.devh + grpc-server-spring-boot-starter + ${grpc-spring-boot-starter.version} + + + + io.grpc + grpc-stub + ${grpc.version} + + + + io.grpc + grpc-protobuf + ${grpc.version} + + + + org.springframework.retry + spring-retry + + + + org.mapstruct + mapstruct + ${mapstruct.version} + + + + org.projectlombok + lombok + ${lombok.version} + provided + + + + + + + + + org.apache.avro + avro-maven-plugin + ${avro-maven-plugin.version} + + + + io.github.ascopes + protobuf-maven-plugin + ${protobuf-plugin.version} + + + + org.springframework.boot + spring-boot-maven-plugin + + + true + + + + org.projectlombok + lombok + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + ${java.version} + + + org.projectlombok + lombok + ${lombok.version} + + + org.mapstruct + mapstruct-processor + ${mapstruct.version} + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + test + + + + + + org.apache.maven.plugins + maven-checkstyle-plugin + 3.1.2 + + checkstyle.xml + true + true + true + + + + + check + + compile + + + + + com.puppycrawl.tools + checkstyle + 10.3 + + + + + + com.github.spotbugs + spotbugs-maven-plugin + 4.8.5.0 + + Max + High + + + + + check + + + + + + + org.jacoco + jacoco-maven-plugin + 0.8.12 + + file + + + + jacoco-initialize + + prepare-agent + + + + jacoco-check + + check + + + + + BUNDLE + + + INSTRUCTION + COVEREDRATIO + 0.01 + + + LINE + COVEREDRATIO + 0.2 + + + BRANCH + COVEREDRATIO + 0.2 + + + COMPLEXITY + COVEREDRATIO + 0.2 + + + METHOD + COVEREDRATIO + 0.2 + + + CLASS + MISSEDCOUNT + 1 + + + + + + + + jacoco-site + install + + report + + + + + + + + + + check + + + + org.apache.maven.plugins + maven-checkstyle-plugin + + + com.github.spotbugs + spotbugs-maven-plugin + + + + + + + com.github.spotbugs + spotbugs-maven-plugin + + + + + + coverage + + + + org.jacoco + jacoco-maven-plugin + + + + + diff --git a/postman/feature.json b/postman/feature.json new file mode 100644 index 0000000..3fc3b7f --- /dev/null +++ b/postman/feature.json @@ -0,0 +1,829 @@ +{ + "info": { + "_postman_id": "0b4cd248-a26a-4053-b877-6c6779f465ca", + "name": "Feature", + "description": "Тестирование комментариев", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "_exporter_id": "45621385", + "_collection_link": "https://belkinivan.postman.co/workspace/Belkin-Ivan's-Workspace~70934887-56e4-4bfd-8ac6-47a16ac3b83d/collection/45621385-0b4cd248-a26a-4053-b877-6c6779f465ca?action=share&source=collection_link&creator=45621385" + }, + "item": [ + { + "name": "Admin", + "item": [ + { + "name": "Удаление комментария администратором", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Код ответа должен быть 204\", function () {", + " pm.response.to.have.status(204);", + "});", + "", + "pm.test(\"Тело ответа должно быть пустым\", function () {", + " pm.response.to.have.body('');", + "});" + ], + "type": "text/javascript", + "packages": {}, + "requests": {} + } + }, + { + "listen": "prerequest", + "script": { + "exec": [ + "const prepareAdminTest = async () => {", + " const api = new API(pm);", + " const rnd = new RandomUtils();", + " ", + " try {", + " const user = await api.addUser(rnd.getUser());", + " const category = await api.addCategory(rnd.getCategory());", + " let event = await api.addEvent(user.id, rnd.getEvent(category.id));", + " event = await api.publishEvent(event.id);", + " ", + " const commentData = {", + " text: \"Комментарий для тестирования удаления администратором\"", + " };", + " ", + " const comment = await api.post(", + " `/users/${user.id}/comments/${event.id}`, ", + " commentData, ", + " pm.collectionVariables.get(\"baseUrl\")", + " );", + " ", + " pm.collectionVariables.set(\"adminCommentId\", comment.id);", + " console.log(\"Создан комментарий для удаления администратором:\", comment.id);", + " ", + " } catch(err) {", + " console.error(\"Ошибка при подготовке комментария:\", err);", + " }", + "};", + "", + "prepareAdminTest();" + ], + "type": "text/javascript", + "packages": {}, + "requests": {} + } + } + ], + "request": { + "method": "DELETE", + "header": [], + "url": { + "raw": "{{baseUrl}}/admin/comments/{{adminCommentId}}", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "admin", + "comments", + "{{adminCommentId}}" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Private", + "item": [ + { + "name": "Создание комментария", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "const prepareCommentTest = async () => {", + " const api = new API(pm);", + " const rnd = new RandomUtils();", + " ", + " try {", + " // Создаем пользователя и событие", + " const user = await api.addUser(rnd.getUser());", + " const category = await api.addCategory(rnd.getCategory());", + " let event = await api.addEvent(user.id, rnd.getEvent(category.id));", + " event = await api.publishEvent(event.id);", + " ", + " // Сохраняем ID для использования в тестах", + " pm.collectionVariables.set(\"userId\", user.id);", + " pm.collectionVariables.set(\"eventId\", event.id);", + " ", + " // Подготавливаем данные комментария", + " const commentData = {", + " text: \"Комментарий к событию.\"", + " };", + " ", + " pm.request.body.update({", + " mode: 'raw',", + " raw: JSON.stringify(commentData),", + " options: { raw: { language: 'json' } }", + " });", + " ", + " console.log(\"Подготовлены данные для создания комментария:\", commentData);", + " ", + " } catch(err) {", + " console.error(\"Ошибка при подготовке данных:\", err);", + " }", + "};", + "", + "prepareCommentTest();" + ], + "type": "text/javascript", + "packages": {}, + "requests": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "// Проверка успешного создания комментария", + "pm.test(\"Статус код должен быть 201\", function () {", + " pm.response.to.have.status(201);", + "});", + "", + "pm.test(\"Ответ должен содержать поля комментария\", function () {", + " const response = pm.response.json();", + " pm.expect(response).to.have.property('id');", + " pm.expect(response).to.have.property('text');", + " pm.expect(response).to.have.property('userId');", + " pm.expect(response).to.have.property('eventId');", + " pm.expect(response).to.have.property('createdDate');", + " ", + " // Сохраняем ID комментария для дальнейших тестов", + " pm.collectionVariables.set(\"commentId\", response.id);", + "});", + "", + "pm.test(\"Текст комментария должен совпадать с отправленным\", function () {", + " const response = pm.response.json();", + " const requestBody = JSON.parse(pm.request.body.raw);", + " pm.expect(response.text).to.equal(requestBody.text);", + "});", + "", + "pm.test(\"ID события должны совпадать с отправленным\", function () {", + " const response = pm.response.json();", + " const eventId = pm.collectionVariables.get(\"eventId\");", + " pm.expect(response.eventId).to.equal(parseInt(eventId));", + "});", + "", + "pm.test(\"ID пользователя должны совпадать с отправленным\", function () {", + " const response = pm.response.json();", + " const userId = pm.collectionVariables.get(\"userId\");", + " pm.expect(response.userId).to.equal(parseInt(userId));", + "});" + ], + "type": "text/javascript", + "packages": {}, + "requests": {} + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\"text\": \"Новый комментарий\"}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/users/{{userId}}/comments/{{eventId}}", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "users", + "{{userId}}", + "comments", + "{{eventId}}" + ] + } + }, + "response": [] + }, + { + "name": "Создание слишком длинного комментария", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "// Создаем комментарий длиннее 5000 символов", + "const longText = 'a'.repeat(5001);", + "const invalidComment = {", + " text: longText", + "};", + "", + "pm.request.body.update({", + " mode: 'raw',", + " raw: JSON.stringify(invalidComment),", + " options: { raw: { language: 'json' } }", + "});" + ], + "type": "text/javascript", + "packages": {}, + "requests": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Статус код должен быть 400\", function () {", + " pm.response.to.have.status(400);", + "});" + ], + "type": "text/javascript", + "packages": {}, + "requests": {} + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\"text\": \"a\".repeat(5001)}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/users/{{userId}}/comments/{{eventId}}", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "users", + "{{userId}}", + "comments", + "{{eventId}}" + ] + } + }, + "response": [] + }, + { + "name": "Создание пустого комментария", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "const emptyComment = {", + " text: \"\"", + "};", + "", + "pm.request.body.update({", + " mode: 'raw',", + " raw: JSON.stringify(emptyComment),", + " options: { raw: { language: 'json' } }", + "});" + ], + "type": "text/javascript", + "packages": {}, + "requests": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Статус код должен быть 400\", function () {", + " pm.response.to.have.status(400);", + "});" + ], + "type": "text/javascript", + "packages": {}, + "requests": {} + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\"text\": \"\"}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/users/{{userId}}/comments/{{eventId}}", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "users", + "{{userId}}", + "comments", + "{{eventId}}" + ] + } + }, + "response": [] + }, + { + "name": "Получение комментариев пользователя", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Статус код должен быть 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test(\"В ответе должен быть список комментариев пользователя\", function () {", + " const response = pm.response.json();", + " const userId = pm.collectionVariables.get(\"userId\");", + " ", + " pm.expect(response).to.be.an('array');", + " ", + " response.forEach(comment => {", + " pm.expect(comment.userId).to.equal(parseInt(userId));", + " });", + "});" + ], + "type": "text/javascript", + "packages": {}, + "requests": {} + } + }, + { + "listen": "prerequest", + "script": { + "exec": [ + "" + ], + "type": "text/javascript", + "packages": {}, + "requests": {} + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/users/{{userId}}/comments?from=0&size=10", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "users", + "{{userId}}", + "comments" + ], + "query": [ + { + "key": "from", + "value": "0" + }, + { + "key": "size", + "value": "10" + } + ] + } + }, + "response": [] + }, + { + "name": "Обновление комментария", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "const updateData = {", + " text: \"Обновленный текст комментария\"", + "};", + "", + "pm.request.body.update({", + " mode: 'raw',", + " raw: JSON.stringify(updateData),", + " options: { raw: { language: 'json' } }", + "});" + ], + "type": "text/javascript", + "packages": {}, + "requests": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Статус код должен быть 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test(\"Текст комментария должен быть обновленный\", function () {", + " const response = pm.response.json();", + " const requestBody = JSON.parse(pm.request.body.raw);", + " pm.expect(response.text).to.equal(requestBody.text);", + "});" + ], + "type": "text/javascript", + "packages": {}, + "requests": {} + } + } + ], + "request": { + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\"text\": \"Обновленный текст комментария\"}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/users/{{userId}}/comments/{{eventId}}/{{commentId}}", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "users", + "{{userId}}", + "comments", + "{{eventId}}", + "{{commentId}}" + ] + } + }, + "response": [] + }, + { + "name": "Удаление комментария пользователем", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Статус код должен быть 204\", function () {", + " pm.response.to.have.status(204);", + "});", + "", + "pm.test(\"Тело ответа должно быть пустым\", function () {", + " pm.response.to.have.body('');", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "DELETE", + "header": [], + "url": { + "raw": "{{baseUrl}}/users/{{userId}}/comments/{{commentId}}", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "users", + "{{userId}}", + "comments", + "{{commentId}}" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Public", + "item": [ + { + "name": "Получение комментариев события", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Статус код должен быть 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test(\"В ответе должен быть список комментариев\", function () {", + " const response = pm.response.json();", + " pm.expect(response).to.be.an('array');", + " pm.expect(response.length).to.be.greaterThan(0);", + "});", + "", + "pm.test(\"Комментарии должны относиться к определенному событию\", function () {", + " const response = pm.response.json();", + " const eventId = pm.collectionVariables.get(\"eventId\");", + " ", + " response.forEach(comment => {", + " pm.expect(comment.eventId).to.equal(parseInt(eventId));", + " });", + "});", + "", + "pm.test(\"Комментарии должны быть отсортированы по дате создания\", function () {", + " const response = pm.response.json();", + " if (response.length > 1) {", + " const firstDate = new Date(response[0].createdDate);", + " const secondDate = new Date(response[1].createdDate);", + " pm.expect(firstDate.getTime()).to.be.greaterThan(secondDate.getTime());", + " }", + "});" + ], + "type": "text/javascript", + "packages": {}, + "requests": {} + } + }, + { + "listen": "prerequest", + "script": { + "exec": [ + "const prepareCommentTest = async () => {\r", + " const api = new API(pm);\r", + " const rnd = new RandomUtils();\r", + " \r", + " try {\r", + " // Создаем пользователя и событие\r", + " const user = await api.addUser(rnd.getUser());\r", + " const category = await api.addCategory(rnd.getCategory());\r", + " let event = await api.addEvent(user.id, rnd.getEvent(category.id));\r", + " event = await api.publishEvent(event.id);\r", + " const comment = await api.addComment(user.id, event.id, rnd.getComment()); \r", + " // Сохраняем ID для использования в тестах\r", + " pm.collectionVariables.set(\"userId\", user.id);\r", + " pm.collectionVariables.set(\"eventId\", event.id);\r", + " \r", + " console.log(\"Подготовлены данные для создания комментария:\", comment);\r", + " \r", + " } catch(err) {\r", + " console.error(\"Ошибка при подготовке данных:\", err);\r", + " }\r", + "};\r", + "\r", + "prepareCommentTest();" + ], + "type": "text/javascript", + "packages": {}, + "requests": {} + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/comments/event/{{eventId}}?from=0&size=10", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "comments", + "event", + "{{eventId}}" + ], + "query": [ + { + "key": "from", + "value": "0" + }, + { + "key": "size", + "value": "10" + } + ] + } + }, + "response": [] + } + ] + } + ], + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "requests": {}, + "exec": [ + "// Класс для работы с API комментариев", + "API = class {", + " constructor(postman, verbose = false, baseUrl = \"http://localhost:8080\") {", + " this.baseUrl = baseUrl;", + " this.pm = postman;", + " this._verbose = verbose;", + " }", + "", + " async addUser(user, verbose=null) {", + " return this.post(\"/admin/users\", user, \"Ошибка при добавлении нового пользователя: \", verbose);", + " }", + "", + " async addCategory(category, verbose=null) {", + " return this.post(\"/admin/categories\", category, \"Ошибка при добавлении новой категории: \", verbose);", + " }", + "", + " async addComment(userId, eventId, comment, verbose=null) {", + " return this.post(\"/users/\" + userId + \"/comments/\" + eventId, comment, \"Ошибка при добавлении нового комментария: \", verbose);", + " }", + "", + " async addEvent(userId, event, verbose=null) {", + " return this.post(\"/users/\" + userId + \"/events\", event, \"Ошибка при добавлении нового события: \", verbose);", + " }", + "", + " async publishEvent(eventId, verbose=null) {", + " return this.patch('/admin/events/' + eventId, {stateAction: \"PUBLISH_EVENT\"}, \"Ошибка при публикации события\", verbose);", + " }", + "", + " async post(path, body, errorText = \"Ошибка при выполнении post-запроса: \", verbose=null) {", + " return this.sendRequest(\"POST\", path, body, errorText, verbose);", + " }", + "", + " async patch(path, body = null, errorText = \"Ошибка при выполнении patch-запроса: \", verbose=null) {", + " return this.sendRequest(\"PATCH\", path, body, errorText);", + " }", + "", + " async get(path, body = null, errorText = \"Ошибка при выполнении get-запроса: \", verbose=null) {", + " return this.sendRequest(\"GET\", path, body, errorText, verbose);", + " }", + "", + " async sendRequest(method, path, body=null, errorText = \"Ошибка при выполнении запроса: \", verbose=null) {", + " return new Promise((resolve, reject) => {", + " verbose = verbose == null ? this._verbose : verbose;", + " ", + " const request = {", + " url: this.baseUrl + path,", + " method: method,", + " body: body == null ? \"\" : JSON.stringify(body),", + " header: { \"Content-Type\": \"application/json\" },", + " };", + "", + " if(verbose) {", + " console.log(\"Отправляю запрос: \", request);", + " }", + "", + " try {", + " this.pm.sendRequest(request, (error, response) => {", + " if(error || (response.code >= 400 && response.code <= 599)) {", + " let err = error ? error : JSON.stringify(response.json());", + " console.error(\"При выполнении запроса к серверу возникла ошибка.\\n\", err,", + " \"\\nДля отладки проблемы повторите такой же запрос к вашей программе \" + ", + " \"на локальном компьютере. Данные запроса:\\n\", JSON.stringify(request));", + "", + " reject(new Error(errorText + err));", + " }", + "", + " if(verbose) {", + " console.log(\"Результат обработки запроса: код состояния - \", response.code, \", тело: \", response.json());", + " }", + " ", + " if (response.stream.length === 0){", + " reject(new Error('Отправлено пустое тело ответа'))", + " }else{", + " resolve(response.json());", + " }", + " });", + " } catch(err) {", + " if(verbose) {", + " console.error(errorText, err);", + " }", + " return Promise.reject(err);", + " }", + " });", + " }", + "};", + "", + "RandomUtils = class {", + " constructor() {}", + "", + " getUser() {", + " return {", + " name: pm.variables.replaceIn('{{$randomFullName}}'),", + " email: pm.variables.replaceIn('{{$randomEmail}}')", + " };", + " }", + "", + " getCategory() {", + " return {", + " name: pm.variables.replaceIn('{{$randomWord}}') + Math.floor(Math.random() * 100).toString()", + " };", + " }", + "", + " getComment() {", + " return {", + " text: pm.variables.replaceIn('{{$randomWord}}') + Math.floor(Math.random() * 100).toString()", + " };", + " }", + "", + " getEvent(categoryId) {", + " return {", + " annotation: \"Событие для проверки комментариев\",", + " category: categoryId,", + " description: \"Описание события для проверки комментариев\",", + " eventDate: this.getFutureDateTime(),", + " location: {", + " lat: 55.5555,", + " lon: 44.4444,", + " },", + " paid: false,", + " participantLimit: 0,", + " requestModeration: false,", + " title: \"Событие для проверки комментариев\",", + " }", + " }", + " ", + " getFutureDateTime(hourShift = 5, minuteShift=0, yearShift=0) {", + " let moment = require('moment');", + "", + " let m = moment();", + " m.add(hourShift, 'hour');", + " m.add(minuteShift, 'minute');", + " m.add(yearShift, 'year');", + "", + " return m.format('YYYY-MM-DD HH:mm:ss');", + " }", + "}" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "packages": {}, + "requests": {}, + "exec": [ + "" + ] + } + } + ], + "variable": [ + { + "key": "baseUrl", + "value": "http://localhost:8080" + }, + { + "key": "userId", + "value": "" + }, + { + "key": "eventId", + "value": "" + }, + { + "key": "commentId", + "value": "" + }, + { + "key": "adminCommentId", + "value": "" + }, + { + "key": "userEmail", + "value": "" + } + ] +} \ No newline at end of file diff --git a/stats/pom.xml b/stats/pom.xml new file mode 100644 index 0000000..c6c54a8 --- /dev/null +++ b/stats/pom.xml @@ -0,0 +1,36 @@ + + + 4.0.0 + + + ru.practicum + explore-with-me + 0.0.1-SNAPSHOT + + + ru.yandex.practicum + stats + pom + + + stats-client + stats-dto + stats-server + + + + + org.springframework.boot + spring-boot-starter-web + + + + org.projectlombok + lombok + + + + + diff --git a/stats/stats-client/pom.xml b/stats/stats-client/pom.xml new file mode 100644 index 0000000..f00d66c --- /dev/null +++ b/stats/stats-client/pom.xml @@ -0,0 +1,55 @@ + + + 4.0.0 + + + ru.yandex.practicum + stats + 0.0.1-SNAPSHOT + + + stats-client + + + 21 + 21 + UTF-8 + + + + + org.springframework.boot + spring-boot-starter-web + + + + org.projectlombok + lombok + true + + + + ru.yandex.practicum + stats-dto + 0.0.1-SNAPSHOT + + + + org.springframework.cloud + spring-cloud-starter-netflix-eureka-client + + + + org.springframework.cloud + spring-cloud-starter-config + + + + org.springframework.cloud + spring-cloud-commons + + + + diff --git a/stats/stats-client/src/main/java/ru/yandex/practicum/StatsClientApp.java b/stats/stats-client/src/main/java/ru/yandex/practicum/StatsClientApp.java new file mode 100644 index 0000000..8c3fb01 --- /dev/null +++ b/stats/stats-client/src/main/java/ru/yandex/practicum/StatsClientApp.java @@ -0,0 +1,77 @@ +package ru.yandex.practicum; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import org.springframework.web.util.UriBuilder; +import ru.yandex.practicum.exception.StatsClientException; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; + +import static ru.yandex.practicum.constants.DateTimeConstants.DATE_TIME_PATTERN; + +@Slf4j +@Component +public class StatsClientApp { + private final RestClient restClient; + private final DateTimeFormatter formatter = DateTimeFormatter.ofPattern(DATE_TIME_PATTERN); + + public StatsClientApp(@Value("${stats-server.url:http://localhost:9090}") String serverUrl) { + this.restClient = RestClient.builder() + .baseUrl(serverUrl) + .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .build(); + } + + public void saveHit(CreateEndpointHitDto createEndpointHitDto) { + try { + restClient.post() + .uri("/hit") + .body(createEndpointHitDto) + .retrieve() + .toBodilessEntity(); + + log.debug("Запись обращения к эндпоинту успешно сохранена: приложение {}, URI {}", + createEndpointHitDto.app(), createEndpointHitDto.uri()); + } catch (Exception exception) { + log.error("Ошибка при сохранении статистики: {}", exception.getMessage()); + throw new StatsClientException("Endpoint statistics could not be saved", exception); + } + } + + public List getStats(LocalDateTime start, LocalDateTime end, + List uris, boolean unique) { + try { + List result = restClient.get() + .uri(uriBuilder -> { + UriBuilder builder = uriBuilder.path("/stats") + .queryParam("start", start.format(formatter)) + .queryParam("end", end.format(formatter)); + + if (uris != null && !uris.isEmpty()) { + uris.forEach(uri -> builder.queryParam("uris", uri)); + } + + builder.queryParam("unique", unique); + + return builder.build(); + }) + .retrieve() + .body(new ParameterizedTypeReference<>() { + }); + + log.debug("Получено записей статистики: {}", result != null ? result.size() : 0); + + return result; + } catch (Exception exception) { + log.error("Ошибка при получении статистики: {}", exception.getMessage()); + throw new StatsClientException("Statistics could not be retrieved", exception); + } + } +} diff --git a/stats/stats-client/src/main/java/ru/yandex/practicum/exception/StatsClientException.java b/stats/stats-client/src/main/java/ru/yandex/practicum/exception/StatsClientException.java new file mode 100644 index 0000000..ee42c9a --- /dev/null +++ b/stats/stats-client/src/main/java/ru/yandex/practicum/exception/StatsClientException.java @@ -0,0 +1,7 @@ +package ru.yandex.practicum.exception; + +public class StatsClientException extends RuntimeException { + public StatsClientException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/stats/stats-dto/pom.xml b/stats/stats-dto/pom.xml new file mode 100644 index 0000000..500a307 --- /dev/null +++ b/stats/stats-dto/pom.xml @@ -0,0 +1,22 @@ + + + 4.0.0 + + + ru.yandex.practicum + stats + 0.0.1-SNAPSHOT + + + stats-dto + + + + org.springframework.boot + spring-boot-starter-validation + + + + diff --git a/stats/stats-dto/src/main/java/ru/yandex/practicum/CreateEndpointHitDto.java b/stats/stats-dto/src/main/java/ru/yandex/practicum/CreateEndpointHitDto.java new file mode 100644 index 0000000..8e8f523 --- /dev/null +++ b/stats/stats-dto/src/main/java/ru/yandex/practicum/CreateEndpointHitDto.java @@ -0,0 +1,25 @@ +package ru.yandex.practicum; + +import com.fasterxml.jackson.annotation.JsonFormat; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +import java.time.LocalDateTime; + +import static ru.yandex.practicum.constants.DateTimeConstants.DATE_TIME_PATTERN; + +public record CreateEndpointHitDto( + @NotBlank(message = "Название приложения не может быть пустым") + String app, + + @NotBlank(message = "URI не может быть пустым") + String uri, + + @NotBlank(message = "IP адрес не может быть пустым") + String ip, + + @NotNull(message = "Временная метка не может быть null") + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = DATE_TIME_PATTERN) + LocalDateTime timestamp +) { +} diff --git a/stats/stats-dto/src/main/java/ru/yandex/practicum/StatsRequest.java b/stats/stats-dto/src/main/java/ru/yandex/practicum/StatsRequest.java new file mode 100644 index 0000000..307f942 --- /dev/null +++ b/stats/stats-dto/src/main/java/ru/yandex/practicum/StatsRequest.java @@ -0,0 +1,24 @@ +package ru.yandex.practicum; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; + +import static ru.yandex.practicum.constants.DateTimeConstants.DATE_TIME_PATTERN; + +public record StatsRequest( + LocalDateTime start, + LocalDateTime end, + List uris, + boolean unique +) { + public static StatsRequest of(String start, String end, List uris, boolean unique) { + DateTimeFormatter formatter = DateTimeFormatter.ofPattern(DATE_TIME_PATTERN); + return new StatsRequest( + LocalDateTime.parse(start, formatter), + LocalDateTime.parse(end, formatter), + uris, + unique + ); + } +} diff --git a/stats/stats-dto/src/main/java/ru/yandex/practicum/ViewStatsDto.java b/stats/stats-dto/src/main/java/ru/yandex/practicum/ViewStatsDto.java new file mode 100644 index 0000000..541d276 --- /dev/null +++ b/stats/stats-dto/src/main/java/ru/yandex/practicum/ViewStatsDto.java @@ -0,0 +1,8 @@ +package ru.yandex.practicum; + +public record ViewStatsDto( + String app, + String uri, + Long hits +) { +} diff --git a/stats/stats-dto/src/main/java/ru/yandex/practicum/constants/DateTimeConstants.java b/stats/stats-dto/src/main/java/ru/yandex/practicum/constants/DateTimeConstants.java new file mode 100644 index 0000000..10d19ff --- /dev/null +++ b/stats/stats-dto/src/main/java/ru/yandex/practicum/constants/DateTimeConstants.java @@ -0,0 +1,9 @@ +package ru.yandex.practicum.constants; + +public final class DateTimeConstants { + + private DateTimeConstants() { + } + + public static final String DATE_TIME_PATTERN = "yyyy-MM-dd HH:mm:ss"; +} diff --git a/stats/stats-server/Dockerfile b/stats/stats-server/Dockerfile new file mode 100644 index 0000000..0ff1817 --- /dev/null +++ b/stats/stats-server/Dockerfile @@ -0,0 +1,5 @@ +FROM eclipse-temurin:21-jre-jammy +VOLUME /tmp +ARG JAR_FILE=target/*.jar +COPY ${JAR_FILE} app.jar +ENTRYPOINT ["sh", "-c", "java ${JAVA_OPTS} -jar /app.jar"] \ No newline at end of file diff --git a/stats/stats-server/pom.xml b/stats/stats-server/pom.xml new file mode 100644 index 0000000..c6fa37c --- /dev/null +++ b/stats/stats-server/pom.xml @@ -0,0 +1,92 @@ + + + 4.0.0 + + + ru.yandex.practicum + stats + 0.0.1-SNAPSHOT + + + stats-server + + + 21 + 21 + UTF-8 + + + + + ru.yandex.practicum + stats-dto + 0.0.1-SNAPSHOT + compile + + + + org.springframework.boot + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-starter-actuator + + + + org.projectlombok + lombok + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + org.postgresql + postgresql + runtime + + + + org.mapstruct + mapstruct + ${mapstruct.version} + + + + com.h2database + h2 + runtime + + + + org.springframework.cloud + spring-cloud-starter-config + + + + org.springframework.cloud + spring-cloud-starter-netflix-eureka-client + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + org.apache.maven.plugins + maven-compiler-plugin + + + + + \ No newline at end of file diff --git a/stats/stats-server/src/main/java/ru/yandex/practicum/StatServerApp.java b/stats/stats-server/src/main/java/ru/yandex/practicum/StatServerApp.java new file mode 100644 index 0000000..789c299 --- /dev/null +++ b/stats/stats-server/src/main/java/ru/yandex/practicum/StatServerApp.java @@ -0,0 +1,13 @@ +package ru.yandex.practicum; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; + +@SpringBootApplication +@ConfigurationPropertiesScan +public class StatServerApp { + public static void main(String[] args) { + SpringApplication.run(StatServerApp.class, args); + } +} diff --git a/stats/stats-server/src/main/java/ru/yandex/practicum/controller/StatsController.java b/stats/stats-server/src/main/java/ru/yandex/practicum/controller/StatsController.java new file mode 100644 index 0000000..0d7c10e --- /dev/null +++ b/stats/stats-server/src/main/java/ru/yandex/practicum/controller/StatsController.java @@ -0,0 +1,42 @@ +package ru.yandex.practicum.controller; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; +import ru.yandex.practicum.CreateEndpointHitDto; +import ru.yandex.practicum.StatsRequest; +import ru.yandex.practicum.ViewStatsDto; +import ru.yandex.practicum.exception.BadRequestException; +import ru.yandex.practicum.service.StatsService; + +import java.util.List; + +@Slf4j +@RestController +@RequiredArgsConstructor +public class StatsController { + private final StatsService statsService; + + @PostMapping("/hit") + @ResponseStatus(HttpStatus.CREATED) + public void createHit(@RequestBody @Valid CreateEndpointHitDto createEndpointHitDto) { + log.debug("Controller: createHit requestBody={}", createEndpointHitDto); + statsService.createHit(createEndpointHitDto); + } + + @GetMapping("/stats") + public List getStats(@RequestParam("start") String start, @RequestParam("end") String end, + @RequestParam(value = "uris", required = false) List uris, + @RequestParam(value = "unique", defaultValue = "false") boolean unique) { + StatsRequest request = StatsRequest.of(start, end, uris, unique); + + if (request.start().isAfter(request.end())) { + throw new BadRequestException("Дата начала должна быть раньше даты окончания"); + } + + log.debug("Controller: getStats request={}", request); + return statsService.getStats(request); + } +} diff --git a/stats/stats-server/src/main/java/ru/yandex/practicum/exception/BadRequestException.java b/stats/stats-server/src/main/java/ru/yandex/practicum/exception/BadRequestException.java new file mode 100644 index 0000000..4340bf4 --- /dev/null +++ b/stats/stats-server/src/main/java/ru/yandex/practicum/exception/BadRequestException.java @@ -0,0 +1,7 @@ +package ru.yandex.practicum.exception; + +public class BadRequestException extends RuntimeException { + public BadRequestException(String message) { + super(message); + } +} diff --git a/stats/stats-server/src/main/java/ru/yandex/practicum/exception/ErrorHandler.java b/stats/stats-server/src/main/java/ru/yandex/practicum/exception/ErrorHandler.java new file mode 100644 index 0000000..ce7907f --- /dev/null +++ b/stats/stats-server/src/main/java/ru/yandex/practicum/exception/ErrorHandler.java @@ -0,0 +1,29 @@ +package ru.yandex.practicum.exception; + +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public class ErrorHandler { + @ExceptionHandler(BadRequestException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ErrorResponse handleBadRequestException(BadRequestException exception, HttpServletRequest request) { + return buildResponse(HttpStatus.BAD_REQUEST, exception, request); + } + + private ErrorResponse buildResponse(HttpStatus httpStatus, Exception exception, HttpServletRequest request) { + return buildResponse(httpStatus, exception.getMessage(), exception, request); + } + + private ErrorResponse buildResponse(HttpStatus httpStatus, String message, Exception exception, HttpServletRequest request) { + String path = request.getRequestURI(); + String httpMethod = request.getMethod(); + int statusCode = httpStatus.value(); + String error = httpStatus.getReasonPhrase(); + + return new ErrorResponse(path, httpMethod, statusCode, error, message); + } +} diff --git a/stats/stats-server/src/main/java/ru/yandex/practicum/exception/ErrorResponse.java b/stats/stats-server/src/main/java/ru/yandex/practicum/exception/ErrorResponse.java new file mode 100644 index 0000000..8f0532e --- /dev/null +++ b/stats/stats-server/src/main/java/ru/yandex/practicum/exception/ErrorResponse.java @@ -0,0 +1,10 @@ +package ru.yandex.practicum.exception; + +public record ErrorResponse( + String path, + String httpMethod, + int statusCode, + String error, + String message +) { +} \ No newline at end of file diff --git a/stats/stats-server/src/main/java/ru/yandex/practicum/mapper/EndpointHitMapper.java b/stats/stats-server/src/main/java/ru/yandex/practicum/mapper/EndpointHitMapper.java new file mode 100644 index 0000000..7ffff00 --- /dev/null +++ b/stats/stats-server/src/main/java/ru/yandex/practicum/mapper/EndpointHitMapper.java @@ -0,0 +1,13 @@ +package ru.yandex.practicum.mapper; + +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import ru.yandex.practicum.CreateEndpointHitDto; +import ru.yandex.practicum.model.EndpointHit; + +@Mapper(componentModel = "spring") +public interface EndpointHitMapper { + + @Mapping(target = "id", ignore = true) + EndpointHit fromNewRequest(CreateEndpointHitDto request); +} diff --git a/stats/stats-server/src/main/java/ru/yandex/practicum/model/EndpointHit.java b/stats/stats-server/src/main/java/ru/yandex/practicum/model/EndpointHit.java new file mode 100644 index 0000000..e224bbf --- /dev/null +++ b/stats/stats-server/src/main/java/ru/yandex/practicum/model/EndpointHit.java @@ -0,0 +1,26 @@ +package ru.yandex.practicum.model; + +import jakarta.persistence.*; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "endpoint_hits") +@Getter +@Setter +@EqualsAndHashCode(onlyExplicitlyIncluded = true) +public class EndpointHit { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @EqualsAndHashCode.Include + @Column(name = "hit_id") + private Long id; + + private String app; + private String uri; + private String ip; + private LocalDateTime timestamp; +} diff --git a/stats/stats-server/src/main/java/ru/yandex/practicum/repository/StatsRepository.java b/stats/stats-server/src/main/java/ru/yandex/practicum/repository/StatsRepository.java new file mode 100644 index 0000000..21c10af --- /dev/null +++ b/stats/stats-server/src/main/java/ru/yandex/practicum/repository/StatsRepository.java @@ -0,0 +1,31 @@ +package ru.yandex.practicum.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import ru.yandex.practicum.ViewStatsDto; +import ru.yandex.practicum.model.EndpointHit; + +import java.time.LocalDateTime; +import java.util.List; + +public interface StatsRepository extends JpaRepository { + @Query(""" + SELECT new ru.yandex.practicum.ViewStatsDto(e.app, e.uri, COUNT(DISTINCT e.ip)) + FROM EndpointHit e + WHERE e.timestamp BETWEEN :start AND :end + AND (:uris IS NULL OR e.uri IN :uris) + GROUP BY e.app, e.uri + ORDER BY COUNT(DISTINCT e.ip) DESC + """) + List getUniqueStats(LocalDateTime start, LocalDateTime end, List uris); + + @Query(""" + SELECT new ru.yandex.practicum.ViewStatsDto(e.app, e.uri, COUNT(e)) + FROM EndpointHit e + WHERE e.timestamp BETWEEN :start AND :end + AND (:uris IS NULL OR e.uri IN :uris) + GROUP BY e.app, e.uri + ORDER BY COUNT(e) DESC + """) + List getStats(LocalDateTime start, LocalDateTime end, List uris); +} diff --git a/stats/stats-server/src/main/java/ru/yandex/practicum/service/StatsService.java b/stats/stats-server/src/main/java/ru/yandex/practicum/service/StatsService.java new file mode 100644 index 0000000..0d19776 --- /dev/null +++ b/stats/stats-server/src/main/java/ru/yandex/practicum/service/StatsService.java @@ -0,0 +1,13 @@ +package ru.yandex.practicum.service; + +import ru.yandex.practicum.CreateEndpointHitDto; +import ru.yandex.practicum.StatsRequest; +import ru.yandex.practicum.ViewStatsDto; + +import java.util.List; + +public interface StatsService { + void createHit(CreateEndpointHitDto createEndpointHitDto); + + List getStats(StatsRequest request); +} diff --git a/stats/stats-server/src/main/java/ru/yandex/practicum/service/StatsServiceImpl.java b/stats/stats-server/src/main/java/ru/yandex/practicum/service/StatsServiceImpl.java new file mode 100644 index 0000000..43c8418 --- /dev/null +++ b/stats/stats-server/src/main/java/ru/yandex/practicum/service/StatsServiceImpl.java @@ -0,0 +1,45 @@ +package ru.yandex.practicum.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import ru.yandex.practicum.CreateEndpointHitDto; +import ru.yandex.practicum.StatsRequest; +import ru.yandex.practicum.ViewStatsDto; +import ru.yandex.practicum.mapper.EndpointHitMapper; +import ru.yandex.practicum.model.EndpointHit; +import ru.yandex.practicum.repository.StatsRepository; + +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class StatsServiceImpl implements StatsService { + private final StatsRepository statsRepository; + private final EndpointHitMapper hitMapper; + + @Override + @Transactional + public void createHit(CreateEndpointHitDto createEndpointHitDto) { + EndpointHit hit = hitMapper.fromNewRequest(createEndpointHitDto); + hit = statsRepository.save(hit); + log.info("Отправлен запрос на сохранение информации id={}", hit.getId()); + } + + @Override + public List getStats(StatsRequest request) { + List uris = (request.uris() == null || request.uris().isEmpty()) + ? null + : request.uris(); + + List result = request.unique() + ? statsRepository.getUniqueStats(request.start(), request.end(), uris) + : statsRepository.getStats(request.start(), request.end(), uris); + + log.info("Размер полученного списка статистики: {}", result.size()); + return result; + } +} diff --git a/stats/stats-server/src/main/resources/application.yaml b/stats/stats-server/src/main/resources/application.yaml new file mode 100644 index 0000000..50188d5 --- /dev/null +++ b/stats/stats-server/src/main/resources/application.yaml @@ -0,0 +1,21 @@ +spring: + application: + name: stats-server + config: + import: "configserver:" + cloud: + config: + discovery: + enabled: true + service-id: config-server + fail-fast: true + +server: + port: 0 + +eureka: + client: + serviceUrl: + defaultZone: http://localhost:8761/eureka/ + instance: + prefer-ip-address: true \ No newline at end of file diff --git a/stats/stats-server/src/main/resources/schema.sql b/stats/stats-server/src/main/resources/schema.sql new file mode 100644 index 0000000..bb8b290 --- /dev/null +++ b/stats/stats-server/src/main/resources/schema.sql @@ -0,0 +1,8 @@ +create TABLE IF NOT EXISTS endpoint_hits ( + hit_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + app VARCHAR(255) NOT NULL, + uri VARCHAR(2000) NOT NULL, + ip VARCHAR(45) NOT NULL, + timestamp TIMESTAMP WITHOUT TIME ZONE NOT NULL +); +create index IF NOT EXISTS idx_endpoint_hits_app_uri_timestamp ON endpoint_hits(app, uri, timestamp); \ No newline at end of file