diff --git a/src/main/java/com/wcc/platform/configuration/GlobalExceptionHandler.java b/src/main/java/com/wcc/platform/configuration/GlobalExceptionHandler.java index 921fe215..54c8e14a 100644 --- a/src/main/java/com/wcc/platform/configuration/GlobalExceptionHandler.java +++ b/src/main/java/com/wcc/platform/configuration/GlobalExceptionHandler.java @@ -13,6 +13,7 @@ import com.wcc.platform.domain.exceptions.EmailSendException; import com.wcc.platform.domain.exceptions.ErrorDetails; import com.wcc.platform.domain.exceptions.ForbiddenException; +import com.wcc.platform.domain.exceptions.InvalidCycleStatusTransitionException; import com.wcc.platform.domain.exceptions.InvalidProgramTypeException; import com.wcc.platform.domain.exceptions.InvalidTokenException; import com.wcc.platform.domain.exceptions.MemberNotFoundException; @@ -30,12 +31,12 @@ import java.util.NoSuchElementException; import java.util.stream.Collectors; import java.util.stream.IntStream; +import lombok.extern.slf4j.Slf4j; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.web.bind.MethodArgumentNotValidException; -import lombok.extern.slf4j.Slf4j; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -125,12 +126,16 @@ public ResponseEntity handleRecordAlreadyExitsException( } /** - * Receive MentorStatusException or MentorCapacityExceededException and return {@link - * HttpStatus#CONFLICT}. + * Receive MentorStatusException, MentorCapacityExceededException or + * InvalidCycleStatusTransitionException and return {@link HttpStatus#CONFLICT}. */ - @ExceptionHandler({MentorStatusException.class, MentorCapacityExceededException.class}) + @ExceptionHandler({ + MentorStatusException.class, + MentorCapacityExceededException.class, + InvalidCycleStatusTransitionException.class + }) @ResponseStatus(HttpStatus.CONFLICT) - public ResponseEntity handleMentorStatus( + public ResponseEntity handleConflicts( final RuntimeException ex, final WebRequest request) { final var errorDetails = new ErrorDetails( diff --git a/src/main/java/com/wcc/platform/controller/MentorshipAdminMatchesController.java b/src/main/java/com/wcc/platform/controller/MentorshipAdminMatchesController.java index bffc214f..4a78579a 100644 --- a/src/main/java/com/wcc/platform/controller/MentorshipAdminMatchesController.java +++ b/src/main/java/com/wcc/platform/controller/MentorshipAdminMatchesController.java @@ -13,6 +13,7 @@ import com.wcc.platform.domain.platform.type.RoleType; import com.wcc.platform.repository.MentorshipCycleRepository; import com.wcc.platform.service.MenteeWorkflowService; +import com.wcc.platform.service.MentorshipCycleService; import com.wcc.platform.service.MentorshipMatchingService; import com.wcc.platform.service.MentorshipRecommendationService; import io.swagger.v3.oas.annotations.Operation; @@ -51,6 +52,7 @@ public class MentorshipAdminMatchesController { private final MentorshipCycleRepository cycleRepository; private final MentorshipRecommendationService recommendationService; private final MenteeWorkflowService workflowService; + private final MentorshipCycleService cycleService; // ==================== Match Management ==================== @@ -265,4 +267,24 @@ public ResponseEntity> getAllCycles() { final List cycles = cycleRepository.getAll(); return ResponseEntity.ok(cycles); } + + /** + * API to update the status of a mentorship cycle. + * + * @param cycleId the ID of the cycle to update + * @param request the status update request containing the new status + * @return the updated cycle entity + */ + @PatchMapping("/cycles/status") + @RequiresRole({RoleType.ADMIN, RoleType.MENTORSHIP_ADMIN}) + @Operation( + summary = "Update the status of a mentorship cycle", + security = {@SecurityRequirement(name = BEARER_AUTH)}) + @ResponseStatus(HttpStatus.OK) + public ResponseEntity updateCycleStatus( + @Parameter(description = "Cycle ID") @RequestParam final Long cycleId, + @Parameter(description = "New cycle status") @RequestParam final CycleStatus status) { + final MentorshipCycleEntity updated = cycleService.updateStatus(cycleId, status); + return ResponseEntity.ok(updated); + } } diff --git a/src/main/java/com/wcc/platform/domain/exceptions/InvalidCycleStatusTransitionException.java b/src/main/java/com/wcc/platform/domain/exceptions/InvalidCycleStatusTransitionException.java new file mode 100644 index 00000000..eb3a2326 --- /dev/null +++ b/src/main/java/com/wcc/platform/domain/exceptions/InvalidCycleStatusTransitionException.java @@ -0,0 +1,19 @@ +package com.wcc.platform.domain.exceptions; + +import com.wcc.platform.domain.platform.mentorship.CycleStatus; + +/** Exception thrown when an invalid mentorship cycle status transition is attempted. */ +public class InvalidCycleStatusTransitionException extends RuntimeException { + + /** + * Constructor with current and requested status. + * + * @param current the current cycle status + * @param requested the requested target status + */ + public InvalidCycleStatusTransitionException( + final CycleStatus current, final CycleStatus requested) { + super( + "Invalid status transition from '%s' to '%s'".formatted(current.name(), requested.name())); + } +} diff --git a/src/main/java/com/wcc/platform/repository/MentorshipCycleRepository.java b/src/main/java/com/wcc/platform/repository/MentorshipCycleRepository.java index 133052ce..332d0fcf 100644 --- a/src/main/java/com/wcc/platform/repository/MentorshipCycleRepository.java +++ b/src/main/java/com/wcc/platform/repository/MentorshipCycleRepository.java @@ -58,4 +58,13 @@ public interface MentorshipCycleRepository extends CrudRepository findLastCompletedCycle(); + + /** + * Update only the status of a mentorship cycle. + * + * @param cycleId the cycle ID + * @param status the new status + * @return the updated cycle entity + */ + MentorshipCycleEntity updateStatus(Long cycleId, CycleStatus status); } diff --git a/src/main/java/com/wcc/platform/repository/postgres/mentorship/PostgresMenteeRepository.java b/src/main/java/com/wcc/platform/repository/postgres/mentorship/PostgresMenteeRepository.java index c73f1cab..64445afa 100644 --- a/src/main/java/com/wcc/platform/repository/postgres/mentorship/PostgresMenteeRepository.java +++ b/src/main/java/com/wcc/platform/repository/postgres/mentorship/PostgresMenteeRepository.java @@ -83,10 +83,15 @@ public Mentee create(final Mentee mentee) { memberId = memberMapper.addMember(mentee); } - if (findById(memberId).isEmpty()) { + final var existing = findById(memberId); + if (existing.isEmpty()) { insertMenteeDetails(mentee, memberId); } else { - updateMenteeDetails(mentee, memberId); + final var profileStatus = + mentee.getProfileStatus() != null + ? mentee.getProfileStatus() + : existing.get().getProfileStatus(); + updateMenteeDetails(mentee, memberId, profileStatus); } jdbc.update(SQL_DELETE_TECH_AREAS, memberId); @@ -108,7 +113,11 @@ public Mentee update(final Long id, final Mentee mentee) { validate(mentee); memberMapper.updateMember(mentee, id); - updateMenteeDetails(mentee, id); + final var profileStatus = + mentee.getProfileStatus() != null + ? mentee.getProfileStatus() + : findById(id).map(Mentee::getProfileStatus).orElse(ProfileStatus.PENDING); + updateMenteeDetails(mentee, id, profileStatus); jdbc.update(SQL_DELETE_TECH_AREAS, id); jdbc.update(SQL_DELETE_LANGUAGES, id); @@ -186,8 +195,8 @@ public Mentee updateProfileStatus(final Long menteeId, final ProfileStatus statu () -> new MenteeNotSavedException("Mentee not found after status update: " + menteeId)); } - private void updateMenteeDetails(final Mentee mentee, final Long memberId) { - final var profileStatus = mentee.getProfileStatus(); + private void updateMenteeDetails( + final Mentee mentee, final Long memberId, final ProfileStatus profileStatus) { final var skills = mentee.getSkills(); jdbc.update( SQL_UPDATE_MENTEE, diff --git a/src/main/java/com/wcc/platform/repository/postgres/mentorship/PostgresMentorshipCycleRepository.java b/src/main/java/com/wcc/platform/repository/postgres/mentorship/PostgresMentorshipCycleRepository.java index d9c5b5dd..16413c9d 100644 --- a/src/main/java/com/wcc/platform/repository/postgres/mentorship/PostgresMentorshipCycleRepository.java +++ b/src/main/java/com/wcc/platform/repository/postgres/mentorship/PostgresMentorshipCycleRepository.java @@ -24,6 +24,9 @@ public class PostgresMentorshipCycleRepository implements MentorshipCycleRepository { private static final String DELETE_SQL = "DELETE FROM mentorship_cycles WHERE cycle_id = ?"; + private static final String UPDATE_STATUS_SQL = + "UPDATE mentorship_cycles SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE cycle_id = ?"; + private static final String SELECT_ALL = "SELECT * FROM mentorship_cycles ORDER BY cycle_year DESC, cycle_month"; @@ -167,6 +170,17 @@ public Optional findLastCompletedCycle() { SELECT_LAST_CYCLE, rs -> rs.next() ? Optional.of(mapRow(rs)) : Optional.empty()); } + @Override + public MentorshipCycleEntity updateStatus(final Long cycleId, final CycleStatus status) { + final int rowsUpdated = jdbc.update(UPDATE_STATUS_SQL, status.getStatusId(), cycleId); + if (rowsUpdated == 0) { + throw new IllegalStateException("Failed to update status for cycle ID: " + cycleId); + } + return findById(cycleId) + .orElseThrow( + () -> new IllegalStateException("Failed to retrieve cycle after status update")); + } + private MentorshipCycleEntity mapRow(final ResultSet rs) throws SQLException { return MentorshipCycleEntity.builder() .cycleId(rs.getLong("cycle_id")) diff --git a/src/main/java/com/wcc/platform/service/MenteeService.java b/src/main/java/com/wcc/platform/service/MenteeService.java index 9ce7a1c5..60a8d295 100644 --- a/src/main/java/com/wcc/platform/service/MenteeService.java +++ b/src/main/java/com/wcc/platform/service/MenteeService.java @@ -146,7 +146,9 @@ private Mentee createOrUpdateMentee(final Mentee mentee) { } private Mentee handleMenteeWithId(final Mentee mentee) { - if (menteeRepository.findById(mentee.getId()).isPresent()) { + final var existingMentee = menteeRepository.findById(mentee.getId()); + if (existingMentee.isPresent()) { + mentee.setMemberTypes(mergeMemberTypes(existingMentee.get().getMemberTypes())); return menteeRepository.update(mentee.getId(), mentee); } diff --git a/src/main/java/com/wcc/platform/service/MentorshipCycleService.java b/src/main/java/com/wcc/platform/service/MentorshipCycleService.java new file mode 100644 index 00000000..63932188 --- /dev/null +++ b/src/main/java/com/wcc/platform/service/MentorshipCycleService.java @@ -0,0 +1,54 @@ +package com.wcc.platform.service; + +import com.wcc.platform.domain.exceptions.InvalidCycleStatusTransitionException; +import com.wcc.platform.domain.platform.mentorship.CycleStatus; +import com.wcc.platform.domain.platform.mentorship.MentorshipCycleEntity; +import com.wcc.platform.repository.MentorshipCycleRepository; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +/** + * Service for mentorship cycle lifecycle management, including status transitions. + */ +@Service +@RequiredArgsConstructor +public class MentorshipCycleService { + + private static final Map> ALLOWED_TRANSITIONS = + Map.of( + CycleStatus.DRAFT, Set.of(CycleStatus.OPEN, CycleStatus.CANCELLED), + CycleStatus.OPEN, Set.of(CycleStatus.CLOSED, CycleStatus.CANCELLED), + CycleStatus.CLOSED, Set.of(CycleStatus.IN_PROGRESS, CycleStatus.CANCELLED), + CycleStatus.IN_PROGRESS, Set.of(CycleStatus.COMPLETED, CycleStatus.CANCELLED), + CycleStatus.COMPLETED, Set.of(), + CycleStatus.CANCELLED, Set.of()); + + private final MentorshipCycleRepository cycleRepository; + + /** + * Update the status of a mentorship cycle, enforcing valid state transitions. + * + * @param cycleId the ID of the cycle to update + * @param newStatus the target status + * @return the updated cycle entity + * @throws NoSuchElementException if the cycle is not found + * @throws InvalidCycleStatusTransitionException if the transition is not permitted + */ + public MentorshipCycleEntity updateStatus(final Long cycleId, final CycleStatus newStatus) { + final MentorshipCycleEntity cycle = + cycleRepository + .findById(cycleId) + .orElseThrow( + () -> new NoSuchElementException("Mentorship cycle not found with ID: " + cycleId)); + + final CycleStatus current = cycle.getStatus(); + if (!ALLOWED_TRANSITIONS.getOrDefault(current, Set.of()).contains(newStatus)) { + throw new InvalidCycleStatusTransitionException(current, newStatus); + } + + return cycleRepository.updateStatus(cycleId, newStatus); + } +} diff --git a/src/test/java/com/wcc/platform/service/MentorshipCycleServiceTest.java b/src/test/java/com/wcc/platform/service/MentorshipCycleServiceTest.java new file mode 100644 index 00000000..0f9af3ab --- /dev/null +++ b/src/test/java/com/wcc/platform/service/MentorshipCycleServiceTest.java @@ -0,0 +1,181 @@ +package com.wcc.platform.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.wcc.platform.domain.exceptions.InvalidCycleStatusTransitionException; +import com.wcc.platform.domain.platform.mentorship.CycleStatus; +import com.wcc.platform.domain.platform.mentorship.MentorshipCycleEntity; +import com.wcc.platform.repository.MentorshipCycleRepository; +import java.util.NoSuchElementException; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +class MentorshipCycleServiceTest { + + @Mock private MentorshipCycleRepository cycleRepository; + + private MentorshipCycleService cycleService; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + cycleService = new MentorshipCycleService(cycleRepository); + } + + @Test + @DisplayName("Given cycle does not exist, when updating status, then throw NoSuchElementException") + void shouldThrowWhenCycleNotFound() { + when(cycleRepository.findById(99L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> cycleService.updateStatus(99L, CycleStatus.OPEN)) + .isInstanceOf(NoSuchElementException.class) + .hasMessageContaining("99"); + + verify(cycleRepository, never()).updateStatus(99L, CycleStatus.OPEN); + } + + @Test + @DisplayName("Given cycle in DRAFT status, when transitioning to OPEN, then update succeeds") + void shouldAllowDraftToOpen() { + final var cycle = cycleWithStatus(1L, CycleStatus.DRAFT); + final var updated = cycleWithStatus(1L, CycleStatus.OPEN); + when(cycleRepository.findById(1L)).thenReturn(Optional.of(cycle)); + when(cycleRepository.updateStatus(1L, CycleStatus.OPEN)).thenReturn(updated); + + final MentorshipCycleEntity result = cycleService.updateStatus(1L, CycleStatus.OPEN); + + assertThat(result.getStatus()).isEqualTo(CycleStatus.OPEN); + verify(cycleRepository).updateStatus(1L, CycleStatus.OPEN); + } + + @Test + @DisplayName( + "Given cycle in DRAFT status, when transitioning to CANCELLED, then update succeeds") + void shouldAllowDraftToCancelled() { + final var cycle = cycleWithStatus(1L, CycleStatus.DRAFT); + final var updated = cycleWithStatus(1L, CycleStatus.CANCELLED); + when(cycleRepository.findById(1L)).thenReturn(Optional.of(cycle)); + when(cycleRepository.updateStatus(1L, CycleStatus.CANCELLED)).thenReturn(updated); + + final MentorshipCycleEntity result = cycleService.updateStatus(1L, CycleStatus.CANCELLED); + + assertThat(result.getStatus()).isEqualTo(CycleStatus.CANCELLED); + } + + @Test + @DisplayName("Given cycle in OPEN status, when transitioning to CLOSED, then update succeeds") + void shouldAllowOpenToClosed() { + final var cycle = cycleWithStatus(1L, CycleStatus.OPEN); + final var updated = cycleWithStatus(1L, CycleStatus.CLOSED); + when(cycleRepository.findById(1L)).thenReturn(Optional.of(cycle)); + when(cycleRepository.updateStatus(1L, CycleStatus.CLOSED)).thenReturn(updated); + + final MentorshipCycleEntity result = cycleService.updateStatus(1L, CycleStatus.CLOSED); + + assertThat(result.getStatus()).isEqualTo(CycleStatus.CLOSED); + } + + @Test + @DisplayName( + "Given cycle in CLOSED status, when transitioning to IN_PROGRESS, then update succeeds") + void shouldAllowClosedToInProgress() { + final var cycle = cycleWithStatus(1L, CycleStatus.CLOSED); + final var updated = cycleWithStatus(1L, CycleStatus.IN_PROGRESS); + when(cycleRepository.findById(1L)).thenReturn(Optional.of(cycle)); + when(cycleRepository.updateStatus(1L, CycleStatus.IN_PROGRESS)).thenReturn(updated); + + final MentorshipCycleEntity result = cycleService.updateStatus(1L, CycleStatus.IN_PROGRESS); + + assertThat(result.getStatus()).isEqualTo(CycleStatus.IN_PROGRESS); + } + + @Test + @DisplayName( + "Given cycle in IN_PROGRESS status, when transitioning to COMPLETED, then update succeeds") + void shouldAllowInProgressToCompleted() { + final var cycle = cycleWithStatus(1L, CycleStatus.IN_PROGRESS); + final var updated = cycleWithStatus(1L, CycleStatus.COMPLETED); + when(cycleRepository.findById(1L)).thenReturn(Optional.of(cycle)); + when(cycleRepository.updateStatus(1L, CycleStatus.COMPLETED)).thenReturn(updated); + + final MentorshipCycleEntity result = cycleService.updateStatus(1L, CycleStatus.COMPLETED); + + assertThat(result.getStatus()).isEqualTo(CycleStatus.COMPLETED); + } + + @Test + @DisplayName( + "Given cycle in DRAFT status, when transitioning to IN_PROGRESS, then throw InvalidCycleStatusTransitionException") + void shouldRejectDraftToInProgress() { + final var cycle = cycleWithStatus(1L, CycleStatus.DRAFT); + when(cycleRepository.findById(1L)).thenReturn(Optional.of(cycle)); + + assertThatThrownBy(() -> cycleService.updateStatus(1L, CycleStatus.IN_PROGRESS)) + .isInstanceOf(InvalidCycleStatusTransitionException.class) + .hasMessageContaining("DRAFT") + .hasMessageContaining("IN_PROGRESS"); + + verify(cycleRepository, never()).updateStatus(1L, CycleStatus.IN_PROGRESS); + } + + @Test + @DisplayName( + "Given cycle in OPEN status, when transitioning to DRAFT, then throw InvalidCycleStatusTransitionException") + void shouldRejectOpenToDraft() { + final var cycle = cycleWithStatus(1L, CycleStatus.OPEN); + when(cycleRepository.findById(1L)).thenReturn(Optional.of(cycle)); + + assertThatThrownBy(() -> cycleService.updateStatus(1L, CycleStatus.DRAFT)) + .isInstanceOf(InvalidCycleStatusTransitionException.class) + .hasMessageContaining("OPEN") + .hasMessageContaining("DRAFT"); + } + + @Test + @DisplayName( + "Given cycle in COMPLETED status, when transitioning to any status, then throw InvalidCycleStatusTransitionException") + void shouldRejectAnyTransitionFromCompleted() { + final var cycle = cycleWithStatus(1L, CycleStatus.COMPLETED); + when(cycleRepository.findById(1L)).thenReturn(Optional.of(cycle)); + + assertThatThrownBy(() -> cycleService.updateStatus(1L, CycleStatus.CANCELLED)) + .isInstanceOf(InvalidCycleStatusTransitionException.class) + .hasMessageContaining("COMPLETED"); + } + + @Test + @DisplayName( + "Given cycle in CANCELLED status, when transitioning to any status, then throw InvalidCycleStatusTransitionException") + void shouldRejectAnyTransitionFromCancelled() { + final var cycle = cycleWithStatus(1L, CycleStatus.CANCELLED); + when(cycleRepository.findById(1L)).thenReturn(Optional.of(cycle)); + + assertThatThrownBy(() -> cycleService.updateStatus(1L, CycleStatus.OPEN)) + .isInstanceOf(InvalidCycleStatusTransitionException.class) + .hasMessageContaining("CANCELLED"); + } + + @Test + @DisplayName( + "Given cycle in OPEN status, when transitioning to same OPEN status, then throw InvalidCycleStatusTransitionException") + void shouldRejectSameStatusTransition() { + final var cycle = cycleWithStatus(1L, CycleStatus.OPEN); + when(cycleRepository.findById(1L)).thenReturn(Optional.of(cycle)); + + assertThatThrownBy(() -> cycleService.updateStatus(1L, CycleStatus.OPEN)) + .isInstanceOf(InvalidCycleStatusTransitionException.class) + .hasMessageContaining("OPEN"); + } + + private MentorshipCycleEntity cycleWithStatus(final Long id, final CycleStatus status) { + return MentorshipCycleEntity.builder().cycleId(id).status(status).build(); + } +}