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/.gitignore b/.gitignore
index 64703cb..744eca1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -20,3 +20,4 @@ target/
*.iws
*.iml
*.ipr
+/.run/
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 (docker).run.xml b/.run/MainApplication.run.xml
similarity index 53%
rename from .run/MainApplication (docker).run.xml
rename to .run/MainApplication.run.xml
index 6595b73..d0e71e8 100644
--- a/.run/MainApplication (docker).run.xml
+++ b/.run/MainApplication.run.xml
@@ -1,9 +1,8 @@
-
-
-
+
+
diff --git a/.run/StatApplication (docker).run.xml b/.run/StatApplication (docker).run.xml
deleted file mode 100644
index b2b26b0..0000000
--- a/.run/StatApplication (docker).run.xml
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
\ 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
index ba300a8..65d72ff 100644
--- a/.run/StatApplication.run.xml
+++ b/.run/StatApplication.run.xml
@@ -1,12 +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/compose-local.yaml b/compose-local.yaml
new file mode 100644
index 0000000..33f6f10
--- /dev/null
+++ b/compose-local.yaml
@@ -0,0 +1,31 @@
+name: java-explore-with-me-local
+services:
+ stats-db-local:
+ image: postgres:16.1
+ container_name: stats_db_local
+ ports:
+ - "6542:5432"
+ environment:
+ - POSTGRES_DB=statDB_local
+ - POSTGRES_USER=localuser
+ - POSTGRES_PASSWORD=localpass
+ healthcheck:
+ test: pg_isready -q -d $$POSTGRES_DB -U $$POSTGRES_USER
+ timeout: 5s
+ interval: 5s
+ retries: 10
+
+ main-db-local:
+ image: postgres:16.1
+ container_name: main_db_local
+ ports:
+ - "6543:5432"
+ environment:
+ - POSTGRES_DB=mainDB_local
+ - POSTGRES_USER=localuser
+ - POSTGRES_PASSWORD=localpass
+ healthcheck:
+ test: pg_isready -q -d $$POSTGRES_DB -U $$POSTGRES_USER
+ timeout: 5s
+ interval: 5s
+ retries: 10
\ No newline at end of file
diff --git a/compose.yaml b/compose.yaml
index 215df4e..07cc69d 100644
--- a/compose.yaml
+++ b/compose.yaml
@@ -1,3 +1,4 @@
+name: java-explore-with-me
services:
stats-db:
image: postgres:16.1
@@ -14,13 +15,13 @@ services:
interval: 5s
retries: 10
- main-db:
+ ewm-db:
image: postgres:16.1
- container_name: main_db
+ container_name: ewm_db
ports:
- - "6542:5432"
+ - "5433:5432"
environment:
- - POSTGRES_DB=mainDB
+ - POSTGRES_DB=ewmDB
- POSTGRES_USER=dbuser
- POSTGRES_PASSWORD=12345
healthcheck:
@@ -41,14 +42,16 @@ services:
- SPRING_DATASOURCE_USERNAME=dbuser
- SPRING_DATASOURCE_PASSWORD=12345
- main-service:
+ ewm-service:
build: ./main-service
- container_name: main_service
+ container_name: ewm_service
ports:
- "8080:8080"
depends_on:
- - main-db
+ - stats-server
+ - ewm-db
environment:
- - SPRING_DATASOURCE_URL=jdbc:postgresql://main-db:5432/mainDB
+ - SPRING_DATASOURCE_URL=jdbc:postgresql://ewm-db:5432/ewmDB
- SPRING_DATASOURCE_USERNAME=dbuser
- SPRING_DATASOURCE_PASSWORD=12345
+ - STATS_SERVER_URL=http://stats-server:9090
\ No newline at end of file
diff --git a/main-service/pom.xml b/main-service/pom.xml
index 7335124..05cdb5c 100644
--- a/main-service/pom.xml
+++ b/main-service/pom.xml
@@ -15,50 +15,48 @@
Main Service
+
+ ru.practicum
+ stats-client
+ 0.0.1-SNAPSHOT
+ compile
+
org.springframework.boot
spring-boot-starter-web
-
org.springframework.boot
spring-boot-starter-actuator
-
org.springframework.boot
spring-boot-starter-validation
-
org.postgresql
postgresql
runtime
-
-
- ru.practicum
- stats-client
- 0.0.1-SNAPSHOT
- compile
-
-
com.h2database
h2
runtime
-
org.projectlombok
lombok
provided
-
org.springframework.boot
spring-boot-starter-data-jpa
+
+ org.mapstruct
+ mapstruct
+ ${mapstruct.version}
+
@@ -67,6 +65,10 @@
org.springframework.boot
spring-boot-maven-plugin
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+
diff --git a/main-service/src/main/java/ru/practicum/category/controller/AdminCategoryController.java b/main-service/src/main/java/ru/practicum/category/controller/AdminCategoryController.java
new file mode 100644
index 0000000..859fbf6
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/category/controller/AdminCategoryController.java
@@ -0,0 +1,40 @@
+package ru.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.web.bind.annotation.*;
+import ru.practicum.category.dto.CategoryDto;
+import ru.practicum.category.dto.NewCategoryDto;
+import ru.practicum.category.service.CategoryService;
+
+@Slf4j
+@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 id={}", categoryId);
+ categoryService.deleteCategory(categoryId);
+ }
+
+ @PatchMapping("/{categoryId}")
+ public CategoryDto updateCategory(@PathVariable @Positive Long categoryId,
+ @RequestBody @Valid NewCategoryDto newCategoryDto) {
+ log.debug("Controller: updateCategory id={}, data={}", categoryId, newCategoryDto);
+ return categoryService.updateCategory(categoryId, newCategoryDto);
+ }
+}
diff --git a/main-service/src/main/java/ru/practicum/category/controller/PublicCategoryController.java b/main-service/src/main/java/ru/practicum/category/controller/PublicCategoryController.java
new file mode 100644
index 0000000..367393b
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/category/controller/PublicCategoryController.java
@@ -0,0 +1,37 @@
+package ru.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.web.bind.annotation.*;
+import ru.practicum.category.dto.CategoryDto;
+import ru.practicum.category.service.CategoryService;
+
+import java.util.List;
+
+@Slf4j
+@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) {
+ int page = from / size;
+ Pageable pageable = PageRequest.of(page, size);
+ log.debug("Controller: getCategories with from={}, size={}", from, size);
+ return categoryService.getCategories(pageable);
+ }
+
+ @GetMapping("/{categoryId}")
+ public CategoryDto getCategoryById(@PathVariable @Positive Long categoryId) {
+ log.debug("Controller: getCategoryById with id={}", categoryId);
+ return categoryService.getCategoryById(categoryId);
+ }
+}
diff --git a/main-service/src/main/java/ru/practicum/category/dto/CategoryDto.java b/main-service/src/main/java/ru/practicum/category/dto/CategoryDto.java
new file mode 100644
index 0000000..d5e735b
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/category/dto/CategoryDto.java
@@ -0,0 +1,13 @@
+package ru.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/main-service/src/main/java/ru/practicum/category/dto/NewCategoryDto.java b/main-service/src/main/java/ru/practicum/category/dto/NewCategoryDto.java
new file mode 100644
index 0000000..a57e7e5
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/category/dto/NewCategoryDto.java
@@ -0,0 +1,11 @@
+package ru.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/main-service/src/main/java/ru/practicum/category/mapper/CategoryMapper.java b/main-service/src/main/java/ru/practicum/category/mapper/CategoryMapper.java
new file mode 100644
index 0000000..c8682f9
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/category/mapper/CategoryMapper.java
@@ -0,0 +1,22 @@
+package ru.practicum.category.mapper;
+
+import org.mapstruct.Mapper;
+import org.mapstruct.Mapping;
+import org.mapstruct.MappingTarget;
+import ru.practicum.category.dto.CategoryDto;
+import ru.practicum.category.dto.NewCategoryDto;
+import ru.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/main-service/src/main/java/ru/practicum/category/model/Category.java b/main-service/src/main/java/ru/practicum/category/model/Category.java
new file mode 100644
index 0000000..5daf678
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/category/model/Category.java
@@ -0,0 +1,23 @@
+package ru.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/main-service/src/main/java/ru/practicum/category/repository/CategoryRepository.java b/main-service/src/main/java/ru/practicum/category/repository/CategoryRepository.java
new file mode 100644
index 0000000..96920b0
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/category/repository/CategoryRepository.java
@@ -0,0 +1,14 @@
+package ru.practicum.category.repository;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.repository.query.Param;
+import ru.practicum.category.model.Category;
+
+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);
+}
diff --git a/main-service/src/main/java/ru/practicum/category/service/CategoryService.java b/main-service/src/main/java/ru/practicum/category/service/CategoryService.java
new file mode 100644
index 0000000..292cfb5
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/category/service/CategoryService.java
@@ -0,0 +1,22 @@
+package ru.practicum.category.service;
+
+import org.springframework.data.domain.Pageable;
+import ru.practicum.category.dto.CategoryDto;
+import ru.practicum.category.dto.NewCategoryDto;
+import ru.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/main-service/src/main/java/ru/practicum/category/service/CategoryServiceImpl.java b/main-service/src/main/java/ru/practicum/category/service/CategoryServiceImpl.java
new file mode 100644
index 0000000..8f12c67
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/category/service/CategoryServiceImpl.java
@@ -0,0 +1,107 @@
+package ru.practicum.category.service;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import ru.practicum.category.dto.CategoryDto;
+import ru.practicum.category.dto.NewCategoryDto;
+import ru.practicum.category.mapper.CategoryMapper;
+import ru.practicum.category.model.Category;
+import ru.practicum.category.repository.CategoryRepository;
+import ru.practicum.event.repository.EventRepository;
+import ru.practicum.exception.ConflictException;
+import ru.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) {
+ Page categoriesPage = categoryRepository.findAll(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/main-service/src/main/java/ru/practicum/comment/controller/AdminCommentController.java b/main-service/src/main/java/ru/practicum/comment/controller/AdminCommentController.java
new file mode 100644
index 0000000..d84ee12
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/comment/controller/AdminCommentController.java
@@ -0,0 +1,22 @@
+package ru.practicum.comment.controller;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.annotation.*;
+import ru.practicum.comment.service.CommentService;
+
+@Slf4j
+@RestController
+@RequestMapping("/admin/comments")
+@RequiredArgsConstructor
+public class AdminCommentController {
+ private final CommentService commentService;
+
+ @DeleteMapping("/{commentId}")
+ @ResponseStatus(HttpStatus.NO_CONTENT)
+ public void deleteCommentAdmin(@PathVariable Long commentId) {
+ log.debug("Controller: deleteCommentAdmin with commentId={}", commentId);
+ commentService.deleteCommentAdmin(commentId);
+ }
+}
\ No newline at end of file
diff --git a/main-service/src/main/java/ru/practicum/comment/controller/PrivateCommentController.java b/main-service/src/main/java/ru/practicum/comment/controller/PrivateCommentController.java
new file mode 100644
index 0000000..9350d82
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/comment/controller/PrivateCommentController.java
@@ -0,0 +1,62 @@
+package ru.practicum.comment.controller;
+
+import jakarta.validation.constraints.Positive;
+import jakarta.validation.constraints.PositiveOrZero;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.annotation.*;
+import ru.practicum.comment.dto.CommentDto;
+import ru.practicum.comment.dto.NewCommentDto;
+import ru.practicum.comment.dto.UpdateCommentDto;
+import ru.practicum.comment.service.CommentService;
+
+import java.util.List;
+
+@Slf4j
+@RestController
+@RequestMapping("/users/{userId}/comments")
+@RequiredArgsConstructor
+public class PrivateCommentController {
+ private final CommentService commentService;
+
+ @PostMapping("/{eventId}")
+ @ResponseStatus(HttpStatus.CREATED)
+ public CommentDto createComment(@PathVariable Long userId,
+ @PathVariable Long eventId,
+ @RequestBody @Valid NewCommentDto newCommentDto
+ ) {
+ log.debug("Controller: createComment with userId={}, eventId={}, newCommentDto={}", userId, eventId, newCommentDto);
+ return commentService.createComment(userId, eventId, newCommentDto);
+ }
+
+ @PatchMapping("/{eventId}/{commentId}")
+ public CommentDto updateComment(@PathVariable Long userId,
+ @PathVariable Long eventId,
+ @PathVariable Long commentId,
+ @Valid @RequestBody UpdateCommentDto updateCommentDto
+ ) {
+ log.debug("Controller: updateComment userId={}, eventId={}, commentId={}, updateCommentDto={}", userId, eventId, commentId, updateCommentDto);
+ return commentService.updateComment(userId, eventId, commentId, updateCommentDto);
+ }
+
+ @DeleteMapping("/{commentId}")
+ @ResponseStatus(HttpStatus.NO_CONTENT)
+ public void deleteComment(@PathVariable Long userId,
+ @PathVariable Long commentId
+ ) {
+ log.debug("Controller: deleteComment userId={}, commentId={}", userId, commentId);
+ commentService.deleteComment(userId, commentId);
+ }
+
+ @GetMapping
+ public List getCommentsForUser(@PathVariable Long userId,
+ @RequestParam(defaultValue = "0") @PositiveOrZero Integer from,
+ @RequestParam(defaultValue = "10") @Positive Integer size
+ ) {
+ log.debug("Controller: getCommentsForUser with userId={}, from={}, size={}", userId, from, size);
+ return commentService.getCommentsForUser(userId, PageRequest.of(from / size, size));
+ }
+}
diff --git a/main-service/src/main/java/ru/practicum/comment/controller/PublicCommentController.java b/main-service/src/main/java/ru/practicum/comment/controller/PublicCommentController.java
new file mode 100644
index 0000000..12076ec
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/comment/controller/PublicCommentController.java
@@ -0,0 +1,29 @@
+package ru.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.web.bind.annotation.*;
+import ru.practicum.comment.dto.CommentDto;
+import ru.practicum.comment.service.CommentService;
+
+import java.util.List;
+
+@Slf4j
+@RestController
+@RequestMapping("/comments")
+@RequiredArgsConstructor
+public class PublicCommentController {
+ private final CommentService commentService;
+
+ @GetMapping("/event/{eventId}")
+ public List getCommentsForEvent(@PathVariable Long eventId,
+ @RequestParam(defaultValue = "0") @PositiveOrZero Integer from,
+ @RequestParam(defaultValue = "10") @Positive Integer size
+ ) {
+ log.debug("Controller: getCommentsForEvent with eventId={}, from {}, size {}", eventId, from, size);
+ return commentService.getCommentsForEvent(eventId, PageRequest.of(from / size, size));
+ }
+}
diff --git a/main-service/src/main/java/ru/practicum/comment/dto/CommentDto.java b/main-service/src/main/java/ru/practicum/comment/dto/CommentDto.java
new file mode 100644
index 0000000..f9f9d30
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/comment/dto/CommentDto.java
@@ -0,0 +1,19 @@
+package ru.practicum.comment.dto;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+
+import static ru.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)
+ String createdDate
+) {
+}
diff --git a/main-service/src/main/java/ru/practicum/comment/dto/NewCommentDto.java b/main-service/src/main/java/ru/practicum/comment/dto/NewCommentDto.java
new file mode 100644
index 0000000..377b748
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/comment/dto/NewCommentDto.java
@@ -0,0 +1,11 @@
+package ru.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/main-service/src/main/java/ru/practicum/comment/dto/UpdateCommentDto.java b/main-service/src/main/java/ru/practicum/comment/dto/UpdateCommentDto.java
new file mode 100644
index 0000000..902b906
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/comment/dto/UpdateCommentDto.java
@@ -0,0 +1,11 @@
+package ru.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/main-service/src/main/java/ru/practicum/comment/mapper/CommentMapper.java b/main-service/src/main/java/ru/practicum/comment/mapper/CommentMapper.java
new file mode 100644
index 0000000..1e760e5
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/comment/mapper/CommentMapper.java
@@ -0,0 +1,26 @@
+package ru.practicum.comment.mapper;
+
+import org.mapstruct.*;
+import ru.practicum.comment.dto.CommentDto;
+import ru.practicum.comment.dto.NewCommentDto;
+import ru.practicum.comment.dto.UpdateCommentDto;
+import ru.practicum.comment.model.Comment;
+import ru.practicum.event.model.Event;
+import ru.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/main-service/src/main/java/ru/practicum/comment/model/Comment.java b/main-service/src/main/java/ru/practicum/comment/model/Comment.java
new file mode 100644
index 0000000..af8a6a0
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/comment/model/Comment.java
@@ -0,0 +1,38 @@
+package ru.practicum.comment.model;
+
+import jakarta.persistence.*;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.Setter;
+import org.hibernate.annotations.CreationTimestamp;
+import ru.practicum.event.model.Event;
+import ru.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/main-service/src/main/java/ru/practicum/comment/repository/CommentRepository.java b/main-service/src/main/java/ru/practicum/comment/repository/CommentRepository.java
new file mode 100644
index 0000000..e976d45
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/comment/repository/CommentRepository.java
@@ -0,0 +1,46 @@
+package ru.practicum.comment.repository;
+
+import org.springframework.data.domain.Page;
+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.practicum.comment.model.Comment;
+
+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"""
+ )
+ Page 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"""
+ )
+ Page findByUserId(@Param("userId") Long userId, Pageable pageable);
+}
diff --git a/main-service/src/main/java/ru/practicum/comment/service/CommentService.java b/main-service/src/main/java/ru/practicum/comment/service/CommentService.java
new file mode 100644
index 0000000..ab0a783
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/comment/service/CommentService.java
@@ -0,0 +1,22 @@
+package ru.practicum.comment.service;
+
+import org.springframework.data.domain.Pageable;
+import ru.practicum.comment.dto.CommentDto;
+import ru.practicum.comment.dto.NewCommentDto;
+import ru.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/main-service/src/main/java/ru/practicum/comment/service/CommentServiceImpl.java b/main-service/src/main/java/ru/practicum/comment/service/CommentServiceImpl.java
new file mode 100644
index 0000000..c5fa561
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/comment/service/CommentServiceImpl.java
@@ -0,0 +1,115 @@
+package ru.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.practicum.comment.dto.CommentDto;
+import ru.practicum.comment.dto.NewCommentDto;
+import ru.practicum.comment.dto.UpdateCommentDto;
+import ru.practicum.comment.mapper.CommentMapper;
+import ru.practicum.comment.model.Comment;
+import ru.practicum.comment.repository.CommentRepository;
+import ru.practicum.event.model.Event;
+import ru.practicum.event.model.EventState;
+import ru.practicum.event.service.EventService;
+import ru.practicum.exception.ConflictException;
+import ru.practicum.exception.NotFoundException;
+import ru.practicum.user.model.User;
+import ru.practicum.user.service.UserService;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+@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) {
+ Comment comment = 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
+ @Transactional(readOnly = true)
+ public List getCommentsForEvent(Long eventId, Pageable pageable) {
+ eventService.getEventOrThrow(eventId);
+
+ log.info("Получен список комментариев для события: eventId={}", eventId);
+
+ return commentRepository.findByEventId(eventId, pageable)
+ .stream()
+ .map(commentMapper::toDto)
+ .collect(Collectors.toList());
+ }
+
+ @Override
+ @Transactional(readOnly = true)
+ public List getCommentsForUser(Long userId, Pageable pageable) {
+ userService.getUserByIdOrThrow(userId);
+
+ log.info("Получен список комментариев для пользователя: userId={}", userId);
+
+ return commentRepository.findByUserId(userId, pageable)
+ .stream()
+ .map(commentMapper::toDto)
+ .collect(Collectors.toList());
+ }
+}
diff --git a/main-service/src/main/java/ru/practicum/compilation/controller/AdminCompilationController.java b/main-service/src/main/java/ru/practicum/compilation/controller/AdminCompilationController.java
new file mode 100644
index 0000000..f4c8f98
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/compilation/controller/AdminCompilationController.java
@@ -0,0 +1,41 @@
+package ru.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.web.bind.annotation.*;
+import ru.practicum.compilation.dto.CompilationDto;
+import ru.practicum.compilation.dto.NewCompilationDto;
+import ru.practicum.compilation.dto.UpdateCompilationRequest;
+import ru.practicum.compilation.service.CompilationService;
+
+@Slf4j
+@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/main-service/src/main/java/ru/practicum/compilation/controller/PublicCompilationController.java b/main-service/src/main/java/ru/practicum/compilation/controller/PublicCompilationController.java
new file mode 100644
index 0000000..5ee2a3c
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/compilation/controller/PublicCompilationController.java
@@ -0,0 +1,36 @@
+package ru.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.web.bind.annotation.*;
+import ru.practicum.compilation.dto.CompilationDto;
+import ru.practicum.compilation.service.CompilationService;
+
+import java.util.List;
+
+@Slf4j
+@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/main-service/src/main/java/ru/practicum/compilation/dto/CompilationDto.java b/main-service/src/main/java/ru/practicum/compilation/dto/CompilationDto.java
new file mode 100644
index 0000000..078f753
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/compilation/dto/CompilationDto.java
@@ -0,0 +1,13 @@
+package ru.practicum.compilation.dto;
+
+import ru.practicum.event.dto.EventShortDto;
+
+import java.util.Set;
+
+public record CompilationDto(
+ Long id,
+ Set events,
+ boolean pinned,
+ String title
+) {
+}
diff --git a/main-service/src/main/java/ru/practicum/compilation/dto/NewCompilationDto.java b/main-service/src/main/java/ru/practicum/compilation/dto/NewCompilationDto.java
new file mode 100644
index 0000000..9348e54
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/compilation/dto/NewCompilationDto.java
@@ -0,0 +1,17 @@
+package ru.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/main-service/src/main/java/ru/practicum/compilation/dto/UpdateCompilationRequest.java b/main-service/src/main/java/ru/practicum/compilation/dto/UpdateCompilationRequest.java
new file mode 100644
index 0000000..b7ad807
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/compilation/dto/UpdateCompilationRequest.java
@@ -0,0 +1,15 @@
+package ru.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/main-service/src/main/java/ru/practicum/compilation/mapper/CompilationMapper.java b/main-service/src/main/java/ru/practicum/compilation/mapper/CompilationMapper.java
new file mode 100644
index 0000000..bcf2693
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/compilation/mapper/CompilationMapper.java
@@ -0,0 +1,24 @@
+package ru.practicum.compilation.mapper;
+
+import org.mapstruct.*;
+import ru.practicum.compilation.dto.CompilationDto;
+import ru.practicum.compilation.dto.NewCompilationDto;
+import ru.practicum.compilation.dto.UpdateCompilationRequest;
+import ru.practicum.compilation.model.Compilation;
+import ru.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/main-service/src/main/java/ru/practicum/compilation/model/Compilation.java b/main-service/src/main/java/ru/practicum/compilation/model/Compilation.java
new file mode 100644
index 0000000..d142911
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/compilation/model/Compilation.java
@@ -0,0 +1,35 @@
+package ru.practicum.compilation.model;
+
+import jakarta.persistence.*;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.Setter;
+import ru.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/main-service/src/main/java/ru/practicum/compilation/repository/CompilationRepository.java b/main-service/src/main/java/ru/practicum/compilation/repository/CompilationRepository.java
new file mode 100644
index 0000000..349ca99
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/compilation/repository/CompilationRepository.java
@@ -0,0 +1,24 @@
+package ru.practicum.compilation.repository;
+
+import org.springframework.data.domain.Page;
+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.practicum.compilation.model.Compilation;
+
+import java.util.Optional;
+
+public interface CompilationRepository extends JpaRepository {
+ Page findByPinned(Boolean pinned, Pageable pageable);
+
+ @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);
+}
diff --git a/main-service/src/main/java/ru/practicum/compilation/service/CompilationService.java b/main-service/src/main/java/ru/practicum/compilation/service/CompilationService.java
new file mode 100644
index 0000000..7acb92c
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/compilation/service/CompilationService.java
@@ -0,0 +1,20 @@
+package ru.practicum.compilation.service;
+
+import org.springframework.data.domain.Pageable;
+import ru.practicum.compilation.dto.CompilationDto;
+import ru.practicum.compilation.dto.NewCompilationDto;
+import ru.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/main-service/src/main/java/ru/practicum/compilation/service/CompilationServiceImpl.java b/main-service/src/main/java/ru/practicum/compilation/service/CompilationServiceImpl.java
new file mode 100644
index 0000000..ac5ae0a
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/compilation/service/CompilationServiceImpl.java
@@ -0,0 +1,133 @@
+package ru.practicum.compilation.service;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.domain.Page;
+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.practicum.compilation.dto.CompilationDto;
+import ru.practicum.compilation.dto.NewCompilationDto;
+import ru.practicum.compilation.dto.UpdateCompilationRequest;
+import ru.practicum.compilation.mapper.CompilationMapper;
+import ru.practicum.compilation.model.Compilation;
+import ru.practicum.compilation.repository.CompilationRepository;
+import ru.practicum.event.model.Event;
+import ru.practicum.event.repository.EventRepository;
+import ru.practicum.exception.ConflictException;
+import ru.practicum.exception.NotFoundException;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+
+@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()
+ );
+
+ Page compilationsPage;
+ if (pinned != null)
+ compilationsPage = compilationRepository.findByPinned(pinned, sortedPageable);
+ else
+ compilationsPage = compilationRepository.findAll(sortedPageable);
+
+ List compilations = compilationsPage.getContent();
+
+ if (!compilations.isEmpty()) {
+ log.info("Получен список подборок pinned={}, pageable={}", pinned, pageable);
+ return compilations.stream()
+ .map(compilationMapper::toDto)
+ .toList();
+ }
+ log.info("Список подборок пуст");
+ return List.of();
+ }
+
+ @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/main-service/src/main/java/ru/practicum/constants/DateTimeConstants.java b/main-service/src/main/java/ru/practicum/constants/DateTimeConstants.java
new file mode 100644
index 0000000..276e937
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/constants/DateTimeConstants.java
@@ -0,0 +1,9 @@
+package ru.practicum.constants;
+
+public final class DateTimeConstants {
+
+ private DateTimeConstants() {
+ }
+
+ public static final String DATE_TIME_PATTERN = "yyyy-MM-dd HH:mm:ss";
+}
diff --git a/main-service/src/main/java/ru/practicum/event/controller/AdminEventController.java b/main-service/src/main/java/ru/practicum/event/controller/AdminEventController.java
new file mode 100644
index 0000000..4a3d982
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/event/controller/AdminEventController.java
@@ -0,0 +1,39 @@
+package ru.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.web.bind.annotation.*;
+import ru.practicum.event.dto.EventFullDto;
+import ru.practicum.event.dto.SearchEventAdminRequest;
+import ru.practicum.event.dto.UpdateEventAdminRequest;
+import ru.practicum.event.service.EventService;
+
+import java.util.List;
+
+@Slf4j
+@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 id={}, data {}", eventId, request);
+ return eventService.updateEventAdmin(eventId, request);
+ }
+}
diff --git a/main-service/src/main/java/ru/practicum/event/controller/PrivateEventController.java b/main-service/src/main/java/ru/practicum/event/controller/PrivateEventController.java
new file mode 100644
index 0000000..7c3d58e
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/event/controller/PrivateEventController.java
@@ -0,0 +1,86 @@
+package ru.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.web.bind.annotation.*;
+import ru.practicum.event.dto.EventFullDto;
+import ru.practicum.event.dto.EventShortDto;
+import ru.practicum.event.dto.NewEventDto;
+import ru.practicum.event.dto.UpdateEventUserRequest;
+import ru.practicum.event.service.EventService;
+import ru.practicum.request.dto.EventRequestStatusUpdateRequest;
+import ru.practicum.request.dto.EventRequestStatusUpdateResult;
+import ru.practicum.request.dto.ParticipationRequestDto;
+import ru.practicum.request.service.RequestService;
+
+import java.util.List;
+
+@Slf4j
+@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 with id={} with 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 with id={} with 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 with id={} and 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 with id={} and eventId={} with 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 with id={} and 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 with id={} and eventId={} with data {}", userId, eventId, request);
+ return requestService.updateRequestStatus(userId, eventId, request);
+ }
+}
diff --git a/main-service/src/main/java/ru/practicum/event/controller/PublicEventController.java b/main-service/src/main/java/ru/practicum/event/controller/PublicEventController.java
new file mode 100644
index 0000000..ef94b27
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/event/controller/PublicEventController.java
@@ -0,0 +1,40 @@
+package ru.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.web.bind.annotation.*;
+import ru.practicum.event.dto.EventFullDto;
+import ru.practicum.event.dto.EventShortDto;
+import ru.practicum.event.dto.SearchEventPublicRequest;
+import ru.practicum.event.service.EventService;
+
+import java.util.List;
+
+@Slf4j
+@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 with filters {}", request);
+ return eventService.getEventsPublic(request, pageRequest, httpRequest.getRemoteAddr());
+ }
+
+ @GetMapping("/{id}")
+ public EventFullDto getEventByIdPublic(@PathVariable @Positive Long id, HttpServletRequest request) {
+ log.debug("Controller: getEventByIdPublic with id={}", id);
+ return eventService.getEventByIdPublic(id, request.getRemoteAddr());
+ }
+}
diff --git a/main-service/src/main/java/ru/practicum/event/dto/EventFullDto.java b/main-service/src/main/java/ru/practicum/event/dto/EventFullDto.java
new file mode 100644
index 0000000..eb00b02
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/event/dto/EventFullDto.java
@@ -0,0 +1,48 @@
+package ru.practicum.event.dto;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import ru.practicum.category.dto.CategoryDto;
+import ru.practicum.event.model.EventState;
+import ru.practicum.user.dto.UserShortDto;
+
+import java.time.LocalDateTime;
+
+import static ru.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/main-service/src/main/java/ru/practicum/event/dto/EventShortDto.java b/main-service/src/main/java/ru/practicum/event/dto/EventShortDto.java
new file mode 100644
index 0000000..cdbd050
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/event/dto/EventShortDto.java
@@ -0,0 +1,31 @@
+package ru.practicum.event.dto;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import ru.practicum.category.dto.CategoryDto;
+import ru.practicum.user.dto.UserShortDto;
+
+import java.time.LocalDateTime;
+
+import static ru.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/main-service/src/main/java/ru/practicum/event/dto/EventStatistics.java b/main-service/src/main/java/ru/practicum/event/dto/EventStatistics.java
new file mode 100644
index 0000000..f380700
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/event/dto/EventStatistics.java
@@ -0,0 +1,9 @@
+package ru.practicum.event.dto;
+
+import java.util.Map;
+
+public record EventStatistics(
+ Map confirmedRequests,
+ Map views
+) {
+}
diff --git a/main-service/src/main/java/ru/practicum/event/dto/EventWithCountConfirmedRequests.java b/main-service/src/main/java/ru/practicum/event/dto/EventWithCountConfirmedRequests.java
new file mode 100644
index 0000000..b76bf01
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/event/dto/EventWithCountConfirmedRequests.java
@@ -0,0 +1,16 @@
+package ru.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/main-service/src/main/java/ru/practicum/event/dto/LocationDto.java b/main-service/src/main/java/ru/practicum/event/dto/LocationDto.java
new file mode 100644
index 0000000..4bc8e89
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/event/dto/LocationDto.java
@@ -0,0 +1,12 @@
+package ru.practicum.event.dto;
+
+import jakarta.validation.constraints.NotNull;
+
+public record LocationDto(
+ @NotNull(message = "Широта не может быть null")
+ Float lat,
+
+ @NotNull(message = "Долгота не может быть null")
+ Float lon
+) {
+}
diff --git a/main-service/src/main/java/ru/practicum/event/dto/NewEventDto.java b/main-service/src/main/java/ru/practicum/event/dto/NewEventDto.java
new file mode 100644
index 0000000..1a0b755
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/event/dto/NewEventDto.java
@@ -0,0 +1,52 @@
+package ru.practicum.event.dto;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import jakarta.validation.constraints.*;
+
+import java.time.LocalDateTime;
+
+import static ru.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/main-service/src/main/java/ru/practicum/event/dto/SearchEventAdminRequest.java b/main-service/src/main/java/ru/practicum/event/dto/SearchEventAdminRequest.java
new file mode 100644
index 0000000..84695ad
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/event/dto/SearchEventAdminRequest.java
@@ -0,0 +1,28 @@
+package ru.practicum.event.dto;
+
+import org.springframework.format.annotation.DateTimeFormat;
+import ru.practicum.event.model.EventState;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+import static ru.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/main-service/src/main/java/ru/practicum/event/dto/SearchEventPublicRequest.java b/main-service/src/main/java/ru/practicum/event/dto/SearchEventPublicRequest.java
new file mode 100644
index 0000000..1298414
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/event/dto/SearchEventPublicRequest.java
@@ -0,0 +1,46 @@
+package ru.practicum.event.dto;
+
+import org.springframework.format.annotation.DateTimeFormat;
+import ru.practicum.event.model.SortState;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+import static ru.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/main-service/src/main/java/ru/practicum/event/dto/UpdateEventAdminRequest.java b/main-service/src/main/java/ru/practicum/event/dto/UpdateEventAdminRequest.java
new file mode 100644
index 0000000..ae6da00
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/event/dto/UpdateEventAdminRequest.java
@@ -0,0 +1,40 @@
+package ru.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.practicum.event.model.StateActionAdmin;
+
+import java.time.LocalDateTime;
+
+import static ru.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/main-service/src/main/java/ru/practicum/event/dto/UpdateEventUserRequest.java b/main-service/src/main/java/ru/practicum/event/dto/UpdateEventUserRequest.java
new file mode 100644
index 0000000..d98ffc0
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/event/dto/UpdateEventUserRequest.java
@@ -0,0 +1,40 @@
+package ru.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.practicum.event.model.StateActionUser;
+
+import java.time.LocalDateTime;
+
+import static ru.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/main-service/src/main/java/ru/practicum/event/mapper/EventMapper.java b/main-service/src/main/java/ru/practicum/event/mapper/EventMapper.java
new file mode 100644
index 0000000..6ae6349
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/event/mapper/EventMapper.java
@@ -0,0 +1,45 @@
+package ru.practicum.event.mapper;
+
+import org.mapstruct.*;
+import ru.practicum.category.model.Category;
+import ru.practicum.event.dto.*;
+import ru.practicum.event.model.Event;
+import ru.practicum.event.model.EventState;
+import ru.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/main-service/src/main/java/ru/practicum/event/mapper/LocationMapper.java b/main-service/src/main/java/ru/practicum/event/mapper/LocationMapper.java
new file mode 100644
index 0000000..1aac2bf
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/event/mapper/LocationMapper.java
@@ -0,0 +1,12 @@
+package ru.practicum.event.mapper;
+
+import org.mapstruct.Mapper;
+import ru.practicum.event.dto.LocationDto;
+import ru.practicum.event.model.EventLocation;
+
+@Mapper(componentModel = "spring")
+public interface LocationMapper {
+ EventLocation toEventLocation(LocationDto dto);
+
+ LocationDto toDto(EventLocation entity);
+}
diff --git a/main-service/src/main/java/ru/practicum/event/model/Event.java b/main-service/src/main/java/ru/practicum/event/model/Event.java
new file mode 100644
index 0000000..112ca24
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/event/model/Event.java
@@ -0,0 +1,83 @@
+package ru.practicum.event.model;
+
+import jakarta.persistence.*;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.Setter;
+import org.hibernate.annotations.CreationTimestamp;
+import ru.practicum.category.model.Category;
+import ru.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/main-service/src/main/java/ru/practicum/event/model/EventLocation.java b/main-service/src/main/java/ru/practicum/event/model/EventLocation.java
new file mode 100644
index 0000000..64f8396
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/event/model/EventLocation.java
@@ -0,0 +1,17 @@
+package ru.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/main-service/src/main/java/ru/practicum/event/model/EventState.java b/main-service/src/main/java/ru/practicum/event/model/EventState.java
new file mode 100644
index 0000000..9cfbb30
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/event/model/EventState.java
@@ -0,0 +1,7 @@
+package ru.practicum.event.model;
+
+public enum EventState {
+ PENDING,
+ PUBLISHED,
+ CANCELED
+}
diff --git a/main-service/src/main/java/ru/practicum/event/model/SortState.java b/main-service/src/main/java/ru/practicum/event/model/SortState.java
new file mode 100644
index 0000000..16d7cc9
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/event/model/SortState.java
@@ -0,0 +1,6 @@
+package ru.practicum.event.model;
+
+public enum SortState {
+ EVENT_DATE,
+ VIEWS
+}
diff --git a/main-service/src/main/java/ru/practicum/event/model/StateActionAdmin.java b/main-service/src/main/java/ru/practicum/event/model/StateActionAdmin.java
new file mode 100644
index 0000000..1306769
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/event/model/StateActionAdmin.java
@@ -0,0 +1,6 @@
+package ru.practicum.event.model;
+
+public enum StateActionAdmin {
+ PUBLISH_EVENT,
+ REJECT_EVENT
+}
diff --git a/main-service/src/main/java/ru/practicum/event/model/StateActionUser.java b/main-service/src/main/java/ru/practicum/event/model/StateActionUser.java
new file mode 100644
index 0000000..e629286
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/event/model/StateActionUser.java
@@ -0,0 +1,6 @@
+package ru.practicum.event.model;
+
+public enum StateActionUser {
+ SEND_TO_REVIEW,
+ CANCEL_REVIEW
+}
diff --git a/main-service/src/main/java/ru/practicum/event/repository/EventRepository.java b/main-service/src/main/java/ru/practicum/event/repository/EventRepository.java
new file mode 100644
index 0000000..7e4d094
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/event/repository/EventRepository.java
@@ -0,0 +1,37 @@
+package ru.practicum.event.repository;
+
+import org.springframework.data.domain.Page;
+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.practicum.event.model.Event;
+
+import java.util.List;
+import java.util.Optional;
+
+public interface EventRepository extends JpaRepository, JpaSpecificationExecutor {
+
+ Page 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/main-service/src/main/java/ru/practicum/event/repository/SearchEventSpecifications.java b/main-service/src/main/java/ru/practicum/event/repository/SearchEventSpecifications.java
new file mode 100644
index 0000000..dfb6b91
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/event/repository/SearchEventSpecifications.java
@@ -0,0 +1,93 @@
+package ru.practicum.event.repository;
+
+import jakarta.persistence.criteria.Root;
+import jakarta.persistence.criteria.Subquery;
+import org.springframework.data.jpa.domain.Specification;
+import ru.practicum.event.model.Event;
+import ru.practicum.event.model.EventState;
+import ru.practicum.request.model.Request;
+import ru.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/main-service/src/main/java/ru/practicum/event/service/EventService.java b/main-service/src/main/java/ru/practicum/event/service/EventService.java
new file mode 100644
index 0000000..03f9882
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/event/service/EventService.java
@@ -0,0 +1,27 @@
+package ru.practicum.event.service;
+
+import org.springframework.data.domain.Pageable;
+import ru.practicum.event.dto.*;
+import ru.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/main-service/src/main/java/ru/practicum/event/service/EventServiceImpl.java b/main-service/src/main/java/ru/practicum/event/service/EventServiceImpl.java
new file mode 100644
index 0000000..8608b9a
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/event/service/EventServiceImpl.java
@@ -0,0 +1,362 @@
+package ru.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.practicum.CreateEndpointHitDto;
+import ru.practicum.StatsClient;
+import ru.practicum.ViewStatsDto;
+import ru.practicum.category.dto.CategoryDto;
+import ru.practicum.category.mapper.CategoryMapper;
+import ru.practicum.category.model.Category;
+import ru.practicum.category.service.CategoryService;
+import ru.practicum.event.dto.*;
+import ru.practicum.event.mapper.EventMapper;
+import ru.practicum.event.model.*;
+import ru.practicum.event.repository.EventRepository;
+import ru.practicum.event.repository.SearchEventSpecifications;
+import ru.practicum.exception.BadRequestException;
+import ru.practicum.exception.ConflictException;
+import ru.practicum.exception.NotFoundException;
+import ru.practicum.exception.ValidationException;
+import ru.practicum.request.repository.RequestRepository;
+import ru.practicum.user.model.User;
+import ru.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 StatsClient 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);
+ Page eventsPage = eventRepository.findAllByInitiatorIdOrderByCreatedOnDesc(userId, pageable);
+
+ if (eventsPage.isEmpty()) return List.of();
+
+ List eventIds = eventsPage.stream()
+ .map(Event::getId)
+ .toList();
+
+ EventStatistics stats = getEventStatistics(eventIds);
+
+ return eventsPage.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/main-service/src/main/java/ru/practicum/exception/BadRequestException.java b/main-service/src/main/java/ru/practicum/exception/BadRequestException.java
new file mode 100644
index 0000000..7aee4bc
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/exception/BadRequestException.java
@@ -0,0 +1,7 @@
+package ru.practicum.exception;
+
+public class BadRequestException extends RuntimeException {
+ public BadRequestException(String message) {
+ super(message);
+ }
+}
diff --git a/main-service/src/main/java/ru/practicum/exception/ConflictException.java b/main-service/src/main/java/ru/practicum/exception/ConflictException.java
new file mode 100644
index 0000000..e2fddf1
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/exception/ConflictException.java
@@ -0,0 +1,7 @@
+package ru.practicum.exception;
+
+public class ConflictException extends RuntimeException {
+ public ConflictException(String message) {
+ super(message);
+ }
+}
diff --git a/main-service/src/main/java/ru/practicum/exception/ErrorHandler.java b/main-service/src/main/java/ru/practicum/exception/ErrorHandler.java
new file mode 100644
index 0000000..de60fcd
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/exception/ErrorHandler.java
@@ -0,0 +1,139 @@
+package ru.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/main-service/src/main/java/ru/practicum/exception/ErrorResponse.java b/main-service/src/main/java/ru/practicum/exception/ErrorResponse.java
new file mode 100644
index 0000000..75785f0
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/exception/ErrorResponse.java
@@ -0,0 +1,10 @@
+package ru.practicum.exception;
+
+public record ErrorResponse(
+ String path,
+ String httpMethod,
+ int statusCode,
+ String error,
+ String message
+) {
+}
diff --git a/main-service/src/main/java/ru/practicum/exception/ForbiddenException.java b/main-service/src/main/java/ru/practicum/exception/ForbiddenException.java
new file mode 100644
index 0000000..85aebb9
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/exception/ForbiddenException.java
@@ -0,0 +1,7 @@
+package ru.practicum.exception;
+
+public class ForbiddenException extends RuntimeException {
+ public ForbiddenException(String message) {
+ super(message);
+ }
+}
diff --git a/main-service/src/main/java/ru/practicum/exception/NotFoundException.java b/main-service/src/main/java/ru/practicum/exception/NotFoundException.java
new file mode 100644
index 0000000..628c788
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/exception/NotFoundException.java
@@ -0,0 +1,7 @@
+package ru.practicum.exception;
+
+public class NotFoundException extends RuntimeException {
+ public NotFoundException(String message) {
+ super(message);
+ }
+}
diff --git a/main-service/src/main/java/ru/practicum/exception/ValidationException.java b/main-service/src/main/java/ru/practicum/exception/ValidationException.java
new file mode 100644
index 0000000..d751d21
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/exception/ValidationException.java
@@ -0,0 +1,7 @@
+package ru.practicum.exception;
+
+public class ValidationException extends RuntimeException {
+ public ValidationException(String message) {
+ super(message);
+ }
+}
diff --git a/main-service/src/main/java/ru/practicum/request/controller/PrivateRequestController.java b/main-service/src/main/java/ru/practicum/request/controller/PrivateRequestController.java
new file mode 100644
index 0000000..23ae88d
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/request/controller/PrivateRequestController.java
@@ -0,0 +1,40 @@
+package ru.practicum.request.controller;
+
+import jakarta.validation.constraints.Positive;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.annotation.*;
+import ru.practicum.request.dto.ParticipationRequestDto;
+import ru.practicum.request.service.RequestService;
+
+import java.util.List;
+
+@Slf4j
+@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/main-service/src/main/java/ru/practicum/request/dto/EventRequestStatusUpdateRequest.java b/main-service/src/main/java/ru/practicum/request/dto/EventRequestStatusUpdateRequest.java
new file mode 100644
index 0000000..82e2ceb
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/request/dto/EventRequestStatusUpdateRequest.java
@@ -0,0 +1,11 @@
+package ru.practicum.request.dto;
+
+import ru.practicum.request.model.RequestStatus;
+
+import java.util.List;
+
+public record EventRequestStatusUpdateRequest(
+ List requestIds,
+ RequestStatus status
+) {
+}
diff --git a/main-service/src/main/java/ru/practicum/request/dto/EventRequestStatusUpdateResult.java b/main-service/src/main/java/ru/practicum/request/dto/EventRequestStatusUpdateResult.java
new file mode 100644
index 0000000..9190900
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/request/dto/EventRequestStatusUpdateResult.java
@@ -0,0 +1,9 @@
+package ru.practicum.request.dto;
+
+import java.util.List;
+
+public record EventRequestStatusUpdateResult(
+ List confirmedRequests,
+ List rejectedRequests
+) {
+}
diff --git a/main-service/src/main/java/ru/practicum/request/dto/ParticipationRequestDto.java b/main-service/src/main/java/ru/practicum/request/dto/ParticipationRequestDto.java
new file mode 100644
index 0000000..dfad229
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/request/dto/ParticipationRequestDto.java
@@ -0,0 +1,22 @@
+package ru.practicum.request.dto;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import ru.practicum.request.model.RequestStatus;
+
+import java.time.LocalDateTime;
+
+import static ru.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/main-service/src/main/java/ru/practicum/request/mapper/RequestMapper.java b/main-service/src/main/java/ru/practicum/request/mapper/RequestMapper.java
new file mode 100644
index 0000000..be41b54
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/request/mapper/RequestMapper.java
@@ -0,0 +1,29 @@
+package ru.practicum.request.mapper;
+
+import org.mapstruct.Mapper;
+import org.mapstruct.Mapping;
+import ru.practicum.event.model.Event;
+import ru.practicum.request.dto.EventRequestStatusUpdateResult;
+import ru.practicum.request.dto.ParticipationRequestDto;
+import ru.practicum.request.model.Request;
+import ru.practicum.request.model.RequestStatus;
+import ru.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/main-service/src/main/java/ru/practicum/request/model/Request.java b/main-service/src/main/java/ru/practicum/request/model/Request.java
new file mode 100644
index 0000000..8d4b3ec
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/request/model/Request.java
@@ -0,0 +1,49 @@
+package ru.practicum.request.model;
+
+import jakarta.persistence.*;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.Setter;
+import org.hibernate.annotations.CreationTimestamp;
+import ru.practicum.event.model.Event;
+import ru.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/main-service/src/main/java/ru/practicum/request/model/RequestStatus.java b/main-service/src/main/java/ru/practicum/request/model/RequestStatus.java
new file mode 100644
index 0000000..af9a14f
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/request/model/RequestStatus.java
@@ -0,0 +1,8 @@
+package ru.practicum.request.model;
+
+public enum RequestStatus {
+ PENDING,
+ CONFIRMED,
+ REJECTED,
+ CANCELED
+}
diff --git a/main-service/src/main/java/ru/practicum/request/repository/RequestRepository.java b/main-service/src/main/java/ru/practicum/request/repository/RequestRepository.java
new file mode 100644
index 0000000..cda3855
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/request/repository/RequestRepository.java
@@ -0,0 +1,56 @@
+package ru.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.practicum.event.dto.EventWithCountConfirmedRequests;
+import ru.practicum.request.dto.ParticipationRequestDto;
+import ru.practicum.request.model.Request;
+import ru.practicum.request.model.RequestStatus;
+
+import java.util.List;
+
+public interface RequestRepository extends JpaRepository {
+
+ @Query("""
+ SELECT NEW ru.practicum.event.dto.EventWithCountConfirmedRequests(r.event.id, COUNT(r))
+ FROM Request r
+ WHERE r.event.id IN :eventIds AND r.status = ru.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.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/main-service/src/main/java/ru/practicum/request/service/RequestService.java b/main-service/src/main/java/ru/practicum/request/service/RequestService.java
new file mode 100644
index 0000000..b130be2
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/request/service/RequestService.java
@@ -0,0 +1,19 @@
+package ru.practicum.request.service;
+
+import ru.practicum.request.dto.EventRequestStatusUpdateRequest;
+import ru.practicum.request.dto.EventRequestStatusUpdateResult;
+import ru.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/main-service/src/main/java/ru/practicum/request/service/RequestServiceImpl.java b/main-service/src/main/java/ru/practicum/request/service/RequestServiceImpl.java
new file mode 100644
index 0000000..fefffe4
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/request/service/RequestServiceImpl.java
@@ -0,0 +1,212 @@
+package ru.practicum.request.service;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import ru.practicum.event.model.Event;
+import ru.practicum.event.model.EventState;
+import ru.practicum.event.service.EventService;
+import ru.practicum.exception.ConflictException;
+import ru.practicum.exception.NotFoundException;
+import ru.practicum.exception.ValidationException;
+import ru.practicum.request.dto.EventRequestStatusUpdateRequest;
+import ru.practicum.request.dto.EventRequestStatusUpdateResult;
+import ru.practicum.request.dto.ParticipationRequestDto;
+import ru.practicum.request.mapper.RequestMapper;
+import ru.practicum.request.model.Request;
+import ru.practicum.request.model.RequestStatus;
+import ru.practicum.request.repository.RequestRepository;
+import ru.practicum.user.model.User;
+import ru.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/main-service/src/main/java/ru/practicum/user/controller/AdminUserController.java b/main-service/src/main/java/ru/practicum/user/controller/AdminUserController.java
new file mode 100644
index 0000000..b55727b
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/user/controller/AdminUserController.java
@@ -0,0 +1,48 @@
+package ru.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.web.bind.annotation.*;
+import ru.practicum.user.dto.NewUserRequest;
+import ru.practicum.user.dto.UserDto;
+import ru.practicum.user.service.UserService;
+
+import java.util.List;
+
+@Slf4j
+@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 {}", 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 id={}", userId);
+ userService.deleteUser(userId);
+ }
+}
diff --git a/main-service/src/main/java/ru/practicum/user/dto/NewUserRequest.java b/main-service/src/main/java/ru/practicum/user/dto/NewUserRequest.java
new file mode 100644
index 0000000..48aebaf
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/user/dto/NewUserRequest.java
@@ -0,0 +1,17 @@
+package ru.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/main-service/src/main/java/ru/practicum/user/dto/UserDto.java b/main-service/src/main/java/ru/practicum/user/dto/UserDto.java
new file mode 100644
index 0000000..9a73a41
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/user/dto/UserDto.java
@@ -0,0 +1,8 @@
+package ru.practicum.user.dto;
+
+public record UserDto(
+ Long id,
+ String name,
+ String email
+) {
+}
diff --git a/main-service/src/main/java/ru/practicum/user/dto/UserShortDto.java b/main-service/src/main/java/ru/practicum/user/dto/UserShortDto.java
new file mode 100644
index 0000000..e540cf6
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/user/dto/UserShortDto.java
@@ -0,0 +1,7 @@
+package ru.practicum.user.dto;
+
+public record UserShortDto(
+ Long id,
+ String name
+) {
+}
diff --git a/main-service/src/main/java/ru/practicum/user/mapper/UserMapper.java b/main-service/src/main/java/ru/practicum/user/mapper/UserMapper.java
new file mode 100644
index 0000000..a0eefc6
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/user/mapper/UserMapper.java
@@ -0,0 +1,19 @@
+package ru.practicum.user.mapper;
+
+import org.mapstruct.Mapper;
+import org.mapstruct.Mapping;
+import ru.practicum.user.dto.NewUserRequest;
+import ru.practicum.user.dto.UserDto;
+import ru.practicum.user.dto.UserShortDto;
+import ru.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/main-service/src/main/java/ru/practicum/user/model/User.java b/main-service/src/main/java/ru/practicum/user/model/User.java
new file mode 100644
index 0000000..036864c
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/user/model/User.java
@@ -0,0 +1,26 @@
+package ru.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/main-service/src/main/java/ru/practicum/user/repository/UserRepository.java b/main-service/src/main/java/ru/practicum/user/repository/UserRepository.java
new file mode 100644
index 0000000..e3d8095
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/user/repository/UserRepository.java
@@ -0,0 +1,14 @@
+package ru.practicum.user.repository;
+
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.repository.JpaRepository;
+import ru.practicum.user.model.User;
+
+import java.util.List;
+
+public interface UserRepository extends JpaRepository {
+ boolean existsByEmail(String email);
+
+ Page findByIdIn(List ids, Pageable pageable);
+}
diff --git a/main-service/src/main/java/ru/practicum/user/service/UserService.java b/main-service/src/main/java/ru/practicum/user/service/UserService.java
new file mode 100644
index 0000000..b694803
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/user/service/UserService.java
@@ -0,0 +1,20 @@
+package ru.practicum.user.service;
+
+import org.springframework.data.domain.Pageable;
+import ru.practicum.user.dto.NewUserRequest;
+import ru.practicum.user.dto.UserDto;
+import ru.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/main-service/src/main/java/ru/practicum/user/service/UserServiceImpl.java b/main-service/src/main/java/ru/practicum/user/service/UserServiceImpl.java
new file mode 100644
index 0000000..f9666c0
--- /dev/null
+++ b/main-service/src/main/java/ru/practicum/user/service/UserServiceImpl.java
@@ -0,0 +1,77 @@
+package ru.practicum.user.service;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.domain.Page;
+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.practicum.exception.ConflictException;
+import ru.practicum.exception.NotFoundException;
+import ru.practicum.user.dto.NewUserRequest;
+import ru.practicum.user.dto.UserDto;
+import ru.practicum.user.mapper.UserMapper;
+import ru.practicum.user.model.User;
+import ru.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")
+ );
+ Page users = (ids == null || ids.isEmpty())
+ ? userRepository.findAll(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/main-service/src/main/resources/application-local.properties b/main-service/src/main/resources/application-local.properties
new file mode 100644
index 0000000..4500b27
--- /dev/null
+++ b/main-service/src/main/resources/application-local.properties
@@ -0,0 +1,19 @@
+# Server Configuration
+server.port=8080
+stats.server.url=http://localhost:9090
+# JPA / Hibernate
+spring.jpa.hibernate.ddl-auto=create
+spring.jpa.properties.hibernate.format_sql=true
+spring.jpa.show-sql=false
+spring.sql.init.mode=always
+# DataSource
+spring.datasource.driverClassName=org.postgresql.Driver
+spring.datasource.url=jdbc:postgresql://localhost:6543/mainDB_local
+spring.datasource.username=localuser
+spring.datasource.password=localpass
+# Logging
+logging.level.ru.practicum=DEBUG
+logging.level.org.springframework.orm.jpa=INFO
+logging.level.org.springframework.transaction=INFO
+logging.level.org.springframework.transaction.interceptor=DEBUG
+logging.level.org.springframework.orm.jpa.JpaTransactionManager=INFO
\ No newline at end of file
diff --git a/main-service/src/main/resources/application-test.properties b/main-service/src/main/resources/application-test.properties
new file mode 100644
index 0000000..0171bc3
--- /dev/null
+++ b/main-service/src/main/resources/application-test.properties
@@ -0,0 +1,15 @@
+# JPA / Hibernate
+spring.jpa.hibernate.ddl-auto=none
+spring.jpa.properties.hibernate.format_sql=true
+spring.sql.init.mode=always
+# DataSource
+spring.datasource.url=jdbc:h2:mem:main;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH
+spring.datasource.driverClassName=org.h2.Driver
+spring.datasource.username=main
+spring.datasource.password=main
+# Logging
+logging.level.ru.practicum=DEBUG
+logging.level.org.springframework.orm.jpa=INFO
+logging.level.org.springframework.transaction=DEBUG
+logging.level.org.springframework.transaction.interceptor=DEBUG
+logging.level.org.springframework.orm.jpa.JpaTransactionManager=INFO
\ No newline at end of file
diff --git a/main-service/src/main/resources/application.properties b/main-service/src/main/resources/application.properties
index 6885798..d531835 100644
--- a/main-service/src/main/resources/application.properties
+++ b/main-service/src/main/resources/application.properties
@@ -1,11 +1,18 @@
+# Server Configuration
server.port=8080
-stats.server.url=http://localhost:9090
-
-spring.jpa.show-sql=true
-spring.jpa.hibernate.ddl-auto=update
+stats.server.url=${STATS_SERVER_URL}
+# JPA / Hibernate
+spring.jpa.hibernate.ddl-auto=validate
spring.jpa.properties.hibernate.format_sql=true
-
+spring.jpa.show-sql=false
+spring.sql.init.mode=always
+# DataSource
spring.datasource.driverClassName=org.postgresql.Driver
-spring.datasource.url=jdbc:postgresql://localhost:6542/mainDB
-spring.datasource.username=dbuser
-spring.datasource.password=12345
\ No newline at end of file
+spring.datasource.url=${SPRING_DATASOURCE_URL}
+spring.datasource.username=${SPRING_DATASOURCE_USERNAME}
+spring.datasource.password=${SPRING_DATASOURCE_PASSWORD}
+# Logging
+logging.level.org.springframework.orm.jpa=INFO
+logging.level.org.springframework.transaction=INFO
+logging.level.org.springframework.transaction.interceptor=DEBUG
+logging.level.org.springframework.orm.jpa.JpaTransactionManager=INFO
\ No newline at end of file
diff --git a/main-service/src/main/resources/schema.sql b/main-service/src/main/resources/schema.sql
new file mode 100644
index 0000000..59365c2
--- /dev/null
+++ b/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/postman/feature.json b/postman/feature.json
new file mode 100644
index 0000000..44c6113
--- /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/stats-client/src/main/java/ru/practicum/StatsClient.java b/stats/stats-client/src/main/java/ru/practicum/StatsClient.java
index 6851b2a..2cbc962 100644
--- a/stats/stats-client/src/main/java/ru/practicum/StatsClient.java
+++ b/stats/stats-client/src/main/java/ru/practicum/StatsClient.java
@@ -14,11 +14,13 @@
import java.time.format.DateTimeFormatter;
import java.util.List;
+import static ru.practicum.constants.DateTimeConstants.DATE_TIME_PATTERN;
+
@Slf4j
@Component
public class StatsClient {
private final RestClient restClient;
- private final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+ private final DateTimeFormatter formatter = DateTimeFormatter.ofPattern(DATE_TIME_PATTERN);
public StatsClient(@Value("${stats-server.url:http://localhost:9090}") String serverUrl) {
this.restClient = RestClient.builder()
@@ -44,7 +46,7 @@ public void saveHit(CreateEndpointHitDto createEndpointHitDto) {
}
public List getStats(LocalDateTime start, LocalDateTime end,
- List uris, Boolean unique) {
+ List uris, boolean unique) {
try {
List result = restClient.get()
.uri(uriBuilder -> {
@@ -55,9 +57,9 @@ public List getStats(LocalDateTime start, LocalDateTime end,
if (uris != null && !uris.isEmpty()) {
uris.forEach(uri -> builder.queryParam("uris", uri));
}
- if (unique != null) {
- builder.queryParam("unique", unique);
- }
+
+ builder.queryParam("unique", unique);
+
return builder.build();
})
.retrieve()
@@ -65,11 +67,11 @@ public List getStats(LocalDateTime start, LocalDateTime end,
});
log.debug("Получено записей статистики: {}", result != null ? result.size() : 0);
- return result != null ? result : List.of();
+ return result;
} catch (Exception exception) {
log.error("Ошибка при получении статистики: {}", exception.getMessage());
- throw new StatsClientException("Couldn't get statistics", exception);
+ throw new StatsClientException("Statistics could not be retrieved", exception);
}
}
}
diff --git a/stats/stats-client/src/main/resources/application.properties b/stats/stats-client/src/main/resources/application.properties
deleted file mode 100644
index 6d6b68c..0000000
--- a/stats/stats-client/src/main/resources/application.properties
+++ /dev/null
@@ -1 +0,0 @@
-stats.server.url=http://localhost:9090
\ No newline at end of file
diff --git a/stats/stats-dto/src/main/java/ru/practicum/CreateEndpointHitDto.java b/stats/stats-dto/src/main/java/ru/practicum/CreateEndpointHitDto.java
index d3ae6eb..2ff39fa 100644
--- a/stats/stats-dto/src/main/java/ru/practicum/CreateEndpointHitDto.java
+++ b/stats/stats-dto/src/main/java/ru/practicum/CreateEndpointHitDto.java
@@ -6,6 +6,8 @@
import java.time.LocalDateTime;
+import static ru.practicum.constants.DateTimeConstants.DATE_TIME_PATTERN;
+
public record CreateEndpointHitDto(
@NotBlank(message = "Название приложения не может быть пустым")
String app,
@@ -17,7 +19,7 @@ public record CreateEndpointHitDto(
String ip,
@NotNull(message = "Временная метка не может быть null")
- @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+ @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = DATE_TIME_PATTERN)
LocalDateTime timestamp
) {
}
diff --git a/stats/stats-dto/src/main/java/ru/practicum/StatsRequest.java b/stats/stats-dto/src/main/java/ru/practicum/StatsRequest.java
index 6db7ac9..329a487 100644
--- a/stats/stats-dto/src/main/java/ru/practicum/StatsRequest.java
+++ b/stats/stats-dto/src/main/java/ru/practicum/StatsRequest.java
@@ -4,6 +4,8 @@
import java.time.format.DateTimeFormatter;
import java.util.List;
+import static ru.practicum.constants.DateTimeConstants.DATE_TIME_PATTERN;
+
public record StatsRequest(
LocalDateTime start,
LocalDateTime end,
@@ -11,7 +13,7 @@ public record StatsRequest(
boolean unique
) {
public static StatsRequest of(String start, String end, List uris, boolean unique) {
- DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+ DateTimeFormatter formatter = DateTimeFormatter.ofPattern(DATE_TIME_PATTERN);
return new StatsRequest(
LocalDateTime.parse(start, formatter),
LocalDateTime.parse(end, formatter),
diff --git a/stats/stats-dto/src/main/java/ru/practicum/constants/DateTimeConstants.java b/stats/stats-dto/src/main/java/ru/practicum/constants/DateTimeConstants.java
new file mode 100644
index 0000000..276e937
--- /dev/null
+++ b/stats/stats-dto/src/main/java/ru/practicum/constants/DateTimeConstants.java
@@ -0,0 +1,9 @@
+package ru.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/src/main/java/ru/practicum/controller/StatsController.java b/stats/stats-server/src/main/java/ru/practicum/controller/StatsController.java
index 1387c1f..e4f6d1f 100644
--- a/stats/stats-server/src/main/java/ru/practicum/controller/StatsController.java
+++ b/stats/stats-server/src/main/java/ru/practicum/controller/StatsController.java
@@ -8,6 +8,7 @@
import ru.practicum.CreateEndpointHitDto;
import ru.practicum.StatsRequest;
import ru.practicum.ViewStatsDto;
+import ru.practicum.exception.BadRequestException;
import ru.practicum.service.StatsService;
import java.util.List;
@@ -20,18 +21,22 @@ public class StatsController {
@PostMapping("/hit")
@ResponseStatus(HttpStatus.CREATED)
- public void createHit(@Valid @RequestBody CreateEndpointHitDto createEndpointHitDto) {
- log.info("Controller: createHit requestBody={}", createEndpointHitDto);
+ 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) {
- log.debug("start={}, end={}, uris={}, unique={}", start, end, uris, unique);
+ @RequestParam(value = "uris", required = false) List uris,
+ @RequestParam(value = "unique", defaultValue = "false") boolean unique) {
StatsRequest request = StatsRequest.of(start, end, uris, unique);
- log.info("Controller: getStats request={}", request);
+
+ 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/practicum/exception/BadRequestException.java b/stats/stats-server/src/main/java/ru/practicum/exception/BadRequestException.java
new file mode 100644
index 0000000..7aee4bc
--- /dev/null
+++ b/stats/stats-server/src/main/java/ru/practicum/exception/BadRequestException.java
@@ -0,0 +1,7 @@
+package ru.practicum.exception;
+
+public class BadRequestException extends RuntimeException {
+ public BadRequestException(String message) {
+ super(message);
+ }
+}
diff --git a/stats/stats-server/src/main/java/ru/practicum/exception/ErrorHandler.java b/stats/stats-server/src/main/java/ru/practicum/exception/ErrorHandler.java
new file mode 100644
index 0000000..8fb199b
--- /dev/null
+++ b/stats/stats-server/src/main/java/ru/practicum/exception/ErrorHandler.java
@@ -0,0 +1,29 @@
+package ru.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/practicum/exception/ErrorResponse.java b/stats/stats-server/src/main/java/ru/practicum/exception/ErrorResponse.java
new file mode 100644
index 0000000..31994d6
--- /dev/null
+++ b/stats/stats-server/src/main/java/ru/practicum/exception/ErrorResponse.java
@@ -0,0 +1,10 @@
+package ru.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/practicum/model/EndpointHit.java b/stats/stats-server/src/main/java/ru/practicum/model/EndpointHit.java
index be82130..5c73895 100644
--- a/stats/stats-server/src/main/java/ru/practicum/model/EndpointHit.java
+++ b/stats/stats-server/src/main/java/ru/practicum/model/EndpointHit.java
@@ -1,19 +1,21 @@
package ru.practicum.model;
import jakarta.persistence.*;
-import lombok.*;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.Setter;
import java.time.LocalDateTime;
@Entity
@Table(name = "endpoint_hits")
@Getter
-@Setter(AccessLevel.PROTECTED)
-@ToString
-@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Setter
+@EqualsAndHashCode(onlyExplicitlyIncluded = true)
public class EndpointHit {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
+ @EqualsAndHashCode.Include
@Column(name = "hit_id")
private Long id;
@@ -21,23 +23,4 @@ public class EndpointHit {
private String uri;
private String ip;
private LocalDateTime timestamp;
-
- public EndpointHit(String app, String uri, String ip, LocalDateTime timestamp) {
- this.app = app;
- this.uri = uri;
- this.ip = ip;
- this.timestamp = timestamp;
- }
-
- @Override
- public boolean equals(Object o) {
- if (this == o) return true;
- if (!(o instanceof EndpointHit)) return false;
- return id != null && id.equals(((EndpointHit) o).getId());
- }
-
- @Override
- public int hashCode() {
- return id == null ? 0 : id.hashCode();
- }
}
diff --git a/stats/stats-server/src/main/resources/application-local.properties b/stats/stats-server/src/main/resources/application-local.properties
new file mode 100644
index 0000000..fffbce2
--- /dev/null
+++ b/stats/stats-server/src/main/resources/application-local.properties
@@ -0,0 +1,18 @@
+# Server Configuration
+server.port=9090
+# JPA / Hibernate
+spring.jpa.hibernate.ddl-auto=create
+spring.jpa.properties.hibernate.format_sql=true
+spring.jpa.show-sql=false
+spring.sql.init.mode=always
+# DataSource
+spring.datasource.driverClassName=org.postgresql.Driver
+spring.datasource.url=jdbc:postgresql://localhost:6542/statDB_local
+spring.datasource.username=localuser
+spring.datasource.password=localpass
+# Logging
+logging.level.ru.practicum=DEBUG
+logging.level.org.springframework.orm.jpa=INFO
+logging.level.org.springframework.transaction=INFO
+logging.level.org.springframework.transaction.interceptor=DEBUG
+logging.level.org.springframework.orm.jpa.JpaTransactionManager=INFO
\ No newline at end of file
diff --git a/stats/stats-server/src/main/resources/application-test.properties b/stats/stats-server/src/main/resources/application-test.properties
index 7345fa2..768c576 100644
--- a/stats/stats-server/src/main/resources/application-test.properties
+++ b/stats/stats-server/src/main/resources/application-test.properties
@@ -1,12 +1,13 @@
+# JPA / Hibernate
spring.jpa.hibernate.ddl-auto=none
spring.jpa.properties.hibernate.format_sql=true
spring.sql.init.mode=always
-
+# DataSource
+spring.datasource.url=jdbc:h2:mem:stat;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH
spring.datasource.driverClassName=org.h2.Driver
-spring.datasource.url=jdbc:h2:mem:stat
spring.datasource.username=stat
spring.datasource.password=stat
-
+# Logging
logging.level.ru.practicum=DEBUG
logging.level.org.springframework.orm.jpa=INFO
logging.level.org.springframework.transaction=DEBUG
diff --git a/stats/stats-server/src/main/resources/application.properties b/stats/stats-server/src/main/resources/application.properties
index 85cfdaf..d18474c 100644
--- a/stats/stats-server/src/main/resources/application.properties
+++ b/stats/stats-server/src/main/resources/application.properties
@@ -1,14 +1,16 @@
+# Server Configuration
server.port=9090
+# JPA / Hibernate
spring.jpa.hibernate.ddl-auto=validate
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.show-sql=false
spring.sql.init.mode=always
-
+# DataSource
spring.datasource.driverClassName=org.postgresql.Driver
-spring.datasource.url=jdbc:postgresql://localhost:5432/statDB
-spring.datasource.username=dbuser
-spring.datasource.password=12345
-
+spring.datasource.url=${SPRING_DATASOURCE_URL}
+spring.datasource.username=${SPRING_DATASOURCE_USERNAME}
+spring.datasource.password=${SPRING_DATASOURCE_PASSWORD}
+# Logging
logging.level.org.springframework.orm.jpa=INFO
logging.level.org.springframework.transaction=INFO
logging.level.org.springframework.transaction.interceptor=DEBUG