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
-
-
-
-
-
- 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
+
+
+
+
+
+ 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