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
1 change: 1 addition & 0 deletions backend/lined/.beads/interactions.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@
{"id":"int-00878b38","kind":"field_change","created_at":"2026-05-26T04:33:18.818645Z","actor":"Oleksii Makieiev","issue_id":"lined-byj","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Completed: added bounded k6 load-test baseline script, documented smoke/baseline workflows, Docker fallback, synthetic data behavior, and runtime metrics verification; Gradle test/check and static script checks passed."}}
{"id":"int-4377e589","kind":"field_change","created_at":"2026-05-29T08:40:48.238866Z","actor":"Oleksii Makieiev","issue_id":"lined-vbf","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented runtime scenario summary docs and CLI with critic review, verification, and Notion write-back."}}
{"id":"int-5e657a3c","kind":"field_change","created_at":"2026-05-30T16:21:49.590801Z","actor":"Oleksii Makieiev","issue_id":"lined-z7y","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented account provisioning policy seam and shared role resolver; focused tests and ./gradlew check pass."}}
{"id":"int-7ae524e7","kind":"field_change","created_at":"2026-06-01T14:19:13.525997Z","actor":"Oleksii Makieiev","issue_id":"lined-0zb","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Completed calendar time-window refactor with validated window seam, conflict analyzer, header-bound requester checks, critic review, and ./gradlew check."}}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package io.backend.lined.event.api;

import io.backend.lined.common.exception.ForbiddenException;
import io.backend.lined.event.service.EventService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
Expand All @@ -10,6 +11,7 @@
import jakarta.validation.Valid;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Objects;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
Expand Down Expand Up @@ -94,19 +96,31 @@ public ResponseEntity<List<EventConflictDto>> findConflicts(
@RequestParam Long lobbyId,
@RequestParam OffsetDateTime start,
@RequestParam OffsetDateTime end,
@RequestParam Long requesterId) {
@RequestParam Long requesterId,
Comment thread
Pan14ek marked this conversation as resolved.
@Parameter(description = "Current user id (temporary for MVP)", example = "42")
@RequestHeader("X-User-Id") Long currentUserId) {
ensureRequesterMatchesCurrentUser(requesterId, currentUserId);
return ResponseEntity.ok(
service.findConflicts(lobbyId, start, end, requesterId));
service.findConflicts(lobbyId, start, end, currentUserId));
}

@GetMapping("/user-conflict")
public ResponseEntity<UserConflictDto> hasConflict(
@RequestParam Long userId,
@RequestParam OffsetDateTime start,
@RequestParam OffsetDateTime end,
@RequestParam Long requesterId) {
@RequestParam Long requesterId,
@Parameter(description = "Current user id (temporary for MVP)", example = "42")
@RequestHeader("X-User-Id") Long currentUserId) {
ensureRequesterMatchesCurrentUser(requesterId, currentUserId);
return ResponseEntity.ok(
service.hasConflict(userId, start, end, requesterId));
service.hasConflict(userId, start, end, currentUserId));
}

private void ensureRequesterMatchesCurrentUser(Long requesterId, Long currentUserId) {
if (!Objects.equals(requesterId, currentUserId)) {
throw new ForbiddenException("Requester id must match current user");
}
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package io.backend.lined.event.service;

import io.backend.lined.common.exception.BadRequestException;
import java.time.OffsetDateTime;
import java.util.Optional;

/**
* Validated half-open calendar time window where start is inclusive and end is exclusive.
*
* @param start inclusive start instant
* @param end exclusive end instant
*/
record CalendarTimeWindow(OffsetDateTime start, OffsetDateTime end) {

/**
* Creates a validated time window.
*
* @param start inclusive start instant
* @param end exclusive end instant
* @param message error message used when the bounds are invalid
* @return validated calendar time window
* @throws BadRequestException when either bound is null or start is not before end
*/
static CalendarTimeWindow of(OffsetDateTime start, OffsetDateTime end, String message) {
if (start == null || end == null || !start.isBefore(end)) {
throw new BadRequestException(message);
}
return new CalendarTimeWindow(start, end);
}

/**
* Checks whether this window overlaps another window using half-open interval semantics.
*
* @param other validated window to compare with
* @return true when the windows share a non-empty time range
*/
boolean overlaps(CalendarTimeWindow other) {
return start.isBefore(other.end) && end.isAfter(other.start);
}

/**
* Calculates the shared bounds between this window and another window.
*
* @param other validated window to compare with
* @return overlap bounds, or empty when the windows do not overlap
*/
Optional<CalendarTimeWindow> overlapWith(CalendarTimeWindow other) {
if (!overlaps(other)) {
return Optional.empty();
}
// The overlap of two validated windows is always valid, so this can skip re-validation.
return Optional.of(new CalendarTimeWindow(max(start, other.start), min(end, other.end)));
Comment thread
Pan14ek marked this conversation as resolved.
}

/**
* Returns the later of two timestamps.
*
* @param first first timestamp
* @param second second timestamp
* @return later timestamp
*/
private static OffsetDateTime max(OffsetDateTime first, OffsetDateTime second) {
return first.isAfter(second) ? first : second;
}

/**
* Returns the earlier of two timestamps.
*
* @param first first timestamp
* @param second second timestamp
* @return earlier timestamp
*/
private static OffsetDateTime min(OffsetDateTime first, OffsetDateTime second) {
return first.isBefore(second) ? first : second;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package io.backend.lined.event.service;

import io.backend.lined.event.api.EventConflictDto;
import io.backend.lined.event.api.EventMapper;
import io.backend.lined.event.domain.EventEntity;
import java.util.ArrayList;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

/**
* Finds overlapping event pairs and maps them into calendar conflict responses.
*/
@Component
@RequiredArgsConstructor
public class EventConflictAnalyzer {

private final EventMapper mapper;

/**
* Finds all pairwise conflicts in the supplied event order.
*
* @param events events that already match the scheduling search window
* @return conflict pairs with calculated overlap bounds
*/
public List<EventConflictDto> findConflicts(List<EventEntity> events) {
var windows = events.stream().map(this::windowOf).toList();
List<EventConflictDto> conflicts = new ArrayList<>();
for (int i = 0; i < events.size(); i++) {
for (int j = i + 1; j < events.size(); j++) {
addConflict(events.get(i), windows.get(i), events.get(j), windows.get(j), conflicts);
}
}
return conflicts;
}

private void addConflict(EventEntity first, CalendarTimeWindow firstWindow,
EventEntity second, CalendarTimeWindow secondWindow,
List<EventConflictDto> conflicts) {
firstWindow.overlapWith(secondWindow).ifPresent(overlap ->
conflicts.add(new EventConflictDto(
mapper.toDto(first), mapper.toDto(second), overlap.start(), overlap.end())));
}

private CalendarTimeWindow windowOf(EventEntity event) {
if (event.getStartAt() == null || event.getEndAt() == null
|| !event.getStartAt().isBefore(event.getEndAt())) {
throw new IllegalStateException(
"Stored event %d has invalid time window".formatted(event.getId()));
}
return new CalendarTimeWindow(event.getStartAt(), event.getEndAt());
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package io.backend.lined.event.service;

import io.backend.lined.common.EntityFinder;
import io.backend.lined.common.exception.BadRequestException;
import io.backend.lined.common.exception.ForbiddenException;
import io.backend.lined.common.exception.NotFoundException;
import io.backend.lined.event.api.EventConflictDto;
import io.backend.lined.event.api.EventCreateDto;
Expand All @@ -18,8 +18,8 @@
import io.backend.lined.user.domain.UserRepository;
import jakarta.transaction.Transactional;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

Expand All @@ -33,22 +33,20 @@ public class EventServiceImpl implements EventService {
private final UserRepository userRepo;
private final EventMapper mapper;
private final LobbyAccessPolicy accessPolicy;
private final EventConflictAnalyzer conflictAnalyzer;

@Override
public EventDto create(EventCreateDto dto, Long currentUserId) {
var owner = mustUser(currentUserId);
var lobby = mustLobby(dto.lobbyId());
accessPolicy.ensureMember(lobby, currentUserId);

if (!dto.startAt().isBefore(dto.endAt())) {
throw new BadRequestException("startAt must be before endAt");
}
var window = eventWindow(dto.startAt(), dto.endAt());

var entity = EventEntity.builder()
.title(dto.title())
.shared(dto.shared())
.startAt(dto.startAt())
.endAt(dto.endAt())
.startAt(window.start())
.endAt(window.end())
.timezone(dto.timezone())
.lobby(lobby)
.owner(owner)
Expand All @@ -61,27 +59,22 @@ public EventDto create(EventCreateDto dto, Long currentUserId) {
public EventDto update(Long id, EventUpdateDto dto, Long currentUserId) {
var e = mustEvent(id);
accessPolicy.ensureMember(e.getLobby(), currentUserId);
var window = eventWindow(
Comment thread
Pan14ek marked this conversation as resolved.
dto.startAt() == null ? e.getStartAt() : dto.startAt(),
dto.endAt() == null ? e.getEndAt() : dto.endAt());

if (dto.title() != null && !dto.title().isBlank()) {
e.setTitle(dto.title());
}
if (dto.shared() != null) {
e.setShared(dto.shared());
}
if (dto.startAt() != null) {
e.setStartAt(dto.startAt());
}
if (dto.endAt() != null) {
e.setEndAt(dto.endAt());
}
e.setStartAt(window.start());
e.setEndAt(window.end());
if (dto.timezone() != null && !dto.timezone().isBlank()) {
e.setTimezone(dto.timezone());
}

if (!e.getStartAt().isBefore(e.getEndAt())) {
throw new BadRequestException("startAt must be before endAt");
}

return mapper.toDto(e);
}

Expand All @@ -97,12 +90,11 @@ public List<EventDto> list(Long lobbyId, OffsetDateTime from, OffsetDateTime to,
Long currentUserId) {
var lobby = mustLobby(lobbyId);
accessPolicy.ensureMember(lobby, currentUserId);
var window = queryWindow(from, to);

if (from == null || to == null || !from.isBefore(to)) {
throw new BadRequestException("Invalid time window: from < to is required");
}

return repo.findOverlapping(lobbyId, from, to).stream().map(mapper::toDto).toList();
return repo.findOverlapping(lobbyId, window.start(), window.end()).stream()
.map(mapper::toDto)
.toList();
}

@Override
Expand All @@ -111,35 +103,17 @@ public List<EventConflictDto> findConflicts(Long lobbyId,
Long requesterId) {
var lobby = mustLobby(lobbyId);
accessPolicy.ensureMember(lobby, requesterId);
if (!start.isBefore(end)) {
throw new BadRequestException("start must be before end");
}
var events = repo.findOverlapping(lobbyId, start, end);
List<EventConflictDto> conflicts = new ArrayList<>();
for (int i = 0; i < events.size(); i++) {
for (int j = i + 1; j < events.size(); j++) {
var a = events.get(i);
var b = events.get(j);
var overlapStart = a.getStartAt().isAfter(b.getStartAt())
? a.getStartAt() : b.getStartAt();
var overlapEnd = a.getEndAt().isBefore(b.getEndAt())
? a.getEndAt() : b.getEndAt();
if (overlapStart.isBefore(overlapEnd)) {
conflicts.add(new EventConflictDto(
mapper.toDto(a), mapper.toDto(b), overlapStart, overlapEnd));
}
}
}
return conflicts;
var window = conflictWindow(start, end);
var events = repo.findOverlapping(lobbyId, window.start(), window.end());
return conflictAnalyzer.findConflicts(events);
}

@Override
public UserConflictDto hasConflict(Long userId, OffsetDateTime start,
OffsetDateTime end, Long requesterId) {
if (!start.isBefore(end)) {
throw new BadRequestException("start must be before end");
}
var overlapping = repo.findOverlappingByUser(userId, start, end);
ensureOwnCalendar(userId, requesterId);
var window = conflictWindow(start, end);
var overlapping = repo.findOverlappingByUser(userId, window.start(), window.end());
if (overlapping.isEmpty()) {
return new UserConflictDto(userId, false, null);
}
Expand All @@ -161,4 +135,22 @@ private EventEntity mustEvent(Long id) {
() -> new NotFoundException("Event %d not found".formatted(id)));
}

private CalendarTimeWindow eventWindow(OffsetDateTime start, OffsetDateTime end) {
return CalendarTimeWindow.of(start, end, "startAt must be before endAt");
}

private CalendarTimeWindow queryWindow(OffsetDateTime start, OffsetDateTime end) {
return CalendarTimeWindow.of(start, end, "Invalid time window: from < to is required");
}

private CalendarTimeWindow conflictWindow(OffsetDateTime start, OffsetDateTime end) {
return CalendarTimeWindow.of(start, end, "start must be before end");
}

private void ensureOwnCalendar(Long userId, Long requesterId) {
if (!Objects.equals(userId, requesterId)) {
throw new ForbiddenException("Requester can only check their own calendar");
}
}

}
Loading
Loading