From 5ee8c9c5df1150f495d3f41b874cc567a600b5c7 Mon Sep 17 00:00:00 2001 From: cuqmbr Date: Sun, 11 May 2025 10:51:19 +0300 Subject: [PATCH] add vehicle enrollments management --- .../Repositories/RouteAddressRepository.cs | 7 + .../VehicleEnrollmentRepository.cs | 7 + .../Interfaces/Persistence/UnitOfWork.cs | 4 + .../Resources/Localization/en-US.json | 10 +- src/Application/Routes/RouteAddressDto.cs | 25 +- .../AddVehicleEnrollmentCommand.cs | 20 + .../AddVehicleEnrollmentCommandAuthorizer.cs | 32 + .../AddVehicleEnrollmentCommandHandler.cs | 214 ++++++ .../AddVehicleEnrollmentCommandValidator.cs | 63 ++ .../DeleteVehicleEnrollmentCommand.cs | 8 + ...eleteVehicleEnrollmentCommandAuthorizer.cs | 31 + .../DeleteVehicleEnrollmentCommandHandler.cs | 38 ++ ...DeleteVehicleEnrollmentCommandValidator.cs | 14 + .../UpdateVehicleEnrollmentCommand.cs | 18 + ...pdateVehicleEnrollmentCommandAuthorizer.cs | 33 + .../UpdateVehicleEnrollmentCommandHandler.cs | 191 ++++++ ...UpdateVehicleEnrollmentCommandValidator.cs | 58 ++ .../Models/RouteAddressDetailModel.cs | 13 + .../GetVehicleEnrollmentQuery.cs | 9 + .../GetVehicleEnrollmentQueryAuthorizer.cs | 32 + .../GetVehicleEnrollmentQueryHandler.cs | 58 ++ .../GetVehicleEnrollmentQueryValidator.cs | 14 + .../GetVehicleEnrollmentsPageQuery.cs | 52 ++ ...etVehicleEnrollmentsPageQueryAuthorizer.cs | 31 + .../GetVehicleEnrollmentsPageQueryHandler.cs | 157 +++++ ...GetVehicleEnrollmentsPageQueryValidator.cs | 43 ++ .../RouteAddressDetailDto.cs | 29 + .../VehicleEnrollmentDto.cs | 50 ++ .../VehicleEnrollmentRouteAddressDto.cs | 75 +++ .../AddVehicleEnrollmentViewModel.cs | 15 + ...etVehicleEnrollmentsPageFilterViewModel.cs | 46 ++ .../ViewModels/RouteAddressDetailViewModel.cs | 13 + .../UpdateVehicleEnrollmentViewModel.cs | 11 + src/Domain/Entities/Route.cs | 2 + src/Domain/Entities/RouteAddress.cs | 3 + src/Domain/Entities/RouteAddressDetail.cs | 20 + src/Domain/Entities/Vehicle.cs | 3 + src/Domain/Entities/VehicleEnrollment.cs | 23 + src/Domain/Enums/Currency.cs | 30 + .../Controllers/AddressesController.cs | 3 +- .../Controllers/AircraftsController.cs | 3 +- src/HttpApi/Controllers/BusesController.cs | 3 +- src/HttpApi/Controllers/CitiesController.cs | 3 +- .../Controllers/CountriesController.cs | 3 +- src/HttpApi/Controllers/RegionsController.cs | 7 +- src/HttpApi/Controllers/RoutesController.cs | 3 +- src/HttpApi/Controllers/TrainsController.cs | 3 +- .../VehicleEnrollmentsController.cs | 260 +++++++ src/Persistence/InMemory/InMemoryDbContext.cs | 5 + .../InMemory/InMemoryUnitOfWork.cs | 8 + .../InMemoryRouteAddressRepository.cs | 11 + .../InMemoryVehicleEnrollmentRepository.cs | 11 + src/Persistence/Json/JsonDbContext.cs | 0 .../RouteAddressDetailConfiguration.cs | 85 +++ .../VehicleEnrollmentConfiguration.cs | 88 +++ ...lment_and_Route_Address_Detail.Designer.cs | 637 ++++++++++++++++++ ...cle_Enrollment_and_Route_Address_Detail.cs | 133 ++++ .../PostgreSqlDbContextModelSnapshot.cs | 161 +++++ .../PostgreSql/PostgreSqlDbContext.cs | 11 +- .../PostgreSql/PostgreSqlUnitOfWork.cs | 8 + .../PostgreSqlRouteAddressRepository.cs | 11 + .../PostgreSqlVehicleEnrollmentRepository.cs | 11 + .../TypeConverters/CurrencyConverter.cs | 13 + .../TypeConverters/DateTimeOffsetConverter.cs | 15 + 64 files changed, 2976 insertions(+), 22 deletions(-) create mode 100644 src/Application/Common/Interfaces/Persistence/Repositories/RouteAddressRepository.cs create mode 100644 src/Application/Common/Interfaces/Persistence/Repositories/VehicleEnrollmentRepository.cs create mode 100644 src/Application/VehicleEnrollments/Commands/AddVehicleEnrollment/AddVehicleEnrollmentCommand.cs create mode 100644 src/Application/VehicleEnrollments/Commands/AddVehicleEnrollment/AddVehicleEnrollmentCommandAuthorizer.cs create mode 100644 src/Application/VehicleEnrollments/Commands/AddVehicleEnrollment/AddVehicleEnrollmentCommandHandler.cs create mode 100644 src/Application/VehicleEnrollments/Commands/AddVehicleEnrollment/AddVehicleEnrollmentCommandValidator.cs create mode 100644 src/Application/VehicleEnrollments/Commands/DeleteVehicleEnrollment/DeleteVehicleEnrollmentCommand.cs create mode 100644 src/Application/VehicleEnrollments/Commands/DeleteVehicleEnrollment/DeleteVehicleEnrollmentCommandAuthorizer.cs create mode 100644 src/Application/VehicleEnrollments/Commands/DeleteVehicleEnrollment/DeleteVehicleEnrollmentCommandHandler.cs create mode 100644 src/Application/VehicleEnrollments/Commands/DeleteVehicleEnrollment/DeleteVehicleEnrollmentCommandValidator.cs create mode 100644 src/Application/VehicleEnrollments/Commands/UpdateVehicleEnrollment/UpdateVehicleEnrollmentCommand.cs create mode 100644 src/Application/VehicleEnrollments/Commands/UpdateVehicleEnrollment/UpdateVehicleEnrollmentCommandAuthorizer.cs create mode 100644 src/Application/VehicleEnrollments/Commands/UpdateVehicleEnrollment/UpdateVehicleEnrollmentCommandHandler.cs create mode 100644 src/Application/VehicleEnrollments/Commands/UpdateVehicleEnrollment/UpdateVehicleEnrollmentCommandValidator.cs create mode 100644 src/Application/VehicleEnrollments/Models/RouteAddressDetailModel.cs create mode 100644 src/Application/VehicleEnrollments/Queries/GetVehicleEnrollment/GetVehicleEnrollmentQuery.cs create mode 100644 src/Application/VehicleEnrollments/Queries/GetVehicleEnrollment/GetVehicleEnrollmentQueryAuthorizer.cs create mode 100644 src/Application/VehicleEnrollments/Queries/GetVehicleEnrollment/GetVehicleEnrollmentQueryHandler.cs create mode 100644 src/Application/VehicleEnrollments/Queries/GetVehicleEnrollment/GetVehicleEnrollmentQueryValidator.cs create mode 100644 src/Application/VehicleEnrollments/Queries/GetVehicleEnrollmentsPage/GetVehicleEnrollmentsPageQuery.cs create mode 100644 src/Application/VehicleEnrollments/Queries/GetVehicleEnrollmentsPage/GetVehicleEnrollmentsPageQueryAuthorizer.cs create mode 100644 src/Application/VehicleEnrollments/Queries/GetVehicleEnrollmentsPage/GetVehicleEnrollmentsPageQueryHandler.cs create mode 100644 src/Application/VehicleEnrollments/Queries/GetVehicleEnrollmentsPage/GetVehicleEnrollmentsPageQueryValidator.cs create mode 100644 src/Application/VehicleEnrollments/RouteAddressDetailDto.cs create mode 100644 src/Application/VehicleEnrollments/VehicleEnrollmentDto.cs create mode 100644 src/Application/VehicleEnrollments/VehicleEnrollmentRouteAddressDto.cs create mode 100644 src/Application/VehicleEnrollments/ViewModels/AddVehicleEnrollmentViewModel.cs create mode 100644 src/Application/VehicleEnrollments/ViewModels/GetVehicleEnrollmentsPageFilterViewModel.cs create mode 100644 src/Application/VehicleEnrollments/ViewModels/RouteAddressDetailViewModel.cs create mode 100644 src/Application/VehicleEnrollments/ViewModels/UpdateVehicleEnrollmentViewModel.cs create mode 100644 src/Domain/Entities/RouteAddressDetail.cs create mode 100644 src/Domain/Entities/VehicleEnrollment.cs create mode 100644 src/Domain/Enums/Currency.cs create mode 100644 src/HttpApi/Controllers/VehicleEnrollmentsController.cs create mode 100644 src/Persistence/InMemory/Repositories/InMemoryRouteAddressRepository.cs create mode 100644 src/Persistence/InMemory/Repositories/InMemoryVehicleEnrollmentRepository.cs delete mode 100644 src/Persistence/Json/JsonDbContext.cs create mode 100644 src/Persistence/PostgreSql/Configurations/RouteAddressDetailConfiguration.cs create mode 100644 src/Persistence/PostgreSql/Configurations/VehicleEnrollmentConfiguration.cs create mode 100644 src/Persistence/PostgreSql/Migrations/20250504143929_Add_Vehicle_Enrollment_and_Route_Address_Detail.Designer.cs create mode 100644 src/Persistence/PostgreSql/Migrations/20250504143929_Add_Vehicle_Enrollment_and_Route_Address_Detail.cs create mode 100644 src/Persistence/PostgreSql/Repositories/PostgreSqlRouteAddressRepository.cs create mode 100644 src/Persistence/PostgreSql/Repositories/PostgreSqlVehicleEnrollmentRepository.cs create mode 100644 src/Persistence/TypeConverters/CurrencyConverter.cs create mode 100644 src/Persistence/TypeConverters/DateTimeOffsetConverter.cs diff --git a/src/Application/Common/Interfaces/Persistence/Repositories/RouteAddressRepository.cs b/src/Application/Common/Interfaces/Persistence/Repositories/RouteAddressRepository.cs new file mode 100644 index 0000000..4ff5733 --- /dev/null +++ b/src/Application/Common/Interfaces/Persistence/Repositories/RouteAddressRepository.cs @@ -0,0 +1,7 @@ +using cuqmbr.TravelGuide.Domain.Entities; + +namespace cuqmbr.TravelGuide.Application.Common.Interfaces + .Persistence.Repositories; + +public interface RouteAddressRepository : + BaseRepository { } diff --git a/src/Application/Common/Interfaces/Persistence/Repositories/VehicleEnrollmentRepository.cs b/src/Application/Common/Interfaces/Persistence/Repositories/VehicleEnrollmentRepository.cs new file mode 100644 index 0000000..1341b74 --- /dev/null +++ b/src/Application/Common/Interfaces/Persistence/Repositories/VehicleEnrollmentRepository.cs @@ -0,0 +1,7 @@ +using cuqmbr.TravelGuide.Domain.Entities; + +namespace cuqmbr.TravelGuide.Application.Common.Interfaces + .Persistence.Repositories; + +public interface VehicleEnrollmentRepository : + BaseRepository { } diff --git a/src/Application/Common/Interfaces/Persistence/UnitOfWork.cs b/src/Application/Common/Interfaces/Persistence/UnitOfWork.cs index e4cad5f..5cd0770 100644 --- a/src/Application/Common/Interfaces/Persistence/UnitOfWork.cs +++ b/src/Application/Common/Interfaces/Persistence/UnitOfWork.cs @@ -22,6 +22,10 @@ public interface UnitOfWork : IDisposable TrainRepository TrainRepository { get; } + VehicleEnrollmentRepository VehicleEnrollmentRepository { get; } + + RouteAddressRepository RouteAddressRepository { get; } + int Save(); Task SaveAsync(CancellationToken cancellationToken); diff --git a/src/Application/Resources/Localization/en-US.json b/src/Application/Resources/Localization/en-US.json index 86a35db..ff87349 100644 --- a/src/Application/Resources/Localization/en-US.json +++ b/src/Application/Resources/Localization/en-US.json @@ -8,7 +8,15 @@ }, "Validation": { "DistinctOrder": "Must have distinct order values.", - "SameVehicleType": "Must have the same vehicle type." + "SameVehicleType": "Must have the same vehicle type.", + "DateTimeOffset": { + "GreaterThanOrEqualTo": "Must be greater or equal to {0:U}" + }, + "VehicleEnrollments": { + "OverlapWithOther": "Provided vehicle enrollment overlapping in schedule with other one.", + "NegativeTime": "Specified time must be positive time span.", + "NegativeCost": "Specified cost must be positive value." + } }, "ExceptionHandling": { "ValidationException": { diff --git a/src/Application/Routes/RouteAddressDto.cs b/src/Application/Routes/RouteAddressDto.cs index 13288f0..fbe2148 100644 --- a/src/Application/Routes/RouteAddressDto.cs +++ b/src/Application/Routes/RouteAddressDto.cs @@ -5,18 +5,20 @@ namespace cuqmbr.TravelGuide.Application.Routes; public sealed class RouteAddressDto : IMapFrom { + public Guid RouteAddressUuid { get; set; } + public short Order { get; set; } - public Guid Uuid { get; set; } + public Guid AddressUuid { get; set; } - public string Name { get; set; } + public string AddressName { get; set; } - public double Longitude { get; set; } + public double AddressLongitude { get; set; } - public double Latitude { get; set; } + public double AddressLatitude { get; set; } - public string VehicleType { get; set; } + public string AddressVehicleType { get; set; } public Guid CountryUuid { get; set; } @@ -34,19 +36,22 @@ public sealed class RouteAddressDto : IMapFrom { profile.CreateMap() .ForMember( - d => d.Uuid, + d => d.RouteAddressUuid, + opt => opt.MapFrom(s => s.Guid)) + .ForMember( + d => d.AddressUuid, opt => opt.MapFrom(s => s.Address.Guid)) .ForMember( - d => d.Name, + d => d.AddressName, opt => opt.MapFrom(s => s.Address.Name)) .ForMember( - d => d.Longitude, + d => d.AddressLongitude, opt => opt.MapFrom(s => s.Address.Longitude)) .ForMember( - d => d.Latitude, + d => d.AddressLatitude, opt => opt.MapFrom(s => s.Address.Latitude)) .ForMember( - d => d.VehicleType, + d => d.AddressVehicleType, opt => opt.MapFrom(s => s.Address.VehicleType.Name)) .ForMember( d => d.CityUuid, diff --git a/src/Application/VehicleEnrollments/Commands/AddVehicleEnrollment/AddVehicleEnrollmentCommand.cs b/src/Application/VehicleEnrollments/Commands/AddVehicleEnrollment/AddVehicleEnrollmentCommand.cs new file mode 100644 index 0000000..8f5c49e --- /dev/null +++ b/src/Application/VehicleEnrollments/Commands/AddVehicleEnrollment/AddVehicleEnrollmentCommand.cs @@ -0,0 +1,20 @@ +using cuqmbr.TravelGuide.Application.VehicleEnrollments.Models; +using cuqmbr.TravelGuide.Domain.Enums; +using MediatR; + +namespace cuqmbr.TravelGuide.Application.VehicleEnrollments + .Commands.AddVehicleEnrollment; + +public record AddVehicleEnrollmentCommand : IRequest +{ + public DateTimeOffset DepartureTime { get; set; } + + public Currency Currency { get; set; } + + + public Guid VehicleGuid { get; set; } + + public Guid RouteGuid { get; set; } + + public ICollection RouteAddressDetails { get; set; } +} diff --git a/src/Application/VehicleEnrollments/Commands/AddVehicleEnrollment/AddVehicleEnrollmentCommandAuthorizer.cs b/src/Application/VehicleEnrollments/Commands/AddVehicleEnrollment/AddVehicleEnrollmentCommandAuthorizer.cs new file mode 100644 index 0000000..dbaeae9 --- /dev/null +++ b/src/Application/VehicleEnrollments/Commands/AddVehicleEnrollment/AddVehicleEnrollmentCommandAuthorizer.cs @@ -0,0 +1,32 @@ +using cuqmbr.TravelGuide.Application.Common.Authorization; +using cuqmbr.TravelGuide.Application.Common.Interfaces.Services; +using cuqmbr.TravelGuide.Application.Common.Models; +using MediatR.Behaviors.Authorization; + +namespace cuqmbr.TravelGuide.Application.VehicleEnrollments + .Commands.AddVehicleEnrollment; + +public class AddVehicleEnrollmentCommandAuthorizer : + AbstractRequestAuthorizer +{ + private readonly SessionUserService _sessionUserService; + + public AddVehicleEnrollmentCommandAuthorizer(SessionUserService sessionUserService) + { + _sessionUserService = sessionUserService; + } + + public override void BuildPolicy(AddVehicleEnrollmentCommand request) + { + UseRequirement(new MustBeAuthenticatedRequirement + { + IsAuthenticated= _sessionUserService.IsAuthenticated + }); + + UseRequirement(new MustBeInRolesRequirement + { + RequiredRoles = [IdentityRole.Administrator], + UserRoles = _sessionUserService.Roles + }); + } +} diff --git a/src/Application/VehicleEnrollments/Commands/AddVehicleEnrollment/AddVehicleEnrollmentCommandHandler.cs b/src/Application/VehicleEnrollments/Commands/AddVehicleEnrollment/AddVehicleEnrollmentCommandHandler.cs new file mode 100644 index 0000000..3f4dac9 --- /dev/null +++ b/src/Application/VehicleEnrollments/Commands/AddVehicleEnrollment/AddVehicleEnrollmentCommandHandler.cs @@ -0,0 +1,214 @@ +using MediatR; +using cuqmbr.TravelGuide.Application.Common.Interfaces.Persistence; +using cuqmbr.TravelGuide.Domain.Entities; +using AutoMapper; +using cuqmbr.TravelGuide.Application.Common.Exceptions; +using FluentValidation.Results; +using Microsoft.Extensions.Localization; + +namespace cuqmbr.TravelGuide.Application.VehicleEnrollments + .Commands.AddVehicleEnrollment; + +public class AddVehicleEnrollmentCommandHandler : + IRequestHandler +{ + private readonly UnitOfWork _unitOfWork; + private readonly IMapper _mapper; + private readonly IStringLocalizer _localizer; + + public AddVehicleEnrollmentCommandHandler( + UnitOfWork unitOfWork, + IMapper mapper, + IStringLocalizer localizer) + { + _unitOfWork = unitOfWork; + _mapper = mapper; + _localizer = localizer; + } + + public async Task Handle( + AddVehicleEnrollmentCommand request, + CancellationToken cancellationToken) + { + // Check if the vehicle exists. + + var vehicle = await _unitOfWork.VehicleRepository.GetOneAsync( + e => e.Guid == request.VehicleGuid, cancellationToken); + + if (vehicle == null) + { + throw new NotFoundException( + $"Vehicle with Guid: {request.VehicleGuid} not found."); + } + + + // Check if the route exists. + + var route = await _unitOfWork.RouteRepository.GetOneAsync( + e => e.Guid == request.RouteGuid, e => e.RouteAddresses, + cancellationToken); + + if (route == null) + { + throw new NotFoundException( + $"Route with Guid: {request.RouteGuid} not found."); + } + + + // Check if specified vehicle and route compatible. + + if (vehicle.VehicleType != route.VehicleType) + { + throw new ValidationException( + new List + { + new() + { + PropertyName = nameof(request.VehicleGuid), + ErrorMessage = _localizer["Validation.SameVehicleType"] + } + }); + } + + + // Check that request has the same Route Addresses + // as a route data from datastore. + + var sameRouteAddresses = route.RouteAddresses.All( + ra => request.RouteAddressDetails.Any( + rad => rad.RouteAddressGuid == ra.Guid)); + + if (!sameRouteAddresses) + { + throw new NotFoundException( + $"Not all route addresses are found in a datastore."); + } + + // Check vehicle enrollments that might overlap with new one. + + var requestDepartureTime = request.DepartureTime; + + var requestTravelTime = + request.RouteAddressDetails.Aggregate( + TimeSpan.Zero, (sum, rad) => sum + + rad.TimeToNextAddress + rad.CurrentAddressStopTime); + + var requestArrivalTime = requestDepartureTime + requestTravelTime; + + var enrollmentHistory = + await _unitOfWork.VehicleEnrollmentRepository.GetPageAsync( + e => + e.Vehicle.Guid == request.VehicleGuid && + e.DepartureTime >= DateTimeOffset.UtcNow.AddDays(-7), + e => e.RouteAddressDetails, + 1, 200, cancellationToken); + + // Three cases are included: + // + // ---RD---------SD----------RA---> + // time + // + // ---RD---------SA----------RA---> + // time + // + // ---SD-----RD-------RA-----SA---> + // time + // Where: + // RD - request enrollment departure time + // RA - request enrollment arrival time + // SD - datastore enrollment (S for store) departure time + // SA - datastore enrollment (S for store) arrival time + + var overlappingWithOtherEnrollments = enrollmentHistory.Items + .Where(ve => + { + var departureTime = ve.DepartureTime; + + var arrivalTime = + ve.DepartureTime + + ve.RouteAddressDetails + .Aggregate( + TimeSpan.Zero, + (sum, rad) => sum + + rad.TimeToNextAddress + + rad.CurrentAddressStopTime); + + return + (departureTime >= requestDepartureTime && + departureTime <= requestArrivalTime) || + (arrivalTime >= requestDepartureTime && + arrivalTime <= requestArrivalTime) || + (departureTime <= requestDepartureTime && + arrivalTime >= requestArrivalTime); + }) + .Any(); + + if (overlappingWithOtherEnrollments) + { + throw new ValidationException( + new List + { + new() + { + PropertyName = nameof(request.DepartureTime), + ErrorMessage = _localizer["Validation." + + "VehicleEnrollments.OverlapWithOther"] + } + }); + } + + + // Create entity and add to datastore. + + var entity = new VehicleEnrollment() + { + DepartureTime = request.DepartureTime, + Currency = request.Currency, + VehicleId = vehicle.Id, + RouteId = route.Id, + RouteAddressDetails = route.RouteAddresses + .OrderBy(ra => ra.Order) + .Select(ra => new RouteAddressDetail() + { + TimeToNextAddress = request.RouteAddressDetails + .First(rad => rad.RouteAddressGuid == ra.Guid) + .TimeToNextAddress, + CostToNextAddress = request.RouteAddressDetails + .First(rad => rad.RouteAddressGuid == ra.Guid) + .CostToNextAddress, + CurrentAddressStopTime = request.RouteAddressDetails + .First(rad => rad.RouteAddressGuid == ra.Guid) + .CurrentAddressStopTime, + RouteAddressId = ra.Id + }) + .ToArray() + }; + + entity = await _unitOfWork.VehicleEnrollmentRepository.AddOneAsync( + entity, cancellationToken); + + await _unitOfWork.SaveAsync(cancellationToken); + + // Hydrate vehicle enrollment with address information + + var routeAddresses = await _unitOfWork.RouteAddressRepository + .GetPageAsync( + e => + request.RouteAddressDetails + .Select(rad => rad.RouteAddressGuid) + .Contains(e.Guid), + e => e.Address.City.Region.Country, + 1, entity.RouteAddressDetails.Count(), + cancellationToken); + + foreach (var rad in entity.RouteAddressDetails) + { + rad.RouteAddress = routeAddresses.Items + .First(ra => ra.Id == rad.RouteAddressId); + } + + _unitOfWork.Dispose(); + + return _mapper.Map(entity); + } +} diff --git a/src/Application/VehicleEnrollments/Commands/AddVehicleEnrollment/AddVehicleEnrollmentCommandValidator.cs b/src/Application/VehicleEnrollments/Commands/AddVehicleEnrollment/AddVehicleEnrollmentCommandValidator.cs new file mode 100644 index 0000000..c971787 --- /dev/null +++ b/src/Application/VehicleEnrollments/Commands/AddVehicleEnrollment/AddVehicleEnrollmentCommandValidator.cs @@ -0,0 +1,63 @@ +using cuqmbr.TravelGuide.Application.Common.Interfaces.Services; +using cuqmbr.TravelGuide.Domain.Enums; +using FluentValidation; +using Microsoft.Extensions.Localization; + +namespace cuqmbr.TravelGuide.Application.VehicleEnrollments + .Commands.AddVehicleEnrollment; + +public class AddVehicleEnrollmentCommandValidator : + AbstractValidator +{ + public AddVehicleEnrollmentCommandValidator( + IStringLocalizer localizer, + CultureService cultureService, + TimeZoneService timeZoneService) + { + RuleFor(v => v.DepartureTime) + .NotEmpty() + .WithMessage(localizer["FluentValidation.NotEmpty"]) + .Must(dt => dt >= DateTimeOffset.Now) + .WithMessage( + String.Format( + cultureService.Culture, + localizer["FluentValidation.DateTimeOffset.GreaterThanOrEqualTo"], + DateTimeOffset.Now.ToOffset(timeZoneService.TimeZone.BaseUtcOffset))); + + RuleFor(v => v.Currency) + .Must(c => Currency.Enumerations.ContainsValue(c)) + .WithMessage( + String.Format( + localizer["FluentValidation.MustBeInEnum"], + String.Join( + ", ", + Currency.Enumerations.Values.Select(e => e.Name)))); + + RuleFor(v => v.RouteAddressDetails.Count) + .GreaterThanOrEqualTo(2) + .WithMessage( + String.Format( + cultureService.Culture, + localizer["FluentValidation.GreaterThanOrEqualTo"], + 2)); + + RuleFor(v => v.RouteAddressDetails) + .Must(v => v.All(rad => rad.RouteAddressGuid != Guid.Empty)) + .WithMessage(localizer["FluentValidation.NotEmpty"]) + .Must(v => v.All(rad => rad.TimeToNextAddress >= TimeSpan.Zero)) + .WithMessage(localizer["VehicleEnrollments.NegativeTime"]) + .Must(v => v.All(rad => rad.CostToNextAddress >= 0)) + .WithMessage(localizer["VehicleEnrollments.NegativeCost"]) + .Must(v => v.All(rad => rad.CurrentAddressStopTime >= TimeSpan.Zero)) + .WithMessage(localizer["VehicleEnrollments.NegativeTime"]); + + + RuleFor(v => v.VehicleGuid) + .NotEmpty() + .WithMessage(localizer["FluentValidation.NotEmpty"]); + + RuleFor(v => v.RouteGuid) + .NotEmpty() + .WithMessage(localizer["FluentValidation.NotEmpty"]); + } +} diff --git a/src/Application/VehicleEnrollments/Commands/DeleteVehicleEnrollment/DeleteVehicleEnrollmentCommand.cs b/src/Application/VehicleEnrollments/Commands/DeleteVehicleEnrollment/DeleteVehicleEnrollmentCommand.cs new file mode 100644 index 0000000..a11cbd2 --- /dev/null +++ b/src/Application/VehicleEnrollments/Commands/DeleteVehicleEnrollment/DeleteVehicleEnrollmentCommand.cs @@ -0,0 +1,8 @@ +using MediatR; + +namespace cuqmbr.TravelGuide.Application.VehicleEnrollments.Commands.DeleteVehicleEnrollment; + +public record DeleteVehicleEnrollmentCommand : IRequest +{ + public Guid Guid { get; set; } +} diff --git a/src/Application/VehicleEnrollments/Commands/DeleteVehicleEnrollment/DeleteVehicleEnrollmentCommandAuthorizer.cs b/src/Application/VehicleEnrollments/Commands/DeleteVehicleEnrollment/DeleteVehicleEnrollmentCommandAuthorizer.cs new file mode 100644 index 0000000..ecc544b --- /dev/null +++ b/src/Application/VehicleEnrollments/Commands/DeleteVehicleEnrollment/DeleteVehicleEnrollmentCommandAuthorizer.cs @@ -0,0 +1,31 @@ +using cuqmbr.TravelGuide.Application.Common.Authorization; +using cuqmbr.TravelGuide.Application.Common.Interfaces.Services; +using cuqmbr.TravelGuide.Application.Common.Models; +using MediatR.Behaviors.Authorization; + +namespace cuqmbr.TravelGuide.Application.VehicleEnrollments.Commands.DeleteVehicleEnrollment; + +public class DeleteVehicleEnrollmentCommandAuthorizer : + AbstractRequestAuthorizer +{ + private readonly SessionUserService _sessionUserService; + + public DeleteVehicleEnrollmentCommandAuthorizer(SessionUserService sessionUserService) + { + _sessionUserService = sessionUserService; + } + + public override void BuildPolicy(DeleteVehicleEnrollmentCommand request) + { + UseRequirement(new MustBeAuthenticatedRequirement + { + IsAuthenticated= _sessionUserService.IsAuthenticated + }); + + UseRequirement(new MustBeInRolesRequirement + { + RequiredRoles = [IdentityRole.Administrator], + UserRoles = _sessionUserService.Roles + }); + } +} diff --git a/src/Application/VehicleEnrollments/Commands/DeleteVehicleEnrollment/DeleteVehicleEnrollmentCommandHandler.cs b/src/Application/VehicleEnrollments/Commands/DeleteVehicleEnrollment/DeleteVehicleEnrollmentCommandHandler.cs new file mode 100644 index 0000000..0dbb95b --- /dev/null +++ b/src/Application/VehicleEnrollments/Commands/DeleteVehicleEnrollment/DeleteVehicleEnrollmentCommandHandler.cs @@ -0,0 +1,38 @@ +using MediatR; +using cuqmbr.TravelGuide.Application.Common.Interfaces.Persistence; +using cuqmbr.TravelGuide.Application.Common.Exceptions; + +namespace cuqmbr.TravelGuide.Application.VehicleEnrollments.Commands.DeleteVehicleEnrollment; + +public class DeleteVehicleEnrollmentCommandHandler : + IRequestHandler +{ + private readonly UnitOfWork _unitOfWork; + + public DeleteVehicleEnrollmentCommandHandler(UnitOfWork unitOfWork) + { + _unitOfWork = unitOfWork; + } + + public async Task Handle( + DeleteVehicleEnrollmentCommand request, + CancellationToken cancellationToken) + { + var entity = await _unitOfWork.VehicleEnrollmentRepository.GetOneAsync( + e => e.Guid == request.Guid, cancellationToken); + + if (entity == null) + { + throw new NotFoundException(); + } + + // TODO: Check for tickets bought for this enrollment. + // Decide whether to cancel tickets or do not allow deletion. + + await _unitOfWork.VehicleEnrollmentRepository.DeleteOneAsync( + entity, cancellationToken); + + await _unitOfWork.SaveAsync(cancellationToken); + _unitOfWork.Dispose(); + } +} diff --git a/src/Application/VehicleEnrollments/Commands/DeleteVehicleEnrollment/DeleteVehicleEnrollmentCommandValidator.cs b/src/Application/VehicleEnrollments/Commands/DeleteVehicleEnrollment/DeleteVehicleEnrollmentCommandValidator.cs new file mode 100644 index 0000000..0126b0b --- /dev/null +++ b/src/Application/VehicleEnrollments/Commands/DeleteVehicleEnrollment/DeleteVehicleEnrollmentCommandValidator.cs @@ -0,0 +1,14 @@ +using FluentValidation; +using Microsoft.Extensions.Localization; + +namespace cuqmbr.TravelGuide.Application.VehicleEnrollments.Commands.DeleteVehicleEnrollment; + +public class DeleteVehicleEnrollmentCommandValidator : AbstractValidator +{ + public DeleteVehicleEnrollmentCommandValidator(IStringLocalizer localizer) + { + RuleFor(v => v.Guid) + .NotEmpty() + .WithMessage(localizer["FluentValidation.NotEmpty"]); + } +} diff --git a/src/Application/VehicleEnrollments/Commands/UpdateVehicleEnrollment/UpdateVehicleEnrollmentCommand.cs b/src/Application/VehicleEnrollments/Commands/UpdateVehicleEnrollment/UpdateVehicleEnrollmentCommand.cs new file mode 100644 index 0000000..918eda7 --- /dev/null +++ b/src/Application/VehicleEnrollments/Commands/UpdateVehicleEnrollment/UpdateVehicleEnrollmentCommand.cs @@ -0,0 +1,18 @@ +using cuqmbr.TravelGuide.Application.VehicleEnrollments.Models; +using cuqmbr.TravelGuide.Domain.Enums; +using MediatR; + +namespace cuqmbr.TravelGuide.Application.VehicleEnrollments + .Commands.UpdateVehicleEnrollment; + +public record UpdateVehicleEnrollmentCommand : IRequest +{ + public Guid Guid { get; set; } + + public DateTimeOffset DepartureTime { get; set; } + + public Currency Currency { get; set; } + + + public ICollection RouteAddressDetails { get; set; } +} diff --git a/src/Application/VehicleEnrollments/Commands/UpdateVehicleEnrollment/UpdateVehicleEnrollmentCommandAuthorizer.cs b/src/Application/VehicleEnrollments/Commands/UpdateVehicleEnrollment/UpdateVehicleEnrollmentCommandAuthorizer.cs new file mode 100644 index 0000000..9831597 --- /dev/null +++ b/src/Application/VehicleEnrollments/Commands/UpdateVehicleEnrollment/UpdateVehicleEnrollmentCommandAuthorizer.cs @@ -0,0 +1,33 @@ +using cuqmbr.TravelGuide.Application.Common.Authorization; +using cuqmbr.TravelGuide.Application.Common.Interfaces.Services; +using cuqmbr.TravelGuide.Application.Common.Models; +using MediatR.Behaviors.Authorization; + +namespace cuqmbr.TravelGuide.Application.VehicleEnrollments + .Commands.UpdateVehicleEnrollment; + +public class UpdateVehicleEnrollmentCommandAuthorizer : + AbstractRequestAuthorizer +{ + private readonly SessionUserService _sessionUserService; + + public UpdateVehicleEnrollmentCommandAuthorizer( + SessionUserService sessionUserService) + { + _sessionUserService = sessionUserService; + } + + public override void BuildPolicy(UpdateVehicleEnrollmentCommand request) + { + UseRequirement(new MustBeAuthenticatedRequirement + { + IsAuthenticated= _sessionUserService.IsAuthenticated + }); + + UseRequirement(new MustBeInRolesRequirement + { + RequiredRoles = [IdentityRole.Administrator], + UserRoles = _sessionUserService.Roles + }); + } +} diff --git a/src/Application/VehicleEnrollments/Commands/UpdateVehicleEnrollment/UpdateVehicleEnrollmentCommandHandler.cs b/src/Application/VehicleEnrollments/Commands/UpdateVehicleEnrollment/UpdateVehicleEnrollmentCommandHandler.cs new file mode 100644 index 0000000..1b2d0b7 --- /dev/null +++ b/src/Application/VehicleEnrollments/Commands/UpdateVehicleEnrollment/UpdateVehicleEnrollmentCommandHandler.cs @@ -0,0 +1,191 @@ +using MediatR; +using cuqmbr.TravelGuide.Application.Common.Interfaces.Persistence; +using AutoMapper; +using cuqmbr.TravelGuide.Application.Common.Exceptions; +using FluentValidation.Results; +using Microsoft.Extensions.Localization; +using cuqmbr.TravelGuide.Domain.Entities; + +namespace cuqmbr.TravelGuide.Application.VehicleEnrollments + .Commands.UpdateVehicleEnrollment; + +public class UpdateVehicleEnrollmentCommandHandler : + IRequestHandler +{ + private readonly UnitOfWork _unitOfWork; + private readonly IMapper _mapper; + private readonly IStringLocalizer _localizer; + + public UpdateVehicleEnrollmentCommandHandler( + UnitOfWork unitOfWork, + IMapper mapper, + IStringLocalizer localizer) + { + _unitOfWork = unitOfWork; + _mapper = mapper; + _localizer = localizer; + } + + public async Task Handle( + UpdateVehicleEnrollmentCommand request, + CancellationToken cancellationToken) + { + // TODO: Check for tickets bought for this enrollment. + // Decide whether allow or not to perform update action. + + var entity = await _unitOfWork.VehicleEnrollmentRepository.GetOneAsync( + e => e.Guid == request.Guid, e => e.RouteAddressDetails, + cancellationToken); + + if (entity == null) + { + throw new NotFoundException(); + } + + // Check that request has the same Route Addresses + // as a route data from datastore. + + var route = await _unitOfWork.RouteRepository.GetOneAsync( + e => e.Id == entity.RouteId, e => e.RouteAddresses, + cancellationToken); + + var sameRouteAddresses = route.RouteAddresses.All( + ra => request.RouteAddressDetails.Any( + rad => rad.RouteAddressGuid == ra.Guid)); + + if (!sameRouteAddresses) + { + throw new NotFoundException( + $"Not all route addresses are found in a datastore."); + } + + + // Check vehicle enrollments that might overlap with updated one. + // Exclude this vehicle enrollment. + + var requestDepartureTime = request.DepartureTime; + + var requestTravelTime = + request.RouteAddressDetails.Aggregate( + TimeSpan.Zero, (sum, rad) => sum + + rad.TimeToNextAddress + rad.CurrentAddressStopTime); + + var requestArrivalTime = requestDepartureTime + requestTravelTime; + + var enrollmentHistory = + await _unitOfWork.VehicleEnrollmentRepository.GetPageAsync( + e => + e.Vehicle.Id == entity.VehicleId && + e.Id != entity.Id && + e.DepartureTime >= DateTimeOffset.UtcNow.AddDays(-7), + e => e.RouteAddressDetails, + 1, 200, cancellationToken); + + // Three cases are included: + // + // ---RD---------SD----------RA---> + // time + // + // ---RD---------SA----------RA---> + // time + // + // ---SD-----RD-------RA-----SA---> + // time + // Where: + // RD - request enrollment departure time + // RA - request enrollment arrival time + // SD - datastore enrollment (S for store) departure time + // SA - datastore enrollment (S for store) arrival time + + var overlappingWithOtherEnrollments = enrollmentHistory.Items + .Where(ve => + { + var departureTime = ve.DepartureTime; + + var arrivalTime = + ve.DepartureTime + + ve.RouteAddressDetails + .Aggregate( + TimeSpan.Zero, + (sum, rad) => sum + + rad.TimeToNextAddress + + rad.CurrentAddressStopTime); + + return + (departureTime >= requestDepartureTime && + departureTime <= requestArrivalTime) || + (arrivalTime >= requestDepartureTime && + arrivalTime <= requestArrivalTime) || + (departureTime <= requestDepartureTime && + arrivalTime >= requestArrivalTime); + }) + .Any(); + + if (overlappingWithOtherEnrollments) + { + throw new ValidationException( + new List + { + new() + { + PropertyName = nameof(request.DepartureTime), + ErrorMessage = _localizer["Validation." + + "VehicleEnrollments.OverlapWithOther"] + } + }); + } + + + // Update entity and add to datastore. + + entity.DepartureTime = request.DepartureTime; + entity.Currency = request.Currency; + + foreach (var rad in entity.RouteAddressDetails) + { + var correspondingRouteAddress = route.RouteAddresses + .First(ra => ra.Id == rad.RouteAddressId); + + rad.TimeToNextAddress = request.RouteAddressDetails + .First(rrad => rrad.RouteAddressGuid == rad.RouteAddress.Guid) + .TimeToNextAddress; + rad.CostToNextAddress = request.RouteAddressDetails + .First(rrad => rrad.RouteAddressGuid == rad.RouteAddress.Guid) + .CostToNextAddress; + rad.CurrentAddressStopTime = request.RouteAddressDetails + .First(rrad => rrad.RouteAddressGuid == rad.RouteAddress.Guid) + .CurrentAddressStopTime; + } + + entity = await _unitOfWork.VehicleEnrollmentRepository.UpdateOneAsync( + entity, cancellationToken); + + await _unitOfWork.SaveAsync(cancellationToken); + + // Hydrate vehicle enrollment with address information + + var routeAddresses = await _unitOfWork.RouteAddressRepository + .GetPageAsync( + e => + entity.RouteAddressDetails + .Select(rad => rad.RouteAddressId) + .Contains(e.Id), + e => e.Address.City.Region.Country, + 1, entity.RouteAddressDetails.Count(), + cancellationToken); + + foreach (var rad in entity.RouteAddressDetails) + { + rad.RouteAddress = routeAddresses.Items + .First(ra => ra.Id == rad.RouteAddressId); + } + + entity.RouteAddressDetails = entity.RouteAddressDetails + .OrderBy(rad => rad.RouteAddress.Order) + .ToArray(); + + _unitOfWork.Dispose(); + + return _mapper.Map(entity); + } +} diff --git a/src/Application/VehicleEnrollments/Commands/UpdateVehicleEnrollment/UpdateVehicleEnrollmentCommandValidator.cs b/src/Application/VehicleEnrollments/Commands/UpdateVehicleEnrollment/UpdateVehicleEnrollmentCommandValidator.cs new file mode 100644 index 0000000..c15552f --- /dev/null +++ b/src/Application/VehicleEnrollments/Commands/UpdateVehicleEnrollment/UpdateVehicleEnrollmentCommandValidator.cs @@ -0,0 +1,58 @@ +using cuqmbr.TravelGuide.Application.Common.Interfaces.Services; +using cuqmbr.TravelGuide.Domain.Enums; +using FluentValidation; +using Microsoft.Extensions.Localization; + +namespace cuqmbr.TravelGuide.Application.VehicleEnrollments + .Commands.UpdateVehicleEnrollment; + +public class UpdateVehicleEnrollmentCommandValidator : + AbstractValidator +{ + public UpdateVehicleEnrollmentCommandValidator( + IStringLocalizer localizer, + CultureService cultureService, + TimeZoneService timeZoneService) + { + RuleFor(v => v.Guid) + .NotEmpty() + .WithMessage(localizer["FluentValidation.NotEmpty"]); + + RuleFor(v => v.DepartureTime) + .NotEmpty() + .WithMessage(localizer["FluentValidation.NotEmpty"]) + .Must(dt => dt >= DateTimeOffset.Now) + .WithMessage( + String.Format( + cultureService.Culture, + localizer["FluentValidation.DateTimeOffset.GreaterThanOrEqualTo"], + DateTimeOffset.Now.ToOffset(timeZoneService.TimeZone.BaseUtcOffset))); + + RuleFor(v => v.Currency) + .Must(c => Currency.Enumerations.ContainsValue(c)) + .WithMessage( + String.Format( + localizer["FluentValidation.MustBeInEnum"], + String.Join( + ", ", + Currency.Enumerations.Values.Select(e => e.Name)))); + + RuleFor(v => v.RouteAddressDetails.Count) + .GreaterThanOrEqualTo(2) + .WithMessage( + String.Format( + cultureService.Culture, + localizer["FluentValidation.GreaterThanOrEqualTo"], + 2)); + + RuleFor(v => v.RouteAddressDetails) + .Must(v => v.All(rad => rad.RouteAddressGuid != Guid.Empty)) + .WithMessage(localizer["FluentValidation.NotEmpty"]) + .Must(v => v.All(rad => rad.TimeToNextAddress >= TimeSpan.Zero)) + .WithMessage(localizer["VehicleEnrollments.NegativeTime"]) + .Must(v => v.All(rad => rad.CostToNextAddress >= 0)) + .WithMessage(localizer["VehicleEnrollments.NegativeCost"]) + .Must(v => v.All(rad => rad.CurrentAddressStopTime >= TimeSpan.Zero)) + .WithMessage(localizer["VehicleEnrollments.NegativeTime"]); + } +} diff --git a/src/Application/VehicleEnrollments/Models/RouteAddressDetailModel.cs b/src/Application/VehicleEnrollments/Models/RouteAddressDetailModel.cs new file mode 100644 index 0000000..e3d972c --- /dev/null +++ b/src/Application/VehicleEnrollments/Models/RouteAddressDetailModel.cs @@ -0,0 +1,13 @@ +namespace cuqmbr.TravelGuide.Application.VehicleEnrollments.Models; + +public sealed class RouteAddressDetailModel +{ + public TimeSpan TimeToNextAddress { get; set; } + + public decimal CostToNextAddress { get; set; } + + public TimeSpan CurrentAddressStopTime { get; set; } + + + public Guid RouteAddressGuid { get; set; } +} diff --git a/src/Application/VehicleEnrollments/Queries/GetVehicleEnrollment/GetVehicleEnrollmentQuery.cs b/src/Application/VehicleEnrollments/Queries/GetVehicleEnrollment/GetVehicleEnrollmentQuery.cs new file mode 100644 index 0000000..4220c6f --- /dev/null +++ b/src/Application/VehicleEnrollments/Queries/GetVehicleEnrollment/GetVehicleEnrollmentQuery.cs @@ -0,0 +1,9 @@ +using MediatR; + +namespace cuqmbr.TravelGuide.Application.VehicleEnrollments + .Queries.GetVehicleEnrollment; + +public record GetVehicleEnrollmentQuery : IRequest +{ + public Guid Guid { get; set; } +} diff --git a/src/Application/VehicleEnrollments/Queries/GetVehicleEnrollment/GetVehicleEnrollmentQueryAuthorizer.cs b/src/Application/VehicleEnrollments/Queries/GetVehicleEnrollment/GetVehicleEnrollmentQueryAuthorizer.cs new file mode 100644 index 0000000..58fe68f --- /dev/null +++ b/src/Application/VehicleEnrollments/Queries/GetVehicleEnrollment/GetVehicleEnrollmentQueryAuthorizer.cs @@ -0,0 +1,32 @@ +using cuqmbr.TravelGuide.Application.Common.Authorization; +using cuqmbr.TravelGuide.Application.Common.Interfaces.Services; +using cuqmbr.TravelGuide.Application.Common.Models; +using MediatR.Behaviors.Authorization; + +namespace cuqmbr.TravelGuide.Application.VehicleEnrollments + .Queries.GetVehicleEnrollment; + +public class GetVehicleEnrollmentQueryAuthorizer : + AbstractRequestAuthorizer +{ + private readonly SessionUserService _sessionUserService; + + public GetVehicleEnrollmentQueryAuthorizer(SessionUserService sessionUserService) + { + _sessionUserService = sessionUserService; + } + + public override void BuildPolicy(GetVehicleEnrollmentQuery request) + { + UseRequirement(new MustBeAuthenticatedRequirement + { + IsAuthenticated= _sessionUserService.IsAuthenticated + }); + + UseRequirement(new MustBeInRolesRequirement + { + RequiredRoles = [IdentityRole.Administrator], + UserRoles = _sessionUserService.Roles + }); + } +} diff --git a/src/Application/VehicleEnrollments/Queries/GetVehicleEnrollment/GetVehicleEnrollmentQueryHandler.cs b/src/Application/VehicleEnrollments/Queries/GetVehicleEnrollment/GetVehicleEnrollmentQueryHandler.cs new file mode 100644 index 0000000..5ca9b2c --- /dev/null +++ b/src/Application/VehicleEnrollments/Queries/GetVehicleEnrollment/GetVehicleEnrollmentQueryHandler.cs @@ -0,0 +1,58 @@ +using MediatR; +using cuqmbr.TravelGuide.Application.Common.Interfaces.Persistence; +using cuqmbr.TravelGuide.Application.Common.Exceptions; +using AutoMapper; + +namespace cuqmbr.TravelGuide.Application.VehicleEnrollments + .Queries.GetVehicleEnrollment; + +public class GetVehicleEnrollmentQueryHandler : + IRequestHandler +{ + private readonly UnitOfWork _unitOfWork; + private readonly IMapper _mapper; + + public GetVehicleEnrollmentQueryHandler( + UnitOfWork unitOfWork, + IMapper mapper) + { + _unitOfWork = unitOfWork; + _mapper = mapper; + } + + public async Task Handle( + GetVehicleEnrollmentQuery request, + CancellationToken cancellationToken) + { + var entity = await _unitOfWork.VehicleEnrollmentRepository.GetOneAsync( + e => e.Guid == request.Guid, e => e.RouteAddressDetails, + cancellationToken); + + if (entity == null) + { + throw new NotFoundException(); + } + + // Hydrate vehicle enrollment with address information + + var routeAddresses = await _unitOfWork.RouteAddressRepository + .GetPageAsync( + e => + entity.RouteAddressDetails + .Select(rad => rad.RouteAddressId) + .Contains(e.Id), + e => e.Address.City.Region.Country, + 1, entity.RouteAddressDetails.Count(), + cancellationToken); + + foreach (var rad in entity.RouteAddressDetails) + { + rad.RouteAddress = routeAddresses.Items + .First(ra => ra.Id == rad.RouteAddressId); + } + + _unitOfWork.Dispose(); + + return _mapper.Map(entity); + } +} diff --git a/src/Application/VehicleEnrollments/Queries/GetVehicleEnrollment/GetVehicleEnrollmentQueryValidator.cs b/src/Application/VehicleEnrollments/Queries/GetVehicleEnrollment/GetVehicleEnrollmentQueryValidator.cs new file mode 100644 index 0000000..8a5931f --- /dev/null +++ b/src/Application/VehicleEnrollments/Queries/GetVehicleEnrollment/GetVehicleEnrollmentQueryValidator.cs @@ -0,0 +1,14 @@ +using FluentValidation; +using Microsoft.Extensions.Localization; + +namespace cuqmbr.TravelGuide.Application.VehicleEnrollments.Queries.GetVehicleEnrollment; + +public class GetVehicleEnrollmentQueryValidator : AbstractValidator +{ + public GetVehicleEnrollmentQueryValidator(IStringLocalizer localizer) + { + RuleFor(v => v.Guid) + .NotEmpty() + .WithMessage(localizer["FluentValidation.NotEmpty"]); + } +} diff --git a/src/Application/VehicleEnrollments/Queries/GetVehicleEnrollmentsPage/GetVehicleEnrollmentsPageQuery.cs b/src/Application/VehicleEnrollments/Queries/GetVehicleEnrollmentsPage/GetVehicleEnrollmentsPageQuery.cs new file mode 100644 index 0000000..a2c07bb --- /dev/null +++ b/src/Application/VehicleEnrollments/Queries/GetVehicleEnrollmentsPage/GetVehicleEnrollmentsPageQuery.cs @@ -0,0 +1,52 @@ +using cuqmbr.TravelGuide.Application.Common.Models; +using cuqmbr.TravelGuide.Domain.Enums; +using MediatR; + +namespace cuqmbr.TravelGuide.Application.VehicleEnrollments + .Queries.GetVehicleEnrollmentsPage; + +public record GetVehicleEnrollmentsPageQuery : + IRequest> +{ + public int PageNumber { get; set; } = 1; + + public int PageSize { get; set; } = 10; + + // public string Search { get; set; } = String.Empty; + + public string Sort { get; set; } = String.Empty; + + public Guid? RouteGuid { get; set; } + + public Guid? VehicleGuid { get; set; } + + public int? NumberOfAddressesGreaterThanOrEqual { get; set; } + + public int? NumberOfAddressesLessThanOrEqual { get; set; } + + public DateTimeOffset? DepartureTimeGreaterThanOrEqual { get; set; } + + public DateTimeOffset? DepartureTimeLessThanOrEqual { get; set; } + + public DateTimeOffset? ArrivalTimeGreaterThanOrEqual { get; set; } + + public DateTimeOffset? ArrivalTimeLessThanOrEqual { get; set; } + + public TimeSpan? TravelTimeGreaterThanOrEqual { get; set; } + + public TimeSpan? TravelTimeLessThanOrEqual { get; set; } + + public TimeSpan? TimeMovingGreaterThanOrEqual { get; set; } + + public TimeSpan? TimeMovingLessThanOrEqual { get; set; } + + public TimeSpan? TimeInStopsGreaterThanOrEqual { get; set; } + + public TimeSpan? TimeInStopsLessThanOrEqual { get; set; } + + public decimal? CostGreaterThanOrEqual { get; set; } + + public decimal? CostLessThanOrEqual { get; set; } + + public Currency? Currency { get; set; } +} diff --git a/src/Application/VehicleEnrollments/Queries/GetVehicleEnrollmentsPage/GetVehicleEnrollmentsPageQueryAuthorizer.cs b/src/Application/VehicleEnrollments/Queries/GetVehicleEnrollmentsPage/GetVehicleEnrollmentsPageQueryAuthorizer.cs new file mode 100644 index 0000000..fc83c92 --- /dev/null +++ b/src/Application/VehicleEnrollments/Queries/GetVehicleEnrollmentsPage/GetVehicleEnrollmentsPageQueryAuthorizer.cs @@ -0,0 +1,31 @@ +using cuqmbr.TravelGuide.Application.Common.Authorization; +using cuqmbr.TravelGuide.Application.Common.Interfaces.Services; +using cuqmbr.TravelGuide.Application.Common.Models; +using MediatR.Behaviors.Authorization; + +namespace cuqmbr.TravelGuide.Application.VehicleEnrollments.Queries.GetVehicleEnrollmentsPage; + +public class GetVehicleEnrollmentsPageQueryAuthorizer : + AbstractRequestAuthorizer +{ + private readonly SessionUserService _sessionUserService; + + public GetVehicleEnrollmentsPageQueryAuthorizer(SessionUserService sessionUserService) + { + _sessionUserService = sessionUserService; + } + + public override void BuildPolicy(GetVehicleEnrollmentsPageQuery request) + { + UseRequirement(new MustBeAuthenticatedRequirement + { + IsAuthenticated= _sessionUserService.IsAuthenticated + }); + + UseRequirement(new MustBeInRolesRequirement + { + RequiredRoles = [IdentityRole.Administrator], + UserRoles = _sessionUserService.Roles + }); + } +} diff --git a/src/Application/VehicleEnrollments/Queries/GetVehicleEnrollmentsPage/GetVehicleEnrollmentsPageQueryHandler.cs b/src/Application/VehicleEnrollments/Queries/GetVehicleEnrollmentsPage/GetVehicleEnrollmentsPageQueryHandler.cs new file mode 100644 index 0000000..ee4f86c --- /dev/null +++ b/src/Application/VehicleEnrollments/Queries/GetVehicleEnrollmentsPage/GetVehicleEnrollmentsPageQueryHandler.cs @@ -0,0 +1,157 @@ +using MediatR; +using cuqmbr.TravelGuide.Application.Common.Interfaces.Persistence; +using AutoMapper; +using cuqmbr.TravelGuide.Application.Common.Models; +using cuqmbr.TravelGuide.Application.Common.Extensions; + +namespace cuqmbr.TravelGuide.Application.VehicleEnrollments + .Queries.GetVehicleEnrollmentsPage; + +public class GetVehicleEnrollmentsPageQueryHandler : + IRequestHandler> +{ + private readonly UnitOfWork _unitOfWork; + private readonly IMapper _mapper; + + public GetVehicleEnrollmentsPageQueryHandler( + UnitOfWork unitOfWork, + IMapper mapper) + { + _unitOfWork = unitOfWork; + _mapper = mapper; + } + + public async Task> Handle( + GetVehicleEnrollmentsPageQuery request, + CancellationToken cancellationToken) + { + // TODO: Add search functionality or remove it + var paginatedList = await _unitOfWork.VehicleEnrollmentRepository.GetPageAsync( + e => + // (e.Name.ToLower().Contains(request.Search.ToLower()) || + // e.City.Region.Country.Name.ToLower().Contains(request.Search.ToLower())) && + (request.RouteGuid != null + ? e.Route.Guid == request.RouteGuid + : true) && + (request.VehicleGuid != null + ? e.Vehicle.Guid >= request.VehicleGuid + : true) && + (request.NumberOfAddressesGreaterThanOrEqual != null + ? + e.RouteAddressDetails.Count() >= + request.NumberOfAddressesGreaterThanOrEqual + : true) && + (request.NumberOfAddressesLessThanOrEqual != null + ? + e.RouteAddressDetails.Count() <= + request.NumberOfAddressesLessThanOrEqual + : true) && + (request.DepartureTimeGreaterThanOrEqual != null + ? e.DepartureTime >= request.DepartureTimeGreaterThanOrEqual + : true) && + (request.DepartureTimeLessThanOrEqual != null + ? e.DepartureTime <= request.DepartureTimeLessThanOrEqual + : true) && + (request.ArrivalTimeGreaterThanOrEqual != null + ? + e.DepartureTime.AddSeconds(e.RouteAddressDetails + .Sum(rad => rad.TimeToNextAddress.TotalSeconds + rad.CurrentAddressStopTime.TotalSeconds)) >= + request.ArrivalTimeGreaterThanOrEqual + : true) && + (request.ArrivalTimeLessThanOrEqual != null + ? + e.DepartureTime.AddSeconds(e.RouteAddressDetails + .Sum(rad => rad.TimeToNextAddress.TotalSeconds + rad.CurrentAddressStopTime.TotalSeconds)) <= + request.ArrivalTimeLessThanOrEqual + : true) && + (request.TravelTimeGreaterThanOrEqual != null + ? + e.RouteAddressDetails + .Sum(rad => rad.TimeToNextAddress.TotalSeconds) >= + request.TravelTimeGreaterThanOrEqual.Value.TotalSeconds + : true) && + (request.TravelTimeLessThanOrEqual != null + ? + e.RouteAddressDetails + .Sum(rad => rad.TimeToNextAddress.TotalSeconds) <= + request.TravelTimeLessThanOrEqual.Value.TotalSeconds + : true) && + (request.TimeMovingGreaterThanOrEqual != null + ? + e.RouteAddressDetails + .Sum(rad => rad.TimeToNextAddress.TotalSeconds) >= + request.TimeMovingGreaterThanOrEqual.Value.TotalSeconds + : true) && + (request.TimeMovingLessThanOrEqual != null + ? + e.RouteAddressDetails + .Sum(rad => rad.TimeToNextAddress.TotalSeconds) <= + request.TimeMovingLessThanOrEqual.Value.TotalSeconds + : true) && + (request.TimeInStopsGreaterThanOrEqual != null + ? + e.RouteAddressDetails + .Sum(rad => rad.CurrentAddressStopTime.TotalSeconds) >= + request.TimeInStopsGreaterThanOrEqual.Value.TotalSeconds + : true) && + (request.TimeInStopsLessThanOrEqual != null + ? + e.RouteAddressDetails + .Sum(rad => rad.CurrentAddressStopTime.TotalSeconds) <= + request.TimeInStopsLessThanOrEqual.Value.TotalSeconds + : true) && + (request.CostGreaterThanOrEqual != null + ? e.RouteAddressDetails.Sum(rad => rad.CostToNextAddress) >= + request.CostGreaterThanOrEqual + : true) && + (request.CostLessThanOrEqual != null + ? e.RouteAddressDetails.Sum(rad => rad.CostToNextAddress) <= + request.CostLessThanOrEqual + : true) && + (request.Currency != null + ? e.Currency == request.Currency + : true), + e => e.RouteAddressDetails, + request.PageNumber, request.PageSize, + cancellationToken); + + + // Hydrate vehicle enrollment with address information + + var routeAddressIds = paginatedList.Items + .SelectMany(ve => ve.RouteAddressDetails) + .Select(rad => rad.RouteAddressId); + + var routeAddresses = await _unitOfWork.RouteAddressRepository + .GetPageAsync( + e => + routeAddressIds.Contains(e.Id), + e => e.Address.City.Region.Country, + 1, paginatedList.Items.Sum(e => e.RouteAddressDetails.Count()), + cancellationToken); + + foreach (var vehicleEnrollment in paginatedList.Items) + { + foreach (var routeAddressDetail in + vehicleEnrollment.RouteAddressDetails) + { + routeAddressDetail.RouteAddress = routeAddresses.Items + .First(ra => ra.Id == routeAddressDetail.RouteAddressId); + } + } + + + var mappedItems = _mapper + .ProjectTo(paginatedList.Items.AsQueryable()); + + mappedItems = QueryableExtension + .ApplySort(mappedItems, request.Sort); + + _unitOfWork.Dispose(); + + return new PaginatedList( + mappedItems.ToList(), + paginatedList.TotalCount, request.PageNumber, + request.PageSize); + } +} diff --git a/src/Application/VehicleEnrollments/Queries/GetVehicleEnrollmentsPage/GetVehicleEnrollmentsPageQueryValidator.cs b/src/Application/VehicleEnrollments/Queries/GetVehicleEnrollmentsPage/GetVehicleEnrollmentsPageQueryValidator.cs new file mode 100644 index 0000000..9cfb2d3 --- /dev/null +++ b/src/Application/VehicleEnrollments/Queries/GetVehicleEnrollmentsPage/GetVehicleEnrollmentsPageQueryValidator.cs @@ -0,0 +1,43 @@ +using cuqmbr.TravelGuide.Application.Common.Interfaces.Services; +using FluentValidation; +using Microsoft.Extensions.Localization; + +namespace cuqmbr.TravelGuide.Application.VehicleEnrollments.Queries.GetVehicleEnrollmentsPage; + +public class GetVehicleEnrollmentsPageQueryValidator : AbstractValidator +{ + public GetVehicleEnrollmentsPageQueryValidator( + IStringLocalizer localizer, + CultureService cultureService) + { + RuleFor(v => v.PageNumber) + .GreaterThanOrEqualTo(1) + .WithMessage( + String.Format( + cultureService.Culture, + localizer["FluentValidation.GreaterThanOrEqualTo"], + 1)); + + RuleFor(v => v.PageSize) + .GreaterThanOrEqualTo(1) + .WithMessage( + String.Format( + cultureService.Culture, + localizer["FluentValidation.GreaterThanOrEqualTo"], + 1)) + .LessThanOrEqualTo(50) + .WithMessage( + String.Format( + cultureService.Culture, + localizer["FluentValidation.LessThanOrEqualTo"], + 50)); + + // RuleFor(v => v.Search) + // .MaximumLength(64) + // .WithMessage( + // String.Format( + // cultureService.Culture, + // localizer["FluentValidation.MaximumLength"], + // 64)); + } +} diff --git a/src/Application/VehicleEnrollments/RouteAddressDetailDto.cs b/src/Application/VehicleEnrollments/RouteAddressDetailDto.cs new file mode 100644 index 0000000..fe8d741 --- /dev/null +++ b/src/Application/VehicleEnrollments/RouteAddressDetailDto.cs @@ -0,0 +1,29 @@ +using cuqmbr.TravelGuide.Application.Common.Mappings; +using cuqmbr.TravelGuide.Domain.Entities; + +namespace cuqmbr.TravelGuide.Application.VehicleEnrollments; + +public sealed class RouteAddressDetailDto : IMapFrom +{ + public TimeSpan TimeToNextAddress { get; set; } + + public decimal CostToNextAddress { get; set; } + + public TimeSpan CurrentAddressStopTime { get; set; } + + public Guid RouteAddressUuid { get; set; } + + + public VehicleEnrollmentRouteAddressDto RouteAddress { get; set; } + + public void Mapping(MappingProfile profile) + { + profile.CreateMap() + .ForMember( + d => d.RouteAddressUuid, + opt => opt.MapFrom(s => s.RouteAddress.Guid)) + .ForMember( + d => d.RouteAddress, + opt => opt.MapFrom(s => s.RouteAddress)); + } +} diff --git a/src/Application/VehicleEnrollments/VehicleEnrollmentDto.cs b/src/Application/VehicleEnrollments/VehicleEnrollmentDto.cs new file mode 100644 index 0000000..0da091a --- /dev/null +++ b/src/Application/VehicleEnrollments/VehicleEnrollmentDto.cs @@ -0,0 +1,50 @@ +using cuqmbr.TravelGuide.Application.Common.Mappings; +using cuqmbr.TravelGuide.Application.Common.Mappings.Resolvers; +using cuqmbr.TravelGuide.Domain.Entities; + +namespace cuqmbr.TravelGuide.Application.VehicleEnrollments; + +public sealed class VehicleEnrollmentDto : IMapFrom +{ + public Guid Uuid { get; set; } + + public DateTimeOffset DepartureTime { get; set; } + + public DateTimeOffset ArrivalTime => + DepartureTime + + RouteAddressDetails.Aggregate(TimeSpan.Zero, (sum, next) => + sum + next.TimeToNextAddress + next.CurrentAddressStopTime); + + public TimeSpan TravelTime => + RouteAddressDetails.Aggregate(TimeSpan.Zero, (sum, next) => + sum + next.TimeToNextAddress + next.CurrentAddressStopTime); + + public TimeSpan TimeMoving => + RouteAddressDetails.Aggregate(TimeSpan.Zero, (sum, next) => + sum + next.TimeToNextAddress); + + public TimeSpan TimeInStops => + RouteAddressDetails.Aggregate(TimeSpan.Zero, (sum, next) => + sum + next.CurrentAddressStopTime); + + public decimal TotalCost => + RouteAddressDetails.Aggregate((decimal)0, (sum, next) => + sum + next.CostToNextAddress); + + public string Currency { get; set; } + + public ICollection RouteAddressDetails { get; set; } + + public void Mapping(MappingProfile profile) + { + profile.CreateMap() + .ForMember( + d => d.Uuid, + opt => opt.MapFrom(s => s.Guid)) + .ForMember( + d => d.DepartureTime, + opt => opt + .MapFrom( + s => s.DepartureTime)); + } +} diff --git a/src/Application/VehicleEnrollments/VehicleEnrollmentRouteAddressDto.cs b/src/Application/VehicleEnrollments/VehicleEnrollmentRouteAddressDto.cs new file mode 100644 index 0000000..dae8127 --- /dev/null +++ b/src/Application/VehicleEnrollments/VehicleEnrollmentRouteAddressDto.cs @@ -0,0 +1,75 @@ +using cuqmbr.TravelGuide.Application.Common.Mappings; +using cuqmbr.TravelGuide.Domain.Entities; + +namespace cuqmbr.TravelGuide.Application.VehicleEnrollments; + +public sealed class VehicleEnrollmentRouteAddressDto : IMapFrom +{ + public Guid Uuid { get; set; } + + public short Order { get; set; } + + + public Guid AddressUuid { get; set; } + + public string AddressName { get; set; } + + public double AddressLongitude { get; set; } + + public double AddressLatitude { get; set; } + + public string AddressVehicleType { get; set; } + + public Guid CountryUuid { get; set; } + + public string CountryName { get; set; } + + public Guid RegionUuid { get; set; } + + public string RegionName { get; set; } + + public Guid CityUuid { get; set; } + + public string CityName { get; set; } + + public void Mapping(MappingProfile profile) + { + profile.CreateMap() + .ForMember( + d => d.Uuid, + opt => opt.MapFrom(s => s.Guid)) + .ForMember( + d => d.AddressUuid, + opt => opt.MapFrom(s => s.Address.Guid)) + .ForMember( + d => d.AddressName, + opt => opt.MapFrom(s => s.Address.Name)) + .ForMember( + d => d.AddressLongitude, + opt => opt.MapFrom(s => s.Address.Longitude)) + .ForMember( + d => d.AddressLatitude, + opt => opt.MapFrom(s => s.Address.Latitude)) + .ForMember( + d => d.AddressVehicleType, + opt => opt.MapFrom(s => s.Address.VehicleType.Name)) + .ForMember( + d => d.CityUuid, + opt => opt.MapFrom(s => s.Address.City.Guid)) + .ForMember( + d => d.CityName, + opt => opt.MapFrom(s => s.Address.City.Name)) + .ForMember( + d => d.RegionUuid, + opt => opt.MapFrom(s => s.Address.City.Region.Guid)) + .ForMember( + d => d.RegionName, + opt => opt.MapFrom(s => s.Address.City.Region.Name)) + .ForMember( + d => d.CountryUuid, + opt => opt.MapFrom(s => s.Address.City.Region.Country.Guid)) + .ForMember( + d => d.CountryName, + opt => opt.MapFrom(s => s.Address.City.Region.Country.Name)); + } +} diff --git a/src/Application/VehicleEnrollments/ViewModels/AddVehicleEnrollmentViewModel.cs b/src/Application/VehicleEnrollments/ViewModels/AddVehicleEnrollmentViewModel.cs new file mode 100644 index 0000000..dd09bcf --- /dev/null +++ b/src/Application/VehicleEnrollments/ViewModels/AddVehicleEnrollmentViewModel.cs @@ -0,0 +1,15 @@ +namespace cuqmbr.TravelGuide.Application.VehicleEnrollments.ViewModels; + +public sealed class AddVehicleEnrollmentViewModel +{ + public DateTimeOffset DepartureTime { get; set; } + + public string Currency { get; set; } + + + public Guid VehicleUuid { get; set; } + + public Guid RouteUuid { get; set; } + + public ICollection RouteAddressDetails { get; set; } +} diff --git a/src/Application/VehicleEnrollments/ViewModels/GetVehicleEnrollmentsPageFilterViewModel.cs b/src/Application/VehicleEnrollments/ViewModels/GetVehicleEnrollmentsPageFilterViewModel.cs new file mode 100644 index 0000000..5cde753 --- /dev/null +++ b/src/Application/VehicleEnrollments/ViewModels/GetVehicleEnrollmentsPageFilterViewModel.cs @@ -0,0 +1,46 @@ +namespace cuqmbr.TravelGuide.Application.VehicleEnrollments.ViewModels; + +public sealed class GetVehicleEnrollmentsPageFilterViewModel +{ + public int PageNumber { get; set; } = 1; + + public int PageSize { get; set; } = 10; + + public string Search { get; set; } = String.Empty; + + public string Sort { get; set; } = String.Empty; + + public Guid? RouteGuid { get; set; } + + public Guid? VehicleGuid { get; set; } + + public int? NumberOfAddressesGreaterThanOrEqual { get; set; } + + public int? NumberOfAddressesLessThanOrEqual { get; set; } + + public DateTimeOffset? DepartureTimeGreaterThanOrEqual { get; set; } + + public DateTimeOffset? DepartureTimeLessThanOrEqual { get; set; } + + public DateTimeOffset? ArrivalTimeGreaterThanOrEqual { get; set; } + + public DateTimeOffset? ArrivalTimeLessThanOrEqual { get; set; } + + public TimeSpan? TravelTimeGreaterThanOrEqual { get; set; } + + public TimeSpan? TravelTimeLessThanOrEqual { get; set; } + + public TimeSpan? TimeMovingGreaterThanOrEqual { get; set; } + + public TimeSpan? TimeMovingLessThanOrEqual { get; set; } + + public TimeSpan? TimeInStopsGreaterThanOrEqual { get; set; } + + public TimeSpan? TimeInStopsLessThanOrEqual { get; set; } + + public decimal? CostGreaterThanOrEqual { get; set; } + + public decimal? CostLessThanOrEqual { get; set; } + + public string? Currency { get; set; } +} diff --git a/src/Application/VehicleEnrollments/ViewModels/RouteAddressDetailViewModel.cs b/src/Application/VehicleEnrollments/ViewModels/RouteAddressDetailViewModel.cs new file mode 100644 index 0000000..e1aaa6f --- /dev/null +++ b/src/Application/VehicleEnrollments/ViewModels/RouteAddressDetailViewModel.cs @@ -0,0 +1,13 @@ +namespace cuqmbr.TravelGuide.Application.VehicleEnrollments.ViewModels; + +public sealed class RouteAddressDetailViewModel +{ + public TimeSpan TimeToNextAddress { get; set; } + + public decimal CostToNextAddress { get; set; } + + public TimeSpan CurrentAddressStopTime { get; set; } + + + public Guid RouteAddressUuid { get; set; } +} diff --git a/src/Application/VehicleEnrollments/ViewModels/UpdateVehicleEnrollmentViewModel.cs b/src/Application/VehicleEnrollments/ViewModels/UpdateVehicleEnrollmentViewModel.cs new file mode 100644 index 0000000..5c61d67 --- /dev/null +++ b/src/Application/VehicleEnrollments/ViewModels/UpdateVehicleEnrollmentViewModel.cs @@ -0,0 +1,11 @@ +namespace cuqmbr.TravelGuide.Application.VehicleEnrollments.ViewModels; + +public sealed class UpdateVehicleEnrollmentViewModel +{ + public DateTimeOffset DepartureTime { get; set; } + + public string Currency { get; set; } + + + public ICollection RouteAddressDetails { get; set; } +} diff --git a/src/Domain/Entities/Route.cs b/src/Domain/Entities/Route.cs index f947c43..c4a0920 100644 --- a/src/Domain/Entities/Route.cs +++ b/src/Domain/Entities/Route.cs @@ -10,4 +10,6 @@ public sealed class Route : EntityBase public ICollection RouteAddresses { get; set; } + + public ICollection VehicleEnrollments { get; set; } } diff --git a/src/Domain/Entities/RouteAddress.cs b/src/Domain/Entities/RouteAddress.cs index f216cca..d383ff5 100644 --- a/src/Domain/Entities/RouteAddress.cs +++ b/src/Domain/Entities/RouteAddress.cs @@ -13,4 +13,7 @@ public sealed class RouteAddress : EntityBase public long RouteId { get; set; } public Route Route { get; set; } + + + public ICollection Details { get; set; } } diff --git a/src/Domain/Entities/RouteAddressDetail.cs b/src/Domain/Entities/RouteAddressDetail.cs new file mode 100644 index 0000000..3c4a6e4 --- /dev/null +++ b/src/Domain/Entities/RouteAddressDetail.cs @@ -0,0 +1,20 @@ +namespace cuqmbr.TravelGuide.Domain.Entities; + +public sealed class RouteAddressDetail : EntityBase +{ + public TimeSpan TimeToNextAddress { get; set; } + + public decimal CostToNextAddress { get; set; } + + public TimeSpan CurrentAddressStopTime { get; set; } + + + public long VehicleEnrollmentId { get; set; } + + public VehicleEnrollment VehicleEnrollment { get; set; } + + + public long RouteAddressId { get; set; } + + public RouteAddress RouteAddress { get; set; } +} diff --git a/src/Domain/Entities/Vehicle.cs b/src/Domain/Entities/Vehicle.cs index b0882df..42196e8 100644 --- a/src/Domain/Entities/Vehicle.cs +++ b/src/Domain/Entities/Vehicle.cs @@ -5,4 +5,7 @@ namespace cuqmbr.TravelGuide.Domain.Entities; public abstract class Vehicle : EntityBase { public VehicleType VehicleType { get; set; } + + + public ICollection Enrollments { get; set; } } diff --git a/src/Domain/Entities/VehicleEnrollment.cs b/src/Domain/Entities/VehicleEnrollment.cs new file mode 100644 index 0000000..ac92112 --- /dev/null +++ b/src/Domain/Entities/VehicleEnrollment.cs @@ -0,0 +1,23 @@ +using cuqmbr.TravelGuide.Domain.Enums; + +namespace cuqmbr.TravelGuide.Domain.Entities; + +public class VehicleEnrollment : EntityBase +{ + public DateTimeOffset DepartureTime { get; set; } + + public Currency Currency { get; set; } + + + public long VehicleId { get; set; } + + public Vehicle Vehicle { get; set; } + + + public long RouteId { get; set; } + + public Route Route { get; set; } + + + public ICollection RouteAddressDetails { get; set; } +} diff --git a/src/Domain/Enums/Currency.cs b/src/Domain/Enums/Currency.cs new file mode 100644 index 0000000..4960808 --- /dev/null +++ b/src/Domain/Enums/Currency.cs @@ -0,0 +1,30 @@ +namespace cuqmbr.TravelGuide.Domain.Enums; + +// Do not forget to update the schema of your database when changing +// this class (if you use it with a database) + +// ISO-4217 Country Codes dated 2025-03-31 + +public abstract class Currency : Enumeration +{ + public static readonly Currency USD = new USDCurrency(); + public static readonly Currency EUR = new EURCurrency(); + public static readonly Currency UAH = new UAHCurrency(); + + protected Currency(int value, string name) : base(value, name) { } + + private sealed class USDCurrency : Currency + { + public USDCurrency() : base(840, "USD") { } + } + + private sealed class EURCurrency : Currency + { + public EURCurrency() : base(978, "EUR") { } + } + + private sealed class UAHCurrency : Currency + { + public UAHCurrency() : base(980, "UAH") { } + } +} diff --git a/src/HttpApi/Controllers/AddressesController.cs b/src/HttpApi/Controllers/AddressesController.cs index 825dca9..2d8c8c1 100644 --- a/src/HttpApi/Controllers/AddressesController.cs +++ b/src/HttpApi/Controllers/AddressesController.cs @@ -189,7 +189,8 @@ public class AddressesController : ControllerBase "Not enough privileges to perform an action", typeof(ProblemDetails))] [SwaggerResponse( - StatusCodes.Status404NotFound, "Object not found", typeof(AddressDto))] + StatusCodes.Status404NotFound, "Object not found", + typeof(ProblemDetails))] [SwaggerResponse( StatusCodes.Status500InternalServerError, "Internal server error", typeof(ProblemDetails))] diff --git a/src/HttpApi/Controllers/AircraftsController.cs b/src/HttpApi/Controllers/AircraftsController.cs index 1aaa592..2c040e8 100644 --- a/src/HttpApi/Controllers/AircraftsController.cs +++ b/src/HttpApi/Controllers/AircraftsController.cs @@ -177,7 +177,8 @@ public class AircraftsController : ControllerBase "Not enough privileges to perform an action", typeof(ProblemDetails))] [SwaggerResponse( - StatusCodes.Status404NotFound, "Object not found", typeof(AircraftDto))] + StatusCodes.Status404NotFound, "Object not found", + typeof(ProblemDetails))] [SwaggerResponse( StatusCodes.Status500InternalServerError, "Internal server error", typeof(ProblemDetails))] diff --git a/src/HttpApi/Controllers/BusesController.cs b/src/HttpApi/Controllers/BusesController.cs index 48eb35d..c5d55a5 100644 --- a/src/HttpApi/Controllers/BusesController.cs +++ b/src/HttpApi/Controllers/BusesController.cs @@ -177,7 +177,8 @@ public class BusesController : ControllerBase "Not enough privileges to perform an action", typeof(ProblemDetails))] [SwaggerResponse( - StatusCodes.Status404NotFound, "Object not found", typeof(BusDto))] + StatusCodes.Status404NotFound, "Object not found", + typeof(ProblemDetails))] [SwaggerResponse( StatusCodes.Status500InternalServerError, "Internal server error", typeof(ProblemDetails))] diff --git a/src/HttpApi/Controllers/CitiesController.cs b/src/HttpApi/Controllers/CitiesController.cs index 086a5ad..73bb49d 100644 --- a/src/HttpApi/Controllers/CitiesController.cs +++ b/src/HttpApi/Controllers/CitiesController.cs @@ -172,7 +172,8 @@ public class CitiesController : ControllerBase "Not enough privileges to perform an action", typeof(ProblemDetails))] [SwaggerResponse( - StatusCodes.Status404NotFound, "Object not found", typeof(CityDto))] + StatusCodes.Status404NotFound, "Object not found", + typeof(ProblemDetails))] [SwaggerResponse( StatusCodes.Status500InternalServerError, "Internal server error", typeof(ProblemDetails))] diff --git a/src/HttpApi/Controllers/CountriesController.cs b/src/HttpApi/Controllers/CountriesController.cs index 76dae58..056245e 100644 --- a/src/HttpApi/Controllers/CountriesController.cs +++ b/src/HttpApi/Controllers/CountriesController.cs @@ -160,7 +160,8 @@ public class CountriesController : ControllerBase "Not enough privileges to perform an action", typeof(ProblemDetails))] [SwaggerResponse( - StatusCodes.Status404NotFound, "Object not found", typeof(CountryDto))] + StatusCodes.Status404NotFound, "Object not found", + typeof(ProblemDetails))] [SwaggerResponse( StatusCodes.Status500InternalServerError, "Internal server error", typeof(ProblemDetails))] diff --git a/src/HttpApi/Controllers/RegionsController.cs b/src/HttpApi/Controllers/RegionsController.cs index 478ec03..92d1d4b 100644 --- a/src/HttpApi/Controllers/RegionsController.cs +++ b/src/HttpApi/Controllers/RegionsController.cs @@ -9,7 +9,6 @@ using cuqmbr.TravelGuide.Application.Regions.Queries.GetRegionsPage; using cuqmbr.TravelGuide.Application.Regions.Queries.GetRegion; using cuqmbr.TravelGuide.Application.Regions.Commands.UpdateRegion; using cuqmbr.TravelGuide.Application.Regions.Commands.DeleteRegion; -using cuqmbr.TravelGuide.Application.Regions.ViewModels; namespace cuqmbr.TravelGuide.HttpApi.Controllers; @@ -49,7 +48,8 @@ public class RegionsController : ControllerBase await Mediator.Send( new AddRegionCommand() { - Name = viewModel.Name + Name = viewModel.Name, + CountryGuid = viewModel.CountryUuid }, cancellationToken)); } @@ -171,7 +171,8 @@ public class RegionsController : ControllerBase "Not enough privileges to perform an action", typeof(ProblemDetails))] [SwaggerResponse( - StatusCodes.Status404NotFound, "Object not found", typeof(RegionDto))] + StatusCodes.Status404NotFound, "Object not found", + typeof(ProblemDetails))] [SwaggerResponse( StatusCodes.Status500InternalServerError, "Internal server error", typeof(ProblemDetails))] diff --git a/src/HttpApi/Controllers/RoutesController.cs b/src/HttpApi/Controllers/RoutesController.cs index 1735a76..7eb6850 100644 --- a/src/HttpApi/Controllers/RoutesController.cs +++ b/src/HttpApi/Controllers/RoutesController.cs @@ -182,7 +182,8 @@ public class RoutesController : ControllerBase "Not enough privileges to perform an action", typeof(ProblemDetails))] [SwaggerResponse( - StatusCodes.Status404NotFound, "Object not found", typeof(RouteDto))] + StatusCodes.Status404NotFound, "Object not found", + typeof(ProblemDetails))] [SwaggerResponse( StatusCodes.Status500InternalServerError, "Internal server error", typeof(ProblemDetails))] diff --git a/src/HttpApi/Controllers/TrainsController.cs b/src/HttpApi/Controllers/TrainsController.cs index 4d9baef..fac873a 100644 --- a/src/HttpApi/Controllers/TrainsController.cs +++ b/src/HttpApi/Controllers/TrainsController.cs @@ -177,7 +177,8 @@ public class TrainsController : ControllerBase "Not enough privileges to perform an action", typeof(ProblemDetails))] [SwaggerResponse( - StatusCodes.Status404NotFound, "Object not found", typeof(TrainDto))] + StatusCodes.Status404NotFound, "Object not found", + typeof(ProblemDetails))] [SwaggerResponse( StatusCodes.Status500InternalServerError, "Internal server error", typeof(ProblemDetails))] diff --git a/src/HttpApi/Controllers/VehicleEnrollmentsController.cs b/src/HttpApi/Controllers/VehicleEnrollmentsController.cs new file mode 100644 index 0000000..b5a703c --- /dev/null +++ b/src/HttpApi/Controllers/VehicleEnrollmentsController.cs @@ -0,0 +1,260 @@ +using Microsoft.AspNetCore.Mvc; +using Swashbuckle.AspNetCore.Annotations; +using cuqmbr.TravelGuide.Domain.Enums; +using cuqmbr.TravelGuide.Application.Common.Models; +using cuqmbr.TravelGuide.Application.Common.ViewModels; +using cuqmbr.TravelGuide.Application.VehicleEnrollments; +using cuqmbr.TravelGuide.Application.VehicleEnrollments + .Commands.AddVehicleEnrollment; +using cuqmbr.TravelGuide.Application.VehicleEnrollments + .Queries.GetVehicleEnrollmentsPage; +using cuqmbr.TravelGuide.Application.VehicleEnrollments + .Queries.GetVehicleEnrollment; +using cuqmbr.TravelGuide.Application.VehicleEnrollments + .Commands.UpdateVehicleEnrollment; +using cuqmbr.TravelGuide.Application.VehicleEnrollments + .Commands.DeleteVehicleEnrollment; +using cuqmbr.TravelGuide.Application.VehicleEnrollments.Models; +using cuqmbr.TravelGuide.Application.VehicleEnrollments.ViewModels; + +namespace cuqmbr.TravelGuide.HttpApi.Controllers; + +[Route("vehicleEnrollments")] +public class VehicleEnrollmentsController : ControllerBase +{ + [HttpPost] + [SwaggerOperation("Add a vehicle enrollment")] + [SwaggerResponse( + StatusCodes.Status201Created, "Object successfuly created", + typeof(VehicleEnrollmentDto))] + [SwaggerResponse( + StatusCodes.Status400BadRequest, "Input data validation error", + typeof(HttpValidationProblemDetails))] + [SwaggerResponse( + StatusCodes.Status400BadRequest, + "Enrollment travel time overlapping with " + + "other enrollment time of the vehicle", + typeof(HttpValidationProblemDetails))] + [SwaggerResponse( + StatusCodes.Status401Unauthorized, "Unauthorized to perform an action", + typeof(ProblemDetails))] + [SwaggerResponse( + StatusCodes.Status403Forbidden, + "Not enough privileges to perform an action", + typeof(ProblemDetails))] + [SwaggerResponse( + StatusCodes.Status404NotFound, "Given route not found", + typeof(ProblemDetails))] + [SwaggerResponse( + StatusCodes.Status404NotFound, "Given vehicle not found", + typeof(ProblemDetails))] + [SwaggerResponse( + StatusCodes.Status404NotFound, "At least one route address not found", + typeof(ProblemDetails))] + [SwaggerResponse( + StatusCodes.Status500InternalServerError, "Internal server error", + typeof(ProblemDetails))] + public async Task> Add( + [FromBody] AddVehicleEnrollmentViewModel viewModel, + CancellationToken cancellationToken) + { + return StatusCode( + StatusCodes.Status201Created, + await Mediator.Send( + new AddVehicleEnrollmentCommand() + { + DepartureTime = viewModel.DepartureTime, + Currency = Currency.FromName(viewModel.Currency), + VehicleGuid = viewModel.VehicleUuid, + RouteGuid = viewModel.RouteUuid, + RouteAddressDetails = viewModel.RouteAddressDetails.Select( + rad => new RouteAddressDetailModel() + { + TimeToNextAddress = rad.TimeToNextAddress, + CostToNextAddress = rad.CostToNextAddress, + CurrentAddressStopTime = rad.CurrentAddressStopTime, + RouteAddressGuid = rad.RouteAddressUuid + }) + .ToArray() + }, + cancellationToken)); + } + + [HttpGet] + [SwaggerOperation("Get a list of all vehicle enrollments")] + [SwaggerResponse( + StatusCodes.Status200OK, "Request successful", + typeof(PaginatedList))] + [SwaggerResponse( + StatusCodes.Status400BadRequest, "Input data validation error", + typeof(HttpValidationProblemDetails))] + [SwaggerResponse( + StatusCodes.Status401Unauthorized, "Unauthorized to perform an action", + typeof(ProblemDetails))] + [SwaggerResponse( + StatusCodes.Status403Forbidden, + "Not enough privileges to perform an action", + typeof(ProblemDetails))] + [SwaggerResponse( + StatusCodes.Status500InternalServerError, "Internal server error", + typeof(ProblemDetails))] + public async Task> GetPage( + [FromQuery] PageQuery pageQuery, [FromQuery] SearchQuery searchQuery, + [FromQuery] SortQuery sortQuery, + [FromQuery] GetVehicleEnrollmentsPageFilterViewModel filterQuery, + CancellationToken cancellationToken) + { + return await Mediator.Send( + new GetVehicleEnrollmentsPageQuery() + { + PageNumber = pageQuery.PageNumber, + PageSize = pageQuery.PageSize, + // Search = searchQuery.Search, + Sort = sortQuery.Sort, + RouteGuid = filterQuery.RouteGuid, + VehicleGuid = filterQuery.VehicleGuid, + NumberOfAddressesGreaterThanOrEqual = + filterQuery.NumberOfAddressesGreaterThanOrEqual, + NumberOfAddressesLessThanOrEqual = + filterQuery.NumberOfAddressesLessThanOrEqual, + DepartureTimeGreaterThanOrEqual = + filterQuery.DepartureTimeGreaterThanOrEqual, + DepartureTimeLessThanOrEqual = + filterQuery.DepartureTimeLessThanOrEqual, + ArrivalTimeGreaterThanOrEqual = + filterQuery.ArrivalTimeGreaterThanOrEqual, + ArrivalTimeLessThanOrEqual = + filterQuery.ArrivalTimeLessThanOrEqual, + TravelTimeGreaterThanOrEqual = + filterQuery.TravelTimeGreaterThanOrEqual, + TravelTimeLessThanOrEqual = + filterQuery.TravelTimeLessThanOrEqual, + TimeMovingGreaterThanOrEqual = + filterQuery.TimeMovingGreaterThanOrEqual, + TimeMovingLessThanOrEqual = + filterQuery.TimeMovingLessThanOrEqual, + TimeInStopsGreaterThanOrEqual = + filterQuery.TimeInStopsGreaterThanOrEqual, + TimeInStopsLessThanOrEqual = + filterQuery.TimeInStopsLessThanOrEqual, + CostGreaterThanOrEqual = + filterQuery.CostGreaterThanOrEqual, + CostLessThanOrEqual = + filterQuery.CostLessThanOrEqual, + Currency = Currency.FromName(filterQuery.Currency) + }, + cancellationToken); + } + + [HttpGet("{uuid:guid}")] + [SwaggerOperation("Get a vehicle enrollment by uuid")] + [SwaggerResponse( + StatusCodes.Status200OK, "Request successful", + typeof(VehicleEnrollmentDto))] + [SwaggerResponse( + StatusCodes.Status400BadRequest, "Input data validation error", + typeof(HttpValidationProblemDetails))] + [SwaggerResponse( + StatusCodes.Status401Unauthorized, "Unauthorized to perform an action", + typeof(ProblemDetails))] + [SwaggerResponse( + StatusCodes.Status403Forbidden, + "Not enough privileges to perform an action", + typeof(ProblemDetails))] + [SwaggerResponse( + StatusCodes.Status404NotFound, "Object not found", + typeof(VehicleEnrollmentDto))] + [SwaggerResponse( + StatusCodes.Status500InternalServerError, "Internal server error", + typeof(ProblemDetails))] + public async Task Get( + [FromRoute] Guid uuid, + CancellationToken cancellationToken) + { + return await Mediator.Send( + new GetVehicleEnrollmentQuery() { Guid = uuid }, + cancellationToken); + } + + [HttpPut("{uuid:guid}")] + [SwaggerOperation("Update a vehicle enrollment")] + [SwaggerResponse( + StatusCodes.Status200OK, "Request successful", + typeof(VehicleEnrollmentDto))] + [SwaggerResponse( + StatusCodes.Status400BadRequest, "Input data validation error", + typeof(HttpValidationProblemDetails))] + [SwaggerResponse( + StatusCodes.Status400BadRequest, + "Enrollment travel time overlapping with " + + "other enrollment time of the vehicle", + typeof(HttpValidationProblemDetails))] + [SwaggerResponse( + StatusCodes.Status401Unauthorized, "Unauthorized to perform an action", + typeof(ProblemDetails))] + [SwaggerResponse( + StatusCodes.Status403Forbidden, + "Not enough privileges to perform an action", + typeof(ProblemDetails))] + [SwaggerResponse( + StatusCodes.Status404NotFound, "Object not found", + typeof(ProblemDetails))] + [SwaggerResponse( + StatusCodes.Status404NotFound, "At least one route address not found", + typeof(ProblemDetails))] + [SwaggerResponse( + StatusCodes.Status500InternalServerError, "Internal server error", + typeof(ProblemDetails))] + public async Task Update( + [FromRoute] Guid uuid, + [FromBody] UpdateVehicleEnrollmentViewModel viewModel, + CancellationToken cancellationToken) + { + return await Mediator.Send( + new UpdateVehicleEnrollmentCommand() + { + Guid = uuid, + DepartureTime = viewModel.DepartureTime, + Currency = Currency.FromName(viewModel.Currency), + RouteAddressDetails = viewModel.RouteAddressDetails.Select( + rad => new RouteAddressDetailModel() + { + TimeToNextAddress = rad.TimeToNextAddress, + CostToNextAddress = rad.CostToNextAddress, + CurrentAddressStopTime = rad.CurrentAddressStopTime, + RouteAddressGuid = rad.RouteAddressUuid + }) + .ToArray() + }, + cancellationToken); + } + + [HttpDelete("{uuid:guid}")] + [SwaggerOperation("Delete a vehicle enrollment")] + [SwaggerResponse(StatusCodes.Status204NoContent, "Request successful")] + [SwaggerResponse( + StatusCodes.Status400BadRequest, "Input data validation error", + typeof(HttpValidationProblemDetails))] + [SwaggerResponse( + StatusCodes.Status401Unauthorized, "Unauthorized to perform an action", + typeof(ProblemDetails))] + [SwaggerResponse( + StatusCodes.Status403Forbidden, + "Not enough privileges to perform an action", + typeof(ProblemDetails))] + [SwaggerResponse( + StatusCodes.Status404NotFound, "Object not found", + typeof(ProblemDetails))] + [SwaggerResponse( + StatusCodes.Status500InternalServerError, "Internal server error", + typeof(ProblemDetails))] + public async Task Delete( + [FromRoute] Guid uuid, + CancellationToken cancellationToken) + { + await Mediator.Send( + new DeleteVehicleEnrollmentCommand() { Guid = uuid }, + cancellationToken); + return StatusCode(StatusCodes.Status204NoContent); + } +} diff --git a/src/Persistence/InMemory/InMemoryDbContext.cs b/src/Persistence/InMemory/InMemoryDbContext.cs index 48d0c61..9b2f919 100644 --- a/src/Persistence/InMemory/InMemoryDbContext.cs +++ b/src/Persistence/InMemory/InMemoryDbContext.cs @@ -27,5 +27,10 @@ public class InMemoryDbContext : DbContext .Properties() .HaveColumnType("vehicle_type") .HaveConversion(); + + builder + .Properties() + .HaveColumnType("currency") + .HaveConversion(); } } diff --git a/src/Persistence/InMemory/InMemoryUnitOfWork.cs b/src/Persistence/InMemory/InMemoryUnitOfWork.cs index e7faa4b..cde7f75 100644 --- a/src/Persistence/InMemory/InMemoryUnitOfWork.cs +++ b/src/Persistence/InMemory/InMemoryUnitOfWork.cs @@ -22,6 +22,10 @@ public sealed class InMemoryUnitOfWork : UnitOfWork BusRepository = new InMemoryBusRepository(_dbContext); AircraftRepository = new InMemoryAircraftRepository(_dbContext); TrainRepository = new InMemoryTrainRepository(_dbContext); + VehicleEnrollmentRepository = + new InMemoryVehicleEnrollmentRepository(_dbContext); + RouteAddressRepository = + new InMemoryRouteAddressRepository(_dbContext); } public CountryRepository CountryRepository { get; init; } @@ -42,6 +46,10 @@ public sealed class InMemoryUnitOfWork : UnitOfWork public TrainRepository TrainRepository { get; init; } + public VehicleEnrollmentRepository VehicleEnrollmentRepository { get; init; } + + public RouteAddressRepository RouteAddressRepository { get; init; } + public int Save() { return _dbContext.SaveChanges(); diff --git a/src/Persistence/InMemory/Repositories/InMemoryRouteAddressRepository.cs b/src/Persistence/InMemory/Repositories/InMemoryRouteAddressRepository.cs new file mode 100644 index 0000000..ce24624 --- /dev/null +++ b/src/Persistence/InMemory/Repositories/InMemoryRouteAddressRepository.cs @@ -0,0 +1,11 @@ +using cuqmbr.TravelGuide.Application.Common.Interfaces.Persistence.Repositories; +using cuqmbr.TravelGuide.Domain.Entities; + +namespace cuqmbr.TravelGuide.Persistence.InMemory.Repositories; + +public sealed class InMemoryRouteAddressRepository : + InMemoryBaseRepository, RouteAddressRepository +{ + public InMemoryRouteAddressRepository(InMemoryDbContext dbContext) + : base(dbContext) { } +} diff --git a/src/Persistence/InMemory/Repositories/InMemoryVehicleEnrollmentRepository.cs b/src/Persistence/InMemory/Repositories/InMemoryVehicleEnrollmentRepository.cs new file mode 100644 index 0000000..2bf6313 --- /dev/null +++ b/src/Persistence/InMemory/Repositories/InMemoryVehicleEnrollmentRepository.cs @@ -0,0 +1,11 @@ +using cuqmbr.TravelGuide.Application.Common.Interfaces.Persistence.Repositories; +using cuqmbr.TravelGuide.Domain.Entities; + +namespace cuqmbr.TravelGuide.Persistence.InMemory.Repositories; + +public sealed class InMemoryVehicleEnrollmentRepository : + InMemoryBaseRepository, VehicleEnrollmentRepository +{ + public InMemoryVehicleEnrollmentRepository(InMemoryDbContext dbContext) + : base(dbContext) { } +} diff --git a/src/Persistence/Json/JsonDbContext.cs b/src/Persistence/Json/JsonDbContext.cs deleted file mode 100644 index e69de29..0000000 diff --git a/src/Persistence/PostgreSql/Configurations/RouteAddressDetailConfiguration.cs b/src/Persistence/PostgreSql/Configurations/RouteAddressDetailConfiguration.cs new file mode 100644 index 0000000..56c95cb --- /dev/null +++ b/src/Persistence/PostgreSql/Configurations/RouteAddressDetailConfiguration.cs @@ -0,0 +1,85 @@ +using cuqmbr.TravelGuide.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace cuqmbr.TravelGuide.Persistence.PostgreSql.Configurations; + +public class RouteAddressDetailConfiguration : + BaseConfiguration +{ + public override void Configure(EntityTypeBuilder builder) + { + builder + .ToTable("route_address_details"); + + base.Configure(builder); + + + builder + .Property(rad => rad.TimeToNextAddress) + .HasColumnName("time_to_next_address") + .HasColumnType("interval") + .IsRequired(true); + + builder + .Property(rad => rad.CostToNextAddress) + .HasColumnName("cost_to_next_address") + .HasColumnType("numeric(24,12)") + .IsRequired(true); + + builder + .Property(rad => rad.CurrentAddressStopTime) + .HasColumnName("current_address_stop_time") + .HasColumnType("interval") + .IsRequired(true); + + + builder + .Property(rad => rad.VehicleEnrollmentId) + .HasColumnName("vehicle_enrollment_id") + .HasColumnType("bigint") + .IsRequired(true); + + + builder + .HasOne(rad => rad.VehicleEnrollment) + .WithMany(ve => ve.RouteAddressDetails) + .HasForeignKey(rad => rad.VehicleEnrollmentId) + .HasConstraintName( + "fk_" + + $"{builder.Metadata.GetTableName()}_" + + $"{builder.Property(rad => rad.VehicleEnrollmentId).Metadata.GetColumnName()}") + .OnDelete(DeleteBehavior.Cascade); + + builder + .HasIndex(rad => rad.VehicleEnrollmentId) + .HasDatabaseName( + "ix_" + + $"{builder.Metadata.GetTableName()}_" + + $"{builder.Property(rad => rad.VehicleEnrollmentId).Metadata.GetColumnName()}"); + + + builder + .Property(rad => rad.RouteAddressId) + .HasColumnName("route_address_id") + .HasColumnType("bigint") + .IsRequired(true); + + builder + .HasOne(rad => rad.RouteAddress) + .WithMany(ra => ra.Details) + .HasForeignKey(rad => rad.RouteAddressId) + .HasConstraintName( + "fk_" + + $"{builder.Metadata.GetTableName()}_" + + $"{builder.Property(rad => rad.RouteAddressId).Metadata.GetColumnName()}") + .OnDelete(DeleteBehavior.Cascade); + + builder + .HasIndex(rad => rad.RouteAddressId) + .HasDatabaseName( + "ix_" + + $"{builder.Metadata.GetTableName()}_" + + $"{builder.Property(rad => rad.RouteAddressId).Metadata.GetColumnName()}"); + } +} diff --git a/src/Persistence/PostgreSql/Configurations/VehicleEnrollmentConfiguration.cs b/src/Persistence/PostgreSql/Configurations/VehicleEnrollmentConfiguration.cs new file mode 100644 index 0000000..3c5ce9f --- /dev/null +++ b/src/Persistence/PostgreSql/Configurations/VehicleEnrollmentConfiguration.cs @@ -0,0 +1,88 @@ +using cuqmbr.TravelGuide.Domain.Entities; +using cuqmbr.TravelGuide.Domain.Enums; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace cuqmbr.TravelGuide.Persistence.PostgreSql.Configurations; + +public class VehicleEnrollmentConfiguration : BaseConfiguration +{ + public override void Configure(EntityTypeBuilder builder) + { + builder + .Property(ve => ve.Currency) + .HasColumnName("currency") + .HasColumnType("varchar(8)") + .IsRequired(true); + + builder + .ToTable( + "vehicle_enrollments", + ve => ve.HasCheckConstraint( + "ck_" + + $"{builder.Metadata.GetTableName()}_" + + $"{builder.Property(ve => ve.Currency) + .Metadata.GetColumnName()}", + $"{builder.Property(ve => ve.Currency) + .Metadata.GetColumnName()} IN ('{String + .Join("', '", Currency.Enumerations + .Values.Select(v => v.Name))}')")); + + base.Configure(builder); + + + builder + .Property(ve => ve.DepartureTime) + .HasColumnName("departure_time") + .HasColumnType("timestamptz") + .IsRequired(true); + + + builder + .Property(ve => ve.VehicleId) + .HasColumnName("vehicle_id") + .HasColumnType("bigint") + .IsRequired(true); + + builder + .HasOne(ve => ve.Vehicle) + .WithMany(v => v.Enrollments) + .HasForeignKey(ve => ve.VehicleId) + .HasConstraintName( + "fk_" + + $"{builder.Metadata.GetTableName()}_" + + $"{builder.Property(ve => ve.VehicleId).Metadata.GetColumnName()}") + .OnDelete(DeleteBehavior.Cascade); + + builder + .HasIndex(ve => ve.VehicleId) + .HasDatabaseName( + "ix_" + + $"{builder.Metadata.GetTableName()}_" + + $"{builder.Property(ve => ve.VehicleId).Metadata.GetColumnName()}"); + + + builder + .Property(ve => ve.RouteId) + .HasColumnName("route_id") + .HasColumnType("bigint") + .IsRequired(true); + + builder + .HasOne(ve => ve.Route) + .WithMany(r => r.VehicleEnrollments) + .HasForeignKey(ve => ve.RouteId) + .HasConstraintName( + "fk_" + + $"{builder.Metadata.GetTableName()}_" + + $"{builder.Property(ve => ve.RouteId).Metadata.GetColumnName()}") + .OnDelete(DeleteBehavior.Cascade); + + builder + .HasIndex(ve => ve.RouteId) + .HasDatabaseName( + "ix_" + + $"{builder.Metadata.GetTableName()}_" + + $"{builder.Property(ve => ve.RouteId).Metadata.GetColumnName()}"); + } +} diff --git a/src/Persistence/PostgreSql/Migrations/20250504143929_Add_Vehicle_Enrollment_and_Route_Address_Detail.Designer.cs b/src/Persistence/PostgreSql/Migrations/20250504143929_Add_Vehicle_Enrollment_and_Route_Address_Detail.Designer.cs new file mode 100644 index 0000000..8dcb5cd --- /dev/null +++ b/src/Persistence/PostgreSql/Migrations/20250504143929_Add_Vehicle_Enrollment_and_Route_Address_Detail.Designer.cs @@ -0,0 +1,637 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using cuqmbr.TravelGuide.Persistence.PostgreSql; + +#nullable disable + +namespace Persistence.PostgreSql.Migrations +{ + [DbContext(typeof(PostgreSqlDbContext))] + [Migration("20250504143929_Add_Vehicle_Enrollment_and_Route_Address_Detail")] + partial class Add_Vehicle_Enrollment_and_Route_Address_Detail + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("application") + .HasAnnotation("ProductVersion", "9.0.4") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.HasSequence("addresses_id_sequence"); + + modelBuilder.HasSequence("cities_id_sequence"); + + modelBuilder.HasSequence("countries_id_sequence"); + + modelBuilder.HasSequence("regions_id_sequence"); + + modelBuilder.HasSequence("route_address_details_id_sequence"); + + modelBuilder.HasSequence("route_addresses_id_sequence"); + + modelBuilder.HasSequence("routes_id_sequence"); + + modelBuilder.HasSequence("vehicle_enrollments_id_sequence"); + + modelBuilder.HasSequence("vehicles_id_sequence"); + + modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.Address", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id") + .HasDefaultValueSql("nextval('application.addresses_id_sequence')"); + + NpgsqlPropertyBuilderExtensions.UseSequence(b.Property("Id"), "addresses_id_sequence"); + + b.Property("CityId") + .HasColumnType("bigint") + .HasColumnName("city_id"); + + b.Property("Guid") + .HasColumnType("uuid") + .HasColumnName("uuid"); + + b.Property("Latitude") + .HasColumnType("double precision"); + + b.Property("Longitude") + .HasColumnType("double precision"); + + b.Property("Name") + .IsRequired() + .HasColumnType("varchar(128)") + .HasColumnName("name"); + + b.Property("VehicleType") + .IsRequired() + .HasColumnType("varchar(16)") + .HasColumnName("vehicle_type"); + + b.HasKey("Id") + .HasName("pk_addresses"); + + b.HasAlternateKey("Guid") + .HasName("altk_addresses_uuid"); + + b.HasIndex("CityId") + .HasDatabaseName("ix_addresses_city_id"); + + b.ToTable("addresses", "application", t => + { + t.HasCheckConstraint("ck_addresses_vehicle_type", "vehicle_type IN ('bus', 'train', 'aircraft')"); + }); + }); + + modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.City", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id") + .HasDefaultValueSql("nextval('application.cities_id_sequence')"); + + NpgsqlPropertyBuilderExtensions.UseSequence(b.Property("Id"), "cities_id_sequence"); + + b.Property("Guid") + .HasColumnType("uuid") + .HasColumnName("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("varchar(64)") + .HasColumnName("name"); + + b.Property("RegionId") + .HasColumnType("bigint") + .HasColumnName("region_id"); + + b.HasKey("Id") + .HasName("pk_cities"); + + b.HasAlternateKey("Guid") + .HasName("altk_cities_uuid"); + + b.HasIndex("RegionId") + .HasDatabaseName("ix_cities_region_id"); + + b.ToTable("cities", "application"); + }); + + modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.Country", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id") + .HasDefaultValueSql("nextval('application.countries_id_sequence')"); + + NpgsqlPropertyBuilderExtensions.UseSequence(b.Property("Id"), "countries_id_sequence"); + + b.Property("Guid") + .HasColumnType("uuid") + .HasColumnName("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("varchar(64)") + .HasColumnName("name"); + + b.HasKey("Id") + .HasName("pk_countries"); + + b.HasAlternateKey("Guid") + .HasName("altk_countries_uuid"); + + b.ToTable("countries", "application"); + }); + + modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.Region", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id") + .HasDefaultValueSql("nextval('application.regions_id_sequence')"); + + NpgsqlPropertyBuilderExtensions.UseSequence(b.Property("Id"), "regions_id_sequence"); + + b.Property("CountryId") + .HasColumnType("bigint") + .HasColumnName("country_id"); + + b.Property("Guid") + .HasColumnType("uuid") + .HasColumnName("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("varchar(64)") + .HasColumnName("name"); + + b.HasKey("Id") + .HasName("pk_regions"); + + b.HasAlternateKey("Guid") + .HasName("altk_regions_uuid"); + + b.HasIndex("CountryId") + .HasDatabaseName("ix_regions_country_id"); + + b.ToTable("regions", "application"); + }); + + modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.Route", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id") + .HasDefaultValueSql("nextval('application.routes_id_sequence')"); + + NpgsqlPropertyBuilderExtensions.UseSequence(b.Property("Id"), "routes_id_sequence"); + + b.Property("Guid") + .HasColumnType("uuid") + .HasColumnName("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("varchar(64)") + .HasColumnName("name"); + + b.Property("VehicleType") + .IsRequired() + .HasColumnType("varchar(16)") + .HasColumnName("vehicle_type"); + + b.HasKey("Id") + .HasName("pk_routes"); + + b.HasAlternateKey("Guid") + .HasName("altk_routes_uuid"); + + b.ToTable("routes", "application", t => + { + t.HasCheckConstraint("ck_routes_vehicle_type", "vehicle_type IN ('bus', 'train', 'aircraft')"); + }); + }); + + modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.RouteAddress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id") + .HasDefaultValueSql("nextval('application.route_addresses_id_sequence')"); + + NpgsqlPropertyBuilderExtensions.UseSequence(b.Property("Id"), "route_addresses_id_sequence"); + + b.Property("AddressId") + .HasColumnType("bigint") + .HasColumnName("address_id"); + + b.Property("Guid") + .HasColumnType("uuid") + .HasColumnName("uuid"); + + b.Property("Order") + .HasColumnType("smallint") + .HasColumnName("order"); + + b.Property("RouteId") + .HasColumnType("bigint") + .HasColumnName("route_id"); + + b.HasKey("Id") + .HasName("pk_route_addresses"); + + b.HasAlternateKey("Guid") + .HasName("altk_route_addresses_uuid"); + + b.HasAlternateKey("AddressId", "RouteId", "Order") + .HasName("altk_route_addresses_address_id_route_id_order"); + + b.HasIndex("AddressId") + .HasDatabaseName("ix_route_addresses_address_id"); + + b.HasIndex("RouteId") + .HasDatabaseName("ix_route_addresses_route_id"); + + b.ToTable("route_addresses", "application"); + }); + + modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.RouteAddressDetail", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id") + .HasDefaultValueSql("nextval('application.route_address_details_id_sequence')"); + + NpgsqlPropertyBuilderExtensions.UseSequence(b.Property("Id"), "route_address_details_id_sequence"); + + b.Property("CostToNextAddress") + .HasColumnType("numeric(24,12)") + .HasColumnName("cost_to_next_address"); + + b.Property("CurrentAddressStopTime") + .HasColumnType("interval") + .HasColumnName("current_address_stop_time"); + + b.Property("Guid") + .HasColumnType("uuid") + .HasColumnName("uuid"); + + b.Property("RouteAddressId") + .HasColumnType("bigint") + .HasColumnName("route_address_id"); + + b.Property("TimeToNextAddress") + .HasColumnType("interval") + .HasColumnName("time_to_next_address"); + + b.Property("VehicleEnrollmentId") + .HasColumnType("bigint") + .HasColumnName("vehicle_enrollment_id"); + + b.HasKey("Id") + .HasName("pk_route_address_details"); + + b.HasAlternateKey("Guid") + .HasName("altk_route_address_details_uuid"); + + b.HasIndex("RouteAddressId") + .HasDatabaseName("ix_route_address_details_route_address_id"); + + b.HasIndex("VehicleEnrollmentId") + .HasDatabaseName("ix_route_address_details_vehicle_enrollment_id"); + + b.ToTable("route_address_details", "application"); + }); + + modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.Vehicle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id") + .HasDefaultValueSql("nextval('application.vehicles_id_sequence')"); + + NpgsqlPropertyBuilderExtensions.UseSequence(b.Property("Id"), "vehicles_id_sequence"); + + b.Property("Guid") + .HasColumnType("uuid") + .HasColumnName("uuid"); + + b.Property("VehicleType") + .IsRequired() + .HasColumnType("varchar(16)") + .HasColumnName("vehicle_type"); + + b.HasKey("Id") + .HasName("pk_vehicles"); + + b.HasAlternateKey("Guid") + .HasName("altk_vehicles_uuid"); + + b.ToTable("vehicles", "application", t => + { + t.HasCheckConstraint("ck_vehicles_vehicle_type", "vehicle_type IN ('bus', 'train', 'aircraft')"); + }); + + b.HasDiscriminator("VehicleType"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.VehicleEnrollment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id") + .HasDefaultValueSql("nextval('application.vehicle_enrollments_id_sequence')"); + + NpgsqlPropertyBuilderExtensions.UseSequence(b.Property("Id"), "vehicle_enrollments_id_sequence"); + + b.Property("Currency") + .IsRequired() + .HasColumnType("varchar(8)") + .HasColumnName("currency"); + + b.Property("DepartureTime") + .HasColumnType("timestamptz") + .HasColumnName("departure_time"); + + b.Property("Guid") + .HasColumnType("uuid") + .HasColumnName("uuid"); + + b.Property("RouteId") + .HasColumnType("bigint") + .HasColumnName("route_id"); + + b.Property("VehicleId") + .HasColumnType("bigint") + .HasColumnName("vehicle_id"); + + b.HasKey("Id") + .HasName("pk_vehicle_enrollments"); + + b.HasAlternateKey("Guid") + .HasName("altk_vehicle_enrollments_uuid"); + + b.HasIndex("RouteId") + .HasDatabaseName("ix_vehicle_enrollments_route_id"); + + b.HasIndex("VehicleId") + .HasDatabaseName("ix_vehicle_enrollments_vehicle_id"); + + b.ToTable("vehicle_enrollments", "application", t => + { + t.HasCheckConstraint("ck_vehicle_enrollments_currency", "currency IN ('USD', 'EUR', 'UAH')"); + }); + }); + + modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.Aircraft", b => + { + b.HasBaseType("cuqmbr.TravelGuide.Domain.Entities.Vehicle"); + + b.Property("Capacity") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("smallint") + .HasColumnName("capacity"); + + b.Property("Model") + .IsRequired() + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("varchar(64)") + .HasColumnName("model"); + + b.Property("Number") + .IsRequired() + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("varchar(32)") + .HasColumnName("number"); + + b.ToTable(t => + { + t.HasCheckConstraint("ck_vehicles_vehicle_type", "vehicle_type IN ('bus', 'train', 'aircraft')"); + }); + + b.HasDiscriminator().HasValue("aircraft"); + }); + + modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.Bus", b => + { + b.HasBaseType("cuqmbr.TravelGuide.Domain.Entities.Vehicle"); + + b.Property("Capacity") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("smallint") + .HasColumnName("capacity"); + + b.Property("Model") + .IsRequired() + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("varchar(64)") + .HasColumnName("model"); + + b.Property("Number") + .IsRequired() + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("varchar(32)") + .HasColumnName("number"); + + b.ToTable(t => + { + t.HasCheckConstraint("ck_vehicles_vehicle_type", "vehicle_type IN ('bus', 'train', 'aircraft')"); + }); + + b.HasDiscriminator().HasValue("bus"); + }); + + modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.Train", b => + { + b.HasBaseType("cuqmbr.TravelGuide.Domain.Entities.Vehicle"); + + b.Property("Capacity") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("smallint") + .HasColumnName("capacity"); + + b.Property("Model") + .IsRequired() + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("varchar(64)") + .HasColumnName("model"); + + b.Property("Number") + .IsRequired() + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("varchar(32)") + .HasColumnName("number"); + + b.ToTable(t => + { + t.HasCheckConstraint("ck_vehicles_vehicle_type", "vehicle_type IN ('bus', 'train', 'aircraft')"); + }); + + b.HasDiscriminator().HasValue("train"); + }); + + modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.Address", b => + { + b.HasOne("cuqmbr.TravelGuide.Domain.Entities.City", "City") + .WithMany("Addresses") + .HasForeignKey("CityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_addresses_city_id"); + + b.Navigation("City"); + }); + + modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.City", b => + { + b.HasOne("cuqmbr.TravelGuide.Domain.Entities.Region", "Region") + .WithMany("Cities") + .HasForeignKey("RegionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_cities_region_id"); + + b.Navigation("Region"); + }); + + modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.Region", b => + { + b.HasOne("cuqmbr.TravelGuide.Domain.Entities.Country", "Country") + .WithMany("Regions") + .HasForeignKey("CountryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_regions_country_id"); + + b.Navigation("Country"); + }); + + modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.RouteAddress", b => + { + b.HasOne("cuqmbr.TravelGuide.Domain.Entities.Address", "Address") + .WithMany("AddressRoutes") + .HasForeignKey("AddressId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_route_addresses_address_id"); + + b.HasOne("cuqmbr.TravelGuide.Domain.Entities.Route", "Route") + .WithMany("RouteAddresses") + .HasForeignKey("RouteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_route_addresses_route_id"); + + b.Navigation("Address"); + + b.Navigation("Route"); + }); + + modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.RouteAddressDetail", b => + { + b.HasOne("cuqmbr.TravelGuide.Domain.Entities.RouteAddress", "RouteAddress") + .WithMany("Details") + .HasForeignKey("RouteAddressId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_route_address_details_route_address_id"); + + b.HasOne("cuqmbr.TravelGuide.Domain.Entities.VehicleEnrollment", "VehicleEnrollment") + .WithMany("RouteAddressDetails") + .HasForeignKey("VehicleEnrollmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_route_address_details_vehicle_enrollment_id"); + + b.Navigation("RouteAddress"); + + b.Navigation("VehicleEnrollment"); + }); + + modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.VehicleEnrollment", b => + { + b.HasOne("cuqmbr.TravelGuide.Domain.Entities.Route", "Route") + .WithMany("VehicleEnrollments") + .HasForeignKey("RouteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_vehicle_enrollments_route_id"); + + b.HasOne("cuqmbr.TravelGuide.Domain.Entities.Vehicle", "Vehicle") + .WithMany("Enrollments") + .HasForeignKey("VehicleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_vehicle_enrollments_vehicle_id"); + + b.Navigation("Route"); + + b.Navigation("Vehicle"); + }); + + modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.Address", b => + { + b.Navigation("AddressRoutes"); + }); + + modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.City", b => + { + b.Navigation("Addresses"); + }); + + modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.Country", b => + { + b.Navigation("Regions"); + }); + + modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.Region", b => + { + b.Navigation("Cities"); + }); + + modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.Route", b => + { + b.Navigation("RouteAddresses"); + + b.Navigation("VehicleEnrollments"); + }); + + modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.RouteAddress", b => + { + b.Navigation("Details"); + }); + + modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.Vehicle", b => + { + b.Navigation("Enrollments"); + }); + + modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.VehicleEnrollment", b => + { + b.Navigation("RouteAddressDetails"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Persistence/PostgreSql/Migrations/20250504143929_Add_Vehicle_Enrollment_and_Route_Address_Detail.cs b/src/Persistence/PostgreSql/Migrations/20250504143929_Add_Vehicle_Enrollment_and_Route_Address_Detail.cs new file mode 100644 index 0000000..2ee55fb --- /dev/null +++ b/src/Persistence/PostgreSql/Migrations/20250504143929_Add_Vehicle_Enrollment_and_Route_Address_Detail.cs @@ -0,0 +1,133 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Persistence.PostgreSql.Migrations +{ + /// + public partial class Add_Vehicle_Enrollment_and_Route_Address_Detail : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateSequence( + name: "route_address_details_id_sequence", + schema: "application"); + + migrationBuilder.CreateSequence( + name: "vehicle_enrollments_id_sequence", + schema: "application"); + + migrationBuilder.CreateTable( + name: "vehicle_enrollments", + schema: "application", + columns: table => new + { + id = table.Column(type: "bigint", nullable: false, defaultValueSql: "nextval('application.vehicle_enrollments_id_sequence')"), + departure_time = table.Column(type: "timestamptz", nullable: false), + currency = table.Column(type: "varchar(8)", nullable: false), + vehicle_id = table.Column(type: "bigint", nullable: false), + route_id = table.Column(type: "bigint", nullable: false), + uuid = table.Column(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_vehicle_enrollments", x => x.id); + table.UniqueConstraint("altk_vehicle_enrollments_uuid", x => x.uuid); + table.CheckConstraint("ck_vehicle_enrollments_currency", "currency IN ('USD', 'EUR', 'UAH')"); + table.ForeignKey( + name: "fk_vehicle_enrollments_route_id", + column: x => x.route_id, + principalSchema: "application", + principalTable: "routes", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "fk_vehicle_enrollments_vehicle_id", + column: x => x.vehicle_id, + principalSchema: "application", + principalTable: "vehicles", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "route_address_details", + schema: "application", + columns: table => new + { + id = table.Column(type: "bigint", nullable: false, defaultValueSql: "nextval('application.route_address_details_id_sequence')"), + time_to_next_address = table.Column(type: "interval", nullable: false), + cost_to_next_address = table.Column(type: "numeric(24,12)", nullable: false), + current_address_stop_time = table.Column(type: "interval", nullable: false), + vehicle_enrollment_id = table.Column(type: "bigint", nullable: false), + route_address_id = table.Column(type: "bigint", nullable: false), + uuid = table.Column(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_route_address_details", x => x.id); + table.UniqueConstraint("altk_route_address_details_uuid", x => x.uuid); + table.ForeignKey( + name: "fk_route_address_details_route_address_id", + column: x => x.route_address_id, + principalSchema: "application", + principalTable: "route_addresses", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "fk_route_address_details_vehicle_enrollment_id", + column: x => x.vehicle_enrollment_id, + principalSchema: "application", + principalTable: "vehicle_enrollments", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "ix_route_address_details_route_address_id", + schema: "application", + table: "route_address_details", + column: "route_address_id"); + + migrationBuilder.CreateIndex( + name: "ix_route_address_details_vehicle_enrollment_id", + schema: "application", + table: "route_address_details", + column: "vehicle_enrollment_id"); + + migrationBuilder.CreateIndex( + name: "ix_vehicle_enrollments_route_id", + schema: "application", + table: "vehicle_enrollments", + column: "route_id"); + + migrationBuilder.CreateIndex( + name: "ix_vehicle_enrollments_vehicle_id", + schema: "application", + table: "vehicle_enrollments", + column: "vehicle_id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "route_address_details", + schema: "application"); + + migrationBuilder.DropTable( + name: "vehicle_enrollments", + schema: "application"); + + migrationBuilder.DropSequence( + name: "route_address_details_id_sequence", + schema: "application"); + + migrationBuilder.DropSequence( + name: "vehicle_enrollments_id_sequence", + schema: "application"); + } + } +} diff --git a/src/Persistence/PostgreSql/Migrations/PostgreSqlDbContextModelSnapshot.cs b/src/Persistence/PostgreSql/Migrations/PostgreSqlDbContextModelSnapshot.cs index 8140f00..b480530 100644 --- a/src/Persistence/PostgreSql/Migrations/PostgreSqlDbContextModelSnapshot.cs +++ b/src/Persistence/PostgreSql/Migrations/PostgreSqlDbContextModelSnapshot.cs @@ -31,10 +31,14 @@ namespace Persistence.PostgreSql.Migrations modelBuilder.HasSequence("regions_id_sequence"); + modelBuilder.HasSequence("route_address_details_id_sequence"); + modelBuilder.HasSequence("route_addresses_id_sequence"); modelBuilder.HasSequence("routes_id_sequence"); + modelBuilder.HasSequence("vehicle_enrollments_id_sequence"); + modelBuilder.HasSequence("vehicles_id_sequence"); modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.Address", b => @@ -264,6 +268,55 @@ namespace Persistence.PostgreSql.Migrations b.ToTable("route_addresses", "application"); }); + modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.RouteAddressDetail", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id") + .HasDefaultValueSql("nextval('application.route_address_details_id_sequence')"); + + NpgsqlPropertyBuilderExtensions.UseSequence(b.Property("Id"), "route_address_details_id_sequence"); + + b.Property("CostToNextAddress") + .HasColumnType("numeric(24,12)") + .HasColumnName("cost_to_next_address"); + + b.Property("CurrentAddressStopTime") + .HasColumnType("interval") + .HasColumnName("current_address_stop_time"); + + b.Property("Guid") + .HasColumnType("uuid") + .HasColumnName("uuid"); + + b.Property("RouteAddressId") + .HasColumnType("bigint") + .HasColumnName("route_address_id"); + + b.Property("TimeToNextAddress") + .HasColumnType("interval") + .HasColumnName("time_to_next_address"); + + b.Property("VehicleEnrollmentId") + .HasColumnType("bigint") + .HasColumnName("vehicle_enrollment_id"); + + b.HasKey("Id") + .HasName("pk_route_address_details"); + + b.HasAlternateKey("Guid") + .HasName("altk_route_address_details_uuid"); + + b.HasIndex("RouteAddressId") + .HasDatabaseName("ix_route_address_details_route_address_id"); + + b.HasIndex("VehicleEnrollmentId") + .HasDatabaseName("ix_route_address_details_vehicle_enrollment_id"); + + b.ToTable("route_address_details", "application"); + }); + modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.Vehicle", b => { b.Property("Id") @@ -299,6 +352,55 @@ namespace Persistence.PostgreSql.Migrations b.UseTphMappingStrategy(); }); + modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.VehicleEnrollment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id") + .HasDefaultValueSql("nextval('application.vehicle_enrollments_id_sequence')"); + + NpgsqlPropertyBuilderExtensions.UseSequence(b.Property("Id"), "vehicle_enrollments_id_sequence"); + + b.Property("Currency") + .IsRequired() + .HasColumnType("varchar(8)") + .HasColumnName("currency"); + + b.Property("DepartureTime") + .HasColumnType("timestamptz") + .HasColumnName("departure_time"); + + b.Property("Guid") + .HasColumnType("uuid") + .HasColumnName("uuid"); + + b.Property("RouteId") + .HasColumnType("bigint") + .HasColumnName("route_id"); + + b.Property("VehicleId") + .HasColumnType("bigint") + .HasColumnName("vehicle_id"); + + b.HasKey("Id") + .HasName("pk_vehicle_enrollments"); + + b.HasAlternateKey("Guid") + .HasName("altk_vehicle_enrollments_uuid"); + + b.HasIndex("RouteId") + .HasDatabaseName("ix_vehicle_enrollments_route_id"); + + b.HasIndex("VehicleId") + .HasDatabaseName("ix_vehicle_enrollments_vehicle_id"); + + b.ToTable("vehicle_enrollments", "application", t => + { + t.HasCheckConstraint("ck_vehicle_enrollments_currency", "currency IN ('USD', 'EUR', 'UAH')"); + }); + }); + modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.Aircraft", b => { b.HasBaseType("cuqmbr.TravelGuide.Domain.Entities.Vehicle"); @@ -443,6 +545,48 @@ namespace Persistence.PostgreSql.Migrations b.Navigation("Route"); }); + modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.RouteAddressDetail", b => + { + b.HasOne("cuqmbr.TravelGuide.Domain.Entities.RouteAddress", "RouteAddress") + .WithMany("Details") + .HasForeignKey("RouteAddressId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_route_address_details_route_address_id"); + + b.HasOne("cuqmbr.TravelGuide.Domain.Entities.VehicleEnrollment", "VehicleEnrollment") + .WithMany("RouteAddressDetails") + .HasForeignKey("VehicleEnrollmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_route_address_details_vehicle_enrollment_id"); + + b.Navigation("RouteAddress"); + + b.Navigation("VehicleEnrollment"); + }); + + modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.VehicleEnrollment", b => + { + b.HasOne("cuqmbr.TravelGuide.Domain.Entities.Route", "Route") + .WithMany("VehicleEnrollments") + .HasForeignKey("RouteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_vehicle_enrollments_route_id"); + + b.HasOne("cuqmbr.TravelGuide.Domain.Entities.Vehicle", "Vehicle") + .WithMany("Enrollments") + .HasForeignKey("VehicleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_vehicle_enrollments_vehicle_id"); + + b.Navigation("Route"); + + b.Navigation("Vehicle"); + }); + modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.Address", b => { b.Navigation("AddressRoutes"); @@ -466,6 +610,23 @@ namespace Persistence.PostgreSql.Migrations modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.Route", b => { b.Navigation("RouteAddresses"); + + b.Navigation("VehicleEnrollments"); + }); + + modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.RouteAddress", b => + { + b.Navigation("Details"); + }); + + modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.Vehicle", b => + { + b.Navigation("Enrollments"); + }); + + modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.VehicleEnrollment", b => + { + b.Navigation("RouteAddressDetails"); }); #pragma warning restore 612, 618 } diff --git a/src/Persistence/PostgreSql/PostgreSqlDbContext.cs b/src/Persistence/PostgreSql/PostgreSqlDbContext.cs index afede19..c9e667e 100644 --- a/src/Persistence/PostgreSql/PostgreSqlDbContext.cs +++ b/src/Persistence/PostgreSql/PostgreSqlDbContext.cs @@ -36,7 +36,16 @@ public class PostgreSqlDbContext : DbContext { builder .Properties() - .HaveColumnType("vehicle_type") + .HaveColumnType("varchar(16)") .HaveConversion(); + + builder + .Properties() + .HaveColumnType("varchar(8)") + .HaveConversion(); + + builder + .Properties() + .HaveConversion(); } } diff --git a/src/Persistence/PostgreSql/PostgreSqlUnitOfWork.cs b/src/Persistence/PostgreSql/PostgreSqlUnitOfWork.cs index 0642a3e..0e588c2 100644 --- a/src/Persistence/PostgreSql/PostgreSqlUnitOfWork.cs +++ b/src/Persistence/PostgreSql/PostgreSqlUnitOfWork.cs @@ -22,6 +22,10 @@ public sealed class PostgreSqlUnitOfWork : UnitOfWork BusRepository = new PostgreSqlBusRepository(_dbContext); AircraftRepository = new PostgreSqlAircraftRepository(_dbContext); TrainRepository = new PostgreSqlTrainRepository(_dbContext); + VehicleEnrollmentRepository = + new PostgreSqlVehicleEnrollmentRepository(_dbContext); + RouteAddressRepository = + new PostgreSqlRouteAddressRepository(_dbContext); } public CountryRepository CountryRepository { get; init; } @@ -42,6 +46,10 @@ public sealed class PostgreSqlUnitOfWork : UnitOfWork public TrainRepository TrainRepository { get; init; } + public VehicleEnrollmentRepository VehicleEnrollmentRepository { get; init; } + + public RouteAddressRepository RouteAddressRepository { get; init; } + public int Save() { return _dbContext.SaveChanges(); diff --git a/src/Persistence/PostgreSql/Repositories/PostgreSqlRouteAddressRepository.cs b/src/Persistence/PostgreSql/Repositories/PostgreSqlRouteAddressRepository.cs new file mode 100644 index 0000000..fcd828b --- /dev/null +++ b/src/Persistence/PostgreSql/Repositories/PostgreSqlRouteAddressRepository.cs @@ -0,0 +1,11 @@ +using cuqmbr.TravelGuide.Application.Common.Interfaces.Persistence.Repositories; +using cuqmbr.TravelGuide.Domain.Entities; + +namespace cuqmbr.TravelGuide.Persistence.PostgreSql.Repositories; + +public sealed class PostgreSqlRouteAddressRepository : + PostgreSqlBaseRepository, RouteAddressRepository +{ + public PostgreSqlRouteAddressRepository(PostgreSqlDbContext dbContext) + : base(dbContext) { } +} diff --git a/src/Persistence/PostgreSql/Repositories/PostgreSqlVehicleEnrollmentRepository.cs b/src/Persistence/PostgreSql/Repositories/PostgreSqlVehicleEnrollmentRepository.cs new file mode 100644 index 0000000..434b69c --- /dev/null +++ b/src/Persistence/PostgreSql/Repositories/PostgreSqlVehicleEnrollmentRepository.cs @@ -0,0 +1,11 @@ +using cuqmbr.TravelGuide.Application.Common.Interfaces.Persistence.Repositories; +using cuqmbr.TravelGuide.Domain.Entities; + +namespace cuqmbr.TravelGuide.Persistence.PostgreSql.Repositories; + +public sealed class PostgreSqlVehicleEnrollmentRepository : + PostgreSqlBaseRepository, VehicleEnrollmentRepository +{ + public PostgreSqlVehicleEnrollmentRepository(PostgreSqlDbContext dbContext) + : base(dbContext) { } +} diff --git a/src/Persistence/TypeConverters/CurrencyConverter.cs b/src/Persistence/TypeConverters/CurrencyConverter.cs new file mode 100644 index 0000000..9c2bb7f --- /dev/null +++ b/src/Persistence/TypeConverters/CurrencyConverter.cs @@ -0,0 +1,13 @@ +using cuqmbr.TravelGuide.Domain.Enums; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace cuqmbr.TravelGuide.Persistence.TypeConverters; + +public class CurrencyConverter : ValueConverter +{ + public CurrencyConverter() + : base( + v => v.Name, + v => Currency.FromName(v)) + { } +} diff --git a/src/Persistence/TypeConverters/DateTimeOffsetConverter.cs b/src/Persistence/TypeConverters/DateTimeOffsetConverter.cs new file mode 100644 index 0000000..841c33c --- /dev/null +++ b/src/Persistence/TypeConverters/DateTimeOffsetConverter.cs @@ -0,0 +1,15 @@ +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace cuqmbr.TravelGuide.Persistence.TypeConverters; + +// Convert localized time to UTC + +public class DateTimeOffsetConverter : + ValueConverter +{ + public DateTimeOffsetConverter() + : base( + v => v.ToUniversalTime(), + v => v) + { } +}