Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/Microservices.sln
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Product.Domain", "Services\
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Product.Infrastructure", "Services\Product\Product.Infrastructure\Product.Infrastructure.csproj", "{B4000003-0000-0000-0000-000000000001}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Product.Tests", "Services\Product\Product.Tests\Product.Tests.csproj", "{B4000004-0000-0000-0000-000000000001}"
EndProject

Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Notification", "Notification", "{A1B2C3D4-0006-0000-0000-000000000001}"
EndProject
Expand Down Expand Up @@ -85,6 +87,7 @@ Global
{B4000001-0000-0000-0000-000000000001} = {A1B2C3D4-0005-0000-0000-000000000001}
{B4000002-0000-0000-0000-000000000001} = {A1B2C3D4-0005-0000-0000-000000000001}
{B4000003-0000-0000-0000-000000000001} = {A1B2C3D4-0005-0000-0000-000000000001}
{B4000004-0000-0000-0000-000000000001} = {A1B2C3D4-0005-0000-0000-000000000001}
{B5000001-0000-0000-0000-000000000001} = {A1B2C3D4-0006-0000-0000-000000000001}
{B5000002-0000-0000-0000-000000000001} = {A1B2C3D4-0006-0000-0000-000000000001}
{B5000003-0000-0000-0000-000000000001} = {A1B2C3D4-0006-0000-0000-000000000001}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
using Microsoft.AspNetCore.Mvc;
using Product.API.ViewModels;
using Product.Domain.Entities;
using Product.Domain.Interfaces;

namespace Product.API.Controllers;

[ApiController]
[Route("categories")]
[Route("api/products/categories")]
[Produces("application/json")]
public class ProductCategoriesController : ControllerBase
{
private readonly IProductCategoryRepository _repository;

public ProductCategoriesController(IProductCategoryRepository repository)
{
_repository = repository;
}

[HttpGet]
public async Task<ActionResult<IEnumerable<ProductCategoryVM>>> GetAll(CancellationToken cancellationToken)
{
var categories = await _repository.GetAllAsync(cancellationToken);
return Ok(categories.Select(ToViewModel));
}

[HttpGet("{id:int}")]
public async Task<ActionResult<ProductCategoryVM>> GetById(int id, CancellationToken cancellationToken)
{
var category = await _repository.GetByIdAsync(id, cancellationToken);
if (category is null)
return NotFound();

return Ok(ToViewModel(category));
}

[HttpPost]
public async Task<ActionResult<ProductCategoryVM>> Create([FromBody] ProductCategoryVM model, CancellationToken cancellationToken)
{
var created = await _repository.AddAsync(
new ProductCategory
{
Name = model.Name!,
Description = model.Description,
Icon = model.Icon
},
cancellationToken);

return CreatedAtAction(nameof(GetById), new { id = created.Id }, ToViewModel(created));
}

[HttpPut("{id:int}")]
public async Task<ActionResult<ProductCategoryVM>> Update(int id, [FromBody] ProductCategoryVM model, CancellationToken cancellationToken)
{
var updated = await _repository.UpdateAsync(
new ProductCategory
{
Id = id,
Name = model.Name!,
Description = model.Description,
Icon = model.Icon
},
cancellationToken);

if (updated is null)
return NotFound();

return Ok(ToViewModel(updated));
}

[HttpDelete("{id:int}")]
public async Task<IActionResult> Delete(int id, CancellationToken cancellationToken)
{
return await _repository.DeleteAsync(id, cancellationToken) ? NoContent() : NotFound();
}

private static ProductCategoryVM ToViewModel(ProductCategory category) => new()
{
Id = category.Id,
Name = category.Name,
Description = category.Description,
Icon = category.Icon
};
}
29 changes: 0 additions & 29 deletions src/Services/Product/Product.API/Controllers/ProductController.cs

This file was deleted.

106 changes: 106 additions & 0 deletions src/Services/Product/Product.API/Controllers/ProductsController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
using Microsoft.AspNetCore.Mvc;
using Product.API.ViewModels;
using Product.Domain.Interfaces;
using ProductEntity = Product.Domain.Entities.Product;

namespace Product.API.Controllers;

/// <summary>
/// Products. Served both at the service root (the gateway strips the /api/products
/// prefix before forwarding) and at /api/products for direct calls to the service.
/// </summary>
[ApiController]
[Route("")]
[Route("api/products")]
[Produces("application/json")]
public class ProductsController : ControllerBase
{
private readonly IProductRepository _repository;

public ProductsController(IProductRepository repository)
{
_repository = repository;
}

[HttpGet]
public async Task<ActionResult<IEnumerable<ProductVM>>> GetAll(CancellationToken cancellationToken)
{
var products = await _repository.GetAllAsync(cancellationToken);
return Ok(products.Select(ToViewModel));
}

[HttpGet("{id:int}")]
public async Task<ActionResult<ProductVM>> GetById(int id, CancellationToken cancellationToken)
{
var product = await _repository.GetByIdAsync(id, cancellationToken);
if (product is null)
return NotFound();

return Ok(ToViewModel(product));
}

[HttpPost]
public async Task<ActionResult<ProductVM>> Create([FromBody] ProductVM model, CancellationToken cancellationToken)
{
if (!await _repository.CategoryExistsAsync(model.ProductCategoryId, cancellationToken))
return BadRequest($"Product category {model.ProductCategoryId} does not exist.");

var product = new ProductEntity { Name = model.Name! };
Apply(model, product);

var created = await _repository.AddAsync(product, cancellationToken);
return CreatedAtAction(nameof(GetById), new { id = created.Id }, ToViewModel(created));
}

[HttpPut("{id:int}")]
public async Task<ActionResult<ProductVM>> Update(int id, [FromBody] ProductVM model, CancellationToken cancellationToken)
{
if (!await _repository.CategoryExistsAsync(model.ProductCategoryId, cancellationToken))
return BadRequest($"Product category {model.ProductCategoryId} does not exist.");

var product = new ProductEntity { Id = id, Name = model.Name! };
Apply(model, product);

var updated = await _repository.UpdateAsync(product, cancellationToken);
if (updated is null)
return NotFound();

return Ok(ToViewModel(updated));
}

[HttpDelete("{id:int}")]
public async Task<IActionResult> Delete(int id, CancellationToken cancellationToken)
{
return await _repository.DeleteAsync(id, cancellationToken) ? NoContent() : NotFound();
}

private static void Apply(ProductVM model, ProductEntity product)
{
product.Name = model.Name!;
product.Description = model.Description;
product.Icon = model.Icon;
product.BuyingPrice = model.BuyingPrice;
product.SellingPrice = model.SellingPrice;
product.UnitsInStock = model.UnitsInStock;
product.IsActive = model.IsActive;
product.IsDiscontinued = model.IsDiscontinued;
product.ParentId = model.ParentId;
product.ProductCategoryId = model.ProductCategoryId;
}

private static ProductVM ToViewModel(ProductEntity product) => new()
{
Id = product.Id,
Name = product.Name,
Description = product.Description,
Icon = product.Icon,
BuyingPrice = product.BuyingPrice,
SellingPrice = product.SellingPrice,
UnitsInStock = product.UnitsInStock,
IsActive = product.IsActive,
IsDiscontinued = product.IsDiscontinued,
ParentId = product.ParentId,
ProductCategoryId = product.ProductCategoryId,
ProductCategoryName = product.ProductCategory?.Name
};
}
4 changes: 2 additions & 2 deletions src/Services/Product/Product.API/Product.API.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
<ItemGroup>
<ProjectReference Include="..\Product.Domain\Product.Domain.csproj" />
<ProjectReference Include="..\Product.Infrastructure\Product.Infrastructure.csproj" />
<ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj" />
<ProjectReference Include="..\..\Shared\Shared.Infrastructure\Shared.Infrastructure.csproj" />
<ProjectReference Include="..\..\..\Shared\Shared.Contracts\Shared.Contracts.csproj" />
<ProjectReference Include="..\..\..\Shared\Shared.Infrastructure\Shared.Infrastructure.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.*" />
Expand Down
31 changes: 30 additions & 1 deletion src/Services/Product/Product.API/Program.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
using Product.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Product.Domain.Interfaces;
using Product.Infrastructure.Data;
using Product.Infrastructure.Repositories;

var builder = WebApplication.CreateBuilder(args);

Expand All @@ -11,8 +13,33 @@
builder.Services.AddDbContext<ProductDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));

builder.Services.AddScoped<IProductRepository, ProductRepository>();
builder.Services.AddScoped<IProductCategoryRepository, ProductCategoryRepository>();

var app = builder.Build();

using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<ProductDbContext>();
var logger = scope.ServiceProvider.GetRequiredService<ILogger<Program>>();

const int maxAttempts = 10;
for (var attempt = 1; ; attempt++)
{
try
{
await db.Database.MigrateAsync();
await ProductDbSeeder.SeedAsync(db);
break;
}
catch (Exception ex) when (attempt < maxAttempts)
{
logger.LogWarning(ex, "Database not ready (attempt {Attempt}/{MaxAttempts}); retrying.", attempt, maxAttempts);
await Task.Delay(TimeSpan.FromSeconds(3));
}
}
}

if (app.Environment.IsDevelopment())
{
app.UseSwagger();
Expand All @@ -23,3 +50,5 @@
app.MapHealthChecks("/healthz");

app.Run();

public partial class Program;
18 changes: 18 additions & 0 deletions src/Services/Product/Product.API/ViewModels/ProductCategoryVM.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using System.ComponentModel.DataAnnotations;

namespace Product.API.ViewModels;

public class ProductCategoryVM
{
public int Id { get; set; }

[Required]
[MaxLength(100)]
public string? Name { get; set; }

[MaxLength(500)]
public string? Description { get; set; }

[MaxLength(256)]
public string? Icon { get; set; }
}
32 changes: 32 additions & 0 deletions src/Services/Product/Product.API/ViewModels/ProductVM.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
using System.ComponentModel.DataAnnotations;

namespace Product.API.ViewModels;

/// <summary>
/// Wire shape of a product. Mirrors the monolith's ViewModels/Shop/ProductVM so the
/// Angular client can be repointed at this service unchanged; ProductCategoryId and
/// ParentId are additive so the category can be set over the API.
/// </summary>
public class ProductVM
{
public int Id { get; set; }

[Required]
[MaxLength(100)]
public string? Name { get; set; }

[MaxLength(500)]
public string? Description { get; set; }

[MaxLength(256)]
public string? Icon { get; set; }

public decimal BuyingPrice { get; set; }
public decimal SellingPrice { get; set; }
public int UnitsInStock { get; set; }
public bool IsActive { get; set; }
public bool IsDiscontinued { get; set; }
public int? ParentId { get; set; }
public int ProductCategoryId { get; set; }
public string? ProductCategoryName { get; set; }
}
Empty file.
10 changes: 10 additions & 0 deletions src/Services/Product/Product.Domain/Entities/BaseEntity.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace Product.Domain.Entities;

public abstract class BaseEntity
{
public int Id { get; set; }
public string? CreatedBy { get; set; }
public string? UpdatedBy { get; set; }
public DateTime CreatedDate { get; set; }
public DateTime UpdatedDate { get; set; }
}
21 changes: 21 additions & 0 deletions src/Services/Product/Product.Domain/Entities/Product.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
namespace Product.Domain.Entities;

public class Product : BaseEntity
{
public required string Name { get; set; }
public string? Description { get; set; }
public string? Icon { get; set; }
public decimal BuyingPrice { get; set; }
public decimal SellingPrice { get; set; }
public int UnitsInStock { get; set; }
public bool IsActive { get; set; }
public bool IsDiscontinued { get; set; }

public int? ParentId { get; set; }
public Product? Parent { get; set; }

public int ProductCategoryId { get; set; }
public ProductCategory? ProductCategory { get; set; }

public ICollection<Product> Children { get; } = [];
}
10 changes: 10 additions & 0 deletions src/Services/Product/Product.Domain/Entities/ProductCategory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace Product.Domain.Entities;

public class ProductCategory : BaseEntity
{
public required string Name { get; set; }
public string? Description { get; set; }
public string? Icon { get; set; }

public ICollection<Product> Products { get; } = [];
}
Empty file.
Loading