TaskController.java
/*
* BSD 2-Clause License
*
* Copyright (c) 2022, [Aleksandra Serba, Marcin Czerniak, Bartosz Wawrzyniak, Adrian Antkowiak]
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
* FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
* DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
* CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
* OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
package dev.vernite.vernite.task;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Optional;
import org.springframework.http.HttpStatus;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import dev.vernite.vernite.auditlog.AuditLog;
import dev.vernite.vernite.auditlog.AuditLogRepository;
import dev.vernite.vernite.auditlog.JsonDiff;
import dev.vernite.vernite.common.utils.counter.CounterSequenceRepository;
import dev.vernite.vernite.integration.git.GitTaskService;
import dev.vernite.vernite.project.Project;
import dev.vernite.vernite.project.ProjectRepository;
import dev.vernite.vernite.release.Release;
import dev.vernite.vernite.release.ReleaseRepository;
import dev.vernite.vernite.sprint.Sprint;
import dev.vernite.vernite.sprint.SprintRepository;
import dev.vernite.vernite.status.StatusRepository;
import dev.vernite.vernite.task.Task.Type;
import dev.vernite.vernite.user.User;
import dev.vernite.vernite.user.UserRepository;
import dev.vernite.vernite.utils.FieldErrorException;
import dev.vernite.vernite.utils.ObjectNotFoundException;
import io.swagger.v3.oas.annotations.Parameter;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
@RestController
@AllArgsConstructor
@RequestMapping("/project/{projectId}/task")
public class TaskController {
private static final String PARENT_FIELD = "parentTaskId";
private MappingJackson2HttpMessageConverter converter;
private TaskRepository taskRepository;
private StatusRepository statusRepository;
private AuditLogRepository auditLogRepository;
private ProjectRepository projectRepository;
private UserRepository userRepository;
private SprintRepository sprintRepository;
private ReleaseRepository releaseRepository;
private CounterSequenceRepository counterSequenceRepository;
private GitTaskService service;
/**
* Handle the request to change sprint of a task.
*
* @param sprintId the sprint id (can be null)
* @param task the task
* @param project the project
*/
private void handleSprint(Long sprintId, Task task, Project project) {
Sprint sprint = null;
if (sprintId != null) {
sprint = sprintRepository.findByIdAndProjectOrThrow(sprintId, project);
if (sprint.getStatusEnum() == Sprint.Status.CLOSED) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "cannot assign task to closed sprint");
}
}
task.setSprint(sprint);
}
/**
* Handle the request to change release ID of a task.
*
* @param sprintId the release id (can be null)
* @param task the task
* @param project the project
*/
private void handleReleaseId(Long releaseId, Task task, Project project) {
Release release = null;
if (releaseId != null) {
release = releaseRepository.findById(releaseId)
.orElseThrow(() -> new ObjectNotFoundException());
if (release.getProject().getId() != project.getId()) {
throw new ObjectNotFoundException();
}
}
task.setRelease(release);
}
/**
* Handle the request to change the assignee of a task.
*
* @param assigneeId the assignee id (can be null)
* @param task the task
*/
private void handleAssignee(Optional<Long> assigneeId, Task task) {
User assignee = null;
if (assigneeId.isPresent()) {
assignee = userRepository.findById(assigneeId.get()).orElse(null);
if (!task.getStatus().getProject().isMember(assignee)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid assignee");
}
}
task.setAssignee(assignee);
}
/**
* Handle the request to change the parent task of a task.
*
* @param parentTaskId the parent task id (can be null)
* @param task the task
* @param project the project
*/
private void handleParent(Optional<Long> parentTaskId, Task task, Project project) {
Task parentTask = null;
if (parentTaskId.isPresent()) {
parentTask = taskRepository.findByProjectAndNumberOrThrow(project, parentTaskId.get());
if (!Type.values()[task.getType()].isValidParent(Type.values()[parentTask.getType()])) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid parent task");
}
}
task.setParentTask(parentTask);
}
/**
* Get all tasks for project with given ID.
*
* @param user logged in user
* @param projectId ID of project
* @param filter filter for tasks
* @return list of tasks
*/
@GetMapping
public List<Task> getAll(@NotNull @Parameter(hidden = true) User user, @PathVariable long projectId,
@ModelAttribute TaskFilter filter) {
var project = projectRepository.findByIdAndMemberOrThrow(projectId, user);
return taskRepository.findAllOrdered(filter.toSpecification(project));
}
/**
* Get task with given ID.
*
* @param user logged in user
* @param projectId ID of project
* @param id ID of task
* @return task with given ID
*/
@GetMapping("/{id}")
public Task get(@NotNull @Parameter(hidden = true) User user, @PathVariable long projectId, @PathVariable long id) {
var project = projectRepository.findByIdAndMemberOrThrow(projectId, user);
return taskRepository.findByProjectAndNumberOrThrow(project, id);
}
/**
* Create new task.
*
* @param user logged in user
* @param projectId ID of project
* @param create request with task data
* @return newly created task
*/
@PostMapping
public Mono<Task> create(@NotNull @Parameter(hidden = true) User user, @PathVariable long projectId,
@RequestBody @Valid CreateTask create) {
var project = projectRepository.findByIdAndMemberOrThrow(projectId, user);
var status = statusRepository.findByIdAndProjectOrThrow(create.getStatusId(), project);
var id = counterSequenceRepository.getIncrementCounter(project.getTaskCounter().getId());
var task = new Task(id, status, user, create);
handleSprint(create.getSprintId(), task, project);
handleAssignee(Optional.ofNullable(create.getAssigneeId()), task);
handleParent(Optional.ofNullable(create.getParentTaskId()), task, project);
handleReleaseId(create.getReleaseId(), task, project);
if (task.getType() == Task.Type.SUBTASK.ordinal() && task.getParentTask() == null) {
throw new FieldErrorException(PARENT_FIELD, "subtask must have parent");
}
Task savedTask = taskRepository.save(task);
List<Mono<Void>> results = new ArrayList<>();
if (create.getIssue() != null) {
results.add(service.handleIssueAction(create.getIssue(), task).then());
}
if (create.getPull() != null) {
results.add(service.handlePullAction(create.getPull(), task).then());
}
return Flux.concat(results).then(Mono.fromRunnable(() -> {
JsonNode newValue = converter.getObjectMapper().valueToTree(savedTask);
AuditLog log = new AuditLog();
log.setDate(new Date());
log.setUser(user);
log.setProject(project);
log.setType("task");
log.setOldValues(null);
try {
log.setNewValues(converter.getObjectMapper().writeValueAsString(newValue));
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
log.setSameValues(null);
auditLogRepository.save(log);
})).then(Mono.just(savedTask));
}
/**
* Update task with given ID.
*
* @param user logged in user
* @param projectId ID of project
* @param id ID of task
* @param update request with task data
* @return updated task
*/
@PutMapping("/{id}")
public Mono<Task> update(@NotNull @Parameter(hidden = true) User user, @PathVariable long projectId,
@PathVariable long id, @RequestBody @Valid UpdateTask update) {
var project = projectRepository.findByIdAndMemberOrThrow(projectId, user);
var task = taskRepository.findByProjectAndNumberOrThrow(project, id);
JsonNode oldValue = converter.getObjectMapper().valueToTree(task);
task.update(update);
if (update.isSprintIdSet()) {
handleSprint(update.getSprintId(), task, project);
}
if (update.isAssigneeIdSet()) {
handleAssignee(Optional.ofNullable(update.getAssigneeId()), task);
}
if (update.isParentTaskIdSet()) {
handleParent(Optional.ofNullable(update.getParentTaskId()), task, project);
}
if (update.isReleaseIdSet()) {
handleReleaseId(update.getReleaseId(), task, project);
}
if (update.getStatusId() != null) {
var status = statusRepository.findByIdAndProjectOrThrow(update.getStatusId(), project);
task.setStatus(status);
}
if (task.getType() == Task.Type.SUBTASK.ordinal() && task.getParentTask() == null) {
throw new FieldErrorException(PARENT_FIELD, "subtask must have parent");
}
Task savedTask = taskRepository.save(task);
List<Mono<Void>> results = new ArrayList<>();
if (update.getIssue() != null) {
results.add(service.handleIssueAction(update.getIssue(), task).then());
}
if (update.getPull() != null) {
results.add(service.handlePullAction(update.getPull(), task).then());
}
return Flux.concat(results).then(service.patchIssue(task).then()).then(Mono.fromRunnable(() -> {
JsonNode newValue = converter.getObjectMapper().valueToTree(savedTask);
JsonNode[] out = new JsonNode[3];
JsonDiff.diff(oldValue, newValue, out);
if (out[0] == null && out[1] == null) {
return;
}
AuditLog log = new AuditLog();
log.setDate(new Date());
log.setUser(user);
log.setProject(project);
log.setType("task");
try {
log.apply(converter.getObjectMapper(), out);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
auditLogRepository.save(log);
})).thenReturn(savedTask);
}
/**
* Delete task with given ID.
*
* @param user logged in user
* @param projectId ID of project
* @param id ID of task
* @throws JsonProcessingException thrown when JSON serialization fails
*/
@DeleteMapping("/{id}")
public void delete(@NotNull @Parameter(hidden = true) User user, @PathVariable long projectId,
@PathVariable long id) throws JsonProcessingException {
var project = projectRepository.findByIdAndMemberOrThrow(projectId, user);
Task task = taskRepository.findByProjectAndNumberOrThrow(project, id);
JsonNode oldValue = converter.getObjectMapper().valueToTree(task);
AuditLog log = new AuditLog();
log.setDate(new Date());
log.setUser(user);
log.setProject(project);
log.setType("task");
log.setOldValues(converter.getObjectMapper().writeValueAsString(oldValue));
log.setNewValues(null);
log.setSameValues(null);
auditLogRepository.save(log);
taskRepository.delete(task);
}
}