Skip to content

Commit 05919be

Browse files
[Feature] add version optimistic locking for the Competition Hierarchy (#545)
* feat: add version field to CompetitionEvent * feat(db): add migrations * feat: add method to GlobalExceptionHandler that handle OptimisticLockingFailureException * feat: add check to HierarchyValidator * feat: update DTOs * feat: add StaleEntityVersionException & ErrorCode.ENTITY_VERSION_CONFLICT * feat: add/update unit tests * fix: unit test * fix: unit test * chore: fix formatter * fix git error * fix: avoid exposing the fully qualified entity class name * feat: add OpenApi docs about 409 error * feat: add/update Javadocs in services in competition module * fix formatter
1 parent 31622fc commit 05919be

26 files changed

Lines changed: 365 additions & 41 deletions

src/main/java/com/itasocialacademy/oitassist/competition/controller/CompetitionController.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,13 +143,17 @@ public ResponseEntity<CompetitionTreeResponse> getCompetitionTree(@PathVariable
143143
@ApiResponse(responseCode = "403", description = "Access denied (requires ADMIN or ORG role)",
144144
content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))),
145145
@ApiResponse(responseCode = "404", description = "Competition not found",
146+
content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))),
147+
@ApiResponse(responseCode = "409",
148+
description = "Conflict — the entity was modified by another request since it was last read "
149+
+ "(stale version)",
146150
content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class)))
147151
})
148152
@PatchMapping("/{competitionId}/status")
149153
@PreAuthorize("hasAnyRole('ADMIN', 'ORG')")
150154
public ResponseEntity<CompetitionResponse> changeStatus(
151155
@PathVariable Long competitionId,
152156
@Valid @RequestBody ChangeCompetitionStatusRequest request) {
153-
return ResponseEntity.ok(competitionService.changeStatus(competitionId, request.status()));
157+
return ResponseEntity.ok(competitionService.changeStatus(competitionId, request));
154158
}
155159
}

src/main/java/com/itasocialacademy/oitassist/competition/controller/StageController.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,10 @@ public ResponseEntity<List<StageResponse>> getAllStages(@PathVariable Long compe
8484
@ApiResponse(responseCode = "403", description = "Access denied",
8585
content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))),
8686
@ApiResponse(responseCode = "404", description = "Stage not found",
87+
content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))),
88+
@ApiResponse(responseCode = "409",
89+
description = "Conflict — the entity was modified by another request since it was last read "
90+
+ "(stale version)",
8791
content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class)))
8892
})
8993
@PutMapping("/competitions/{competitionId}/stages/{stageId}")
@@ -105,6 +109,10 @@ public ResponseEntity<StageResponse> updateStage(
105109
@ApiResponse(responseCode = "403", description = "Access denied",
106110
content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))),
107111
@ApiResponse(responseCode = "404", description = "Stage not found",
112+
content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))),
113+
@ApiResponse(responseCode = "409",
114+
description = "Conflict — the entity was modified by another request since it was last read "
115+
+ "(stale version)",
108116
content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class)))
109117
})
110118
@PreAuthorize("hasAnyRole('ADMIN', 'ORG')")

src/main/java/com/itasocialacademy/oitassist/competition/controller/TourController.java

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,11 @@ public ResponseEntity<List<TourResponse>> getAllTours(@PathVariable Long stageId
7979
@ApiResponse(responseCode = "403", description = "Access denied",
8080
content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
8181
@ApiResponse(responseCode = "404", description = "Tour not found",
82-
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
82+
content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
83+
@ApiResponse(responseCode = "409",
84+
description = "Conflict — the entity was modified by another request since it was last read "
85+
+ "(stale version)",
86+
content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class)))
8387
})
8488
@PutMapping("/stages/{stageId}/tours/{tourId}")
8589
@PreAuthorize("hasAnyRole('ADMIN', 'ORG')")
@@ -98,7 +102,11 @@ public ResponseEntity<TourResponse> updateTour(
98102
@ApiResponse(responseCode = "403", description = "Access denied",
99103
content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))),
100104
@ApiResponse(responseCode = "404", description = "Tour not found",
101-
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
105+
content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
106+
@ApiResponse(responseCode = "409",
107+
description = "Conflict — the entity was modified by another request since it was last read "
108+
+ "(stale version)",
109+
content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class)))
102110
})
103111
@PatchMapping("/stages/{stageId}/tours/{tourId}/status")
104112
@PreAuthorize("hasAnyRole('ADMIN', 'ORG')")

src/main/java/com/itasocialacademy/oitassist/competition/dao/model/CompetitionEvent.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import jakarta.persistence.GenerationType;
77
import jakarta.persistence.Id;
88
import jakarta.persistence.MappedSuperclass;
9+
import jakarta.persistence.Version;
910
import java.time.Instant;
1011
import java.time.ZonedDateTime;
1112
import lombok.AllArgsConstructor;
@@ -58,4 +59,8 @@ public abstract class CompetitionEvent {
5859
@LastModifiedDate
5960
@Column(name = "updated_at")
6061
private Instant updatedAt;
62+
63+
@Version
64+
@Column(name = "version", nullable = false)
65+
private Long version;
6166
}

src/main/java/com/itasocialacademy/oitassist/competition/dto/request/ChangeCompetitionStatusRequest.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import com.itasocialacademy.oitassist.competition.dao.enums.CompetitionStatus;
44
import io.swagger.v3.oas.annotations.media.Schema;
5+
import io.swagger.v3.oas.annotations.media.Schema.RequiredMode;
56
import jakarta.validation.constraints.NotNull;
67

78
@Schema(description = "DTO for changing the lifecycle status of a Competition")
@@ -10,5 +11,8 @@ public record ChangeCompetitionStatusRequest(
1011
description = "The target status to transition the competition into (e.g., PUBLISHED, FINISHED, ARCHIVED)",
1112
example = "PUBLISHED",
1213
requiredMode = Schema.RequiredMode.REQUIRED) @NotNull(
13-
message = "New status cannot be null") CompetitionStatus status) {
14+
message = "New status cannot be null") CompetitionStatus status,
15+
16+
@Schema(description = "Optimistic locking version; must be echoed back on updates",
17+
requiredMode = RequiredMode.REQUIRED) @NotNull Long version) {
1418
}

src/main/java/com/itasocialacademy/oitassist/competition/dto/request/ChangeStageStatusRequest.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,15 @@
22

33
import com.itasocialacademy.oitassist.competition.dao.enums.StageStatus;
44
import io.swagger.v3.oas.annotations.media.Schema;
5+
import io.swagger.v3.oas.annotations.media.Schema.RequiredMode;
56
import jakarta.validation.constraints.NotNull;
67

78
@Schema(description = "DTO for changing the lifecycle status of a Stage")
89
public record ChangeStageStatusRequest(
910
@Schema(description = "The new status of the stage", example = "IN_PROGRESS",
1011
requiredMode = Schema.RequiredMode.REQUIRED) @NotNull(
11-
message = "Stage status must not be null") StageStatus status) {
12+
message = "Stage status must not be null") StageStatus status,
13+
14+
@Schema(description = "Optimistic locking version; must be echoed back on updates",
15+
requiredMode = RequiredMode.REQUIRED) @NotNull Long version) {
1216
}

src/main/java/com/itasocialacademy/oitassist/competition/dto/request/ChangeTourStatusRequest.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,15 @@
22

33
import com.itasocialacademy.oitassist.competition.dao.enums.ExecutionStatus;
44
import io.swagger.v3.oas.annotations.media.Schema;
5+
import io.swagger.v3.oas.annotations.media.Schema.RequiredMode;
56
import jakarta.validation.constraints.NotNull;
67

78
@Schema(description = "DTO for changing the lifecycle status of a Tour")
89
public record ChangeTourStatusRequest(
910
@Schema(description = "The new execution status of the tour", example = "IN_PROGRESS",
1011
requiredMode = Schema.RequiredMode.REQUIRED) @NotNull(
11-
message = "Execution status must not be null") ExecutionStatus status) {
12+
message = "Execution status must not be null") ExecutionStatus status,
13+
14+
@Schema(description = "Optimistic locking version; must be echoed back on updates",
15+
requiredMode = RequiredMode.REQUIRED) @NotNull Long version) {
1216
}

src/main/java/com/itasocialacademy/oitassist/competition/dto/request/UpdateStageRequest.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import com.itasocialacademy.oitassist.competition.dto.validation.ValidDateRange;
55
import com.itasocialacademy.oitassist.competition.dao.enums.StageScope;
66
import io.swagger.v3.oas.annotations.media.Schema;
7+
import io.swagger.v3.oas.annotations.media.Schema.RequiredMode;
78
import jakarta.validation.constraints.Min;
89
import jakarta.validation.constraints.NotBlank;
910
import jakarta.validation.constraints.NotNull;
@@ -26,6 +27,9 @@ public record UpdateStageRequest(
2627
@Schema(description = "Scope of the stage", example = "NATIONAL",
2728
requiredMode = Schema.RequiredMode.REQUIRED) @NotNull StageScope scope,
2829

29-
@Schema(description = "Order of the stage in the hierarchy", example = "2") @Min(1) Short sortPosition)
30+
@Schema(description = "Order of the stage in the hierarchy", example = "2") @Min(1) Short sortPosition,
31+
32+
@Schema(description = "Optimistic locking version; must be echoed back on updates",
33+
requiredMode = RequiredMode.REQUIRED) @NotNull Long version)
3034
implements HasDateRange {
3135
}

src/main/java/com/itasocialacademy/oitassist/competition/dto/request/UpdateTourRequest.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import com.itasocialacademy.oitassist.competition.dto.validation.HasDateRange;
44
import com.itasocialacademy.oitassist.competition.dto.validation.ValidDateRange;
55
import io.swagger.v3.oas.annotations.media.Schema;
6+
import io.swagger.v3.oas.annotations.media.Schema.RequiredMode;
67
import jakarta.validation.constraints.Min;
78
import jakarta.validation.constraints.NotBlank;
89
import jakarta.validation.constraints.NotNull;
@@ -25,6 +26,10 @@ public record UpdateTourRequest(
2526
@Schema(description = "Location for the tour", example = "Konotop city",
2627
requiredMode = Schema.RequiredMode.REQUIRED) @NotBlank String location,
2728

28-
@Schema(description = "Order of the tour in the hierarchy", example = "1") @Min(1) Short sortPosition)
29+
@Schema(description = "Order of the tour in the hierarchy", example = "1") @Min(1) Short sortPosition,
30+
31+
@Schema(description = "Optimistic locking version; must be echoed back on updates",
32+
requiredMode = RequiredMode.REQUIRED) @NotNull Long version)
33+
2934
implements HasDateRange {
3035
}

src/main/java/com/itasocialacademy/oitassist/competition/dto/response/CompetitionResponse.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,5 +15,6 @@ public record CompetitionResponse(
1515
@Schema(description = "End date", example = "2026-12-25T18:00:00Z") ZonedDateTime dateFinish,
1616
@Schema(description = "Current lifecycle status", example = "PUBLISHED") CompetitionStatus competitionStatus,
1717
@Schema(description = "ID of the user who created it", example = "5") Long createdBy,
18-
@Schema(description = "ID of the user who last updated it", example = "5") Long updatedBy) {
18+
@Schema(description = "ID of the user who last updated it", example = "5") Long updatedBy,
19+
@Schema(description = "Optimistic locking version; must be echoed back on updates") Long version) {
1920
}

0 commit comments

Comments
 (0)