diff --git a/.github/workflows/ci-pipeline.yml b/.github/workflows/ci-pipeline.yml index f247233..c8c5052 100644 --- a/.github/workflows/ci-pipeline.yml +++ b/.github/workflows/ci-pipeline.yml @@ -20,6 +20,8 @@ jobs: 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 diff --git a/OrderTrackingApp.Api/Controllers/OrdersController.cs b/OrderTrackingApp.Api/Controllers/OrdersController.cs new file mode 100644 index 0000000..13dd792 --- /dev/null +++ b/OrderTrackingApp.Api/Controllers/OrdersController.cs @@ -0,0 +1,22 @@ +using AutoMapper; +using MediatR; +using Microsoft.AspNetCore.Mvc; +using OrderTrackingApp.Api.Models; +using OrderTrackingApp.Application.Commands.Orders; + +namespace OrderTrackingApp.Api.Controllers +{ + [Route("api/[controller]")] + [ApiController] + public class OrdersController(IMediator mediator, IMapper mapper) : ControllerBase + { + [HttpPost] + 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 }); + } + } +} diff --git a/OrderTrackingApp.Api/Controllers/WeatherForecastController.cs b/OrderTrackingApp.Api/Controllers/WeatherForecastController.cs deleted file mode 100644 index 13b5931..0000000 --- a/OrderTrackingApp.Api/Controllers/WeatherForecastController.cs +++ /dev/null @@ -1,33 +0,0 @@ -using Microsoft.AspNetCore.Mvc; - -namespace OrderTrackingApp.Api.Controllers -{ - [ApiController] - [Route("[controller]")] - public class WeatherForecastController : ControllerBase - { - private static readonly string[] Summaries = new[] - { - "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" - }; - - private readonly ILogger _logger; - - public WeatherForecastController(ILogger logger) - { - _logger = logger; - } - - [HttpGet(Name = "GetWeatherForecast")] - public IEnumerable Get() - { - return Enumerable.Range(1, 5).Select(index => new WeatherForecast - { - Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)), - TemperatureC = Random.Shared.Next(-20, 55), - Summary = Summaries[Random.Shared.Next(Summaries.Length)] - }) - .ToArray(); - } - } -} diff --git a/OrderTrackingApp.Api/Extensions/ApiServiceRegistrations.cs b/OrderTrackingApp.Api/Extensions/ApiServiceRegistrations.cs new file mode 100644 index 0000000..d0d3e4f --- /dev/null +++ b/OrderTrackingApp.Api/Extensions/ApiServiceRegistrations.cs @@ -0,0 +1,27 @@ +using FluentValidation; +using OrderTrackingApp.Api.MappingProfiles; +using OrderTrackingApp.Api.Validators; +using System.Text.Json; + +namespace OrderTrackingApp.Api.Extensions +{ + public static class ApiServiceRegistrations + { + public static IServiceCollection AddApiServices(this IServiceCollection services) + { + services.AddEndpointsApiExplorer(); + services.AddSwaggerGen(); + + services.AddControllers() + .AddJsonOptions(options => + { + options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; + }); + + services.AddValidatorsFromAssemblyContaining(); + services.AddAutoMapper(config => { }, typeof(OrderMappingProfile).Assembly); + + return services; + } + } +} diff --git a/OrderTrackingApp.Api/MappingProfiles/OrderMappingProfile.cs b/OrderTrackingApp.Api/MappingProfiles/OrderMappingProfile.cs new file mode 100644 index 0000000..72900a1 --- /dev/null +++ b/OrderTrackingApp.Api/MappingProfiles/OrderMappingProfile.cs @@ -0,0 +1,15 @@ +using AutoMapper; +using OrderTrackingApp.Api.Models; +using OrderTrackingApp.Application.Commands.Orders; + +namespace OrderTrackingApp.Api.MappingProfiles +{ + public class OrderMappingProfile : Profile + { + public OrderMappingProfile() + { + 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/OrderTrackingApp.Api.csproj b/OrderTrackingApp.Api/OrderTrackingApp.Api.csproj index 21a8bb8..2dfe61f 100644 --- a/OrderTrackingApp.Api/OrderTrackingApp.Api.csproj +++ b/OrderTrackingApp.Api/OrderTrackingApp.Api.csproj @@ -10,10 +10,15 @@ + + + + + diff --git a/OrderTrackingApp.Api/Program.cs b/OrderTrackingApp.Api/Program.cs index 6950881..571889f 100644 --- a/OrderTrackingApp.Api/Program.cs +++ b/OrderTrackingApp.Api/Program.cs @@ -1,25 +1,27 @@ +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); -// Add services to the container. - -builder.Services.AddControllers(); -// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle -builder.Services.AddEndpointsApiExplorer(); -builder.Services.AddSwaggerGen(); - builder.Services.AddPersistenceServices(builder.Configuration); -builder.Services.AddReadPersistence(builder.Configuration); +builder.Services.AddReadPersistence(); +builder.Services.AddInfrastructureServices(); +builder.Services.AddApplicationServices(); +builder.Services.AddApiServices(); var app = builder.Build(); -// Configure the HTTP request pipeline. if (app.Environment.IsDevelopment()) { - app.UseSwagger(); - app.UseSwaggerUI(); + app.UseSwagger(options => + { + options.RouteTemplate = "/openapi/{documentName}.json"; + }); + app.MapScalarApiReference(); } app.UseHttpsRedirection(); diff --git a/OrderTrackingApp.Api/Properties/launchSettings.json b/OrderTrackingApp.Api/Properties/launchSettings.json index a5c6743..4c1dc4f 100644 --- a/OrderTrackingApp.Api/Properties/launchSettings.json +++ b/OrderTrackingApp.Api/Properties/launchSettings.json @@ -12,9 +12,10 @@ "http": { "commandName": "Project", "dotnetRunMessages": true, - "launchBrowser": true, - "launchUrl": "swagger", + "launchBrowser": false, + "launchUrl": "scalar", "applicationUrl": "http://localhost:5077", + "hotReloadEnabled": false, "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.Api/appsettings.json b/OrderTrackingApp.Api/appsettings.json index b558c5c..917aef7 100644 --- a/OrderTrackingApp.Api/appsettings.json +++ b/OrderTrackingApp.Api/appsettings.json @@ -7,8 +7,8 @@ }, "AllowedHosts": "*", "ConnectionStrings": { - "DefaultConnection": "", - "MongoDb": "" + "SqlConnection": "Server=localhost,1433;Database=OrderDb;User=sa;Password=YourStrong!Passw0rd;TrustServerCertificate=True", + "MongoDb": "mongodb://mongoadmin:secret123@localhost:27017", + "RabbitMq": "amqp://guest:guest@localhost:5672/" } - } diff --git a/OrderTrackingApp.Application/Commands/Orders/CreateOrderCommand.cs b/OrderTrackingApp.Application/Commands/Orders/CreateOrderCommand.cs index dba7b19..3ec6b5c 100644 --- a/OrderTrackingApp.Application/Commands/Orders/CreateOrderCommand.cs +++ b/OrderTrackingApp.Application/Commands/Orders/CreateOrderCommand.cs @@ -1,10 +1,18 @@ using MediatR; -using OrderTrackingApp.Application.DTOs; namespace OrderTrackingApp.Application.Commands.Orders { - public class CreateOrderCommand : IRequest + public class CreateOrderCommand : IRequest { - public string CustomerName { get; set; } = string.Empty; + 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/Commands/Orders/CreateOrderCommandHandler.cs b/OrderTrackingApp.Application/Commands/Orders/CreateOrderCommandHandler.cs index 170e8e4..9f77403 100644 --- a/OrderTrackingApp.Application/Commands/Orders/CreateOrderCommandHandler.cs +++ b/OrderTrackingApp.Application/Commands/Orders/CreateOrderCommandHandler.cs @@ -1,36 +1,59 @@ using MediatR; -using OrderTrackingApp.Application.DTOs; using OrderTrackingApp.Application.Interfaces; using OrderTrackingApp.Domain.Entities; +using OrderTrackingApp.Domain.Events; +using OrderTrackingApp.Domain.Interfaces; namespace OrderTrackingApp.Application.Commands.Orders { - public class CreateOrderCommandHandler : IRequestHandler + public class CreateOrderCommandHandler(IOrderWriteRepository orderRepository, IProductRepository productRepository, IMediator mediator) + : IRequestHandler { - private readonly IOrderWriteRepository _orderRepository; - - public CreateOrderCommandHandler(IOrderWriteRepository orderRepository) + public async Task Handle(CreateOrderCommand request, CancellationToken cancellationToken) { - _orderRepository = orderRepository; - } + 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 OrderItem { ProductId = item.ProductId, Quantity = item.Quantity, UnitPrice = product.Price }); + + product.StockQuantity -= item.Quantity; + + updatedProducts.Add(product); + } + + await productRepository.UpdateProducts(updatedProducts); - public async Task Handle(CreateOrderCommand request, CancellationToken cancellationToken) - { var order = new Order { - CustomerName = request.CustomerName, + Id = Guid.NewGuid(), + OrderNumber = $"ORD-{DateTime.UtcNow.Ticks}", + CustomerId = request.CustomerId, + OrderDate = DateTime.UtcNow, Status = OrderStatus.Pending, + Items = orderItems }; - await _orderRepository.AddAsync(order); + await orderRepository.AddAsync(order); - return new OrderDto - { - Id = order.Id, - CustomerName = order.CustomerName, - Status = order.Status, - CreatedAt = order.CreatedAt - }; + await mediator.Publish(new OrderCreatedEvent(order.Id), cancellationToken); + + return order.Id; } } } diff --git a/OrderTrackingApp.Application/Extensions/ApplicationServiceRegistrations.cs b/OrderTrackingApp.Application/Extensions/ApplicationServiceRegistrations.cs new file mode 100644 index 0000000..92f2ea3 --- /dev/null +++ b/OrderTrackingApp.Application/Extensions/ApplicationServiceRegistrations.cs @@ -0,0 +1,14 @@ +using Microsoft.Extensions.DependencyInjection; +using OrderTrackingApp.Application.Commands.Orders; + +namespace OrderTrackingApp.Application.Extensions +{ + public static class ApplicationServiceRegistrations + { + public static IServiceCollection AddApplicationServices(this IServiceCollection services) + { + services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(CreateOrderCommandHandler).Assembly)); + return services; + } + } +} diff --git a/OrderTrackingApp.Application/Interfaces/IOrderWriteRepository.cs b/OrderTrackingApp.Application/Interfaces/IOrderWriteRepository.cs index c31d16a..183b1fb 100644 --- a/OrderTrackingApp.Application/Interfaces/IOrderWriteRepository.cs +++ b/OrderTrackingApp.Application/Interfaces/IOrderWriteRepository.cs @@ -9,5 +9,7 @@ public interface IOrderWriteRepository Task UpdateAsync(Order order); Task DeleteAsync(Guid id); + + Task GetByIdAsync(Guid id); } } diff --git a/OrderTrackingApp.Blazor.Server/Program.cs b/OrderTrackingApp.Blazor.Server/Program.cs index fd8b203..d784bf9 100644 --- a/OrderTrackingApp.Blazor.Server/Program.cs +++ b/OrderTrackingApp.Blazor.Server/Program.cs @@ -1,4 +1,3 @@ -using OrderTrackingApp.Blazor.Client.Pages; using OrderTrackingApp.Blazor.Server.Components; var builder = WebApplication.CreateBuilder(args); diff --git a/OrderTrackingApp.Consumer/OrderTrackingApp.Consumer.csproj b/OrderTrackingApp.Consumer/OrderTrackingApp.Consumer.csproj index 0fdd151..48f1333 100644 --- a/OrderTrackingApp.Consumer/OrderTrackingApp.Consumer.csproj +++ b/OrderTrackingApp.Consumer/OrderTrackingApp.Consumer.csproj @@ -9,5 +9,11 @@ + + + + + + diff --git a/OrderTrackingApp.Consumer/Program.cs b/OrderTrackingApp.Consumer/Program.cs index d13263b..3cb619d 100644 --- a/OrderTrackingApp.Consumer/Program.cs +++ b/OrderTrackingApp.Consumer/Program.cs @@ -1,7 +1,12 @@ using OrderTrackingApp.Consumer; +using OrderTrackingApp.Persistence.Extensions; +using OrderTrackingApp.ReadPersistence.Extensions; var builder = Host.CreateApplicationBuilder(args); builder.Services.AddHostedService(); +builder.Services.AddPersistenceServices(builder.Configuration); +builder.Services.AddReadPersistence(); + var host = builder.Build(); host.Run(); diff --git a/OrderTrackingApp.Consumer/Properties/launchSettings.json b/OrderTrackingApp.Consumer/Properties/launchSettings.json index df9bcc3..49b44b4 100644 --- a/OrderTrackingApp.Consumer/Properties/launchSettings.json +++ b/OrderTrackingApp.Consumer/Properties/launchSettings.json @@ -4,6 +4,7 @@ "OrderTrackingApp.Consumer": { "commandName": "Project", "dotnetRunMessages": true, + "hotReloadEnabled": false, "environmentVariables": { "DOTNET_ENVIRONMENT": "Development" } diff --git a/OrderTrackingApp.Consumer/Worker.cs b/OrderTrackingApp.Consumer/Worker.cs index c08d5b4..683e1f2 100644 --- a/OrderTrackingApp.Consumer/Worker.cs +++ b/OrderTrackingApp.Consumer/Worker.cs @@ -1,24 +1,100 @@ -namespace OrderTrackingApp.Consumer +using OrderTrackingApp.Application.Interfaces; +using OrderTrackingApp.Domain.Events; +using OrderTrackingApp.ReadPersistence.Interfaces; +using OrderTrackingApp.ReadPersistence.Models; +using RabbitMQ.Client; +using RabbitMQ.Client.Events; +using System.Text; +using System.Text.Json; + +namespace OrderTrackingApp.Consumer; + +public class Worker : BackgroundService { - public class Worker : BackgroundService + private readonly ILogger _logger; + private readonly IServiceProvider _serviceProvider; + private IConnection? _connection; + private IChannel? _channel; + + public Worker(ILogger logger, IServiceProvider serviceProvider) { - private readonly ILogger _logger; + _logger = logger; + _serviceProvider = serviceProvider; + } - public Worker(ILogger logger) - { - _logger = logger; - } + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + await InitializeRabbitMqListener(); - protected override async Task ExecuteAsync(CancellationToken stoppingToken) + var consumer = new AsyncEventingBasicConsumer(_channel!); + + consumer.ReceivedAsync += async (model, ea) => { - while (!stoppingToken.IsCancellationRequested) + try { - if (_logger.IsEnabled(LogLevel.Information)) + 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) { - _logger.LogInformation("Worker running at../: {time}", DateTimeOffset.Now); + var order = await orderWriteRepository.GetByIdAsync(orderEvent.OrderId) + ?? throw new Exception($"Order with ID {orderEvent.OrderId} not found."); + + var readModel = new OrderReadModel + { + Id = order.Id, + CustomerId = order.CustomerId, + Status = order.Status, + CreatedAt = order.OrderDate, + Items = order.Items.Select(i => new OrderItemReadModel + { + ProductId = i.ProductId, + Quantity = i.Quantity, + UnitPrice = i.UnitPrice + }).ToList() + }; + + await orderReadRepository.InsertAsync(readModel); + + _logger.LogInformation("Synced OrderId {orderId} to MongoDB", order.Id); + + await _channel!.BasicAckAsync(deliveryTag: ea.DeliveryTag, multiple: false); } - await Task.Delay(60000, stoppingToken); } - } + 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.json b/OrderTrackingApp.Consumer/appsettings.json index b2dcdb6..576f5d9 100644 --- a/OrderTrackingApp.Consumer/appsettings.json +++ b/OrderTrackingApp.Consumer/appsettings.json @@ -4,5 +4,10 @@ "Default": "Information", "Microsoft.Hosting.Lifetime": "Information" } + }, + "ConnectionStrings": { + "SqlConnection": "Server=localhost,1433;Database=OrderDb;User=sa;Password=YourStrong!Passw0rd;TrustServerCertificate=True", + "MongoDb": "mongodb://mongoadmin:secret123@localhost:27017", + "RabbitMq": "amqp://guest:guest@localhost:5672/" } } diff --git a/OrderTrackingApp.Domain/Entities/Order.cs b/OrderTrackingApp.Domain/Entities/Order.cs index be533df..045d6c2 100644 --- a/OrderTrackingApp.Domain/Entities/Order.cs +++ b/OrderTrackingApp.Domain/Entities/Order.cs @@ -2,12 +2,16 @@ { public class Order { - public Guid Id { get; set; } = Guid.NewGuid(); + public Guid Id { get; set; } - public string CustomerName { get; set; } = string.Empty; + public string OrderNumber { get; set; } = string.Empty; - public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + 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/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 index fa71b7a..db4c3f8 100644 --- a/OrderTrackingApp.Domain/OrderTrackingApp.Domain.csproj +++ b/OrderTrackingApp.Domain/OrderTrackingApp.Domain.csproj @@ -6,4 +6,8 @@ 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 index fa71b7a..ed80f58 100644 --- a/OrderTrackingApp.Infrastructure/OrderTrackingApp.Infrastructure.csproj +++ b/OrderTrackingApp.Infrastructure/OrderTrackingApp.Infrastructure.csproj @@ -6,4 +6,14 @@ enable + + + + + + + + + + diff --git a/OrderTrackingApp.MigrationRunner/Dockerfile b/OrderTrackingApp.MigrationRunner/Dockerfile new file mode 100644 index 0000000..2e7e226 --- /dev/null +++ b/OrderTrackingApp.MigrationRunner/Dockerfile @@ -0,0 +1,31 @@ +FROM mcr.microsoft.com/dotnet/runtime:8.0 AS base +WORKDIR /app + +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +WORKDIR /src + +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.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..117f12c --- /dev/null +++ b/OrderTrackingApp.MigrationRunner/OrderTrackingApp.MigrationRunner.csproj @@ -0,0 +1,30 @@ + + + + Exe + net8.0 + enable + enable + Linux + + + + + + + + + Always + + + + + + + + + + + + + diff --git a/OrderTrackingApp.MigrationRunner/ProductSeeder.cs b/OrderTrackingApp.MigrationRunner/ProductSeeder.cs new file mode 100644 index 0000000..15c49bf --- /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 = 100 + }, + new() + { + Id = Guid.Parse("2a13551b-059c-4035-b275-8a8cb82bd272"), + Name = "Mechanical Keyboard", + Sku = "KEY-101", + Description = "RGB mechanical keyboard", + Price = 79.99m, + StockQuantity = 50 + } + }; + + 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..034bcb2 --- /dev/null +++ b/OrderTrackingApp.Persistence.Tests/OrderTrackingApp.Persistence.Tests.csproj @@ -0,0 +1,27 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + diff --git a/OrderTrackingApp.Persistence.Tests/UnitTest1.cs b/OrderTrackingApp.Persistence.Tests/UnitTest1.cs new file mode 100644 index 0000000..9376a2a --- /dev/null +++ b/OrderTrackingApp.Persistence.Tests/UnitTest1.cs @@ -0,0 +1,44 @@ +using Microsoft.EntityFrameworkCore; +using OrderTrackingApp.Persistence.Repositories; + +namespace OrderTrackingApp.Persistence.Tests +{ + public class UnitTest1 + { + [Fact] + public async Task Test1() + { + var dbContext = CreateDbContext(); + + var repo= new OrderWriteRepository(dbContext); + + var id = Guid.NewGuid(); + await repo.AddAsync(new Domain.Entities.Order + { + Id = id, + CustomerId = Guid.NewGuid(), + OrderDate = DateTime.UtcNow, + Status = Domain.Entities.OrderStatus.Pending, + Items = new List + { + new Domain.Entities.OrderItem + { + ProductId = Guid.NewGuid(), + Quantity = 2, + UnitPrice = 10.0m + } + } + }); + + var order = await repo.GetByIdAsync(id); + } + + private AppDbContext CreateDbContext() + { + var options = new DbContextOptionsBuilder() + .UseSqlServer("Server=localhost,1433;Database=OrderDb;User=sa;Password=YourStrong!Passw0rd;TrustServerCertificate=True") + .Options; + return new AppDbContext(options); + } + } +} \ No newline at end of file diff --git a/OrderTrackingApp.Persistence/AppDbContext.cs b/OrderTrackingApp.Persistence/AppDbContext.cs index c825e68..bb81642 100644 --- a/OrderTrackingApp.Persistence/AppDbContext.cs +++ b/OrderTrackingApp.Persistence/AppDbContext.cs @@ -3,20 +3,65 @@ namespace OrderTrackingApp.Persistence { - public class AppDbContext : DbContext + public class AppDbContext(DbContextOptions options) : DbContext(options) { - public AppDbContext(DbContextOptions options) : base(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(); // maps OrderStatus enum to string + .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/PersistenceServiceRegistration.cs b/OrderTrackingApp.Persistence/Extensions/PersistenceServiceRegistrations.cs similarity index 84% rename from OrderTrackingApp.Persistence/Extensions/PersistenceServiceRegistration.cs rename to OrderTrackingApp.Persistence/Extensions/PersistenceServiceRegistrations.cs index 72bcb90..aa8ac99 100644 --- a/OrderTrackingApp.Persistence/Extensions/PersistenceServiceRegistration.cs +++ b/OrderTrackingApp.Persistence/Extensions/PersistenceServiceRegistrations.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using OrderTrackingApp.Application.Interfaces; +using OrderTrackingApp.Domain.Interfaces; using OrderTrackingApp.Persistence.Repositories; namespace OrderTrackingApp.Persistence.Extensions @@ -11,9 +12,10 @@ public static class PersistenceServiceRegistration public static IServiceCollection AddPersistenceServices(this IServiceCollection services, IConfiguration config) { services.AddDbContext(options => - options.UseSqlServer(config.GetConnectionString("DefaultConnection"))); + 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 index e9d2b4e..ba93773 100644 --- a/OrderTrackingApp.Persistence/OrderTrackingApp.Persistence.csproj +++ b/OrderTrackingApp.Persistence/OrderTrackingApp.Persistence.csproj @@ -8,6 +8,10 @@ + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/OrderTrackingApp.Persistence/Repositories/OrderWriteRepository.cs b/OrderTrackingApp.Persistence/Repositories/OrderWriteRepository.cs index 7a89df6..c660a71 100644 --- a/OrderTrackingApp.Persistence/Repositories/OrderWriteRepository.cs +++ b/OrderTrackingApp.Persistence/Repositories/OrderWriteRepository.cs @@ -4,7 +4,7 @@ namespace OrderTrackingApp.Persistence.Repositories { - internal class OrderWriteRepository(AppDbContext dbContext) : IOrderWriteRepository + public class OrderWriteRepository(AppDbContext dbContext) : IOrderWriteRepository { public async Task AddAsync(Order order) { @@ -28,5 +28,10 @@ public async Task UpdateAsync(Order order) dbContext.Orders.Update(order); await dbContext.SaveChangesAsync(); } + + public async Task GetByIdAsync(Guid id) + { + return await dbContext.Orders.FirstOrDefaultAsync(x => x.Id == id); + } } } 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 index f08fe3f..02d71ac 100644 --- a/OrderTrackingApp.ReadPersistence/Extensions/ReadPersistenceServiceRegistration.cs +++ b/OrderTrackingApp.ReadPersistence/Extensions/ReadPersistenceServiceRegistration.cs @@ -1,5 +1,4 @@ -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; using OrderTrackingApp.ReadPersistence.Interfaces; using OrderTrackingApp.ReadPersistence.Repositories; @@ -7,7 +6,7 @@ namespace OrderTrackingApp.ReadPersistence.Extensions { public static class ReadPersistenceServiceRegistration { - public static IServiceCollection AddReadPersistence(this IServiceCollection services, IConfiguration config) + public static IServiceCollection AddReadPersistence(this IServiceCollection services) { services.AddSingleton(); services.AddScoped(); diff --git a/OrderTrackingApp.ReadPersistence/Interfaces/IOrderReadRepository.cs b/OrderTrackingApp.ReadPersistence/Interfaces/IOrderReadRepository.cs index bac5b4f..93a1b2f 100644 --- a/OrderTrackingApp.ReadPersistence/Interfaces/IOrderReadRepository.cs +++ b/OrderTrackingApp.ReadPersistence/Interfaces/IOrderReadRepository.cs @@ -1,11 +1,13 @@ -using OrderTrackingApp.Application.DTOs; +using OrderTrackingApp.ReadPersistence.Models; namespace OrderTrackingApp.ReadPersistence.Interfaces { public interface IOrderReadRepository { - Task GetByIdAsync(Guid id); + Task GetByIdAsync(Guid id); - Task> GetAllAsync(); + Task> GetAllAsync(); + + Task InsertAsync(OrderReadModel order); } } 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 index 5635d56..179e539 100644 --- a/OrderTrackingApp.ReadPersistence/MongoDbContext.cs +++ b/OrderTrackingApp.ReadPersistence/MongoDbContext.cs @@ -3,7 +3,7 @@ namespace OrderTrackingApp.ReadPersistence { - internal class MongoDbContext + public class MongoDbContext { public IMongoDatabase Database { get; } diff --git a/OrderTrackingApp.ReadPersistence/Repositories/OrderReadRepository.cs b/OrderTrackingApp.ReadPersistence/Repositories/OrderReadRepository.cs index 9d55de0..de4a620 100644 --- a/OrderTrackingApp.ReadPersistence/Repositories/OrderReadRepository.cs +++ b/OrderTrackingApp.ReadPersistence/Repositories/OrderReadRepository.cs @@ -1,26 +1,26 @@ using MongoDB.Driver; -using OrderTrackingApp.Application.DTOs; using OrderTrackingApp.ReadPersistence.Interfaces; +using OrderTrackingApp.ReadPersistence.Models; namespace OrderTrackingApp.ReadPersistence.Repositories { - internal class OrderReadRepository : IOrderReadRepository + public class OrderReadRepository(MongoDbContext context) : IOrderReadRepository { - private readonly IMongoCollection _collection; + private readonly IMongoCollection _collection = context.Database.GetCollection("Orders"); - public OrderReadRepository(MongoDbContext context) + public async Task> GetAllAsync() { - _collection = context.Database.GetCollection("Orders"); + return await _collection.Find(_ => true).ToListAsync(); } - public async Task> GetAllAsync() + public async Task GetByIdAsync(Guid id) { - return await _collection.Find(_ => true).ToListAsync(); + return await _collection.Find(o => o.Id == id).FirstOrDefaultAsync(); } - public async Task GetByIdAsync(Guid id) + public async Task InsertAsync(OrderReadModel order) { - return await _collection.Find(o => o.Id == id).FirstOrDefaultAsync(); + await _collection.InsertOneAsync(order); } } } diff --git a/OrderTrackingApp.sln b/OrderTrackingApp.sln index 3792842..008bed0 100644 --- a/OrderTrackingApp.sln +++ b/OrderTrackingApp.sln @@ -30,6 +30,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OrderTrackingApp.Blazor.Ser EndProject 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.Tests", "OrderTrackingApp.Persistence.Tests\OrderTrackingApp.Persistence.Tests.csproj", "{77A97D9B-3156-47EE-91A1-8D6F0D9BABE3}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OrderTrackingApp.MigrationRunner", "OrderTrackingApp.MigrationRunner\OrderTrackingApp.MigrationRunner.csproj", "{C9DE7296-2132-490F-97DE-96FF65C7A5BB}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -72,6 +76,14 @@ Global {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 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -86,6 +98,8 @@ Global {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} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {ECFA56EC-C322-4A64-8936-E930A2EEBE28}