Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,6 @@ tasks {
xml.required.set(true) // Enable XML report for SonarQube
html.required.set(true) // Keep HTML for local viewing
csv.required.set(false) // Disable CSV to reduce clutter
html.outputLocation.set(layout.buildDirectory.dir("reports/jacoco/integration"))
}
}

Expand Down Expand Up @@ -133,6 +132,7 @@ tasks {
}

jacocoTestCoverageVerification {
dependsOn("jacocoTestReport", "jacocoIntegrationReport")
executionData(
fileTree(layout.buildDirectory.asFile.get()).include(
"jacoco/test.exec",
Expand Down Expand Up @@ -270,4 +270,3 @@ tasks.register("pmdAll") {
description = "Runs all PMD checks (main, test, testInt)."
dependsOn("pmdMain", "pmdTest", "pmdTestInt")
}

Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import com.fasterxml.jackson.databind.JsonMappingException;
import com.fasterxml.jackson.databind.JsonMappingException.Reference;
import com.fasterxml.jackson.databind.exc.InvalidFormatException;
import com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException;
import com.wcc.platform.domain.exceptions.ApplicationMenteeWorkflowException;
import com.wcc.platform.domain.exceptions.ApplicationNotFoundException;
Expand Down Expand Up @@ -32,6 +33,7 @@
import java.util.NoSuchElementException;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import java.time.Month;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpStatus;
Expand Down Expand Up @@ -217,6 +219,13 @@ public ResponseEntity<ErrorDetails> handleForbiddenException(
private String extractReadableMessage(final HttpMessageNotReadableException ex) {
final var cause = ex.getMostSpecificCause();

if (cause instanceof InvalidFormatException invalidFormat
&& Month.class.equals(invalidFormat.getTargetType())
&& invalidFormat.getPath().stream()
.anyMatch(reference -> "cycleMonth".equals(reference.getFieldName()))) {
return "Cycle month must be between February and November";
}

if (cause instanceof UnrecognizedPropertyException unrecognizedProperty) {
final var allowedFields =
unrecognizedProperty.getKnownPropertyIds().stream()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@

import com.wcc.platform.configuration.security.RequiresPermission;
import com.wcc.platform.configuration.security.RequiresRole;
import com.wcc.platform.domain.exceptions.CycleNotFoundException;
import com.wcc.platform.domain.auth.Permission;
import com.wcc.platform.domain.exceptions.CycleNotFoundException;
import com.wcc.platform.domain.platform.mentorship.ApplicationStatus;
import com.wcc.platform.domain.platform.mentorship.CycleStatus;
import com.wcc.platform.domain.platform.mentorship.MatchCancelRequest;
import com.wcc.platform.domain.platform.mentorship.MenteeApplicationAdminResponse;
import com.wcc.platform.domain.platform.mentorship.MentorshipCycleCreateRequest;
import com.wcc.platform.domain.platform.mentorship.MentorshipCycleEntity;
import com.wcc.platform.domain.platform.mentorship.MentorshipMatch;
import com.wcc.platform.domain.platform.mentorship.recommendation.MentorshipRecommendationResponse;
Expand Down Expand Up @@ -198,6 +199,24 @@ public ResponseEntity<MentorshipMatch> incrementSessionCount(

// ==================== Cycle Management ====================

/**
* API to create a mentorship cycle in draft status.
*
* @param request The cycle creation request
* @return Created mentorship cycle
*/
@PostMapping("/cycles")
@RequiresRole({RoleType.ADMIN, RoleType.MENTORSHIP_ADMIN})
@Operation(
summary = "Create a mentorship cycle",
security = {@SecurityRequirement(name = BEARER_AUTH)})
@ResponseStatus(HttpStatus.CREATED)
public ResponseEntity<MentorshipCycleEntity> createCycle(
@Valid @RequestBody final MentorshipCycleCreateRequest request) {
final MentorshipCycleEntity createdCycle = cycleService.createCycle(request);
return ResponseEntity.status(HttpStatus.CREATED).body(createdCycle);
}

/**
* API to get the currently open mentorship cycle.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.wcc.platform.domain.platform.mentorship;

import com.wcc.platform.validation.ValidMentorshipCycleMonth;
import jakarta.validation.constraints.FutureOrPresent;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import java.time.LocalDate;
import java.time.Month;
import java.time.Year;

/** Request payload for creating a mentorship cycle. */
public record MentorshipCycleCreateRequest(
Comment thread
MichaelaMatiskova marked this conversation as resolved.
Resolved
@NotNull @FutureOrPresent Year cycleYear,
@NotNull @ValidMentorshipCycleMonth Month cycleMonth,
@NotNull MentorshipType mentorshipType,
@NotNull @FutureOrPresent LocalDate registrationStartDate,
@NotNull @FutureOrPresent LocalDate registrationEndDate,
@NotNull @FutureOrPresent LocalDate cycleStartDate,
@FutureOrPresent LocalDate cycleEndDate,
@Min(1) Integer maxMenteesPerMentor,
Comment thread
MichaelaMatiskova marked this conversation as resolved.
Resolved
String description) {}
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package com.wcc.platform.domain.platform.mentorship;

import com.wcc.platform.domain.cms.pages.mentorship.MentorsPage;
import com.wcc.platform.validation.ValidMentorshipCycleMonth;
import jakarta.validation.constraints.FutureOrPresent;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import java.time.LocalDate;
import java.time.Month;
import java.time.Year;
Expand All @@ -16,14 +20,22 @@
@Builder
public class MentorshipCycleEntity {
private Long cycleId;
@NotNull @FutureOrPresent
private Year cycleYear;
@NotNull
private MentorshipType mentorshipType;
@NotNull @ValidMentorshipCycleMonth
private Month cycleMonth;
@NotNull @FutureOrPresent
private LocalDate registrationStartDate;
@NotNull @FutureOrPresent
private LocalDate registrationEndDate;
@NotNull @FutureOrPresent
private LocalDate cycleStartDate;
@FutureOrPresent
private LocalDate cycleEndDate;
private CycleStatus status;
@Min(1)
private Integer maxMenteesPerMentor;
private String description;
private ZonedDateTime createdAt;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import com.wcc.platform.domain.platform.mentorship.CycleStatus;
import com.wcc.platform.domain.platform.mentorship.MentorshipCycleEntity;
import com.wcc.platform.domain.platform.mentorship.MentorshipType;
import java.time.Month;
import java.time.Year;
import java.util.List;
import java.util.Optional;
Expand Down Expand Up @@ -67,4 +68,7 @@ public interface MentorshipCycleRepository extends CrudRepository<MentorshipCycl
* @return the updated cycle entity
*/
MentorshipCycleEntity updateStatus(Long cycleId, CycleStatus status);

Optional<MentorshipCycleEntity> findByYearAndTypeAndMonth(
Year year, MentorshipType type, Month month);
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.wcc.platform.repository.postgres.mentorship;

import com.wcc.platform.domain.exceptions.DuplicatedException;
import com.wcc.platform.domain.platform.mentorship.CycleStatus;
import com.wcc.platform.domain.platform.mentorship.MentorshipCycleEntity;
import com.wcc.platform.domain.platform.mentorship.MentorshipType;
Expand Down Expand Up @@ -66,13 +67,25 @@ public class PostgresMentorshipCycleRepository implements MentorshipCycleReposit
+ "description = ?, updated_at = CURRENT_TIMESTAMP "
+ "WHERE cycle_id = ?";

private static final String SELECT_CYCLE_BY_KEY =
"SELECT * FROM mentorship_cycles "
+ "WHERE cycle_year = ? AND mentorship_type = ? AND cycle_month = ?";

private final JdbcTemplate jdbc;

@Override
public MentorshipCycleEntity create(final MentorshipCycleEntity entity) {
final var existing = findByYearAndType(entity.getCycleYear(), entity.getMentorshipType());
final var existing =
findByYearAndTypeAndMonth(
entity.getCycleYear(), entity.getMentorshipType(), entity.getCycleMonth());

if (existing.isPresent()) {
return update(existing.get().getCycleId(), entity);
throw new DuplicatedException(
"Mentorship cycle already exists for year %d, type %s and month %s"
.formatted(
entity.getCycleYear().getValue(),
entity.getMentorshipType().name(),
entity.getCycleMonth().name()));
}

final Long generatedId =
Expand Down Expand Up @@ -201,4 +214,15 @@ private MentorshipCycleEntity mapRow(final ResultSet rs) throws SQLException {
.updatedAt(rs.getTimestamp("updated_at").toInstant().atZone(ZoneId.systemDefault()))
.build();
}

@Override
public Optional<MentorshipCycleEntity> findByYearAndTypeAndMonth(
final Year year, final MentorshipType type, final Month month) {
return jdbc.query(
SELECT_CYCLE_BY_KEY,
rs -> rs.next() ? Optional.of(mapRow(rs)) : Optional.empty(),
year.getValue(),
type.getMentorshipTypeId(),
month.getValue());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import com.wcc.platform.domain.exceptions.CycleNotFoundException;
import com.wcc.platform.domain.exceptions.InvalidCycleStatusTransitionException;
import com.wcc.platform.domain.platform.mentorship.CycleStatus;
import com.wcc.platform.domain.platform.mentorship.MentorshipCycleCreateRequest;
import com.wcc.platform.domain.platform.mentorship.MentorshipCycleEntity;
import com.wcc.platform.repository.MentorshipCycleRepository;
import jakarta.validation.constraints.FutureOrPresent;
Expand Down Expand Up @@ -61,4 +62,47 @@ public MentorshipCycleEntity updateStatus(final Long cycleId, final CycleStatus

return cycleRepository.updateStatus(cycleId, newStatus);
}

/**
* Creates a new mentorship cycle in draft status.
*
* @param request cycle creation data
* @return the created mentorship cycle
* @throws IllegalArgumentException if the supplied dates are inconsistent
*/
public MentorshipCycleEntity createCycle(final MentorshipCycleCreateRequest request) {

if (request.registrationEndDate().isBefore(request.registrationStartDate())) {
throw new IllegalArgumentException(
"Registration end date must be greater than or equal to registration start date");
}

if (request.cycleStartDate().isBefore(request.registrationStartDate())) {
throw new IllegalArgumentException(
"Cycle start date must be greater than or equal to registration start date");
}

if (request.cycleEndDate() != null
&& request.cycleEndDate().isBefore(request.cycleStartDate())) {
throw new IllegalArgumentException(
"Cycle end date must be greater than or equal to cycle start date");
}

final MentorshipCycleEntity cycle =
MentorshipCycleEntity.builder()
.cycleYear(request.cycleYear())
.cycleMonth(request.cycleMonth())
.mentorshipType(request.mentorshipType())
.registrationStartDate(request.registrationStartDate())
.registrationEndDate(request.registrationEndDate())
.cycleStartDate(request.cycleStartDate())
.cycleEndDate(request.cycleEndDate())
.status(CycleStatus.DRAFT)
.maxMenteesPerMentor(
request.maxMenteesPerMentor() != null ? request.maxMenteesPerMentor() : 5)
.description(request.description())
.build();

return cycleRepository.create(cycle);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.wcc.platform.validation;

import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import java.time.Month;

/** Validates the allowed month range for mentorship cycles. */
public class MentorshipCycleMonthValidator
implements ConstraintValidator<ValidMentorshipCycleMonth, Month> {

@Override
public boolean isValid(final Month month, final ConstraintValidatorContext context) {
return month == null || (month.getValue() >= 2 && month.getValue() <= 11);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.wcc.platform.validation;

import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import java.lang.annotation.RetentionPolicy;

/**
* Validates that a mentorship cycle month is between February and November.
*/
@Documented
@Constraint(validatedBy = MentorshipCycleMonthValidator.class)
@Target({
ElementType.FIELD,
ElementType.PARAMETER,
ElementType.ANNOTATION_TYPE,
ElementType.RECORD_COMPONENT
})
@Retention(RetentionPolicy.RUNTIME)
public @interface ValidMentorshipCycleMonth {

String message() default "Cycle month must be between February and November";

Class<?>[] groups() default {};

Class<? extends Payload>[] payload() default {};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
-- Restrict new mentorship cycles to February through November.
-- Existing January and December cycles remain valid historical data.
ALTER TABLE mentorship_cycles
ADD CONSTRAINT valid_cycle_month
CHECK (cycle_month >= 2 AND cycle_month <= 11)
NOT VALID;
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import com.wcc.platform.domain.exceptions.ForbiddenException;
import com.wcc.platform.domain.platform.mentorship.TechnicalAreaProficiency;
import java.util.List;
import java.time.Month;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
Expand Down Expand Up @@ -160,4 +161,30 @@ void shouldReturnBadRequestWithFallbackMessageForInvalidEnumValue() {
assertEquals(BAD_REQUEST, response.getStatusCode());
assertEquals(expectation, response.getBody());
}

@Test
@DisplayName(
"Given malformed JSON with invalid mentorship cycle month, "
+ "when handling, then return the cycle month validation message")
void shouldReturnCycleMonthValidationMessageForInvalidMonth() {
record CycleRequest(Month cycleMonth) {}

var invalidJson = "{\"cycleMonth\":\"JANUAR\"}";
var cause =
assertThrows(
InvalidFormatException.class,
() -> new ObjectMapper().readValue(invalidJson, CycleRequest.class));
var exception =
new HttpMessageNotReadableException(
"JSON parse error", cause, new MockHttpInputMessage(invalidJson.getBytes()));

var response =
globalExceptionHandler.handleHttpMessageNotReadableException(exception, webRequest);

var expectation =
new ErrorDetails(
BAD_REQUEST.value(), "Cycle month must be between February and November", DETAILS);
assertEquals(BAD_REQUEST, response.getStatusCode());
assertEquals(expectation, response.getBody());
}
}
Loading