From af17e7034f333f7418083c7a9847fa648ebb321e Mon Sep 17 00:00:00 2001 From: Krywion Date: Sat, 16 May 2026 01:10:09 +0200 Subject: [PATCH 01/14] feat: add OpenAPI spec for flight wizard backend endpoints --- .../src/main/resources/api/openapi.yaml | 225 +++++++++++++++++- 1 file changed, 224 insertions(+), 1 deletion(-) diff --git a/skyroster-backend/src/main/resources/api/openapi.yaml b/skyroster-backend/src/main/resources/api/openapi.yaml index 474b1c3..5b21e38 100644 --- a/skyroster-backend/src/main/resources/api/openapi.yaml +++ b/skyroster-backend/src/main/resources/api/openapi.yaml @@ -156,6 +156,56 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorResponse' + + /api/aircraft/availability: + get: + operationId: getAvailableAircraft + tags: [ Aircraft ] + summary: List aircraft available in a given time window + parameters: + - in: query + name: from + required: true + schema: + type: string + format: date-time + description: Window start (ISO offset datetime) + - in: query + name: to + required: true + schema: + type: string + format: date-time + description: Window end (ISO offset datetime) + - in: query + name: baseId + required: false + schema: + type: string + format: uuid + description: Optional filter by operational base + responses: + '200': + description: List of available aircraft + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/AircraftResponse' + '400': + description: Bad request (e.g. from >= to) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /api/pilots: get: tags: @@ -248,6 +298,53 @@ paths: schema: $ref: '#/components/schemas/ErrorResponse' + /api/pilots/availability: + get: + operationId: getAvailablePilots + tags: [ Pilots ] + summary: List pilots available in a given time window with optional aircraft type qualification + parameters: + - in: query + name: from + required: true + schema: + type: string + format: date-time + - in: query + name: to + required: true + schema: + type: string + format: date-time + - in: query + name: aircraftTypeId + required: false + schema: + type: string + format: uuid + description: Filter to pilots qualified for this aircraft type + responses: + '200': + description: List of available pilots + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/PilotResponse' + '400': + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /api/rules: get: operationId: getRules @@ -295,6 +392,53 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorResponse' + post: + operationId: createFlight + tags: [ Flights ] + summary: Create a new flight (with rule validation) + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateFlightRequest' + responses: + '201': + description: Flight created + content: + application/json: + schema: + $ref: '#/components/schemas/FlightResponse' + '400': + description: Bad request (invalid payload or start >= end) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Aircraft/Pilot/Airport not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '409': + description: Time conflict for aircraft or pilot + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '422': + description: Rule violation + content: + application/json: + schema: + $ref: '#/components/schemas/RuleViolationErrorResponse' /api/aircraft/{id}: put: @@ -884,4 +1028,83 @@ components: $ref: '#/components/schemas/OperationalBaseInfo' description: type: string - example: "Lot rejsowy Kraków - Gdańsk" \ No newline at end of file + example: "Lot rejsowy Kraków - Gdańsk" + + CreateFlightRequest: + type: object + required: + - aircraftId + - pilotId + - startDateTime + - endDateTime + - startAirportId + properties: + aircraftId: + type: string + format: uuid + pilotId: + type: string + format: uuid + startDateTime: + type: string + format: date-time + endDateTime: + type: string + format: date-time + startAirportId: + type: string + format: uuid + endAirportId: + type: string + format: uuid + description: Optional arrival airport + description: + type: string + + RuleViolation: + type: object + required: + - ruleId + - ruleName + - ruleType + - message + properties: + ruleId: + type: string + format: uuid + ruleName: + type: string + ruleType: + $ref: '#/components/schemas/RuleType' + message: + type: string + + RuleViolationErrorResponse: + type: object + required: + - status + - error + - message + - timestamp + - path + - violations + properties: + status: + type: integer + example: 422 + error: + type: string + example: "Unprocessable Entity" + message: + type: string + example: "Cannot create flight: rule violations detected" + timestamp: + type: string + format: date-time + path: + type: string + example: "/api/flights" + violations: + type: array + items: + $ref: '#/components/schemas/RuleViolation' \ No newline at end of file From 1c0cd5fd136ef9db96282758f2ede7543cca53dd Mon Sep 17 00:00:00 2001 From: Krywion Date: Sat, 16 May 2026 01:10:37 +0200 Subject: [PATCH 02/14] feat: add RuleViolation domain record and flight/rule exceptions --- .../exception/FlightTimeConflictException.java | 7 +++++++ .../exception/RuleViolationException.java | 18 ++++++++++++++++++ .../domain/model/RuleViolation.java | 6 ++++++ 3 files changed, 31 insertions(+) create mode 100644 skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/domain/exception/FlightTimeConflictException.java create mode 100644 skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/domain/exception/RuleViolationException.java create mode 100644 skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/domain/model/RuleViolation.java diff --git a/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/domain/exception/FlightTimeConflictException.java b/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/domain/exception/FlightTimeConflictException.java new file mode 100644 index 0000000..1418cb6 --- /dev/null +++ b/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/domain/exception/FlightTimeConflictException.java @@ -0,0 +1,7 @@ +package pl.skyroster.skyroster_backend.domain.exception; + +public class FlightTimeConflictException extends RuntimeException { + public FlightTimeConflictException(String message) { + super(message); + } +} diff --git a/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/domain/exception/RuleViolationException.java b/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/domain/exception/RuleViolationException.java new file mode 100644 index 0000000..2adf528 --- /dev/null +++ b/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/domain/exception/RuleViolationException.java @@ -0,0 +1,18 @@ +package pl.skyroster.skyroster_backend.domain.exception; + +import pl.skyroster.skyroster_backend.domain.model.RuleViolation; + +import java.util.List; + +public class RuleViolationException extends RuntimeException { + private final List violations; + + public RuleViolationException(List violations) { + super("Rule violations detected: " + violations.size()); + this.violations = List.copyOf(violations); + } + + public List getViolations() { + return violations; + } +} diff --git a/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/domain/model/RuleViolation.java b/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/domain/model/RuleViolation.java new file mode 100644 index 0000000..fa3ad77 --- /dev/null +++ b/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/domain/model/RuleViolation.java @@ -0,0 +1,6 @@ +package pl.skyroster.skyroster_backend.domain.model; + +import java.util.UUID; + +public record RuleViolation(UUID ruleId, String ruleName, RuleType ruleType, String message) { +} From 92ca403b9c65dd7101afcf6cf7e475f5b57c1bb0 Mon Sep 17 00:00:00 2001 From: Krywion Date: Sat, 16 May 2026 01:11:18 +0200 Subject: [PATCH 03/14] feat: handle FlightTimeConflict (409) and RuleViolation (422) in global handler --- .../config/GlobalExceptionHandler.java | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/infrastructure/config/GlobalExceptionHandler.java b/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/infrastructure/config/GlobalExceptionHandler.java index 8347c88..12042cc 100644 --- a/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/infrastructure/config/GlobalExceptionHandler.java +++ b/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/infrastructure/config/GlobalExceptionHandler.java @@ -8,8 +8,10 @@ import org.springframework.web.bind.annotation.RestControllerAdvice; import pl.skyroster.skyroster_backend.domain.exception.*; import pl.skyroster.skyroster_backend.generated.model.ErrorResponse; +import pl.skyroster.skyroster_backend.generated.model.RuleViolationErrorResponse; import java.time.OffsetDateTime; +import java.util.List; @RestControllerAdvice public class GlobalExceptionHandler { @@ -32,6 +34,34 @@ public ResponseEntity handleAircraftHasAssignedFlights(AircraftHa return buildResponse(HttpStatus.CONFLICT, ex.getMessage(), request); } + @ExceptionHandler(FlightTimeConflictException.class) + public ResponseEntity handleFlightTimeConflict(FlightTimeConflictException ex, + HttpServletRequest request) { + return buildResponse(HttpStatus.CONFLICT, ex.getMessage(), request); + } + + @ExceptionHandler(RuleViolationException.class) + public ResponseEntity handleRuleViolation(RuleViolationException ex, + HttpServletRequest request) { + List apiViolations = ex.getViolations().stream() + .map(v -> new pl.skyroster.skyroster_backend.generated.model.RuleViolation( + v.ruleId(), + v.ruleName(), + pl.skyroster.skyroster_backend.generated.model.RuleType.valueOf(v.ruleType().name()), + v.message() + )) + .toList(); + RuleViolationErrorResponse body = new RuleViolationErrorResponse( + HttpStatus.UNPROCESSABLE_ENTITY.value(), + HttpStatus.UNPROCESSABLE_ENTITY.getReasonPhrase(), + "Cannot create flight: rule violations detected", + OffsetDateTime.now(), + request.getRequestURI(), + apiViolations + ); + return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY).body(body); + } + @ExceptionHandler(DataIntegrityViolationException.class) public ResponseEntity handleDataIntegrityViolation(DataIntegrityViolationException ex, HttpServletRequest request) { From d66bc02ece84805e5f354d65ffab3bbb229330ef Mon Sep 17 00:00:00 2001 From: Krywion Date: Sat, 16 May 2026 01:26:03 +0200 Subject: [PATCH 04/14] feat: add availability and overlap query methods to repositories --- .../domain/port/AircraftRepository.java | 2 + .../domain/port/FlightRepository.java | 64 +++++++++++++++++-- .../port/OperationalBaseRepository.java | 2 + .../domain/port/PilotRepository.java | 24 ++++++- .../persistence/JpaAircraftRepository.java | 17 +++++ 5 files changed, 103 insertions(+), 6 deletions(-) diff --git a/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/domain/port/AircraftRepository.java b/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/domain/port/AircraftRepository.java index 6b006d1..c1d6187 100644 --- a/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/domain/port/AircraftRepository.java +++ b/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/domain/port/AircraftRepository.java @@ -2,6 +2,7 @@ import pl.skyroster.skyroster_backend.domain.model.Aircraft; +import java.time.OffsetDateTime; import java.util.List; import java.util.Optional; import java.util.UUID; @@ -13,4 +14,5 @@ public interface AircraftRepository { boolean existsByRegistrationNumber(String registrationNumber); boolean existsByRegistrationNumberAndIdNot(String registrationNumber, UUID id); void deleteById(UUID id); + List findAvailable(OffsetDateTime from, OffsetDateTime to, UUID baseIdOrNull); } diff --git a/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/domain/port/FlightRepository.java b/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/domain/port/FlightRepository.java index e335ab3..0bc384f 100644 --- a/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/domain/port/FlightRepository.java +++ b/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/domain/port/FlightRepository.java @@ -1,17 +1,71 @@ package pl.skyroster.skyroster_backend.domain.port; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import pl.skyroster.skyroster_backend.domain.model.Flight; import java.time.OffsetDateTime; import java.util.List; +import java.util.Optional; import java.util.UUID; public interface FlightRepository extends JpaRepository { + List findAll(); + boolean existsByAircraftId(UUID aircraftId); - boolean existsByPilotIdAndFlightEndAfter( - UUID pilotId, - OffsetDateTime now - ); -} \ No newline at end of file + + boolean existsByPilotIdAndFlightEndAfter(UUID pilotId, OffsetDateTime now); + + @Query(""" + SELECT f FROM Flight f + WHERE f.aircraft.id = :aircraftId + AND f.flightStart < :to + AND f.flightEnd > :from + """) + List findOverlappingByAircraft(@Param("aircraftId") UUID aircraftId, + @Param("from") OffsetDateTime from, + @Param("to") OffsetDateTime to); + + @Query(""" + SELECT f FROM Flight f + WHERE f.pilot.id = :pilotId + AND f.flightStart < :to + AND f.flightEnd > :from + """) + List findOverlappingByPilot(@Param("pilotId") UUID pilotId, + @Param("from") OffsetDateTime from, + @Param("to") OffsetDateTime to); + + @Query(""" + SELECT f FROM Flight f + WHERE f.pilot.id = :pilotId + AND f.flightStart < :to + AND f.flightEnd > :from + ORDER BY f.flightStart + """) + List findByPilotInPeriod(@Param("pilotId") UUID pilotId, + @Param("from") OffsetDateTime from, + @Param("to") OffsetDateTime to); + + @Query(""" + SELECT f FROM Flight f + WHERE f.pilot.id = :pilotId + AND f.flightEnd <= :before + ORDER BY f.flightEnd DESC + LIMIT 1 + """) + Optional findLastByPilotBefore(@Param("pilotId") UUID pilotId, + @Param("before") OffsetDateTime before); + + @Query(""" + SELECT f FROM Flight f + WHERE f.pilot.id = :pilotId + AND f.flightStart >= :after + ORDER BY f.flightStart + LIMIT 1 + """) + Optional findFirstByPilotAfter(@Param("pilotId") UUID pilotId, + @Param("after") OffsetDateTime after); +} diff --git a/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/domain/port/OperationalBaseRepository.java b/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/domain/port/OperationalBaseRepository.java index cd26ebd..5d114b4 100644 --- a/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/domain/port/OperationalBaseRepository.java +++ b/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/domain/port/OperationalBaseRepository.java @@ -3,7 +3,9 @@ import pl.skyroster.skyroster_backend.domain.model.OperationalBase; import java.util.Optional; +import java.util.UUID; public interface OperationalBaseRepository { Optional findByIcaoCode(String icaoCode); + Optional findById(UUID id); } diff --git a/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/domain/port/PilotRepository.java b/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/domain/port/PilotRepository.java index 2213bb6..b090c96 100644 --- a/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/domain/port/PilotRepository.java +++ b/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/domain/port/PilotRepository.java @@ -4,8 +4,11 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import pl.skyroster.skyroster_backend.domain.model.Pilot; +import java.time.OffsetDateTime; +import java.util.List; import java.util.Optional; import java.util.UUID; @@ -17,9 +20,28 @@ public interface PilotRepository { }) @Query("SELECT p FROM Pilot p") Page findAllWithRelations(Pageable pageable); + Optional findById(UUID id); + boolean existsById(UUID id); + boolean existsByLicence(String licence); + void deleteById(UUID id); + Pilot save(Pilot pilot); -} \ No newline at end of file + + @EntityGraph(attributePaths = {"qualifications", "aircraftTypes", "operationalBase"}) + @Query(""" + SELECT DISTINCT p FROM Pilot p + LEFT JOIN p.aircraftTypes at + WHERE (:aircraftTypeId IS NULL OR at.id = :aircraftTypeId) + AND p.id NOT IN ( + SELECT f.pilot.id FROM Flight f + WHERE f.flightStart < :to AND f.flightEnd > :from + ) + """) + List findAvailable(@Param("from") OffsetDateTime from, + @Param("to") OffsetDateTime to, + @Param("aircraftTypeId") UUID aircraftTypeIdOrNull); +} diff --git a/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/infrastructure/adapter/out/persistence/JpaAircraftRepository.java b/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/infrastructure/adapter/out/persistence/JpaAircraftRepository.java index 04d3f60..68457d5 100644 --- a/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/infrastructure/adapter/out/persistence/JpaAircraftRepository.java +++ b/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/infrastructure/adapter/out/persistence/JpaAircraftRepository.java @@ -1,10 +1,27 @@ package pl.skyroster.skyroster_backend.infrastructure.adapter.out.persistence; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import pl.skyroster.skyroster_backend.domain.model.Aircraft; import pl.skyroster.skyroster_backend.domain.port.AircraftRepository; +import java.time.OffsetDateTime; +import java.util.List; import java.util.UUID; public interface JpaAircraftRepository extends JpaRepository, AircraftRepository { + + @Override + @Query(""" + SELECT a FROM Aircraft a + WHERE (:baseId IS NULL OR a.operationalBase.id = :baseId) + AND a.id NOT IN ( + SELECT f.aircraft.id FROM Flight f + WHERE f.flightStart < :to AND f.flightEnd > :from + ) + """) + List findAvailable(@Param("from") OffsetDateTime from, + @Param("to") OffsetDateTime to, + @Param("baseId") UUID baseIdOrNull); } From fe3c88204f34922ee32ee3b71c62f7c3339478f0 Mon Sep 17 00:00:00 2001 From: Krywion Date: Sat, 16 May 2026 01:30:33 +0200 Subject: [PATCH 05/14] feat: add RuleValidator domain service with unit tests --- .../domain/service/RuleValidator.java | 108 +++++++++++++++ .../domain/service/RuleValidatorTest.java | 128 ++++++++++++++++++ 2 files changed, 236 insertions(+) create mode 100644 skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/domain/service/RuleValidator.java create mode 100644 skyroster-backend/src/test/java/pl/skyroster/skyroster_backend/domain/service/RuleValidatorTest.java diff --git a/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/domain/service/RuleValidator.java b/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/domain/service/RuleValidator.java new file mode 100644 index 0000000..dfa6b67 --- /dev/null +++ b/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/domain/service/RuleValidator.java @@ -0,0 +1,108 @@ +package pl.skyroster.skyroster_backend.domain.service; + +import pl.skyroster.skyroster_backend.domain.model.Rule; +import pl.skyroster.skyroster_backend.domain.model.RulePeriod; +import pl.skyroster.skyroster_backend.domain.model.RuleViolation; + +import java.time.DayOfWeek; +import java.time.Duration; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.time.temporal.ChronoUnit; +import java.time.temporal.TemporalAdjusters; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +public class RuleValidator { + + public record TimeRange(OffsetDateTime from, OffsetDateTime to) { + public long hours() { + return Duration.between(from, to).toMinutes() / 60L; + } + } + + public List validate(UUID pilotId, + OffsetDateTime newStart, + OffsetDateTime newEnd, + List rules, + List existingFlights) { + List violations = new ArrayList<>(); + long newHours = Duration.between(newStart, newEnd).toMinutes() / 60L; + + for (Rule rule : rules) { + switch (rule.getType()) { + case MAX_WORK_TIME -> { + TimeRange window = windowFor(rule.getPeriod(), newStart); + long sum = newHours; + for (TimeRange f : existingFlights) { + if (f.from().isBefore(window.to()) && f.to().isAfter(window.from())) { + sum += f.hours(); + } + } + if (sum > rule.getValue()) { + violations.add(new RuleViolation( + rule.getId(), + rule.getName(), + rule.getType(), + "Pilot would exceed " + rule.getValue() + "h/" + rule.getPeriod() + + " (would total " + sum + "h)" + )); + } + } + case MIN_REST_TIME -> { + boolean reported = false; + for (TimeRange f : existingFlights) { + if (reported) break; + if (!f.to().isAfter(newStart)) { + long gap = Duration.between(f.to(), newStart).toMinutes() / 60L; + if (gap < rule.getValue()) { + violations.add(new RuleViolation( + rule.getId(), + rule.getName(), + rule.getType(), + "Rest before flight is " + gap + "h, required: " + rule.getValue() + "h" + )); + reported = true; + } + } + if (!reported && !f.from().isBefore(newEnd)) { + long gap = Duration.between(newEnd, f.from()).toMinutes() / 60L; + if (gap < rule.getValue()) { + violations.add(new RuleViolation( + rule.getId(), + rule.getName(), + rule.getType(), + "Rest after flight is " + gap + "h, required: " + rule.getValue() + "h" + )); + reported = true; + } + } + } + } + case MIN_FLIGHT_TIME -> { + // Achievement rule — ignored at creation time. + } + } + } + return violations; + } + + private static TimeRange windowFor(RulePeriod period, OffsetDateTime ref) { + OffsetDateTime refUtc = ref.withOffsetSameInstant(ZoneOffset.UTC); + return switch (period) { + case DAY -> new TimeRange( + refUtc.truncatedTo(ChronoUnit.DAYS), + refUtc.truncatedTo(ChronoUnit.DAYS).plusDays(1) + ); + case WEEK -> new TimeRange( + refUtc.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)).truncatedTo(ChronoUnit.DAYS), + refUtc.with(TemporalAdjusters.nextOrSame(DayOfWeek.SUNDAY)).truncatedTo(ChronoUnit.DAYS).plusDays(1) + ); + case MONTH -> new TimeRange( + refUtc.with(TemporalAdjusters.firstDayOfMonth()).truncatedTo(ChronoUnit.DAYS), + refUtc.with(TemporalAdjusters.firstDayOfNextMonth()).truncatedTo(ChronoUnit.DAYS) + ); + }; + } +} diff --git a/skyroster-backend/src/test/java/pl/skyroster/skyroster_backend/domain/service/RuleValidatorTest.java b/skyroster-backend/src/test/java/pl/skyroster/skyroster_backend/domain/service/RuleValidatorTest.java new file mode 100644 index 0000000..affddaa --- /dev/null +++ b/skyroster-backend/src/test/java/pl/skyroster/skyroster_backend/domain/service/RuleValidatorTest.java @@ -0,0 +1,128 @@ +package pl.skyroster.skyroster_backend.domain.service; + +import org.junit.jupiter.api.Test; +import pl.skyroster.skyroster_backend.domain.model.Rule; +import pl.skyroster.skyroster_backend.domain.model.RulePeriod; +import pl.skyroster.skyroster_backend.domain.model.RuleType; +import pl.skyroster.skyroster_backend.domain.model.RuleViolation; + +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +class RuleValidatorTest { + + private static final UUID PILOT = UUID.randomUUID(); + + private final RuleValidator validator = new RuleValidator(); + + @Test + void noViolations_whenNoRulesAndNoPriorFlights() { + List result = validator.validate( + PILOT, + instant("2026-05-20T10:00:00Z"), + instant("2026-05-20T13:00:00Z"), + List.of(), + List.of() + ); + assertThat(result).isEmpty(); + } + + @Test + void maxWorkTime_violated_whenSumExceedsLimit() { + Rule maxMonth = rule("Max monthly", RuleType.MAX_WORK_TIME, 10, RulePeriod.MONTH); + List existing = List.of( + tr("2026-05-10T08:00:00Z", "2026-05-10T16:00:00Z") + ); + List result = validator.validate( + PILOT, + instant("2026-05-20T10:00:00Z"), + instant("2026-05-20T13:00:00Z"), + List.of(maxMonth), + existing + ); + assertThat(result).hasSize(1); + assertThat(result.get(0).ruleType()).isEqualTo(RuleType.MAX_WORK_TIME); + assertThat(result.get(0).message()).contains("11"); + } + + @Test + void maxWorkTime_notViolated_whenWithinLimit() { + Rule maxMonth = rule("Max monthly", RuleType.MAX_WORK_TIME, 100, RulePeriod.MONTH); + List existing = List.of( + tr("2026-05-10T08:00:00Z", "2026-05-10T16:00:00Z") + ); + List result = validator.validate( + PILOT, + instant("2026-05-20T10:00:00Z"), + instant("2026-05-20T13:00:00Z"), + List.of(maxMonth), + existing + ); + assertThat(result).isEmpty(); + } + + @Test + void minRestTime_violated_whenGapBeforeIsTooShort() { + Rule minRest = rule("Min rest", RuleType.MIN_REST_TIME, 12, RulePeriod.DAY); + List existing = List.of( + tr("2026-05-20T05:00:00Z", "2026-05-20T08:00:00Z") + ); + List result = validator.validate( + PILOT, + instant("2026-05-20T10:00:00Z"), + instant("2026-05-20T13:00:00Z"), + List.of(minRest), + existing + ); + assertThat(result).hasSize(1); + assertThat(result.get(0).ruleType()).isEqualTo(RuleType.MIN_REST_TIME); + } + + @Test + void minRestTime_violated_whenGapAfterIsTooShort() { + Rule minRest = rule("Min rest", RuleType.MIN_REST_TIME, 12, RulePeriod.DAY); + List existing = List.of( + tr("2026-05-20T15:00:00Z", "2026-05-20T18:00:00Z") + ); + List result = validator.validate( + PILOT, + instant("2026-05-20T10:00:00Z"), + instant("2026-05-20T13:00:00Z"), + List.of(minRest), + existing + ); + assertThat(result).hasSize(1); + assertThat(result.get(0).ruleType()).isEqualTo(RuleType.MIN_REST_TIME); + } + + @Test + void minFlightTime_isIgnored() { + Rule minFlight = rule("Min flight monthly", RuleType.MIN_FLIGHT_TIME, 100, RulePeriod.MONTH); + List result = validator.validate( + PILOT, + instant("2026-05-20T10:00:00Z"), + instant("2026-05-20T13:00:00Z"), + List.of(minFlight), + List.of() + ); + assertThat(result).isEmpty(); + } + + // ---- helpers ---- + + private static OffsetDateTime instant(String iso) { + return OffsetDateTime.parse(iso).withOffsetSameInstant(ZoneOffset.UTC); + } + + private static Rule rule(String name, RuleType type, int value, RulePeriod period) { + return new Rule(UUID.randomUUID(), name, type, value, period, null); + } + + private static RuleValidator.TimeRange tr(String fromIso, String toIso) { + return new RuleValidator.TimeRange(instant(fromIso), instant(toIso)); + } +} From 1b5766ad623664053638bfc873d6607e3bfbd84e Mon Sep 17 00:00:00 2001 From: Krywion Date: Sat, 16 May 2026 01:35:37 +0200 Subject: [PATCH 06/14] feat: add availability and create-flight endpoints with rule validation --- .../flight/CreateFlightUseCase.java | 106 ++++++++++++++++++ .../flight/GetAvailableAircraftUseCase.java | 28 +++++ .../flight/GetAvailablePilotsUseCase.java | 28 +++++ .../application/flight/GetFlightsUseCase.java | 19 +--- .../domain/model/Flight.java | 6 + .../adapter/in/web/AircraftController.java | 20 +++- .../adapter/in/web/FlightController.java | 32 +++++- .../adapter/in/web/PilotController.java | 16 +++ .../mappers/FlightResponseMapper.java | 18 +++ 9 files changed, 253 insertions(+), 20 deletions(-) create mode 100644 skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/application/flight/CreateFlightUseCase.java create mode 100644 skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/application/flight/GetAvailableAircraftUseCase.java create mode 100644 skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/application/flight/GetAvailablePilotsUseCase.java create mode 100644 skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/infrastructure/mappers/FlightResponseMapper.java diff --git a/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/application/flight/CreateFlightUseCase.java b/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/application/flight/CreateFlightUseCase.java new file mode 100644 index 0000000..f902264 --- /dev/null +++ b/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/application/flight/CreateFlightUseCase.java @@ -0,0 +1,106 @@ +package pl.skyroster.skyroster_backend.application.flight; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import pl.skyroster.skyroster_backend.domain.exception.AircraftNotFoundException; +import pl.skyroster.skyroster_backend.domain.exception.FlightTimeConflictException; +import pl.skyroster.skyroster_backend.domain.exception.OperationalBaseNotFoundException; +import pl.skyroster.skyroster_backend.domain.exception.PilotNotFoundException; +import pl.skyroster.skyroster_backend.domain.exception.RuleViolationException; +import pl.skyroster.skyroster_backend.domain.model.Aircraft; +import pl.skyroster.skyroster_backend.domain.model.Flight; +import pl.skyroster.skyroster_backend.domain.model.OperationalBase; +import pl.skyroster.skyroster_backend.domain.model.Pilot; +import pl.skyroster.skyroster_backend.domain.model.Rule; +import pl.skyroster.skyroster_backend.domain.model.RuleViolation; +import pl.skyroster.skyroster_backend.domain.port.AircraftRepository; +import pl.skyroster.skyroster_backend.domain.port.FlightRepository; +import pl.skyroster.skyroster_backend.domain.port.OperationalBaseRepository; +import pl.skyroster.skyroster_backend.domain.port.PilotRepository; +import pl.skyroster.skyroster_backend.domain.port.RuleRepository; +import pl.skyroster.skyroster_backend.domain.service.RuleValidator; + +import java.time.OffsetDateTime; +import java.time.temporal.TemporalAdjusters; +import java.util.List; +import java.util.UUID; + +@Service +public class CreateFlightUseCase { + + private final AircraftRepository aircraftRepository; + private final PilotRepository pilotRepository; + private final OperationalBaseRepository operationalBaseRepository; + private final FlightRepository flightRepository; + private final RuleRepository ruleRepository; + private final RuleValidator ruleValidator = new RuleValidator(); + + public CreateFlightUseCase(AircraftRepository aircraftRepository, + PilotRepository pilotRepository, + OperationalBaseRepository operationalBaseRepository, + FlightRepository flightRepository, + RuleRepository ruleRepository) { + this.aircraftRepository = aircraftRepository; + this.pilotRepository = pilotRepository; + this.operationalBaseRepository = operationalBaseRepository; + this.flightRepository = flightRepository; + this.ruleRepository = ruleRepository; + } + + @Transactional + public Flight execute(UUID aircraftId, UUID pilotId, + OffsetDateTime startDateTime, OffsetDateTime endDateTime, + UUID startAirportId, UUID endAirportIdOrNull, + String descriptionOrNull) { + if (startDateTime == null || endDateTime == null || !startDateTime.isBefore(endDateTime)) { + throw new IllegalArgumentException("startDateTime must be before endDateTime"); + } + + Aircraft aircraft = aircraftRepository.findById(aircraftId) + .orElseThrow(AircraftNotFoundException::new); + Pilot pilot = pilotRepository.findById(pilotId) + .orElseThrow(() -> new PilotNotFoundException(pilotId)); + OperationalBase startAirport = operationalBaseRepository.findById(startAirportId) + .orElseThrow(() -> new OperationalBaseNotFoundException(startAirportId.toString())); + OperationalBase endAirport = null; + if (endAirportIdOrNull != null) { + endAirport = operationalBaseRepository.findById(endAirportIdOrNull) + .orElseThrow(() -> new OperationalBaseNotFoundException(endAirportIdOrNull.toString())); + } + + if (!flightRepository.findOverlappingByAircraft(aircraftId, startDateTime, endDateTime).isEmpty()) { + throw new FlightTimeConflictException("Aircraft already has a flight in this time window"); + } + if (!flightRepository.findOverlappingByPilot(pilotId, startDateTime, endDateTime).isEmpty()) { + throw new FlightTimeConflictException("Pilot already has a flight in this time window"); + } + + OffsetDateTime monthStart = startDateTime + .with(TemporalAdjusters.firstDayOfMonth()) + .toLocalDate() + .atStartOfDay() + .atOffset(startDateTime.getOffset()); + OffsetDateTime monthEnd = monthStart.plusMonths(1); + List contextFlights = flightRepository.findByPilotInPeriod(pilotId, monthStart, monthEnd); + List ranges = contextFlights.stream() + .map(f -> new RuleValidator.TimeRange(f.getFlightStart(), f.getFlightEnd())) + .toList(); + List rules = ruleRepository.findAll(); + List violations = ruleValidator.validate(pilotId, startDateTime, endDateTime, rules, ranges); + if (!violations.isEmpty()) { + throw new RuleViolationException(violations); + } + + Flight flight = new Flight( + UUID.randomUUID(), + aircraft, + pilot, + startDateTime, + endDateTime, + startAirport, + endAirport, + descriptionOrNull + ); + return flightRepository.save(flight); + } +} diff --git a/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/application/flight/GetAvailableAircraftUseCase.java b/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/application/flight/GetAvailableAircraftUseCase.java new file mode 100644 index 0000000..f92ce21 --- /dev/null +++ b/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/application/flight/GetAvailableAircraftUseCase.java @@ -0,0 +1,28 @@ +package pl.skyroster.skyroster_backend.application.flight; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import pl.skyroster.skyroster_backend.domain.model.Aircraft; +import pl.skyroster.skyroster_backend.domain.port.AircraftRepository; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.UUID; + +@Service +public class GetAvailableAircraftUseCase { + + private final AircraftRepository aircraftRepository; + + public GetAvailableAircraftUseCase(AircraftRepository aircraftRepository) { + this.aircraftRepository = aircraftRepository; + } + + @Transactional(readOnly = true) + public List execute(OffsetDateTime from, OffsetDateTime to, UUID baseIdOrNull) { + if (from == null || to == null || !from.isBefore(to)) { + throw new IllegalArgumentException("from must be before to and both required"); + } + return aircraftRepository.findAvailable(from, to, baseIdOrNull); + } +} diff --git a/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/application/flight/GetAvailablePilotsUseCase.java b/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/application/flight/GetAvailablePilotsUseCase.java new file mode 100644 index 0000000..0de0858 --- /dev/null +++ b/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/application/flight/GetAvailablePilotsUseCase.java @@ -0,0 +1,28 @@ +package pl.skyroster.skyroster_backend.application.flight; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import pl.skyroster.skyroster_backend.domain.model.Pilot; +import pl.skyroster.skyroster_backend.domain.port.PilotRepository; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.UUID; + +@Service +public class GetAvailablePilotsUseCase { + + private final PilotRepository pilotRepository; + + public GetAvailablePilotsUseCase(PilotRepository pilotRepository) { + this.pilotRepository = pilotRepository; + } + + @Transactional(readOnly = true) + public List execute(OffsetDateTime from, OffsetDateTime to, UUID aircraftTypeIdOrNull) { + if (from == null || to == null || !from.isBefore(to)) { + throw new IllegalArgumentException("from must be before to and both required"); + } + return pilotRepository.findAvailable(from, to, aircraftTypeIdOrNull); + } +} diff --git a/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/application/flight/GetFlightsUseCase.java b/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/application/flight/GetFlightsUseCase.java index 9037025..cc2531c 100644 --- a/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/application/flight/GetFlightsUseCase.java +++ b/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/application/flight/GetFlightsUseCase.java @@ -4,8 +4,7 @@ import org.springframework.transaction.annotation.Transactional; import pl.skyroster.skyroster_backend.domain.port.FlightRepository; import pl.skyroster.skyroster_backend.generated.model.FlightResponse; -import pl.skyroster.skyroster_backend.infrastructure.mappers.AircraftResponseMapper; -import pl.skyroster.skyroster_backend.infrastructure.mappers.OperationalBaseInfoMapper; +import pl.skyroster.skyroster_backend.infrastructure.mappers.FlightResponseMapper; import java.util.List; @@ -19,18 +18,8 @@ public GetFlightsUseCase(FlightRepository flightRepository) { @Transactional(readOnly = true) public List execute() { - return flightRepository.findAll().stream().map(flight -> { - var aircraftResponse = AircraftResponseMapper.map(flight.getAircraft()); - - return new FlightResponse( - flight.getId(), - aircraftResponse, - flight.getFlightStart(), - flight.getFlightEnd(), - OperationalBaseInfoMapper.map(flight.getStartAirport()), - OperationalBaseInfoMapper.map(flight.getEndAirport()), - flight.getDescription() - ); - }).toList(); + return flightRepository.findAll().stream() + .map(FlightResponseMapper::map) + .toList(); } } diff --git a/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/domain/model/Flight.java b/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/domain/model/Flight.java index 5f9616d..4f0876a 100644 --- a/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/domain/model/Flight.java +++ b/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/domain/model/Flight.java @@ -1,11 +1,17 @@ package pl.skyroster.skyroster_backend.domain.model; import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.NoArgsConstructor; +import lombok.Setter; import java.time.OffsetDateTime; import java.util.UUID; @Entity +@NoArgsConstructor +@AllArgsConstructor +@Setter public class Flight { @Id diff --git a/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/infrastructure/adapter/in/web/AircraftController.java b/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/infrastructure/adapter/in/web/AircraftController.java index 92f3f01..cca8f44 100644 --- a/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/infrastructure/adapter/in/web/AircraftController.java +++ b/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/infrastructure/adapter/in/web/AircraftController.java @@ -8,11 +8,13 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import pl.skyroster.skyroster_backend.application.aircraft.AddAircraftUseCase; import pl.skyroster.skyroster_backend.application.aircraft.DeleteAircraftUseCase; import pl.skyroster.skyroster_backend.application.aircraft.GetAircraftUseCase; import pl.skyroster.skyroster_backend.application.aircraft.UpdateAircraftUseCase; +import pl.skyroster.skyroster_backend.application.flight.GetAvailableAircraftUseCase; import pl.skyroster.skyroster_backend.domain.model.Aircraft; import pl.skyroster.skyroster_backend.generated.api.ApiApi; import pl.skyroster.skyroster_backend.generated.model.AddAircraftRequest; @@ -20,7 +22,9 @@ import pl.skyroster.skyroster_backend.generated.model.AircraftTypeInfo; import pl.skyroster.skyroster_backend.generated.model.OperationalBaseInfo; import pl.skyroster.skyroster_backend.generated.model.UpdateAircraftRequest; +import pl.skyroster.skyroster_backend.infrastructure.mappers.AircraftResponseMapper; +import java.time.OffsetDateTime; import java.util.List; import java.util.UUID; @@ -31,15 +35,29 @@ public class AircraftController { private final GetAircraftUseCase getAircraftUseCase; private final UpdateAircraftUseCase updateAircraftUseCase; private final DeleteAircraftUseCase deleteAircraftUseCase; + private final GetAvailableAircraftUseCase getAvailableAircraftUseCase; public AircraftController(AddAircraftUseCase addAircraftUseCase, GetAircraftUseCase getAircraftUseCase, UpdateAircraftUseCase updateAircraftUseCase, - DeleteAircraftUseCase deleteAircraftUseCase) { + DeleteAircraftUseCase deleteAircraftUseCase, + GetAvailableAircraftUseCase getAvailableAircraftUseCase) { this.addAircraftUseCase = addAircraftUseCase; this.getAircraftUseCase = getAircraftUseCase; this.updateAircraftUseCase = updateAircraftUseCase; this.deleteAircraftUseCase = deleteAircraftUseCase; + this.getAvailableAircraftUseCase = getAvailableAircraftUseCase; + } + + @GetMapping(ApiApi.PATH_GET_AVAILABLE_AIRCRAFT) + public ResponseEntity> getAvailableAircraft( + @RequestParam("from") OffsetDateTime from, + @RequestParam("to") OffsetDateTime to, + @RequestParam(value = "baseId", required = false) UUID baseId) { + List response = getAvailableAircraftUseCase.execute(from, to, baseId).stream() + .map(AircraftResponseMapper::map) + .toList(); + return ResponseEntity.ok(response); } @PostMapping(ApiApi.PATH_ADD_AIRCRAFT) diff --git a/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/infrastructure/adapter/in/web/FlightController.java b/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/infrastructure/adapter/in/web/FlightController.java index c653650..e12a781 100644 --- a/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/infrastructure/adapter/in/web/FlightController.java +++ b/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/infrastructure/adapter/in/web/FlightController.java @@ -1,24 +1,48 @@ package pl.skyroster.skyroster_backend.infrastructure.adapter.in.web; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; +import pl.skyroster.skyroster_backend.application.flight.CreateFlightUseCase; import pl.skyroster.skyroster_backend.application.flight.GetFlightsUseCase; +import pl.skyroster.skyroster_backend.domain.model.Flight; +import pl.skyroster.skyroster_backend.generated.api.ApiApi; +import pl.skyroster.skyroster_backend.generated.model.CreateFlightRequest; import pl.skyroster.skyroster_backend.generated.model.FlightResponse; +import pl.skyroster.skyroster_backend.infrastructure.mappers.FlightResponseMapper; import java.util.List; @RestController -@RequestMapping("/api/flights") public class FlightController { + private final GetFlightsUseCase getFlightsUseCase; + private final CreateFlightUseCase createFlightUseCase; - public FlightController(GetFlightsUseCase getFlightsUseCase) { + public FlightController(GetFlightsUseCase getFlightsUseCase, CreateFlightUseCase createFlightUseCase) { this.getFlightsUseCase = getFlightsUseCase; + this.createFlightUseCase = createFlightUseCase; } - @GetMapping + @GetMapping(ApiApi.PATH_DISPLAY_FLIGHTS) public List getFlights() { return getFlightsUseCase.execute(); } + + @PostMapping(ApiApi.PATH_CREATE_FLIGHT) + public ResponseEntity createFlight(@RequestBody CreateFlightRequest request) { + Flight flight = createFlightUseCase.execute( + request.getAircraftId(), + request.getPilotId(), + request.getStartDateTime(), + request.getEndDateTime(), + request.getStartAirportId(), + request.getEndAirportId(), + request.getDescription() + ); + return ResponseEntity.status(HttpStatus.CREATED).body(FlightResponseMapper.map(flight)); + } } diff --git a/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/infrastructure/adapter/in/web/PilotController.java b/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/infrastructure/adapter/in/web/PilotController.java index 9384efd..83d9faf 100644 --- a/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/infrastructure/adapter/in/web/PilotController.java +++ b/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/infrastructure/adapter/in/web/PilotController.java @@ -4,6 +4,7 @@ import org.jspecify.annotations.Nullable; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import pl.skyroster.skyroster_backend.application.flight.GetAvailablePilotsUseCase; import pl.skyroster.skyroster_backend.application.pilot.AddPilotUseCase; import pl.skyroster.skyroster_backend.application.pilot.DeletePilotUseCase; import pl.skyroster.skyroster_backend.application.pilot.GetPilotUseCase; @@ -13,7 +14,10 @@ import pl.skyroster.skyroster_backend.generated.model.PilotPatchRequest; import pl.skyroster.skyroster_backend.generated.model.PilotRequest; import pl.skyroster.skyroster_backend.generated.model.PilotResponse; +import pl.skyroster.skyroster_backend.infrastructure.mappers.PilotMapper; +import java.time.OffsetDateTime; +import java.util.List; import java.util.UUID; @RequiredArgsConstructor @@ -24,6 +28,18 @@ public class PilotController { private final DeletePilotUseCase deletePilotUseCase; private final PatchPilotUseCase patchPilotUseCase; private final AddPilotUseCase addPilotUseCase; + private final GetAvailablePilotsUseCase getAvailablePilotsUseCase; + + @GetMapping(ApiApi.PATH_GET_AVAILABLE_PILOTS) + public ResponseEntity> getAvailablePilots( + @RequestParam("from") OffsetDateTime from, + @RequestParam("to") OffsetDateTime to, + @RequestParam(value = "aircraftTypeId", required = false) UUID aircraftTypeId) { + List response = getAvailablePilotsUseCase.execute(from, to, aircraftTypeId).stream() + .map(PilotMapper::toResponse) + .toList(); + return ResponseEntity.ok(response); + } @GetMapping(ApiApi.PATH_GET_PILOTS) public ResponseEntity getPilots(@RequestParam Integer page, @RequestParam Integer size, @RequestParam @Nullable String sort) { diff --git a/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/infrastructure/mappers/FlightResponseMapper.java b/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/infrastructure/mappers/FlightResponseMapper.java new file mode 100644 index 0000000..65ead1a --- /dev/null +++ b/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/infrastructure/mappers/FlightResponseMapper.java @@ -0,0 +1,18 @@ +package pl.skyroster.skyroster_backend.infrastructure.mappers; + +import pl.skyroster.skyroster_backend.domain.model.Flight; +import pl.skyroster.skyroster_backend.generated.model.FlightResponse; + +public class FlightResponseMapper { + public static FlightResponse map(Flight flight) { + return new FlightResponse( + flight.getId(), + AircraftResponseMapper.map(flight.getAircraft()), + flight.getFlightStart(), + flight.getFlightEnd(), + OperationalBaseInfoMapper.map(flight.getStartAirport()), + flight.getEndAirport() != null ? OperationalBaseInfoMapper.map(flight.getEndAirport()) : null, + flight.getDescription() + ); + } +} From 53b4fc5312cdfac196fa9d47649901c58c554d09 Mon Sep 17 00:00:00 2001 From: Krywion Date: Sat, 16 May 2026 01:38:17 +0200 Subject: [PATCH 07/14] fix: return 404 for OperationalBaseNotFound (was 400) Per OpenAPI spec for POST /api/flights, missing startAirportId/endAirportId should respond 404 not 400. No other production caller exercises this handler, so the change is safe. --- .../infrastructure/config/GlobalExceptionHandler.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/infrastructure/config/GlobalExceptionHandler.java b/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/infrastructure/config/GlobalExceptionHandler.java index 12042cc..c3ff5a5 100644 --- a/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/infrastructure/config/GlobalExceptionHandler.java +++ b/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/infrastructure/config/GlobalExceptionHandler.java @@ -89,7 +89,7 @@ public ResponseEntity handlePilotAlreadyExists(PilotAlreadyExists @ExceptionHandler(OperationalBaseNotFoundException.class) public ResponseEntity handleOperationalBaseNotFound(OperationalBaseNotFoundException ex, HttpServletRequest request) { - return buildResponse(HttpStatus.BAD_REQUEST, ex.getMessage(), request); + return buildResponse(HttpStatus.NOT_FOUND, ex.getMessage(), request); } @ExceptionHandler(IllegalArgumentException.class) From 42b469c48366d2f9605a7eb15e96498273108c12 Mon Sep 17 00:00:00 2001 From: Krywion Date: Sat, 16 May 2026 01:47:50 +0200 Subject: [PATCH 08/14] test: add integration tests for availability and create flight endpoints --- .../AircraftAvailabilityIntegrationTest.java | 79 +++++++++ .../in/web/CreateFlightIntegrationTest.java | 156 ++++++++++++++++++ .../web/PilotAvailabilityIntegrationTest.java | 78 +++++++++ 3 files changed, 313 insertions(+) create mode 100644 skyroster-backend/src/test/java/pl/skyroster/skyroster_backend/infrastructure/adapter/in/web/AircraftAvailabilityIntegrationTest.java create mode 100644 skyroster-backend/src/test/java/pl/skyroster/skyroster_backend/infrastructure/adapter/in/web/CreateFlightIntegrationTest.java create mode 100644 skyroster-backend/src/test/java/pl/skyroster/skyroster_backend/infrastructure/adapter/in/web/PilotAvailabilityIntegrationTest.java diff --git a/skyroster-backend/src/test/java/pl/skyroster/skyroster_backend/infrastructure/adapter/in/web/AircraftAvailabilityIntegrationTest.java b/skyroster-backend/src/test/java/pl/skyroster/skyroster_backend/infrastructure/adapter/in/web/AircraftAvailabilityIntegrationTest.java new file mode 100644 index 0000000..26af35c --- /dev/null +++ b/skyroster-backend/src/test/java/pl/skyroster/skyroster_backend/infrastructure/adapter/in/web/AircraftAvailabilityIntegrationTest.java @@ -0,0 +1,79 @@ +package pl.skyroster.skyroster_backend.infrastructure.adapter.in.web; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import dasniko.testcontainers.keycloak.KeycloakContainer; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.context.annotation.Import; +import org.springframework.http.HttpHeaders; +import org.springframework.test.web.servlet.MockMvc; +import pl.skyroster.skyroster_backend.TestcontainersConfiguration; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; + +import static org.hamcrest.Matchers.greaterThanOrEqualTo; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@Import(TestcontainersConfiguration.class) +class AircraftAvailabilityIntegrationTest { + + @Autowired private MockMvc mockMvc; + @Autowired private KeycloakContainer keycloak; + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @Test + void availability_shouldReturn401_whenNoToken() throws Exception { + mockMvc.perform(get("/api/aircraft/availability") + .param("from", "2026-05-20T10:00:00+02:00") + .param("to", "2026-05-20T13:00:00+02:00")) + .andExpect(status().isUnauthorized()); + } + + @Test + void availability_shouldReturnAircraftWithoutOverlappingFlights() throws Exception { + String token = getToken("admin", "test1234"); + mockMvc.perform(get("/api/aircraft/availability") + .param("from", "2026-06-01T08:00:00+02:00") + .param("to", "2026-06-01T12:00:00+02:00") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$.length()").value(greaterThanOrEqualTo(1))); + } + + @Test + void availability_shouldExcludeAircraftWithConflictingSeededFlight() throws Exception { + String token = getToken("admin", "test1234"); + mockMvc.perform(get("/api/aircraft/availability") + .param("from", "2026-05-09T11:00:00+02:00") + .param("to", "2026-05-09T12:00:00+02:00") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[?(@.id == 'e0000000-0000-0000-0000-000000000001')]").doesNotExist()); + } + + private String getToken(String username, String password) throws Exception { + String tokenUrl = keycloak.getAuthServerUrl() + "/realms/skyroster/protocol/openid-connect/token"; + String body = "grant_type=password&client_id=skyroster-frontend&username=" + username + "&password=" + password; + HttpResponse response = HttpClient.newHttpClient().send( + HttpRequest.newBuilder() + .uri(URI.create(tokenUrl)) + .header("Content-Type", "application/x-www-form-urlencoded") + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build(), + HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() != 200) throw new RuntimeException("Token fetch failed: " + response.body()); + JsonNode json = MAPPER.readTree(response.body()); + return json.get("access_token").asText(); + } +} diff --git a/skyroster-backend/src/test/java/pl/skyroster/skyroster_backend/infrastructure/adapter/in/web/CreateFlightIntegrationTest.java b/skyroster-backend/src/test/java/pl/skyroster/skyroster_backend/infrastructure/adapter/in/web/CreateFlightIntegrationTest.java new file mode 100644 index 0000000..e523c9d --- /dev/null +++ b/skyroster-backend/src/test/java/pl/skyroster/skyroster_backend/infrastructure/adapter/in/web/CreateFlightIntegrationTest.java @@ -0,0 +1,156 @@ +package pl.skyroster.skyroster_backend.infrastructure.adapter.in.web; + +import com.fasterxml.jackson.databind.ObjectMapper; +import dasniko.testcontainers.keycloak.KeycloakContainer; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.context.annotation.Import; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import pl.skyroster.skyroster_backend.TestcontainersConfiguration; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; + +import static org.hamcrest.Matchers.greaterThanOrEqualTo; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@Import(TestcontainersConfiguration.class) +class CreateFlightIntegrationTest { + + @Autowired private MockMvc mockMvc; + @Autowired private KeycloakContainer keycloak; + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @Test + void createFlight_shouldReturn401_whenNoToken() throws Exception { + mockMvc.perform(post("/api/flights") + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isUnauthorized()); + } + + @Test + void createFlight_shouldReturn201_happyPath() throws Exception { + String token = getToken("admin", "test1234"); + String body = """ + { + "aircraftId": "e0000000-0000-0000-0000-000000000004", + "pilotId": "c0000000-0000-0000-0000-000000000004", + "startDateTime": "2026-09-01T08:00:00+02:00", + "endDateTime": "2026-09-01T10:00:00+02:00", + "startAirportId": "a0000000-0000-0000-0000-000000000001", + "endAirportId": "a0000000-0000-0000-0000-000000000002", + "description": "Test flight" + } + """; + mockMvc.perform(post("/api/flights") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").exists()) + .andExpect(jsonPath("$.aircraft.id").value("e0000000-0000-0000-0000-000000000004")); + } + + @Test + void createFlight_shouldReturn400_whenStartAfterEnd() throws Exception { + String token = getToken("admin", "test1234"); + String body = """ + { + "aircraftId": "e0000000-0000-0000-0000-000000000004", + "pilotId": "c0000000-0000-0000-0000-000000000004", + "startDateTime": "2026-09-01T10:00:00+02:00", + "endDateTime": "2026-09-01T08:00:00+02:00", + "startAirportId": "a0000000-0000-0000-0000-000000000001" + } + """; + mockMvc.perform(post("/api/flights") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isBadRequest()); + } + + @Test + void createFlight_shouldReturn404_whenStartAirportUnknown() throws Exception { + String token = getToken("admin", "test1234"); + String body = """ + { + "aircraftId": "e0000000-0000-0000-0000-000000000004", + "pilotId": "c0000000-0000-0000-0000-000000000004", + "startDateTime": "2026-09-02T08:00:00+02:00", + "endDateTime": "2026-09-02T10:00:00+02:00", + "startAirportId": "ffffffff-ffff-ffff-ffff-ffffffffffff" + } + """; + mockMvc.perform(post("/api/flights") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isNotFound()); + } + + @Test + void createFlight_shouldReturn409_whenAircraftConflict() throws Exception { + String token = getToken("admin", "test1234"); + String body = """ + { + "aircraftId": "e0000000-0000-0000-0000-000000000001", + "pilotId": "c0000000-0000-0000-0000-000000000004", + "startDateTime": "2026-05-09T11:00:00+02:00", + "endDateTime": "2026-05-09T12:00:00+02:00", + "startAirportId": "a0000000-0000-0000-0000-000000000001" + } + """; + mockMvc.perform(post("/api/flights") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isConflict()); + } + + @Test + void createFlight_shouldReturn422_whenRuleViolated() throws Exception { + String token = getToken("admin", "test1234"); + String body = """ + { + "aircraftId": "e0000000-0000-0000-0000-000000000004", + "pilotId": "c0000000-0000-0000-0000-000000000004", + "startDateTime": "2026-10-01T06:00:00+02:00", + "endDateTime": "2026-10-01T18:00:00+02:00", + "startAirportId": "a0000000-0000-0000-0000-000000000001" + } + """; + mockMvc.perform(post("/api/flights") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.violations").isArray()) + .andExpect(jsonPath("$.violations.length()").value(greaterThanOrEqualTo(1))); + } + + private String getToken(String username, String password) throws Exception { + String tokenUrl = keycloak.getAuthServerUrl() + "/realms/skyroster/protocol/openid-connect/token"; + String body = "grant_type=password&client_id=skyroster-frontend&username=" + username + "&password=" + password; + HttpResponse response = HttpClient.newHttpClient().send( + HttpRequest.newBuilder() + .uri(URI.create(tokenUrl)) + .header("Content-Type", "application/x-www-form-urlencoded") + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build(), + HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() != 200) throw new RuntimeException("Token fetch failed: " + response.body()); + return MAPPER.readTree(response.body()).get("access_token").asText(); + } +} diff --git a/skyroster-backend/src/test/java/pl/skyroster/skyroster_backend/infrastructure/adapter/in/web/PilotAvailabilityIntegrationTest.java b/skyroster-backend/src/test/java/pl/skyroster/skyroster_backend/infrastructure/adapter/in/web/PilotAvailabilityIntegrationTest.java new file mode 100644 index 0000000..f62bc6f --- /dev/null +++ b/skyroster-backend/src/test/java/pl/skyroster/skyroster_backend/infrastructure/adapter/in/web/PilotAvailabilityIntegrationTest.java @@ -0,0 +1,78 @@ +package pl.skyroster.skyroster_backend.infrastructure.adapter.in.web; + +import com.fasterxml.jackson.databind.ObjectMapper; +import dasniko.testcontainers.keycloak.KeycloakContainer; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.context.annotation.Import; +import org.springframework.http.HttpHeaders; +import org.springframework.test.web.servlet.MockMvc; +import pl.skyroster.skyroster_backend.TestcontainersConfiguration; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; + +import static org.hamcrest.Matchers.greaterThanOrEqualTo; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@Import(TestcontainersConfiguration.class) +class PilotAvailabilityIntegrationTest { + + @Autowired private MockMvc mockMvc; + @Autowired private KeycloakContainer keycloak; + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @Test + void availability_shouldReturn401_whenNoToken() throws Exception { + mockMvc.perform(get("/api/pilots/availability") + .param("from", "2026-06-01T08:00:00+02:00") + .param("to", "2026-06-01T12:00:00+02:00")) + .andExpect(status().isUnauthorized()); + } + + @Test + void availability_shouldReturnAllSeededPilots_whenNoConflict() throws Exception { + String token = getToken("admin", "test1234"); + mockMvc.perform(get("/api/pilots/availability") + .param("from", "2026-07-01T08:00:00+02:00") + .param("to", "2026-07-01T12:00:00+02:00") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$.length()").value(greaterThanOrEqualTo(4))); + } + + @Test + void availability_shouldFilterByAircraftType() throws Exception { + String token = getToken("admin", "test1234"); + mockMvc.perform(get("/api/pilots/availability") + .param("from", "2026-07-01T08:00:00+02:00") + .param("to", "2026-07-01T12:00:00+02:00") + .param("aircraftTypeId", "b0000000-0000-0000-0000-000000000001") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[?(@.id == 'c0000000-0000-0000-0000-000000000003')]").doesNotExist()); + } + + private String getToken(String username, String password) throws Exception { + String tokenUrl = keycloak.getAuthServerUrl() + "/realms/skyroster/protocol/openid-connect/token"; + String body = "grant_type=password&client_id=skyroster-frontend&username=" + username + "&password=" + password; + HttpResponse response = HttpClient.newHttpClient().send( + HttpRequest.newBuilder() + .uri(URI.create(tokenUrl)) + .header("Content-Type", "application/x-www-form-urlencoded") + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build(), + HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() != 200) throw new RuntimeException("Token fetch failed: " + response.body()); + return MAPPER.readTree(response.body()).get("access_token").asText(); + } +} From 89bd9784f68f1e347c0562be992b43cb491d1181 Mon Sep 17 00:00:00 2001 From: Krywion Date: Sat, 16 May 2026 02:02:38 +0200 Subject: [PATCH 09/14] test: update AircraftIntegrationTest to expect 404 for missing operational base Follow-up to handler change: OperationalBaseNotFoundException now returns 404. --- .../adapter/in/web/AircraftIntegrationTest.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/skyroster-backend/src/test/java/pl/skyroster/skyroster_backend/infrastructure/adapter/in/web/AircraftIntegrationTest.java b/skyroster-backend/src/test/java/pl/skyroster/skyroster_backend/infrastructure/adapter/in/web/AircraftIntegrationTest.java index 8317d34..bb78491 100644 --- a/skyroster-backend/src/test/java/pl/skyroster/skyroster_backend/infrastructure/adapter/in/web/AircraftIntegrationTest.java +++ b/skyroster-backend/src/test/java/pl/skyroster/skyroster_backend/infrastructure/adapter/in/web/AircraftIntegrationTest.java @@ -107,7 +107,7 @@ void addAircraft_shouldReturn400_whenAircraftTypeNotFound() throws Exception { } @Test - void addAircraft_shouldReturn400_whenOperationalBaseNotFound() throws Exception { + void addAircraft_shouldReturn404_whenOperationalBaseNotFound() throws Exception { String token = getToken("admin", "test1234"); String body = """ { @@ -121,8 +121,8 @@ void addAircraft_shouldReturn400_whenOperationalBaseNotFound() throws Exception .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) .contentType(MediaType.APPLICATION_JSON) .content(body)) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.status").value(400)); + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.status").value(404)); } @Test @@ -292,7 +292,7 @@ void updateAircraft_shouldReturn400_whenAircraftTypeNotFound() throws Exception } @Test - void updateAircraft_shouldReturn400_whenOperationalBaseNotFound() throws Exception { + void updateAircraft_shouldReturn404_whenOperationalBaseNotFound() throws Exception { String token = getToken("admin", "test1234"); UUID aircraftId = createAircraft(token, "SP-BAS", "B738", "EPWA"); @@ -307,7 +307,7 @@ void updateAircraft_shouldReturn400_whenOperationalBaseNotFound() throws Excepti .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) .contentType(MediaType.APPLICATION_JSON) .content(body)) - .andExpect(status().isBadRequest()); + .andExpect(status().isNotFound()); } @Test From a2da17fbc32196b85b88ae579ecd422c1cbb4682 Mon Sep 17 00:00:00 2001 From: Krywion Date: Sat, 16 May 2026 02:04:53 +0200 Subject: [PATCH 10/14] feat: add stable UUIDs to BASES matching backend operational_bases seed --- skyroster-frontend/src/data/mockData.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/skyroster-frontend/src/data/mockData.js b/skyroster-frontend/src/data/mockData.js index 2ed14ec..1d54ef5 100644 --- a/skyroster-frontend/src/data/mockData.js +++ b/skyroster-frontend/src/data/mockData.js @@ -17,11 +17,11 @@ export const AIRCRAFT_TYPES = [ ] export const BASES = [ - { label: 'Warszawa (EPWA)', value: 'EPWA' }, - { label: 'Kraków (EPKK)', value: 'EPKK' }, - { label: 'Gdańsk (EPGD)', value: 'EPGD' }, - { label: 'Wrocław (EPWR)', value: 'EPWR' }, - { label: 'Poznań (EPPO)', value: 'EPPO' } + { id: 'a0000000-0000-0000-0000-000000000001', label: 'Warszawa (EPWA)', icaoCode: 'EPWA', value: 'EPWA' }, + { id: 'a0000000-0000-0000-0000-000000000002', label: 'Kraków (EPKK)', icaoCode: 'EPKK', value: 'EPKK' }, + { id: 'a0000000-0000-0000-0000-000000000003', label: 'Gdańsk (EPGD)', icaoCode: 'EPGD', value: 'EPGD' }, + { id: 'a0000000-0000-0000-0000-000000000004', label: 'Wrocław (EPWR)', icaoCode: 'EPWR', value: 'EPWR' }, + { id: 'a0000000-0000-0000-0000-000000000005', label: 'Poznań (EPPO)', icaoCode: 'EPPO', value: 'EPPO' } ] export const AVAILABILITY_STATUSES = [ From 1f94eed1cf2bbde422cd6af9733288f10d432944 Mon Sep 17 00:00:00 2001 From: Krywion Date: Sat, 16 May 2026 02:04:55 +0200 Subject: [PATCH 11/14] feat: wire frontend stores to backend API (load + availability + create flight) --- skyroster-frontend/src/stores/aircraft.js | 37 ++++++++++++++- skyroster-frontend/src/stores/flights.js | 56 ++++++++++++++++++----- skyroster-frontend/src/stores/pilots.js | 36 ++++++++++++++- 3 files changed, 115 insertions(+), 14 deletions(-) diff --git a/skyroster-frontend/src/stores/aircraft.js b/skyroster-frontend/src/stores/aircraft.js index a38496b..4ad1bd7 100644 --- a/skyroster-frontend/src/stores/aircraft.js +++ b/skyroster-frontend/src/stores/aircraft.js @@ -1,6 +1,7 @@ import { ref, computed } from 'vue' import { defineStore } from 'pinia' import { initialAircraft, AIRCRAFT_TYPES, BASES, AVAILABILITY_STATUSES } from '../data/mockData' +import apiClient from '../api/axios' export const useAircraftStore = defineStore('aircraft', () => { const aircraft = ref([...initialAircraft]) @@ -62,6 +63,38 @@ export const useAircraftStore = defineStore('aircraft', () => { return availabilityOptions.find(s => s.value === status) } + async function loadAircraft() { + try { + const { data } = await apiClient.get('/aircraft') + aircraft.value = data.map(a => ({ + id: a.id, + rejestracja: a.registrationNumber, + typ: a.aircraftType.icaoCode, + typId: a.aircraftType.id, + baza: a.operationalBase.icaoCode, + bazaId: a.operationalBase.id, + dostepnosc: 'dostepny' + })) + return true + } catch (e) { + console.error('loadAircraft failed', e) + return false + } + } + + async function loadAvailableAircraft({ from, to, baseId }) { + const params = { from, to } + if (baseId) params.baseId = baseId + const { data } = await apiClient.get('/aircraft/availability', { params }) + return data.map(a => ({ + id: a.id, + rejestracja: a.registrationNumber, + typ: a.aircraftType.icaoCode, + typId: a.aircraftType.id, + baza: a.operationalBase.icaoCode + })) + } + return { aircraft, aircraftCount, @@ -74,6 +107,8 @@ export const useAircraftStore = defineStore('aircraft', () => { deleteAircraft, getAircraftById, getAircraftDisplay, - getAvailabilityInfo + getAvailabilityInfo, + loadAircraft, + loadAvailableAircraft } }) diff --git a/skyroster-frontend/src/stores/flights.js b/skyroster-frontend/src/stores/flights.js index e8d8ee1..58ca373 100644 --- a/skyroster-frontend/src/stores/flights.js +++ b/skyroster-frontend/src/stores/flights.js @@ -11,21 +11,52 @@ export const useFlightsStore = defineStore('flights', () => { const flightsCount = computed(() => flights.value.length) - function generateId() { - const maxId = flights.value.reduce((max, flight) => { - const num = parseInt(flight.id.replace('F', '')) - return num > max ? num : max - }, 0) - return `F${String(maxId + 1).padStart(3, '0')}` + async function addFlight(flightData) { + try { + const { data } = await apiClient.post('/flights', flightData) + const mapped = { + id: data.id, + aircraftId: data.aircraft?.id, + pilotId: data.pilot?.id ?? null, + start: data.flightStart, + end: data.flightEnd, + text: data.startAirport && data.endAirport + ? `${data.startAirport.icaoCode} → ${data.endAirport.icaoCode}` + : '', + description: data.description + } + flights.value.push(mapped) + return { ok: true, flight: mapped } + } catch (e) { + if (e.response?.status === 422) { + return { ok: false, status: 422, violations: e.response.data.violations ?? [] } + } + if (e.response?.status === 409) { + return { ok: false, status: 409, message: e.response.data?.message ?? 'Konflikt zasobu' } + } + return { ok: false, status: e.response?.status ?? 500, message: e.response?.data?.message ?? e.message } + } } - function addFlight(flightData) { - const newFlight = { - ...flightData, - id: generateId() + async function loadFlights() { + try { + const { data } = await apiClient.get('/flights') + flights.value = data.map(f => ({ + id: f.id, + aircraftId: f.aircraft?.id, + pilotId: f.pilot?.id ?? null, + start: f.flightStart, + end: f.flightEnd, + text: f.startAirport && f.endAirport + ? `${f.startAirport.icaoCode} → ${f.endAirport.icaoCode}` + : '', + description: f.description + })) + return true + } catch (e) { + console.error('loadFlights failed', e) + return false } - flights.value.push(newFlight) - return newFlight } function updateFlight(id, flightData) { @@ -189,6 +220,7 @@ export const useFlightsStore = defineStore('flights', () => { updateFlight, deleteFlight, loadPlanningSchedules, + loadFlights, getFlightById, getSchedulerEvents, getSchedulerResources diff --git a/skyroster-frontend/src/stores/pilots.js b/skyroster-frontend/src/stores/pilots.js index 5ba22d8..5a5d49d 100644 --- a/skyroster-frontend/src/stores/pilots.js +++ b/skyroster-frontend/src/stores/pilots.js @@ -1,6 +1,7 @@ import { ref, computed } from 'vue' import { defineStore } from 'pinia' import { initialPilots, QUALIFICATIONS, AIRCRAFT_TYPES, BASES } from '../data/mockData' +import apiClient from '../api/axios' export const usePilotsStore = defineStore('pilots', () => { const pilots = ref([...initialPilots]) @@ -54,6 +55,37 @@ export const usePilotsStore = defineStore('pilots', () => { return pilot ? `${pilot.imie} ${pilot.nazwisko}` : '' } + async function loadPilots() { + try { + const { data } = await apiClient.get('/pilots', { params: { page: 0, size: 100 } }) + const items = data.content ?? data ?? [] + pilots.value = items.map(mapApiPilot) + return true + } catch (e) { + console.error('loadPilots failed', e) + return false + } + } + + async function loadAvailablePilots({ from, to, aircraftTypeId }) { + const params = { from, to } + if (aircraftTypeId) params.aircraftTypeId = aircraftTypeId + const { data } = await apiClient.get('/pilots/availability', { params }) + return data.map(mapApiPilot) + } + + function mapApiPilot(p) { + return { + id: p.id, + imie: p.firstName ?? p.name ?? p.imie, + nazwisko: p.surname ?? p.nazwisko, + licencja: p.licence ?? p.licencja, + kwalifikacje: (p.qualifications ?? p.kwalifikacje ?? []).map(q => q.value ?? q), + typySamolotow: (p.aircraftTypes ?? p.typySamolotow ?? []).map(t => t.icaoCode ?? t), + bazaMacierzysta: p.homeBase?.icaoCode ?? p.operationalBase?.icaoCode ?? p.bazaMacierzysta ?? null + } + } + return { pilots, pilotsCount, @@ -64,6 +96,8 @@ export const usePilotsStore = defineStore('pilots', () => { updatePilot, deletePilot, getPilotById, - getPilotFullName + getPilotFullName, + loadPilots, + loadAvailablePilots } }) From 21d9703958d565456740d7a313ae42282579a221 Mon Sep 17 00:00:00 2001 From: Krywion Date: Sat, 16 May 2026 02:05:59 +0200 Subject: [PATCH 12/14] feat: add FlightWizardDialog with sequential steps and rule violation display --- .../scheduler/FlightWizardDialog.vue | 262 ++++++++++++++++++ 1 file changed, 262 insertions(+) create mode 100644 skyroster-frontend/src/components/scheduler/FlightWizardDialog.vue diff --git a/skyroster-frontend/src/components/scheduler/FlightWizardDialog.vue b/skyroster-frontend/src/components/scheduler/FlightWizardDialog.vue new file mode 100644 index 0000000..b1dc2b0 --- /dev/null +++ b/skyroster-frontend/src/components/scheduler/FlightWizardDialog.vue @@ -0,0 +1,262 @@ + + + + + From b340df9e5b0a7040d1e458b2fe150d23ff18496d Mon Sep 17 00:00:00 2001 From: Krywion Date: Sat, 16 May 2026 02:06:28 +0200 Subject: [PATCH 13/14] feat: open FlightWizardDialog on 'Dodaj lot' click, keep flat dialog for edit --- .../src/views/SchedulerView.vue | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/skyroster-frontend/src/views/SchedulerView.vue b/skyroster-frontend/src/views/SchedulerView.vue index 4667fd2..fedde62 100644 --- a/skyroster-frontend/src/views/SchedulerView.vue +++ b/skyroster-frontend/src/views/SchedulerView.vue @@ -11,6 +11,7 @@ import InputText from 'primevue/inputtext' import Select from 'primevue/select' import DatePicker from 'primevue/datepicker' import FlightScheduler from '../components/scheduler/FlightScheduler.vue' +import FlightWizardDialog from '../components/scheduler/FlightWizardDialog.vue' import { BASES } from '../data/mockData' // Add BASES import const flightsStore = useFlightsStore() @@ -20,15 +21,26 @@ const toast = useToast() const schedulerRef = ref(null) const dialogVisible = ref(false) +const wizardVisible = ref(false) const isEditMode = ref(false) const selectedFlight = ref(null) const flightForm = ref(getEmptyForm()) -onMounted(() => { - flightsStore.loadPlanningSchedules() +onMounted(async () => { + await Promise.all([ + aircraftStore.loadAircraft(), + pilotsStore.loadPilots(), + flightsStore.loadFlights(), + flightsStore.loadPlanningSchedules() + ]) }) +async function onFlightCreated() { + toast.add({ severity: 'success', summary: 'Sukces', detail: 'Lot został utworzony', life: 3000 }) + await flightsStore.loadFlights() +} + const baseOptions = BASES.map(b => ({ label: `${b.value} - ${b.label}`, value: b.value @@ -138,10 +150,7 @@ function handleTimeRangeSelected(selection) { } function openAddDialog() { - selectedFlight.value = null - isEditMode.value = false - flightForm.value = getEmptyForm() - dialogVisible.value = true + wizardVisible.value = true } function saveFlight() { @@ -332,6 +341,8 @@ function navigateNext() { + + From 1f4cf2894d7c3bf412ca5d0eaef7ea284b4064c3 Mon Sep 17 00:00:00 2001 From: Maksymilian Krywionek Date: Sat, 16 May 2026 15:09:33 +0200 Subject: [PATCH 14/14] fixes --- skyroster-frontend/src/stores/aircraft.js | 2 +- skyroster-frontend/src/views/SchedulerView.vue | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/skyroster-frontend/src/stores/aircraft.js b/skyroster-frontend/src/stores/aircraft.js index fcee19c..9938c35 100644 --- a/skyroster-frontend/src/stores/aircraft.js +++ b/skyroster-frontend/src/stores/aircraft.js @@ -1,7 +1,7 @@ import {computed, ref} from 'vue' import {defineStore} from 'pinia' import apiClient from '../api/axios' -import {initialAircraft, AIRCRAFT_TYPES, AVAILABILITY_STATUSES, BASES} from '../data/mockData' +import {AIRCRAFT_TYPES, AVAILABILITY_STATUSES, BASES} from '../data/mockData' async function getAircraftList() { diff --git a/skyroster-frontend/src/views/SchedulerView.vue b/skyroster-frontend/src/views/SchedulerView.vue index 817696c..85274bf 100644 --- a/skyroster-frontend/src/views/SchedulerView.vue +++ b/skyroster-frontend/src/views/SchedulerView.vue @@ -35,6 +35,7 @@ onMounted(async () => { flightsStore.loadFlights(), flightsStore.loadPlanningSchedules() ]) +}) async function onFlightCreated() { toast.add({ severity: 'success', summary: 'Sukces', detail: 'Lot został utworzony', life: 3000 })