From fdf147fe8360937db20366ca89ad4949f2dc3fcf Mon Sep 17 00:00:00 2001 From: cuqmbr Date: Thu, 1 May 2025 20:50:22 +0300 Subject: [PATCH] add route entity management --- .../Common/Exceptions/ValidationException.cs | 15 + .../Repositories/RouteRepository.cs | 6 + .../Interfaces/Persistence/UnitOfWork.cs | 2 + .../Resources/Localization/en-US.json | 4 + .../Commands/AddRoute/AddRouteCommand.cs | 14 + .../AddRoute/AddRouteCommandAuthorizer.cs | 31 ++ .../AddRoute/AddRouteCommandHandler.cs | 84 ++++ .../AddRoute/AddRouteCommandValidator.cs | 45 ++ .../DeleteRoute/DeleteRouteCommand.cs | 8 + .../DeleteRouteCommandAuthorizer.cs | 31 ++ .../DeleteRoute/DeleteRouteCommandHandler.cs | 37 ++ .../DeleteRouteCommandValidator.cs | 14 + .../UpdateRoute/UpdateRouteCommand.cs | 16 + .../UpdateRouteCommandAuthorizer.cs | 31 ++ .../UpdateRoute/UpdateRouteCommandHandler.cs | 108 +++++ .../UpdateRouteCommandValidator.cs | 49 +++ .../Routes/Models/RouteAddressModel.cs | 8 + .../Routes/Queries/GetRoute/GetRouteQuery.cs | 8 + .../GetRoute/GetRouteQueryAuthorizer.cs | 31 ++ .../Queries/GetRoute/GetRouteQueryHandler.cs | 60 +++ .../GetRoute/GetRouteQueryValidator.cs | 14 + .../GetRoutesPage/GetRoutesPageQuery.cs | 18 + .../GetRoutesPageQueryAuthorizer.cs | 31 ++ .../GetRoutesPageQueryHandler.cs | 74 ++++ .../GetRoutesPageQueryValidator.cs | 44 ++ src/Application/Routes/RouteAddressDto.cs | 70 +++ src/Application/Routes/RouteDto.cs | 29 ++ .../Routes/ViewModels/AddRouteViewModel.cs | 10 + .../GetRoutesPageFilterViewModel.cs | 6 + .../ViewModels/RouteAddressViewModel.cs | 8 + .../Routes/ViewModels/UpdateRouteViewModel.cs | 10 + src/Domain/Entities/Address.cs | 2 +- src/Domain/Entities/Route.cs | 3 +- src/HttpApi/Controllers/RoutesController.cs | 198 +++++++++ .../GlobalExceptionHandlerMiddleware.cs | 49 ++- .../InMemory/InMemoryUnitOfWork.cs | 4 + .../Repositories/InMemoryRouteRepository.cs | 11 + .../Configurations/AddressConfiguration.cs | 2 +- .../RouteAddressConfiguration.cs | 81 ++++ .../Configurations/RouteConfiguration.cs | 40 ++ ...6_Add_Route_and_RouteAddresses.Designer.cs | 400 ++++++++++++++++++ ...0501112816_Add_Route_and_RouteAddresses.cs | 132 ++++++ .../PostgreSqlDbContextModelSnapshot.cs | 131 ++++++ .../PostgreSql/PostgreSqlDbContext.cs | 11 +- .../PostgreSql/PostgreSqlUnitOfWork.cs | 3 + .../Repositories/PostgreSqlRouteRepository.cs | 11 + .../TypeConverters/VehicleTypeConverter.cs | 13 + 47 files changed, 1978 insertions(+), 29 deletions(-) create mode 100644 src/Application/Common/Interfaces/Persistence/Repositories/RouteRepository.cs create mode 100644 src/Application/Routes/Commands/AddRoute/AddRouteCommand.cs create mode 100644 src/Application/Routes/Commands/AddRoute/AddRouteCommandAuthorizer.cs create mode 100644 src/Application/Routes/Commands/AddRoute/AddRouteCommandHandler.cs create mode 100644 src/Application/Routes/Commands/AddRoute/AddRouteCommandValidator.cs create mode 100644 src/Application/Routes/Commands/DeleteRoute/DeleteRouteCommand.cs create mode 100644 src/Application/Routes/Commands/DeleteRoute/DeleteRouteCommandAuthorizer.cs create mode 100644 src/Application/Routes/Commands/DeleteRoute/DeleteRouteCommandHandler.cs create mode 100644 src/Application/Routes/Commands/DeleteRoute/DeleteRouteCommandValidator.cs create mode 100644 src/Application/Routes/Commands/UpdateRoute/UpdateRouteCommand.cs create mode 100644 src/Application/Routes/Commands/UpdateRoute/UpdateRouteCommandAuthorizer.cs create mode 100644 src/Application/Routes/Commands/UpdateRoute/UpdateRouteCommandHandler.cs create mode 100644 src/Application/Routes/Commands/UpdateRoute/UpdateRouteCommandValidator.cs create mode 100644 src/Application/Routes/Models/RouteAddressModel.cs create mode 100644 src/Application/Routes/Queries/GetRoute/GetRouteQuery.cs create mode 100644 src/Application/Routes/Queries/GetRoute/GetRouteQueryAuthorizer.cs create mode 100644 src/Application/Routes/Queries/GetRoute/GetRouteQueryHandler.cs create mode 100644 src/Application/Routes/Queries/GetRoute/GetRouteQueryValidator.cs create mode 100644 src/Application/Routes/Queries/GetRoutesPage/GetRoutesPageQuery.cs create mode 100644 src/Application/Routes/Queries/GetRoutesPage/GetRoutesPageQueryAuthorizer.cs create mode 100644 src/Application/Routes/Queries/GetRoutesPage/GetRoutesPageQueryHandler.cs create mode 100644 src/Application/Routes/Queries/GetRoutesPage/GetRoutesPageQueryValidator.cs create mode 100644 src/Application/Routes/RouteAddressDto.cs create mode 100644 src/Application/Routes/RouteDto.cs create mode 100644 src/Application/Routes/ViewModels/AddRouteViewModel.cs create mode 100644 src/Application/Routes/ViewModels/GetRoutesPageFilterViewModel.cs create mode 100644 src/Application/Routes/ViewModels/RouteAddressViewModel.cs create mode 100644 src/Application/Routes/ViewModels/UpdateRouteViewModel.cs create mode 100644 src/HttpApi/Controllers/RoutesController.cs create mode 100644 src/Persistence/InMemory/Repositories/InMemoryRouteRepository.cs create mode 100644 src/Persistence/PostgreSql/Configurations/RouteAddressConfiguration.cs create mode 100644 src/Persistence/PostgreSql/Configurations/RouteConfiguration.cs create mode 100644 src/Persistence/PostgreSql/Migrations/20250501112816_Add_Route_and_RouteAddresses.Designer.cs create mode 100644 src/Persistence/PostgreSql/Migrations/20250501112816_Add_Route_and_RouteAddresses.cs create mode 100644 src/Persistence/PostgreSql/Repositories/PostgreSqlRouteRepository.cs create mode 100644 src/Persistence/PostgreSql/TypeConverters/VehicleTypeConverter.cs diff --git a/src/Application/Common/Exceptions/ValidationException.cs b/src/Application/Common/Exceptions/ValidationException.cs index 7c1df67..0ed4b17 100644 --- a/src/Application/Common/Exceptions/ValidationException.cs +++ b/src/Application/Common/Exceptions/ValidationException.cs @@ -10,9 +10,24 @@ public class ValidationException : Exception Errors = new Dictionary(); } + public ValidationException(string message) : base(message) + { + Errors = new Dictionary(); + } + public ValidationException(IEnumerable failures) : this() { + // TODO: Make serialized dictionary look more like this + // "errors": { + // "viewModel": [ + // "The viewModel field is required." + // ], + // "$.addresses[0].order": [ + // "The JSON value could not be converted to System.Int16. Path: $.addresses[0].order | LineNumber: 5 | BytePositionInLine: 26." + // ] + // }, + Errors = failures .GroupBy(f => f.PropertyName, f => f.ErrorMessage) .ToDictionary(fg => fg.Key, fg => fg.ToArray()); diff --git a/src/Application/Common/Interfaces/Persistence/Repositories/RouteRepository.cs b/src/Application/Common/Interfaces/Persistence/Repositories/RouteRepository.cs new file mode 100644 index 0000000..83249fb --- /dev/null +++ b/src/Application/Common/Interfaces/Persistence/Repositories/RouteRepository.cs @@ -0,0 +1,6 @@ +using cuqmbr.TravelGuide.Domain.Entities; + +namespace cuqmbr.TravelGuide.Application.Common.Interfaces + .Persistence.Repositories; + +public interface RouteRepository : BaseRepository { } diff --git a/src/Application/Common/Interfaces/Persistence/UnitOfWork.cs b/src/Application/Common/Interfaces/Persistence/UnitOfWork.cs index 78d0a07..fde3ef8 100644 --- a/src/Application/Common/Interfaces/Persistence/UnitOfWork.cs +++ b/src/Application/Common/Interfaces/Persistence/UnitOfWork.cs @@ -12,6 +12,8 @@ public interface UnitOfWork : IDisposable AddressRepository AddressRepository { get; } + RouteRepository RouteRepository { 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 cec7742..86a35db 100644 --- a/src/Application/Resources/Localization/en-US.json +++ b/src/Application/Resources/Localization/en-US.json @@ -6,6 +6,10 @@ "LessThanOrEqualTo": "Must be less than or equal to {0:G}.", "MustBeInEnum": "Must be one of the following: {0}." }, + "Validation": { + "DistinctOrder": "Must have distinct order values.", + "SameVehicleType": "Must have the same vehicle type." + }, "ExceptionHandling": { "ValidationException": { "Title": "One or more validation errors occurred.", diff --git a/src/Application/Routes/Commands/AddRoute/AddRouteCommand.cs b/src/Application/Routes/Commands/AddRoute/AddRouteCommand.cs new file mode 100644 index 0000000..01180a3 --- /dev/null +++ b/src/Application/Routes/Commands/AddRoute/AddRouteCommand.cs @@ -0,0 +1,14 @@ +using cuqmbr.TravelGuide.Domain.Enums; +using MediatR; +using cuqmbr.TravelGuide.Application.Routes.Models; + +namespace cuqmbr.TravelGuide.Application.Routes.Commands.AddRoute; + +public record AddRouteCommand : IRequest +{ + public string Name { get; set; } + + public VehicleType VehicleType { get; set; } + + public ICollection Addresses { get; set; } +} diff --git a/src/Application/Routes/Commands/AddRoute/AddRouteCommandAuthorizer.cs b/src/Application/Routes/Commands/AddRoute/AddRouteCommandAuthorizer.cs new file mode 100644 index 0000000..9040482 --- /dev/null +++ b/src/Application/Routes/Commands/AddRoute/AddRouteCommandAuthorizer.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.Routes.Commands.AddRoute; + +public class AddRouteCommandAuthorizer : + AbstractRequestAuthorizer +{ + private readonly SessionUserService _sessionUserService; + + public AddRouteCommandAuthorizer(SessionUserService sessionUserService) + { + _sessionUserService = sessionUserService; + } + + public override void BuildPolicy(AddRouteCommand request) + { + UseRequirement(new MustBeAuthenticatedRequirement + { + IsAuthenticated= _sessionUserService.IsAuthenticated + }); + + UseRequirement(new MustBeInRolesRequirement + { + RequiredRoles = [IdentityRole.Administrator], + UserRoles = _sessionUserService.Roles + }); + } +} diff --git a/src/Application/Routes/Commands/AddRoute/AddRouteCommandHandler.cs b/src/Application/Routes/Commands/AddRoute/AddRouteCommandHandler.cs new file mode 100644 index 0000000..df3c7cc --- /dev/null +++ b/src/Application/Routes/Commands/AddRoute/AddRouteCommandHandler.cs @@ -0,0 +1,84 @@ +using MediatR; +using cuqmbr.TravelGuide.Application.Common.Interfaces.Persistence; +using cuqmbr.TravelGuide.Domain.Entities; +using AutoMapper; +using cuqmbr.TravelGuide.Application.Common.Exceptions; +using Microsoft.Extensions.Localization; +using FluentValidation.Results; + +namespace cuqmbr.TravelGuide.Application.Routes.Commands.AddRoute; + +public class AddRouteCommandHandler : + IRequestHandler +{ + private readonly UnitOfWork _unitOfWork; + private readonly IMapper _mapper; + private readonly IStringLocalizer _localizer; + + public AddRouteCommandHandler( + UnitOfWork unitOfWork, + IMapper mapper, + IStringLocalizer localizer) + { + _unitOfWork = unitOfWork; + _mapper = mapper; + _localizer = localizer; + } + + public async Task Handle( + AddRouteCommand request, + CancellationToken cancellationToken) + { + var page = await _unitOfWork.AddressRepository.GetPageAsync( + e => request.Addresses.Select(a => a.Guid).Contains(e.Guid), + e => e.City.Region.Country, + 1, request.Addresses.Count, cancellationToken); + + var invalidVehicleTypeAddress = + page.Items.FirstOrDefault(a => a.VehicleType != request.VehicleType); + if (invalidVehicleTypeAddress != null) + { + throw new ValidationException( + new List + { + new() + { + PropertyName = nameof(request.Addresses), + ErrorMessage = _localizer["Validation.SameVehicleType"] + } + }); + } + + var pageContainsAllRequestedAddresses = + request.Addresses.Select(e => e.Guid) + .All(e => page.Items.Select(e => e.Guid).Contains(e)); + if (!pageContainsAllRequestedAddresses) + { + var notFoundCount = request.Addresses.Count - page.TotalCount; + throw new NotFoundException( + $"{notFoundCount} addresses was not found."); + } + + var entity = new Route() + { + Name = request.Name, + VehicleType = request.VehicleType, + RouteAddresses = request.Addresses.Select( + e => new RouteAddress() + { + Order = e.Order, + AddressId = page.Items.Single(i => i.Guid == e.Guid).Id + }) + .OrderBy(e => e.Order) + .ToArray() + }; + + entity = await _unitOfWork.RouteRepository.AddOneAsync( + entity, cancellationToken); + + await _unitOfWork.SaveAsync(cancellationToken); + _unitOfWork.Dispose(); + + return _mapper.Map(entity); + } +} diff --git a/src/Application/Routes/Commands/AddRoute/AddRouteCommandValidator.cs b/src/Application/Routes/Commands/AddRoute/AddRouteCommandValidator.cs new file mode 100644 index 0000000..ead366b --- /dev/null +++ b/src/Application/Routes/Commands/AddRoute/AddRouteCommandValidator.cs @@ -0,0 +1,45 @@ +using cuqmbr.TravelGuide.Application.Common.Interfaces.Services; +using cuqmbr.TravelGuide.Domain.Enums; +using FluentValidation; +using Microsoft.Extensions.Localization; + +namespace cuqmbr.TravelGuide.Application.Routes.Commands.AddRoute; + +public class AddRouteCommandValidator : AbstractValidator +{ + public AddRouteCommandValidator( + IStringLocalizer localizer, + CultureService cultureService) + { + RuleFor(v => v.Name) + .NotEmpty() + .WithMessage(localizer["FluentValidation.NotEmpty"]) + .MaximumLength(64) + .WithMessage( + String.Format( + cultureService.Culture, + localizer["FluentValidation.MaximumLength"], + 64)); + + RuleFor(v => v.VehicleType) + .Must((v, vt) => VehicleType.Enumerations.ContainsValue(vt)) + .WithMessage( + String.Format( + localizer["FluentValidation.MustBeInEnum"], + String.Join( + ", ", + VehicleType.Enumerations.Values.Select(e => e.Name)))); + + RuleFor(v => v.Addresses.Count) + .GreaterThanOrEqualTo(2) + .WithMessage( + String.Format( + cultureService.Culture, + localizer["FluentValidation.GreaterThanOrEqualTo"], + 2)); + + RuleFor(v => v.Addresses) + .Must((v, a) => a.DistinctBy(e => e.Order).Count() == a.Count()) + .WithMessage(localizer["Validation.DistinctOrder"]); + } +} diff --git a/src/Application/Routes/Commands/DeleteRoute/DeleteRouteCommand.cs b/src/Application/Routes/Commands/DeleteRoute/DeleteRouteCommand.cs new file mode 100644 index 0000000..dbf98bc --- /dev/null +++ b/src/Application/Routes/Commands/DeleteRoute/DeleteRouteCommand.cs @@ -0,0 +1,8 @@ +using MediatR; + +namespace cuqmbr.TravelGuide.Application.Routes.Commands.DeleteRoute; + +public record DeleteRouteCommand : IRequest +{ + public Guid Guid { get; set; } +} diff --git a/src/Application/Routes/Commands/DeleteRoute/DeleteRouteCommandAuthorizer.cs b/src/Application/Routes/Commands/DeleteRoute/DeleteRouteCommandAuthorizer.cs new file mode 100644 index 0000000..8978db7 --- /dev/null +++ b/src/Application/Routes/Commands/DeleteRoute/DeleteRouteCommandAuthorizer.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.Routes.Commands.DeleteRoute; + +public class DeleteRouteCommandAuthorizer : + AbstractRequestAuthorizer +{ + private readonly SessionUserService _sessionUserService; + + public DeleteRouteCommandAuthorizer(SessionUserService sessionUserService) + { + _sessionUserService = sessionUserService; + } + + public override void BuildPolicy(DeleteRouteCommand request) + { + UseRequirement(new MustBeAuthenticatedRequirement + { + IsAuthenticated= _sessionUserService.IsAuthenticated + }); + + UseRequirement(new MustBeInRolesRequirement + { + RequiredRoles = [IdentityRole.Administrator], + UserRoles = _sessionUserService.Roles + }); + } +} diff --git a/src/Application/Routes/Commands/DeleteRoute/DeleteRouteCommandHandler.cs b/src/Application/Routes/Commands/DeleteRoute/DeleteRouteCommandHandler.cs new file mode 100644 index 0000000..d1fde57 --- /dev/null +++ b/src/Application/Routes/Commands/DeleteRoute/DeleteRouteCommandHandler.cs @@ -0,0 +1,37 @@ +using MediatR; +using cuqmbr.TravelGuide.Application.Common.Interfaces.Persistence; +using cuqmbr.TravelGuide.Application.Common.Exceptions; + +namespace cuqmbr.TravelGuide.Application.Routes.Commands.DeleteRoute; + +public class DeleteRouteCommandHandler : IRequestHandler +{ + private readonly UnitOfWork _unitOfWork; + + public DeleteRouteCommandHandler(UnitOfWork unitOfWork) + { + _unitOfWork = unitOfWork; + } + + public async Task Handle( + DeleteRouteCommand request, + CancellationToken cancellationToken) + { + var entity = await _unitOfWork.RouteRepository.GetOneAsync( + e => e.Guid == request.Guid, cancellationToken); + + if (entity == null) + { + throw new NotFoundException(); + } + + // TODO: Check for Vehicles that using this route in Enrollments + // Delete if there are no such Vehicles + + await _unitOfWork.RouteRepository.DeleteOneAsync( + entity, cancellationToken); + + await _unitOfWork.SaveAsync(cancellationToken); + _unitOfWork.Dispose(); + } +} diff --git a/src/Application/Routes/Commands/DeleteRoute/DeleteRouteCommandValidator.cs b/src/Application/Routes/Commands/DeleteRoute/DeleteRouteCommandValidator.cs new file mode 100644 index 0000000..3c655d2 --- /dev/null +++ b/src/Application/Routes/Commands/DeleteRoute/DeleteRouteCommandValidator.cs @@ -0,0 +1,14 @@ +using FluentValidation; +using Microsoft.Extensions.Localization; + +namespace cuqmbr.TravelGuide.Application.Routes.Commands.DeleteRoute; + +public class DeleteRouteCommandValidator : AbstractValidator +{ + public DeleteRouteCommandValidator(IStringLocalizer localizer) + { + RuleFor(v => v.Guid) + .NotEmpty() + .WithMessage(localizer["FluentValidation.NotEmpty"]); + } +} diff --git a/src/Application/Routes/Commands/UpdateRoute/UpdateRouteCommand.cs b/src/Application/Routes/Commands/UpdateRoute/UpdateRouteCommand.cs new file mode 100644 index 0000000..4fd3797 --- /dev/null +++ b/src/Application/Routes/Commands/UpdateRoute/UpdateRouteCommand.cs @@ -0,0 +1,16 @@ +using MediatR; +using cuqmbr.TravelGuide.Domain.Enums; +using cuqmbr.TravelGuide.Application.Routes.Models; + +namespace cuqmbr.TravelGuide.Application.Routes.Commands.UpdateRoute; + +public record UpdateRouteCommand : IRequest +{ + public Guid Guid { get; set; } + + public string Name { get; set; } + + public VehicleType VehicleType { get; set; } + + public ICollection Addresses { get; set; } +} diff --git a/src/Application/Routes/Commands/UpdateRoute/UpdateRouteCommandAuthorizer.cs b/src/Application/Routes/Commands/UpdateRoute/UpdateRouteCommandAuthorizer.cs new file mode 100644 index 0000000..611b7ae --- /dev/null +++ b/src/Application/Routes/Commands/UpdateRoute/UpdateRouteCommandAuthorizer.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.Routes.Commands.UpdateRoute; + +public class UpdateRouteCommandAuthorizer : + AbstractRequestAuthorizer +{ + private readonly SessionUserService _sessionUserService; + + public UpdateRouteCommandAuthorizer(SessionUserService sessionUserService) + { + _sessionUserService = sessionUserService; + } + + public override void BuildPolicy(UpdateRouteCommand request) + { + UseRequirement(new MustBeAuthenticatedRequirement + { + IsAuthenticated= _sessionUserService.IsAuthenticated + }); + + UseRequirement(new MustBeInRolesRequirement + { + RequiredRoles = [IdentityRole.Administrator], + UserRoles = _sessionUserService.Roles + }); + } +} diff --git a/src/Application/Routes/Commands/UpdateRoute/UpdateRouteCommandHandler.cs b/src/Application/Routes/Commands/UpdateRoute/UpdateRouteCommandHandler.cs new file mode 100644 index 0000000..df48877 --- /dev/null +++ b/src/Application/Routes/Commands/UpdateRoute/UpdateRouteCommandHandler.cs @@ -0,0 +1,108 @@ +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.Routes.Commands.UpdateRoute; + +public class UpdateRouteCommandHandler : + IRequestHandler +{ + private readonly UnitOfWork _unitOfWork; + private readonly IMapper _mapper; + public IStringLocalizer _localizer { get; set; } + + public UpdateRouteCommandHandler( + UnitOfWork unitOfWork, + IMapper mapper, + IStringLocalizer localizer) + { + _unitOfWork = unitOfWork; + _mapper = mapper; + _localizer = localizer; + } + + public async Task Handle( + UpdateRouteCommand request, + CancellationToken cancellationToken) + { + var entity = await _unitOfWork.RouteRepository.GetOneAsync( + e => e.Guid == request.Guid, + e => e.RouteAddresses, + cancellationToken); + + if (entity == null) + { + throw new NotFoundException(); + } + + var page = await _unitOfWork.AddressRepository.GetPageAsync( + e => request.Addresses.Select(a => a.Guid).Contains(e.Guid), + e => e.City.Region.Country, + 1, request.Addresses.Count, cancellationToken); + + var invalidVehicleTypeAddress = + page.Items.FirstOrDefault(a => a.VehicleType != request.VehicleType); + if (invalidVehicleTypeAddress != null) + { + throw new ValidationException( + new List + { + new() + { + PropertyName = nameof(request.Addresses), + ErrorMessage = _localizer["Validation.SameVehicleType"] + } + }); + } + + var pageContainsAllRequestedAddresses = + request.Addresses.Select(e => e.Guid) + .All(e => page.Items.Select(e => e.Guid).Contains(e)); + if (!pageContainsAllRequestedAddresses) + { + var notFoundCount = request.Addresses.Count - page.TotalCount; + throw new NotFoundException( + $"{notFoundCount} addresses was not found."); + } + + + entity.Guid = request.Guid; + entity.Name = request.Name; + entity.VehicleType = request.VehicleType; + + + var requestRouteAddresses = request.Addresses.Select( + e => new RouteAddress() + { + Order = e.Order, + AddressId = page.Items.Single(i => i.Guid == e.Guid).Id + }); + + var commonRouteAddresses = entity.RouteAddresses.IntersectBy( + requestRouteAddresses.Select(ra => (ra.Order, ra.AddressId)), + ra => (ra.Order, ra.AddressId)); + + var newRouteAddresses = requestRouteAddresses.ExceptBy( + entity.RouteAddresses.Select(ra => (ra.Order, ra.AddressId)), + ra => (ra.Order, ra.AddressId)); + + var combinedRouteAddresses = commonRouteAddresses.UnionBy( + newRouteAddresses, ra => (ra.Order, ra.AddressId)); + + entity.RouteAddresses = combinedRouteAddresses + .OrderBy(e => e.Order).ToList(); + + + entity = await _unitOfWork.RouteRepository.UpdateOneAsync( + entity, cancellationToken); + + await _unitOfWork.SaveAsync(cancellationToken); + _unitOfWork.Dispose(); + + return _mapper.Map(entity); + } +} diff --git a/src/Application/Routes/Commands/UpdateRoute/UpdateRouteCommandValidator.cs b/src/Application/Routes/Commands/UpdateRoute/UpdateRouteCommandValidator.cs new file mode 100644 index 0000000..cc4ddd3 --- /dev/null +++ b/src/Application/Routes/Commands/UpdateRoute/UpdateRouteCommandValidator.cs @@ -0,0 +1,49 @@ +using cuqmbr.TravelGuide.Application.Common.Interfaces.Services; +using cuqmbr.TravelGuide.Domain.Enums; +using FluentValidation; +using Microsoft.Extensions.Localization; + +namespace cuqmbr.TravelGuide.Application.Routes.Commands.UpdateRoute; + +public class UpdateRouteCommandValidator : AbstractValidator +{ + public UpdateRouteCommandValidator( + IStringLocalizer localizer, + CultureService cultureService) + { + RuleFor(v => v.Guid) + .NotEmpty() + .WithMessage(localizer["FluentValidation.NotEmpty"]); + + RuleFor(v => v.Name) + .NotEmpty() + .WithMessage(localizer["FluentValidation.NotEmpty"]) + .MaximumLength(64) + .WithMessage( + String.Format( + cultureService.Culture, + localizer["FluentValidation.MaximumLength"], + 64)); + + RuleFor(v => v.VehicleType) + .Must((v, vt) => VehicleType.Enumerations.ContainsValue(vt)) + .WithMessage( + String.Format( + localizer["FluentValidation.MustBeInEnum"], + String.Join( + ", ", + VehicleType.Enumerations.Values.Select(e => e.Name)))); + + RuleFor(v => v.Addresses.Count) + .GreaterThanOrEqualTo(2) + .WithMessage( + String.Format( + cultureService.Culture, + localizer["FluentValidation.GreaterThanOrEqualTo"], + 2)); + + RuleFor(v => v.Addresses) + .Must((v, a) => a.DistinctBy(e => e.Order).Count() == a.Count()) + .WithMessage(localizer["Validation.DistinctOrder"]); + } +} diff --git a/src/Application/Routes/Models/RouteAddressModel.cs b/src/Application/Routes/Models/RouteAddressModel.cs new file mode 100644 index 0000000..2bcde99 --- /dev/null +++ b/src/Application/Routes/Models/RouteAddressModel.cs @@ -0,0 +1,8 @@ +namespace cuqmbr.TravelGuide.Application.Routes.Models; + +public sealed class RouteAddressModel +{ + public short Order { get; set; } + + public Guid Guid { get; set; } +} diff --git a/src/Application/Routes/Queries/GetRoute/GetRouteQuery.cs b/src/Application/Routes/Queries/GetRoute/GetRouteQuery.cs new file mode 100644 index 0000000..4d8ad72 --- /dev/null +++ b/src/Application/Routes/Queries/GetRoute/GetRouteQuery.cs @@ -0,0 +1,8 @@ +using MediatR; + +namespace cuqmbr.TravelGuide.Application.Routes.Queries.GetRoute; + +public record GetRouteQuery : IRequest +{ + public Guid Guid { get; set; } +} diff --git a/src/Application/Routes/Queries/GetRoute/GetRouteQueryAuthorizer.cs b/src/Application/Routes/Queries/GetRoute/GetRouteQueryAuthorizer.cs new file mode 100644 index 0000000..7ca5727 --- /dev/null +++ b/src/Application/Routes/Queries/GetRoute/GetRouteQueryAuthorizer.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.Routes.Queries.GetRoute; + +public class GetRouteQueryAuthorizer : + AbstractRequestAuthorizer +{ + private readonly SessionUserService _sessionUserService; + + public GetRouteQueryAuthorizer(SessionUserService sessionUserService) + { + _sessionUserService = sessionUserService; + } + + public override void BuildPolicy(GetRouteQuery request) + { + UseRequirement(new MustBeAuthenticatedRequirement + { + IsAuthenticated= _sessionUserService.IsAuthenticated + }); + + UseRequirement(new MustBeInRolesRequirement + { + RequiredRoles = [IdentityRole.Administrator], + UserRoles = _sessionUserService.Roles + }); + } +} diff --git a/src/Application/Routes/Queries/GetRoute/GetRouteQueryHandler.cs b/src/Application/Routes/Queries/GetRoute/GetRouteQueryHandler.cs new file mode 100644 index 0000000..0f8f2db --- /dev/null +++ b/src/Application/Routes/Queries/GetRoute/GetRouteQueryHandler.cs @@ -0,0 +1,60 @@ +using MediatR; +using cuqmbr.TravelGuide.Application.Common.Interfaces.Persistence; +using cuqmbr.TravelGuide.Application.Common.Exceptions; +using AutoMapper; +using cuqmbr.TravelGuide.Domain.Entities; + +namespace cuqmbr.TravelGuide.Application.Routes.Queries.GetRoute; + +public class GetRouteQueryHandler : + IRequestHandler +{ + private readonly UnitOfWork _unitOfWork; + private readonly IMapper _mapper; + + public GetRouteQueryHandler( + UnitOfWork unitOfWork, + IMapper mapper) + { + _unitOfWork = unitOfWork; + _mapper = mapper; + } + + public async Task Handle( + GetRouteQuery request, + CancellationToken cancellationToken) + { + var entity = await _unitOfWork.RouteRepository.GetOneAsync( + e => e.Guid == request.Guid, e => e.RouteAddresses, + cancellationToken); + + if (entity == null) + { + throw new NotFoundException(); + } + + // TODO: Find a way to include through lists + var addresses = await _unitOfWork.AddressRepository.GetPageAsync( + e => entity.RouteAddresses.Select(ra => ra.AddressId).Contains(e.Id), + e => e.City.Region.Country, + 1, entity.RouteAddresses.Count, cancellationToken); + + entity.RouteAddresses = entity.RouteAddresses.Select( + e => new RouteAddress() + { + Id = e.Id, + Guid = e.Guid, + Order = e.Order, + RouteId = e.RouteId, + Route = e.Route, + AddressId = e.AddressId, + Address = addresses.Items.First(a => a.Id == e.AddressId) + }) + .OrderBy(e => e.Order) + .ToArray(); + + _unitOfWork.Dispose(); + + return _mapper.Map(entity); + } +} diff --git a/src/Application/Routes/Queries/GetRoute/GetRouteQueryValidator.cs b/src/Application/Routes/Queries/GetRoute/GetRouteQueryValidator.cs new file mode 100644 index 0000000..30b065a --- /dev/null +++ b/src/Application/Routes/Queries/GetRoute/GetRouteQueryValidator.cs @@ -0,0 +1,14 @@ +using FluentValidation; +using Microsoft.Extensions.Localization; + +namespace cuqmbr.TravelGuide.Application.Routes.Queries.GetRoute; + +public class GetRouteQueryValidator : AbstractValidator +{ + public GetRouteQueryValidator(IStringLocalizer localizer) + { + RuleFor(v => v.Guid) + .NotEmpty() + .WithMessage(localizer["FluentValidation.NotEmpty"]); + } +} diff --git a/src/Application/Routes/Queries/GetRoutesPage/GetRoutesPageQuery.cs b/src/Application/Routes/Queries/GetRoutesPage/GetRoutesPageQuery.cs new file mode 100644 index 0000000..9680329 --- /dev/null +++ b/src/Application/Routes/Queries/GetRoutesPage/GetRoutesPageQuery.cs @@ -0,0 +1,18 @@ +using cuqmbr.TravelGuide.Application.Common.Models; +using cuqmbr.TravelGuide.Domain.Enums; +using MediatR; + +namespace cuqmbr.TravelGuide.Application.Routes.Queries.GetRoutesPage; + +public record GetRoutesPageQuery : 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 VehicleType? VehicleType { get; set; } +} diff --git a/src/Application/Routes/Queries/GetRoutesPage/GetRoutesPageQueryAuthorizer.cs b/src/Application/Routes/Queries/GetRoutesPage/GetRoutesPageQueryAuthorizer.cs new file mode 100644 index 0000000..dc64fa3 --- /dev/null +++ b/src/Application/Routes/Queries/GetRoutesPage/GetRoutesPageQueryAuthorizer.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.Routes.Queries.GetRoutesPage; + +public class GetRoutesPageQueryAuthorizer : + AbstractRequestAuthorizer +{ + private readonly SessionUserService _sessionUserService; + + public GetRoutesPageQueryAuthorizer(SessionUserService sessionUserService) + { + _sessionUserService = sessionUserService; + } + + public override void BuildPolicy(GetRoutesPageQuery request) + { + UseRequirement(new MustBeAuthenticatedRequirement + { + IsAuthenticated= _sessionUserService.IsAuthenticated + }); + + UseRequirement(new MustBeInRolesRequirement + { + RequiredRoles = [IdentityRole.Administrator], + UserRoles = _sessionUserService.Roles + }); + } +} diff --git a/src/Application/Routes/Queries/GetRoutesPage/GetRoutesPageQueryHandler.cs b/src/Application/Routes/Queries/GetRoutesPage/GetRoutesPageQueryHandler.cs new file mode 100644 index 0000000..15e3304 --- /dev/null +++ b/src/Application/Routes/Queries/GetRoutesPage/GetRoutesPageQueryHandler.cs @@ -0,0 +1,74 @@ +using MediatR; +using cuqmbr.TravelGuide.Application.Common.Interfaces.Persistence; +using AutoMapper; +using cuqmbr.TravelGuide.Application.Common.Models; +using cuqmbr.TravelGuide.Application.Common.Extensions; +using cuqmbr.TravelGuide.Domain.Entities; + +namespace cuqmbr.TravelGuide.Application.Routes.Queries.GetRoutesPage; + +public class GetRoutesPageQueryHandler : + IRequestHandler> +{ + private readonly UnitOfWork _unitOfWork; + private readonly IMapper _mapper; + + public GetRoutesPageQueryHandler( + UnitOfWork unitOfWork, + IMapper mapper) + { + _unitOfWork = unitOfWork; + _mapper = mapper; + } + + public async Task> Handle( + GetRoutesPageQuery request, + CancellationToken cancellationToken) + { + var paginatedList = await _unitOfWork.RouteRepository.GetPageAsync( + e => + e.Name.ToLower().Contains(request.Search.ToLower()) && + (request.VehicleType != null + ? e.VehicleType == request.VehicleType + : true), + e => e.RouteAddresses, + request.PageNumber, request.PageSize, + cancellationToken); + + foreach (var route in paginatedList.Items) + { + // TODO: Find a way to include through lists + var addresses = await _unitOfWork.AddressRepository.GetPageAsync( + e => route.RouteAddresses.Select(ra => ra.AddressId).Contains(e.Id), + e => e.City.Region.Country, + 1, route.RouteAddresses.Count, cancellationToken); + + route.RouteAddresses = route.RouteAddresses.Select( + e => new RouteAddress() + { + Id = e.Id, + Guid = e.Guid, + Order = e.Order, + RouteId = e.RouteId, + Route = e.Route, + AddressId = e.AddressId, + Address = addresses.Items.First(a => a.Id == e.AddressId) + }) + .OrderBy(e => e.Order) + .ToArray(); + } + + 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/Routes/Queries/GetRoutesPage/GetRoutesPageQueryValidator.cs b/src/Application/Routes/Queries/GetRoutesPage/GetRoutesPageQueryValidator.cs new file mode 100644 index 0000000..3ad2ba7 --- /dev/null +++ b/src/Application/Routes/Queries/GetRoutesPage/GetRoutesPageQueryValidator.cs @@ -0,0 +1,44 @@ +using cuqmbr.TravelGuide.Application.Common.Interfaces.Services; +using cuqmbr.TravelGuide.Domain.Enums; +using FluentValidation; +using Microsoft.Extensions.Localization; + +namespace cuqmbr.TravelGuide.Application.Routes.Queries.GetRoutesPage; + +public class GetRoutesPageQueryValidator : AbstractValidator +{ + public GetRoutesPageQueryValidator( + 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/Routes/RouteAddressDto.cs b/src/Application/Routes/RouteAddressDto.cs new file mode 100644 index 0000000..13288f0 --- /dev/null +++ b/src/Application/Routes/RouteAddressDto.cs @@ -0,0 +1,70 @@ +using cuqmbr.TravelGuide.Application.Common.Mappings; +using cuqmbr.TravelGuide.Domain.Entities; + +namespace cuqmbr.TravelGuide.Application.Routes; + +public sealed class RouteAddressDto : IMapFrom +{ + public short Order { get; set; } + + + public Guid Uuid { get; set; } + + public string Name { get; set; } + + public double Longitude { get; set; } + + public double Latitude { get; set; } + + public string VehicleType { 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.Address.Guid)) + .ForMember( + d => d.Name, + opt => opt.MapFrom(s => s.Address.Name)) + .ForMember( + d => d.Longitude, + opt => opt.MapFrom(s => s.Address.Longitude)) + .ForMember( + d => d.Latitude, + opt => opt.MapFrom(s => s.Address.Latitude)) + .ForMember( + d => d.VehicleType, + 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/Routes/RouteDto.cs b/src/Application/Routes/RouteDto.cs new file mode 100644 index 0000000..8b97da9 --- /dev/null +++ b/src/Application/Routes/RouteDto.cs @@ -0,0 +1,29 @@ +using cuqmbr.TravelGuide.Application.Common.Mappings; +using cuqmbr.TravelGuide.Domain.Entities; + +namespace cuqmbr.TravelGuide.Application.Routes; + +public sealed class RouteDto : IMapFrom +{ + public Guid Uuid { get; set; } + + public string Name { get; set; } + + public string VehicleType { get; set; } + + public ICollection Addresses { get; set; } + + public void Mapping(MappingProfile profile) + { + profile.CreateMap() + .ForMember( + d => d.Uuid, + opt => opt.MapFrom(s => s.Guid)) + .ForMember( + d => d.VehicleType, + opt => opt.MapFrom(s => s.VehicleType.Name)) + .ForMember( + d => d.Addresses, + opt => opt.MapFrom(s => s.RouteAddresses)); + } +} diff --git a/src/Application/Routes/ViewModels/AddRouteViewModel.cs b/src/Application/Routes/ViewModels/AddRouteViewModel.cs new file mode 100644 index 0000000..1370a5c --- /dev/null +++ b/src/Application/Routes/ViewModels/AddRouteViewModel.cs @@ -0,0 +1,10 @@ +namespace cuqmbr.TravelGuide.Application.Routes.ViewModels; + +public sealed class AddRouteViewModel +{ + public string Name { get; set; } + + public string VehicleType { get; set; } + + public ICollection Addresses { get; set; } +} diff --git a/src/Application/Routes/ViewModels/GetRoutesPageFilterViewModel.cs b/src/Application/Routes/ViewModels/GetRoutesPageFilterViewModel.cs new file mode 100644 index 0000000..d3e3a4a --- /dev/null +++ b/src/Application/Routes/ViewModels/GetRoutesPageFilterViewModel.cs @@ -0,0 +1,6 @@ +namespace cuqmbr.TravelGuide.Application.Routes.ViewModels; + +public sealed class GetRoutesPageFilterViewModel +{ + public string? VehicleType { get; set; } +} diff --git a/src/Application/Routes/ViewModels/RouteAddressViewModel.cs b/src/Application/Routes/ViewModels/RouteAddressViewModel.cs new file mode 100644 index 0000000..922665d --- /dev/null +++ b/src/Application/Routes/ViewModels/RouteAddressViewModel.cs @@ -0,0 +1,8 @@ +namespace cuqmbr.TravelGuide.Application.Routes.ViewModels; + +public sealed class RouteAddressViewModel +{ + public short Order { get; set; } + + public Guid Uuid { get; set; } +} diff --git a/src/Application/Routes/ViewModels/UpdateRouteViewModel.cs b/src/Application/Routes/ViewModels/UpdateRouteViewModel.cs new file mode 100644 index 0000000..5245b17 --- /dev/null +++ b/src/Application/Routes/ViewModels/UpdateRouteViewModel.cs @@ -0,0 +1,10 @@ +namespace cuqmbr.TravelGuide.Application.Routes.ViewModels; + +public sealed class UpdateRouteViewModel +{ + public string Name { get; set; } + + public string VehicleType { get; set; } + + public ICollection Addresses { get; set; } +} diff --git a/src/Domain/Entities/Address.cs b/src/Domain/Entities/Address.cs index 1c00718..5d32156 100644 --- a/src/Domain/Entities/Address.cs +++ b/src/Domain/Entities/Address.cs @@ -19,5 +19,5 @@ public sealed class Address : EntityBase public City City { get; set; } - // public ICollection AddressRoutes { get; set; } + public ICollection AddressRoutes { get; set; } } diff --git a/src/Domain/Entities/Route.cs b/src/Domain/Entities/Route.cs index 8199c35..f947c43 100644 --- a/src/Domain/Entities/Route.cs +++ b/src/Domain/Entities/Route.cs @@ -6,9 +6,8 @@ public sealed class Route : EntityBase { public string Name { get; set; } - // public VehicleType VehicleType { get; set; } + public VehicleType VehicleType { get; set; } public ICollection RouteAddresses { get; set; } } - diff --git a/src/HttpApi/Controllers/RoutesController.cs b/src/HttpApi/Controllers/RoutesController.cs new file mode 100644 index 0000000..1735a76 --- /dev/null +++ b/src/HttpApi/Controllers/RoutesController.cs @@ -0,0 +1,198 @@ +using Microsoft.AspNetCore.Mvc; +using Swashbuckle.AspNetCore.Annotations; +using cuqmbr.TravelGuide.Application.Common.Models; +using cuqmbr.TravelGuide.Application.Common.ViewModels; +using cuqmbr.TravelGuide.Domain.Enums; +using cuqmbr.TravelGuide.Application.Routes; +using cuqmbr.TravelGuide.Application.Routes.Commands.AddRoute; +using cuqmbr.TravelGuide.Application.Routes.Queries.GetRoutesPage; +using cuqmbr.TravelGuide.Application.Routes.Queries.GetRoute; +using cuqmbr.TravelGuide.Application.Routes.Commands.UpdateRoute; +using cuqmbr.TravelGuide.Application.Routes.Commands.DeleteRoute; +using cuqmbr.TravelGuide.Application.Routes.ViewModels; +using cuqmbr.TravelGuide.Application.Routes.Models; + +namespace cuqmbr.TravelGuide.HttpApi.Controllers; + +[Route("routes")] +public class RoutesController : ControllerBase +{ + [HttpPost] + [SwaggerOperation("Add a route")] + [SwaggerResponse( + StatusCodes.Status201Created, "Object successfuly created", + typeof(RouteDto))] + [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, "One or more addresses was not found", + typeof(ProblemDetails))] + [SwaggerResponse( + StatusCodes.Status500InternalServerError, "Internal server error", + typeof(ProblemDetails))] + public async Task> Add( + [FromBody] AddRouteViewModel viewModel, + CancellationToken cancellationToken) + { + return StatusCode( + StatusCodes.Status201Created, + await Mediator.Send( + new AddRouteCommand() + { + Name = viewModel.Name, + VehicleType = VehicleType.FromName(viewModel.VehicleType), + Addresses = viewModel.Addresses.Select( + e => new RouteAddressModel() + { + Order = e.Order, + Guid = e.Uuid + + }).ToArray() + }, + cancellationToken)); + } + + [HttpGet] + [SwaggerOperation("Get a list of all routes")] + [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] GetRoutesPageFilterViewModel filterQuery, + CancellationToken cancellationToken) + { + return await Mediator.Send( + new GetRoutesPageQuery() + { + PageNumber = pageQuery.PageNumber, + PageSize = pageQuery.PageSize, + Search = searchQuery.Search, + Sort = sortQuery.Sort, + VehicleType = VehicleType.FromName(filterQuery.VehicleType) + }, + cancellationToken); + } + + [HttpGet("{uuid:guid}")] + [SwaggerOperation("Get a route by uuid")] + [SwaggerResponse( + StatusCodes.Status200OK, "Request successful", typeof(RouteDto))] + [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(RouteDto))] + [SwaggerResponse( + StatusCodes.Status500InternalServerError, "Internal server error", + typeof(ProblemDetails))] + public async Task Get( + [FromRoute] Guid uuid, + CancellationToken cancellationToken) + { + return await Mediator.Send(new GetRouteQuery() { Guid = uuid }, + cancellationToken); + } + + [HttpPut("{uuid:guid}")] + [SwaggerOperation("Update a route")] + [SwaggerResponse( + StatusCodes.Status400BadRequest, "Object already exists", + typeof(ProblemDetails))] + [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(RouteDto))] + [SwaggerResponse( + StatusCodes.Status404NotFound, "One or more addresses was not found", + typeof(ProblemDetails))] + [SwaggerResponse( + StatusCodes.Status500InternalServerError, "Internal server error", + typeof(ProblemDetails))] + public async Task Update( + [FromRoute] Guid uuid, + [FromBody] UpdateRouteViewModel viewModel, + CancellationToken cancellationToken) + { + return await Mediator.Send( + new UpdateRouteCommand() + { + Guid = uuid, + Name = viewModel.Name, + VehicleType = VehicleType.FromName(viewModel.VehicleType), + Addresses = viewModel.Addresses.Select( + e => new RouteAddressModel() + { + Order = e.Order, + Guid = e.Uuid + + }).ToArray() + }, + cancellationToken); + } + + [HttpDelete("{uuid:guid}")] + [SwaggerOperation("Delete a route")] + [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(RouteDto))] + [SwaggerResponse( + StatusCodes.Status500InternalServerError, "Internal server error", + typeof(ProblemDetails))] + public async Task Delete( + [FromRoute] Guid uuid, + CancellationToken cancellationToken) + { + await Mediator.Send( + new DeleteRouteCommand() { Guid = uuid }, + cancellationToken); + return StatusCode(StatusCodes.Status204NoContent); + } +} diff --git a/src/HttpApi/Middlewares/GlobalExceptionHandlerMiddleware.cs b/src/HttpApi/Middlewares/GlobalExceptionHandlerMiddleware.cs index 39c0018..6e303b5 100644 --- a/src/HttpApi/Middlewares/GlobalExceptionHandlerMiddleware.cs +++ b/src/HttpApi/Middlewares/GlobalExceptionHandlerMiddleware.cs @@ -1,7 +1,7 @@ using cuqmbr.TravelGuide.Application.Common.Exceptions; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Localization; -using System.Reflection; +using System.Diagnostics; namespace cuqmbr.TravelGuide.HttpApi.Middlewares; @@ -11,6 +11,8 @@ public class GlobalExceptionHandlerMiddleware : IMiddleware private readonly ILogger _logger; private readonly IStringLocalizer _localizer; + + public GlobalExceptionHandlerMiddleware( ILogger logger, IStringLocalizer localizer) @@ -75,7 +77,7 @@ public class GlobalExceptionHandlerMiddleware : IMiddleware context.Response.StatusCode = StatusCodes.Status400BadRequest; - await context.Response.WriteAsJsonAsync(new HttpValidationProblemDetails(ex.Errors) + await context.Response.WriteAsJsonAsync(new HttpValidationProblemDetailsWithTraceId(ex.Errors) { Status = StatusCodes.Status400BadRequest, Type = "https://datatracker.ietf.org/doc/html/rfc7231#section-6.5.1", @@ -90,7 +92,7 @@ public class GlobalExceptionHandlerMiddleware : IMiddleware { context.Response.StatusCode = StatusCodes.Status401Unauthorized; - await context.Response.WriteAsJsonAsync(new ProblemDetails() + await context.Response.WriteAsJsonAsync(new ProblemDetailsWithTraceId() { Status = StatusCodes.Status401Unauthorized, Type = "https://datatracker.ietf.org/doc/html/rfc7235#section-3.1", @@ -105,7 +107,7 @@ public class GlobalExceptionHandlerMiddleware : IMiddleware { context.Response.StatusCode = StatusCodes.Status400BadRequest; - await context.Response.WriteAsJsonAsync(new ProblemDetails() + await context.Response.WriteAsJsonAsync(new ProblemDetailsWithTraceId() { Status = StatusCodes.Status400BadRequest, Type = "https://datatracker.ietf.org/doc/html/rfc7231#section-6.5.1", @@ -120,7 +122,7 @@ public class GlobalExceptionHandlerMiddleware : IMiddleware { context.Response.StatusCode = StatusCodes.Status400BadRequest; - await context.Response.WriteAsJsonAsync(new ProblemDetails() + await context.Response.WriteAsJsonAsync(new ProblemDetailsWithTraceId() { Status = StatusCodes.Status400BadRequest, Type = "https://datatracker.ietf.org/doc/html/rfc7231#section-6.5.1", @@ -135,7 +137,7 @@ public class GlobalExceptionHandlerMiddleware : IMiddleware { context.Response.StatusCode = StatusCodes.Status400BadRequest; - await context.Response.WriteAsJsonAsync(new ProblemDetails() + await context.Response.WriteAsJsonAsync(new ProblemDetailsWithTraceId() { Status = StatusCodes.Status400BadRequest, Type = "https://datatracker.ietf.org/doc/html/rfc7231#section-6.5.1", @@ -150,7 +152,7 @@ public class GlobalExceptionHandlerMiddleware : IMiddleware { context.Response.StatusCode = StatusCodes.Status403Forbidden; - await context.Response.WriteAsJsonAsync(new ProblemDetails() + await context.Response.WriteAsJsonAsync(new ProblemDetailsWithTraceId() { Status = StatusCodes.Status403Forbidden, Type = "https://datatracker.ietf.org/doc/html/rfc7231#section-6.5.3", @@ -165,7 +167,7 @@ public class GlobalExceptionHandlerMiddleware : IMiddleware { context.Response.StatusCode = StatusCodes.Status400BadRequest; - await context.Response.WriteAsJsonAsync(new ProblemDetails() + await context.Response.WriteAsJsonAsync(new ProblemDetailsWithTraceId() { Status = StatusCodes.Status400BadRequest, Type = "https://datatracker.ietf.org/doc/html/rfc7231#section-6.5.1", @@ -180,7 +182,7 @@ public class GlobalExceptionHandlerMiddleware : IMiddleware { context.Response.StatusCode = StatusCodes.Status404NotFound; - await context.Response.WriteAsJsonAsync(new ProblemDetails() + await context.Response.WriteAsJsonAsync(new ProblemDetailsWithTraceId() { Status = StatusCodes.Status404NotFound, Type = "https://datatracker.ietf.org/doc/html/rfc7231#section-6.5.4", @@ -193,7 +195,7 @@ public class GlobalExceptionHandlerMiddleware : IMiddleware { context.Response.StatusCode = StatusCodes.Status500InternalServerError; - await context.Response.WriteAsJsonAsync(new ProblemDetails() + await context.Response.WriteAsJsonAsync(new ProblemDetailsWithTraceId() { Status = StatusCodes.Status500InternalServerError, Type = "https://datatracker.ietf.org/doc/html/rfc7231#section-6.6.1", @@ -202,9 +204,26 @@ public class GlobalExceptionHandlerMiddleware : IMiddleware }); } - // class ProblemDetailsWithTraceId : ProblemDetails - // { - // public string TraceId { get; init; } = Activity.Current?.TraceId.ToString(); - // } -} + class ProblemDetailsWithTraceId : ProblemDetails + { + public ProblemDetailsWithTraceId() + { + Extensions = new Dictionary() + { + ["traceId"] = Activity.Current.Id + }; + } + } + class HttpValidationProblemDetailsWithTraceId : HttpValidationProblemDetails + { + public HttpValidationProblemDetailsWithTraceId( + IDictionary errors) : base(errors) + { + Extensions = new Dictionary() + { + ["traceId"] = Activity.Current.Id + }; + } + } +} diff --git a/src/Persistence/InMemory/InMemoryUnitOfWork.cs b/src/Persistence/InMemory/InMemoryUnitOfWork.cs index 0f10021..b719aad 100644 --- a/src/Persistence/InMemory/InMemoryUnitOfWork.cs +++ b/src/Persistence/InMemory/InMemoryUnitOfWork.cs @@ -16,6 +16,8 @@ public sealed class InMemoryUnitOfWork : UnitOfWork CountryRepository = new InMemoryCountryRepository(_dbContext); RegionRepository = new InMemoryRegionRepository(_dbContext); CityRepository = new InMemoryCityRepository(_dbContext); + AddressRepository = new InMemoryAddressRepository(_dbContext); + RouteRepository = new InMemoryRouteRepository(_dbContext); } public CountryRepository CountryRepository { get; init; } @@ -26,6 +28,8 @@ public sealed class InMemoryUnitOfWork : UnitOfWork public AddressRepository AddressRepository { get; init; } + public RouteRepository RouteRepository { get; init; } + public int Save() { return _dbContext.SaveChanges(); diff --git a/src/Persistence/InMemory/Repositories/InMemoryRouteRepository.cs b/src/Persistence/InMemory/Repositories/InMemoryRouteRepository.cs new file mode 100644 index 0000000..f728978 --- /dev/null +++ b/src/Persistence/InMemory/Repositories/InMemoryRouteRepository.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 InMemoryRouteRepository : + InMemoryBaseRepository, RouteRepository +{ + public InMemoryRouteRepository(InMemoryDbContext dbContext) + : base(dbContext) { } +} diff --git a/src/Persistence/PostgreSql/Configurations/AddressConfiguration.cs b/src/Persistence/PostgreSql/Configurations/AddressConfiguration.cs index 7d7d53b..a7c96f2 100644 --- a/src/Persistence/PostgreSql/Configurations/AddressConfiguration.cs +++ b/src/Persistence/PostgreSql/Configurations/AddressConfiguration.cs @@ -18,7 +18,7 @@ public class AddressConfiguration : BaseConfiguration
builder .ToTable( "addresses", - b => b.HasCheckConstraint( + a => a.HasCheckConstraint( "ck_" + $"{builder.Metadata.GetTableName()}_" + $"{builder.Property(a => a.VehicleType) diff --git a/src/Persistence/PostgreSql/Configurations/RouteAddressConfiguration.cs b/src/Persistence/PostgreSql/Configurations/RouteAddressConfiguration.cs new file mode 100644 index 0000000..9d44a61 --- /dev/null +++ b/src/Persistence/PostgreSql/Configurations/RouteAddressConfiguration.cs @@ -0,0 +1,81 @@ +using cuqmbr.TravelGuide.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace cuqmbr.TravelGuide.Persistence.PostgreSql.Configurations; + +public class RouteAddressConfiguration : BaseConfiguration +{ + public override void Configure(EntityTypeBuilder builder) + { + builder + .ToTable("route_addresses"); + + base.Configure(builder); + + + builder + .Property(ra => ra.Order) + .HasColumnName("order") + .HasColumnType("smallint") + .IsRequired(true); + + + builder + .Property(ra => ra.AddressId) + .HasColumnName("address_id") + .HasColumnType("bigint") + .IsRequired(true); + + builder + .HasOne(ra => ra.Address) + .WithMany(a => a.AddressRoutes) + .HasForeignKey(ra => ra.AddressId) + .HasConstraintName( + "fk_" + + $"{builder.Metadata.GetTableName()}_" + + $"{builder.Property(ra => ra.AddressId).Metadata.GetColumnName()}") + .OnDelete(DeleteBehavior.Cascade); + + builder + .HasIndex(ra => ra.AddressId) + .HasDatabaseName( + "ix_" + + $"{builder.Metadata.GetTableName()}_" + + $"{builder.Property(ra => ra.AddressId).Metadata.GetColumnName()}"); + + + builder + .Property(ra => ra.RouteId) + .HasColumnName("route_id") + .HasColumnType("bigint") + .IsRequired(true); + + builder + .HasOne(ra => ra.Route) + .WithMany(a => a.RouteAddresses) + .HasForeignKey(ra => ra.RouteId) + .HasConstraintName( + "fk_" + + $"{builder.Metadata.GetTableName()}_" + + $"{builder.Property(ra => ra.RouteId).Metadata.GetColumnName()}") + .OnDelete(DeleteBehavior.Cascade); + + builder + .HasIndex(ra => ra.RouteId) + .HasDatabaseName( + "ix_" + + $"{builder.Metadata.GetTableName()}_" + + $"{builder.Property(ra => ra.RouteId).Metadata.GetColumnName()}"); + + + builder + .HasAlternateKey(ra => new { ra.AddressId, ra.RouteId, ra.Order }) + .HasName( + "altk_" + + $"{builder.Metadata.GetTableName()}_" + + $"{builder.Property(ra => ra.AddressId).Metadata.GetColumnName()}_" + + $"{builder.Property(ra => ra.RouteId).Metadata.GetColumnName()}_" + + $"{builder.Property(ra => ra.Order).Metadata.GetColumnName()}"); + } +} diff --git a/src/Persistence/PostgreSql/Configurations/RouteConfiguration.cs b/src/Persistence/PostgreSql/Configurations/RouteConfiguration.cs new file mode 100644 index 0000000..de9177a --- /dev/null +++ b/src/Persistence/PostgreSql/Configurations/RouteConfiguration.cs @@ -0,0 +1,40 @@ +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 RouteConfiguration : BaseConfiguration +{ + public override void Configure(EntityTypeBuilder builder) + { + builder + .Property(r => r.VehicleType) + .HasColumnName("vehicle_type") + .HasColumnType("varchar(16)") + .IsRequired(true); + + builder + .ToTable( + "routes", + r => r.HasCheckConstraint( + "ck_" + + $"{builder.Metadata.GetTableName()}_" + + $"{builder.Property(a => a.VehicleType) + .Metadata.GetColumnName()}", + $"{builder.Property(a => a.VehicleType) + .Metadata.GetColumnName()} IN ('{String + .Join("', '", VehicleType.Enumerations + .Values.Select(v => v.Name))}')")); + + base.Configure(builder); + + + builder + .Property(r => r.Name) + .HasColumnName("name") + .HasColumnType("varchar(64)") + .IsRequired(true); + } +} diff --git a/src/Persistence/PostgreSql/Migrations/20250501112816_Add_Route_and_RouteAddresses.Designer.cs b/src/Persistence/PostgreSql/Migrations/20250501112816_Add_Route_and_RouteAddresses.Designer.cs new file mode 100644 index 0000000..3e5d1c4 --- /dev/null +++ b/src/Persistence/PostgreSql/Migrations/20250501112816_Add_Route_and_RouteAddresses.Designer.cs @@ -0,0 +1,400 @@ +// +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("20250501112816_Add_Route_and_RouteAddresses")] + partial class Add_Route_and_RouteAddresses + { + /// + 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_addresses_id_sequence"); + + modelBuilder.HasSequence("routes_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_Guid"); + + b.HasIndex("CityId") + .HasDatabaseName("ix_addresses_city_id"); + + b.HasIndex("Guid") + .IsUnique() + .HasDatabaseName("ix_addresses_uuid"); + + b.HasIndex("Id") + .IsUnique() + .HasDatabaseName("ix_addresses_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_Guid"); + + b.HasIndex("Guid") + .IsUnique() + .HasDatabaseName("ix_cities_uuid"); + + b.HasIndex("Id") + .IsUnique() + .HasDatabaseName("ix_cities_id"); + + 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_Guid"); + + b.HasIndex("Guid") + .IsUnique() + .HasDatabaseName("ix_countries_uuid"); + + b.HasIndex("Id") + .IsUnique() + .HasDatabaseName("ix_countries_id"); + + 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_Guid"); + + b.HasIndex("CountryId") + .HasDatabaseName("ix_regions_country_id"); + + b.HasIndex("Guid") + .IsUnique() + .HasDatabaseName("ix_regions_uuid"); + + b.HasIndex("Id") + .IsUnique() + .HasDatabaseName("ix_regions_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_Guid"); + + b.HasIndex("Guid") + .IsUnique() + .HasDatabaseName("ix_routes_uuid"); + + b.HasIndex("Id") + .IsUnique() + .HasDatabaseName("ix_routes_id"); + + 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_Guid"); + + 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("Guid") + .IsUnique() + .HasDatabaseName("ix_route_addresses_uuid"); + + b.HasIndex("Id") + .IsUnique() + .HasDatabaseName("ix_route_addresses_id"); + + b.HasIndex("RouteId") + .HasDatabaseName("ix_route_addresses_route_id"); + + b.ToTable("route_addresses", "application"); + }); + + 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.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"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Persistence/PostgreSql/Migrations/20250501112816_Add_Route_and_RouteAddresses.cs b/src/Persistence/PostgreSql/Migrations/20250501112816_Add_Route_and_RouteAddresses.cs new file mode 100644 index 0000000..2908cf2 --- /dev/null +++ b/src/Persistence/PostgreSql/Migrations/20250501112816_Add_Route_and_RouteAddresses.cs @@ -0,0 +1,132 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Persistence.PostgreSql.Migrations +{ + /// + public partial class Add_Route_and_RouteAddresses : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateSequence( + name: "route_addresses_id_sequence", + schema: "application"); + + migrationBuilder.CreateSequence( + name: "routes_id_sequence", + schema: "application"); + + migrationBuilder.CreateTable( + name: "routes", + schema: "application", + columns: table => new + { + id = table.Column(type: "bigint", nullable: false, defaultValueSql: "nextval('application.routes_id_sequence')"), + name = table.Column(type: "varchar(64)", nullable: false), + vehicle_type = table.Column(type: "varchar(16)", nullable: false), + uuid = table.Column(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_routes", x => x.id); + table.UniqueConstraint("altk_routes_Guid", x => x.uuid); + table.CheckConstraint("ck_routes_vehicle_type", "vehicle_type IN ('bus', 'train', 'aircraft')"); + }); + + migrationBuilder.CreateTable( + name: "route_addresses", + schema: "application", + columns: table => new + { + id = table.Column(type: "bigint", nullable: false, defaultValueSql: "nextval('application.route_addresses_id_sequence')"), + order = table.Column(type: "smallint", nullable: false), + address_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_route_addresses", x => x.id); + table.UniqueConstraint("altk_route_addresses_address_id_route_id_order", x => new { x.address_id, x.route_id, x.order }); + table.UniqueConstraint("altk_route_addresses_Guid", x => x.uuid); + table.ForeignKey( + name: "fk_route_addresses_address_id", + column: x => x.address_id, + principalSchema: "application", + principalTable: "addresses", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "fk_route_addresses_route_id", + column: x => x.route_id, + principalSchema: "application", + principalTable: "routes", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "ix_route_addresses_address_id", + schema: "application", + table: "route_addresses", + column: "address_id"); + + migrationBuilder.CreateIndex( + name: "ix_route_addresses_id", + schema: "application", + table: "route_addresses", + column: "id", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_route_addresses_route_id", + schema: "application", + table: "route_addresses", + column: "route_id"); + + migrationBuilder.CreateIndex( + name: "ix_route_addresses_uuid", + schema: "application", + table: "route_addresses", + column: "uuid", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_routes_id", + schema: "application", + table: "routes", + column: "id", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_routes_uuid", + schema: "application", + table: "routes", + column: "uuid", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "route_addresses", + schema: "application"); + + migrationBuilder.DropTable( + name: "routes", + schema: "application"); + + migrationBuilder.DropSequence( + name: "route_addresses_id_sequence", + schema: "application"); + + migrationBuilder.DropSequence( + name: "routes_id_sequence", + schema: "application"); + } + } +} diff --git a/src/Persistence/PostgreSql/Migrations/PostgreSqlDbContextModelSnapshot.cs b/src/Persistence/PostgreSql/Migrations/PostgreSqlDbContextModelSnapshot.cs index d8602a8..f76eb3e 100644 --- a/src/Persistence/PostgreSql/Migrations/PostgreSqlDbContextModelSnapshot.cs +++ b/src/Persistence/PostgreSql/Migrations/PostgreSqlDbContextModelSnapshot.cs @@ -31,6 +31,10 @@ namespace Persistence.PostgreSql.Migrations modelBuilder.HasSequence("regions_id_sequence"); + modelBuilder.HasSequence("route_addresses_id_sequence"); + + modelBuilder.HasSequence("routes_id_sequence"); + modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.Address", b => { b.Property("Id") @@ -210,6 +214,102 @@ namespace Persistence.PostgreSql.Migrations 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_Guid"); + + b.HasIndex("Guid") + .IsUnique() + .HasDatabaseName("ix_routes_uuid"); + + b.HasIndex("Id") + .IsUnique() + .HasDatabaseName("ix_routes_id"); + + 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_Guid"); + + 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("Guid") + .IsUnique() + .HasDatabaseName("ix_route_addresses_uuid"); + + b.HasIndex("Id") + .IsUnique() + .HasDatabaseName("ix_route_addresses_id"); + + b.HasIndex("RouteId") + .HasDatabaseName("ix_route_addresses_route_id"); + + b.ToTable("route_addresses", "application"); + }); + modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.Address", b => { b.HasOne("cuqmbr.TravelGuide.Domain.Entities.City", "City") @@ -246,6 +346,32 @@ namespace Persistence.PostgreSql.Migrations 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.Address", b => + { + b.Navigation("AddressRoutes"); + }); + modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.City", b => { b.Navigation("Addresses"); @@ -260,6 +386,11 @@ namespace Persistence.PostgreSql.Migrations { b.Navigation("Cities"); }); + + modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.Route", b => + { + b.Navigation("RouteAddresses"); + }); #pragma warning restore 612, 618 } } diff --git a/src/Persistence/PostgreSql/PostgreSqlDbContext.cs b/src/Persistence/PostgreSql/PostgreSqlDbContext.cs index 009b9a6..43195c1 100644 --- a/src/Persistence/PostgreSql/PostgreSqlDbContext.cs +++ b/src/Persistence/PostgreSql/PostgreSqlDbContext.cs @@ -1,8 +1,8 @@ using System.Reflection; using cuqmbr.TravelGuide.Domain.Enums; using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Microsoft.Extensions.Options; +using cuqmbr.TravelGuide.Persistence.PostgreSql.TypeConverters; namespace cuqmbr.TravelGuide.Persistence.PostgreSql; @@ -41,12 +41,3 @@ public class PostgreSqlDbContext : DbContext .HaveConversion(); } } - -public class VehicleTypeConverter : ValueConverter -{ - public VehicleTypeConverter() - : base( - v => v.Name, - v => VehicleType.FromName(v)) - { } -} diff --git a/src/Persistence/PostgreSql/PostgreSqlUnitOfWork.cs b/src/Persistence/PostgreSql/PostgreSqlUnitOfWork.cs index bd97758..a2d696f 100644 --- a/src/Persistence/PostgreSql/PostgreSqlUnitOfWork.cs +++ b/src/Persistence/PostgreSql/PostgreSqlUnitOfWork.cs @@ -17,6 +17,7 @@ public sealed class PostgreSqlUnitOfWork : UnitOfWork RegionRepository = new PostgreSqlRegionRepository(_dbContext); CityRepository = new PostgreSqlCityRepository(_dbContext); AddressRepository = new PostgreSqlAddressRepository(_dbContext); + RouteRepository = new PostgreSqlRouteRepository(_dbContext); } public CountryRepository CountryRepository { get; init; } @@ -27,6 +28,8 @@ public sealed class PostgreSqlUnitOfWork : UnitOfWork public AddressRepository AddressRepository { get; init; } + public RouteRepository RouteRepository { get; init; } + public int Save() { return _dbContext.SaveChanges(); diff --git a/src/Persistence/PostgreSql/Repositories/PostgreSqlRouteRepository.cs b/src/Persistence/PostgreSql/Repositories/PostgreSqlRouteRepository.cs new file mode 100644 index 0000000..1af92ff --- /dev/null +++ b/src/Persistence/PostgreSql/Repositories/PostgreSqlRouteRepository.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 PostgreSqlRouteRepository : + PostgreSqlBaseRepository, RouteRepository +{ + public PostgreSqlRouteRepository(PostgreSqlDbContext dbContext) + : base(dbContext) { } +} diff --git a/src/Persistence/PostgreSql/TypeConverters/VehicleTypeConverter.cs b/src/Persistence/PostgreSql/TypeConverters/VehicleTypeConverter.cs new file mode 100644 index 0000000..50f5c9c --- /dev/null +++ b/src/Persistence/PostgreSql/TypeConverters/VehicleTypeConverter.cs @@ -0,0 +1,13 @@ +using cuqmbr.TravelGuide.Domain.Enums; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace cuqmbr.TravelGuide.Persistence.PostgreSql.TypeConverters; + +public class VehicleTypeConverter : ValueConverter +{ + public VehicleTypeConverter() + : base( + v => v.Name, + v => VehicleType.FromName(v)) + { } +}