Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -143,13 +143,17 @@ public ResponseEntity<CompetitionTreeResponse> getCompetitionTree(@PathVariable
@ApiResponse(responseCode = "403", description = "Access denied (requires ADMIN or ORG role)",
content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))),
@ApiResponse(responseCode = "404", description = "Competition not found",
content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))),
@ApiResponse(responseCode = "409",
description = "Conflict — the entity was modified by another request since it was last read "
+ "(stale version)",
content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class)))
})
@PatchMapping("/{competitionId}/status")
@PreAuthorize("hasAnyRole('ADMIN', 'ORG')")
public ResponseEntity<CompetitionResponse> changeStatus(
@PathVariable Long competitionId,
@Valid @RequestBody ChangeCompetitionStatusRequest request) {
return ResponseEntity.ok(competitionService.changeStatus(competitionId, request.status()));
return ResponseEntity.ok(competitionService.changeStatus(competitionId, request));
Comment thread
antonpampukha marked this conversation as resolved.
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,10 @@ public ResponseEntity<List<StageResponse>> getAllStages(@PathVariable Long compe
@ApiResponse(responseCode = "403", description = "Access denied",
content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))),
@ApiResponse(responseCode = "404", description = "Stage not found",
content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))),
@ApiResponse(responseCode = "409",
description = "Conflict — the entity was modified by another request since it was last read "
+ "(stale version)",
content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class)))
})
@PutMapping("/competitions/{competitionId}/stages/{stageId}")
Expand All @@ -105,6 +109,10 @@ public ResponseEntity<StageResponse> updateStage(
@ApiResponse(responseCode = "403", description = "Access denied",
content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))),
@ApiResponse(responseCode = "404", description = "Stage not found",
content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))),
@ApiResponse(responseCode = "409",
description = "Conflict — the entity was modified by another request since it was last read "
+ "(stale version)",
content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class)))
})
@PreAuthorize("hasAnyRole('ADMIN', 'ORG')")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,11 @@ public ResponseEntity<List<TourResponse>> getAllTours(@PathVariable Long stageId
@ApiResponse(responseCode = "403", description = "Access denied",
content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
@ApiResponse(responseCode = "404", description = "Tour not found",
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
@ApiResponse(responseCode = "409",
description = "Conflict — the entity was modified by another request since it was last read "
+ "(stale version)",
content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class)))
})
@PutMapping("/stages/{stageId}/tours/{tourId}")
@PreAuthorize("hasAnyRole('ADMIN', 'ORG')")
Expand All @@ -98,7 +102,11 @@ public ResponseEntity<TourResponse> updateTour(
@ApiResponse(responseCode = "403", description = "Access denied",
content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))),
@ApiResponse(responseCode = "404", description = "Tour not found",
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
@ApiResponse(responseCode = "409",
description = "Conflict — the entity was modified by another request since it was last read "
+ "(stale version)",
content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class)))
})
@PatchMapping("/stages/{stageId}/tours/{tourId}/status")
@PreAuthorize("hasAnyRole('ADMIN', 'ORG')")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.MappedSuperclass;
import jakarta.persistence.Version;
import java.time.Instant;
import java.time.ZonedDateTime;
import lombok.AllArgsConstructor;
Expand Down Expand Up @@ -58,4 +59,8 @@ public abstract class CompetitionEvent {
@LastModifiedDate
@Column(name = "updated_at")
private Instant updatedAt;

@Version
@Column(name = "version", nullable = false)
private Long version;
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.itasocialacademy.oitassist.competition.dao.enums.CompetitionStatus;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema.RequiredMode;
import jakarta.validation.constraints.NotNull;

@Schema(description = "DTO for changing the lifecycle status of a Competition")
Expand All @@ -10,5 +11,8 @@ public record ChangeCompetitionStatusRequest(
description = "The target status to transition the competition into (e.g., PUBLISHED, FINISHED, ARCHIVED)",
example = "PUBLISHED",
requiredMode = Schema.RequiredMode.REQUIRED) @NotNull(
message = "New status cannot be null") CompetitionStatus status) {
message = "New status cannot be null") CompetitionStatus status,

@Schema(description = "Optimistic locking version; must be echoed back on updates",
requiredMode = RequiredMode.REQUIRED) @NotNull Long version) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,15 @@

import com.itasocialacademy.oitassist.competition.dao.enums.StageStatus;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema.RequiredMode;
import jakarta.validation.constraints.NotNull;

@Schema(description = "DTO for changing the lifecycle status of a Stage")
public record ChangeStageStatusRequest(
@Schema(description = "The new status of the stage", example = "IN_PROGRESS",
requiredMode = Schema.RequiredMode.REQUIRED) @NotNull(
message = "Stage status must not be null") StageStatus status) {
message = "Stage status must not be null") StageStatus status,

@Schema(description = "Optimistic locking version; must be echoed back on updates",
requiredMode = RequiredMode.REQUIRED) @NotNull Long version) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,15 @@

import com.itasocialacademy.oitassist.competition.dao.enums.ExecutionStatus;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema.RequiredMode;
import jakarta.validation.constraints.NotNull;

@Schema(description = "DTO for changing the lifecycle status of a Tour")
public record ChangeTourStatusRequest(
@Schema(description = "The new execution status of the tour", example = "IN_PROGRESS",
requiredMode = Schema.RequiredMode.REQUIRED) @NotNull(
message = "Execution status must not be null") ExecutionStatus status) {
message = "Execution status must not be null") ExecutionStatus status,

@Schema(description = "Optimistic locking version; must be echoed back on updates",
requiredMode = RequiredMode.REQUIRED) @NotNull Long version) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import com.itasocialacademy.oitassist.competition.dto.validation.ValidDateRange;
import com.itasocialacademy.oitassist.competition.dao.enums.StageScope;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema.RequiredMode;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
Expand All @@ -26,6 +27,9 @@ public record UpdateStageRequest(
@Schema(description = "Scope of the stage", example = "NATIONAL",
requiredMode = Schema.RequiredMode.REQUIRED) @NotNull StageScope scope,

@Schema(description = "Order of the stage in the hierarchy", example = "2") @Min(1) Short sortPosition)
@Schema(description = "Order of the stage in the hierarchy", example = "2") @Min(1) Short sortPosition,

@Schema(description = "Optimistic locking version; must be echoed back on updates",
requiredMode = RequiredMode.REQUIRED) @NotNull Long version)
implements HasDateRange {
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import com.itasocialacademy.oitassist.competition.dto.validation.HasDateRange;
import com.itasocialacademy.oitassist.competition.dto.validation.ValidDateRange;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema.RequiredMode;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
Expand All @@ -25,6 +26,10 @@ public record UpdateTourRequest(
@Schema(description = "Location for the tour", example = "Konotop city",
requiredMode = Schema.RequiredMode.REQUIRED) @NotBlank String location,

@Schema(description = "Order of the tour in the hierarchy", example = "1") @Min(1) Short sortPosition)
@Schema(description = "Order of the tour in the hierarchy", example = "1") @Min(1) Short sortPosition,

@Schema(description = "Optimistic locking version; must be echoed back on updates",
requiredMode = RequiredMode.REQUIRED) @NotNull Long version)

implements HasDateRange {
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,6 @@ public record CompetitionResponse(
@Schema(description = "End date", example = "2026-12-25T18:00:00Z") ZonedDateTime dateFinish,
@Schema(description = "Current lifecycle status", example = "PUBLISHED") CompetitionStatus competitionStatus,
@Schema(description = "ID of the user who created it", example = "5") Long createdBy,
@Schema(description = "ID of the user who last updated it", example = "5") Long updatedBy) {
@Schema(description = "ID of the user who last updated it", example = "5") Long updatedBy,
@Schema(description = "Optimistic locking version; must be echoed back on updates") Long version) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@

import com.itasocialacademy.oitassist.competition.dao.enums.StageScope;
import com.itasocialacademy.oitassist.competition.dao.enums.StageStatus;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.ZonedDateTime;
import lombok.Builder;

@Schema(description = "DTO representing a Stage entity response")
@Builder
public record StageResponse(
Long id,
Expand All @@ -15,5 +17,6 @@ public record StageResponse(
ZonedDateTime dateFinish,
Short sortPosition,
StageScope scope,
StageStatus status) {
StageStatus status,
Long version) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,6 @@ public record TourResponse(
ZonedDateTime dateFinish,
Short sortPosition,
String location,
ExecutionStatus executionStatus) {
ExecutionStatus executionStatus,
Long version) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.itasocialacademy.oitassist.competition.exceptions;

import com.itasocialacademy.oitassist.core.enums.ErrorCode;
import com.itasocialacademy.oitassist.core.exceptions.BusinessException;

/**
* Thrown when a client-submitted {@code version} does not match the entity's
* current version in the database — i.e. the entity was modified by someone
* else since the client last read it.
*/
public class StaleEntityVersionException extends BusinessException {
public static final String ERROR_MESSAGE =
"Object of class [%s] with identifier [%s] was updated by another request.";

public StaleEntityVersionException(Class<?> entityClass, Object identifier) {
super(ERROR_MESSAGE
.formatted(entityClass.getSimpleName(), identifier), ErrorCode.ENTITY_VERSION_CONFLICT);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import com.itasocialacademy.oitassist.competition.dao.repository.TourRepository;
import com.itasocialacademy.oitassist.competition.dao.specification.CompetitionSpecification;
import com.itasocialacademy.oitassist.competition.dto.filter.CompetitionSearchFilter;
import com.itasocialacademy.oitassist.competition.dto.request.ChangeCompetitionStatusRequest;
import com.itasocialacademy.oitassist.competition.dto.request.CreateCompetitionRequest;
import com.itasocialacademy.oitassist.competition.dto.response.CompetitionResponse;
import com.itasocialacademy.oitassist.competition.dto.response.CompetitionTreeResponse;
Expand Down Expand Up @@ -94,21 +95,23 @@ public Page<CompetitionResponse> getArchived(CompetitionSearchFilter filter, Pag

@Override
@Transactional
public CompetitionResponse changeStatus(Long competitionId, CompetitionStatus status) {
public CompetitionResponse changeStatus(Long competitionId, ChangeCompetitionStatusRequest request) {
Competition competition = competitionRepository.findById(competitionId)
.orElseThrow(() -> new CompetitionNotFoundException(competitionId));

validator.validateEntityVersion(request.version(), competition.getVersion(), Competition.class, competitionId);
CompetitionStatus currentStatus = competition.getCompetitionStatus();

validator.validateCompetitionStatusTransition(currentStatus, status);
validator.validateCompetitionStatusTransition(currentStatus, request.status());

if (status == CompetitionStatus.ENROLLMENT || status == CompetitionStatus.PUBLISHED) {
if (request.status() == CompetitionStatus.ENROLLMENT
|| request.status() == CompetitionStatus.PUBLISHED) {
validatePublishingRequirements(competitionId);
} else if (status == CompetitionStatus.FINISHED) {
} else if (request.status() == CompetitionStatus.FINISHED) {
validator.validateAllStagesCompletedForCompetition(competitionId);
}

competition.setCompetitionStatus(status);
competition.setCompetitionStatus(request.status());
return mapper.toResponse(competitionRepository.save(competition));
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ public StageResponse update(Long compId, Long stageId, UpdateStageRequest reques
Stage stage = stageRepository.findById(stageId)
.orElseThrow(() -> new StageNotFoundException(stageId));

validator.validateEntityVersion(request.version(), stage.getVersion(), Stage.class, stageId);

validator.validateStageEligibility(compId, stage.getCompetitionId());
validator.validateImmutabilityByCompetitionId(stage.getCompetitionId());
validator.validateStageDates(stage.getCompetitionId(), request.dateStart(), request.dateFinish());
Expand Down Expand Up @@ -125,6 +127,8 @@ public StageResponse changeStatus(Long compId, Long stageId, ChangeStageStatusRe
Stage stage = stageRepository.findById(stageId)
.orElseThrow(() -> new StageNotFoundException(stageId));

validator.validateEntityVersion(request.version(), stage.getVersion(), Stage.class, stageId);

validator.validateStageEligibility(compId, stage.getCompetitionId());
validator.checkIfCompetitionPublishedByCompetitionId(compId);
validator.validateStageStatusTransition(stage.getStatus(), request.status());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ public TourResponse update(Long pathStageId, Long tourId, UpdateTourRequest requ
Tour tour = tourRepository.findById(tourId)
.orElseThrow(() -> new TourNotFoundException(tourId));

validator.validateEntityVersion(request.version(), tour.getVersion(), Tour.class, tourId);

validator.validateTourEligibility(pathStageId, tour.getStageId());
validator.validateImmutabilityByStageId(tour.getStageId());
validator.validateTourDates(tour.getStageId(), request.dateStart(), request.dateFinish());
Expand Down Expand Up @@ -114,6 +116,8 @@ public TourResponse changeStatus(Long stageId, Long tourId, ChangeTourStatusRequ
Tour tour = tourRepository.findById(tourId)
.orElseThrow(() -> new TourNotFoundException(tourId));

validator.validateEntityVersion(request.version(), tour.getVersion(), Tour.class, tourId);

validator.validateTourEligibility(stageId, tour.getStageId());
validator.checkIfCompetitionPublishedByStageId(stageId);
validator.checkIfStageInProgress(stageId, request.status());
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package com.itasocialacademy.oitassist.competition.service.interfaces;

import com.itasocialacademy.oitassist.competition.dao.enums.CompetitionStatus;
import com.itasocialacademy.oitassist.competition.dto.filter.CompetitionSearchFilter;
import com.itasocialacademy.oitassist.competition.dto.request.ChangeCompetitionStatusRequest;
import com.itasocialacademy.oitassist.competition.dto.request.CreateCompetitionRequest;
import com.itasocialacademy.oitassist.competition.dto.response.CompetitionResponse;
import com.itasocialacademy.oitassist.competition.dto.response.CompetitionTreeResponse;
Expand Down Expand Up @@ -32,14 +32,17 @@ public interface CompetitionService {
CompetitionResponse getVisibleById(Long competitionId);

/**
* Transitions the competition to a new status. Publishing requires at least one
* Stage and one Tour.
* Transitions the competition to a new status. Publishing (ENROLLMENT or
* PUBLISHED) requires at least one Stage and one Tour; finishing (FINISHED)
* additionally requires every Stage to be completed. The request's
* {@code version} must match the competition's current version, or the
* transition is rejected as a stale-version conflict.
*
* @param competitionId Competition ID
* @param status a status of a Competition
* @param request the target status and the expected current version
* @return {@link CompetitionResponse}
*/
CompetitionResponse changeStatus(Long competitionId, CompetitionStatus status);
CompetitionResponse changeStatus(Long competitionId, ChangeCompetitionStatusRequest request);

/**
* Retrieves a paginated list of competitions visible to the current user,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,28 @@ public interface StageService {
*/
StageResponse update(Long compId, Long stageId, UpdateStageRequest request);

/**
* Changes a Stage's status manually.
* <p>
* Business Rules Validated:
* </p>
* <ul>
* <li>The stage must actually belong to the competition specified in the URL
* path.</li>
* <li>The parent competition must be PUBLISHED.</li>
* <li>The requested transition must be valid for the stage's current
* status.</li>
* <li>Starting a stage requires the previous stage (by sort position) to be
* FINISHED; finishing a stage requires all its tours to be completed.</li>
* <li>The request's {@code version} must match the stage's current version, or
* the change is rejected as a stale-version conflict.</li>
* </ul>
*
* @param compId the competition ID from the request path
* @param stageId the ID of the stage whose status is being changed
* @param request the DTO containing the target status and the expected version
* @return the updated stage response DTO
*/
StageResponse changeStatus(Long compId, Long stageId, ChangeStageStatusRequest request);

/**
Expand Down
Loading
Loading