diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..47565c6 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,17 @@ +**/bin/ +**/obj/ +**/.vs/ +**/.vscode/ +**/*.user +**/*.suo +**/*.pdb +**/*.db +**/*.log +**/node_modules/ +Dockerfile* +docker-compose* +.env +.git +.gitignore +README.md +/vsdbg \ No newline at end of file diff --git a/.env b/.env new file mode 100644 index 0000000..88aaa35 --- /dev/null +++ b/.env @@ -0,0 +1,5 @@ +MSSQL_SA_PASSWORD=YourStrong!Passw0rd +MONGO_INITDB_ROOT_USERNAME=admin +MONGO_INITDB_ROOT_PASSWORD=secret +RABBITMQ_DEFAULT_USER=guest +RABBITMQ_DEFAULT_PASS=guest \ No newline at end of file diff --git a/.github/workflows/ci-pipeline.yml b/.github/workflows/ci-pipeline.yml new file mode 100644 index 0000000..c8c5052 --- /dev/null +++ b/.github/workflows/ci-pipeline.yml @@ -0,0 +1,48 @@ +name: Parallel Docker Builds with Cache + +on: + push: + branches: [develop, master] + pull_request: + branches: [develop, master] + +jobs: + build-docker: + name: "Build docker images" + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - dockerfile: OrderTrackingApp.Api/Dockerfile + image-name: ordertrackingapp-api + - dockerfile: OrderTrackingApp.Consumer/Dockerfile + image-name: ordertrackingapp-consumer + - dockerfile: OrderTrackingApp.Blazor.Server/Dockerfile + image-name: ordertrackingapp-blazor-server + - dockerfile: OrderTrackingApp.MigrationRunner/Dockerfile + image-name: ordertrackingapp-migration-runner + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Cache Docker layers + uses: actions/cache@v3 + with: + path: /tmp/.buildx-cache + key: ${{ runner.os }}-buildx-${{ matrix.image-name }} + restore-keys: | + ${{ runner.os }}-buildx- + + - name: Build Docker image with cache + run: | + docker buildx build \ + --cache-from=type=local,src=/tmp/.buildx-cache \ + --cache-to=type=local,dest=/tmp/.buildx-cache \ + -f ${{ matrix.dockerfile }} \ + -t ${{ matrix.image-name }} \ + --load . diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..992dcd4 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,12 @@ +{ + "version": "0.2.0", + "configurations": [ + + { + "name": ".NET Core Attach", + "type": "coreclr", + "request": "attach", + "processId": "${command:pickProcess}" + } + ] +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..b1c79f6 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,12 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "type": "dotnet", + "task": "build", + "group": "build", + "problemMatcher": [], + "label": "dotnet: build" + } + ] +} \ No newline at end of file diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..c1bc24b --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,36 @@ + + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + diff --git a/OrderTrackingApp.Api/Controllers/OrdersController.cs b/OrderTrackingApp.Api/Controllers/OrdersController.cs new file mode 100644 index 0000000..978a006 --- /dev/null +++ b/OrderTrackingApp.Api/Controllers/OrdersController.cs @@ -0,0 +1,44 @@ +using AutoMapper; +using MediatR; +using Microsoft.AspNetCore.Mvc; +using OrderTrackingApp.Api.Examples; +using OrderTrackingApp.Api.Models; +using OrderTrackingApp.Application.Orders.Commands; +using OrderTrackingApp.Application.Orders.Queries; +using Swashbuckle.AspNetCore.Filters; + +namespace OrderTrackingApp.Api.Controllers +{ + [Route("api/[controller]")] + [ApiController] + public class OrdersController(IMediator mediator, IMapper mapper) : ControllerBase + { + [HttpPost] + + [SwaggerRequestExample(typeof(CreateOrderRequest), typeof(CreateOrderRequestExample))] + [ProducesResponseType(StatusCodes.Status201Created)] + public async Task CreateOrder([FromBody] CreateOrderRequest request) + { + var command = mapper.Map(request); + var orderId = await mediator.Send(command); + + return CreatedAtAction(nameof(CreateOrder), new { id = orderId }, new { OrderId = orderId }); + } + + [HttpGet("{id}")] + public async Task GetOrderById(Guid id) + { + var order = await mediator.Send(new GetOrderByIdQuery(id)); + return Ok(order); + } + + [HttpGet] + public async Task GetOrders([FromQuery] GetOrdersRequest request) + { + var query = mapper.Map(request); + + var orders = await mediator.Send(query); + return Ok(orders); + } + } +} diff --git a/OrderTrackingApp.Api/Dockerfile b/OrderTrackingApp.Api/Dockerfile new file mode 100644 index 0000000..85667b6 --- /dev/null +++ b/OrderTrackingApp.Api/Dockerfile @@ -0,0 +1,40 @@ +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base +WORKDIR /app +EXPOSE 8080 + +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src + +# Copy root package props first +COPY ["Directory.Packages.props", "."] + +COPY ["OrderTrackingApp.Api/OrderTrackingApp.Api.csproj", "OrderTrackingApp.Api/"] +COPY ["OrderTrackingApp.Application/OrderTrackingApp.Application.csproj", "OrderTrackingApp.Application/"] +COPY ["OrderTrackingApp.Application.Contracts/OrderTrackingApp.Application.Contracts.csproj", "OrderTrackingApp.Application.Contracts/"] +COPY ["OrderTrackingApp.Domain/OrderTrackingApp.Domain.csproj", "OrderTrackingApp.Domain/"] +COPY ["OrderTrackingApp.Infrastructure/OrderTrackingApp.Infrastructure.csproj", "OrderTrackingApp.Infrastructure/"] +COPY ["OrderTrackingApp.Persistence/OrderTrackingApp.Persistence.csproj", "OrderTrackingApp.Persistence/"] +COPY ["OrderTrackingApp.ReadPersistence/OrderTrackingApp.ReadPersistence.csproj", "OrderTrackingApp.ReadPersistence/"] + +# Copy your root certificate into the container +COPY nscacert.crt /usr/local/share/ca-certificates/nscacert.crt + +# Update the certificate store +RUN update-ca-certificates + +RUN dotnet restore "OrderTrackingApp.Api/OrderTrackingApp.Api.csproj" + +COPY . . + +RUN dotnet build "OrderTrackingApp.Api/OrderTrackingApp.Api.csproj" -c Release -o /app/build --no-restore + +FROM build AS publish +RUN dotnet publish "OrderTrackingApp.Api/OrderTrackingApp.Api.csproj" -c Release -o /app/publish --no-restore + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . + +ENV ASPNETCORE_URLS=http://0.0.0.0:8080 + +ENTRYPOINT ["dotnet", "OrderTrackingApp.Api.dll"] diff --git a/OrderTrackingApp.Api/Examples/CreateOrderRequestExample.cs b/OrderTrackingApp.Api/Examples/CreateOrderRequestExample.cs new file mode 100644 index 0000000..bcbc646 --- /dev/null +++ b/OrderTrackingApp.Api/Examples/CreateOrderRequestExample.cs @@ -0,0 +1,28 @@ +using OrderTrackingApp.Api.Models; +using Swashbuckle.AspNetCore.Filters; + +namespace OrderTrackingApp.Api.Examples; + +public class CreateOrderRequestExample : IExamplesProvider +{ + public CreateOrderRequest GetExamples() + { + return new CreateOrderRequest + { + CustomerId = Guid.NewGuid(), + Items = new List + { + new CreateOrderItemRequest + { + ProductId = Guid.Parse("b111a4dd-f45c-4d40-a6a3-3002f62f823f"), + Quantity = 2 + }, + new CreateOrderItemRequest + { + ProductId = Guid.Parse("2a13551b-059c-4035-b275-8a8cb82bd272"), + Quantity = 3 + } + } + }; + } +} diff --git a/OrderTrackingApp.Api/Extensions/ApiServiceRegistrations.cs b/OrderTrackingApp.Api/Extensions/ApiServiceRegistrations.cs new file mode 100644 index 0000000..b2198cb --- /dev/null +++ b/OrderTrackingApp.Api/Extensions/ApiServiceRegistrations.cs @@ -0,0 +1,34 @@ +using FluentValidation; +using OrderTrackingApp.Api.Examples; +using OrderTrackingApp.Api.MappingProfiles; +using OrderTrackingApp.Api.Validators; +using Swashbuckle.AspNetCore.Filters; +using System.Text.Json; + +namespace OrderTrackingApp.Api.Extensions +{ + public static class ApiServiceRegistrations + { + public static IServiceCollection AddApiServices(this IServiceCollection services) + { + services.AddEndpointsApiExplorer(); + + services.AddSwaggerGen(options => { + options.ExampleFilters(); + }); + + services.AddSwaggerExamplesFromAssemblyOf(); + + services.AddControllers() + .AddJsonOptions(options => + { + options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; + }); + + services.AddValidatorsFromAssemblyContaining(); + services.AddAutoMapper(config => { }, typeof(OrderProfile).Assembly); + + return services; + } + } +} diff --git a/OrderTrackingApp.Api/MappingProfiles/OrderProfile.cs b/OrderTrackingApp.Api/MappingProfiles/OrderProfile.cs new file mode 100644 index 0000000..6e97398 --- /dev/null +++ b/OrderTrackingApp.Api/MappingProfiles/OrderProfile.cs @@ -0,0 +1,18 @@ +using AutoMapper; +using OrderTrackingApp.Api.Models; +using OrderTrackingApp.Application.Orders.Commands; +using OrderTrackingApp.Application.Orders.Queries; + +namespace OrderTrackingApp.Api.MappingProfiles +{ + public class OrderProfile : Profile + { + public OrderProfile() + { + CreateMap(); + CreateMap(); + + CreateMap(); + } + } +} diff --git a/OrderTrackingApp.Api/Models/CreateOrderRequest.cs b/OrderTrackingApp.Api/Models/CreateOrderRequest.cs new file mode 100644 index 0000000..2b699f0 --- /dev/null +++ b/OrderTrackingApp.Api/Models/CreateOrderRequest.cs @@ -0,0 +1,22 @@ +using System.ComponentModel.DataAnnotations; + +namespace OrderTrackingApp.Api.Models +{ + public class CreateOrderRequest + { + [Required] + public Guid CustomerId { get; set; } + + [Required] + public List Items { get; set; } = []; + } + + public class CreateOrderItemRequest + { + [Required] + public Guid ProductId { get; set; } + + [Range(1, int.MaxValue)] + public int Quantity { get; set; } + } +} diff --git a/OrderTrackingApp.Api/Models/GetOrdersRequest.cs b/OrderTrackingApp.Api/Models/GetOrdersRequest.cs new file mode 100644 index 0000000..39f30a2 --- /dev/null +++ b/OrderTrackingApp.Api/Models/GetOrdersRequest.cs @@ -0,0 +1,15 @@ +namespace OrderTrackingApp.Api.Models +{ + public class GetOrdersRequest + { + public int Page { get; set; } = 1; + + public int PageSize { get; set; } = 50; + + public string? Status { get; set; } + + public DateTime? FromDate { get; set; } + + public DateTime? ToDate { get; set; } + } +} diff --git a/OrderTrackingApp.Api/OrderTrackingApp.Api.csproj b/OrderTrackingApp.Api/OrderTrackingApp.Api.csproj new file mode 100644 index 0000000..b4123b8 --- /dev/null +++ b/OrderTrackingApp.Api/OrderTrackingApp.Api.csproj @@ -0,0 +1,27 @@ + + + + net10.0 + enable + enable + fd420179-d5b0-44df-b29b-018fbfbdb9f1 + Linux + ..\OrderTrackingApp + + + + + + + + + + + + + + + + + + diff --git a/OrderTrackingApp.Api/Program.cs b/OrderTrackingApp.Api/Program.cs new file mode 100644 index 0000000..571889f --- /dev/null +++ b/OrderTrackingApp.Api/Program.cs @@ -0,0 +1,33 @@ +using OrderTrackingApp.Infrastructure.Extensions; +using OrderTrackingApp.Persistence.Extensions; +using OrderTrackingApp.ReadPersistence.Extensions; +using OrderTrackingApp.Application.Extensions; +using OrderTrackingApp.Api.Extensions; +using Scalar.AspNetCore; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddPersistenceServices(builder.Configuration); +builder.Services.AddReadPersistence(); +builder.Services.AddInfrastructureServices(); +builder.Services.AddApplicationServices(); +builder.Services.AddApiServices(); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(options => + { + options.RouteTemplate = "/openapi/{documentName}.json"; + }); + app.MapScalarApiReference(); +} + +app.UseHttpsRedirection(); + +app.UseAuthorization(); + +app.MapControllers(); + +app.Run(); diff --git a/OrderTrackingApp.Api/Properties/launchSettings.json b/OrderTrackingApp.Api/Properties/launchSettings.json new file mode 100644 index 0000000..4c1dc4f --- /dev/null +++ b/OrderTrackingApp.Api/Properties/launchSettings.json @@ -0,0 +1,42 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:12188", + "sslPort": 44349 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "launchUrl": "scalar", + "applicationUrl": "http://localhost:5077", + "hotReloadEnabled": false, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7113;http://localhost:5077", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/OrderTrackingApp.Api/Validators/CreateOrderRequestValidator.cs b/OrderTrackingApp.Api/Validators/CreateOrderRequestValidator.cs new file mode 100644 index 0000000..e5fe048 --- /dev/null +++ b/OrderTrackingApp.Api/Validators/CreateOrderRequestValidator.cs @@ -0,0 +1,20 @@ +using FluentValidation; +using OrderTrackingApp.Api.Models; + +namespace OrderTrackingApp.Api.Validators +{ + public class CreateOrderRequestValidator : AbstractValidator + { + public CreateOrderRequestValidator() + { + RuleFor(x => x.CustomerId).NotEmpty(); + RuleFor(x => x.Items).NotEmpty(); + + RuleForEach(x => x.Items).ChildRules(items => + { + items.RuleFor(i => i.ProductId).NotEmpty(); + items.RuleFor(i => i.Quantity).GreaterThan(0); + }); + } + } +} diff --git a/OrderTrackingApp.Client/wwwroot/appsettings.Development.json b/OrderTrackingApp.Api/appsettings.Development.json similarity index 100% rename from OrderTrackingApp.Client/wwwroot/appsettings.Development.json rename to OrderTrackingApp.Api/appsettings.Development.json diff --git a/OrderTrackingApp.Api/appsettings.json b/OrderTrackingApp.Api/appsettings.json new file mode 100644 index 0000000..7013322 --- /dev/null +++ b/OrderTrackingApp.Api/appsettings.json @@ -0,0 +1,14 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "ConnectionStrings": { + "SqlConnection": "Server=localhost,1433;Database=OrderDb;User=sa;Password=YourStrong!Passw0rd;TrustServerCertificate=True", + "MongoDb": "mongodb://admin:secret@localhost:27017", + "RabbitMq": "amqp://guest:guest@localhost:5672/" + } +} diff --git a/OrderTrackingApp.Application.Contracts/OrderTrackingApp.Application.Contracts.csproj b/OrderTrackingApp.Application.Contracts/OrderTrackingApp.Application.Contracts.csproj new file mode 100644 index 0000000..55356d8 --- /dev/null +++ b/OrderTrackingApp.Application.Contracts/OrderTrackingApp.Application.Contracts.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + enable + + + + + + + diff --git a/OrderTrackingApp.Application.Contracts/Orders/IOrderReadRepository.cs b/OrderTrackingApp.Application.Contracts/Orders/IOrderReadRepository.cs new file mode 100644 index 0000000..cb387b9 --- /dev/null +++ b/OrderTrackingApp.Application.Contracts/Orders/IOrderReadRepository.cs @@ -0,0 +1,16 @@ + +namespace OrderTrackingApp.Application.Contracts.Orders +{ + public interface IOrderReadRepository + { + Task GetByIdAsync(Guid id); + + Task> GetAllAsync(); + + Task InsertAsync(OrderDto order); + + Task> GetPaginatedOrdersAsync(int page, int pageSize, string? status, + DateTime? fromDate, DateTime? toDate, + CancellationToken cancellationToken); + } +} diff --git a/OrderTrackingApp.Application.Contracts/Orders/IOrderWriteRepository.cs b/OrderTrackingApp.Application.Contracts/Orders/IOrderWriteRepository.cs new file mode 100644 index 0000000..0bb50bc --- /dev/null +++ b/OrderTrackingApp.Application.Contracts/Orders/IOrderWriteRepository.cs @@ -0,0 +1,13 @@ +namespace OrderTrackingApp.Application.Contracts.Orders +{ + public interface IOrderWriteRepository + { + Task AddAsync(OrderDto order); + + Task UpdateAsync(OrderDto order); + + Task DeleteAsync(Guid id); + + Task GetByIdAsync(Guid id); + } +} diff --git a/OrderTrackingApp.Application.Contracts/Orders/OrderDto.cs b/OrderTrackingApp.Application.Contracts/Orders/OrderDto.cs new file mode 100644 index 0000000..33ba483 --- /dev/null +++ b/OrderTrackingApp.Application.Contracts/Orders/OrderDto.cs @@ -0,0 +1,28 @@ +using OrderTrackingApp.Domain.Entities; + +namespace OrderTrackingApp.Application.Contracts.Orders +{ + public class OrderDto + { + public Guid Id { get; set; } + + public string OrderNumber { get; set; } = string.Empty; + + public OrderStatus Status { get; set; } + + public DateTime CreatedAt { get; set; } + + public Guid CustomerId { get; set; } + + public List Items { get; set; } = []; + } + + public class OrderItemDto + { + public Guid ProductId { get; set; } + + public int Quantity { get; set; } + + public decimal UnitPrice { get; set; } + } +} diff --git a/OrderTrackingApp.Application.Contracts/PaginatedResult.cs b/OrderTrackingApp.Application.Contracts/PaginatedResult.cs new file mode 100644 index 0000000..773201d --- /dev/null +++ b/OrderTrackingApp.Application.Contracts/PaginatedResult.cs @@ -0,0 +1,13 @@ +namespace OrderTrackingApp.Application.Contracts +{ + public class PaginatedResult(List items, long totalCount, int page, int pageSize) + { + public List Items { get; } = items; + + public long TotalCount { get; } = totalCount; + + public int Page { get; } = page; + + public int PageSize { get; } = pageSize; + } +} diff --git a/OrderTrackingApp.Application/Extensions/ApplicationServiceRegistrations.cs b/OrderTrackingApp.Application/Extensions/ApplicationServiceRegistrations.cs new file mode 100644 index 0000000..ca42207 --- /dev/null +++ b/OrderTrackingApp.Application/Extensions/ApplicationServiceRegistrations.cs @@ -0,0 +1,16 @@ +using Microsoft.Extensions.DependencyInjection; +using OrderTrackingApp.Application.MappingProfiles; +using OrderTrackingApp.Application.Orders.Commands; + +namespace OrderTrackingApp.Application.Extensions +{ + public static class ApplicationServiceRegistrations + { + public static IServiceCollection AddApplicationServices(this IServiceCollection services) + { + services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(CreateOrderCommandHandler).Assembly)); + services.AddAutoMapper(config => { }, typeof(OrderProfile).Assembly); + return services; + } + } +} diff --git a/OrderTrackingApp.Application/MappingProfiles/OrderProfile.cs b/OrderTrackingApp.Application/MappingProfiles/OrderProfile.cs new file mode 100644 index 0000000..611290f --- /dev/null +++ b/OrderTrackingApp.Application/MappingProfiles/OrderProfile.cs @@ -0,0 +1,15 @@ +using AutoMapper; +using OrderTrackingApp.Application.Contracts.Orders; +using OrderTrackingApp.Domain.Entities; + +namespace OrderTrackingApp.Application.MappingProfiles +{ + public class OrderProfile : Profile + { + public OrderProfile() + { + CreateMap().ReverseMap(); + CreateMap().ReverseMap(); + } + } +} diff --git a/OrderTrackingApp.Application/OrderTrackingApp.Application.csproj b/OrderTrackingApp.Application/OrderTrackingApp.Application.csproj new file mode 100644 index 0000000..3e668a6 --- /dev/null +++ b/OrderTrackingApp.Application/OrderTrackingApp.Application.csproj @@ -0,0 +1,19 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + diff --git a/OrderTrackingApp.Application/Orders/Commands/CreateOrderCommand.cs b/OrderTrackingApp.Application/Orders/Commands/CreateOrderCommand.cs new file mode 100644 index 0000000..491fdc3 --- /dev/null +++ b/OrderTrackingApp.Application/Orders/Commands/CreateOrderCommand.cs @@ -0,0 +1,18 @@ +using MediatR; + +namespace OrderTrackingApp.Application.Orders.Commands +{ + public class CreateOrderCommand : IRequest + { + public Guid CustomerId { get; set; } + + public List Items { get; set; } = []; + } + + public class CreateOrderItemDto + { + public Guid ProductId { get; set; } + + public int Quantity { get; set; } + } +} diff --git a/OrderTrackingApp.Application/Orders/Commands/CreateOrderCommandHandler.cs b/OrderTrackingApp.Application/Orders/Commands/CreateOrderCommandHandler.cs new file mode 100644 index 0000000..3c161b5 --- /dev/null +++ b/OrderTrackingApp.Application/Orders/Commands/CreateOrderCommandHandler.cs @@ -0,0 +1,59 @@ +using MediatR; +using OrderTrackingApp.Application.Contracts.Orders; +using OrderTrackingApp.Domain.Entities; +using OrderTrackingApp.Domain.Events; +using OrderTrackingApp.Domain.Interfaces; + +namespace OrderTrackingApp.Application.Orders.Commands +{ + public class CreateOrderCommandHandler(IOrderWriteRepository orderRepository, IProductRepository productRepository, IMediator mediator) + : IRequestHandler + { + public async Task Handle(CreateOrderCommand request, CancellationToken cancellationToken) + { + var orderItems = new List(); + var updatedProducts = new List(); + + var productIds = request.Items.Select(item => item.ProductId).ToList(); + var products = await productRepository.GetByIdsAsync(productIds); + var productDict = products.ToDictionary(p => p.Id, p => p); + + foreach (var item in request.Items) + { + if (!productDict.TryGetValue(item.ProductId, out Product? product)) + { + throw new KeyNotFoundException($"Product with ID {item.ProductId} not found."); + } + + if(product.StockQuantity < item.Quantity) + { + throw new InvalidOperationException($"Insufficient stock for product {product.Name}. Available: {product.StockQuantity}, Requested: {item.Quantity}."); + } + + orderItems.Add(new OrderItemDto { ProductId = item.ProductId, Quantity = item.Quantity, UnitPrice = product.Price }); + + product.StockQuantity -= item.Quantity; + + updatedProducts.Add(product); + } + + await productRepository.UpdateProducts(updatedProducts); + + var order = new OrderDto + { + Id = Guid.NewGuid(), + OrderNumber = $"ORD-{DateTime.UtcNow.Ticks}", + CustomerId = request.CustomerId, + CreatedAt = DateTime.UtcNow, + Status = OrderStatus.Pending, + Items = orderItems + }; + + await orderRepository.AddAsync(order); + + await mediator.Publish(new OrderCreatedEvent(order.Id), cancellationToken); + + return order.Id; + } + } +} diff --git a/OrderTrackingApp.Application/Orders/Queries/GetOrderByIdQuery.cs b/OrderTrackingApp.Application/Orders/Queries/GetOrderByIdQuery.cs new file mode 100644 index 0000000..d1c39ca --- /dev/null +++ b/OrderTrackingApp.Application/Orders/Queries/GetOrderByIdQuery.cs @@ -0,0 +1,7 @@ +using MediatR; +using OrderTrackingApp.Application.Contracts.Orders; + +namespace OrderTrackingApp.Application.Orders.Queries +{ + public record GetOrderByIdQuery(Guid OrderId) : IRequest; +} diff --git a/OrderTrackingApp.Application/Orders/Queries/GetOrderByIdQueryHandler.cs b/OrderTrackingApp.Application/Orders/Queries/GetOrderByIdQueryHandler.cs new file mode 100644 index 0000000..5be44d3 --- /dev/null +++ b/OrderTrackingApp.Application/Orders/Queries/GetOrderByIdQueryHandler.cs @@ -0,0 +1,14 @@ +using MediatR; +using OrderTrackingApp.Application.Contracts.Orders; + +namespace OrderTrackingApp.Application.Orders.Queries +{ + public class GetOrderByIdQueryHandler(IOrderReadRepository orderReadRepository) : IRequestHandler + { + public async Task Handle(GetOrderByIdQuery request, CancellationToken cancellationToken) + { + var order = await orderReadRepository.GetByIdAsync(request.OrderId); + return order ?? throw new Exception($"Order not found for ID {request.OrderId}"); + } + } +} diff --git a/OrderTrackingApp.Application/Orders/Queries/GetOrdersQuery.cs b/OrderTrackingApp.Application/Orders/Queries/GetOrdersQuery.cs new file mode 100644 index 0000000..729822a --- /dev/null +++ b/OrderTrackingApp.Application/Orders/Queries/GetOrdersQuery.cs @@ -0,0 +1,19 @@ +using MediatR; +using OrderTrackingApp.Application.Contracts; +using OrderTrackingApp.Application.Contracts.Orders; + +namespace OrderTrackingApp.Application.Orders.Queries +{ + public record GetOrdersQuery : IRequest> + { + public int Page { get; set; } = 1; + + public int PageSize { get; set; } = 50; + + public string? Status { get; set; } + + public DateTime? FromDate { get; set; } + + public DateTime? ToDate { get; set; } + } +} diff --git a/OrderTrackingApp.Application/Orders/Queries/GetOrdersQueryHandler.cs b/OrderTrackingApp.Application/Orders/Queries/GetOrdersQueryHandler.cs new file mode 100644 index 0000000..7756a4e --- /dev/null +++ b/OrderTrackingApp.Application/Orders/Queries/GetOrdersQueryHandler.cs @@ -0,0 +1,20 @@ +using MediatR; +using OrderTrackingApp.Application.Contracts; +using OrderTrackingApp.Application.Contracts.Orders; + +namespace OrderTrackingApp.Application.Orders.Queries +{ + public class GetOrdersQueryHandler(IOrderReadRepository orderReadRepository) : IRequestHandler> + { + public async Task> Handle(GetOrdersQuery request, CancellationToken cancellationToken) + { + return await orderReadRepository.GetPaginatedOrdersAsync( + page: request.Page, + pageSize: request.PageSize, + status: request.Status, + fromDate: request.FromDate, + toDate: request.ToDate, + cancellationToken: cancellationToken); + } + } +} diff --git a/OrderTrackingApp.Client/OrderTrackingApp.Blazor.Client.csproj b/OrderTrackingApp.Blazor.Client/OrderTrackingApp.Blazor.Client.csproj similarity index 83% rename from OrderTrackingApp.Client/OrderTrackingApp.Blazor.Client.csproj rename to OrderTrackingApp.Blazor.Client/OrderTrackingApp.Blazor.Client.csproj index 81d27a4..aaeb1dd 100644 --- a/OrderTrackingApp.Client/OrderTrackingApp.Blazor.Client.csproj +++ b/OrderTrackingApp.Blazor.Client/OrderTrackingApp.Blazor.Client.csproj @@ -1,7 +1,7 @@ - net8.0 + net10.0 enable enable true @@ -9,7 +9,7 @@ - + diff --git a/OrderTrackingApp.Client/Pages/Counter.razor b/OrderTrackingApp.Blazor.Client/Pages/Counter.razor similarity index 100% rename from OrderTrackingApp.Client/Pages/Counter.razor rename to OrderTrackingApp.Blazor.Client/Pages/Counter.razor diff --git a/OrderTrackingApp.Client/Program.cs b/OrderTrackingApp.Blazor.Client/Program.cs similarity index 100% rename from OrderTrackingApp.Client/Program.cs rename to OrderTrackingApp.Blazor.Client/Program.cs diff --git a/OrderTrackingApp.Client/_Imports.razor b/OrderTrackingApp.Blazor.Client/_Imports.razor similarity index 89% rename from OrderTrackingApp.Client/_Imports.razor rename to OrderTrackingApp.Blazor.Client/_Imports.razor index 425c382..0b4c4a9 100644 --- a/OrderTrackingApp.Client/_Imports.razor +++ b/OrderTrackingApp.Blazor.Client/_Imports.razor @@ -6,4 +6,4 @@ @using static Microsoft.AspNetCore.Components.Web.RenderMode @using Microsoft.AspNetCore.Components.Web.Virtualization @using Microsoft.JSInterop -@using OrderTrackingApp.Client +@using OrderTrackingApp.Blazor.Client diff --git a/OrderTrackingApp/appsettings.Development.json b/OrderTrackingApp.Blazor.Client/wwwroot/appsettings.Development.json similarity index 100% rename from OrderTrackingApp/appsettings.Development.json rename to OrderTrackingApp.Blazor.Client/wwwroot/appsettings.Development.json diff --git a/OrderTrackingApp.Client/wwwroot/appsettings.json b/OrderTrackingApp.Blazor.Client/wwwroot/appsettings.json similarity index 100% rename from OrderTrackingApp.Client/wwwroot/appsettings.json rename to OrderTrackingApp.Blazor.Client/wwwroot/appsettings.json diff --git a/OrderTrackingApp/Components/App.razor b/OrderTrackingApp.Blazor.Server/Components/App.razor similarity index 85% rename from OrderTrackingApp/Components/App.razor rename to OrderTrackingApp.Blazor.Server/Components/App.razor index 207b995..a009aa6 100644 --- a/OrderTrackingApp/Components/App.razor +++ b/OrderTrackingApp.Blazor.Server/Components/App.razor @@ -7,7 +7,7 @@ - + diff --git a/OrderTrackingApp/Components/Layout/MainLayout.razor b/OrderTrackingApp.Blazor.Server/Components/Layout/MainLayout.razor similarity index 100% rename from OrderTrackingApp/Components/Layout/MainLayout.razor rename to OrderTrackingApp.Blazor.Server/Components/Layout/MainLayout.razor diff --git a/OrderTrackingApp/Components/Layout/MainLayout.razor.css b/OrderTrackingApp.Blazor.Server/Components/Layout/MainLayout.razor.css similarity index 100% rename from OrderTrackingApp/Components/Layout/MainLayout.razor.css rename to OrderTrackingApp.Blazor.Server/Components/Layout/MainLayout.razor.css diff --git a/OrderTrackingApp/Components/Layout/NavMenu.razor b/OrderTrackingApp.Blazor.Server/Components/Layout/NavMenu.razor similarity index 94% rename from OrderTrackingApp/Components/Layout/NavMenu.razor rename to OrderTrackingApp.Blazor.Server/Components/Layout/NavMenu.razor index d3c64c9..2580c74 100644 --- a/OrderTrackingApp/Components/Layout/NavMenu.razor +++ b/OrderTrackingApp.Blazor.Server/Components/Layout/NavMenu.razor @@ -1,6 +1,6 @@  diff --git a/OrderTrackingApp/Components/Layout/NavMenu.razor.css b/OrderTrackingApp.Blazor.Server/Components/Layout/NavMenu.razor.css similarity index 100% rename from OrderTrackingApp/Components/Layout/NavMenu.razor.css rename to OrderTrackingApp.Blazor.Server/Components/Layout/NavMenu.razor.css diff --git a/OrderTrackingApp/Components/Pages/Error.razor b/OrderTrackingApp.Blazor.Server/Components/Pages/Error.razor similarity index 100% rename from OrderTrackingApp/Components/Pages/Error.razor rename to OrderTrackingApp.Blazor.Server/Components/Pages/Error.razor diff --git a/OrderTrackingApp/Components/Pages/Home.razor b/OrderTrackingApp.Blazor.Server/Components/Pages/Home.razor similarity index 100% rename from OrderTrackingApp/Components/Pages/Home.razor rename to OrderTrackingApp.Blazor.Server/Components/Pages/Home.razor diff --git a/OrderTrackingApp/Components/Pages/Weather.razor b/OrderTrackingApp.Blazor.Server/Components/Pages/Weather.razor similarity index 100% rename from OrderTrackingApp/Components/Pages/Weather.razor rename to OrderTrackingApp.Blazor.Server/Components/Pages/Weather.razor diff --git a/OrderTrackingApp/Components/Routes.razor b/OrderTrackingApp.Blazor.Server/Components/Routes.razor similarity index 100% rename from OrderTrackingApp/Components/Routes.razor rename to OrderTrackingApp.Blazor.Server/Components/Routes.razor diff --git a/OrderTrackingApp/Components/_Imports.razor b/OrderTrackingApp.Blazor.Server/Components/_Imports.razor similarity index 78% rename from OrderTrackingApp/Components/_Imports.razor rename to OrderTrackingApp.Blazor.Server/Components/_Imports.razor index 6b191ef..564b32f 100644 --- a/OrderTrackingApp/Components/_Imports.razor +++ b/OrderTrackingApp.Blazor.Server/Components/_Imports.razor @@ -6,6 +6,5 @@ @using static Microsoft.AspNetCore.Components.Web.RenderMode @using Microsoft.AspNetCore.Components.Web.Virtualization @using Microsoft.JSInterop -@using OrderTrackingApp -@using OrderTrackingApp.Client -@using OrderTrackingApp.Components +@using OrderTrackingApp.Blazor.Server +@using OrderTrackingApp.Blazor.Server.Components diff --git a/OrderTrackingApp.Blazor.Server/Dockerfile b/OrderTrackingApp.Blazor.Server/Dockerfile new file mode 100644 index 0000000..52f2bdb --- /dev/null +++ b/OrderTrackingApp.Blazor.Server/Dockerfile @@ -0,0 +1,32 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src + +COPY ["OrderTrackingApp.Blazor.Server/OrderTrackingApp.Blazor.Server.csproj", "OrderTrackingApp.Blazor.Server/"] +COPY ["OrderTrackingApp.Blazor.Client/OrderTrackingApp.Blazor.Client.csproj", "OrderTrackingApp.Blazor.Client/"] + +# Copy root package props first +COPY ["Directory.Packages.props", "."] + +# Copy your root certificate into the container +COPY nscacert.crt /usr/local/share/ca-certificates/nscacert.crt + +# Update the certificate store +RUN update-ca-certificates + +RUN dotnet restore "OrderTrackingApp.Blazor.Server/OrderTrackingApp.Blazor.Server.csproj" + +COPY . . + +RUN dotnet build "OrderTrackingApp.Blazor.Server/OrderTrackingApp.Blazor.Server.csproj" -c Release -o /app/build --no-restore + +FROM build AS publish +RUN dotnet publish "OrderTrackingApp.Blazor.Server/OrderTrackingApp.Blazor.Server.csproj" -c Release -o /app/publish --no-restore + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 +WORKDIR /app +COPY --from=publish /app/publish . + +EXPOSE 8080 + +# Start the app +ENTRYPOINT ["dotnet", "OrderTrackingApp.Blazor.Server.dll"] \ No newline at end of file diff --git a/OrderTrackingApp.Blazor.Server/OrderTrackingApp.Blazor.Server.csproj b/OrderTrackingApp.Blazor.Server/OrderTrackingApp.Blazor.Server.csproj new file mode 100644 index 0000000..96d03b4 --- /dev/null +++ b/OrderTrackingApp.Blazor.Server/OrderTrackingApp.Blazor.Server.csproj @@ -0,0 +1,29 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + Always + + + Always + + + Always + + + + diff --git a/OrderTrackingApp/Program.cs b/OrderTrackingApp.Blazor.Server/Program.cs similarity index 83% rename from OrderTrackingApp/Program.cs rename to OrderTrackingApp.Blazor.Server/Program.cs index 075c206..d784bf9 100644 --- a/OrderTrackingApp/Program.cs +++ b/OrderTrackingApp.Blazor.Server/Program.cs @@ -1,5 +1,4 @@ -using OrderTrackingApp.Client.Pages; -using OrderTrackingApp.Components; +using OrderTrackingApp.Blazor.Server.Components; var builder = WebApplication.CreateBuilder(args); @@ -30,6 +29,6 @@ app.MapRazorComponents() .AddInteractiveServerRenderMode() .AddInteractiveWebAssemblyRenderMode() - .AddAdditionalAssemblies(typeof(OrderTrackingApp.Client._Imports).Assembly); + .AddAdditionalAssemblies(typeof(OrderTrackingApp.Blazor.Client._Imports).Assembly); app.Run(); diff --git a/OrderTrackingApp/Properties/launchSettings.json b/OrderTrackingApp.Blazor.Server/Properties/launchSettings.json similarity index 85% rename from OrderTrackingApp/Properties/launchSettings.json rename to OrderTrackingApp.Blazor.Server/Properties/launchSettings.json index f4d5c4c..46d14d4 100644 --- a/OrderTrackingApp/Properties/launchSettings.json +++ b/OrderTrackingApp.Blazor.Server/Properties/launchSettings.json @@ -4,8 +4,8 @@ "windowsAuthentication": false, "anonymousAuthentication": true, "iisExpress": { - "applicationUrl": "http://localhost:12383", - "sslPort": 44332 + "applicationUrl": "http://localhost:48834", + "sslPort": 44398 } }, "profiles": { @@ -14,7 +14,7 @@ "dotnetRunMessages": true, "launchBrowser": true, "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", - "applicationUrl": "http://localhost:5166", + "applicationUrl": "http://localhost:5229", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } @@ -24,7 +24,7 @@ "dotnetRunMessages": true, "launchBrowser": true, "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", - "applicationUrl": "https://localhost:7299;http://localhost:5166", + "applicationUrl": "https://localhost:7262;http://localhost:5229", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/OrderTrackingApp.Blazor.Server/appsettings.Development.json b/OrderTrackingApp.Blazor.Server/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/OrderTrackingApp.Blazor.Server/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/OrderTrackingApp/appsettings.json b/OrderTrackingApp.Blazor.Server/appsettings.json similarity index 100% rename from OrderTrackingApp/appsettings.json rename to OrderTrackingApp.Blazor.Server/appsettings.json diff --git a/OrderTrackingApp/wwwroot/app.css b/OrderTrackingApp.Blazor.Server/wwwroot/app.css similarity index 100% rename from OrderTrackingApp/wwwroot/app.css rename to OrderTrackingApp.Blazor.Server/wwwroot/app.css diff --git a/OrderTrackingApp/wwwroot/bootstrap/bootstrap.min.css b/OrderTrackingApp.Blazor.Server/wwwroot/bootstrap/bootstrap.min.css similarity index 100% rename from OrderTrackingApp/wwwroot/bootstrap/bootstrap.min.css rename to OrderTrackingApp.Blazor.Server/wwwroot/bootstrap/bootstrap.min.css diff --git a/OrderTrackingApp/wwwroot/bootstrap/bootstrap.min.css.map b/OrderTrackingApp.Blazor.Server/wwwroot/bootstrap/bootstrap.min.css.map similarity index 100% rename from OrderTrackingApp/wwwroot/bootstrap/bootstrap.min.css.map rename to OrderTrackingApp.Blazor.Server/wwwroot/bootstrap/bootstrap.min.css.map diff --git a/OrderTrackingApp/wwwroot/favicon.png b/OrderTrackingApp.Blazor.Server/wwwroot/favicon.png similarity index 100% rename from OrderTrackingApp/wwwroot/favicon.png rename to OrderTrackingApp.Blazor.Server/wwwroot/favicon.png diff --git a/OrderTrackingApp.Consumer/Dockerfile b/OrderTrackingApp.Consumer/Dockerfile new file mode 100644 index 0000000..9b2b11d --- /dev/null +++ b/OrderTrackingApp.Consumer/Dockerfile @@ -0,0 +1,36 @@ +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base + +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src + +# Copy root package props first +COPY ["Directory.Packages.props", "."] + +COPY ["OrderTrackingApp.Consumer/OrderTrackingApp.Consumer.csproj", "OrderTrackingApp.Consumer/"] +COPY ["OrderTrackingApp.Application/OrderTrackingApp.Application.csproj", "OrderTrackingApp.Application/"] +COPY ["OrderTrackingApp.Application.Contracts/OrderTrackingApp.Application.Contracts.csproj", "OrderTrackingApp.Application.Contracts/"] +COPY ["OrderTrackingApp.Domain/OrderTrackingApp.Domain.csproj", "OrderTrackingApp.Domain/"] +COPY ["OrderTrackingApp.Infrastructure/OrderTrackingApp.Infrastructure.csproj", "OrderTrackingApp.Infrastructure/"] +COPY ["OrderTrackingApp.Persistence/OrderTrackingApp.Persistence.csproj", "OrderTrackingApp.Persistence/"] +COPY ["OrderTrackingApp.ReadPersistence/OrderTrackingApp.ReadPersistence.csproj", "OrderTrackingApp.ReadPersistence/"] + +# Copy your root certificate into the container +COPY nscacert.crt /usr/local/share/ca-certificates/nscacert.crt + +# Update the certificate store +RUN update-ca-certificates + +RUN dotnet restore "OrderTrackingApp.Consumer/OrderTrackingApp.Consumer.csproj" + +COPY . . + +RUN dotnet build "OrderTrackingApp.Consumer/OrderTrackingApp.Consumer.csproj" -c Release -o /app/build --no-restore + +FROM build AS publish +RUN dotnet publish "OrderTrackingApp.Consumer/OrderTrackingApp.Consumer.csproj" -c Release -o /app/publish --no-restore + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . + +ENTRYPOINT ["dotnet", "OrderTrackingApp.Consumer.dll"] diff --git a/OrderTrackingApp.Consumer/OrderTrackingApp.Consumer.csproj b/OrderTrackingApp.Consumer/OrderTrackingApp.Consumer.csproj new file mode 100644 index 0000000..bf42411 --- /dev/null +++ b/OrderTrackingApp.Consumer/OrderTrackingApp.Consumer.csproj @@ -0,0 +1,20 @@ + + + + net10.0 + enable + enable + dotnet-OrderTrackingApp.Consumer-4c972be3-521f-42ce-87d8-815ba1ccddd7 + + + + + + + + + + + + + diff --git a/OrderTrackingApp.Consumer/Program.cs b/OrderTrackingApp.Consumer/Program.cs new file mode 100644 index 0000000..2e4ec64 --- /dev/null +++ b/OrderTrackingApp.Consumer/Program.cs @@ -0,0 +1,14 @@ +using OrderTrackingApp.Consumer; +using OrderTrackingApp.Persistence.Extensions; +using OrderTrackingApp.ReadPersistence.Extensions; +using OrderTrackingApp.Application.Extensions; + +var builder = Host.CreateApplicationBuilder(args); +builder.Services.AddHostedService(); + +builder.Services.AddPersistenceServices(builder.Configuration); +builder.Services.AddReadPersistence(); +builder.Services.AddApplicationServices(); + +var host = builder.Build(); +host.Run(); diff --git a/OrderTrackingApp.Consumer/Properties/launchSettings.json b/OrderTrackingApp.Consumer/Properties/launchSettings.json new file mode 100644 index 0000000..49b44b4 --- /dev/null +++ b/OrderTrackingApp.Consumer/Properties/launchSettings.json @@ -0,0 +1,13 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "OrderTrackingApp.Consumer": { + "commandName": "Project", + "dotnetRunMessages": true, + "hotReloadEnabled": false, + "environmentVariables": { + "DOTNET_ENVIRONMENT": "Development" + } + } + } +} diff --git a/OrderTrackingApp.Consumer/Worker.cs b/OrderTrackingApp.Consumer/Worker.cs new file mode 100644 index 0000000..3fc0b34 --- /dev/null +++ b/OrderTrackingApp.Consumer/Worker.cs @@ -0,0 +1,76 @@ +using OrderTrackingApp.Application.Contracts.Orders; +using OrderTrackingApp.Domain.Events; +using RabbitMQ.Client; +using RabbitMQ.Client.Events; +using System.Text; +using System.Text.Json; + +namespace OrderTrackingApp.Consumer; + +public class Worker(ILogger logger, IServiceProvider serviceProvider) : BackgroundService +{ + private IConnection? _connection; + private IChannel? _channel; + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + await InitializeRabbitMqListener(); + + var consumer = new AsyncEventingBasicConsumer(_channel!); + + consumer.ReceivedAsync += async (model, ea) => + { + try + { + using var scope = serviceProvider.CreateScope(); + var orderWriteRepository = scope.ServiceProvider.GetRequiredService(); + var orderReadRepository = scope.ServiceProvider.GetRequiredService(); + + var body = ea.Body.ToArray(); + var message = Encoding.UTF8.GetString(body); + + logger.LogInformation("Received message: {message}", message); + + var orderEvent = JsonSerializer.Deserialize(message); + + if (orderEvent != null) + { + var order = await orderWriteRepository.GetByIdAsync(orderEvent.OrderId) + ?? throw new Exception($"Order with ID {orderEvent.OrderId} not found."); + + await orderReadRepository.InsertAsync(order); + + logger.LogInformation("Synced OrderId {orderId} to MongoDB", order.Id); + + await _channel!.BasicAckAsync(deliveryTag: ea.DeliveryTag, multiple: false); + } + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to process RabbitMQ message."); + + await _channel!.BasicNackAsync(deliveryTag: ea.DeliveryTag, multiple: false, requeue: true); + } + }; + + await _channel!.BasicConsumeAsync(queue: "orders", + autoAck: false, + consumer: consumer, + stoppingToken); + } + + private async Task InitializeRabbitMqListener() + { + var factory = new ConnectionFactory() { HostName = "localhost" }; + _connection = await factory.CreateConnectionAsync(); + _channel = await _connection.CreateChannelAsync(); + + await _channel.BasicQosAsync(0, 1, false); + + await _channel.QueueDeclareAsync(queue: "orders", + durable: true, + exclusive: false, + autoDelete: false, + arguments: null); + } +} diff --git a/OrderTrackingApp.Consumer/appsettings.Development.json b/OrderTrackingApp.Consumer/appsettings.Development.json new file mode 100644 index 0000000..b2dcdb6 --- /dev/null +++ b/OrderTrackingApp.Consumer/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information" + } + } +} diff --git a/OrderTrackingApp.Consumer/appsettings.json b/OrderTrackingApp.Consumer/appsettings.json new file mode 100644 index 0000000..ba98022 --- /dev/null +++ b/OrderTrackingApp.Consumer/appsettings.json @@ -0,0 +1,13 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "ConnectionStrings": { + "SqlConnection": "Server=localhost,1433;Database=OrderDb;User=sa;Password=YourStrong!Passw0rd;TrustServerCertificate=True", + "MongoDb": "mongodb://admin:secret@localhost:27017", + "RabbitMq": "amqp://guest:guest@localhost:5672/" + } +} diff --git a/OrderTrackingApp.Domain/Entities/Order.cs b/OrderTrackingApp.Domain/Entities/Order.cs new file mode 100644 index 0000000..045d6c2 --- /dev/null +++ b/OrderTrackingApp.Domain/Entities/Order.cs @@ -0,0 +1,17 @@ +namespace OrderTrackingApp.Domain.Entities +{ + public class Order + { + public Guid Id { get; set; } + + public string OrderNumber { get; set; } = string.Empty; + + public Guid CustomerId { get; set; } + + public DateTime OrderDate { get; set; } + + public OrderStatus Status { get; set; } + + public List Items { get; set; } = []; + } +} diff --git a/OrderTrackingApp.Domain/Entities/OrderItem.cs b/OrderTrackingApp.Domain/Entities/OrderItem.cs new file mode 100644 index 0000000..a73e340 --- /dev/null +++ b/OrderTrackingApp.Domain/Entities/OrderItem.cs @@ -0,0 +1,14 @@ +namespace OrderTrackingApp.Domain.Entities +{ + public class OrderItem + { + public Guid ProductId { get; set; } + + public int Quantity { get; set; } + + public decimal UnitPrice { get; set; } + + public decimal TotalPrice => Quantity * UnitPrice; + } + +} diff --git a/OrderTrackingApp.Domain/Entities/OrderStatus.cs b/OrderTrackingApp.Domain/Entities/OrderStatus.cs new file mode 100644 index 0000000..e404730 --- /dev/null +++ b/OrderTrackingApp.Domain/Entities/OrderStatus.cs @@ -0,0 +1,11 @@ +namespace OrderTrackingApp.Domain.Entities +{ + public enum OrderStatus + { + Pending = 0, + Processing = 1, + Shipped = 2, + Delivered = 3, + Cancelled = 4 + } +} diff --git a/OrderTrackingApp.Domain/Entities/Product.cs b/OrderTrackingApp.Domain/Entities/Product.cs new file mode 100644 index 0000000..2ca56ef --- /dev/null +++ b/OrderTrackingApp.Domain/Entities/Product.cs @@ -0,0 +1,17 @@ +namespace OrderTrackingApp.Domain.Entities +{ + public class Product + { + public Guid Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public string Sku { get; set; } = string.Empty; + + public string Description { get; set; } = string.Empty; + + public decimal Price { get; set; } + + public int StockQuantity { get; set; } + } +} diff --git a/OrderTrackingApp.Domain/Events/OrderCreatedEvent.cs b/OrderTrackingApp.Domain/Events/OrderCreatedEvent.cs new file mode 100644 index 0000000..8e3f471 --- /dev/null +++ b/OrderTrackingApp.Domain/Events/OrderCreatedEvent.cs @@ -0,0 +1,9 @@ +using MediatR; + +namespace OrderTrackingApp.Domain.Events +{ + public class OrderCreatedEvent(Guid orderId) : INotification + { + public Guid OrderId { get; } = orderId; + } +} diff --git a/OrderTrackingApp.Domain/Interfaces/IProductRepository.cs b/OrderTrackingApp.Domain/Interfaces/IProductRepository.cs new file mode 100644 index 0000000..60b1367 --- /dev/null +++ b/OrderTrackingApp.Domain/Interfaces/IProductRepository.cs @@ -0,0 +1,17 @@ +using OrderTrackingApp.Domain.Entities; + +namespace OrderTrackingApp.Domain.Interfaces +{ + public interface IProductRepository + { + Task GetByIdAsync(Guid id); + + Task> GetByIdsAsync(IEnumerable ids); + + Task AddAsync(Product product); + + Task UpdateAsync(Product product); + + Task UpdateProducts(List updatedProducts); + } +} diff --git a/OrderTrackingApp.Domain/OrderTrackingApp.Domain.csproj b/OrderTrackingApp.Domain/OrderTrackingApp.Domain.csproj new file mode 100644 index 0000000..12bdd8c --- /dev/null +++ b/OrderTrackingApp.Domain/OrderTrackingApp.Domain.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + enable + + + + + + + diff --git a/OrderTrackingApp.Infrastructure/EventHandlers/OrderCreatedEventHandler.cs b/OrderTrackingApp.Infrastructure/EventHandlers/OrderCreatedEventHandler.cs new file mode 100644 index 0000000..d035896 --- /dev/null +++ b/OrderTrackingApp.Infrastructure/EventHandlers/OrderCreatedEventHandler.cs @@ -0,0 +1,14 @@ +using MediatR; +using OrderTrackingApp.Domain.Events; +using OrderTrackingApp.Infrastructure.Messaging; + +namespace OrderTrackingApp.Infrastructure.EventHandlers +{ + public class OrderCreatedEventHandler(IMessagePublisher messagePublisher) : INotificationHandler + { + public async Task Handle(OrderCreatedEvent notification, CancellationToken cancellationToken) + { + await messagePublisher.PublishAsync("orders", notification); + } + } +} diff --git a/OrderTrackingApp.Infrastructure/Extensions/InfrastructureServiceRegistrations.cs b/OrderTrackingApp.Infrastructure/Extensions/InfrastructureServiceRegistrations.cs new file mode 100644 index 0000000..e587344 --- /dev/null +++ b/OrderTrackingApp.Infrastructure/Extensions/InfrastructureServiceRegistrations.cs @@ -0,0 +1,17 @@ +using Microsoft.Extensions.DependencyInjection; +using OrderTrackingApp.Infrastructure.EventHandlers; +using OrderTrackingApp.Infrastructure.Messaging; + +namespace OrderTrackingApp.Infrastructure.Extensions +{ + public static class InfrastructureServiceRegistrations + { + public static IServiceCollection AddInfrastructureServices(this IServiceCollection services) + { + services.AddSingleton(); + services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(OrderCreatedEventHandler).Assembly)); + + return services; + } + } +} diff --git a/OrderTrackingApp.Infrastructure/Messaging/IMessagePublisher.cs b/OrderTrackingApp.Infrastructure/Messaging/IMessagePublisher.cs new file mode 100644 index 0000000..d2559e7 --- /dev/null +++ b/OrderTrackingApp.Infrastructure/Messaging/IMessagePublisher.cs @@ -0,0 +1,7 @@ +namespace OrderTrackingApp.Infrastructure.Messaging +{ + public interface IMessagePublisher + { + Task PublishAsync(string queue, T message); + } +} diff --git a/OrderTrackingApp.Infrastructure/Messaging/RabbitMqPublisher.cs b/OrderTrackingApp.Infrastructure/Messaging/RabbitMqPublisher.cs new file mode 100644 index 0000000..cfd908f --- /dev/null +++ b/OrderTrackingApp.Infrastructure/Messaging/RabbitMqPublisher.cs @@ -0,0 +1,38 @@ +using Microsoft.Extensions.Configuration; +using RabbitMQ.Client; +using System.Text; +using System.Text.Json; + +namespace OrderTrackingApp.Infrastructure.Messaging +{ + internal class RabbitMqPublisher : IMessagePublisher + { + private readonly IConnection _connection; + + public RabbitMqPublisher(IConfiguration configuration) + { + var connectionString = configuration.GetConnectionString("RabbitMq"); + if (string.IsNullOrWhiteSpace(connectionString)) + { + throw new Exception("RabbitMQ connection string is not configured."); + } + + var factory = new ConnectionFactory + { + Uri = new Uri(connectionString) + }; + + _connection = factory.CreateConnectionAsync().GetAwaiter().GetResult(); + } + + public async Task PublishAsync(string topic, T message) + { + using var channel = await _connection.CreateChannelAsync(); + await channel.QueueDeclareAsync(queue: topic, durable: true, exclusive: false, autoDelete: false); + + var body = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(message)); + await channel.BasicPublishAsync(exchange: "", routingKey: topic, body: body); + } + + } +} diff --git a/OrderTrackingApp.Infrastructure/OrderTrackingApp.Infrastructure.csproj b/OrderTrackingApp.Infrastructure/OrderTrackingApp.Infrastructure.csproj new file mode 100644 index 0000000..2007db0 --- /dev/null +++ b/OrderTrackingApp.Infrastructure/OrderTrackingApp.Infrastructure.csproj @@ -0,0 +1,19 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + diff --git a/OrderTrackingApp.MigrationRunner/Dockerfile b/OrderTrackingApp.MigrationRunner/Dockerfile new file mode 100644 index 0000000..bbfbb43 --- /dev/null +++ b/OrderTrackingApp.MigrationRunner/Dockerfile @@ -0,0 +1,35 @@ +FROM mcr.microsoft.com/dotnet/runtime:10.0 AS base +WORKDIR /app + +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src + +# Copy root package props first +COPY ["Directory.Packages.props", "."] + +COPY ["OrderTrackingApp.MigrationRunner/OrderTrackingApp.MigrationRunner.csproj", "OrderTrackingApp.MigrationRunner/"] +COPY ["OrderTrackingApp.Persistence/OrderTrackingApp.Persistence.csproj", "OrderTrackingApp.Persistence/"] +COPY ["OrderTrackingApp.Application/OrderTrackingApp.Application.csproj", "OrderTrackingApp.Application/"] +COPY ["OrderTrackingApp.Application.Contracts/OrderTrackingApp.Application.Contracts.csproj", "OrderTrackingApp.Application.Contracts/"] +COPY ["OrderTrackingApp.Domain/OrderTrackingApp.Domain.csproj", "OrderTrackingApp.Domain/"] + +# Copy your root certificate into the container +COPY nscacert.crt /usr/local/share/ca-certificates/nscacert.crt + +# Update the certificate store +RUN update-ca-certificates + +RUN dotnet restore "OrderTrackingApp.MigrationRunner/OrderTrackingApp.MigrationRunner.csproj" + +COPY . . + +RUN dotnet build "OrderTrackingApp.MigrationRunner/OrderTrackingApp.MigrationRunner.csproj" -c Release -o /app/build --no-restore + +FROM build AS publish +RUN dotnet publish "OrderTrackingApp.MigrationRunner/OrderTrackingApp.MigrationRunner.csproj" -c Release -o /app/publish /p:UseAppHost=false --no-restore + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . + +ENTRYPOINT ["dotnet", "OrderTrackingApp.MigrationRunner.dll"] \ No newline at end of file diff --git a/OrderTrackingApp.MigrationRunner/OrderTrackingApp.MigrationRunner.csproj b/OrderTrackingApp.MigrationRunner/OrderTrackingApp.MigrationRunner.csproj new file mode 100644 index 0000000..7ea555c --- /dev/null +++ b/OrderTrackingApp.MigrationRunner/OrderTrackingApp.MigrationRunner.csproj @@ -0,0 +1,30 @@ + + + + Exe + net10.0 + enable + enable + Linux + + + + + + + + + Always + + + + + + + + + + + + + diff --git a/OrderTrackingApp.MigrationRunner/ProductSeeder.cs b/OrderTrackingApp.MigrationRunner/ProductSeeder.cs new file mode 100644 index 0000000..0940046 --- /dev/null +++ b/OrderTrackingApp.MigrationRunner/ProductSeeder.cs @@ -0,0 +1,42 @@ +using Microsoft.EntityFrameworkCore; +using OrderTrackingApp.Domain.Entities; +using OrderTrackingApp.Persistence; + +namespace OrderTrackingApp.MigrationRunner +{ + internal static class ProductSeeder + { + public static async Task SeedAsync(AppDbContext context, CancellationToken cancellationToken = default) + { + if (await context.Products.AnyAsync(cancellationToken)) + { + return; // Already seeded + } + + var products = new List + { + new() + { + Id = Guid.Parse("b111a4dd-f45c-4d40-a6a3-3002f62f823f"), + Name = "Wireless Mouse", + Sku = "MSE-001", + Description = "Ergonomic wireless mouse", + Price = 29.99m, + StockQuantity = 1000 + }, + new() + { + Id = Guid.Parse("2a13551b-059c-4035-b275-8a8cb82bd272"), + Name = "Mechanical Keyboard", + Sku = "KEY-101", + Description = "RGB mechanical keyboard", + Price = 79.99m, + StockQuantity = 500 + } + }; + + context.Products.AddRange(products); + await context.SaveChangesAsync(cancellationToken); + } + } +} diff --git a/OrderTrackingApp.MigrationRunner/Program.cs b/OrderTrackingApp.MigrationRunner/Program.cs new file mode 100644 index 0000000..4aa3674 --- /dev/null +++ b/OrderTrackingApp.MigrationRunner/Program.cs @@ -0,0 +1,21 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using OrderTrackingApp.MigrationRunner; +using OrderTrackingApp.Persistence; + +var host = Host.CreateDefaultBuilder(args) + .ConfigureServices((context, services) => + { + var connStr = context.Configuration["ConnectionStrings:SqlConnection"]; + services.AddDbContext(options => + options.UseSqlServer(connStr)); + }) + .Build(); + +using var scope = host.Services.CreateScope(); +var db = scope.ServiceProvider.GetRequiredService(); +db.Database.Migrate(); + +await ProductSeeder.SeedAsync(db); + diff --git a/OrderTrackingApp.MigrationRunner/Properties/launchSettings.json b/OrderTrackingApp.MigrationRunner/Properties/launchSettings.json new file mode 100644 index 0000000..139a592 --- /dev/null +++ b/OrderTrackingApp.MigrationRunner/Properties/launchSettings.json @@ -0,0 +1,16 @@ +{ + "profiles": { + "OrderTrackingApp.MigrationRunner": { + "commandName": "Project", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "Container (Dockerfile)": { + "commandName": "Docker", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} \ No newline at end of file diff --git a/OrderTrackingApp.MigrationRunner/appsettings.json b/OrderTrackingApp.MigrationRunner/appsettings.json new file mode 100644 index 0000000..ab66b29 --- /dev/null +++ b/OrderTrackingApp.MigrationRunner/appsettings.json @@ -0,0 +1,5 @@ +{ + "ConnectionStrings": { + "SqlConnection": "Server=localhost,1433;Database=OrderDb;User=sa;Password=YourStrong!Passw0rd;TrustServerCertificate=True" + } +} diff --git a/OrderTrackingApp.Persistence.Tests/OrderTrackingApp.Persistence.Tests.csproj b/OrderTrackingApp.Persistence.Tests/OrderTrackingApp.Persistence.Tests.csproj new file mode 100644 index 0000000..38307a0 --- /dev/null +++ b/OrderTrackingApp.Persistence.Tests/OrderTrackingApp.Persistence.Tests.csproj @@ -0,0 +1,27 @@ + + + + net10.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + diff --git a/OrderTrackingApp.Persistence/AppDbContext.cs b/OrderTrackingApp.Persistence/AppDbContext.cs new file mode 100644 index 0000000..bb81642 --- /dev/null +++ b/OrderTrackingApp.Persistence/AppDbContext.cs @@ -0,0 +1,68 @@ +using Microsoft.EntityFrameworkCore; +using OrderTrackingApp.Domain.Entities; + +namespace OrderTrackingApp.Persistence +{ + public class AppDbContext(DbContextOptions options) : DbContext(options) + { + public DbSet Orders => Set(); + + public DbSet Products => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + // Order + modelBuilder.Entity(entity => + { + entity.HasKey(x => x.Id); + + entity.Property(x => x.OrderNumber) + .IsRequired() + .HasMaxLength(50); + + entity.Property(x => x.Status) + .HasConversion(); // Enum -> string + + entity.Property(x => x.OrderDate) + .IsRequired(); + + // OrderItem - owned collection + entity.OwnsMany(x => x.Items, items => + { + items.WithOwner().HasForeignKey("OrderId"); + items.HasKey("OrderId", "ProductId"); // Composite Key + + items.Property(x => x.ProductId).IsRequired(); + items.Property(x => x.Quantity).IsRequired(); + items.Property(x => x.UnitPrice).IsRequired().HasColumnType("decimal(18,2)"); + items.Ignore(x => x.TotalPrice); // Computed property + items.ToTable("OrderItems"); + }); + }); + + // Product + modelBuilder.Entity(entity => + { + entity.HasKey(x => x.Id); + + entity.Property(x => x.Name) + .IsRequired() + .HasMaxLength(100); + + entity.Property(x => x.Sku) + .IsRequired() + .HasMaxLength(50); + + entity.Property(x => x.Description) + .HasMaxLength(500); + + entity.Property(x => x.Price) + .IsRequired() + .HasColumnType("decimal(18,2)"); + + entity.Property(x => x.StockQuantity) + .IsRequired(); + }); + } + } +} diff --git a/OrderTrackingApp.Persistence/DesignTimeDbContextFactory.cs b/OrderTrackingApp.Persistence/DesignTimeDbContextFactory.cs new file mode 100644 index 0000000..b316aa2 --- /dev/null +++ b/OrderTrackingApp.Persistence/DesignTimeDbContextFactory.cs @@ -0,0 +1,16 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace OrderTrackingApp.Persistence +{ + public class DesignTimeDbContextFactory : IDesignTimeDbContextFactory + { + public AppDbContext CreateDbContext(string[] args) + { + var optionsBuilder = new DbContextOptionsBuilder(); + optionsBuilder.UseSqlServer("Server=localhost,1433;Database=OrderDb;User=sa;Password=YourStrong!Pass123;"); + + return new AppDbContext(optionsBuilder.Options); + } + } +} diff --git a/OrderTrackingApp.Persistence/Extensions/PersistenceServiceRegistrations.cs b/OrderTrackingApp.Persistence/Extensions/PersistenceServiceRegistrations.cs new file mode 100644 index 0000000..200ab96 --- /dev/null +++ b/OrderTrackingApp.Persistence/Extensions/PersistenceServiceRegistrations.cs @@ -0,0 +1,23 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using OrderTrackingApp.Application.Contracts.Orders; +using OrderTrackingApp.Domain.Interfaces; +using OrderTrackingApp.Persistence.Repositories; + +namespace OrderTrackingApp.Persistence.Extensions +{ + public static class PersistenceServiceRegistration + { + public static IServiceCollection AddPersistenceServices(this IServiceCollection services, IConfiguration config) + { + services.AddDbContext(options => + options.UseSqlServer(config.GetConnectionString("SqlConnection"))); + + services.AddScoped(); + services.AddScoped(); + + return services; + } + } +} diff --git a/OrderTrackingApp.Persistence/Migrations/20250716055404_InitSchema.Designer.cs b/OrderTrackingApp.Persistence/Migrations/20250716055404_InitSchema.Designer.cs new file mode 100644 index 0000000..c52b9a4 --- /dev/null +++ b/OrderTrackingApp.Persistence/Migrations/20250716055404_InitSchema.Designer.cs @@ -0,0 +1,116 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using OrderTrackingApp.Persistence; + +#nullable disable + +namespace OrderTrackingApp.Persistence.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20250716055404_InitSchema")] + partial class InitSchema + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.18") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("OrderTrackingApp.Domain.Entities.Order", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CustomerId") + .HasColumnType("uniqueidentifier"); + + b.Property("OrderDate") + .HasColumnType("datetime2"); + + b.Property("OrderNumber") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Status") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Orders"); + }); + + modelBuilder.Entity("OrderTrackingApp.Domain.Entities.Product", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Price") + .HasColumnType("decimal(18,2)"); + + b.Property("Sku") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("StockQuantity") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.ToTable("Products"); + }); + + modelBuilder.Entity("OrderTrackingApp.Domain.Entities.Order", b => + { + b.OwnsMany("OrderTrackingApp.Domain.Entities.OrderItem", "Items", b1 => + { + b1.Property("OrderId") + .HasColumnType("uniqueidentifier"); + + b1.Property("ProductId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b1.Property("Quantity") + .HasColumnType("int"); + + b1.Property("UnitPrice") + .HasColumnType("decimal(18,2)"); + + b1.HasKey("OrderId", "ProductId"); + + b1.ToTable("OrderItems", (string)null); + + b1.WithOwner() + .HasForeignKey("OrderId"); + }); + + b.Navigation("Items"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/OrderTrackingApp.Persistence/Migrations/20250716055404_InitSchema.cs b/OrderTrackingApp.Persistence/Migrations/20250716055404_InitSchema.cs new file mode 100644 index 0000000..cc9a3cf --- /dev/null +++ b/OrderTrackingApp.Persistence/Migrations/20250716055404_InitSchema.cs @@ -0,0 +1,78 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace OrderTrackingApp.Persistence.Migrations +{ + /// + public partial class InitSchema : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Orders", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + OrderNumber = table.Column(type: "nvarchar(50)", maxLength: 50, nullable: false), + CustomerId = table.Column(type: "uniqueidentifier", nullable: false), + OrderDate = table.Column(type: "datetime2", nullable: false), + Status = table.Column(type: "nvarchar(max)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Orders", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Products", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + Name = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + Sku = table.Column(type: "nvarchar(50)", maxLength: 50, nullable: false), + Description = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: false), + Price = table.Column(type: "decimal(18,2)", nullable: false), + StockQuantity = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Products", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "OrderItems", + columns: table => new + { + ProductId = table.Column(type: "uniqueidentifier", nullable: false), + OrderId = table.Column(type: "uniqueidentifier", nullable: false), + Quantity = table.Column(type: "int", nullable: false), + UnitPrice = table.Column(type: "decimal(18,2)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_OrderItems", x => new { x.OrderId, x.ProductId }); + table.ForeignKey( + name: "FK_OrderItems_Orders_OrderId", + column: x => x.OrderId, + principalTable: "Orders", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "OrderItems"); + + migrationBuilder.DropTable( + name: "Products"); + + migrationBuilder.DropTable( + name: "Orders"); + } + } +} diff --git a/OrderTrackingApp.Persistence/Migrations/AppDbContextModelSnapshot.cs b/OrderTrackingApp.Persistence/Migrations/AppDbContextModelSnapshot.cs new file mode 100644 index 0000000..834383a --- /dev/null +++ b/OrderTrackingApp.Persistence/Migrations/AppDbContextModelSnapshot.cs @@ -0,0 +1,113 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using OrderTrackingApp.Persistence; + +#nullable disable + +namespace OrderTrackingApp.Persistence.Migrations +{ + [DbContext(typeof(AppDbContext))] + partial class AppDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.18") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("OrderTrackingApp.Domain.Entities.Order", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CustomerId") + .HasColumnType("uniqueidentifier"); + + b.Property("OrderDate") + .HasColumnType("datetime2"); + + b.Property("OrderNumber") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Status") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Orders"); + }); + + modelBuilder.Entity("OrderTrackingApp.Domain.Entities.Product", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Price") + .HasColumnType("decimal(18,2)"); + + b.Property("Sku") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("StockQuantity") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.ToTable("Products"); + }); + + modelBuilder.Entity("OrderTrackingApp.Domain.Entities.Order", b => + { + b.OwnsMany("OrderTrackingApp.Domain.Entities.OrderItem", "Items", b1 => + { + b1.Property("OrderId") + .HasColumnType("uniqueidentifier"); + + b1.Property("ProductId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b1.Property("Quantity") + .HasColumnType("int"); + + b1.Property("UnitPrice") + .HasColumnType("decimal(18,2)"); + + b1.HasKey("OrderId", "ProductId"); + + b1.ToTable("OrderItems", (string)null); + + b1.WithOwner() + .HasForeignKey("OrderId"); + }); + + b.Navigation("Items"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/OrderTrackingApp.Persistence/OrderTrackingApp.Persistence.csproj b/OrderTrackingApp.Persistence/OrderTrackingApp.Persistence.csproj new file mode 100644 index 0000000..3be187e --- /dev/null +++ b/OrderTrackingApp.Persistence/OrderTrackingApp.Persistence.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + enable + enable + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + diff --git a/OrderTrackingApp.Persistence/Repositories/OrderWriteRepository.cs b/OrderTrackingApp.Persistence/Repositories/OrderWriteRepository.cs new file mode 100644 index 0000000..da9d827 --- /dev/null +++ b/OrderTrackingApp.Persistence/Repositories/OrderWriteRepository.cs @@ -0,0 +1,46 @@ +using AutoMapper; +using Microsoft.EntityFrameworkCore; +using OrderTrackingApp.Application.Contracts.Orders; +using OrderTrackingApp.Domain.Entities; + +namespace OrderTrackingApp.Persistence.Repositories +{ + public class OrderWriteRepository(AppDbContext dbContext, IMapper mapper) : IOrderWriteRepository + { + public async Task AddAsync(OrderDto orderDto) + { + var order = mapper.Map(orderDto); + + dbContext.Orders.Add(order); + await dbContext.SaveChangesAsync(); + } + + public async Task DeleteAsync(Guid id) + { + var order = await dbContext.Orders.FirstOrDefaultAsync(x => x.Id == id); + + if(order != null) + { + dbContext.Orders.Remove(order); + await dbContext.SaveChangesAsync(); + } + } + + public async Task UpdateAsync(OrderDto orderDto) + { + var order = mapper.Map(orderDto); + + dbContext.Orders.Update(order); + await dbContext.SaveChangesAsync(); + } + + public async Task GetByIdAsync(Guid id) + { + var order = await dbContext.Orders + .Include(o => o.Items) + .FirstOrDefaultAsync(x => x.Id == id); + + return mapper.Map(order); + } + } +} diff --git a/OrderTrackingApp.Persistence/Repositories/ProductRepository.cs b/OrderTrackingApp.Persistence/Repositories/ProductRepository.cs new file mode 100644 index 0000000..3ad607d --- /dev/null +++ b/OrderTrackingApp.Persistence/Repositories/ProductRepository.cs @@ -0,0 +1,36 @@ +using Microsoft.EntityFrameworkCore; +using OrderTrackingApp.Domain.Entities; +using OrderTrackingApp.Domain.Interfaces; + +namespace OrderTrackingApp.Persistence.Repositories +{ + public class ProductRepository(AppDbContext appDbContext) : IProductRepository + { + public Task AddAsync(Product product) + { + throw new NotImplementedException(); + } + + public Task GetByIdAsync(Guid id) + { + throw new NotImplementedException(); + } + + public Task> GetByIdsAsync(IEnumerable ids) + { + return appDbContext.Products.Where(p => ids.Any(id => id == p.Id)).ToListAsync(); + } + + public Task UpdateAsync(Product product) + { + throw new NotImplementedException(); + } + + public async Task UpdateProducts(List updatedProducts) + { + appDbContext.Products.UpdateRange(updatedProducts); + + await appDbContext.SaveChangesAsync(); + } + } +} diff --git a/OrderTrackingApp.ReadPersistence/Extensions/ReadPersistenceServiceRegistration.cs b/OrderTrackingApp.ReadPersistence/Extensions/ReadPersistenceServiceRegistration.cs new file mode 100644 index 0000000..85c86ee --- /dev/null +++ b/OrderTrackingApp.ReadPersistence/Extensions/ReadPersistenceServiceRegistration.cs @@ -0,0 +1,18 @@ +using Microsoft.Extensions.DependencyInjection; +using OrderTrackingApp.Application.Contracts.Orders; +using OrderTrackingApp.ReadPersistence.Repositories; + +namespace OrderTrackingApp.ReadPersistence.Extensions +{ + public static class ReadPersistenceServiceRegistration + { + public static IServiceCollection AddReadPersistence(this IServiceCollection services) + { + services.AddSingleton(); + services.AddScoped(); + + services.AddAutoMapper(config => { }, typeof(MappingProfiles.OrderProfile).Assembly); + return services; + } + } +} diff --git a/OrderTrackingApp.ReadPersistence/MappingProfiles/OrderProfile.cs b/OrderTrackingApp.ReadPersistence/MappingProfiles/OrderProfile.cs new file mode 100644 index 0000000..aa1c95b --- /dev/null +++ b/OrderTrackingApp.ReadPersistence/MappingProfiles/OrderProfile.cs @@ -0,0 +1,15 @@ +using AutoMapper; +using OrderTrackingApp.Application.Contracts.Orders; +using OrderTrackingApp.ReadPersistence.Models; + +namespace OrderTrackingApp.ReadPersistence.MappingProfiles +{ + public class OrderProfile : Profile + { + public OrderProfile() + { + CreateMap().ReverseMap(); + CreateMap().ReverseMap(); + } + } +} diff --git a/OrderTrackingApp.ReadPersistence/Models/OrderItemReadModel.cs b/OrderTrackingApp.ReadPersistence/Models/OrderItemReadModel.cs new file mode 100644 index 0000000..2b70674 --- /dev/null +++ b/OrderTrackingApp.ReadPersistence/Models/OrderItemReadModel.cs @@ -0,0 +1,11 @@ +namespace OrderTrackingApp.ReadPersistence.Models +{ + public class OrderItemReadModel + { + public Guid ProductId { get; set; } + + public int Quantity { get; set; } + + public decimal UnitPrice { get; set; } + } +} \ No newline at end of file diff --git a/OrderTrackingApp.ReadPersistence/Models/OrderReadModel.cs b/OrderTrackingApp.ReadPersistence/Models/OrderReadModel.cs new file mode 100644 index 0000000..b5bfaa3 --- /dev/null +++ b/OrderTrackingApp.ReadPersistence/Models/OrderReadModel.cs @@ -0,0 +1,17 @@ +using OrderTrackingApp.Domain.Entities; + +namespace OrderTrackingApp.ReadPersistence.Models +{ + public class OrderReadModel + { + public Guid Id { get; set; } + + public Guid CustomerId { get; set; } + + public OrderStatus Status { get; set; } + + public DateTime CreatedAt { get; set; } + + public List Items { get; set; } = []; + } +} \ No newline at end of file diff --git a/OrderTrackingApp.ReadPersistence/MongoDbContext.cs b/OrderTrackingApp.ReadPersistence/MongoDbContext.cs new file mode 100644 index 0000000..cf395fc --- /dev/null +++ b/OrderTrackingApp.ReadPersistence/MongoDbContext.cs @@ -0,0 +1,22 @@ +using Microsoft.Extensions.Configuration; +using MongoDB.Bson; +using MongoDB.Bson.Serialization; +using MongoDB.Bson.Serialization.Serializers; +using MongoDB.Driver; + +namespace OrderTrackingApp.ReadPersistence +{ + public class MongoDbContext + { + public IMongoDatabase Database { get; } + + public MongoDbContext(IConfiguration config) + { + BsonSerializer.RegisterSerializer(new GuidSerializer(GuidRepresentation.Standard)); + + var connectionString = config.GetConnectionString("MongoDb"); + var mongoClient = new MongoClient(connectionString); + Database = mongoClient.GetDatabase("OrderReadDb"); + } + } +} diff --git a/OrderTrackingApp.ReadPersistence/OrderTrackingApp.ReadPersistence.csproj b/OrderTrackingApp.ReadPersistence/OrderTrackingApp.ReadPersistence.csproj new file mode 100644 index 0000000..112c0f0 --- /dev/null +++ b/OrderTrackingApp.ReadPersistence/OrderTrackingApp.ReadPersistence.csproj @@ -0,0 +1,19 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + diff --git a/OrderTrackingApp.ReadPersistence/Repositories/OrderReadRepository.cs b/OrderTrackingApp.ReadPersistence/Repositories/OrderReadRepository.cs new file mode 100644 index 0000000..96c23ca --- /dev/null +++ b/OrderTrackingApp.ReadPersistence/Repositories/OrderReadRepository.cs @@ -0,0 +1,72 @@ +using AutoMapper; +using MongoDB.Driver; +using OrderTrackingApp.Application.Contracts; +using OrderTrackingApp.Application.Contracts.Orders; +using OrderTrackingApp.Domain.Entities; +using OrderTrackingApp.ReadPersistence.Models; + +namespace OrderTrackingApp.ReadPersistence.Repositories +{ + public class OrderReadRepository(MongoDbContext context, IMapper mapper) : IOrderReadRepository + { + private readonly IMongoCollection _collection = context.Database.GetCollection("Orders"); + + public async Task> GetAllAsync() + { + var orders = await _collection.Find(_ => true).ToListAsync(); + + return mapper.Map>(orders); + } + + public async Task GetByIdAsync(Guid id) + { + var order = await _collection.Find(o => o.Id == id).FirstOrDefaultAsync(); + + return mapper.Map(order); + } + + public async Task> GetPaginatedOrdersAsync(int page, int pageSize, string? status, + DateTime? fromDate, DateTime? toDate, + CancellationToken cancellationToken) + { + var filterBuilder = Builders.Filter; + var filters = new List>(); + + if (!string.IsNullOrWhiteSpace(status) && Enum.TryParse(status, out var parsedStatus)) + { + filters.Add(filterBuilder.Eq(o => o.Status, parsedStatus)); + } + + if (fromDate.HasValue) + { + filters.Add(filterBuilder.Gte(o => o.CreatedAt, fromDate.Value)); + } + + if (toDate.HasValue) + { + filters.Add(filterBuilder.Lte(o => o.CreatedAt, toDate.Value)); + } + + var filter = filters.Any() ? filterBuilder.And(filters) : FilterDefinition.Empty; + + var totalCount = await _collection.CountDocumentsAsync(filter, cancellationToken: cancellationToken); + + var orders = await _collection.Find(filter) + .SortByDescending(o => o.CreatedAt) + .Skip((page - 1) * pageSize) + .Limit(pageSize) + .ToListAsync(cancellationToken); + + var dtos = mapper.Map>(orders); + + return new PaginatedResult(dtos, totalCount, page, pageSize); + } + + public async Task InsertAsync(OrderDto orderDto) + { + var order = mapper.Map(orderDto); + + await _collection.InsertOneAsync(order); + } + } +} diff --git a/OrderTrackingApp.sln b/OrderTrackingApp.sln index 3c65601..1afc192 100644 --- a/OrderTrackingApp.sln +++ b/OrderTrackingApp.sln @@ -1,31 +1,40 @@  Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 -VisualStudioVersion = 17.14.36203.30 d17.14 +VisualStudioVersion = 17.14.36203.30 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OrderTrackingApp.Blazor.Server", "OrderTrackingApp\OrderTrackingApp.Blazor.Server.csproj", "{C0F58571-B42E-4301-BD65-F3E939EA37AA}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OrderTrackingApp.Blazor.Client", "OrderTrackingApp.Client\OrderTrackingApp.Blazor.Client.csproj", "{F4760EDE-BA06-4095-B2B5-6221E2EAC48F}" -EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}" + ProjectSection(SolutionItems) = preProject + .env = .env + docker-compose.yml = docker-compose.yml + README.md = README.md + EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{D901624D-8396-4393-B3AE-7D8405C89D69}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OrderTrackingApp.Api", "..\OrderTrackingApp.Api\OrderTrackingApp.Api.csproj", "{4957811C-F9FB-4259-9E1B-7F7275E75DDB}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OrderTrackingApp.Api", "OrderTrackingApp.Api\OrderTrackingApp.Api.csproj", "{4957811C-F9FB-4259-9E1B-7F7275E75DDB}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OrderTrackingApp.Application", "OrderTrackingApp.Application\OrderTrackingApp.Application.csproj", "{059A6F30-A0CC-4371-853A-50C76C01FEEE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OrderTrackingApp.Domain", "OrderTrackingApp.Domain\OrderTrackingApp.Domain.csproj", "{FE84DD55-5B8A-4629-85FC-5E403D1EFADB}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OrderTrackingApp.Infrastructure", "OrderTrackingApp.Infrastructure\OrderTrackingApp.Infrastructure.csproj", "{F8E303DF-A47D-41C5-AF05-9673DAF4033A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OrderTrackingApp.Persistence", "OrderTrackingApp.Persistence\OrderTrackingApp.Persistence.csproj", "{E0B58B04-C47B-42D8-8AC5-0134B811855C}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OrderTrackingApp.SignalR", "..\OrderTrackingApp.SignalR\OrderTrackingApp.SignalR.csproj", "{8D2C3C59-0A56-4DFD-ABDD-D2C7521C351D}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OrderTrackingApp.ReadPersistence", "OrderTrackingApp.ReadPersistence\OrderTrackingApp.ReadPersistence.csproj", "{AC2F58F3-C7DF-4908-83B9-98841B365CA9}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OrderTrackingApp.Application", "..\OrderTrackingApp.Application\OrderTrackingApp.Application.csproj", "{059A6F30-A0CC-4371-853A-50C76C01FEEE}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OrderTrackingApp.Consumer", "OrderTrackingApp.Consumer\OrderTrackingApp.Consumer.csproj", "{773AD28D-63B8-4A78-90D0-A5282F3BAFDD}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OrderTrackingApp.Domain", "..\OrderTrackingApp.Domain\OrderTrackingApp.Domain.csproj", "{FE84DD55-5B8A-4629-85FC-5E403D1EFADB}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OrderTrackingApp.Blazor.Server", "OrderTrackingApp.Blazor.Server\OrderTrackingApp.Blazor.Server.csproj", "{1FD0A91F-99A3-4D45-AA2B-3081183FEAC3}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OrderTrackingApp.Infrastructure", "..\OrderTrackingApp.Infrastructure\OrderTrackingApp.Infrastructure.csproj", "{F8E303DF-A47D-41C5-AF05-9673DAF4033A}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OrderTrackingApp.Blazor.Client", "OrderTrackingApp.Blazor.Client\OrderTrackingApp.Blazor.Client.csproj", "{43A85AAD-5188-4103-BEF6-F88971B638F2}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OrderTrackingApp.Persistence", "..\OrderTrackingApp.Persistence\OrderTrackingApp.Persistence.csproj", "{E0B58B04-C47B-42D8-8AC5-0134B811855C}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OrderTrackingApp.Persistence.Tests", "OrderTrackingApp.Persistence.Tests\OrderTrackingApp.Persistence.Tests.csproj", "{77A97D9B-3156-47EE-91A1-8D6F0D9BABE3}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OrderTrackingApp.Consumer", "..\OrderTrackingApp.Consumer\OrderTrackingApp.Consumer.csproj", "{3885E6E2-5813-4669-9295-051E8BEA9B0F}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OrderTrackingApp.MigrationRunner", "OrderTrackingApp.MigrationRunner\OrderTrackingApp.MigrationRunner.csproj", "{C9DE7296-2132-490F-97DE-96FF65C7A5BB}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OrderTrackingApp.ReadPersistence", "..\OrderTrackingApp.ReadPersistence\OrderTrackingApp.ReadPersistence.csproj", "{AC2F58F3-C7DF-4908-83B9-98841B365CA9}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OrderTrackingApp.Application.Contracts", "OrderTrackingApp.Application.Contracts\OrderTrackingApp.Application.Contracts.csproj", "{E9BB177D-D125-4D7F-A599-126DADEDCDD1}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -33,22 +42,10 @@ Global Release|Any CPU = Release|Any CPU EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution - {C0F58571-B42E-4301-BD65-F3E939EA37AA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {C0F58571-B42E-4301-BD65-F3E939EA37AA}.Debug|Any CPU.Build.0 = Debug|Any CPU - {C0F58571-B42E-4301-BD65-F3E939EA37AA}.Release|Any CPU.ActiveCfg = Release|Any CPU - {C0F58571-B42E-4301-BD65-F3E939EA37AA}.Release|Any CPU.Build.0 = Release|Any CPU - {F4760EDE-BA06-4095-B2B5-6221E2EAC48F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {F4760EDE-BA06-4095-B2B5-6221E2EAC48F}.Debug|Any CPU.Build.0 = Debug|Any CPU - {F4760EDE-BA06-4095-B2B5-6221E2EAC48F}.Release|Any CPU.ActiveCfg = Release|Any CPU - {F4760EDE-BA06-4095-B2B5-6221E2EAC48F}.Release|Any CPU.Build.0 = Release|Any CPU {4957811C-F9FB-4259-9E1B-7F7275E75DDB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {4957811C-F9FB-4259-9E1B-7F7275E75DDB}.Debug|Any CPU.Build.0 = Debug|Any CPU {4957811C-F9FB-4259-9E1B-7F7275E75DDB}.Release|Any CPU.ActiveCfg = Release|Any CPU {4957811C-F9FB-4259-9E1B-7F7275E75DDB}.Release|Any CPU.Build.0 = Release|Any CPU - {8D2C3C59-0A56-4DFD-ABDD-D2C7521C351D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {8D2C3C59-0A56-4DFD-ABDD-D2C7521C351D}.Debug|Any CPU.Build.0 = Debug|Any CPU - {8D2C3C59-0A56-4DFD-ABDD-D2C7521C351D}.Release|Any CPU.ActiveCfg = Release|Any CPU - {8D2C3C59-0A56-4DFD-ABDD-D2C7521C351D}.Release|Any CPU.Build.0 = Release|Any CPU {059A6F30-A0CC-4371-853A-50C76C01FEEE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {059A6F30-A0CC-4371-853A-50C76C01FEEE}.Debug|Any CPU.Build.0 = Debug|Any CPU {059A6F30-A0CC-4371-853A-50C76C01FEEE}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -65,29 +62,51 @@ Global {E0B58B04-C47B-42D8-8AC5-0134B811855C}.Debug|Any CPU.Build.0 = Debug|Any CPU {E0B58B04-C47B-42D8-8AC5-0134B811855C}.Release|Any CPU.ActiveCfg = Release|Any CPU {E0B58B04-C47B-42D8-8AC5-0134B811855C}.Release|Any CPU.Build.0 = Release|Any CPU - {3885E6E2-5813-4669-9295-051E8BEA9B0F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {3885E6E2-5813-4669-9295-051E8BEA9B0F}.Debug|Any CPU.Build.0 = Debug|Any CPU - {3885E6E2-5813-4669-9295-051E8BEA9B0F}.Release|Any CPU.ActiveCfg = Release|Any CPU - {3885E6E2-5813-4669-9295-051E8BEA9B0F}.Release|Any CPU.Build.0 = Release|Any CPU {AC2F58F3-C7DF-4908-83B9-98841B365CA9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {AC2F58F3-C7DF-4908-83B9-98841B365CA9}.Debug|Any CPU.Build.0 = Debug|Any CPU {AC2F58F3-C7DF-4908-83B9-98841B365CA9}.Release|Any CPU.ActiveCfg = Release|Any CPU {AC2F58F3-C7DF-4908-83B9-98841B365CA9}.Release|Any CPU.Build.0 = Release|Any CPU + {773AD28D-63B8-4A78-90D0-A5282F3BAFDD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {773AD28D-63B8-4A78-90D0-A5282F3BAFDD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {773AD28D-63B8-4A78-90D0-A5282F3BAFDD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {773AD28D-63B8-4A78-90D0-A5282F3BAFDD}.Release|Any CPU.Build.0 = Release|Any CPU + {1FD0A91F-99A3-4D45-AA2B-3081183FEAC3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1FD0A91F-99A3-4D45-AA2B-3081183FEAC3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1FD0A91F-99A3-4D45-AA2B-3081183FEAC3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1FD0A91F-99A3-4D45-AA2B-3081183FEAC3}.Release|Any CPU.Build.0 = Release|Any CPU + {43A85AAD-5188-4103-BEF6-F88971B638F2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {43A85AAD-5188-4103-BEF6-F88971B638F2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {43A85AAD-5188-4103-BEF6-F88971B638F2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {43A85AAD-5188-4103-BEF6-F88971B638F2}.Release|Any CPU.Build.0 = Release|Any CPU + {77A97D9B-3156-47EE-91A1-8D6F0D9BABE3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {77A97D9B-3156-47EE-91A1-8D6F0D9BABE3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {77A97D9B-3156-47EE-91A1-8D6F0D9BABE3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {77A97D9B-3156-47EE-91A1-8D6F0D9BABE3}.Release|Any CPU.Build.0 = Release|Any CPU + {C9DE7296-2132-490F-97DE-96FF65C7A5BB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C9DE7296-2132-490F-97DE-96FF65C7A5BB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C9DE7296-2132-490F-97DE-96FF65C7A5BB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C9DE7296-2132-490F-97DE-96FF65C7A5BB}.Release|Any CPU.Build.0 = Release|Any CPU + {E9BB177D-D125-4D7F-A599-126DADEDCDD1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E9BB177D-D125-4D7F-A599-126DADEDCDD1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E9BB177D-D125-4D7F-A599-126DADEDCDD1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E9BB177D-D125-4D7F-A599-126DADEDCDD1}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution - {C0F58571-B42E-4301-BD65-F3E939EA37AA} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} - {F4760EDE-BA06-4095-B2B5-6221E2EAC48F} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} {4957811C-F9FB-4259-9E1B-7F7275E75DDB} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} - {8D2C3C59-0A56-4DFD-ABDD-D2C7521C351D} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} {059A6F30-A0CC-4371-853A-50C76C01FEEE} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} {FE84DD55-5B8A-4629-85FC-5E403D1EFADB} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} {F8E303DF-A47D-41C5-AF05-9673DAF4033A} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} {E0B58B04-C47B-42D8-8AC5-0134B811855C} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} - {3885E6E2-5813-4669-9295-051E8BEA9B0F} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} {AC2F58F3-C7DF-4908-83B9-98841B365CA9} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} + {773AD28D-63B8-4A78-90D0-A5282F3BAFDD} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} + {1FD0A91F-99A3-4D45-AA2B-3081183FEAC3} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} + {43A85AAD-5188-4103-BEF6-F88971B638F2} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} + {77A97D9B-3156-47EE-91A1-8D6F0D9BABE3} = {D901624D-8396-4393-B3AE-7D8405C89D69} + {C9DE7296-2132-490F-97DE-96FF65C7A5BB} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} + {E9BB177D-D125-4D7F-A599-126DADEDCDD1} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {ECFA56EC-C322-4A64-8936-E930A2EEBE28} diff --git a/OrderTrackingApp/OrderTrackingApp.Blazor.Server.csproj b/OrderTrackingApp/OrderTrackingApp.Blazor.Server.csproj deleted file mode 100644 index 8eac3cb..0000000 --- a/OrderTrackingApp/OrderTrackingApp.Blazor.Server.csproj +++ /dev/null @@ -1,14 +0,0 @@ - - - - net8.0 - enable - enable - - - - - - - - diff --git a/README.md b/README.md index 0a94371..c6669d0 100644 --- a/README.md +++ b/README.md @@ -1 +1,112 @@ -# OrderTrackingApp \ No newline at end of file +[![Parallel Docker Builds with Cache](https://github.com/ParagNaikade/OrderTrackingApp/actions/workflows/ci-pipeline.yml/badge.svg)](https://github.com/ParagNaikade/OrderTrackingApp/actions/workflows/ci-pipeline.yml) + +# 🧾 Order Tracking System + +A distributed order processing system built with **.NET Clean Architecture**, featuring **CQRS**, **RabbitMQ messaging**, and dual persistence in **SQL Server** and **MongoDB**. + +This project demonstrates a scalable microservice pattern using domain events and message queues to decouple the system. + +--- + +## 📐 Architecture Overview + +``` +API ──> Application ──> Infra ──> RabbitMQ ──> Consumer + │ │ + SQL Server MongoDB +``` + +### 🔄 Flow Description + +1. **API** + - Exposes endpoints to create and retrieve orders. + - Triggers a `SaveOrderCommand` when a POST request is received. + +2. **Application** + - Implements **CQRS** to separate command and query logic. + - Handles business rules (e.g., inventory validation). + - Saves the order to **SQL Server**. + - Raises an `OrderCreatedEvent` as a domain event. + +3. **Infra** + - Listens for domain events. + - Publishes messages to **RabbitMQ** queue (`order.created`). + +4. **Consumer** + - Subscribes to the RabbitMQ queue. + - Reads order details from SQL. + - Syncs the order into **MongoDB** for read-side optimization or analytics. + +--- + +## 🛠️ Tech Stack + +| Layer | Technology | +|--------------|--------------------------------| +| API | ASP.NET Core Web API | +| Application | MediatR, CQRS, FluentValidation| +| Infra | Entity Framework Core, RabbitMQ| +| Consumer | Background Worker, MongoDB | +| Messaging | RabbitMQ | +| Databases | SQL Server, MongoDB | +| Architecture | Clean Architecture | +| DevOps | Docker, GitHub Actions (CI/CD) | + +--- + +## 📂 Project Structure + +``` +OrderTrackingApp/ +├── API # Entry point +├── Application # CQRS, Commands/Queries, COre app logic +├── Application.Contracts # Interfaces, DTOs, Validation +├── Domain # Entities, Value Objects, Events +├── Infrastructure # Event Handlers, RabbitMQ +├── Consumer # Background service that syncs to MongoDB +├── ReadPersistence # MongoDB Read Models +├── Persistence # EF Core +└── docker-compose.yml # Services for RabbitMQ, SQL Server, MongoDB +``` + +--- + +## 🚀 Getting Started + +### Prerequisites + +- [.NET 8 SDK](https://dotnet.microsoft.com/) +- [Docker](https://www.docker.com/) +- [MongoDB Compass](https://www.mongodb.com/products/compass) (optional for viewing MongoDB) +- [RabbitMQ Management UI](http://localhost:15672) (user: `guest`, password: `guest`) + +### Run Locally + +```bash +git clone https://github.com/ParagNaikade/order-tracking-app.git +cd order-tracking-app +docker-compose up --build +``` + +### Test API + +- `POST /api/orders` → Creates an order +- `GET /api/orders/{id}` → Retrieves an order +- `GET /api/orders` → Retrieves paginated list + +--- + +## 🧪 Features + +- ✅ Clean Architecture (Separation of Concerns) +- ✅ CQRS with MediatR +- ✅ Domain Events and Messaging +- ✅ Async Communication via RabbitMQ +- ✅ SQL Write DB & MongoDB Read DB (eventual consistency) +- ✅ Dockerized Microservices + +--- + +## 📬 Contact + +Created with ❤️ by [Parag Naikade](https://paragnaikade.com/) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..fab4db5 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,42 @@ +services: + sqlserver: + image: mcr.microsoft.com/mssql/server:2022-latest + container_name: sqlserver + ports: + - "1433:1433" + environment: + MSSQL_SA_PASSWORD: ${MSSQL_SA_PASSWORD} + ACCEPT_EULA: "Y" + volumes: + - sql_data:/var/opt/mssql + + mongodb: + image: mongo + container_name: mongodb + ports: + - "27017:27017" + environment: + MONGO_INITDB_ROOT_USERNAME: ${MONGO_INITDB_ROOT_USERNAME} + MONGO_INITDB_ROOT_PASSWORD: ${MONGO_INITDB_ROOT_PASSWORD} + volumes: + - mongo_data:/data/db + + rabbitmq: + image: rabbitmq:4-management + container_name: rabbitmq + ports: + - "5672:5672" + - "15672:15672" + environment: + RABBITMQ_DEFAULT_USER: ${RABBITMQ_DEFAULT_USER} + RABBITMQ_DEFAULT_PASS: ${RABBITMQ_DEFAULT_PASS} + volumes: + - rabbitmq_data:/var/lib/rabbitmq + +volumes: + sql_data: + name: sql_data + mongo_data: + name: mongo_data + rabbitmq_data: + name: rabbitmq_data diff --git a/nscacert.crt b/nscacert.crt new file mode 100644 index 0000000..8fd2269 --- /dev/null +++ b/nscacert.crt @@ -0,0 +1,24 @@ +-----BEGIN CERTIFICATE----- +MIID+jCCAuKgAwIBAgICATgwDQYJKoZIhvcNAQELBQAwgZYxCzAJBgNVBAYTAkFV +MQwwCgYDVQQIEwNWSUMxEjAQBgNVBAcTCU1lbGJvdXJuZTEWMBQGA1UEChMNTmV0 +c2tvcGUgSW5jLjESMBAGA1UECxMJY2VydGFkbWluMRIwEAYDVQQDEwljZXJ0YWRt +aW4xJTAjBgkqhkiG9w0BCQEWFmNlcnRhZG1pbkBuZXRza29wZS5jb20wHhcNMjEw +MzIxMDUzODMyWhcNMzEwMzE5MDUzODMyWjCBljELMAkGA1UEBhMCQVUxDDAKBgNV +BAgTA1ZJQzESMBAGA1UEBxMJTWVsYm91cm5lMRYwFAYDVQQKEw1OZXRza29wZSBJ +bmMuMRIwEAYDVQQLEwljZXJ0YWRtaW4xEjAQBgNVBAMTCWNlcnRhZG1pbjElMCMG +CSqGSIb3DQEJARYWY2VydGFkbWluQG5ldHNrb3BlLmNvbTCCASIwDQYJKoZIhvcN +AQEBBQADggEPADCCAQoCggEBAK5aO8+dAaPOlPmc+YtaYpnxByrOtx8HDapDM8WD +7/8Y6obWjn0+uP6A0P3rtlECif0NHpKMIY10Kd+WImrMKeEDBkjK4IN4fHff2ji9 +dVPpvqBakGxpFdSDikLHkxswIJBwXPiAfvPPDYNt5tnWRp+C23u3Wj2ocLqAdyuM ++GGUc8lO5yCpDZXsyYZT6ycgAfzRf2IVYCS88l0hqMN45Z/9w/EixAFMamNho61x +x6YQhOzTugSdEr3sFm2hs+ijKZCorGe6eQIlSm4abAQXFLGOFPmRM+Q6MAx6nuV7 +d8+PIyDf+SHBxlAbQkeBucznByLG+HcZAlK6nnuWRgT97B0CAwEAAaNQME4wDAYD +VR0TBAUwAwEB/zAdBgNVHQ4EFgQUN2YGoXPDWG0auDkFXzQEwfPdSrYwHwYDVR0j +BBgwFoAUN2YGoXPDWG0auDkFXzQEwfPdSrYwDQYJKoZIhvcNAQELBQADggEBAC3D +qtNM0uZYsNeBwLCU2MEV9gV+BYBPwJvsXgIBiP2wH8VQ/QXbFw2HW3adVaEb0JgX +Qn3SUaw2sd2SeCw6KyRoXQg+Y06wEl3iTenzbpClp7sxVFB6II9o5aQPNXiAwcM1 +lRUSWOU8kUMYFbq2ndUonJX8eB4QiOzA7JppYf4dNgBXETxBngG1uFlZgGEN6Ars +o6iTHfZ+CTptlkuSjv2sWbK0pkIdsMHf2bScw3lu1oR3hwZCSdxI6Q5tXK4XsLLe +hAM5vIcMC434S3RV6iN9VW2wWYrQ9DPnHxw7x7fwUAcTaxIhOEJF7AhuCtg5mjlP +qwf9GAyaVt+8ilbHYdw= +-----END CERTIFICATE-----