Skip to content

Commit eb7e893

Browse files
authored
[Feature] Implment GET endpoint to retrieve all tours that given task is linked to (#559)
* feat: create new response dto to hold data about task's linked tours * feat: add new mapping * feat: add new query to fetch the list of linked tours for a given task * feat: add and implement new method to get the linked tours for a task * feat: add new endpoint for task's linked tours * test: add new unit tests * fix: formatting * feat: use bulk method defined in CompetitionFacade to receive tour data * feat: add logging and early return if tourIds is empty * test: update service tests * fix: detect missing tour ids and log them * fix: formatter
1 parent b79c30a commit eb7e893

8 files changed

Lines changed: 219 additions & 0 deletions

File tree

src/main/java/com/itasocialacademy/oitassist/taskassignment/controller/AssignmentController.java

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@
66
import com.itasocialacademy.oitassist.taskassignment.dto.request.CreateTaskAssignmentRequestDTO;
77
import com.itasocialacademy.oitassist.taskassignment.dto.request.UpdateTaskAssignmentRequestDTO;
88
import com.itasocialacademy.oitassist.taskassignment.dto.response.DetailedTaskAssignmentResponseDTO;
9+
import com.itasocialacademy.oitassist.taskassignment.dto.response.LinkedToursResponseDTO;
910
import com.itasocialacademy.oitassist.taskassignment.dto.response.TaskAssignmentResponseDTO;
1011
import com.itasocialacademy.oitassist.taskassignment.service.interfaces.AssignmentService;
1112
import io.swagger.v3.oas.annotations.Operation;
13+
import io.swagger.v3.oas.annotations.media.ArraySchema;
1214
import io.swagger.v3.oas.annotations.media.Content;
1315
import io.swagger.v3.oas.annotations.media.Schema;
1416
import io.swagger.v3.oas.annotations.responses.ApiResponse;
@@ -23,6 +25,7 @@
2325
import org.springframework.http.ResponseEntity;
2426
import org.springframework.security.access.prepost.PreAuthorize;
2527
import org.springframework.web.bind.annotation.*;
28+
import java.util.List;
2629

2730
@RestController
2831
@RequestMapping("/api/v1")
@@ -112,6 +115,24 @@ public ResponseEntity<PageResponse<TaskAssignmentResponseDTO>> getByTour(@PathVa
112115
return ResponseEntity.ok(PageResponse.from(assignmentService.getAssignmentsByTourId(pageable, tourId)));
113116
}
114117

118+
@Operation(
119+
summary = "Get tours linked to a task",
120+
description = "Retrieves a list of tours linked to the specified task body. Requires ADMIN or ORG role.")
121+
@ApiResponses(value = {
122+
@ApiResponse(responseCode = "200", description = "Linked tours retrieved successfully",
123+
content = @Content(mediaType = "application/json",
124+
array = @ArraySchema(schema = @Schema(implementation = LinkedToursResponseDTO.class)))),
125+
@ApiResponse(responseCode = "403", description = "Access denied (requires ADMIN or ORG role)",
126+
content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))),
127+
@ApiResponse(responseCode = "404", description = "Task body or linked tour not found",
128+
content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class)))
129+
})
130+
@GetMapping("/tasks/{taskId}/linked-tours")
131+
@PreAuthorize("hasAnyRole('ADMIN', 'ORG')")
132+
public ResponseEntity<List<LinkedToursResponseDTO>> getLinkedTours(@PathVariable Long taskId) {
133+
return ResponseEntity.ok().body(assignmentService.getLinkedToursByTaskId(taskId));
134+
}
135+
115136
@Operation(
116137
summary = "Update a task assignment",
117138
description = "Updates an existing task assignment. Only users with ADMIN or ORG role can perform this action.")

src/main/java/com/itasocialacademy/oitassist/taskassignment/dao/repository/TaskAssignmentRepository.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44
import org.springframework.data.domain.Page;
55
import org.springframework.data.domain.Pageable;
66
import org.springframework.data.jpa.repository.JpaRepository;
7+
import org.springframework.data.jpa.repository.Query;
78
import org.springframework.stereotype.Repository;
9+
import java.util.List;
810

911
@Repository
1012
public interface TaskAssignmentRepository extends JpaRepository<TaskAssignment, Long> {
@@ -13,4 +15,7 @@ public interface TaskAssignmentRepository extends JpaRepository<TaskAssignment,
1315
boolean existsByTaskBodyIdAndTourId(Long taskBodyId, Long tourId);
1416

1517
boolean existsByTaskBodyId(Long taskBodyId);
18+
19+
@Query("SELECT t.tourId FROM TaskAssignment t WHERE t.taskBodyId = :taskBodyId")
20+
List<Long> findTourIdsByTaskBodyId(Long taskBodyId);
1621
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package com.itasocialacademy.oitassist.taskassignment.dto.response;
2+
3+
import com.itasocialacademy.oitassist.competition.dao.enums.ExecutionStatus;
4+
import io.swagger.v3.oas.annotations.media.Schema;
5+
import lombok.Builder;
6+
7+
@Builder
8+
@Schema(description = "DTO representing linked tour details of some given task")
9+
public record LinkedToursResponseDTO(
10+
@Schema(
11+
description = "Id of the tour the task is linked to",
12+
example = "1") Long tourId,
13+
14+
@Schema(
15+
description = "Title of the tour",
16+
example = "Тур 1: Алгоритми та структури даних") String title,
17+
18+
@Schema(
19+
description = "Description of the tour",
20+
example = "Розв'язати алгоритмічні задачі") String description,
21+
22+
@Schema(
23+
description = "Physical or virtual location where the tour takes place",
24+
example = "Ліцей №1, Львів") String location,
25+
26+
@Schema(
27+
description = "The current state of execution ",
28+
example = "IN_PROGRESS") ExecutionStatus executionStatus) {
29+
}

src/main/java/com/itasocialacademy/oitassist/taskassignment/mapper/TaskAssignmentMapper.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
package com.itasocialacademy.oitassist.taskassignment.mapper;
22

3+
import com.itasocialacademy.oitassist.competition.api.dto.TourDetail;
34
import com.itasocialacademy.oitassist.filemanager.api.dto.FileDetailsDTO;
45
import com.itasocialacademy.oitassist.taskassignment.api.dto.TaskAssignmentDetailDTO;
56
import com.itasocialacademy.oitassist.taskassignment.dao.model.TaskAssignment;
67
import com.itasocialacademy.oitassist.taskassignment.dao.model.TaskRequirements;
78
import com.itasocialacademy.oitassist.taskassignment.dto.request.CreateTaskAssignmentRequestDTO;
89
import com.itasocialacademy.oitassist.taskassignment.dto.request.TaskRequirementsRequestDTO;
910
import com.itasocialacademy.oitassist.taskassignment.dto.response.DetailedTaskAssignmentResponseDTO;
11+
import com.itasocialacademy.oitassist.taskassignment.dto.response.LinkedToursResponseDTO;
1012
import com.itasocialacademy.oitassist.taskassignment.dto.response.TaskAssignmentResponseDTO;
1113
import org.mapstruct.Mapper;
14+
import org.mapstruct.Mapping;
1215
import org.mapstruct.ReportingPolicy;
1316
import java.util.List;
1417

@@ -24,4 +27,7 @@ public interface TaskAssignmentMapper {
2427

2528
DetailedTaskAssignmentResponseDTO toDetailedResponse(TaskAssignment entity, String taskTitle,
2629
String taskDescription, List<FileDetailsDTO> files);
30+
31+
@Mapping(target = "tourId", source = "id")
32+
LinkedToursResponseDTO toLinkedToursResponse(TourDetail tourDetail);
2733
}

src/main/java/com/itasocialacademy/oitassist/taskassignment/service/AssignmentServiceImpl.java

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
import com.itasocialacademy.oitassist.competition.dao.enums.ExecutionStatus;
66
import com.itasocialacademy.oitassist.competition.exceptions.CompetitionHierarchyValidationException;
77
import com.itasocialacademy.oitassist.competition.exceptions.TourNotFoundException;
8+
import com.itasocialacademy.oitassist.core.enums.ErrorCode;
9+
import com.itasocialacademy.oitassist.core.exceptions.AuthorizationException;
810
import com.itasocialacademy.oitassist.filemanager.api.FileManagerFacade;
911
import com.itasocialacademy.oitassist.filemanager.api.dto.FileDetailsDTO;
1012
import com.itasocialacademy.oitassist.filemanager.dao.enums.FileRole;
@@ -21,6 +23,7 @@
2123
import com.itasocialacademy.oitassist.taskassignment.dto.request.CreateTaskAssignmentRequestDTO;
2224
import com.itasocialacademy.oitassist.taskassignment.dto.request.UpdateTaskAssignmentRequestDTO;
2325
import com.itasocialacademy.oitassist.taskassignment.dto.response.DetailedTaskAssignmentResponseDTO;
26+
import com.itasocialacademy.oitassist.taskassignment.dto.response.LinkedToursResponseDTO;
2427
import com.itasocialacademy.oitassist.taskassignment.dto.response.TaskAssignmentResponseDTO;
2528
import com.itasocialacademy.oitassist.taskassignment.exceptions.TaskAlreadyAssignedException;
2629
import com.itasocialacademy.oitassist.taskassignment.exceptions.TaskAssignmentNotFoundException;
@@ -212,6 +215,46 @@ public boolean existsByTaskBodyId(Long taskBodyId) {
212215
return taskAssignmentRepository.existsByTaskBodyId(taskBodyId);
213216
}
214217

218+
@Override
219+
@Transactional(readOnly = true)
220+
public List<LinkedToursResponseDTO> getLinkedToursByTaskId(Long taskBodyId) {
221+
TaskBodyDetail taskBody = taskBodyFacade.findTaskBodyById(taskBodyId).orElseThrow(
222+
() -> new TaskNotFoundException(taskBodyId));
223+
224+
checkAdminOrOrg();
225+
226+
List<Long> tourIds = taskAssignmentRepository.findTourIdsByTaskBodyId(taskBody.id());
227+
228+
log.debug("Get LinkedTours by task body Id {}", taskBodyId);
229+
230+
if (tourIds.isEmpty()) {
231+
return List.of();
232+
}
233+
234+
List<TourDetail> tours = competitionFacade.findToursByIds(tourIds);
235+
236+
List<Long> returnedIds = tours.stream()
237+
.map(TourDetail::id)
238+
.toList();
239+
240+
List<Long> missingIds = tourIds.stream()
241+
.filter(id -> !returnedIds.contains(id))
242+
.toList();
243+
244+
if (!missingIds.isEmpty()) {
245+
log.warn(
246+
"Missing linked tours for task body Id {}: requested {} tour(s) but only found {}. "
247+
+ "Missing tour IDs: {}",
248+
taskBodyId,
249+
tourIds.size(),
250+
tours.size(),
251+
missingIds);
252+
}
253+
254+
return tours.stream().map(taskAssignmentMapper::toLinkedToursResponse).toList();
255+
}
256+
257+
// helpers
215258
private List<FileDetailsDTO> resolveTaskFiles(Long taskBodyId) {
216259
Set<FileRole> allowedFileRoles;
217260

@@ -229,4 +272,10 @@ private void validateTourStatus(TourDetail tour, String msg) {
229272
throw new CompetitionHierarchyValidationException(msg + " Tour has already started");
230273
}
231274
}
275+
276+
private void checkAdminOrOrg() {
277+
if (!securityFacade.hasRole("ADMIN") && !securityFacade.hasRole("ORG")) {
278+
throw new AuthorizationException("You do not have permission to this operation", ErrorCode.ACCESS_DENIED);
279+
}
280+
}
232281
}

src/main/java/com/itasocialacademy/oitassist/taskassignment/service/interfaces/AssignmentService.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,20 @@
11
package com.itasocialacademy.oitassist.taskassignment.service.interfaces;
22

33
import com.itasocialacademy.oitassist.competition.exceptions.TourNotFoundException;
4+
import com.itasocialacademy.oitassist.task.exceptions.TaskNotFoundException;
45
import com.itasocialacademy.oitassist.taskassignment.api.dto.TaskAssignmentDetailDTO;
56
import com.itasocialacademy.oitassist.taskassignment.dao.enums.AssignmentVisibility;
67
import com.itasocialacademy.oitassist.taskassignment.dto.request.CreateAndAssignTaskRequestDTO;
78
import com.itasocialacademy.oitassist.taskassignment.dto.request.CreateTaskAssignmentRequestDTO;
89
import com.itasocialacademy.oitassist.taskassignment.dto.request.UpdateTaskAssignmentRequestDTO;
910
import com.itasocialacademy.oitassist.taskassignment.dto.response.DetailedTaskAssignmentResponseDTO;
11+
import com.itasocialacademy.oitassist.taskassignment.dto.response.LinkedToursResponseDTO;
1012
import com.itasocialacademy.oitassist.taskassignment.dto.response.TaskAssignmentResponseDTO;
1113
import com.itasocialacademy.oitassist.taskassignment.exceptions.TaskAlreadyAssignedException;
1214
import com.itasocialacademy.oitassist.taskassignment.exceptions.TaskAssignmentNotFoundException;
1315
import org.springframework.data.domain.Page;
1416
import org.springframework.data.domain.Pageable;
17+
import java.util.List;
1518
import java.util.Optional;
1619

1720
public interface AssignmentService {
@@ -110,4 +113,15 @@ DetailedTaskAssignmentResponseDTO updateTaskAssignment(Long taskAssignmentId,
110113
* body id, {@code false} otherwise
111114
*/
112115
boolean existsByTaskBodyId(Long taskBodyId);
116+
117+
/**
118+
* Retrieves all tours linked to the specified task body.
119+
*
120+
* @param taskBodyId the ID of the task body
121+
* @return a list of {@link LinkedToursResponseDTO} containing details of the
122+
* tours linked to the task
123+
* @throws TaskNotFoundException if no task body exists with the given ID
124+
* @throws TourNotFoundException if any linked tour cannot be found
125+
*/
126+
List<LinkedToursResponseDTO> getLinkedToursByTaskId(Long taskBodyId);
113127
}

src/test/java/com/itasocialacademy/oitassist/taskassignment/controller/AssignmentControllerTest.java

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,19 @@
11
package com.itasocialacademy.oitassist.taskassignment.controller;
22

33
import com.itasocialacademy.oitassist.ControllerUnitTest;
4+
import com.itasocialacademy.oitassist.competition.dao.enums.ExecutionStatus;
45
import com.itasocialacademy.oitassist.competition.exceptions.CompetitionHierarchyValidationException;
56
import com.itasocialacademy.oitassist.competition.exceptions.TourNotFoundException;
67
import com.itasocialacademy.oitassist.filemanager.api.dto.FileDetailsDTO;
8+
import com.itasocialacademy.oitassist.task.exceptions.TaskNotFoundException;
79
import com.itasocialacademy.oitassist.taskassignment.dao.enums.AssignmentVisibility;
810
import com.itasocialacademy.oitassist.taskassignment.dao.model.TaskRequirements;
911
import com.itasocialacademy.oitassist.taskassignment.dto.request.CreateAndAssignTaskRequestDTO;
1012
import com.itasocialacademy.oitassist.taskassignment.dto.request.CreateTaskAssignmentRequestDTO;
1113
import com.itasocialacademy.oitassist.taskassignment.dto.request.TaskRequirementsRequestDTO;
1214
import com.itasocialacademy.oitassist.taskassignment.dto.request.UpdateTaskAssignmentRequestDTO;
1315
import com.itasocialacademy.oitassist.taskassignment.dto.response.DetailedTaskAssignmentResponseDTO;
16+
import com.itasocialacademy.oitassist.taskassignment.dto.response.LinkedToursResponseDTO;
1417
import com.itasocialacademy.oitassist.taskassignment.dto.response.TaskAssignmentResponseDTO;
1518
import com.itasocialacademy.oitassist.taskassignment.exceptions.TaskAlreadyAssignedException;
1619
import com.itasocialacademy.oitassist.taskassignment.exceptions.TaskAssignmentNotFoundException;
@@ -274,6 +277,49 @@ void getByTour_tourNotFound_shouldReturn404() throws Exception {
274277
.andExpect(status().isNotFound());
275278
}
276279

280+
// GET /api/v1/tasks/{taskId}/linked-tours — getLinkedTours
281+
282+
@Test
283+
void getLinkedTours_shouldReturnListAnd200() throws Exception {
284+
LinkedToursResponseDTO mockTourResponse = LinkedToursResponseDTO.builder()
285+
.tourId(10L)
286+
.title("Tour 1")
287+
.description("Description")
288+
.location("Location")
289+
.executionStatus(ExecutionStatus.SCHEDULED)
290+
.build();
291+
292+
when(assignmentService.getLinkedToursByTaskId(3L))
293+
.thenReturn(List.of(mockTourResponse));
294+
295+
mockMvc.perform(get("/api/v1/tasks/{taskId}/linked-tours", 3L))
296+
.andExpect(status().isOk())
297+
.andExpect(jsonPath("$").isArray())
298+
.andExpect(jsonPath("$[0].tourId").value(10L))
299+
.andExpect(jsonPath("$[0].title").value("Tour 1"))
300+
.andExpect(jsonPath("$[0].description").value("Description"))
301+
.andExpect(jsonPath("$[0].location").value("Location"))
302+
.andExpect(jsonPath("$[0].executionStatus").value("SCHEDULED"));
303+
}
304+
305+
@Test
306+
void getLinkedTours_taskNotFound_shouldReturn404() throws Exception {
307+
when(assignmentService.getLinkedToursByTaskId(99L))
308+
.thenThrow(new TaskNotFoundException(99L));
309+
310+
mockMvc.perform(get("/api/v1/tasks/{taskId}/linked-tours", 99L))
311+
.andExpect(status().isNotFound());
312+
}
313+
314+
@Test
315+
void getLinkedTours_tourNotFound_shouldReturn404() throws Exception {
316+
when(assignmentService.getLinkedToursByTaskId(3L))
317+
.thenThrow(new TourNotFoundException(10L));
318+
319+
mockMvc.perform(get("/api/v1/tasks/{taskId}/linked-tours", 3L))
320+
.andExpect(status().isNotFound());
321+
}
322+
277323
// PATCH /api/v1/task-assignments/{assignmentId} — update
278324

279325
@Test

src/test/java/com/itasocialacademy/oitassist/taskassignment/service/AssignmentServiceTest.java

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import java.util.Set;
44
import com.itasocialacademy.oitassist.competition.dao.enums.ExecutionStatus;
55
import com.itasocialacademy.oitassist.filemanager.dao.enums.FileRole;
6+
import com.itasocialacademy.oitassist.taskassignment.dto.response.LinkedToursResponseDTO;
67
import org.junit.jupiter.api.BeforeEach;
78
import org.junit.jupiter.api.Test;
89
import org.junit.jupiter.api.extension.ExtendWith;
@@ -564,4 +565,52 @@ void existsByTaskBodyId_whenNotExists_shouldReturnFalse() {
564565

565566
assertFalse(assignmentService.existsByTaskBodyId(99L));
566567
}
568+
569+
// ---- getLinkedToursByTaskId ----
570+
571+
@Test
572+
void getLinkedToursByTaskId_whenTaskExistsAndUserAuthorized_shouldReturnLinkedTours() {
573+
LinkedToursResponseDTO mockTourResponse = LinkedToursResponseDTO.builder()
574+
.tourId(10L)
575+
.title("Tour 1")
576+
.description("Description")
577+
.location("Location")
578+
.executionStatus(ExecutionStatus.SCHEDULED)
579+
.build();
580+
581+
when(taskBodyFacade.findTaskBodyById(3L)).thenReturn(Optional.of(taskBodyDetail));
582+
when(securityFacade.hasRole("ADMIN")).thenReturn(true);
583+
when(taskAssignmentRepository.findTourIdsByTaskBodyId(3L)).thenReturn(List.of(10L));
584+
when(competitionFacade.findToursByIds(List.of(10L))).thenReturn(List.of(tourDetail));
585+
when(taskAssignmentMapper.toLinkedToursResponse(tourDetail)).thenReturn(mockTourResponse);
586+
587+
List<LinkedToursResponseDTO> result = assignmentService.getLinkedToursByTaskId(3L);
588+
589+
assertNotNull(result);
590+
assertEquals(1, result.size());
591+
assertEquals(10L, result.getFirst().tourId());
592+
assertEquals("Tour 1", result.getFirst().title());
593+
assertEquals("Description", result.getFirst().description());
594+
assertEquals("Location", result.getFirst().location());
595+
assertEquals(ExecutionStatus.SCHEDULED, result.getFirst().executionStatus());
596+
}
597+
598+
@Test
599+
void getLinkedToursByTaskId_whenTaskDoesNotExist_shouldThrowTaskNotFoundException() {
600+
when(taskBodyFacade.findTaskBodyById(99L)).thenReturn(Optional.empty());
601+
602+
assertThrows(TaskNotFoundException.class, () -> assignmentService.getLinkedToursByTaskId(99L));
603+
verify(taskAssignmentRepository, never()).findTourIdsByTaskBodyId(any());
604+
}
605+
606+
@Test
607+
void getLinkedToursByTaskId_whenUserNotAuthorized_shouldThrowAuthorizationException() {
608+
when(taskBodyFacade.findTaskBodyById(3L)).thenReturn(Optional.of(taskBodyDetail));
609+
when(securityFacade.hasRole("ADMIN")).thenReturn(false);
610+
when(securityFacade.hasRole("ORG")).thenReturn(false);
611+
612+
assertThrows(com.itasocialacademy.oitassist.core.exceptions.AuthorizationException.class,
613+
() -> assignmentService.getLinkedToursByTaskId(3L));
614+
verify(taskAssignmentRepository, never()).findTourIdsByTaskBodyId(any());
615+
}
567616
}

0 commit comments

Comments
 (0)