Skip to content

Commit abffe86

Browse files
Merge pull request #141 from the-loopin/feat/138-loopin-64-complete-event-create-and-update-validation
Feat/138 loopin 64 complete event create and update validation
2 parents 63f487b + 1cd2fc8 commit abffe86

16 files changed

Lines changed: 483 additions & 80 deletions
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package com.loopin.api.common.config;
2+
3+
import org.springframework.context.annotation.Bean;
4+
import org.springframework.context.annotation.Configuration;
5+
6+
import java.time.Clock;
7+
8+
@Configuration
9+
public class ClockConfig {
10+
11+
@Bean
12+
public Clock applicationClock() {
13+
return Clock.systemUTC();
14+
}
15+
}

‎src/main/java/com/loopin/api/common/exception/GlobalExceptionHandler.java‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.loopin.api.common.exception;
22

33
import com.loopin.api.common.exception.dto.ErrorResponse;
4+
import com.loopin.api.events.shared.validation.EventRequestValidationException;
45
import jakarta.servlet.http.HttpServletRequest;
56
import lombok.extern.slf4j.Slf4j;
67
import org.springframework.http.HttpStatus;
@@ -113,6 +114,21 @@ public ResponseEntity<ErrorResponse> handleBadRequest(
113114
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response);
114115
}
115116

117+
@ExceptionHandler(EventRequestValidationException.class)
118+
public ResponseEntity<ErrorResponse> handleEventRequestValidation(
119+
EventRequestValidationException exception,
120+
HttpServletRequest request
121+
) {
122+
ErrorResponse response = buildErrorResponse(
123+
HttpStatus.BAD_REQUEST,
124+
"Validation failed",
125+
request.getRequestURI(),
126+
exception.getFieldErrors()
127+
);
128+
129+
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response);
130+
}
131+
116132
@ExceptionHandler(MethodArgumentNotValidException.class)
117133
public ResponseEntity<ErrorResponse> handleValidationError(
118134
MethodArgumentNotValidException exception,

‎src/main/java/com/loopin/api/events/create/CreateEventHandler.java‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import com.loopin.api.events.shared.moderation.EventModerationManager;
1111
import com.loopin.api.events.shared.policy.EventLifecyclePolicy;
1212
import com.loopin.api.events.shared.validation.EventValidator;
13+
import com.loopin.api.events.shared.validation.EventRequestValidator;
1314
import com.loopin.api.notifications.enums.NotificationReferenceType;
1415
import com.loopin.api.notifications.enums.NotificationType;
1516
import com.loopin.api.notifications.service.NotificationCommand;
@@ -29,6 +30,7 @@ public class CreateEventHandler {
2930
private final EventMapper eventMapper;
3031
private final EventFinder eventFinder;
3132
private final EventValidator eventValidator;
33+
private final EventRequestValidator eventRequestValidator;
3234
private final EventInterestManager eventInterestManager;
3335
private final EventModerationManager eventModerationManager;
3436
private final RecommendationIndexer recommendationIndexer;
@@ -42,9 +44,8 @@ public class CreateEventHandler {
4244
@Transactional
4345
public EventResponse handle(CreateEventCommand command) {
4446
EventCreateRequest request = command.request();
47+
eventRequestValidator.validate(request);
4548
User currentUser = eventFinder.findCurrentUser(command.currentUsername());
46-
eventValidator.validateDateRange(request.getStartDateTime(), request.getEndDateTime());
47-
eventValidator.validatePrice(request.getIsFree(), request.getPrice());
4849
eventValidator.validateNoDuplicate(request.getTitle(), request.getCity(), request.getStartDateTime());
4950

5051
Event event = eventMapper.toEntity(request);

‎src/main/java/com/loopin/api/events/dto/request/EventCreateRequest.java‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818

1919
@Getter
2020
@Setter
21-
public class EventCreateRequest {
21+
public class EventCreateRequest implements EventRequest {
2222

2323
@NotBlank(message = "Title is required")
2424
@Size(max = 120, message = "Title must not exceed 120 characters")
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package com.loopin.api.events.dto.request;
2+
3+
import java.math.BigDecimal;
4+
import java.time.LocalDateTime;
5+
import java.util.List;
6+
import java.util.UUID;
7+
8+
/** Fields shared by event create and update requests. */
9+
public interface EventRequest {
10+
11+
BigDecimal getLatitude();
12+
13+
BigDecimal getLongitude();
14+
15+
LocalDateTime getStartDateTime();
16+
17+
LocalDateTime getEndDateTime();
18+
19+
Boolean getIsFree();
20+
21+
BigDecimal getPrice();
22+
23+
String getImageUrl();
24+
25+
List<UUID> getInterestIds();
26+
}

‎src/main/java/com/loopin/api/events/dto/request/EventUpdateRequest.java‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818

1919
@Getter
2020
@Setter
21-
public class EventUpdateRequest {
21+
public class EventUpdateRequest implements EventRequest {
2222

2323
@NotBlank(message = "Title is required")
2424
@Size(max = 120, message = "Title must not exceed 120 characters")
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package com.loopin.api.events.shared.validation;
2+
3+
import java.util.Map;
4+
5+
public class EventRequestValidationException extends IllegalArgumentException {
6+
7+
private final Map<String, String> fieldErrors;
8+
9+
public EventRequestValidationException(Map<String, String> fieldErrors) {
10+
super("Validation failed");
11+
this.fieldErrors = Map.copyOf(fieldErrors);
12+
}
13+
14+
public Map<String, String> getFieldErrors() {
15+
return fieldErrors;
16+
}
17+
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
package com.loopin.api.events.shared.validation;
2+
3+
import com.loopin.api.events.dto.request.EventRequest;
4+
import org.springframework.stereotype.Component;
5+
6+
import java.math.BigDecimal;
7+
import java.net.URI;
8+
import java.net.URISyntaxException;
9+
import java.time.Clock;
10+
import java.time.Duration;
11+
import java.time.LocalDateTime;
12+
import java.util.LinkedHashMap;
13+
import java.util.List;
14+
import java.util.Map;
15+
import java.util.UUID;
16+
17+
@Component
18+
public class EventRequestValidator {
19+
20+
static final Duration MINIMUM_LEAD_TIME = Duration.ofMinutes(30);
21+
static final Duration MAXIMUM_DURATION = Duration.ofHours(24);
22+
static final int MAXIMUM_INTEREST_IDS = 10;
23+
static final int PRICE_SCALE = 2;
24+
static final BigDecimal MINIMUM_PAID_PRICE = new BigDecimal("0.01");
25+
static final BigDecimal MAXIMUM_PRICE = new BigDecimal("1000000.00");
26+
27+
private final Clock clock;
28+
29+
public EventRequestValidator(Clock clock) {
30+
this.clock = clock;
31+
}
32+
33+
public void validate(EventRequest request) {
34+
Map<String, String> errors = new LinkedHashMap<>();
35+
validateDates(request, errors);
36+
validatePrice(request, errors);
37+
validateImageUrl(request.getImageUrl(), errors);
38+
validateCoordinates(request, errors);
39+
validateInterestIds(request.getInterestIds(), errors);
40+
41+
if (!errors.isEmpty()) {
42+
throw new EventRequestValidationException(errors);
43+
}
44+
}
45+
46+
private void validateDates(EventRequest request, Map<String, String> errors) {
47+
LocalDateTime start = request.getStartDateTime();
48+
LocalDateTime end = request.getEndDateTime();
49+
if (start == null || end == null) {
50+
return;
51+
}
52+
53+
if (start.isBefore(LocalDateTime.now(clock).plus(MINIMUM_LEAD_TIME))) {
54+
errors.put("startDateTime", "Start date and time must be at least 30 minutes in the future");
55+
}
56+
if (!end.isAfter(start)) {
57+
errors.put("endDateTime", "End date and time must be after start date and time");
58+
} else if (Duration.between(start, end).compareTo(MAXIMUM_DURATION) > 0) {
59+
errors.put("endDateTime", "Event duration must not exceed 24 hours");
60+
}
61+
}
62+
63+
private void validatePrice(EventRequest request, Map<String, String> errors) {
64+
Boolean isFree = request.getIsFree();
65+
BigDecimal price = request.getPrice();
66+
if (isFree == null) {
67+
return;
68+
}
69+
if (isFree) {
70+
if (price != null && price.compareTo(BigDecimal.ZERO) != 0) {
71+
errors.put("price", "Free events must have price 0 or null");
72+
}
73+
return;
74+
}
75+
76+
if (price == null || price.compareTo(MINIMUM_PAID_PRICE) < 0) {
77+
errors.put("price", "Paid events must have a price of at least 0.01");
78+
} else if (price.scale() > PRICE_SCALE) {
79+
errors.put("price", "Price must have at most 2 decimal places");
80+
} else if (price.compareTo(MAXIMUM_PRICE) > 0) {
81+
errors.put("price", "Price must not exceed 1000000.00");
82+
}
83+
}
84+
85+
private void validateImageUrl(String imageUrl, Map<String, String> errors) {
86+
if (imageUrl == null || imageUrl.isBlank()) {
87+
return;
88+
}
89+
try {
90+
URI uri = new URI(imageUrl);
91+
if ((!"http".equalsIgnoreCase(uri.getScheme()) && !"https".equalsIgnoreCase(uri.getScheme()))
92+
|| uri.getHost() == null) {
93+
errors.put("imageUrl", "Image URL must use HTTP or HTTPS");
94+
}
95+
} catch (URISyntaxException exception) {
96+
errors.put("imageUrl", "Image URL must use HTTP or HTTPS");
97+
}
98+
}
99+
100+
private void validateCoordinates(EventRequest request, Map<String, String> errors) {
101+
if ((request.getLatitude() == null) != (request.getLongitude() == null)) {
102+
errors.put("coordinates", "Latitude and longitude must be provided together");
103+
}
104+
}
105+
106+
private void validateInterestIds(List<UUID> interestIds, Map<String, String> errors) {
107+
if (interestIds == null) {
108+
return;
109+
}
110+
if (interestIds.size() > MAXIMUM_INTEREST_IDS) {
111+
errors.put("interestIds", "At most 10 interests may be selected");
112+
return;
113+
}
114+
if (interestIds.stream().anyMatch(java.util.Objects::isNull)) {
115+
errors.put("interestIds", "Interest IDs must not contain null values");
116+
return;
117+
}
118+
if (interestIds.stream().distinct().count() != interestIds.size()) {
119+
errors.put("interestIds", "Duplicate interests are not allowed");
120+
}
121+
}
122+
}

‎src/main/java/com/loopin/api/events/shared/validation/EventValidator.java‎

Lines changed: 0 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
import org.springframework.data.jpa.domain.Specification;
88
import org.springframework.stereotype.Component;
99

10-
import java.math.BigDecimal;
1110
import java.time.LocalDateTime;
1211

1312
@Component
@@ -16,25 +15,6 @@ public class EventValidator {
1615

1716
private final EventRepository eventRepository;
1817

19-
public void validateDateRange(LocalDateTime startDateTime, LocalDateTime endDateTime) {
20-
if (!endDateTime.isAfter(startDateTime)) {
21-
throw new IllegalArgumentException("End date and time must be after start date and time");
22-
}
23-
}
24-
25-
public void validatePrice(Boolean isFree, BigDecimal price) {
26-
if (Boolean.TRUE.equals(isFree)) {
27-
if (price != null && price.compareTo(BigDecimal.ZERO) != 0) {
28-
throw new IllegalArgumentException("Free events must have price 0 or null");
29-
}
30-
return;
31-
}
32-
33-
if (price == null || price.compareTo(BigDecimal.ZERO) <= 0) {
34-
throw new IllegalArgumentException("Paid events must have price greater than 0");
35-
}
36-
}
37-
3818
public void validateNoDuplicate(String title, String city, LocalDateTime startDateTime) {
3919
boolean exists = eventRepository.exists(Specification.where(notDeleted())
4020
.and(titleEqualsIgnoreCase(title))

‎src/main/java/com/loopin/api/events/update/UpdateEventHandler.java‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import com.loopin.api.events.shared.interest.EventInterestManager;
1212
import com.loopin.api.events.shared.moderation.EventModerationManager;
1313
import com.loopin.api.events.shared.notification.EventMemberNotifier;
14-
import com.loopin.api.events.shared.validation.EventValidator;
14+
import com.loopin.api.events.shared.validation.EventRequestValidator;
1515
import com.loopin.api.recommendation.api.RecommendationIndexer;
1616
import com.loopin.api.users.entity.User;
1717
import lombok.RequiredArgsConstructor;
@@ -30,7 +30,7 @@ public class UpdateEventHandler {
3030
private final EventMapper eventMapper;
3131
private final EventFinder eventFinder;
3232
private final EventAccessPolicy eventAccessPolicy;
33-
private final EventValidator eventValidator;
33+
private final EventRequestValidator eventRequestValidator;
3434
private final EventInterestManager eventInterestManager;
3535
private final EventModerationManager eventModerationManager;
3636
private final RecommendationIndexer recommendationIndexer;
@@ -44,9 +44,8 @@ public class UpdateEventHandler {
4444
@Transactional
4545
public EventResponse handle(UpdateEventCommand command) {
4646
EventUpdateRequest request = command.request();
47+
eventRequestValidator.validate(request);
4748
User currentUser = eventFinder.findCurrentUser(command.currentUsername());
48-
eventValidator.validateDateRange(request.getStartDateTime(), request.getEndDateTime());
49-
eventValidator.validatePrice(request.getIsFree(), request.getPrice());
5049

5150
Event event = eventFinder.findActiveEventById(command.id());
5251
EventLifecyclePolicy.requireEditable(event);

0 commit comments

Comments
 (0)