diff --git a/README.md b/README.md
index 719b268f5..5b263d852 100644
--- a/README.md
+++ b/README.md
@@ -27,4 +27,10 @@
- https://habr.com/ru/articles/259055/
Список выполненных задач:
-...
\ No newline at end of file
+2. Удалить социальные сети: vk, yandex.
+3. Вынести чувствительную информацию в отдельный проперти файл
+4. Переделать тесты так, чтоб во время тестов использовалась in memory БД (H2), а не PostgreSQL
+5. Написать тесты для всех публичных методов контроллера ProfileRestController
+6. Сделать рефакторинг метода com.javarush.jira.bugtracking.attachment.FileUtil#upload
+7. Добавить новый функционал: добавления тегов к задаче (REST API + реализация на сервисе)
+8. Добавить подсчет времени сколько задача находилась в работе и тестировании
\ No newline at end of file
diff --git a/config/_application-prod.yaml b/config/_application-prod.yaml
index 67fd8b7c2..6efe26dc9 100644
--- a/config/_application-prod.yaml
+++ b/config/_application-prod.yaml
@@ -3,7 +3,6 @@ app:
host-url: http://localhost
spring:
datasource:
- url: jdbc:postgresql://localhost:5432/jira
- username: jira
- password: JiraRush
-
+ url: ${DATABASE_URL}
+ username: ${DATABASE_USERNAME}
+ password: ${DATABASE_PASSWORD}
diff --git a/pom.xml b/pom.xml
index f6c152c68..1ea9d4410 100644
--- a/pom.xml
+++ b/pom.xml
@@ -142,6 +142,11 @@
junit-platform-launcher
test
+
+ com.h2database
+ h2
+ test
+
diff --git a/resources/static/fontawesome/css/all.css b/resources/static/fontawesome/css/all.css
index af5980828..6a16cc2f0 100644
--- a/resources/static/fontawesome/css/all.css
+++ b/resources/static/fontawesome/css/all.css
@@ -8603,10 +8603,6 @@ readers do not read off random characters that represent icons */
content: "\f3e8";
}
-.fa-vk:before {
- content: "\f189";
-}
-
.fa-untappd:before {
content: "\f405";
}
@@ -9955,10 +9951,6 @@ readers do not read off random characters that represent icons */
content: "\f3bc";
}
-.fa-yandex:before {
- content: "\f413";
-}
-
.fa-readme:before {
content: "\f4d5";
}
@@ -10183,10 +10175,6 @@ readers do not read off random characters that represent icons */
content: "\f7c6";
}
-.fa-yandex-international:before {
- content: "\f414";
-}
-
.fa-cc-amex:before {
content: "\f1f3";
}
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..c7bf0250f 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,7 @@
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.*;
import java.net.MalformedURLException;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -25,14 +22,13 @@ 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());
- }
+ Path dirPath = Paths.get(directoryPath);
+ try {
+ Files.createDirectories(dirPath);
+ Path filePath = dirPath.resolve(fileName);
+ Files.write(filePath, multipartFile.getBytes());
+ } catch (IOException e) {
+ throw new IllegalRequestDataException("Failed to upload file" + multipartFile.getOriginalFilename());
}
}
diff --git a/src/main/java/com/javarush/jira/bugtracking/task/ActivityRepository.java b/src/main/java/com/javarush/jira/bugtracking/task/ActivityRepository.java
index 3ce8a9386..aa6f710e2 100644
--- a/src/main/java/com/javarush/jira/bugtracking/task/ActivityRepository.java
+++ b/src/main/java/com/javarush/jira/bugtracking/task/ActivityRepository.java
@@ -4,6 +4,7 @@
import org.springframework.data.jpa.repository.Query;
import org.springframework.transaction.annotation.Transactional;
+import java.time.LocalDateTime;
import java.util.List;
@Transactional(readOnly = true)
@@ -13,4 +14,10 @@ public interface ActivityRepository extends BaseRepository {
@Query("SELECT a FROM Activity a JOIN FETCH a.author WHERE a.taskId =:taskId AND a.comment IS NOT NULL ORDER BY a.updated DESC")
List findAllComments(long taskId);
+
+ @Query("SELECT a FROM Activity a JOIN FETCH a.author WHERE a.taskId =:taskId AND a.statusCode=:statusCode ORDER BY a.updated ASC")
+ List findByTaskIdAndStatusCodeOrderByUpdatedAsc(long taskId, String statusCode);
+
+ @Query("SELECT a FROM Activity a JOIN FETCH a.author WHERE a.taskId =:taskId AND a.statusCode=:statusCode AND a.updated > :updatedAfter ORDER BY a.updated ASC")
+ List findByTaskIdAndStatusCodeAndUpdatedAfterOrderByUpdatedAsc(long taskId, String statusCode, LocalDateTime updatedAfter);
}
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..628432047 100644
--- a/src/main/java/com/javarush/jira/bugtracking/task/TaskController.java
+++ b/src/main/java/com/javarush/jira/bugtracking/task/TaskController.java
@@ -13,6 +13,7 @@
import jakarta.annotation.Nullable;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
@@ -23,6 +24,7 @@
import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;
+import java.util.Set;
import static com.javarush.jira.common.BaseHandler.createdResponse;
@@ -151,6 +153,50 @@ public void delete(@PathVariable long id) {
activityService.delete(id);
}
+ @GetMapping("/{id}/tags")
+ public Set getTags(@PathVariable(name = "id") Long taskId) {
+ return taskService.getTaskTags(taskId);
+ }
+
+ @PostMapping("/{id}/tags")
+ @ResponseStatus(HttpStatus.CREATED)
+ public Set addTag(
+ @PathVariable(name = "id") Long taskId,
+ @Valid @RequestParam String tag) {
+ return taskService.addTagToTask(taskId, tag);
+ }
+
+ @PostMapping("/{id}/tags/batch")
+ @ResponseStatus(HttpStatus.CREATED)
+ public Set addTags(
+ @PathVariable(name = "id") Long taskId,
+ @Valid @RequestBody Set<@Size(min = 2, max = 32) String> tags) {
+ return taskService.addTagsToTask(taskId, tags);
+ }
+
+ @DeleteMapping("/{id}/tags")
+ public Set deleteTag(
+ @PathVariable(name = "id") Long taskId,
+ @RequestParam String tag) {
+ return taskService.removeTagFromTask(taskId, tag);
+ }
+
+ @DeleteMapping("/{id}/tags/batch")
+ public Set deleteTags(
+ @PathVariable(name = "id") Long taskId,
+ @RequestBody Set<@Size(min = 2, max = 32) String> tags) {
+ log.info("Remove tags {} from task {}", tags, taskId);
+ return taskService.removeTagsFromTask(taskId, tags);
+ }
+
+ @PutMapping("/{id}/tags/batch")
+ public Set replaceTags(
+ @PathVariable(name = "id") Long taskId,
+ @Valid @RequestBody Set<@Size(min = 2, max = 32) String> tags) {
+ log.info("Replace tags for task {} with {}", taskId, tags);
+ return taskService.replaceTaskTags(taskId, tags);
+ }
+
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..691f4855e 100644
--- a/src/main/java/com/javarush/jira/bugtracking/task/TaskRepository.java
+++ b/src/main/java/com/javarush/jira/bugtracking/task/TaskRepository.java
@@ -7,6 +7,7 @@
import java.util.List;
import java.util.Optional;
+import java.util.Set;
@Transactional(readOnly = true)
public interface TaskRepository extends BaseRepository {
@@ -37,4 +38,7 @@ 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 =:id")
+ Set getTaskTags(long id);
}
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..cae5d5c1c 100644
--- a/src/main/java/com/javarush/jira/bugtracking/task/TaskService.java
+++ b/src/main/java/com/javarush/jira/bugtracking/task/TaskService.java
@@ -5,6 +5,7 @@
import com.javarush.jira.bugtracking.UserBelongRepository;
import com.javarush.jira.bugtracking.sprint.Sprint;
import com.javarush.jira.bugtracking.sprint.SprintRepository;
+import com.javarush.jira.bugtracking.task.exception.ValidationException;
import com.javarush.jira.bugtracking.task.mapper.TaskExtMapper;
import com.javarush.jira.bugtracking.task.mapper.TaskFullMapper;
import com.javarush.jira.bugtracking.task.to.TaskToExt;
@@ -15,30 +16,43 @@
import com.javarush.jira.login.AuthUser;
import com.javarush.jira.ref.RefType;
import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.Assert;
+import java.time.Duration;
import java.time.LocalDateTime;
+import java.util.HashSet;
import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Collectors;
import static com.javarush.jira.bugtracking.ObjectType.TASK;
import static com.javarush.jira.bugtracking.task.TaskUtil.fillExtraFields;
import static com.javarush.jira.bugtracking.task.TaskUtil.makeActivity;
import static com.javarush.jira.ref.ReferenceService.getRefTo;
+@Slf4j
@Service
@RequiredArgsConstructor
public class TaskService {
static final String CANNOT_ASSIGN = "Cannot assign as %s to task with status=%s";
static final String CANNOT_UN_ASSIGN = "Cannot unassign as %s from task with status=%s";
+ static final String TASK_STATUS_IN_PROGRESS = "in_progress";
+ static final String TASK_STATUS_READY_FOR_REVIEW = "ready_for_review";
+ static final String TASK_STATUS_DONE = "done";
+
private final Handlers.TaskExtHandler handler;
private final Handlers.ActivityHandler activityHandler;
private final TaskFullMapper fullMapper;
private final SprintRepository sprintRepository;
private final TaskExtMapper extMapper;
private final UserBelongRepository userBelongRepository;
+ private final ActivityRepository activityRepository;
+ private final TaskRepository taskRepository;
@Transactional
public void changeStatus(long taskId, String statusCode) {
@@ -132,6 +146,16 @@ public void unAssign(long id, String userType, long userId) {
assignment.setEndpoint(LocalDateTime.now());
}
+ @Transactional(readOnly = true)
+ public Duration getTimeInProgress(long id) {
+ return getTimeBetweenStatuses(id, TASK_STATUS_IN_PROGRESS, TASK_STATUS_READY_FOR_REVIEW);
+ }
+
+ @Transactional(readOnly = true)
+ public Duration getTimeInTesting(long id) {
+ return getTimeBetweenStatuses(id, TASK_STATUS_READY_FOR_REVIEW, TASK_STATUS_DONE);
+ }
+
private void checkAssignmentActionPossible(long id, String userType, boolean assign) {
Assert.notNull(userType, "userType must not be null");
Task task = handler.getRepository().getExisted(id);
@@ -140,4 +164,124 @@ private void checkAssignmentActionPossible(long id, String userType, boolean ass
throw new DataConflictException(String.format(assign ? CANNOT_ASSIGN : CANNOT_UN_ASSIGN, userType, task.getStatusCode()));
}
}
+
+ private Duration getTimeBetweenStatuses(long id, String statusFrom, String statusTo) {
+ Optional timeFrom = activityRepository.findByTaskIdAndStatusCodeOrderByUpdatedAsc(id, statusFrom).stream()
+ .findFirst()
+ .map(Activity::getUpdated);
+ if (timeFrom.isEmpty()) {
+ return null;
+ }
+
+ Optional timeTo = activityRepository.findByTaskIdAndStatusCodeAndUpdatedAfterOrderByUpdatedAsc(id, statusTo, timeFrom.get()).stream()
+ .findFirst()
+ .map(Activity::getUpdated);
+
+ return timeTo.map(localDateTime -> Duration.between(timeFrom.get(), localDateTime)).orElse(null);
+ }
+
+ @Transactional(readOnly = true)
+ public Set getTaskTags(Long taskId) {
+ return taskRepository.getTaskTags(taskId);
+ }
+
+ @Transactional
+ public Set addTagToTask(Long taskId, String tag) {
+ validateTag(tag);
+ Task task = handler.get(taskId);
+
+ if (task != null && task.getTags().contains(tag)) {
+ log.warn("Tag '{}' already exists for task {}", tag, taskId);
+ return new HashSet<>(task.getTags());
+ }
+
+ task.getTags().add(tag);
+ handler.update(task, taskId);
+ log.info("Added tag '{}' to task {}", tag, taskId);
+ return new HashSet<>(task.getTags());
+ }
+
+ @Transactional
+ public Set addTagsToTask(Long taskId, Set tags) {
+ validateTags(tags);
+ Task task = handler.get(taskId);
+
+ Set existingTags = task.getTags();
+ Set tagsToAdd = tags.stream()
+ .filter(tag -> !existingTags.contains(tag))
+ .collect(Collectors.toSet());
+
+ if (tagsToAdd.isEmpty()) {
+ log.warn("No new tags to add for task {}", taskId);
+ return new HashSet<>(existingTags);
+ }
+
+ existingTags.addAll(tagsToAdd);
+ handler.update(task, taskId);
+ log.info("Added tags {} to task {}", tagsToAdd, taskId);
+ return new HashSet<>(existingTags);
+ }
+
+ @Transactional
+ public Set removeTagFromTask(Long taskId, String tag) {
+ Task task = handler.get(taskId);
+
+ if (!task.getTags().contains(tag)) {
+ log.warn("Tag '{}' not found for task {}", tag, taskId);
+ throw new NotFoundException("Tag '" + tag + "' not found for task " + taskId);
+ }
+
+ task.getTags().remove(tag);
+ handler.update(task, taskId);
+ log.info("Removed tag '{}' from task {}", tag, taskId);
+ return new HashSet<>(task.getTags());
+ }
+
+ @Transactional
+ public Set removeTagsFromTask(Long taskId, Set tags) {
+ validateTags(tags);
+ Task task = handler.get(taskId);
+
+ Set existingTags = task.getTags();
+ Set tagsToRemove = tags.stream()
+ .filter(existingTags::contains)
+ .collect(Collectors.toSet());
+
+ if (tagsToRemove.isEmpty()) {
+ log.warn("No tags to remove for task {}", taskId);
+ return new HashSet<>(existingTags);
+ }
+
+ existingTags.removeAll(tagsToRemove);
+ handler.update(task, taskId);
+ log.info("Removed tags {} from task {}", tagsToRemove, taskId);
+ return new HashSet<>(existingTags);
+ }
+
+ @Transactional
+ public Set replaceTaskTags(Long taskId, Set newTags) {
+ validateTags(newTags);
+ Task task = handler.get(taskId);
+
+ task.setTags(newTags);
+ handler.update(task, taskId);
+ log.info("Replaced tags for task {} with {}", taskId, newTags);
+ return new HashSet<>(newTags);
+ }
+
+ private void validateTag(String tag) {
+ if (tag == null || tag.isBlank()) {
+ throw new ValidationException("Tag cannot be null or blank");
+ }
+ if (tag.length() < 2 || tag.length() > 32) {
+ throw new ValidationException("Tag must be between 2 and 32 characters");
+ }
+ }
+
+ private void validateTags(Set tags) {
+ if (tags == null || tags.isEmpty()) {
+ throw new ValidationException("Tags set cannot be null or empty");
+ }
+ tags.forEach(this::validateTag);
+ }
}
diff --git a/src/main/java/com/javarush/jira/bugtracking/task/exception/ValidationException.java b/src/main/java/com/javarush/jira/bugtracking/task/exception/ValidationException.java
new file mode 100644
index 000000000..83fd8989c
--- /dev/null
+++ b/src/main/java/com/javarush/jira/bugtracking/task/exception/ValidationException.java
@@ -0,0 +1,7 @@
+package com.javarush.jira.bugtracking.task.exception;
+
+public class ValidationException extends RuntimeException {
+ public ValidationException(String message) {
+ super(message);
+ }
+}
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