From 4b6d3eab28ea01a20e79770fbe7287103f2e4465 Mon Sep 17 00:00:00 2001 From: kudukm Date: Fri, 19 Jun 2026 07:53:52 +0200 Subject: [PATCH 1/6] (SKY-57) implement `PUT /api/flights/{id}` --- .../flight/UpdateFlightUseCase.java | 112 ++++++++++++++++++ .../domain/port/FlightRepository.java | 6 + .../adapter/in/web/FlightController.java | 22 +++- .../config/security/SecurityConfig.java | 4 +- .../src/main/resources/api/openapi.yaml | 54 +++++++++ 5 files changed, 194 insertions(+), 4 deletions(-) create mode 100644 skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/application/flight/UpdateFlightUseCase.java diff --git a/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/application/flight/UpdateFlightUseCase.java b/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/application/flight/UpdateFlightUseCase.java new file mode 100644 index 0000000..71a5906 --- /dev/null +++ b/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/application/flight/UpdateFlightUseCase.java @@ -0,0 +1,112 @@ +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.*; +import pl.skyroster.skyroster_backend.domain.model.*; +import pl.skyroster.skyroster_backend.domain.port.*; +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 UpdateFlightUseCase { + + 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 UpdateFlightUseCase(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 id, 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"); + } + + Flight oldFlight = flightRepository.findById(id) + .orElseThrow(FlightNotFoundException::new); + 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())); + } + + boolean aircraftConflict = flightRepository + .findOverlappingByAircraft(aircraftId, startDateTime, endDateTime) + .stream() + .anyMatch(f -> !f.getId().equals(id)); // skip the finding if it is the updated flight + + if (aircraftConflict) { + throw new FlightTimeConflictException( + "Aircraft already has a flight in this time window"); + } + + boolean pilotConflict = flightRepository + .findOverlappingByPilot(pilotId, startDateTime, endDateTime) + .stream() + .anyMatch(f -> !f.getId().equals(id)); // skip the finding if it is the updated flight + + if (pilotConflict) { + 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) + .stream() + .filter(f -> !f.getId().equals(id)) // skip the finding if it is the updated flight + .toList(); + 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( + id, + aircraft, + pilot, + startDateTime, + endDateTime, + startAirport, + endAirport, + descriptionOrNull + ); + return flightRepository.save(flight); + } +} 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 0bc384f..ebb154d 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 @@ -68,4 +68,10 @@ Optional findLastByPilotBefore(@Param("pilotId") UUID pilotId, """) Optional findFirstByPilotAfter(@Param("pilotId") UUID pilotId, @Param("after") OffsetDateTime after); + + @Query(""" + SELECT f FROM Flight f + WHERE f.id = :id + """) + Optional findById(@Param("id") UUID id); } 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 da3cdb9..6877b09 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 @@ -6,6 +6,7 @@ import pl.skyroster.skyroster_backend.application.flight.CreateFlightUseCase; import pl.skyroster.skyroster_backend.application.flight.DeleteFlightUseCase; import pl.skyroster.skyroster_backend.application.flight.GetFlightsUseCase; +import pl.skyroster.skyroster_backend.application.flight.UpdateFlightUseCase; import pl.skyroster.skyroster_backend.domain.model.Flight; import pl.skyroster.skyroster_backend.generated.api.ApiApi; import pl.skyroster.skyroster_backend.generated.model.CreateFlightRequest; @@ -18,14 +19,16 @@ @RestController public class FlightController { - private final GetFlightsUseCase getFlightsUseCase; private final CreateFlightUseCase createFlightUseCase; + private final GetFlightsUseCase getFlightsUseCase; + private final UpdateFlightUseCase updateFlightUseCase; private final DeleteFlightUseCase deleteFlightUseCase; - public FlightController(GetFlightsUseCase getFlightsUseCase, CreateFlightUseCase createFlightUseCase, DeleteFlightUseCase deleteFlightUseCase) { + public FlightController(GetFlightsUseCase getFlightsUseCase, CreateFlightUseCase createFlightUseCase, DeleteFlightUseCase deleteFlightUseCase, UpdateFlightUseCase updateFlightUseCase) { this.getFlightsUseCase = getFlightsUseCase; this.createFlightUseCase = createFlightUseCase; this.deleteFlightUseCase = deleteFlightUseCase; + this.updateFlightUseCase = updateFlightUseCase; } @GetMapping(ApiApi.PATH_DISPLAY_FLIGHTS) @@ -47,6 +50,21 @@ public ResponseEntity createFlight(@RequestBody CreateFlightRequ return ResponseEntity.status(HttpStatus.CREATED).body(FlightResponseMapper.map(flight)); } + @PutMapping(ApiApi.PATH_UPDATE_FLIGHT) + public ResponseEntity updateFlight(@PathVariable("id") UUID id, @RequestBody CreateFlightRequest request) { + Flight flight = updateFlightUseCase.execute( + id, + request.getAircraftId(), + request.getPilotId(), + request.getStartDateTime(), + request.getEndDateTime(), + request.getStartAirportId(), + request.getEndAirportId(), + request.getDescription() + ); + return ResponseEntity.ok(FlightResponseMapper.map(flight)); + } + @DeleteMapping(ApiApi.PATH_DELETE_FLIGHT) public ResponseEntity deleteAircraft(@PathVariable("id") UUID id) { deleteFlightUseCase.execute(id); diff --git a/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/infrastructure/config/security/SecurityConfig.java b/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/infrastructure/config/security/SecurityConfig.java index eedc941..7cebcea 100644 --- a/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/infrastructure/config/security/SecurityConfig.java +++ b/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/infrastructure/config/security/SecurityConfig.java @@ -1,5 +1,6 @@ package pl.skyroster.skyroster_backend.infrastructure.config.security; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; @@ -12,8 +13,6 @@ import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; -import org.springframework.beans.factory.annotation.Value; - import java.util.List; @Configuration @@ -47,6 +46,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) { .requestMatchers("/api/compliance/**").hasRole("compliance_officer") .requestMatchers("/api/planning/**").hasRole("schedule_planner") .requestMatchers(HttpMethod.DELETE,"/api/flights/**").hasRole("schedule_planner") + .requestMatchers(HttpMethod.PUT, "/api/flights/**").hasRole("schedule_planner") .requestMatchers(HttpMethod.POST, "/api/aircraft/**").hasRole("operations_administrator") .requestMatchers(HttpMethod.GET, "/api/aircraft/**").authenticated() .requestMatchers(HttpMethod.DELETE, "/api/pilots/**").hasRole("operations_administrator") diff --git a/skyroster-backend/src/main/resources/api/openapi.yaml b/skyroster-backend/src/main/resources/api/openapi.yaml index c613fb6..429a65f 100644 --- a/skyroster-backend/src/main/resources/api/openapi.yaml +++ b/skyroster-backend/src/main/resources/api/openapi.yaml @@ -476,6 +476,60 @@ paths: $ref: '#/components/schemas/RuleViolationErrorResponse' /api/flights/{id}: + put: + operationId: updateFlight + tags: [ Flights ] + summary: Update an existing flight (with rule validation) + parameters: + - in: path + name: id + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateFlightRequest' + responses: + '200': + description: Flight updated + 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' delete: operationId: deleteFlight tags: [ Flights ] From 6d09a6a3bba6db4f83653aedf8000e3434fdc307 Mon Sep 17 00:00:00 2001 From: kudukm Date: Fri, 19 Jun 2026 10:14:36 +0200 Subject: [PATCH 2/6] (SKY-58) implement `updateFlight()` and `deleteFlight()` functions and refactor `addFlight()` in `flights.js` --- skyroster-frontend/src/stores/flights.js | 132 +++++++++++++++++------ 1 file changed, 98 insertions(+), 34 deletions(-) diff --git a/skyroster-frontend/src/stores/flights.js b/skyroster-frontend/src/stores/flights.js index 58ca373..579a321 100644 --- a/skyroster-frontend/src/stores/flights.js +++ b/skyroster-frontend/src/stores/flights.js @@ -1,9 +1,9 @@ -import { ref, computed } from 'vue' -import { defineStore } from 'pinia' -import { initialFlights } from '../data/mockData' +import {computed, ref} from 'vue' +import {defineStore} from 'pinia' +import {initialFlights} from '../data/mockData' import apiClient from '../api/axios' -import { useAircraftStore } from './aircraft' -import { usePilotsStore } from './pilots' +import {useAircraftStore} from './aircraft' +import {usePilotsStore} from './pilots' export const useFlightsStore = defineStore('flights', () => { const flights = ref([...initialFlights]) @@ -11,30 +11,42 @@ export const useFlightsStore = defineStore('flights', () => { const flightsCount = computed(() => flights.value.length) + const parseFlightResponse = (data) => ({ + 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 + }) + + function parseApiError(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 + } + } + 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 - } + const mapped = parseFlightResponse(data) 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 } + parseApiError(e) } } @@ -59,22 +71,74 @@ export const useFlightsStore = defineStore('flights', () => { } } - function updateFlight(id, flightData) { - const index = flights.value.findIndex(f => f.id === id) - if (index !== -1) { - flights.value[index] = { ...flights.value[index], ...flightData } - return flights.value[index] + async function updateFlight(id, flightData) { + try { + const {data} = await apiClient.put(`/flights/${id}`, flightData) + + const mapped = parseFlightResponse(data) + const index = flights.value.findIndex(f => f.id === id) + if (index !== -1) { + flights.value[index] = {...flights.value[index], ...flightData} + return {ok: true, flight: mapped} + } + } catch (e) { + parseApiError(e) } + return null } - function deleteFlight(id) { - const index = flights.value.findIndex(f => f.id === id) - if (index !== -1) { - flights.value.splice(index, 1) - return true + async function deleteFlight(id) { + try { + const res = await apiClient.delete( + `/flights/${id}`, + { + headers: { + 'Content-Type': 'application/json' + } + } + ); + + if (res.status >= 200 && res.status < 300) { + const index = flights.value.findIndex(f => f.id === id) + if (index !== -1) { + flights.value.splice(index, 1) + + return { + ok: true + }; + } + } + } catch (e) { + if (e.response?.status === 401) { + return {ok: false, status: 401, message: 'Błąd uwierzytelniania'} + } + + if (e.response?.status === 403) { + return {ok: false, status: 403, message: 'Nie masz uprawnień do usunięcia lotu'} + } + + if (e.response?.status === 404) { + return {ok: false, status: 404, message: 'Nie znaleziono lotu'} + } + + if (e.response?.status === 409) { + return { + ok: false, + status: 409, + message: 'Nie można usunąć trwającego lub zakończonego lotu' + } + } + + return { + ok: false, + status: e.response?.status ?? 500, + message: e.response?.data?.message ?? e.message + } } - return false + return { + ok: false + }; } function getFlightById(id) { From 368a605231b8d3e352cf30708c1c8755522bc83a Mon Sep 17 00:00:00 2001 From: kudukm Date: Fri, 19 Jun 2026 18:51:17 +0200 Subject: [PATCH 3/6] (SKY-58) Use `FlightWizardDialog.vue` when time range is selected, convert Dialog to read-only mode --- .../scheduler/FlightWizardDialog.vue | 27 +++++-- skyroster-frontend/src/stores/flights.js | 6 +- .../src/views/SchedulerView.vue | 77 +++++++++++++------ 3 files changed, 77 insertions(+), 33 deletions(-) diff --git a/skyroster-frontend/src/components/scheduler/FlightWizardDialog.vue b/skyroster-frontend/src/components/scheduler/FlightWizardDialog.vue index b1dc2b0..685e1fc 100644 --- a/skyroster-frontend/src/components/scheduler/FlightWizardDialog.vue +++ b/skyroster-frontend/src/components/scheduler/FlightWizardDialog.vue @@ -1,5 +1,5 @@