diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 000000000..41a7123f1
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,19 @@
+FROM maven:3.8-openjdk-17 AS build
+WORKDIR /app
+
+COPY pom.xml .
+RUN mvn dependency:go-offline -B
+
+COPY src src
+RUN mvn clean package -DskipTests -B
+
+FROM openjdk:17-jre-slim
+WORKDIR /app
+
+COPY --from=build /app/target/jira-1.0.jar app.jar
+
+RUN mkdir -p /app/attachments
+
+EXPOSE 8080
+
+ENTRYPOINT ["java", "-XX:+UseContainerSupport", "-XX:MaxRAMPercentage=75.0", "-jar", "app.jar"]
\ No newline at end of file
diff --git a/README.md b/README.md
index 719b268f5..a7e2e8f39 100644
--- a/README.md
+++ b/README.md
@@ -27,4 +27,16 @@
- https://habr.com/ru/articles/259055/
Список выполненных задач:
-...
\ No newline at end of file
+1. Разобраться со структурой проекта (onboarding).
+2. Удалить социальные сети: vk, yandex.
+3. Вынести чувствительную информацию в отдельный проперти файл:
+-логин
+-пароль БД
+-идентификаторы для OAuth регистрации/авторизации
+-настройки почты
+4. Переделать тесты так, чтоб во время тестов использовалась in memory БД (H2), а не PostgreSQL.
+5. Написать тесты для всех публичных методов контроллера ProfileRestController.
+6. Сделать рефакторинг метода com.javarush.jira.bugtracking.attachment.FileUtil#upload чтоб он использовал современный подход для работы с файловой системмой.
+7. Добавить новый функционал: добавления тегов к задаче (REST API + реализация на сервисе).
+8. Написать Dockerfile для основного сервера
+9. Написать docker-compose файл для запуска контейнера сервера вместе с БД и nginx. Для nginx используй конфиг-файл config/nginx.conf. При необходимости файл конфига можно редактировать.
diff --git a/pom.xml b/pom.xml
index f6c152c68..0f62221b3 100644
--- a/pom.xml
+++ b/pom.xml
@@ -96,6 +96,7 @@
org.projectlombok
lombok
+ 1.18.36
true
@@ -142,6 +143,11 @@
junit-platform-launcher
test
+
+ com.h2database
+ h2
+ test
+
diff --git a/resources/view/login.html b/resources/view/login.html
index 8765ca8ff..d49ce5691 100644
--- a/resources/view/login.html
+++ b/resources/view/login.html
@@ -48,14 +48,6 @@ Sign in
type="button">
-
-
-
-
-
-
diff --git a/resources/view/unauth/register.html b/resources/view/unauth/register.html
index 2ba955045..52a892bd3 100644
--- a/resources/view/unauth/register.html
+++ b/resources/view/unauth/register.html
@@ -77,14 +77,6 @@ Registration
type="button">
-
-
-
-
-
-
diff --git a/src/main/java/com/javarush/jira/bugtracking/attachment/FileUtil.java b/src/main/java/com/javarush/jira/bugtracking/attachment/FileUtil.java
index 6cffbe175..601dcb999 100644
--- a/src/main/java/com/javarush/jira/bugtracking/attachment/FileUtil.java
+++ b/src/main/java/com/javarush/jira/bugtracking/attachment/FileUtil.java
@@ -7,10 +7,8 @@
import org.springframework.core.io.UrlResource;
import org.springframework.web.multipart.MultipartFile;
-import java.io.File;
-import java.io.FileOutputStream;
import java.io.IOException;
-import java.io.OutputStream;
+import java.io.InputStream;
import java.net.MalformedURLException;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -25,14 +23,24 @@ public static void upload(MultipartFile multipartFile, String directoryPath, Str
throw new IllegalRequestDataException("Select a file to upload.");
}
- File dir = new File(directoryPath);
- if (dir.exists() || dir.mkdirs()) {
- File file = new File(directoryPath + fileName);
- try (OutputStream outStream = new FileOutputStream(file)) {
- outStream.write(multipartFile.getBytes());
- } catch (IOException ex) {
- throw new IllegalRequestDataException("Failed to upload file" + multipartFile.getOriginalFilename());
+ try {
+ Path directory = Paths.get(directoryPath);
+ Files.createDirectories(directory);
+
+ Path filePath = directory.resolve(fileName).normalize();
+
+ if (!filePath.startsWith(directory)) {
+ throw new IllegalRequestDataException("Invalid file path: " + fileName);
}
+
+ try (InputStream inputStream = multipartFile.getInputStream()) {
+ Files.copy(inputStream, filePath);
+ }
+
+ } catch (IOException ex) {
+ throw new IllegalRequestDataException(
+ "Failed to upload file: " + multipartFile.getOriginalFilename() + ex
+ );
}
}
diff --git a/src/main/java/com/javarush/jira/bugtracking/task/Task.java b/src/main/java/com/javarush/jira/bugtracking/task/Task.java
index 6c9f4d96e..9ba46a255 100644
--- a/src/main/java/com/javarush/jira/bugtracking/task/Task.java
+++ b/src/main/java/com/javarush/jira/bugtracking/task/Task.java
@@ -16,7 +16,7 @@
import java.util.List;
import java.util.Set;
-
+import java.util.HashSet;
import static com.javarush.jira.bugtracking.task.TaskUtil.checkStatusChangePossible;
@Entity
@@ -74,13 +74,20 @@ public class Task extends TitleEntity implements HasCode {
@OneToMany(mappedBy = "taskId", fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true)
private List activities;
- public Task(Long id, String title, String typeCode, String statusCode, Long parentId, long projectId, Long sprintId) {
+ public Task(Long id, String title, String typeCode, String statusCode, Long parentId, long projectId, Long sprintId, Set tags) {
super(id, title);
this.typeCode = typeCode;
this.statusCode = statusCode;
this.parentId = parentId;
this.projectId = projectId;
this.sprintId = sprintId;
+ this.tags = tags != null ? new HashSet<>(tags) : new HashSet<>();
+ }
+
+ public Task(Long id, String title, String typeCode, String statusCode,
+ Long parentId, Long projectId, Long sprintId) {
+ this(id, title, typeCode, statusCode, parentId,
+ projectId != null ? projectId : 0L, sprintId, new HashSet<>());
}
public void checkAndSetStatusCode(String statusCode) {
diff --git a/src/main/java/com/javarush/jira/bugtracking/task/TaskController.java b/src/main/java/com/javarush/jira/bugtracking/task/TaskController.java
index b53f7ff37..1b5c034b2 100644
--- a/src/main/java/com/javarush/jira/bugtracking/task/TaskController.java
+++ b/src/main/java/com/javarush/jira/bugtracking/task/TaskController.java
@@ -3,10 +3,7 @@
import com.javarush.jira.bugtracking.Handlers;
import com.javarush.jira.bugtracking.UserBelong;
import com.javarush.jira.bugtracking.UserBelongRepository;
-import com.javarush.jira.bugtracking.task.to.ActivityTo;
-import com.javarush.jira.bugtracking.task.to.TaskTo;
-import com.javarush.jira.bugtracking.task.to.TaskToExt;
-import com.javarush.jira.bugtracking.task.to.TaskToFull;
+import com.javarush.jira.bugtracking.task.to.*;
import com.javarush.jira.bugtracking.tree.ITreeNode;
import com.javarush.jira.common.util.Util;
import com.javarush.jira.login.AuthUser;
@@ -20,9 +17,7 @@
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
-import java.util.ArrayList;
-import java.util.LinkedList;
-import java.util.List;
+import java.util.*;
import static com.javarush.jira.common.BaseHandler.createdResponse;
@@ -151,6 +146,61 @@ public void delete(@PathVariable long id) {
activityService.delete(id);
}
+
+
+ @GetMapping("/{id}/tags")
+ @ResponseStatus(HttpStatus.OK)
+ public Set getTags(@PathVariable long id) {
+ log.info("Getting tags for task {}", id);
+ Set tags = taskService.getTags(id);
+ return tags;
+ }
+
+ /**
+ * POST /api/tasks/{id}/tags - добавить тег к задаче
+ * Body: {"tag": "urgent"}
+ */
+ @PostMapping("/{id}/tags")
+ @ResponseStatus(HttpStatus.NO_CONTENT)
+ public void addTag(@PathVariable long id, @Valid @RequestBody TagRequest tagRequest) {
+ log.info("Adding tag '{}' to task {}", tagRequest.getTag(), id);
+ taskService.addTag(id, tagRequest.getTag());
+ }
+
+ /**
+ * DELETE /api/tasks/{id}/tags/{tag} - удалить тег у задачи
+ */
+ @DeleteMapping("/{id}/tags/{tag}")
+ @ResponseStatus(HttpStatus.NO_CONTENT)
+ public void removeTag(@PathVariable long id, @PathVariable String tag) {
+ log.info("Removing tag '{}' from task {}", tag, id);
+ taskService.removeTag(id, tag);
+ }
+
+ /**
+ * PUT /api/tasks/{id}/tags - заменить все теги задачи
+ * Body: ["feature", "backend", "api"]
+ */
+ @PutMapping("/{id}/tags")
+ @ResponseStatus(HttpStatus.NO_CONTENT)
+ public void replaceTags(@PathVariable long id, @RequestBody Set tags) {
+ log.info("Replacing tags for task {} with {}", id, tags);
+ taskService.replaceTags(id, tags);
+ }
+
+ /**
+ * GET /api/tasks/by-tag?tag=urgent - найти все задачи по тегу
+ */
+ @GetMapping("/by-tag")
+ public Set findTasksByTag(@RequestParam String tag) {
+ log.info("Finding tasks by tag: {}", tag);
+ Set tasks = taskService.findTasksByTag(tag);
+ List taskToList = handler.getMapper().toList(new ArrayList<>(tasks));
+ return new HashSet<>(taskToList);
+ }
+
+
+
private record TaskTreeNode(TaskTo taskTo, List subNodes) implements ITreeNode {
public TaskTreeNode(TaskTo taskTo) {
this(taskTo, new LinkedList<>());
diff --git a/src/main/java/com/javarush/jira/bugtracking/task/TaskRepository.java b/src/main/java/com/javarush/jira/bugtracking/task/TaskRepository.java
index 982c4be8b..d265628a1 100644
--- a/src/main/java/com/javarush/jira/bugtracking/task/TaskRepository.java
+++ b/src/main/java/com/javarush/jira/bugtracking/task/TaskRepository.java
@@ -3,10 +3,12 @@
import com.javarush.jira.common.BaseRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
+import java.util.Set;
@Transactional(readOnly = true)
public interface TaskRepository extends BaseRepository {
@@ -37,4 +39,34 @@ WITH RECURSIVE task_with_subtasks AS (
WHERE id IN (SELECT child FROM task_with_subtasks)
""", nativeQuery = true)
void setTaskAndSubTasksSprint(long taskId, Long sprintId);
+
+
+ @Query("SELECT t.tags FROM Task t WHERE t.id = :taskId")
+ Set findTagsByTaskId(@Param("taskId") Long taskId);
+
+
+ @Modifying
+ @Transactional
+ @Query(value = "INSERT INTO task_tag (task_id, tag) VALUES (:taskId, :tag)",
+ nativeQuery = true)
+ void addTag(@Param("taskId") Long taskId, @Param("tag") String tag);
+
+ @Modifying
+ @Transactional
+ @Query(value = "DELETE FROM task_tag WHERE task_id = :taskId AND tag = :tag",
+ nativeQuery = true)
+ void removeTag(@Param("taskId") Long taskId, @Param("tag") String tag);
+
+ @Query("SELECT CASE WHEN COUNT(t) > 0 THEN true ELSE false END " +
+ "FROM Task t JOIN t.tags tag WHERE t.id = :taskId AND tag = :tag")
+ boolean existsByTaskIdAndTag(@Param("taskId") Long taskId, @Param("tag") String tag);
+
+ @Modifying
+ @Transactional
+ @Query(value = "DELETE FROM task_tag WHERE task_id = :taskId",
+ nativeQuery = true)
+ void deleteAllTags(@Param("taskId") Long taskId);
+
+ @Query("SELECT t FROM Task t JOIN t.tags tag WHERE tag = :tag")
+ Set findTasksByTag(@Param("tag") String tag);
}
diff --git a/src/main/java/com/javarush/jira/bugtracking/task/TaskService.java b/src/main/java/com/javarush/jira/bugtracking/task/TaskService.java
index e6f385548..b4e66a661 100644
--- a/src/main/java/com/javarush/jira/bugtracking/task/TaskService.java
+++ b/src/main/java/com/javarush/jira/bugtracking/task/TaskService.java
@@ -21,6 +21,7 @@
import java.time.LocalDateTime;
import java.util.List;
+import java.util.Set;
import static com.javarush.jira.bugtracking.ObjectType.TASK;
import static com.javarush.jira.bugtracking.task.TaskUtil.fillExtraFields;
@@ -39,6 +40,7 @@ public class TaskService {
private final SprintRepository sprintRepository;
private final TaskExtMapper extMapper;
private final UserBelongRepository userBelongRepository;
+ private final TaskRepository taskRepository;
@Transactional
public void changeStatus(long taskId, String statusCode) {
@@ -140,4 +142,74 @@ private void checkAssignmentActionPossible(long id, String userType, boolean ass
throw new DataConflictException(String.format(assign ? CANNOT_ASSIGN : CANNOT_UN_ASSIGN, userType, task.getStatusCode()));
}
}
+
+
+
+ @Transactional(readOnly = true)
+ public Set getTags(Long taskId) {
+ if (!taskRepository.existsById(taskId)) {
+ throw new NotFoundException("Task with id " + taskId + " not found");
+ }
+ return taskRepository.findTagsByTaskId(taskId);
+ }
+
+ @Transactional
+ public void addTag(Long taskId, String tag) {
+ String normalizedTag = tag.trim().toLowerCase();
+
+ if (normalizedTag.isEmpty()) {
+ throw new IllegalArgumentException("Tag cannot be empty");
+ }
+
+ if (normalizedTag.length() < 2 || normalizedTag.length() > 32) {
+ throw new IllegalArgumentException("Tag must be between 2 and 32 characters");
+ }
+
+ if (!taskRepository.existsById(taskId)) {
+ throw new NotFoundException("Task with id " + taskId + " not found");
+ }
+
+ if (taskRepository.existsByTaskIdAndTag(taskId, normalizedTag)) {
+ throw new DataConflictException("Tag '" + normalizedTag + "' already exists for task " + taskId);
+ }
+
+ taskRepository.addTag(taskId, normalizedTag);
+ }
+
+ @Transactional
+ public void removeTag(Long taskId, String tag) {
+ String normalizedTag = tag.trim().toLowerCase();
+
+ if (!taskRepository.existsById(taskId)) {
+ throw new NotFoundException("Task with id " + taskId + " not found");
+ }
+
+ taskRepository.removeTag(taskId, normalizedTag);
+ }
+
+ @Transactional
+ public void replaceTags(Long taskId, Set newTags) {
+ if (!taskRepository.existsById(taskId)) {
+ throw new NotFoundException("Task with id " + taskId + " not found");
+ }
+
+ taskRepository.deleteAllTags(taskId);
+
+ if (newTags != null && !newTags.isEmpty()) {
+ for (String tag : newTags) {
+ String normalizedTag = tag.trim().toLowerCase();
+ if (normalizedTag.length() >= 2 && normalizedTag.length() <= 32) {
+ taskRepository.addTag(taskId, normalizedTag);
+ }
+ }
+ }
+ }
+
+ @Transactional(readOnly = true)
+ public Set findTasksByTag(String tag) {
+ String normalizedTag = tag.trim().toLowerCase();
+ return taskRepository.findTasksByTag(normalizedTag);
+ }
+
+
}
diff --git a/src/main/java/com/javarush/jira/bugtracking/task/to/TagRequest.java b/src/main/java/com/javarush/jira/bugtracking/task/to/TagRequest.java
new file mode 100644
index 000000000..59959a7bc
--- /dev/null
+++ b/src/main/java/com/javarush/jira/bugtracking/task/to/TagRequest.java
@@ -0,0 +1,30 @@
+package com.javarush.jira.bugtracking.task.to;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Pattern;
+import jakarta.validation.constraints.Size;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class TagRequest {
+
+ @NotBlank(message = "Tag cannot be empty")
+ @Size(min = 2, max = 32, message = "Tag must be between 2 and 32 characters")
+ @Pattern(regexp = "^[a-zA-Z0-9_-]+$",
+ message = "Tag can only contain letters, numbers, underscores and hyphens")
+ private String tag;
+
+ // Метод getTag() автоматически создается аннотацией @Data
+ // Но если Lombok не работает, добавьте вручную:
+ public String getTag() {
+ return tag;
+ }
+
+ public void setTag(String tag) {
+ this.tag = tag;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/javarush/jira/common/BaseMapper.java b/src/main/java/com/javarush/jira/common/BaseMapper.java
index b3ae2c6e7..9af4875ba 100644
--- a/src/main/java/com/javarush/jira/common/BaseMapper.java
+++ b/src/main/java/com/javarush/jira/common/BaseMapper.java
@@ -1,10 +1,14 @@
package com.javarush.jira.common;
+import com.javarush.jira.bugtracking.task.Task;
+import com.javarush.jira.bugtracking.task.to.TaskTo;
import com.javarush.jira.common.to.BaseTo;
import org.mapstruct.MappingTarget;
+import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
+import java.util.Set;
public interface BaseMapper {
@@ -17,4 +21,8 @@ public interface BaseMapper {
T toTo(E entity);
List toToList(Collection entities);
+
+ List toList(ArrayList tasks);
+
+ Set toSet(Set tasks);
}
diff --git a/src/main/java/com/javarush/jira/login/internal/sociallogin/handler/VkOAuth2UserDataHandler.java b/src/main/java/com/javarush/jira/login/internal/sociallogin/handler/VkOAuth2UserDataHandler.java
deleted file mode 100644
index e8e05be05..000000000
--- a/src/main/java/com/javarush/jira/login/internal/sociallogin/handler/VkOAuth2UserDataHandler.java
+++ /dev/null
@@ -1,35 +0,0 @@
-package com.javarush.jira.login.internal.sociallogin.handler;
-
-import org.springframework.stereotype.Component;
-
-import java.util.List;
-import java.util.Map;
-
-@Component("vk")
-public class VkOAuth2UserDataHandler implements OAuth2UserDataHandler {
- @Override
- public String getFirstName(OAuth2UserData oAuth2UserData) {
- return getAttribute(oAuth2UserData, "first_name");
- }
-
- @Override
- public String getLastName(OAuth2UserData oAuth2UserData) {
- return getAttribute(oAuth2UserData, "last_name");
- }
-
- @Override
- public String getEmail(OAuth2UserData oAuth2UserData) {
- return oAuth2UserData.getData("email");
- }
-
- private String getAttribute(OAuth2UserData oAuth2UserData, String name) {
- List