diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 000000000..ed83212b5 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,29 @@ +name: .NET Tests + +on: + push: + branches: [ "main", "lab_1" ] + pull_request: + branches: [ "main", "lab_1" ] + +jobs: + build_and_test: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup .NET SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Restore dependencies + run: dotnet restore ./Airline.sln + + - name: Build solution + run: dotnet build ./Airline.sln --no-restore + + - name: Run tests + run: dotnet test ./Airline.Tests/Airline.Tests.csproj --no-build --verbosity detailed \ No newline at end of file diff --git a/Airline.Api.Host/Airline.Api.Host.csproj b/Airline.Api.Host/Airline.Api.Host.csproj new file mode 100644 index 000000000..7491da9b3 --- /dev/null +++ b/Airline.Api.Host/Airline.Api.Host.csproj @@ -0,0 +1,23 @@ + + + + net8.0 + enable + enable + true + + + + + + + + + + + + + + + + diff --git a/Airline.Api.Host/Airline.Api.Host.http b/Airline.Api.Host/Airline.Api.Host.http new file mode 100644 index 000000000..797dadd06 --- /dev/null +++ b/Airline.Api.Host/Airline.Api.Host.http @@ -0,0 +1,6 @@ +@Airline.Api.Host_HostAddress = http://localhost:5147 + +GET {{Airline.Api.Host_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/Airline.Api.Host/Controllers/AnalyticController.cs b/Airline.Api.Host/Controllers/AnalyticController.cs new file mode 100644 index 000000000..8522c52c6 --- /dev/null +++ b/Airline.Api.Host/Controllers/AnalyticController.cs @@ -0,0 +1,172 @@ +using Airline.Application.Contracts.Flight; +using Airline.Application.Contracts.Passenger; +using Microsoft.AspNetCore.Mvc; + +namespace Airline.Api.Host.Controllers; + +/// +/// Controller for executing analytical queries over airline operational data. +/// Provides aggregated insights such as top flights, minimal travel time, passenger baggage analysis, +/// and route-based reporting. +/// +[ApiController] +[Route("api/[controller]")] +public class AnalyticsController( + IAnalyticsService analyticsService, + ILogger logger +) : ControllerBase +{ + /// + /// Retrieves the top N flights by number of passengers. + /// + /// The number of top flights to return (default: 5). + /// + /// A list of flight DTOs sorted by passenger count in descending order. + /// + /// Returns the list of top flights. + /// If an unexpected error occurs during processing. + [HttpGet("top-flights-by-passenger-count")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task>> GetTopFlightsByPassengerCount([FromQuery] int top = 5) + { + logger.LogInformation("GetTopFlightsByPassengerCount method called with top={Top}", top); + try + { + var flights = await analyticsService.GetTopFlightsByPassengerCountAsync(top); + logger.LogInformation("GetTopFlightsByPassengerCount method completed successfully"); + return Ok(flights); + } + catch (Exception ex) + { + logger.LogError(ex, "Error in GetTopFlightsByPassengerCount method"); + return StatusCode(StatusCodes.Status500InternalServerError, ex.Message); + } + } + + /// + /// Retrieves flights with the minimal travel time (duration). + /// + /// + /// A list of flight DTOs with the shortest duration. + /// + /// Returns the list of flights with minimal travel time. + /// If an unexpected error occurs during processing. + [HttpGet("flights-with-min-travel-time")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task>> GetFlightsWithMinTravelTime() + { + logger.LogInformation("GetFlightsWithMinTravelTime method called"); + try + { + var flights = await analyticsService.GetFlightsWithMinTravelTimeAsync(); + logger.LogInformation("GetFlightsWithMinTravelTime method completed successfully"); + return Ok(flights); + } + catch (Exception ex) + { + logger.LogError(ex, "Error in GetFlightsWithMinTravelTime method"); + return StatusCode(StatusCodes.Status500InternalServerError, ex.Message); + } + } + + /// + /// Retrieves passengers with zero checked baggage for a specific flight. + /// + /// The unique identifier of the flight. + /// + /// A list of passenger DTOs who have no checked baggage on the specified flight. + /// + /// Returns the list of passengers with zero baggage. + /// If the specified flight does not exist. + /// If an unexpected error occurs during processing. + [HttpGet("passengers-with-zero-baggage")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task>> GetPassengersWithZeroBaggage([FromQuery] int flightId) + { + logger.LogInformation("GetPassengersWithZeroBaggage method called with flightId={FlightId}", flightId); + try + { + var passengers = await analyticsService.GetPassengersWithZeroBaggageOnFlightAsync(flightId); + logger.LogInformation("GetPassengersWithZeroBaggage method completed successfully for flightId={FlightId}", flightId); + return Ok(passengers); + } + catch (KeyNotFoundException) + { + logger.LogWarning("Flight not found for flightId={FlightId}", flightId); + return NotFound(); + } + catch (Exception ex) + { + logger.LogError(ex, "Error in GetPassengersWithZeroBaggage method for flightId={FlightId}", flightId); + return StatusCode(StatusCodes.Status500InternalServerError, ex.Message); + } + } + + /// + /// Retrieves flights of a specific aircraft model within a given date period. + /// + /// The unique identifier of the aircraft model. + /// Start date of the period (inclusive). + /// End date of the period (inclusive). + /// + /// A list of flight DTOs matching the model and date range. + /// + /// Returns the list of flights matching the criteria. + /// If an unexpected error occurs during processing. + [HttpGet("flights-by-model-in-period")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task>> GetFlightsByModelInPeriod( + [FromQuery] int modelId, + [FromQuery] DateTime from, + [FromQuery] DateTime to) + { + logger.LogInformation("GetFlightsByModelInPeriod method called with modelId={ModelId}, from={From}, to={To}", modelId, from, to); + try + { + var flights = await analyticsService.GetFlightsByModelInPeriodAsync(modelId, from, to); + logger.LogInformation("GetFlightsByModelInPeriod method completed successfully"); + return Ok(flights); + } + catch (Exception ex) + { + logger.LogError(ex, "Error in GetFlightsByModelInPeriod method"); + return StatusCode(StatusCodes.Status500InternalServerError, ex.Message); + } + } + + /// + /// Retrieves flights by route (departure city → arrival city). + /// + /// The city of departure. + /// The city of arrival. + /// + /// A list of flight DTOs matching the specified route. + /// + /// Returns the list of flights matching the route. + /// If an unexpected error occurs during processing. + [HttpGet("flights-by-route")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task>> GetFlightsByRoute( + [FromQuery] string departure, + [FromQuery] string arrival) + { + logger.LogInformation("GetFlightsByRoute method called with departure={Departure}, arrival={Arrival}", departure, arrival); + try + { + var flights = await analyticsService.GetFlightsByRouteAsync(departure, arrival); + logger.LogInformation("GetFlightsByRoute method completed successfully"); + return Ok(flights); + } + catch (Exception ex) + { + logger.LogError(ex, "Error in GetFlightsByRoute method"); + return StatusCode(StatusCodes.Status500InternalServerError, ex.Message); + } + } +} \ No newline at end of file diff --git a/Airline.Api.Host/Controllers/BaseCrudController.cs b/Airline.Api.Host/Controllers/BaseCrudController.cs new file mode 100644 index 000000000..c156df593 --- /dev/null +++ b/Airline.Api.Host/Controllers/BaseCrudController.cs @@ -0,0 +1,214 @@ +using Airline.Application.Contracts; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +namespace Airline.Api.Host.Controllers; + +/// +/// Base controller that provides standardized CRUD (Create, Read, Update, Delete) operations +/// for entities in the airline management system. +/// Implements common HTTP methods with unified error handling, logging, and response formatting. +/// +/// +/// The Data Transfer Object (DTO) type used for read operations. +/// Represents the shape of data exposed to API clients. +/// +/// +/// The Data Transfer Object (DTO) type used for create and update operations. +/// Contains only the fields required for mutation. +/// +/// +/// The type of the unique identifier for the entity (e.g., ). +/// Must be a value type (). +/// +/// The application service that implements business logic. +/// The logger instance for diagnostics and monitoring. +[ApiController] +[Route("api/[controller]")] +public abstract class CrudControllerBase( + IApplicationService service, + ILogger> logger +) : ControllerBase + where TDto : class + where TCreateUpdateDto : class + where TKey : struct +{ + /// + /// Creates a new entity. + /// + /// The data transfer object containing creation data. + /// + /// An representing the result of the operation: + /// + /// 201 Created with the created entity if successful. + /// 500 Internal Server Error if an exception occurs. + /// + /// + [HttpPost] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task> Create(TCreateUpdateDto dto) + { + logger.LogInformation("Create method called in {Controller} with data: {@Dto}", GetType().Name, dto); + try + { + var result = await service.CreateAsync(dto); + logger.LogInformation("Create method completed successfully in {Controller}", GetType().Name); + return CreatedAtAction(nameof(GetById), new { id = GetEntityId(result) }, result); + } + catch (Exception ex) + { + logger.LogError(ex, "Error in Create method of controller {Controller}", GetType().Name); + return StatusCode(StatusCodes.Status500InternalServerError, ex.Message); + } + } + + /// + /// Updates an existing entity by its unique identifier. + /// + /// The unique identifier of the entity to update. + /// The data transfer object containing updated data. + /// + /// An representing the result of the operation: + /// + /// 200 OK with the updated entity if successful. + /// 404 Not Found if the entity does not exist. + /// 500 Internal Server Error if an exception occurs. + /// + /// + [HttpPut("{id}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task> Update(TKey id, TCreateUpdateDto dto) + { + logger.LogInformation("Update method called in {Controller} with id={Id} and data: {@Dto}", GetType().Name, id, dto); + try + { + var result = await service.UpdateAsync(dto, id); + logger.LogInformation("Update method completed successfully in {Controller}", GetType().Name); + return Ok(result); + } + catch (KeyNotFoundException) + { + logger.LogWarning("Entity with id={Id} not found in {Controller}", id, GetType().Name); + return NotFound(); + } + catch (Exception ex) + { + logger.LogError(ex, "Error in Update method of controller {Controller}", GetType().Name); + return StatusCode(StatusCodes.Status500InternalServerError, ex.Message); + } + } + + /// + /// Deletes an entity by its unique identifier. + /// + /// The unique identifier of the entity to delete. + /// + /// An representing the result of the operation: + /// + /// 204 No Content if the entity was successfully deleted. + /// 404 Not Found if the entity does not exist. + /// 500 Internal Server Error if an exception occurs. + /// + /// + [HttpDelete("{id}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task Delete(TKey id) + { + logger.LogInformation("Delete method called in {Controller} with id={Id}", GetType().Name, id); + try + { + var success = await service.DeleteAsync(id); + if (!success) + { + logger.LogWarning("Entity with id={Id} not found during deletion in {Controller}", id, GetType().Name); + return NotFound(); + } + logger.LogInformation("Delete method completed successfully in {Controller}", GetType().Name); + return NoContent(); + } + catch (Exception ex) + { + logger.LogError(ex, "Error in Delete method of controller {Controller}", GetType().Name); + return StatusCode(StatusCodes.Status500InternalServerError, ex.Message); + } + } + + /// + /// Retrieves all entities of the specified type. + /// + /// + /// An representing the result of the operation: + /// + /// 200 OK with the list of entities if successful. + /// 500 Internal Server Error if an exception occurs. + /// + /// + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task>> GetAll() + { + logger.LogInformation("GetAll method called in {Controller}", GetType().Name); + try + { + var result = await service.GetAllAsync(); + logger.LogInformation("GetAll method completed successfully in {Controller}", GetType().Name); + return Ok(result); + } + catch (Exception ex) + { + logger.LogError(ex, "Error in GetAll method of controller {Controller}", GetType().Name); + return StatusCode(StatusCodes.Status500InternalServerError, ex.Message); + } + } + + /// + /// Retrieves an entity by its unique identifier. + /// + /// The unique identifier of the entity to retrieve. + /// + /// An representing the result of the operation: + /// + /// 200 OK with the entity if found. + /// 404 Not Found if the entity does not exist. + /// 500 Internal Server Error if an exception occurs. + /// + /// + [HttpGet("{id}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task> GetById(TKey id) + { + logger.LogInformation("GetById method called in {Controller} with id={Id}", GetType().Name, id); + try + { + var result = await service.GetByIdAsync(id); + if (result == null) + { + logger.LogWarning("Entity with id={Id} not found in {Controller}", id, GetType().Name); + return NotFound(); + } + logger.LogInformation("GetById method completed successfully in {Controller}", GetType().Name); + return Ok(result); + } + catch (Exception ex) + { + logger.LogError(ex, "Error in GetById method of controller {Controller}", GetType().Name); + return StatusCode(StatusCodes.Status500InternalServerError, ex.Message); + } + } + + /// + /// Extracts the unique identifier from a DTO. + /// Must be implemented by derived controllers to support routing. + /// + /// The DTO from which to extract the identifier. + /// The unique identifier of the entity. + protected abstract TKey GetEntityId(TDto dto); +} \ No newline at end of file diff --git a/Airline.Api.Host/Controllers/FlightController.cs b/Airline.Api.Host/Controllers/FlightController.cs new file mode 100644 index 000000000..eeb56a7d9 --- /dev/null +++ b/Airline.Api.Host/Controllers/FlightController.cs @@ -0,0 +1,122 @@ +using Airline.Application.Contracts.Flight; +using Airline.Application.Contracts.Passenger; +using Airline.Application.Contracts.PlaneModel; +using Airline.Application.Contracts.Ticket; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +namespace Airline.Api.Host.Controllers; + +/// +/// Controller for managing flights and retrieving associated data. +/// Inherits from +/// to provide standardized CRUD operations. +/// +[Route("api/[controller]")] +public class FlightsController( + IFlightService flightService, + ILogger logger +) : CrudControllerBase(flightService, logger) +{ + /// + protected override int GetEntityId(FlightDto dto) => dto.Id; + + /// + /// Retrieves the aircraft model associated with a specific flight. + /// + /// The unique identifier of the flight. + /// The aircraft model DTO linked to the flight. + /// Returns the associated aircraft model. + /// If the flight or aircraft model is not found. + /// If an unexpected error occurs. + [HttpGet("{flightId}/model")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task> GetPlaneModel(int flightId) + { + logger.LogInformation("GetPlaneModel method called with flightId={FlightId}", flightId); + try + { + var model = await flightService.GetPlaneModelAsync(flightId); + logger.LogInformation("GetPlaneModel method completed successfully for flightId={FlightId}", flightId); + return Ok(model); + } + catch (KeyNotFoundException) + { + logger.LogWarning("Aircraft model not found for flightId={FlightId}", flightId); + return NotFound(); + } + catch (Exception ex) + { + logger.LogError(ex, "Error in GetPlaneModel method for flightId={FlightId}", flightId); + return StatusCode(StatusCodes.Status500InternalServerError, ex.Message); + } + } + + /// + /// Retrieves all tickets associated with a specific flight. + /// + /// The unique identifier of the flight. + /// A list of ticket DTOs linked to the flight. + /// Returns the list of associated tickets. + /// If the flight is not found. + /// If an unexpected error occurs. + [HttpGet("{flightId}/tickets")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task>> GetTickets(int flightId) + { + logger.LogInformation("GetTickets method called with flightId={FlightId}", flightId); + try + { + var tickets = await flightService.GetTicketsAsync(flightId); + logger.LogInformation("GetTickets method completed successfully for flightId={FlightId}", flightId); + return Ok(tickets); + } + catch (KeyNotFoundException) + { + logger.LogWarning("Tickets not found for flightId={FlightId}", flightId); + return NotFound(); + } + catch (Exception ex) + { + logger.LogError(ex, "Error in GetTickets method for flightId={FlightId}", flightId); + return StatusCode(StatusCodes.Status500InternalServerError, ex.Message); + } + } + + /// + /// Retrieves all passengers associated with a specific flight. + /// + /// The unique identifier of the flight. + /// A list of passenger DTOs linked to the flight. + /// Returns the list of associated passengers. + /// If the flight is not found. + /// If an unexpected error occurs. + [HttpGet("{flightId}/passengers")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task>> GetPassengers(int flightId) + { + logger.LogInformation("GetPassengers method called with flightId={FlightId}", flightId); + try + { + var passengers = await flightService.GetPassengersAsync(flightId); + logger.LogInformation("GetPassengers method completed successfully for flightId={FlightId}", flightId); + return Ok(passengers); + } + catch (KeyNotFoundException) + { + logger.LogWarning("Passengers not found for flightId={FlightId}", flightId); + return NotFound(); + } + catch (Exception ex) + { + logger.LogError(ex, "Error in GetPassengers method for flightId={FlightId}", flightId); + return StatusCode(StatusCodes.Status500InternalServerError, ex.Message); + } + } +} \ No newline at end of file diff --git a/Airline.Api.Host/Controllers/ModelFamilyController.cs b/Airline.Api.Host/Controllers/ModelFamilyController.cs new file mode 100644 index 000000000..b5fcd8ff5 --- /dev/null +++ b/Airline.Api.Host/Controllers/ModelFamilyController.cs @@ -0,0 +1,54 @@ +using Airline.Application.Contracts.ModelFamily; +using Airline.Application.Contracts.PlaneModel; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +namespace Airline.Api.Host.Controllers; + +/// +/// Controller for managing aircraft model families and retrieving associated data. +/// Inherits from +/// to provide standardized CRUD operations. +/// +[Route("api/[controller]")] +public class ModelFamiliesController( + IModelFamilyService modelFamilyService, + ILogger logger +) : CrudControllerBase(modelFamilyService, logger) +{ + /// + protected override int GetEntityId(ModelFamilyDto dto) => dto.Id; + + /// + /// Retrieves all aircraft models associated with a specific model family. + /// + /// The unique identifier of the model family. + /// A list of aircraft model DTOs linked to the family. + /// Returns the list of associated aircraft models. + /// If the model family is not found. + /// If an unexpected error occurs. + [HttpGet("{familyId}/models")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task>> GetPlaneModels(int familyId) + { + logger.LogInformation("GetPlaneModels method called with familyId={FamilyId}", familyId); + try + { + var models = await modelFamilyService.GetPlaneModelsAsync(familyId); + logger.LogInformation("GetPlaneModels method completed successfully for familyId={FamilyId}", familyId); + return Ok(models); + } + catch (KeyNotFoundException) + { + logger.LogWarning("Aircraft models not found for familyId={FamilyId}", familyId); + return NotFound(); + } + catch (Exception ex) + { + logger.LogError(ex, "Error in GetPlaneModels method for familyId={FamilyId}", familyId); + return StatusCode(StatusCodes.Status500InternalServerError, ex.Message); + } + } +} \ No newline at end of file diff --git a/Airline.Api.Host/Controllers/PassengerController.cs b/Airline.Api.Host/Controllers/PassengerController.cs new file mode 100644 index 000000000..e6065d7df --- /dev/null +++ b/Airline.Api.Host/Controllers/PassengerController.cs @@ -0,0 +1,88 @@ +using Airline.Application.Contracts.Flight; +using Airline.Application.Contracts.Passenger; +using Airline.Application.Contracts.Ticket; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +namespace Airline.Api.Host.Controllers; + +/// +/// Controller for managing passengers and retrieving associated data. +/// Inherits from +/// to provide standardized CRUD operations. +/// +[Route("api/[controller]")] +public class PassengersController( + IPassengerService passengerService, + ILogger logger +) : CrudControllerBase(passengerService, logger) +{ + /// + protected override int GetEntityId(PassengerDto dto) => dto.Id; + + /// + /// Retrieves all tickets associated with a specific passenger. + /// + /// The unique identifier of the passenger. + /// A list of ticket DTOs linked to the passenger. + /// Returns the list of associated tickets. + /// If the passenger is not found. + /// If an unexpected error occurs. + [HttpGet("{passengerId}/tickets")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task>> GetTickets(int passengerId) + { + logger.LogInformation("GetTickets method called with passengerId={PassengerId}", passengerId); + try + { + var tickets = await passengerService.GetTicketsAsync(passengerId); + logger.LogInformation("GetTickets method completed successfully for passengerId={PassengerId}", passengerId); + return Ok(tickets); + } + catch (KeyNotFoundException) + { + logger.LogWarning("Tickets not found for passengerId={PassengerId}", passengerId); + return NotFound(); + } + catch (Exception ex) + { + logger.LogError(ex, "Error in GetTickets method for passengerId={PassengerId}", passengerId); + return StatusCode(StatusCodes.Status500InternalServerError, ex.Message); + } + } + + /// + /// Retrieves all flights associated with a specific passenger. + /// + /// The unique identifier of the passenger. + /// A list of flight DTOs linked to the passenger. + /// Returns the list of associated flights. + /// If the passenger is not found. + /// If an unexpected error occurs. + [HttpGet("{passengerId}/flights")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task>> GetFlights(int passengerId) + { + logger.LogInformation("GetFlights method called with passengerId={PassengerId}", passengerId); + try + { + var flights = await passengerService.GetFlightsAsync(passengerId); + logger.LogInformation("GetFlights method completed successfully for passengerId={PassengerId}", passengerId); + return Ok(flights); + } + catch (KeyNotFoundException) + { + logger.LogWarning("Flights not found for passengerId={PassengerId}", passengerId); + return NotFound(); + } + catch (Exception ex) + { + logger.LogError(ex, "Error in GetFlights method for passengerId={PassengerId}", passengerId); + return StatusCode(StatusCodes.Status500InternalServerError, ex.Message); + } + } +} \ No newline at end of file diff --git a/Airline.Api.Host/Controllers/PlaneModelController.cs b/Airline.Api.Host/Controllers/PlaneModelController.cs new file mode 100644 index 000000000..2e4e0d2dc --- /dev/null +++ b/Airline.Api.Host/Controllers/PlaneModelController.cs @@ -0,0 +1,54 @@ +using Airline.Application.Contracts.ModelFamily; +using Airline.Application.Contracts.PlaneModel; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +namespace Airline.Api.Host.Controllers; + +/// +/// Controller for managing aircraft models and retrieving associated data. +/// Inherits from +/// to provide standardized CRUD operations. +/// +[Route("api/[controller]")] +public class PlaneModelsController( + IPlaneModelService planeModelService, + ILogger logger +) : CrudControllerBase(planeModelService, logger) +{ + /// + protected override int GetEntityId(PlaneModelDto dto) => dto.Id; + + /// + /// Retrieves the model family associated with a specific aircraft model. + /// + /// The unique identifier of the aircraft model. + /// The model family DTO linked to the aircraft model. + /// Returns the associated model family. + /// If the aircraft model or model family is not found. + /// If an unexpected error occurs. + [HttpGet("{modelId}/family")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task> GetModelFamily(int modelId) + { + logger.LogInformation("GetModelFamily method called with modelId={ModelId}", modelId); + try + { + var family = await planeModelService.GetModelFamilyAsync(modelId); + logger.LogInformation("GetModelFamily method completed successfully for modelId={ModelId}", modelId); + return Ok(family); + } + catch (KeyNotFoundException) + { + logger.LogWarning("Model family not found for modelId={ModelId}", modelId); + return NotFound(); + } + catch (Exception ex) + { + logger.LogError(ex, "Error in GetModelFamily method for modelId={ModelId}", modelId); + return StatusCode(StatusCodes.Status500InternalServerError, ex.Message); + } + } +} \ No newline at end of file diff --git a/Airline.Api.Host/Controllers/TicketController.cs b/Airline.Api.Host/Controllers/TicketController.cs new file mode 100644 index 000000000..2fea0bf3f --- /dev/null +++ b/Airline.Api.Host/Controllers/TicketController.cs @@ -0,0 +1,88 @@ +using Airline.Application.Contracts.Flight; +using Airline.Application.Contracts.Passenger; +using Airline.Application.Contracts.Ticket; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +namespace Airline.Api.Host.Controllers; + +/// +/// Controller for managing tickets and retrieving associated data. +/// Inherits from +/// to provide standardized CRUD operations. +/// +[Route("api/[controller]")] +public class TicketsController( + ITicketService ticketService, + ILogger logger +) : CrudControllerBase(ticketService, logger) +{ + /// + protected override int GetEntityId(TicketDto dto) => dto.Id; + + /// + /// Retrieves the flight associated with a specific ticket. + /// + /// The unique identifier of the ticket. + /// The flight DTO linked to the ticket. + /// Returns the associated flight. + /// If the ticket or flight is not found. + /// If an unexpected error occurs. + [HttpGet("{ticketId}/flight")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task> GetFlight(int ticketId) + { + logger.LogInformation("GetFlight method called with ticketId={TicketId}", ticketId); + try + { + var flight = await ticketService.GetFlightAsync(ticketId); + logger.LogInformation("GetFlight method completed successfully for ticketId={TicketId}", ticketId); + return Ok(flight); + } + catch (KeyNotFoundException) + { + logger.LogWarning("Flight not found for ticketId={TicketId}", ticketId); + return NotFound(); + } + catch (Exception ex) + { + logger.LogError(ex, "Error in GetFlight method for ticketId={TicketId}", ticketId); + return StatusCode(StatusCodes.Status500InternalServerError, ex.Message); + } + } + + /// + /// Retrieves the passenger associated with a specific ticket. + /// + /// The unique identifier of the ticket. + /// The passenger DTO linked to the ticket. + /// Returns the associated passenger. + /// If the ticket or passenger is not found. + /// If an unexpected error occurs. + [HttpGet("{ticketId}/passenger")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task> GetPassenger(int ticketId) + { + logger.LogInformation("GetPassenger method called with ticketId={TicketId}", ticketId); + try + { + var passenger = await ticketService.GetPassengerAsync(ticketId); + logger.LogInformation("GetPassenger method completed successfully for ticketId={TicketId}", ticketId); + return Ok(passenger); + } + catch (KeyNotFoundException) + { + logger.LogWarning("Passenger not found for ticketId={TicketId}", ticketId); + return NotFound(); + } + catch (Exception ex) + { + logger.LogError(ex, "Error in GetPassenger method for ticketId={TicketId}", ticketId); + return StatusCode(StatusCodes.Status500InternalServerError, ex.Message); + } + } +} \ No newline at end of file diff --git a/Airline.Api.Host/Program.cs b/Airline.Api.Host/Program.cs new file mode 100644 index 000000000..837b54852 --- /dev/null +++ b/Airline.Api.Host/Program.cs @@ -0,0 +1,145 @@ +using Airline.Application; +using Airline.Application.Contracts.Flight; +using Airline.Application.Contracts.ModelFamily; +using Airline.Application.Contracts.Passenger; +using Airline.Application.Contracts.PlaneModel; +using Airline.Application.Contracts.Ticket; +using Airline.Application.Services; +using Airline.Domain; +using Airline.Domain.DataSeed; +using Airline.Domain.Items; +using Airline.Infrastructure.EfCore; +using Airline.Infrastructure.EfCore.Repositories; +using Airline.Infrastructure.Kafka; +using Airline.Infrastructure.Kafka.Deserializers; +using Airline.ServiceDefaults; +using Microsoft.EntityFrameworkCore; +using MongoDB.Driver; +using System.Text.Json.Serialization; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); + +builder.Services.AddSingleton(); + +builder.Services.AddAutoMapper(config => +{ + config.AddProfile(new AirlineProfile()); +}); + +// Repositories +builder.Services.AddTransient, FlightRepository>(); +builder.Services.AddTransient, PassengerRepository>(); +builder.Services.AddTransient, TicketRepository>(); +builder.Services.AddTransient, PlaneModelRepository>(); +builder.Services.AddTransient, ModelFamilyRepository>(); + +// Application Services +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +// Controllers +builder.Services.AddControllers() + .AddJsonOptions(options => + { + options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles; + }); + +// Swagger/OpenAPI +builder.Services.AddEndpointsApiExplorer(); + +builder.Services.AddSwaggerGen(c => +{ + var assemblies = AppDomain.CurrentDomain.GetAssemblies() + .Where(a => a.GetName().Name!.StartsWith("Airline")) + .Distinct(); + + foreach (var assembly in assemblies) + { + var xmlFile = $"{assembly.GetName().Name}.xml"; + var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile); + if (File.Exists(xmlPath)) + c.IncludeXmlComments(xmlPath); + } +}); + + +// MongoDB +builder.AddMongoDBClient("airlineClient"); + +builder.Services.AddDbContext((services, o) => +{ + var client = services.GetRequiredService(); + o.UseMongoDB(client, "db"); +}); + +// Kafka Consumer +builder.Services.AddHostedService(); +builder.AddKafkaConsumer>("airline-kafka", + configureBuilder: kafkaBuilder => + { + kafkaBuilder.SetKeyDeserializer(new AirlineKeyDeserializer()); + kafkaBuilder.SetValueDeserializer(new AirlineValueDeserializer()); + }, + configureSettings: settings => + { + settings.Config.GroupId = "airline-consumer"; + settings.Config.AutoOffsetReset = Confluent.Kafka.AutoOffsetReset.Earliest; + }); + +var app = builder.Build(); + +app.MapDefaultEndpoints(); + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(c => + { + c.SwaggerEndpoint("/swagger/v1/swagger.json", "Airline API v1"); + }); +} + +using (var scope = app.Services.CreateScope()) +{ + var dbContext = scope.ServiceProvider.GetRequiredService(); + var seed = scope.ServiceProvider.GetRequiredService(); + + var flightsExist = dbContext.Flights.Any(); + if (!flightsExist) + { + // ModelFamilies + foreach (var family in seed.ModelFamilies) + await dbContext.ModelFamilies.AddAsync(family); + + // PlaneModels + foreach (var model in seed.PlaneModels) + await dbContext.PlaneModels.AddAsync(model); + + // Passengers + foreach (var passenger in seed.Passengers) + await dbContext.Passengers.AddAsync(passenger); + + // Flights + foreach (var flight in seed.Flights) + await dbContext.Flights.AddAsync(flight); + + // Tickets + foreach (var ticket in seed.Tickets) + await dbContext.Tickets.AddAsync(ticket); + + await dbContext.SaveChangesAsync(); + app.Logger.LogInformation("Database successfully populated with test data."); + } +} + +app.UseHttpsRedirection(); +app.UseAuthorization(); +app.MapControllers(); + +app.Run(); \ No newline at end of file diff --git a/Airline.Api.Host/Properties/launchSettings.json b/Airline.Api.Host/Properties/launchSettings.json new file mode 100644 index 000000000..43157dc31 --- /dev/null +++ b/Airline.Api.Host/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:53894", + "sslPort": 44359 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5147", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7089;http://localhost:5147", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/Airline.Api.Host/appsettings.Development.json b/Airline.Api.Host/appsettings.Development.json new file mode 100644 index 000000000..0c208ae91 --- /dev/null +++ b/Airline.Api.Host/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/Airline.Api.Host/appsettings.json b/Airline.Api.Host/appsettings.json new file mode 100644 index 000000000..10f68b8c8 --- /dev/null +++ b/Airline.Api.Host/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/Airline.AppHost/Airline.AppHost.csproj b/Airline.AppHost/Airline.AppHost.csproj new file mode 100644 index 000000000..40fa4d116 --- /dev/null +++ b/Airline.AppHost/Airline.AppHost.csproj @@ -0,0 +1,26 @@ + + + + + + Exe + net8.0 + enable + enable + c5640d6f-84de-4448-bbef-056105f657a0 + + + + + + + + + + + + + + + + diff --git a/Airline.AppHost/AppHost.cs b/Airline.AppHost/AppHost.cs new file mode 100644 index 000000000..88bdd1e8d --- /dev/null +++ b/Airline.AppHost/AppHost.cs @@ -0,0 +1,24 @@ +var builder = DistributedApplication.CreateBuilder(args); + +var db = builder.AddMongoDB("mongo") + .AddDatabase("db"); + +var kafka = builder.AddKafka("airline-kafka", 9092) + .WithKafkaUI(); + +var apiHost = builder.AddProject("airline-api-host") + .WithReference(db, "airlineClient") + .WithReference(kafka) + .WithEnvironment("KAFKA_BOOTSTRAP_SERVERS", kafka.GetEndpoint("tcp")) + .WithEnvironment("Kafka:TopicName", "airline-contracts") + .WaitFor(db) + .WaitFor(kafka); + +builder.AddProject("airline-generator-kafka-host") + .WithReference(kafka) + .WithEnvironment("KAFKA_BOOTSTRAP_SERVERS", kafka.GetEndpoint("tcp")) + .WithEnvironment("Kafka:TopicName", "airline-contracts") + .WaitFor(kafka) + .WaitFor(apiHost); + +builder.Build().Run(); \ No newline at end of file diff --git a/Airline.AppHost/Properties/launchSettings.json b/Airline.AppHost/Properties/launchSettings.json new file mode 100644 index 000000000..643d26e64 --- /dev/null +++ b/Airline.AppHost/Properties/launchSettings.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17255;http://localhost:15201", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21277", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "https://localhost:23151", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22297" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15201", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19082", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "http://localhost:18107", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20078" + } + } + } +} diff --git a/Airline.AppHost/appsettings.Development.json b/Airline.AppHost/appsettings.Development.json new file mode 100644 index 000000000..0c208ae91 --- /dev/null +++ b/Airline.AppHost/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/Airline.AppHost/appsettings.json b/Airline.AppHost/appsettings.json new file mode 100644 index 000000000..31c092aa4 --- /dev/null +++ b/Airline.AppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/Airline.Application.Contracts/Airline.Application.Contracts.csproj b/Airline.Application.Contracts/Airline.Application.Contracts.csproj new file mode 100644 index 000000000..90b8a4194 --- /dev/null +++ b/Airline.Application.Contracts/Airline.Application.Contracts.csproj @@ -0,0 +1,14 @@ + + + + net8.0 + enable + enable + true + + + + + + + diff --git a/Airline.Application.Contracts/Flight/CreateFlightDto.cs b/Airline.Application.Contracts/Flight/CreateFlightDto.cs new file mode 100644 index 000000000..8ac342923 --- /dev/null +++ b/Airline.Application.Contracts/Flight/CreateFlightDto.cs @@ -0,0 +1,20 @@ +namespace Airline.Application.Contracts.Flight; + +/// +/// Data Transfer Object (DTO) for creating a new flight. +/// Contains scheduling and routing information for an airline flight. +/// +/// The flight code (e.g., "SU101"). +/// The city of departure. +/// The city of arrival. +/// The scheduled departure date and time (local time). +/// The scheduled arrival date and time (local time). +/// The unique identifier of the associated aircraft model. +public record CreateFlightDto( + string FlightCode, + string DepartureCity, + string ArrivalCity, + DateTime DepartureDateTime, + DateTime ArrivalDateTime, + int ModelId +); \ No newline at end of file diff --git a/Airline.Application.Contracts/Flight/FlightDto.cs b/Airline.Application.Contracts/Flight/FlightDto.cs new file mode 100644 index 000000000..48f03809b --- /dev/null +++ b/Airline.Application.Contracts/Flight/FlightDto.cs @@ -0,0 +1,22 @@ +namespace Airline.Application.Contracts.Flight; + +/// +/// Data Transfer Object (DTO) representing a flight in the airline system. +/// Used for read operations and data exchange with clients. +/// +/// The unique identifier of the flight. +/// The flight code (e.g., "SU101"). +/// The city of departure. +/// The city of arrival. +/// The scheduled departure date and time (local time). +/// The scheduled arrival date and time (local time). +/// The unique identifier of the associated aircraft model. +public record FlightDto( + int Id, + string FlightCode, + string DepartureCity, + string ArrivalCity, + DateTime DepartureDateTime, + DateTime ArrivalDateTime, + int ModelId +); \ No newline at end of file diff --git a/Airline.Application.Contracts/Flight/IFlightService.cs b/Airline.Application.Contracts/Flight/IFlightService.cs new file mode 100644 index 000000000..e6cbf03e2 --- /dev/null +++ b/Airline.Application.Contracts/Flight/IFlightService.cs @@ -0,0 +1,33 @@ +using Airline.Application.Contracts.Passenger; +using Airline.Application.Contracts.PlaneModel; +using Airline.Application.Contracts.Ticket; + +namespace Airline.Application.Contracts.Flight; + +/// +/// Service contract for managing flights in the airline system. +/// Extends the generic CRUD interface with flight-specific analytical operations. +/// +public interface IFlightService : IApplicationService +{ + /// + /// Retrieves the aircraft model associated with a specific flight. + /// + /// The unique identifier of the flight. + /// The aircraft model DTO linked to the flight. + public Task GetPlaneModelAsync(int flightId); + + /// + /// Retrieves all tickets associated with a specific flight. + /// + /// The unique identifier of the flight. + /// A list of ticket DTOs linked to the flight. + public Task> GetTicketsAsync(int flightId); + + /// + /// Retrieves all passengers associated with a specific flight. + /// + /// The unique identifier of the flight. + /// A list of passenger DTOs linked to the flight. + public Task> GetPassengersAsync(int flightId); +} \ No newline at end of file diff --git a/Airline.Application.Contracts/IAnalyticService.cs b/Airline.Application.Contracts/IAnalyticService.cs new file mode 100644 index 000000000..a8020624e --- /dev/null +++ b/Airline.Application.Contracts/IAnalyticService.cs @@ -0,0 +1,46 @@ +using Airline.Application.Contracts.Flight; +using Airline.Application.Contracts.Passenger; + +/// +/// Defines a service contract for analytical and reporting operations over airline data. +/// Provides aggregated queries for business intelligence, operational insights, and passenger analytics. +/// +public interface IAnalyticsService +{ + /// + /// Retrieves the top N flights by number of passengers. + /// + /// The number of top flights to return (default: 5). + /// A list of flight DTOs sorted by passenger count in descending order. + public Task> GetTopFlightsByPassengerCountAsync(int top = 5); + + /// + /// Retrieves flights with the minimal travel time (duration). + /// + /// A list of flight DTOs with the shortest duration. + public Task> GetFlightsWithMinTravelTimeAsync(); + + /// + /// Retrieves passengers with zero baggage (no checked luggage) for a specific flight. + /// + /// The unique identifier of the flight. + /// A list of passenger DTOs who have no baggage on the specified flight. + public Task> GetPassengersWithZeroBaggageOnFlightAsync(int flightId); + + /// + /// Retrieves flights of a specific aircraft model within a given date period. + /// + /// The unique identifier of the aircraft model. + /// Start date of the period (inclusive). + /// End date of the period (inclusive). + /// A list of flight DTOs matching the model and date range. + public Task> GetFlightsByModelInPeriodAsync(int modelId, DateTime from, DateTime to); + + /// + /// Retrieves flights by route (departure city to arrival city). + /// + /// The city of departure. + /// The city of arrival. + /// A list of flight DTOs matching the specified route. + public Task> GetFlightsByRouteAsync(string departure, string arrival); +} \ No newline at end of file diff --git a/Airline.Application.Contracts/IApplicationService.cs b/Airline.Application.Contracts/IApplicationService.cs new file mode 100644 index 000000000..ebd069f93 --- /dev/null +++ b/Airline.Application.Contracts/IApplicationService.cs @@ -0,0 +1,62 @@ +namespace Airline.Application.Contracts; + +/// +/// Defines application service interface that provides +/// standardized CRUD (Create, Read, Update, Delete) operations for domain entities. +/// Serves as a contract between the application layer and presentation layer. +/// +/// +/// The Data Transfer Object (DTO) type used for read operations. +/// Represents the shape of data exposed to clients. +/// +/// +/// The Data Transfer Object (DTO) type used for create and update operations. +/// Contains only the fields required for mutation. +/// +/// +/// The type of the unique identifier for the entity (e.g., , ). +/// Must be a value type (). +/// +public interface IApplicationService + where TDto : class + where TCreateUpdateDto : class + where TKey : struct +{ + /// + /// Creates a new entity in the system. + /// + /// The data transfer object containing creation data. + /// The created entity as a DTO, typically with an assigned identifier. + public Task CreateAsync(TCreateUpdateDto dto); + + /// + /// Retrieves an entity by its unique identifier. + /// + /// The unique identifier of the entity to retrieve. + /// The entity as a DTO if found; otherwise, . + public Task GetByIdAsync(TKey id); + + /// + /// Retrieves all entities of the specified type. + /// + /// A list of all entities as DTOs. + public Task> GetAllAsync(); + + /// + /// Updates an existing entity with new data. + /// + /// The data transfer object containing updated data. + /// The unique identifier of the entity to update. + /// The updated entity as a DTO. + public Task UpdateAsync(TCreateUpdateDto dto, TKey id); + + /// + /// Deletes an entity by its unique identifier. + /// + /// The unique identifier of the entity to delete. + /// + /// if the entity was successfully deleted; + /// otherwise, . + /// + public Task DeleteAsync(TKey id); +} \ No newline at end of file diff --git a/Airline.Application.Contracts/ModelFamily/CreateModelFamilyDto.cs b/Airline.Application.Contracts/ModelFamily/CreateModelFamilyDto.cs new file mode 100644 index 000000000..c0503e648 --- /dev/null +++ b/Airline.Application.Contracts/ModelFamily/CreateModelFamilyDto.cs @@ -0,0 +1,12 @@ +namespace Airline.Application.Contracts.ModelFamily; + +/// +/// Data Transfer Object (DTO) for creating a new aircraft model family. +/// Represents a group of aircraft models with common design features. +/// +/// The name of the aircraft model family (e.g., "A320 Family"). +/// The name of the manufacturer (e.g., "Airbus", "Boeing"). +public record CreateModelFamilyDto( + string NameOfFamily, + string ManufacturerName +); \ No newline at end of file diff --git a/Airline.Application.Contracts/ModelFamily/IModelFamilyService.cs b/Airline.Application.Contracts/ModelFamily/IModelFamilyService.cs new file mode 100644 index 000000000..d90168ae0 --- /dev/null +++ b/Airline.Application.Contracts/ModelFamily/IModelFamilyService.cs @@ -0,0 +1,17 @@ +using Airline.Application.Contracts.PlaneModel; + +namespace Airline.Application.Contracts.ModelFamily; + +/// +/// Service contract for managing aircraft model families in the airline system. +/// Extends the generic CRUD interface with family-specific analytical operations. +/// +public interface IModelFamilyService : IApplicationService +{ + /// + /// Retrieves all aircraft models associated with a specific model family. + /// + /// The unique identifier of the model family. + /// A list of aircraft model DTOs linked to the family. + public Task> GetPlaneModelsAsync(int familyId); +} \ No newline at end of file diff --git a/Airline.Application.Contracts/ModelFamily/ModelFamilyDto.cs b/Airline.Application.Contracts/ModelFamily/ModelFamilyDto.cs new file mode 100644 index 000000000..930e74143 --- /dev/null +++ b/Airline.Application.Contracts/ModelFamily/ModelFamilyDto.cs @@ -0,0 +1,14 @@ +namespace Airline.Application.Contracts.ModelFamily; + +/// +/// Data Transfer Object (DTO) representing an aircraft model family in the airline system. +/// Used for read operations and data exchange with clients. +/// +/// The unique identifier of the model family. +/// The name of the aircraft model family (e.g., "A320 Family"). +/// The name of the manufacturer (e.g., "Airbus", "Boeing"). +public record ModelFamilyDto( + int Id, + string NameOfFamily, + string ManufacturerName +); \ No newline at end of file diff --git a/Airline.Application.Contracts/Passenger/CreatePassengerDto.cs b/Airline.Application.Contracts/Passenger/CreatePassengerDto.cs new file mode 100644 index 000000000..5c4f25168 --- /dev/null +++ b/Airline.Application.Contracts/Passenger/CreatePassengerDto.cs @@ -0,0 +1,14 @@ +namespace Airline.Application.Contracts.Passenger; + +/// +/// Data Transfer Object (DTO) for creating a new passenger. +/// Contains personal identification and demographic data. +/// +/// The passport number of the passenger. +/// The full name of the passenger (must not contain digits). +/// The date of birth of the passenger. +public record CreatePassengerDto( + string Passport, + string PassengerName, + DateOnly DateOfBirth +); \ No newline at end of file diff --git a/Airline.Application.Contracts/Passenger/IPassengerService.cs b/Airline.Application.Contracts/Passenger/IPassengerService.cs new file mode 100644 index 000000000..203cc53d0 --- /dev/null +++ b/Airline.Application.Contracts/Passenger/IPassengerService.cs @@ -0,0 +1,25 @@ +using Airline.Application.Contracts.Flight; +using Airline.Application.Contracts.Ticket; + +namespace Airline.Application.Contracts.Passenger; + +/// +/// Service contract for managing passengers in the airline system. +/// Extends the generic CRUD interface with passenger-specific analytical operations. +/// +public interface IPassengerService : IApplicationService +{ + /// + /// Retrieves all tickets associated with a specific passenger. + /// + /// The unique identifier of the passenger. + /// A list of ticket DTOs linked to the passenger. + public Task> GetTicketsAsync(int passengerId); + + /// + /// Retrieves all flights associated with a specific passenger. + /// + /// The unique identifier of the passenger. + /// A list of flight DTOs linked to the passenger. + public Task> GetFlightsAsync(int passengerId); +} \ No newline at end of file diff --git a/Airline.Application.Contracts/Passenger/PassengerDto.cs b/Airline.Application.Contracts/Passenger/PassengerDto.cs new file mode 100644 index 000000000..abc7a6b23 --- /dev/null +++ b/Airline.Application.Contracts/Passenger/PassengerDto.cs @@ -0,0 +1,16 @@ +namespace Airline.Application.Contracts.Passenger; + +/// +/// Data Transfer Object (DTO) representing a passenger in the airline system. +/// Used for read operations and data exchange with clients. +/// +/// The unique identifier of the passenger. +/// The passport number of the passenger. +/// The full name of the passenger. +/// The date of birth of the passenger. +public record PassengerDto( + int Id, + string Passport, + string PassengerName, + DateOnly DateOfBirth +); \ No newline at end of file diff --git a/Airline.Application.Contracts/PlaneModel/CreatePlaneModelDto.cs b/Airline.Application.Contracts/PlaneModel/CreatePlaneModelDto.cs new file mode 100644 index 000000000..cf8f6a06a --- /dev/null +++ b/Airline.Application.Contracts/PlaneModel/CreatePlaneModelDto.cs @@ -0,0 +1,18 @@ +namespace Airline.Application.Contracts.PlaneModel; + +/// +/// Data Transfer Object (DTO) for creating a new aircraft model. +/// Contains technical specifications and references to its model family. +/// +/// The name of the aircraft model (e.g., "A320"). +/// The unique identifier of the associated model family. +/// The maximum flight range in kilometers (must be non-negative). +/// The number of passenger seats (must be non-negative). +/// The cargo capacity in tons (must be non-negative). +public record CreatePlaneModelDto( + string ModelName, + int ModelFamilyId, + double MaxRange, + double PassengerCapacity, + double CargoCapacity +); \ No newline at end of file diff --git a/Airline.Application.Contracts/PlaneModel/IPlaneModelService.cs b/Airline.Application.Contracts/PlaneModel/IPlaneModelService.cs new file mode 100644 index 000000000..3461b208e --- /dev/null +++ b/Airline.Application.Contracts/PlaneModel/IPlaneModelService.cs @@ -0,0 +1,17 @@ +using Airline.Application.Contracts.ModelFamily; + +namespace Airline.Application.Contracts.PlaneModel; + +/// +/// Service contract for managing aircraft models in the airline system. +/// Extends the generic CRUD interface with model-specific analytical operations. +/// +public interface IPlaneModelService : IApplicationService +{ + /// + /// Retrieves the model family associated with a specific aircraft model. + /// + /// The unique identifier of the aircraft model. + /// The model family DTO linked to the aircraft model. + public Task GetModelFamilyAsync(int modelId); +} \ No newline at end of file diff --git a/Airline.Application.Contracts/PlaneModel/PlaneModelDto.cs b/Airline.Application.Contracts/PlaneModel/PlaneModelDto.cs new file mode 100644 index 000000000..5590a56d1 --- /dev/null +++ b/Airline.Application.Contracts/PlaneModel/PlaneModelDto.cs @@ -0,0 +1,20 @@ +namespace Airline.Application.Contracts.PlaneModel; + +/// +/// Data Transfer Object (DTO) representing an aircraft model in the airline system. +/// Used for read operations and data exchange with clients. +/// +/// The unique identifier of the aircraft model. +/// The name of the aircraft model (e.g., "A320"). +/// The unique identifier of the associated model family. +/// The maximum flight range in kilometers. +/// The number of passenger seats. +/// The cargo capacity in tons. +public record PlaneModelDto( + int Id, + string ModelName, + int ModelFamilyId, + double MaxRange, + double PassengerCapacity, + double CargoCapacity +); \ No newline at end of file diff --git a/Airline.Application.Contracts/Ticket/CreateTicketDto.cs b/Airline.Application.Contracts/Ticket/CreateTicketDto.cs new file mode 100644 index 000000000..11ca5e2be --- /dev/null +++ b/Airline.Application.Contracts/Ticket/CreateTicketDto.cs @@ -0,0 +1,18 @@ +namespace Airline.Application.Contracts.Ticket; + +/// +/// Data Transfer Object (DTO) for creating a new ticket. +/// Contains all required fields for ticket creation in the airline system. +/// +/// The unique identifier of the associated flight. +/// The unique identifier of the associated passenger. +/// The assigned seat number (e.g., "12A"). +/// Indicates whether hand luggage is carried. +/// The total checked baggage weight in kilograms (null if no baggage). +public record CreateTicketDto( + int FlightId, + int PassengerId, + string SeatNumber, + bool HandLuggage, + double? BaggageWeight +); \ No newline at end of file diff --git a/Airline.Application.Contracts/Ticket/ITicketService.cs b/Airline.Application.Contracts/Ticket/ITicketService.cs new file mode 100644 index 000000000..f57990240 --- /dev/null +++ b/Airline.Application.Contracts/Ticket/ITicketService.cs @@ -0,0 +1,25 @@ +using Airline.Application.Contracts.Flight; +using Airline.Application.Contracts.Passenger; + +namespace Airline.Application.Contracts.Ticket; + +/// +/// Service contract for managing tickets in the airline system. +/// Extends the generic CRUD interface with ticket-specific analytical operations. +/// +public interface ITicketService : IApplicationService +{ + /// + /// Retrieves the flight associated with a specific ticket. + /// + /// The unique identifier of the ticket. + /// The flight DTO linked to the ticket. + public Task GetFlightAsync(int ticketId); + + /// + /// Retrieves the passenger associated with a specific ticket. + /// + /// The unique identifier of the ticket. + /// The passenger DTO linked to the ticket. + public Task GetPassengerAsync(int ticketId); +} \ No newline at end of file diff --git a/Airline.Application.Contracts/Ticket/TicketDto.cs b/Airline.Application.Contracts/Ticket/TicketDto.cs new file mode 100644 index 000000000..d71d3811a --- /dev/null +++ b/Airline.Application.Contracts/Ticket/TicketDto.cs @@ -0,0 +1,20 @@ +namespace Airline.Application.Contracts.Ticket; + +/// +/// Data Transfer Object (DTO) representing a ticket in the airline system. +/// Used for read operations and data exchange with clients. +/// +/// The unique identifier of the ticket. +/// The unique identifier of the associated flight. +/// The unique identifier of the associated passenger. +/// The assigned seat number (e.g., "12A"). +/// Indicates whether hand luggage is carried. +/// The total checked baggage weight in kilograms (null if no baggage). +public record TicketDto( + int Id, + int FlightId, + int PassengerId, + string SeatNumber, + bool HandLuggage, + double? BaggageWeight +); \ No newline at end of file diff --git a/Airline.Application/Airline.Application.csproj b/Airline.Application/Airline.Application.csproj new file mode 100644 index 000000000..8e1d69d8a --- /dev/null +++ b/Airline.Application/Airline.Application.csproj @@ -0,0 +1,18 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + diff --git a/Airline.Application/AirlineMapperProfile.cs b/Airline.Application/AirlineMapperProfile.cs new file mode 100644 index 000000000..08ec8b2b1 --- /dev/null +++ b/Airline.Application/AirlineMapperProfile.cs @@ -0,0 +1,44 @@ +using Airline.Application.Contracts.Flight; +using Airline.Application.Contracts.Passenger; +using Airline.Application.Contracts.Ticket; +using Airline.Application.Contracts.ModelFamily; +using Airline.Application.Contracts.PlaneModel; +using Airline.Domain.Items; +using AutoMapper; + +namespace Airline.Application; + +/// +/// AutoMapper profile that defines mapping configurations between +/// domain entities and Data Transfer Objects (DTOs). +/// Ensures consistent and safe conversion between layers of the application. +/// +public class AirlineProfile : Profile +{ + /// + /// Initializes a new instance of the class + /// and configures all entity-to-DTO and DTO-to-entity mappings. + /// + public AirlineProfile() + { + // ModelFamily mappings + CreateMap(); + CreateMap(); + + // PlaneModel mappings + CreateMap(); + CreateMap(); + + // Flight mappings + CreateMap(); + CreateMap(); + + // Passenger mappings + CreateMap(); + CreateMap(); + + // Ticket mappings + CreateMap(); + CreateMap(); + } +} \ No newline at end of file diff --git a/Airline.Application/Services/AnalyticService.cs b/Airline.Application/Services/AnalyticService.cs new file mode 100644 index 000000000..ad0628637 --- /dev/null +++ b/Airline.Application/Services/AnalyticService.cs @@ -0,0 +1,130 @@ +using Airline.Application.Contracts.Flight; +using Airline.Application.Contracts.Passenger; +using Airline.Domain; +using Airline.Domain.Items; +using AutoMapper; + +namespace Airline.Application.Services; + +/// +/// Provides analytical and reporting capabilities over airline data. +/// Implements aggregated queries for business intelligence and operational insights. +/// +public class AnalyticsService( + IRepository flightRepository, + IRepository ticketRepository, + IRepository passengerRepository, + IMapper mapper +) : IAnalyticsService +{ + /// + /// Retrieves the top N flights by number of passengers. + /// + /// The number of top flights to return (default: 5). + /// A list of flight DTOs sorted by passenger count in descending order. + public async Task> GetTopFlightsByPassengerCountAsync(int top = 5) + { + var flights = await flightRepository.GetAllAsync(); + var tickets = await ticketRepository.GetAllAsync(); + + var flightPassengerCounts = tickets + .GroupBy(t => t.FlightId) + .Select(g => new { FlightId = g.Key, Count = g.Count() }) + .OrderByDescending(x => x.Count) + .Take(top) + .Select(x => x.FlightId) + .ToHashSet(); + + var topFlights = flights + .Where(f => flightPassengerCounts.Contains(f.Id)) + .ToList(); + + var flightCountMap = tickets + .GroupBy(t => t.FlightId) + .ToDictionary(g => g.Key, g => g.Count()); + + return topFlights + .OrderByDescending(f => flightCountMap.GetValueOrDefault(f.Id, 0)) + .Select(mapper.Map) + .ToList(); + } + + /// + /// Retrieves flights with the minimal travel time. + /// + /// A list of flight DTOs with the shortest duration. + public async Task> GetFlightsWithMinTravelTimeAsync() + { + var flights = await flightRepository.GetAllAsync(); + if (!flights.Any()) return []; + + var minTime = flights.Min(f => f.ArrivalDateTime - f.DepartureDateTime); + return flights + .Where(f => f.ArrivalDateTime - f.DepartureDateTime == minTime) + .Select(mapper.Map) + .ToList(); + } + + /// + /// Retrieves passengers with zero baggage for a specific flight. + /// + /// The unique identifier of the flight. + /// A list of passenger DTOs with no baggage, sorted by full name. + /// + /// Thrown if the specified flight does not exist. + /// + public async Task> GetPassengersWithZeroBaggageOnFlightAsync(int flightId) + { + var flight = await flightRepository.GetAsync(flightId); + if (flight == null) + throw new KeyNotFoundException($"Flight with ID '{flightId}' not found."); + + var tickets = await ticketRepository.GetAllAsync(); + var passengerIds = tickets + .Where(t => t.FlightId == flightId && t.BaggageWeight == null) + .Select(t => t.PassengerId) + .ToHashSet(); + + if (!passengerIds.Any()) return []; + + var passengers = await passengerRepository.GetAllAsync(); + return passengers + .Where(p => passengerIds.Contains(p.Id)) + .OrderBy(p => p.PassengerName) // сортировка по ФИО + .Select(mapper.Map) + .ToList(); + } + + /// + /// Retrieves flights of a specific aircraft model within a date period. + /// + /// The unique identifier of the aircraft model. + /// Start date of the period (inclusive). + /// End date of the period (inclusive). + /// A list of flight DTOs matching the criteria. + public async Task> GetFlightsByModelInPeriodAsync(int modelId, DateTime from, DateTime to) + { + var flights = await flightRepository.GetAllAsync(); + return flights + .Where(f => f.ModelId == modelId && + f.DepartureDateTime >= from && + f.DepartureDateTime <= to) + .Select(mapper.Map) + .ToList(); + } + + /// + /// Retrieves flights by route (departure city → arrival city). + /// + /// The city of departure. + /// The city of arrival. + /// A list of flight DTOs matching the route. + public async Task> GetFlightsByRouteAsync(string departure, string arrival) + { + var flights = await flightRepository.GetAllAsync(); + return flights + .Where(f => f.DepartureCity == departure && f.ArrivalCity == arrival) + .Select(mapper.Map) + .ToList(); + } +} \ No newline at end of file diff --git a/Airline.Application/Services/FlightService.cs b/Airline.Application/Services/FlightService.cs new file mode 100644 index 000000000..ff8b93de3 --- /dev/null +++ b/Airline.Application/Services/FlightService.cs @@ -0,0 +1,150 @@ +using Airline.Application.Contracts.Flight; +using Airline.Application.Contracts.Passenger; +using Airline.Application.Contracts.PlaneModel; +using Airline.Application.Contracts.Ticket; +using Airline.Domain; +using Airline.Domain.Items; +using AutoMapper; + +namespace Airline.Application.Services; + +/// +/// Provides business logic for managing flights in the airline system. +/// Handles CRUD operations, validation of aircraft model existence, +/// and retrieval of associated tickets, passengers, and aircraft models. +/// +public class FlightService( + IRepository flightRepository, + IRepository planeModelRepository, + IRepository ticketRepository, + IRepository passengerRepository, + IMapper mapper +) : IFlightService +{ + /// + /// Creates a new flight. + /// Validates that the associated aircraft model exists. + /// + /// The flight creation data transfer object. + /// The created flight DTO. + /// + /// Thrown if the specified aircraft model does not exist. + /// + public async Task CreateAsync(CreateFlightDto dto) + { + if (await planeModelRepository.GetAsync(dto.ModelId) == null) + throw new KeyNotFoundException($"Plane model '{dto.ModelId}' not found."); + + var flight = mapper.Map(dto); + var maxId = 0; + var last = await flightRepository.GetAllAsync(); + if (last.Any()) + { + maxId = last.Max(f => f.Id); + } + flight.Id = maxId + 1; + var created = await flightRepository.CreateAsync(flight); + return mapper.Map(created); + } + + /// + /// Retrieves a flight by its unique identifier. + /// + /// The unique identifier of the flight. + /// The flight DTO if found; otherwise, null. + public async Task GetByIdAsync(int id) + { + var entity = await flightRepository.GetAsync(id); + return entity == null ? null : mapper.Map(entity); + } + + /// + /// Retrieves all flights in the system. + /// + /// A list of all flight DTOs. + public async Task> GetAllAsync() => + (await flightRepository.GetAllAsync()).Select(mapper.Map).ToList(); + + /// + /// Updates an existing flight with new data. + /// + /// The updated flight data transfer object. + /// The unique identifier of the flight to update. + /// The updated flight DTO. + /// + /// Thrown if the flight does not exist. + /// + public async Task UpdateAsync(CreateFlightDto dto, int id) + { + var existing = await flightRepository.GetAsync(id) + ?? throw new KeyNotFoundException($"Flight '{id}' not found."); + mapper.Map(dto, existing); + var updated = await flightRepository.UpdateAsync(existing); + return mapper.Map(updated); + } + + /// + /// Deletes a flight by its unique identifier. + /// + /// The unique identifier of the flight to delete. + /// True if the flight was deleted; otherwise, false. + public async Task DeleteAsync(int id) => await flightRepository.DeleteAsync(id); + + /// + /// Retrieves the aircraft model associated with a specific flight. + /// + /// The unique identifier of the flight. + /// The aircraft model DTO associated with the flight. + /// + /// Thrown if the flight does not exist. + /// + /// + /// Thrown if the associated aircraft model is missing (data integrity issue). + /// + public async Task GetPlaneModelAsync(int flightId) + { + var flight = await flightRepository.GetAsync(flightId) + ?? throw new KeyNotFoundException($"Flight '{flightId}' not found."); + var model = await planeModelRepository.GetAsync(flight.ModelId) + ?? throw new InvalidOperationException($"Model '{flight.ModelId}' missing."); + return mapper.Map(model); + } + + /// + /// Retrieves all tickets associated with a specific flight. + /// + /// The unique identifier of the flight. + /// A list of ticket DTOs linked to the flight. + /// + /// Thrown if the flight does not exist. + /// + public async Task> GetTicketsAsync(int flightId) + { + var flight = await flightRepository.GetAsync(flightId) != null; + if (!flight) + throw new KeyNotFoundException($"Flight with ID '{flightId}' not found."); + var all = await ticketRepository.GetAllAsync(); + return all.Where(t => t.FlightId == flightId) + .Select(mapper.Map).ToList(); + } + + /// + /// Retrieves all passengers associated with a specific flight. + /// + /// The unique identifier of the flight. + /// A list of passenger DTOs linked to the flight. + /// + /// Thrown if the flight does not exist. + /// + public async Task> GetPassengersAsync(int flightId) + { + var flight = await flightRepository.GetAsync(flightId) != null; + if (!flight) + throw new KeyNotFoundException($"Flight with ID '{flightId}' not found."); + var ticketDtos = await GetTicketsAsync(flightId); + var passengerIds = ticketDtos.Select(t => t.PassengerId).ToHashSet(); + var allPassengers = await passengerRepository.GetAllAsync(); + return allPassengers.Where(p => passengerIds.Contains(p.Id)) + .Select(mapper.Map).ToList(); + } +} \ No newline at end of file diff --git a/Airline.Application/Services/ModelFamilyService.cs b/Airline.Application/Services/ModelFamilyService.cs new file mode 100644 index 000000000..4d26179c1 --- /dev/null +++ b/Airline.Application/Services/ModelFamilyService.cs @@ -0,0 +1,103 @@ +using Airline.Application.Contracts.ModelFamily; +using Airline.Application.Contracts.PlaneModel; +using Airline.Domain; +using Airline.Domain.Items; +using AutoMapper; + +namespace Airline.Application.Services; + +/// +/// Provides business logic for managing aircraft model families in the airline system. +/// Handles CRUD operations and retrieval of associated aircraft models. +/// +public class ModelFamilyService( + IRepository planeModelRepository, + IRepository familyRepository, + IMapper mapper +) : IModelFamilyService +{ + /// + /// Creates a new aircraft model family. + /// + /// The model family creation data transfer object. + /// The created model family DTO. + public async Task CreateAsync(CreateModelFamilyDto dto) + { + var modelFamily = mapper.Map(dto); + var maxId = 0; + var last = await familyRepository.GetAllAsync(); + if (last.Any()) + { + maxId = last.Max(f => f.Id); + } + modelFamily.Id = maxId + 1; + var created = await familyRepository.CreateAsync(modelFamily); + return mapper.Map(created); + } + + /// + /// Retrieves a model family by its unique identifier. + /// + /// The unique identifier of the model family. + /// The model family DTO if found; otherwise, null. + public async Task GetByIdAsync(int id) + { + var entity = await familyRepository.GetAsync(id); + return entity == null ? null : mapper.Map(entity); + } + + /// + /// Retrieves all aircraft model families in the system. + /// + /// A list of all model family DTOs. + public async Task> GetAllAsync() => + (await familyRepository.GetAllAsync()).Select(mapper.Map).ToList(); + + /// + /// Updates an existing model family with new data. + /// + /// The updated model family data transfer object. + /// The unique identifier of the model family to update. + /// The updated model family DTO. + /// + /// Thrown if the model family does not exist. + /// + public async Task UpdateAsync(CreateModelFamilyDto dto, int id) + { + var existing = await familyRepository.GetAsync(id) + ?? throw new KeyNotFoundException($"Model family '{id}' not found."); + mapper.Map(dto, existing); + var updated = await familyRepository.UpdateAsync(existing); + return mapper.Map(updated); + } + + /// + /// Deletes a model family by its unique identifier. + /// + /// The unique identifier of the model family to delete. + /// True if the model family was deleted; otherwise, false. + public async Task DeleteAsync(int id) => await familyRepository.DeleteAsync(id); + + /// + /// Retrieves all aircraft models associated with a specific model family. + /// + /// The unique identifier of the model family. + /// A list of aircraft model DTOs linked to the family. + /// + /// Thrown if the model family does not exist. + /// + public async Task> GetPlaneModelsAsync(int familyId) + { + var family = await familyRepository.GetAsync(familyId); + if (family == null) + throw new KeyNotFoundException($"Model family with ID '{familyId}' not found."); + var allModels = await planeModelRepository.GetAllAsync(); + + var models = allModels + .Where(m => m.ModelFamilyId == familyId) + .Select(mapper.Map) + .ToList(); + + return models; + } +} \ No newline at end of file diff --git a/Airline.Application/Services/PassengerService.cs b/Airline.Application/Services/PassengerService.cs new file mode 100644 index 000000000..d11516cd7 --- /dev/null +++ b/Airline.Application/Services/PassengerService.cs @@ -0,0 +1,134 @@ +using Airline.Application.Contracts.Flight; +using Airline.Application.Contracts.Passenger; +using Airline.Application.Contracts.Ticket; +using Airline.Domain; +using Airline.Domain.Items; +using AutoMapper; +using System.Text.RegularExpressions; + +namespace Airline.Application.Services; + +/// +/// Provides business logic for managing passengers in the airline system. +/// Handles CRUD operations, validation of personal data (name must not contain digits), +/// and retrieval of associated tickets and flights. +/// +public class PassengerService( + IRepository passengerRepository, + IRepository ticketRepository, + IRepository flightRepository, + IMapper mapper +) : IPassengerService +{ + /// + /// Creates a new passenger. + /// Validates that the passenger name is not empty and does not contain digits. + /// + /// The passenger creation data transfer object. + /// The created passenger DTO. + /// + /// Thrown if the passenger name is empty, whitespace, or contains digits. + /// + public async Task CreateAsync(CreatePassengerDto dto) + { + var passenger = mapper.Map(dto); + var maxId = 0; + var last = await passengerRepository.GetAllAsync(); + if (last.Any()) + { + maxId = last.Max(p => p.Id); + } + passenger.Id = maxId + 1; + if (string.IsNullOrWhiteSpace(dto.PassengerName) || Regex.IsMatch(dto.PassengerName, @"\d")) + throw new ArgumentException("Passenger name must not be empty or contain digits."); + var created = await passengerRepository.CreateAsync(passenger); + return mapper.Map(created); + } + + /// + /// Retrieves a passenger by its unique identifier. + /// + /// The unique identifier of the passenger. + /// The passenger DTO if found; otherwise, null. + public async Task GetByIdAsync(int id) + { + var entity = await passengerRepository.GetAsync(id); + return entity == null ? null : mapper.Map(entity); + } + + /// + /// Retrieves all passengers in the system. + /// + /// A list of all passenger DTOs. + public async Task> GetAllAsync() => + (await passengerRepository.GetAllAsync()).Select(mapper.Map).ToList(); + + /// + /// Updates an existing passenger with new data. + /// Validates that the passenger name is not empty and does not contain digits. + /// + /// The updated passenger data transfer object. + /// The unique identifier of the passenger to update. + /// The updated passenger DTO. + /// + /// Thrown if the passenger does not exist. + /// + /// + /// Thrown if the passenger name is empty, whitespace, or contains digits. + /// + public async Task UpdateAsync(CreatePassengerDto dto, int id) + { + var existing = await passengerRepository.GetAsync(id) + ?? throw new KeyNotFoundException($"Passenger '{id}' not found."); + mapper.Map(dto, existing); + if (string.IsNullOrWhiteSpace(dto.PassengerName) || Regex.IsMatch(dto.PassengerName, @"\d")) + throw new ArgumentException("Passenger name must not be empty or contain digits."); + var updated = await passengerRepository.UpdateAsync(existing); + return mapper.Map(updated); + } + + /// + /// Deletes a passenger by its unique identifier. + /// + /// The unique identifier of the passenger to delete. + /// True if the passenger was deleted; otherwise, false. + public async Task DeleteAsync(int id) => await passengerRepository.DeleteAsync(id); + + /// + /// Retrieves all tickets associated with a specific passenger. + /// + /// The unique identifier of the passenger. + /// A list of ticket DTOs linked to the passenger. + /// + /// Thrown if the passenger does not exist. + /// + public async Task> GetTicketsAsync(int passengerId) + { + var passengerExists = await passengerRepository.GetAsync(passengerId) != null; + if (!passengerExists) + throw new KeyNotFoundException($"Passenger with ID '{passengerId}' not found."); + var all = await ticketRepository.GetAllAsync(); + return all.Where(t => t.PassengerId == passengerId) + .Select(mapper.Map).ToList(); + } + + /// + /// Retrieves all flights associated with a specific passenger. + /// + /// The unique identifier of the passenger. + /// A list of flight DTOs linked to the passenger. + /// + /// Thrown if the passenger does not exist. + /// + public async Task> GetFlightsAsync(int passengerId) + { + var passengerExists = await passengerRepository.GetAsync(passengerId) != null; + if (!passengerExists) + throw new KeyNotFoundException($"Passenger with ID '{passengerId}' not found."); + var ticketDtos = await GetTicketsAsync(passengerId); + var flightIds = ticketDtos.Select(t => t.FlightId).ToHashSet(); + var allFlights = await flightRepository.GetAllAsync(); + return allFlights.Where(f => flightIds.Contains(f.Id)) + .Select(mapper.Map).ToList(); + } +} \ No newline at end of file diff --git a/Airline.Application/Services/PlaneModelService.cs b/Airline.Application/Services/PlaneModelService.cs new file mode 100644 index 000000000..a4c665c97 --- /dev/null +++ b/Airline.Application/Services/PlaneModelService.cs @@ -0,0 +1,119 @@ +using Airline.Application.Contracts.ModelFamily; +using Airline.Application.Contracts.PlaneModel; +using Airline.Domain; +using Airline.Domain.Items; +using AutoMapper; + +namespace Airline.Application.Services; + +/// +/// Provides business logic for managing aircraft models in the airline system. +/// Handles CRUD operations, validation of technical specifications, +/// and retrieval of associated model families. +/// +public class PlaneModelService( + IRepository planeModelRepository, + IRepository familyRepository, + IMapper mapper +) : IPlaneModelService +{ + /// + /// Creates a new aircraft model. + /// Validates that the model family exists and that capacity values are non-negative. + /// + /// The aircraft model creation data transfer object. + /// The created aircraft model DTO. + /// + /// Thrown if the specified model family does not exist. + /// + /// + /// Thrown if passenger capacity or cargo capacity is negative. + /// + public async Task CreateAsync(CreatePlaneModelDto dto) + { + if (await familyRepository.GetAsync(dto.ModelFamilyId) == null) + throw new KeyNotFoundException($"Model family '{dto.ModelFamilyId}' not found."); + + var planeModel = mapper.Map(dto); + var maxId = 0; + var last = await planeModelRepository.GetAllAsync(); + if (last.Any()) + { + maxId = last.Max(m => m.Id); + } + planeModel.Id = maxId + 1; + if (dto.PassengerCapacity < 0 || dto.CargoCapacity < 0) + throw new ArgumentException("Capacity values cannot be negative."); + var created = await planeModelRepository.CreateAsync(planeModel); + return mapper.Map(created); + } + + /// + /// Retrieves an aircraft model by its unique identifier. + /// + /// The unique identifier of the aircraft model. + /// The aircraft model DTO if found; otherwise, null. + public async Task GetByIdAsync(int id) + { + var entity = await planeModelRepository.GetAsync(id); + return entity == null ? null : mapper.Map(entity); + } + + /// + /// Retrieves all aircraft models in the system. + /// + /// A list of all aircraft model DTOs. + public async Task> GetAllAsync() => + (await planeModelRepository.GetAllAsync()).Select(mapper.Map).ToList(); + + /// + /// Updates an existing aircraft model with new data. + /// Validates that capacity values are non-negative. + /// + /// The updated aircraft model data transfer object. + /// The unique identifier of the model to update. + /// The updated aircraft model DTO. + /// + /// Thrown if the aircraft model does not exist. + /// + /// + /// Thrown if passenger capacity or cargo capacity is negative. + /// + public async Task UpdateAsync(CreatePlaneModelDto dto, int id) + { + var existing = await planeModelRepository.GetAsync(id) + ?? throw new KeyNotFoundException($"Plane model '{id}' not found."); + mapper.Map(dto, existing); + if (dto.PassengerCapacity < 0 || dto.CargoCapacity < 0) + throw new ArgumentException("Capacity values cannot be negative."); + var updated = await planeModelRepository.UpdateAsync(existing); + return mapper.Map(updated); + } + + /// + /// Deletes an aircraft model by its unique identifier. + /// + /// The unique identifier of the model to delete. + /// True if the model was deleted; otherwise, false. + public async Task DeleteAsync(int id) => await planeModelRepository.DeleteAsync(id); + + /// + /// Retrieves the model family associated with a specific aircraft model. + /// + /// The unique identifier of the aircraft model. + /// The model family DTO associated with the model. + /// + /// Thrown if the aircraft model does not exist. + /// + /// + /// Thrown if the associated model family is missing (data integrity issue). + /// + public async Task GetModelFamilyAsync(int modelId) + { + var model = await planeModelRepository.GetAsync(modelId) + ?? throw new KeyNotFoundException($"Plane model '{modelId}' not found."); + var family = await familyRepository.GetAsync(model.ModelFamilyId) + ?? throw new InvalidOperationException($"Family '{model.ModelFamilyId}' missing."); + return mapper.Map(family); + } +} \ No newline at end of file diff --git a/Airline.Application/Services/TicketService.cs b/Airline.Application/Services/TicketService.cs new file mode 100644 index 000000000..c74909faf --- /dev/null +++ b/Airline.Application/Services/TicketService.cs @@ -0,0 +1,142 @@ +using Airline.Application.Contracts.Flight; +using Airline.Application.Contracts.Passenger; +using Airline.Application.Contracts.Ticket; +using Airline.Domain; +using Airline.Domain.Items; +using AutoMapper; + +namespace Airline.Application.Services; + +/// +/// Provides business logic for managing tickets in the airline system. +/// Handles CRUD operations, validation, and retrieval of related entities (flight, passenger). +/// +public class TicketService( + IRepository ticketRepository, + IRepository flightRepository, + IRepository passengerRepository, + IMapper mapper +) : ITicketService +{ + /// + /// Creates a new ticket for a passenger on a specific flight. + /// Validates that the flight and passenger exist and that baggage weight is non-negative. + /// + /// The ticket creation data transfer object. + /// The created ticket DTO. + /// + /// Thrown if the specified flight or passenger does not exist. + /// + /// + /// Thrown if baggage weight is negative. + /// + public async Task CreateAsync(CreateTicketDto dto) + { + if (await flightRepository.GetAsync(dto.FlightId) == null) + throw new KeyNotFoundException($"Flight '{dto.FlightId}' not found."); + if (await passengerRepository.GetAsync(dto.PassengerId) == null) + throw new KeyNotFoundException($"Passenger '{dto.PassengerId}' not found."); + + var ticket = mapper.Map(dto); + var maxId = 0; + var last = await ticketRepository.GetAllAsync(); + if (last.Any()) + { + maxId = last.Max(t => t.Id); + } + ticket.Id = maxId + 1; + if (dto.BaggageWeight < 0) + throw new ArgumentException("BaggageWeight cannot be negative."); + var created = await ticketRepository.CreateAsync(ticket); + return mapper.Map(created); + } + + /// + /// Retrieves a ticket by its unique identifier. + /// + /// The unique identifier of the ticket. + /// The ticket DTO if found; otherwise, null. + public async Task GetByIdAsync(int id) + { + var entity = await ticketRepository.GetAsync(id); + return entity == null ? null : mapper.Map(entity); + } + + /// + /// Retrieves all tickets in the system. + /// + /// A list of all ticket DTOs. + public async Task> GetAllAsync() => + (await ticketRepository.GetAllAsync()).Select(mapper.Map).ToList(); + + /// + /// Updates an existing ticket with new data. + /// Validates that baggage weight is non-negative. + /// + /// The updated ticket data transfer object. + /// The unique identifier of the ticket to update. + /// The updated ticket DTO. + /// + /// Thrown if the ticket does not exist. + /// + /// + /// Thrown if baggage weight is negative. + /// + public async Task UpdateAsync(CreateTicketDto dto, int id) + { + var existing = await ticketRepository.GetAsync(id) + ?? throw new KeyNotFoundException($"Ticket '{id}' not found."); + mapper.Map(dto, existing); + if (dto.BaggageWeight < 0) + throw new ArgumentException("BaggageWeight cannot be negative."); + var updated = await ticketRepository.UpdateAsync(existing); + return mapper.Map(updated); + } + + /// + /// Deletes a ticket by its unique identifier. + /// + /// The unique identifier of the ticket to delete. + /// True if the ticket was deleted; otherwise, false. + public async Task DeleteAsync(int id) => await ticketRepository.DeleteAsync(id); + + /// + /// Retrieves the flight associated with a specific ticket. + /// + /// The unique identifier of the ticket. + /// The flight DTO associated with the ticket. + /// + /// Thrown if the ticket does not exist. + /// + /// + /// Thrown if the associated flight is missing (data integrity issue). + /// + public async Task GetFlightAsync(int ticketId) + { + var ticket = await ticketRepository.GetAsync(ticketId) + ?? throw new KeyNotFoundException($"Ticket '{ticketId}' not found."); + var flight = await flightRepository.GetAsync(ticket.FlightId) + ?? throw new InvalidOperationException($"Flight '{ticket.FlightId}' missing."); + return mapper.Map(flight); + } + + /// + /// Retrieves the passenger associated with a specific ticket. + /// + /// The unique identifier of the ticket. + /// The passenger DTO associated with the ticket. + /// + /// Thrown if the ticket does not exist. + /// + /// + /// Thrown if the associated passenger is missing (data integrity issue). + /// + public async Task GetPassengerAsync(int ticketId) + { + var ticket = await ticketRepository.GetAsync(ticketId) + ?? throw new KeyNotFoundException($"Ticket '{ticketId}' not found."); + var passenger = await passengerRepository.GetAsync(ticket.PassengerId) + ?? throw new InvalidOperationException($"Passenger '{ticket.PassengerId}' missing."); + return mapper.Map(passenger); + } +} \ No newline at end of file diff --git a/Airline.Domain/Airline.Domain.csproj b/Airline.Domain/Airline.Domain.csproj new file mode 100644 index 000000000..fa71b7ae6 --- /dev/null +++ b/Airline.Domain/Airline.Domain.csproj @@ -0,0 +1,9 @@ + + + + net8.0 + enable + enable + + + diff --git a/Airline.Domain/DataSeed/DataSeed.cs b/Airline.Domain/DataSeed/DataSeed.cs new file mode 100644 index 000000000..71ed083ba --- /dev/null +++ b/Airline.Domain/DataSeed/DataSeed.cs @@ -0,0 +1,475 @@ +using Airline.Domain.Items; + +namespace Airline.Domain.DataSeed; + +/// +/// Seeds test data for the airline system, including model families, +/// plane models, passengers, flights, and tickets. +/// +public class DataSeed +{ + /// + /// The list of model families. + /// + public List ModelFamilies { get; } + + /// + /// The list of plane models. + /// + public List PlaneModels { get; } + + /// + /// The list of passengers. + /// + public List Passengers { get; } + + /// + /// The list of flights. + /// + public List Flights { get; } + + /// + /// The list of tickets. + /// + public List Tickets { get; } + + /// + ///Initializes the DataSeed class and fills it with data. + /// + public DataSeed() + { + ModelFamilies = InitModelFamilies(); + PlaneModels = InitPlaneModels(ModelFamilies); + Passengers = InitPassengers(); + Flights = InitFlights(PlaneModels); + Tickets = InitTickets(Flights, Passengers); + } + + /// + /// Initializes the model families with predefined data. + /// + private static List InitModelFamilies() => + [ + new ModelFamily + { + Id = 1, + NameOfFamily = "A320 Family", + ManufacturerName = "Airbus" + }, + new ModelFamily + { + Id = 2, + NameOfFamily = "767 Family", + ManufacturerName = "Boeing" + }, + new ModelFamily + { + Id = 3, + NameOfFamily = "777 Family", + ManufacturerName = "Boeing" + }, + new ModelFamily + { + Id = 4, + NameOfFamily = "787 Dreamliner", + ManufacturerName = "Boeing" + }, + new ModelFamily + { + Id = 5, + NameOfFamily = "A330 Family", + ManufacturerName = "Airbus" + } + ]; + + /// + /// Initializes plane models linked to their families. + /// + private static List InitPlaneModels(List families) => + [ + new PlaneModel + { + Id = 1, + ModelName = "A320", + PlaneFamily = families[0], + MaxRange = 6000, + PassengerCapacity = 180, + CargoCapacity = 20 + }, + new PlaneModel + { + Id = 2, + ModelName = "B767-300", + PlaneFamily = families[1], + MaxRange = 5500, + PassengerCapacity = 189, + CargoCapacity = 23 + }, + new PlaneModel + { + Id = 3, + ModelName = "B777-300ER", + PlaneFamily = families[2], + MaxRange = 11000, + PassengerCapacity = 370, + CargoCapacity = 45 + }, + new PlaneModel + { + Id = 4, + ModelName = "B787-9", + PlaneFamily = families[3], + MaxRange = 12000, + PassengerCapacity = 290, + CargoCapacity = 40 + }, + new PlaneModel + { + Id = 5, + ModelName = "A330-300", + PlaneFamily = families[4], + MaxRange = 10500, + PassengerCapacity = 300, + CargoCapacity = 42 + } + ]; + + /// + /// Initializes a list of passengers. + /// + private static List InitPassengers() => + [ + new Passenger + { + Id = 1, + Passport = "477419070", + PassengerName = "Ivanov Ivan", + DateOfBirth = new(1990, 01, 15) + }, + new Passenger + { + Id = 2, + Passport = "719011722", + PassengerName = "Petrov Petr", + DateOfBirth = new(1985, 05, 22) + }, + new Passenger + { + Id = 3, + Passport = "269997862", + PassengerName = "Alyohin Alexey", + DateOfBirth = new(1992, 03, 10) + }, + new Passenger + { + Id = 4, + Passport = "690256588", + PassengerName = "Kuzina Anna", + DateOfBirth = new(1991, 07, 30) + }, + new Passenger + { + Id = 5, + Passport = "816817823", + PassengerName = "Kuzin Dmitry", + DateOfBirth = new(1988, 11, 05) + }, + new Passenger + { + Id = 6, + Passport = "303776467", + PassengerName = "Nikitich Dobrynya", + DateOfBirth = new(1995, 09, 18) + }, + new Passenger + { + Id = 7, + Passport = "510907182", + PassengerName = "Popovich Alex", + DateOfBirth = new(1993, 04, 12) + }, + new Passenger + { + Id = 8, + Passport = "463835340", + PassengerName = "Kolyan", + DateOfBirth = new(1987, 08, 25) + }, + new Passenger + { + Id = 9, + Passport = "877654233", + PassengerName = "Lebedev Nikolay Ivanovich", + DateOfBirth = new(1960, 02, 14) + }, + new Passenger + { + Id = 10, + Passport = "112971133", + PassengerName = "Sokolov Tigran", + DateOfBirth = new(1994, 12, 03) + } + ]; + + /// + /// Initializes flights with plane models and schedules. + /// + private static List InitFlights(List models) => + [ + + new Flight + { + Id = 1, + FlightCode = "SU101", + DepartureCity = "Samara", + ArrivalCity = "Wonderland", + DepartureDateTime = new DateTime(2025, 10, 10, 8, 0, 0), + ArrivalDateTime = new DateTime(2025, 10, 10, 15, 0, 0), + TravelTime = TimeSpan.FromHours(2), + Model = models[0] + }, + + new Flight + { + Id = 2, + FlightCode = "SU102", + DepartureCity = "Moscow", + ArrivalCity = "Paris", + DepartureDateTime = new(2025, 10, 10, 6, 5, 0), + ArrivalDateTime = new(2025, 10, 10, 9, 5, 0), + TravelTime = TimeSpan.FromHours(3), + Model = models[1] + }, + + new Flight + { + Id = 3, + FlightCode = "SU103", + DepartureCity = "Berlin", + ArrivalCity = "Paris", + DepartureDateTime = new(2025, 10, 10, 5, 0, 0), + ArrivalDateTime = new(2025, 10, 10, 10, 0, 0), + TravelTime = TimeSpan.FromHours(5), + Model = models[2] + }, + + new Flight + { + Id = 4, + FlightCode = "SU104", + DepartureCity = "Samara", + ArrivalCity = "Wonderland", + DepartureDateTime = new(2025, 10, 11, 6, 0, 0), + ArrivalDateTime = new(2025, 10, 11, 8, 30, 0), + TravelTime = TimeSpan.FromHours(2.5), + Model = models[3] + }, + + new Flight + { + Id = 5, + FlightCode = "AZ201", + DepartureCity = "Rome", + ArrivalCity = "Milan", + DepartureDateTime = new(2025, 10, 11, 22, 0, 0), + ArrivalDateTime = new(2025, 10, 12, 2, 30, 0), + TravelTime = TimeSpan.FromHours(4.5), + Model = models[4] + }, + + new Flight + { + Id = 6, + FlightCode = "SU200", + DepartureCity = "Moscow", + ArrivalCity = "Tokyo", + DepartureDateTime = new(2025, 10, 11, 15, 0, 0), + ArrivalDateTime = new(2025, 10, 12, 6, 0, 0), + TravelTime = TimeSpan.FromHours(15), + Model = models[0] + }, + + new Flight + { + Id = 7, + FlightCode = "DL100", + DepartureCity = "New York", + ArrivalCity = "London", + DepartureDateTime = new(2025, 10, 12, 7, 20, 0), + ArrivalDateTime = new(2025, 10, 13, 13, 20, 0), + TravelTime = TimeSpan.FromHours(6), + Model = models[1] + }, + + new Flight + { + Id = 8, + FlightCode = "SU105", + DepartureCity = "Paris", + ArrivalCity = "Moscow", + DepartureDateTime = new(2025, 10, 13, 23, 0, 0), + ArrivalDateTime = new(2025, 10, 14, 6, 0, 0), + TravelTime = TimeSpan.FromHours(7), + Model = models[0] + } + ]; + + /// + /// Initializes tickets linking flights to passengers. + /// + private static List InitTickets(List flights, List passengers) => + [ + + new Ticket + { + Id = 1, + Flight = flights[0], + Passenger = passengers[0], + SeatNumber = "12A", + HandLuggage = true, + BaggageWeight = 15.6 + }, + new Ticket + { + Id = 2, + Flight = flights[0], + Passenger = passengers[1], + SeatNumber = "12B", + HandLuggage = false, + BaggageWeight = null + }, + new Ticket + { + Id = 3, + Flight = flights[0], + Passenger = passengers[2], + SeatNumber = "12C", + HandLuggage = true, + BaggageWeight = null + }, + new Ticket + { + Id = 4, + Flight = flights[0], + Passenger = passengers[3], + SeatNumber = "13A", + HandLuggage = true, + BaggageWeight = 1.2 + }, + new Ticket + { + Id = 5, + Flight = flights[0], + Passenger = passengers[4], + SeatNumber = "13B", + HandLuggage = true, + BaggageWeight = 10.0 + }, + + new Ticket + { + Id = 6, + Flight = flights[1], + Passenger = passengers[5], + SeatNumber = "15A", + HandLuggage = true, + BaggageWeight = 5.2 + }, + new Ticket + { + Id = 7, + Flight = flights[1], + Passenger = passengers[6], + SeatNumber = "15B", + HandLuggage = true, + BaggageWeight = 18.0 + }, + new Ticket + { + Id = 8, + Flight = flights[1], + Passenger = passengers[7], + SeatNumber = "15C", + HandLuggage = false, + BaggageWeight = null + }, + + new Ticket + { + Id = 9, + Flight = flights[2], + Passenger = passengers[8], + SeatNumber = "20A", + HandLuggage = true, + BaggageWeight = 3.2 + }, + new Ticket + { + Id = 10, + Flight = flights[2], + Passenger = passengers[9], + SeatNumber = "20B", + HandLuggage = true, + BaggageWeight = 7.0 + }, + + new Ticket + { + Id = 11, + Flight = flights[3], + Passenger = passengers[0], + SeatNumber = "10A", + HandLuggage = false, + BaggageWeight = 4.2 + }, + + new Ticket + { + Id = 12, + Flight = flights[4], + Passenger = passengers[1], + SeatNumber = "5A", + HandLuggage = true, + BaggageWeight = 6.0 + }, + + new Ticket + { + Id = 13, + Flight = flights[5], + Passenger = passengers[2], + SeatNumber = "1A", + HandLuggage = true, + BaggageWeight = 25.0 + }, + + new Ticket + { + Id = 14, + Flight = flights[6], + Passenger = passengers[3], + SeatNumber = "8A", + HandLuggage = false, + BaggageWeight = null + }, + + new Ticket + { + Id = 15, + Flight = flights[7], + Passenger = passengers[4], + SeatNumber = "7A", + HandLuggage = true, + BaggageWeight = 11.6 + }, + new Ticket + { + Id = 16, + Flight = flights[7], + Passenger = passengers[5], + SeatNumber = "7B", + HandLuggage = false, + BaggageWeight = 0.5 + } + ]; +} \ No newline at end of file diff --git a/Airline.Domain/IRepository.cs b/Airline.Domain/IRepository.cs new file mode 100644 index 000000000..86d0cc24e --- /dev/null +++ b/Airline.Domain/IRepository.cs @@ -0,0 +1,52 @@ +namespace Airline.Domain; + +/// +/// Defines a generic repository interface that provides +/// basic CRUD (Create, Read, Update, Delete) operations. +/// +/// The type of the entity being managed. +/// The type of the entity's unique identifier. +public interface IRepository + where TEntity : class +{ + /// + /// Creates a new entity in the data store. + /// + /// The entity instance to create. + /// The created entity, typically with an assigned identifier. + public Task CreateAsync(TEntity entity); + + /// + /// Retrieves an entity from the data store by its unique identifier. + /// + /// The unique identifier of the entity to retrieve. + /// + /// The entity if found; otherwise, . + /// + public Task GetAsync(TKey id); + + /// + /// Retrieves all entities of the specified type from the data store. + /// + /// + /// A list containing all entities of the specified type. + /// + public Task> GetAllAsync(); + + /// + /// Updates an existing entity in the data store. + /// + /// The entity instance containing updated data. + /// The updated entity. + public Task UpdateAsync(TEntity entity); + + /// + /// Deletes an entity from the data store by its unique identifier. + /// + /// The unique identifier of the entity to delete. + /// + /// if the entity was successfully deleted; + /// otherwise, . + /// + public Task DeleteAsync(TKey id); +} \ No newline at end of file diff --git a/Airline.Domain/Items/Flight.cs b/Airline.Domain/Items/Flight.cs new file mode 100644 index 000000000..fcf3b68e7 --- /dev/null +++ b/Airline.Domain/Items/Flight.cs @@ -0,0 +1,52 @@ +namespace Airline.Domain.Items; + +/// +/// The class for flight description and information about it. +/// +public class Flight +{ + /// + /// Unique flight's Id. + /// + public int Id { get; set; } + + /// + /// Flight's code. + /// + public required string FlightCode { get; set; } + + /// + /// The place of departure. + /// + public required string DepartureCity { get; set; } + + /// + /// The place of arrival. + /// + public required string ArrivalCity { get; set; } + + /// + /// Date and time of the arrival. + /// + public DateTime? ArrivalDateTime { get; set; } + + /// + /// Flight's departure date and time. + /// + public DateTime? DepartureDateTime { get; set; } + + /// + /// Flight's travel time. + /// + public TimeSpan? TravelTime { get; set; } + + /// + /// The model of plane. + /// + public required PlaneModel? Model { get; set; } + + /// + /// The id of the model. + /// + public int ModelId { get; set; } +} \ No newline at end of file diff --git a/Airline.Domain/Items/ModelFamily.cs b/Airline.Domain/Items/ModelFamily.cs new file mode 100644 index 000000000..717d0987e --- /dev/null +++ b/Airline.Domain/Items/ModelFamily.cs @@ -0,0 +1,22 @@ +namespace Airline.Domain.Items; + +/// +/// The class for information about a model family. +/// +public class ModelFamily +{ + /// + /// Unique model's Id. + /// + public int Id { get; set; } + + /// + /// The name of model's family. + /// + public required string NameOfFamily { get; set; } + + /// + /// The name of the model manufacturer. + /// + public required string ManufacturerName { get; set; } +} \ No newline at end of file diff --git a/Airline.Domain/Items/Passengers.cs b/Airline.Domain/Items/Passengers.cs new file mode 100644 index 000000000..e2a0aaa46 --- /dev/null +++ b/Airline.Domain/Items/Passengers.cs @@ -0,0 +1,28 @@ +namespace Airline.Domain.Items; + +/// +/// The class for describing a passenger. +/// +public class Passenger +{ + /// + /// Unique passenger's Id. + /// + public int Id { get; set; } + + /// + /// The number of passenger's pasport. + /// + public required string Passport { get; set; } + + /// + /// Passenger's full name. + /// + public required string PassengerName { get; set; } + + /// + /// Passenger's date of birth. + /// + public DateOnly? DateOfBirth { get; set; } + +} \ No newline at end of file diff --git a/Airline.Domain/Items/PlaneModel.cs b/Airline.Domain/Items/PlaneModel.cs new file mode 100644 index 000000000..5c75c43d7 --- /dev/null +++ b/Airline.Domain/Items/PlaneModel.cs @@ -0,0 +1,42 @@ +namespace Airline.Domain.Items; + +/// +/// The class for information about a plane model. +/// +public class PlaneModel +{ + /// + /// Unique plane model's Id. + /// + public int Id { get; set; } + + /// + /// The name of plane model. + /// + public required string ModelName { get; set; } + + /// + /// The model family of the plane. + /// + public required ModelFamily? PlaneFamily { get; set; } + + /// + /// The max flight range of the plane model. + /// + public required double MaxRange { get; set; } + + /// + /// The passenger capacity of the plane model. + /// + public required int PassengerCapacity { get; set; } + + /// + /// The cargo capacity of the plane model (tons). + /// + public required double CargoCapacity { get; set; } + + /// + /// The id of model family. + /// + public int ModelFamilyId { get; set; } +} \ No newline at end of file diff --git a/Airline.Domain/Items/Ticket.cs b/Airline.Domain/Items/Ticket.cs new file mode 100644 index 000000000..1d5bd0ff6 --- /dev/null +++ b/Airline.Domain/Items/Ticket.cs @@ -0,0 +1,47 @@ +namespace Airline.Domain.Items; + +/// +/// The class for information about ticket. +/// +public class Ticket +{ + /// + /// Unique ticket's Id. + /// + public int Id { get; set; } + + /// + /// The connection between the ticket and the flight. + /// + public required Flight? Flight { get; set; } + + /// + /// The connection between the ticket and the passenger. + /// + public required Passenger? Passenger { get; set; } + + /// + /// The passenger's seat number. + /// + public required string SeatNumber { get; set; } + + /// + /// The flag to indicate if there is a hand luggage. + /// + public required bool HandLuggage { get; set; } + + /// + /// Total baggage weight. (kilograms) + /// + public double? BaggageWeight { get; set; } + + /// + /// The id to connect between the ticket and the flight. + /// + public int FlightId { get; set; } + + /// + /// The id to connect between the ticket and the passenger. + /// + public int PassengerId { get; set; } +} \ No newline at end of file diff --git a/Airline.Generator.Kafka.Host/Airline.Generator.Kafka.Host.csproj b/Airline.Generator.Kafka.Host/Airline.Generator.Kafka.Host.csproj new file mode 100644 index 000000000..c5b6e15d5 --- /dev/null +++ b/Airline.Generator.Kafka.Host/Airline.Generator.Kafka.Host.csproj @@ -0,0 +1,21 @@ + + + + net8.0 + enable + enable + true + + + + + + + + + + + + + + diff --git a/Airline.Generator.Kafka.Host/AirlineKafkaProducer.cs b/Airline.Generator.Kafka.Host/AirlineKafkaProducer.cs new file mode 100644 index 000000000..cfdd7d8e4 --- /dev/null +++ b/Airline.Generator.Kafka.Host/AirlineKafkaProducer.cs @@ -0,0 +1,46 @@ +using Confluent.Kafka; +using Airline.Application.Contracts.Flight; +using Airline.Generator.Kafka.Host.Interfaces; + +namespace Airline.Generator.Kafka.Host; + +/// +/// Kafka producer service that publishes batches of flight create contracts to a configured topic +/// +/// Application configuration used to resolve Kafka topic name +/// Kafka producer used to send messages +/// Logger instance +public sealed class AirlineKafkaProducer( + IConfiguration configuration, + IProducer> producer, + ILogger logger) : IProducerService +{ + private readonly string _topicName = + configuration.GetSection("Kafka")["TopicName"] ?? throw new KeyNotFoundException("TopicName section of Kafka is missing"); + + /// + /// Sends a batch of flight contracts to Kafka topic using flight code as message key + /// + /// Batch of flight create contracts + public async Task SendAsync(IList batch) + { + try + { + logger.LogInformation("Sending a batch of {count} contracts to {topic}", batch.Count, _topicName); + + var key = batch.FirstOrDefault()?.FlightCode ?? Guid.NewGuid().ToString(); + + var message = new Message> + { + Key = key, + Value = batch + }; + + await producer.ProduceAsync(_topicName, message); + } + catch (Exception ex) + { + logger.LogError(ex, "Exception occurred during sending a batch of {count} contracts to {topic}", batch.Count, _topicName); + } + } +} \ No newline at end of file diff --git a/Airline.Generator.Kafka.Host/Controllers/GeneratorController.cs b/Airline.Generator.Kafka.Host/Controllers/GeneratorController.cs new file mode 100644 index 000000000..4b4ed28ee --- /dev/null +++ b/Airline.Generator.Kafka.Host/Controllers/GeneratorController.cs @@ -0,0 +1,67 @@ +using Airline.Application.Contracts.Flight; +using Airline.Generator.Kafka.Host.Generator; +using Airline.Generator.Kafka.Host.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace Airline.Generator.Kafka.Host.Controllers; + +/// +/// Controller used to generate flight contracts and publish them via Kafka message broker +/// +/// Logger instance +/// Producer service used to send contracts +[Route("api/[controller]")] +[ApiController] +public sealed class GeneratorController( + ILogger logger, + IProducerService producerService) : ControllerBase +{ + /// + /// Generates flight contracts and sends them via Kafka using batches and delay between sends + /// + /// Batch size + /// Total number of contracts to send + /// Delay in seconds between batches + /// List of generated contracts + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task>> Get( + [FromQuery] int batchSize, + [FromQuery] int payloadLimit, + [FromQuery] int waitTime) + { + logger.LogInformation("Generating {limit} contracts via {batchSize} batches and {waitTime}s delay", + payloadLimit, batchSize, waitTime); + + try + { + var results = new List(); + var counter = 0; + + while (counter < payloadLimit) + { + var currentBatchSize = Math.Min(batchSize, payloadLimit - counter); + var batch = FlightGenerator.GenerateContracts(currentBatchSize); + + await producerService.SendAsync(batch); + + logger.LogInformation("Batch of {batchSize} items has been sent", currentBatchSize); + + results.AddRange(batch); + counter += currentBatchSize; + + if (counter < payloadLimit && waitTime > 0) + await Task.Delay(waitTime * 1000); + } + + logger.LogInformation("{method} method of {controller} executed successfully", nameof(Get), GetType().Name); + return Ok(results); + } + catch (Exception ex) + { + logger.LogError(ex, "An exception happened during {method} method of {controller}", nameof(Get), GetType().Name); + return StatusCode(StatusCodes.Status500InternalServerError, ex.Message); + } + } +} \ No newline at end of file diff --git a/Airline.Generator.Kafka.Host/Generator/FlightGenerator.cs b/Airline.Generator.Kafka.Host/Generator/FlightGenerator.cs new file mode 100644 index 000000000..a297db77b --- /dev/null +++ b/Airline.Generator.Kafka.Host/Generator/FlightGenerator.cs @@ -0,0 +1,58 @@ +using Airline.Application.Contracts.Flight; +using Bogus; + +namespace Airline.Generator.Kafka.Host.Generator; + +/// +/// Generates random flight contracts to emulate an external system sending data +/// +public static class FlightGenerator +{ + private static int[]? _modelFamilyIds; + private static string[]? _departureCities; + private static string[]? _arrivalCities; + + /// + /// Initializes the generator with configuration data from the specified configuration source. + /// + /// Configuration instance containing generator settings. + public static void Initialize(IConfiguration configuration) + { + _modelFamilyIds = configuration.GetSection("FlightGenerator:ModelFamilyId").Get() ?? []; + _departureCities = configuration.GetSection("FlightGenerator:DepartureCity").Get() ?? []; + _arrivalCities = configuration.GetSection("FlightGenerator:ArrivalCity").Get() ?? []; + } + + /// + /// Generates a list of flight create contracts + /// + /// Number of contracts to generate + /// Generated list of flight contracts + public static List GenerateContracts(int count) + { + if (_modelFamilyIds?.Length == 0 || + _departureCities?.Length == 0 || + _arrivalCities?.Length == 0) + { + throw new InvalidOperationException("FlightGenerator configuration is empty"); + } + + try + { + return new Faker() + .CustomInstantiator(f => new CreateFlightDto( + FlightCode: $"{f.Random.Char('A', 'Z')}{f.Random.Char('A', 'Z')}{f.Random.Number(100, 999)}", + DepartureCity: f.PickRandom(_departureCities), + ArrivalCity: f.PickRandom(_arrivalCities), + DepartureDateTime: f.Date.Future(), + ArrivalDateTime: f.Date.Future().AddHours(f.Random.Number(1, 10)), + ModelId: f.PickRandom(_modelFamilyIds) + )) + .Generate(count); + } + catch (Exception ex) + { + throw new InvalidOperationException("Failed to generate contracts", ex); + } + } +} \ No newline at end of file diff --git a/Airline.Generator.Kafka.Host/Interfaces/IProducerService.cs b/Airline.Generator.Kafka.Host/Interfaces/IProducerService.cs new file mode 100644 index 000000000..37a66623e --- /dev/null +++ b/Airline.Generator.Kafka.Host/Interfaces/IProducerService.cs @@ -0,0 +1,16 @@ +using Airline.Application.Contracts.Flight; + +namespace Airline.Generator.Kafka.Host.Interfaces; + +/// +/// Abstraction for producing batches of flight create contracts to a Kafka message broker +/// +public interface IProducerService +{ + /// + /// Sends a batch of flight create contracts + /// + /// Batch of flight create contracts to send + /// Task representing asynchronous send operation + public Task SendAsync(IList batch); +} \ No newline at end of file diff --git a/Airline.Generator.Kafka.Host/Program.cs b/Airline.Generator.Kafka.Host/Program.cs new file mode 100644 index 000000000..738a0f7f4 --- /dev/null +++ b/Airline.Generator.Kafka.Host/Program.cs @@ -0,0 +1,54 @@ +using Airline.Application.Contracts.Flight; +using Airline.Generator.Kafka.Host; +using Airline.Generator.Kafka.Host.Generator; +using Airline.Generator.Kafka.Host.Interfaces; +using Airline.Generator.Kafka.Host.Serializers; +using Airline.ServiceDefaults; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); + +FlightGenerator.Initialize(builder.Configuration); + +builder.AddKafkaProducer>("airline-kafka", + configureBuilder: kafkaBuilder => + { + kafkaBuilder.SetKeySerializer(new AirlineKeySerializer()); + kafkaBuilder.SetValueSerializer(new AirlineValueSerializer()); + }); + +builder.Services.AddScoped(); + +builder.Services.AddControllers(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(options => +{ + var assemblies = AppDomain.CurrentDomain.GetAssemblies() + .Where(a => a.GetName().Name!.StartsWith("Airline")) + .Distinct(); + + foreach (var assembly in assemblies) + { + var xmlFile = $"{assembly.GetName().Name}.xml"; + var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile); + if (File.Exists(xmlPath)) + options.IncludeXmlComments(xmlPath); + } +}); + +var app = builder.Build(); + +app.MapDefaultEndpoints(); + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseHttpsRedirection(); +app.UseAuthorization(); +app.MapControllers(); + +app.Run(); \ No newline at end of file diff --git a/Airline.Generator.Kafka.Host/Properties/launchSettings.json b/Airline.Generator.Kafka.Host/Properties/launchSettings.json new file mode 100644 index 000000000..e99c7bec6 --- /dev/null +++ b/Airline.Generator.Kafka.Host/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:60399", + "sslPort": 44344 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5206", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7260;http://localhost:5206", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/Airline.Generator.Kafka.Host/Serializers/AirlineKeySerializer.cs b/Airline.Generator.Kafka.Host/Serializers/AirlineKeySerializer.cs new file mode 100644 index 000000000..409614d06 --- /dev/null +++ b/Airline.Generator.Kafka.Host/Serializers/AirlineKeySerializer.cs @@ -0,0 +1,19 @@ +using Confluent.Kafka; +using System.Text.Json; + +namespace Airline.Generator.Kafka.Host.Serializers; + +/// +/// Kafka serializer that converts a string key into UTF8 JSON bytes +/// +public sealed class AirlineKeySerializer : ISerializer +{ + /// + /// Serializes string key into JSON byte array representation + /// + /// Key value to serialize + /// Serialization context provided by Kafka client + /// UTF8 JSON byte array + public byte[] Serialize(string data, SerializationContext context) => + JsonSerializer.SerializeToUtf8Bytes(data); +} \ No newline at end of file diff --git a/Airline.Generator.Kafka.Host/Serializers/AirlineValueSerializer.cs b/Airline.Generator.Kafka.Host/Serializers/AirlineValueSerializer.cs new file mode 100644 index 000000000..8b45aa987 --- /dev/null +++ b/Airline.Generator.Kafka.Host/Serializers/AirlineValueSerializer.cs @@ -0,0 +1,20 @@ +using Confluent.Kafka; +using Airline.Application.Contracts.Flight; +using System.Text.Json; + +namespace Airline.Generator.Kafka.Host.Serializers; + +/// +/// Kafka serializer that converts a list of flight create contracts into UTF8 JSON bytes +/// +public sealed class AirlineValueSerializer : ISerializer> +{ + /// + /// Serializes contract batch into JSON byte array representation + /// + /// Batch of flight contracts to serialize + /// Serialization context provided by Kafka client + /// UTF8 JSON byte array + public byte[] Serialize(IList data, SerializationContext context) => + JsonSerializer.SerializeToUtf8Bytes(data); +} \ No newline at end of file diff --git a/Airline.Generator.Kafka.Host/appsettings.Development.json b/Airline.Generator.Kafka.Host/appsettings.Development.json new file mode 100644 index 000000000..0c208ae91 --- /dev/null +++ b/Airline.Generator.Kafka.Host/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/Airline.Generator.Kafka.Host/appsettings.json b/Airline.Generator.Kafka.Host/appsettings.json new file mode 100644 index 000000000..9cea2c9e0 --- /dev/null +++ b/Airline.Generator.Kafka.Host/appsettings.json @@ -0,0 +1,15 @@ +{ + "FlightGenerator": { + "ModelFamilyId": [ 1, 2, 3, 4, 5 ], + "DepartureCity": [ "Moscow", "Samara", "Rome", "New York", "Berlin", "Paris" ], + "ArrivalCity": [ "Wonderland", "London", "Milan", "Moscow", "Tokyo", "Paris" ] + }, + + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/Airline.Infrastructure.EfCore/Airline.Infrastructure.EfCore.csproj b/Airline.Infrastructure.EfCore/Airline.Infrastructure.EfCore.csproj new file mode 100644 index 000000000..2dba4f0a5 --- /dev/null +++ b/Airline.Infrastructure.EfCore/Airline.Infrastructure.EfCore.csproj @@ -0,0 +1,19 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + diff --git a/Airline.Infrastructure.EfCore/AirlineDbContext.cs b/Airline.Infrastructure.EfCore/AirlineDbContext.cs new file mode 100644 index 000000000..032c590e6 --- /dev/null +++ b/Airline.Infrastructure.EfCore/AirlineDbContext.cs @@ -0,0 +1,108 @@ +using Airline.Domain.Items; +using Microsoft.EntityFrameworkCore; +using MongoDB.EntityFrameworkCore.Extensions; + +namespace Airline.Infrastructure.EfCore; + +/// +/// Represents the database context for the airline management system. +/// Configures entity-to-collection mappings and property name conventions for MongoDB. +/// +public class AirlineDbContext(DbContextOptions options) : DbContext(options) +{ + /// + /// Gets or sets the collection of aircraft model families. + /// Mapped to the 'model_families' collection in MongoDB. + /// + public DbSet ModelFamilies { get; set; } + + /// + /// Gets or sets the collection of aircraft models. + /// Mapped to the 'plane_models' collection in MongoDB. + /// + public DbSet PlaneModels { get; set; } + + /// + /// Gets or sets the collection of flights. + /// Mapped to the 'flights' collection in MongoDB. + /// + public DbSet Flights { get; set; } + + /// + /// Gets or sets the collection of passengers. + /// Mapped to the 'passengers' collection in MongoDB. + /// + public DbSet Passengers { get; set; } + + /// + /// Gets or sets the collection of tickets. + /// Mapped to the 'tickets' collection in MongoDB. + /// + public DbSet Tickets { get; set; } + + /// + /// Configures the model by mapping entities to MongoDB collections and customizing field names. + /// Disables automatic transaction behavior (MongoDB does not support transactions in this context). + /// + /// The model builder used to configure entity mappings. + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + Database.AutoTransactionBehavior = AutoTransactionBehavior.Never; + + modelBuilder.Entity(entity => + { + entity.ToCollection("model_families"); + entity.HasKey(f => f.Id); + entity.Property(f => f.Id).HasElementName("_id"); + entity.Property(f => f.NameOfFamily).HasElementName("family_name"); + entity.Property(f => f.ManufacturerName).HasElementName("manufacturer"); + }); + + modelBuilder.Entity(entity => + { + entity.ToCollection("plane_models"); + entity.HasKey(m => m.Id); + entity.Property(m => m.Id).HasElementName("_id"); + entity.Property(m => m.ModelName).HasElementName("model_name"); + entity.Property(m => m.MaxRange).HasElementName("max_range_km"); + entity.Property(m => m.PassengerCapacity).HasElementName("passenger_capacity"); + entity.Property(m => m.CargoCapacity).HasElementName("cargo_capacity_tons"); + entity.Property(m => m.ModelFamilyId).HasElementName("family_id"); + }); + + modelBuilder.Entity(entity => + { + entity.ToCollection("passengers"); + entity.HasKey(p => p.Id); + entity.Property(p => p.Id).HasElementName("_id"); + entity.Property(p => p.Passport).HasElementName("passport_number"); + entity.Property(p => p.PassengerName).HasElementName("full_name"); + entity.Property(p => p.DateOfBirth).HasElementName("date_of_birth"); + }); + + modelBuilder.Entity(entity => + { + entity.ToCollection("flights"); + entity.HasKey(f => f.Id); + entity.Property(f => f.Id).HasElementName("_id"); + entity.Property(f => f.FlightCode).HasElementName("flight_code"); + entity.Property(f => f.DepartureCity).HasElementName("departure_city"); + entity.Property(f => f.ArrivalCity).HasElementName("arrival_city"); + entity.Property(f => f.DepartureDateTime).HasElementName("departure_datetime"); + entity.Property(f => f.ArrivalDateTime).HasElementName("arrival_datetime"); + entity.Property(f => f.ModelId).HasElementName("plane_model_id"); + }); + + modelBuilder.Entity(entity => + { + entity.ToCollection("tickets"); + entity.HasKey(t => t.Id); + entity.Property(t => t.Id).HasElementName("_id"); + entity.Property(t => t.SeatNumber).HasElementName("seat_number"); + entity.Property(t => t.HandLuggage).HasElementName("has_hand_luggage"); + entity.Property(t => t.BaggageWeight).HasElementName("baggage_weight_kg"); + entity.Property(t => t.FlightId).HasElementName("flight_id"); + entity.Property(t => t.PassengerId).HasElementName("passenger_id"); + }); + } +} \ No newline at end of file diff --git a/Airline.Infrastructure.EfCore/Repositories/FlightRepository.cs b/Airline.Infrastructure.EfCore/Repositories/FlightRepository.cs new file mode 100644 index 000000000..7e490f888 --- /dev/null +++ b/Airline.Infrastructure.EfCore/Repositories/FlightRepository.cs @@ -0,0 +1,71 @@ +using Airline.Domain.Items; +using Airline.Domain; +using Microsoft.EntityFrameworkCore; + +namespace Airline.Infrastructure.EfCore.Repositories; + +/// +/// Repository implementation for flight entities. +/// Provides data access operations for the Flight domain model using Entity Framework Core with MongoDB. +/// +public class FlightRepository(AirlineDbContext context) : IRepository +{ + /// + /// Creates a new flight in the data store. + /// + /// The flight entity to create. + /// The created flight entity with assigned identifier. + public async Task CreateAsync(Flight entity) + { + var entry = await context.Flights.AddAsync(entity); + await context.SaveChangesAsync(); + return entry.Entity; + } + + /// + /// Deletes a flight from the data store by its unique identifier. + /// + /// The unique identifier of the flight to delete. + /// + /// if the flight was successfully deleted; + /// otherwise, if the flight was not found. + /// + public async Task DeleteAsync(int id) + { + var entity = await context.Flights.FindAsync(id); + if (entity == null) return false; + + context.Flights.Remove(entity); + await context.SaveChangesAsync(); + return true; + } + + /// + /// Retrieves a flight from the data store by its unique identifier. + /// + /// The unique identifier of the flight. + /// + /// The flight entity if found; otherwise, . + /// + public async Task GetAsync(int id) => + await context.Flights.FindAsync(id); + + /// + /// Retrieves all flights from the data store. + /// + /// A list of all flight entities. + public async Task> GetAllAsync() => + await context.Flights.ToListAsync(); + + /// + /// Updates an existing flight in the data store. + /// + /// The flight entity with updated data. + /// The updated flight entity. + public async Task UpdateAsync(Flight entity) + { + context.Flights.Update(entity); + await context.SaveChangesAsync(); + return entity; + } +} \ No newline at end of file diff --git a/Airline.Infrastructure.EfCore/Repositories/ModelFamilyRepository.cs b/Airline.Infrastructure.EfCore/Repositories/ModelFamilyRepository.cs new file mode 100644 index 000000000..766c7ea8d --- /dev/null +++ b/Airline.Infrastructure.EfCore/Repositories/ModelFamilyRepository.cs @@ -0,0 +1,71 @@ +using Airline.Domain.Items; +using Airline.Domain; +using Microsoft.EntityFrameworkCore; + +namespace Airline.Infrastructure.EfCore.Repositories; + +/// +/// Repository implementation for aircraft model family entities. +/// Provides data access operations for the ModelFamily domain model using Entity Framework Core with MongoDB. +/// +public class ModelFamilyRepository(AirlineDbContext context) : IRepository +{ + /// + /// Creates a new aircraft model family in the data store. + /// + /// The model family entity to create. + /// The created model family entity with assigned identifier. + public async Task CreateAsync(ModelFamily entity) + { + var entry = await context.ModelFamilies.AddAsync(entity); + await context.SaveChangesAsync(); + return entry.Entity; + } + + /// + /// Deletes an aircraft model family from the data store by its unique identifier. + /// + /// The unique identifier of the model family to delete. + /// + /// if the model family was successfully deleted; + /// otherwise, if the family was not found. + /// + public async Task DeleteAsync(int id) + { + var entity = await context.ModelFamilies.FindAsync(id); + if (entity == null) return false; + + context.ModelFamilies.Remove(entity); + await context.SaveChangesAsync(); + return true; + } + + /// + /// Retrieves an aircraft model family from the data store by its unique identifier. + /// + /// The unique identifier of the model family. + /// + /// The model family entity if found; otherwise, . + /// + public async Task GetAsync(int id) => + await context.ModelFamilies.FindAsync(id); + + /// + /// Retrieves all aircraft model families from the data store. + /// + /// A list of all model family entities. + public async Task> GetAllAsync() => + await context.ModelFamilies.ToListAsync(); + + /// + /// Updates an existing aircraft model family in the data store. + /// + /// The model family entity with updated data. + /// The updated model family entity. + public async Task UpdateAsync(ModelFamily entity) + { + context.ModelFamilies.Update(entity); + await context.SaveChangesAsync(); + return entity; + } +} \ No newline at end of file diff --git a/Airline.Infrastructure.EfCore/Repositories/PassengerRepository.cs b/Airline.Infrastructure.EfCore/Repositories/PassengerRepository.cs new file mode 100644 index 000000000..7383c306d --- /dev/null +++ b/Airline.Infrastructure.EfCore/Repositories/PassengerRepository.cs @@ -0,0 +1,71 @@ +using Airline.Domain; +using Airline.Domain.Items; +using Microsoft.EntityFrameworkCore; + +namespace Airline.Infrastructure.EfCore.Repositories; + +/// +/// Repository implementation for passenger entities. +/// Provides data access operations for the Passenger domain model using Entity Framework Core with MongoDB. +/// +public class PassengerRepository(AirlineDbContext context) : IRepository +{ + /// + /// Creates a new passenger in the data store. + /// + /// The passenger entity to create. + /// The created passenger entity with assigned identifier. + public async Task CreateAsync(Passenger entity) + { + var entry = await context.Passengers.AddAsync(entity); + await context.SaveChangesAsync(); + return entry.Entity; + } + + /// + /// Deletes a passenger from the data store by its unique identifier. + /// + /// The unique identifier of the passenger to delete. + /// + /// if the passenger was successfully deleted; + /// otherwise, if the passenger was not found. + /// + public async Task DeleteAsync(int id) + { + var entity = await context.Passengers.FindAsync(id); + if (entity == null) return false; + + context.Passengers.Remove(entity); + await context.SaveChangesAsync(); + return true; + } + + /// + /// Retrieves a passenger from the data store by its unique identifier. + /// + /// The unique identifier of the passenger. + /// + /// The passenger entity if found; otherwise, . + /// + public async Task GetAsync(int id) => + await context.Passengers.FindAsync(id); + + /// + /// Retrieves all passengers from the data store. + /// + /// A list of all passenger entities. + public async Task> GetAllAsync() => + await context.Passengers.ToListAsync(); + + /// + /// Updates an existing passenger in the data store. + /// + /// The passenger entity with updated data. + /// The updated passenger entity. + public async Task UpdateAsync(Passenger entity) + { + context.Passengers.Update(entity); + await context.SaveChangesAsync(); + return entity; + } +} \ No newline at end of file diff --git a/Airline.Infrastructure.EfCore/Repositories/PlaneModelRepository.cs b/Airline.Infrastructure.EfCore/Repositories/PlaneModelRepository.cs new file mode 100644 index 000000000..ed1a8aebe --- /dev/null +++ b/Airline.Infrastructure.EfCore/Repositories/PlaneModelRepository.cs @@ -0,0 +1,71 @@ +using Airline.Domain.Items; +using Airline.Domain; +using Microsoft.EntityFrameworkCore; + +namespace Airline.Infrastructure.EfCore.Repositories; + +/// +/// Repository implementation for aircraft model entities. +/// Provides data access operations for the PlaneModel domain model using Entity Framework Core with MongoDB. +/// +public class PlaneModelRepository(AirlineDbContext context) : IRepository +{ + /// + /// Creates a new aircraft model in the data store. + /// + /// The aircraft model entity to create. + /// The created aircraft model entity with assigned identifier. + public async Task CreateAsync(PlaneModel entity) + { + var entry = await context.PlaneModels.AddAsync(entity); + await context.SaveChangesAsync(); + return entry.Entity; + } + + /// + /// Deletes an aircraft model from the data store by its unique identifier. + /// + /// The unique identifier of the aircraft model to delete. + /// + /// if the aircraft model was successfully deleted; + /// otherwise, if the model was not found. + /// + public async Task DeleteAsync(int id) + { + var entity = await context.PlaneModels.FindAsync(id); + if (entity == null) return false; + + context.PlaneModels.Remove(entity); + await context.SaveChangesAsync(); + return true; + } + + /// + /// Retrieves an aircraft model from the data store by its unique identifier. + /// + /// The unique identifier of the aircraft model. + /// + /// The aircraft model entity if found; otherwise, . + /// + public async Task GetAsync(int id) => + await context.PlaneModels.FindAsync(id); + + /// + /// Retrieves all aircraft models from the data store. + /// + /// A list of all aircraft model entities. + public async Task> GetAllAsync() => + await context.PlaneModels.ToListAsync(); + + /// + /// Updates an existing aircraft model in the data store. + /// + /// The aircraft model entity with updated data. + /// The updated aircraft model entity. + public async Task UpdateAsync(PlaneModel entity) + { + context.PlaneModels.Update(entity); + await context.SaveChangesAsync(); + return entity; + } +} \ No newline at end of file diff --git a/Airline.Infrastructure.EfCore/Repositories/TicketRepository.cs b/Airline.Infrastructure.EfCore/Repositories/TicketRepository.cs new file mode 100644 index 000000000..c7f2925b1 --- /dev/null +++ b/Airline.Infrastructure.EfCore/Repositories/TicketRepository.cs @@ -0,0 +1,71 @@ +using Airline.Domain; +using Airline.Domain.Items; +using Microsoft.EntityFrameworkCore; + +namespace Airline.Infrastructure.EfCore.Repositories; + +/// +/// Repository implementation for ticket entities. +/// Provides data access operations for the Ticket domain model using Entity Framework Core with MongoDB. +/// +public class TicketRepository(AirlineDbContext context) : IRepository +{ + /// + /// Creates a new ticket in the data store. + /// + /// The ticket entity to create. + /// The created ticket entity with assigned identifier. + public async Task CreateAsync(Ticket entity) + { + var entry = await context.Tickets.AddAsync(entity); + await context.SaveChangesAsync(); + return entry.Entity; + } + + /// + /// Deletes a ticket from the data store by its unique identifier. + /// + /// The unique identifier of the ticket to delete. + /// + /// if the ticket was successfully deleted; + /// otherwise, if the ticket was not found. + /// + public async Task DeleteAsync(int id) + { + var entity = await context.Tickets.FindAsync(id); + if (entity == null) return false; + + context.Tickets.Remove(entity); + await context.SaveChangesAsync(); + return true; + } + + /// + /// Retrieves a ticket from the data store by its unique identifier. + /// + /// The unique identifier of the ticket. + /// + /// The ticket entity if found; otherwise, . + /// + public async Task GetAsync(int id) => + await context.Tickets.FindAsync(id); + + /// + /// Retrieves all tickets from the data store. + /// + /// A list of all ticket entities. + public async Task> GetAllAsync() => + await context.Tickets.ToListAsync(); + + /// + /// Updates an existing ticket in the data store. + /// + /// The ticket entity with updated data. + /// The updated ticket entity. + public async Task UpdateAsync(Ticket entity) + { + context.Tickets.Update(entity); + await context.SaveChangesAsync(); + return entity; + } +} \ No newline at end of file diff --git a/Airline.Infrastructure.Kafka/Airline.Infrastructure.Kafka.csproj b/Airline.Infrastructure.Kafka/Airline.Infrastructure.Kafka.csproj new file mode 100644 index 000000000..09fa9184d --- /dev/null +++ b/Airline.Infrastructure.Kafka/Airline.Infrastructure.Kafka.csproj @@ -0,0 +1,20 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + + diff --git a/Airline.Infrastructure.Kafka/AirlineKafkaConsumer.cs b/Airline.Infrastructure.Kafka/AirlineKafkaConsumer.cs new file mode 100644 index 000000000..aa2b91898 --- /dev/null +++ b/Airline.Infrastructure.Kafka/AirlineKafkaConsumer.cs @@ -0,0 +1,143 @@ +using Confluent.Kafka; +using Airline.Application.Contracts.Flight; +using Airline.Domain; +using Airline.Domain.Items; +using Airline.Infrastructure.Kafka.Deserializers; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Airline.Infrastructure.Kafka; + +/// +/// Kafka consumer service that processes flight contracts from a specified topic and persists them to the database. +/// +public sealed class FlightKafkaConsumer( + IServiceScopeFactory scopeFactory, + IConfiguration configuration, + ILogger logger +) : BackgroundService +{ + private readonly string _topicName = + configuration["Kafka:TopicName"] ?? throw new KeyNotFoundException("Kafka:TopicName is missing"); + + /// + /// Initializes the Kafka consumer and starts the message processing loop with automatic reconnection. + /// + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + var bootstrapServers = (configuration["KAFKA_BOOTSTRAP_SERVERS"] ?? "localhost:9092") + .Replace("tcp://", ""); + + logger.LogInformation("Kafka bootstrap servers: {bootstrapServers}", bootstrapServers); + logger.LogInformation("Kafka topic name: {topicName}", _topicName); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + var groupId = "airline-consumer-group-permanent"; + + var consumerConfig = new ConsumerConfig + { + BootstrapServers = bootstrapServers, + GroupId = groupId, + AutoOffsetReset = AutoOffsetReset.Earliest, + EnableAutoCommit = true, + SocketTimeoutMs = 20000, + SessionTimeoutMs = 10000, + HeartbeatIntervalMs = 3000 + }; + + using var consumer = new ConsumerBuilder>(consumerConfig) + .SetKeyDeserializer(new AirlineKeyDeserializer()) + .SetValueDeserializer(new AirlineValueDeserializer()) + .Build(); + + consumer.Subscribe(_topicName); + logger.LogInformation("Consumer successfully subscribed to topic {topic} with GroupId {groupId}", _topicName, groupId); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + var consumeResult = consumer.Consume(stoppingToken); + + if (consumeResult?.Message?.Value is null || consumeResult.Message.Value.Count == 0) + continue; + + logger.LogInformation( + "Consumed message {key} from topic {topic} (Partition: {partition}, Offset: {offset})", + consumeResult.Message.Key, + _topicName, + consumeResult.TopicPartition.Partition, + consumeResult.Offset); + + using var scope = scopeFactory.CreateScope(); + var flightService = scope.ServiceProvider.GetRequiredService(); + + foreach (var contract in consumeResult.Message.Value) + { + try + { + var modelExists = await scope.ServiceProvider + .GetRequiredService>() + .GetAsync(contract.ModelId) != null; + + if (!modelExists) + { + logger.LogWarning("Skipping flight {code}: ModelId {modelId} does not exist", + contract.FlightCode, contract.ModelId); + continue; + } + + await flightService.CreateAsync(contract); + logger.LogInformation("Successfully created flight {code} in database", contract.FlightCode); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Skipping invalid flight contract: Code={code}, ModelId={modelId}", + contract.FlightCode, contract.ModelId); + } + } + + consumer.Commit(consumeResult); + logger.LogInformation("Successfully processed and committed message {key} from topic {topic}", + consumeResult.Message.Key, _topicName); + } + catch (ConsumeException ex) when (ex.Error.Code == ErrorCode.UnknownTopicOrPart) + { + logger.LogWarning("Topic {topic} is not available yet, waiting 5 seconds before retry...", _topicName); + await Task.Delay(5000, stoppingToken); + break; + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + logger.LogInformation("Consumer operation cancelled due to shutdown request"); + return; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to consume or process message from topic {topic}", _topicName); + await Task.Delay(2000, stoppingToken); + } + } + } + catch (Exception ex) + { + logger.LogError(ex, "Consumer encountered an unrecoverable error, restarting in 5 seconds..."); + await Task.Delay(5000, stoppingToken); + } + } + } + + /// + /// Performs graceful shutdown of the Kafka consumer service. + /// + public override async Task StopAsync(CancellationToken stoppingToken) + { + logger.LogInformation("Kafka consumer is stopping"); + await base.StopAsync(stoppingToken); + } +} \ No newline at end of file diff --git a/Airline.Infrastructure.Kafka/Deserializers/AirlineKeyDeserializer.cs b/Airline.Infrastructure.Kafka/Deserializers/AirlineKeyDeserializer.cs new file mode 100644 index 000000000..783109e07 --- /dev/null +++ b/Airline.Infrastructure.Kafka/Deserializers/AirlineKeyDeserializer.cs @@ -0,0 +1,25 @@ +using Confluent.Kafka; +using System.Text.Json; + +namespace Airline.Infrastructure.Kafka.Deserializers; + +/// +/// Kafka deserializer for message key represented as string encoded in JSON +/// +public class AirlineKeyDeserializer : IDeserializer +{ + /// + /// Deserializes Kafka message key payload into string value + /// + /// Raw message key bytes + /// Indicates that the key is null + /// Serialization context + /// Deserialized string key + public string Deserialize(ReadOnlySpan data, bool isNull, SerializationContext context) + { + if (isNull) + return null; + + return JsonSerializer.Deserialize(data); + } +} \ No newline at end of file diff --git a/Airline.Infrastructure.Kafka/Deserializers/AirlineValueDeserializer.cs b/Airline.Infrastructure.Kafka/Deserializers/AirlineValueDeserializer.cs new file mode 100644 index 000000000..d1e121d26 --- /dev/null +++ b/Airline.Infrastructure.Kafka/Deserializers/AirlineValueDeserializer.cs @@ -0,0 +1,26 @@ +using Confluent.Kafka; +using Airline.Application.Contracts.Flight; +using System.Text.Json; + +namespace Airline.Infrastructure.Kafka.Deserializers; + +/// +/// Kafka deserializer for message value represented as JSON array of CreateFlightDto contracts +/// +public sealed class AirlineValueDeserializer : IDeserializer> +{ + /// + /// Deserializes Kafka message value payload into list of CreateFlightDto contracts + /// + /// Raw message value bytes + /// Indicates that the value is null + /// Serialization context + /// Deserialized list of contracts or empty list when value is null + public IList Deserialize(ReadOnlySpan data, bool isNull, SerializationContext context) + { + if (isNull) + return []; + + return JsonSerializer.Deserialize>(data) ?? []; + } +} \ No newline at end of file diff --git a/Airline.ServiceDefaults/Airline.ServiceDefaults.csproj b/Airline.ServiceDefaults/Airline.ServiceDefaults.csproj new file mode 100644 index 000000000..915b00810 --- /dev/null +++ b/Airline.ServiceDefaults/Airline.ServiceDefaults.csproj @@ -0,0 +1,22 @@ + + + + net8.0 + enable + enable + true + + + + + + + + + + + + + + + diff --git a/Airline.ServiceDefaults/Extensions.cs b/Airline.ServiceDefaults/Extensions.cs new file mode 100644 index 000000000..93660f617 --- /dev/null +++ b/Airline.ServiceDefaults/Extensions.cs @@ -0,0 +1,127 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Hosting; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +namespace Airline.ServiceDefaults; + +// Adds common Aspire services: service discovery, resilience, health checks, and OpenTelemetry. +// This project should be referenced by each service project in your solution. +// To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults +public static class Extensions +{ + private const string HealthEndpointPath = "/health"; + private const string AlivenessEndpointPath = "/alive"; + + public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.ConfigureOpenTelemetry(); + + builder.AddDefaultHealthChecks(); + + builder.Services.AddServiceDiscovery(); + + builder.Services.ConfigureHttpClientDefaults(http => + { + // Turn on resilience by default + http.AddStandardResilienceHandler(); + + // Turn on service discovery by default + http.AddServiceDiscovery(); + }); + + // Uncomment the following to restrict the allowed schemes for service discovery. + // builder.Services.Configure(options => + // { + // options.AllowedSchemes = ["https"]; + // }); + + return builder; + } + + public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); + + builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation(); + }) + .WithTracing(tracing => + { + tracing.AddSource(builder.Environment.ApplicationName) + .AddAspNetCoreInstrumentation(tracing => + // Exclude health check requests from tracing + tracing.Filter = context => + !context.Request.Path.StartsWithSegments(HealthEndpointPath) + && !context.Request.Path.StartsWithSegments(AlivenessEndpointPath) + ) + // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package) + //.AddGrpcClientInstrumentation() + .AddHttpClientInstrumentation(); + }); + + builder.AddOpenTelemetryExporters(); + + return builder; + } + + private static TBuilder AddOpenTelemetryExporters(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + + if (useOtlpExporter) + { + builder.Services.AddOpenTelemetry().UseOtlpExporter(); + } + + // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package) + //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"])) + //{ + // builder.Services.AddOpenTelemetry() + // .UseAzureMonitor(); + //} + + return builder; + } + + public static TBuilder AddDefaultHealthChecks(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Services.AddHealthChecks() + // Add a default liveness check to ensure app is responsive + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + + return builder; + } + + public static WebApplication MapDefaultEndpoints(this WebApplication app) + { + // Adding health checks endpoints to applications in non-development environments has security implications. + // See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments. + if (app.Environment.IsDevelopment()) + { + // All health checks must pass for app to be considered ready to accept traffic after starting + app.MapHealthChecks(HealthEndpointPath); + + // Only health checks tagged with the "live" tag must pass for app to be considered alive + app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions + { + Predicate = r => r.Tags.Contains("live") + }); + } + + return app; + } +} diff --git a/Airline.Tests/Airline.Tests.cs b/Airline.Tests/Airline.Tests.cs new file mode 100644 index 000000000..458d800f2 --- /dev/null +++ b/Airline.Tests/Airline.Tests.cs @@ -0,0 +1,102 @@ +using Airline.Domain.DataSeed; +using Xunit; + +namespace Airline.Tests; + +public class AirCompanyTests(DataSeed seed) : IClassFixture +{ + /// + /// Verifies that the method returns the top 5 flights based on the number of passengers transported. + /// + [Fact] + public void GetTop5FlightsByPassengerCount_ReturnsCorrectFlights() + { + var flightPassengerCounts = seed.Tickets + .GroupBy(t => t.Flight) + .Select(g => new { Flight = g.Key, Count = g.Count() }) + .OrderByDescending(x => x.Count) + .Take(5) + .Select(x => x.Flight) + .ToList(); + + Assert.Equal(5, flightPassengerCounts.Count); + Assert.Equal("SU101", flightPassengerCounts[0].FlightCode); + } + + /// + /// Verifies that the method correctly finds flights with minimal travel time. + /// + [Fact] + public void GetFlightsWithMinTravelTime_ReturnsCorrectFlights() + { + var validFlights = seed.Flights.Where(f => f.TravelTime.HasValue).ToList(); + var minTime = validFlights.Min(f => f.TravelTime!.Value); + var result = validFlights + .Where(f => f.TravelTime == minTime) + .ToList(); + + Assert.Single(result); + Assert.Equal(TimeSpan.FromHours(2), result[0].TravelTime); + } + + /// + /// Checks that the method returns a list of passengers on the selected flight without baggage, + /// sorted by full name (PassengerName) in alphabetical order. + /// + [Fact] + public void GetPassengersWithZeroBaggageOnsFlight_ReturnsSortedPassengers() + { + var flight = seed.Flights.First(f => f.FlightCode == "SU101"); + var passengersWithNoBaggage = seed.Tickets + .Where(t => t.Flight == flight && t.BaggageWeight == null) + .Select(t => t.Passenger) + .OrderBy(p => p.PassengerName) + .ToList(); + + // Предположим, у Petrov Petr ID = 2, у Sidorov Alexey ID = 3 + Assert.Contains(passengersWithNoBaggage, p => p.Id == 2); + Assert.Contains(passengersWithNoBaggage, p => p.Id == 3); + Assert.Equal(2, passengersWithNoBaggage.Count); + } + + /// + /// Verifies that the method returns all flights of the specified aircraft model + /// that departed during the specified date period. + /// + [Fact] + public void GetFlightsByModelInPeriod_ReturnsCorrectFlights() + { + var modelName = "A320"; + var from = new DateTime(2025, 10, 10); + var to = new DateTime(2025, 10, 12); + + var result = seed.Flights + .Where(f => f.Model.ModelName == modelName && + f.DepartureDateTime >= from && + f.DepartureDateTime <= to) + .ToList(); + + Assert.Equal(2, result.Count); + Assert.Contains(result, f => f.FlightCode == "SU101"); + Assert.Contains(result, f => f.FlightCode == "SU200"); + } + + /// + /// Verifies that the method returns all flights from the specified departure point + /// to the specified arrival point. + /// + [Fact] + public void GetFlightsByRoute_ReturnsCorrectFlights() + { + var departure = "Samara"; + var arrival = "Wonderland"; + + var result = seed.Flights + .Where(f => f.DepartureCity == departure && f.ArrivalCity == arrival) + .ToList(); + + Assert.Equal(2, result.Count); + Assert.Contains(result, f => f.FlightCode == "SU101"); + Assert.Contains(result, f => f.FlightCode == "SU104"); + } +} \ No newline at end of file diff --git a/Airline.Tests/Airline.Tests.csproj b/Airline.Tests/Airline.Tests.csproj new file mode 100644 index 000000000..4adeea4ea --- /dev/null +++ b/Airline.Tests/Airline.Tests.csproj @@ -0,0 +1,23 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Airline.sln b/Airline.sln new file mode 100644 index 000000000..05c3d3d28 --- /dev/null +++ b/Airline.sln @@ -0,0 +1,79 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.14.36603.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Airline.Tests", "Airline.Tests\Airline.Tests.csproj", "{B813F501-EA93-4258-A2CA-A43542C8EEF4}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Airline.Application.Contracts", "Airline.Application.Contracts\Airline.Application.Contracts.csproj", "{0C965A98-CE55-DB0F-63D6-281C96FF8704}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Airline.Infrastructure.EfCore", "Airline.Infrastructure.EfCore\Airline.Infrastructure.EfCore.csproj", "{E4208D1E-7EBE-1EF9-34F7-16DC641B398B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Airline.Application", "Airline.Application\Airline.Application.csproj", "{92A1A31C-40CB-802C-CCC3-246E0C50E4E1}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Airline.Domain", "Airline.Domain\Airline.Domain.csproj", "{1A3ED493-FDE5-C37C-498C-1431A58B395A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Airline.Api.Host", "Airline.Api.Host\Airline.Api.Host.csproj", "{880A43A8-0013-9906-DB33-3DF18CDBA4EE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Airline.AppHost", "Airline.AppHost\Airline.AppHost.csproj", "{371AB662-A469-4684-8FC1-F5363996F00F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Airline.ServiceDefaults", "Airline.ServiceDefaults\Airline.ServiceDefaults.csproj", "{AB4A2E61-005D-D6AF-C18F-732B6C13F746}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Airline.Generator.Kafka.Host", "Airline.Generator.Kafka.Host\Airline.Generator.Kafka.Host.csproj", "{21DA2267-8FDD-9E3B-3B8F-DE4339C512AB}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Airline.Infrastructure.Kafka", "Airline.Infrastructure.Kafka\Airline.Infrastructure.Kafka.csproj", "{83DE4DA4-488F-44FB-BD92-7D0462CD934B}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {B813F501-EA93-4258-A2CA-A43542C8EEF4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B813F501-EA93-4258-A2CA-A43542C8EEF4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B813F501-EA93-4258-A2CA-A43542C8EEF4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B813F501-EA93-4258-A2CA-A43542C8EEF4}.Release|Any CPU.Build.0 = Release|Any CPU + {0C965A98-CE55-DB0F-63D6-281C96FF8704}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0C965A98-CE55-DB0F-63D6-281C96FF8704}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0C965A98-CE55-DB0F-63D6-281C96FF8704}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0C965A98-CE55-DB0F-63D6-281C96FF8704}.Release|Any CPU.Build.0 = Release|Any CPU + {E4208D1E-7EBE-1EF9-34F7-16DC641B398B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E4208D1E-7EBE-1EF9-34F7-16DC641B398B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E4208D1E-7EBE-1EF9-34F7-16DC641B398B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E4208D1E-7EBE-1EF9-34F7-16DC641B398B}.Release|Any CPU.Build.0 = Release|Any CPU + {92A1A31C-40CB-802C-CCC3-246E0C50E4E1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {92A1A31C-40CB-802C-CCC3-246E0C50E4E1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {92A1A31C-40CB-802C-CCC3-246E0C50E4E1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {92A1A31C-40CB-802C-CCC3-246E0C50E4E1}.Release|Any CPU.Build.0 = Release|Any CPU + {1A3ED493-FDE5-C37C-498C-1431A58B395A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1A3ED493-FDE5-C37C-498C-1431A58B395A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1A3ED493-FDE5-C37C-498C-1431A58B395A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1A3ED493-FDE5-C37C-498C-1431A58B395A}.Release|Any CPU.Build.0 = Release|Any CPU + {880A43A8-0013-9906-DB33-3DF18CDBA4EE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {880A43A8-0013-9906-DB33-3DF18CDBA4EE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {880A43A8-0013-9906-DB33-3DF18CDBA4EE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {880A43A8-0013-9906-DB33-3DF18CDBA4EE}.Release|Any CPU.Build.0 = Release|Any CPU + {371AB662-A469-4684-8FC1-F5363996F00F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {371AB662-A469-4684-8FC1-F5363996F00F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {371AB662-A469-4684-8FC1-F5363996F00F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {371AB662-A469-4684-8FC1-F5363996F00F}.Release|Any CPU.Build.0 = Release|Any CPU + {AB4A2E61-005D-D6AF-C18F-732B6C13F746}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AB4A2E61-005D-D6AF-C18F-732B6C13F746}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AB4A2E61-005D-D6AF-C18F-732B6C13F746}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AB4A2E61-005D-D6AF-C18F-732B6C13F746}.Release|Any CPU.Build.0 = Release|Any CPU + {21DA2267-8FDD-9E3B-3B8F-DE4339C512AB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {21DA2267-8FDD-9E3B-3B8F-DE4339C512AB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {21DA2267-8FDD-9E3B-3B8F-DE4339C512AB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {21DA2267-8FDD-9E3B-3B8F-DE4339C512AB}.Release|Any CPU.Build.0 = Release|Any CPU + {83DE4DA4-488F-44FB-BD92-7D0462CD934B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {83DE4DA4-488F-44FB-BD92-7D0462CD934B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {83DE4DA4-488F-44FB-BD92-7D0462CD934B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {83DE4DA4-488F-44FB-BD92-7D0462CD934B}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {8A363FDF-5B26-493B-876E-8807EF719DEA} + EndGlobalSection +EndGlobal