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
131 changes: 122 additions & 9 deletions src/Services/Order/Order.API/Controllers/OrderController.cs
Original file line number Diff line number Diff line change
@@ -1,29 +1,142 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Order.API.DTOs;
using Order.Domain.Entities;
using Order.Infrastructure.Data;

namespace Order.API.Controllers;

[ApiController]
[Route("api/[controller]")]
[Authorize]
public class OrderController : ControllerBase
{
private readonly ILogger<OrderController> _logger;
private readonly OrderDbContext _context;

public OrderController(ILogger<OrderController> logger)
public OrderController(OrderDbContext context)
{
_logger = logger;
_context = context;
}

[HttpGet]
public IActionResult GetAll()
[ProducesResponseType(typeof(List<OrderDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> GetAll()
{
// TODO: Implement — migrate logic from monolith's OrderController
return Ok(new { service = "Order", status = "scaffold" });
var orders = await _context.Orders
.Include(o => o.OrderDetails)
.ToListAsync();

var result = orders.Select(MapToDto).ToList();
return Ok(result);
}

[HttpGet("{id}")]
public IActionResult GetById(int id)
[ProducesResponseType(typeof(OrderDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> GetById(int id)
{
var order = await _context.Orders
.Include(o => o.OrderDetails)
.FirstOrDefaultAsync(o => o.Id == id);

if (order is null)
return NotFound();

return Ok(MapToDto(order));
}

[HttpPost]
[ProducesResponseType(typeof(OrderDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> Create([FromBody] CreateOrderDto dto)
{
var order = new Order.Domain.Entities.Order
{
Discount = dto.Discount,
Comments = dto.Comments,
CashierId = dto.CashierId,
CustomerId = dto.CustomerId,
CreatedDate = DateTime.UtcNow,
UpdatedDate = DateTime.UtcNow
};
Comment on lines +56 to +64

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.

🚩 Audit fields (CreatedBy/UpdatedBy) are never populated despite being defined on all entities

The BaseEntity class (Order.Domain/Entities/BaseEntity.cs:6-7) defines CreatedBy and UpdatedBy audit properties, and the DbContext configures max lengths for them (OrderDbContext.cs:25-26, OrderDbContext.cs:40-41). However, the Create action (OrderController.cs:56-64) and Update action (OrderController.cs:98-103) never set these fields, even though the [Authorize] attribute guarantees an authenticated user identity is available via HttpContext.User. These columns will always be NULL in the database, making the audit trail incomplete.

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. The audit fields (CreatedBy/UpdatedBy) are intentionally left unpopulated in this initial extraction — the monolith's audit logic lives in its shared ApplicationDbContext.SaveChangesAsync override, which is out of scope for this carve-out. Populating them from HttpContext.User claims would be a follow-up enhancement once the identity claim structure is finalized across services.


foreach (var detailDto in dto.OrderDetails)
{
order.OrderDetails.Add(new OrderDetail
{
UnitPrice = detailDto.UnitPrice,
Quantity = detailDto.Quantity,
Discount = detailDto.Discount,
ProductId = detailDto.ProductId,
CreatedDate = DateTime.UtcNow,
UpdatedDate = DateTime.UtcNow
});
}

_context.Orders.Add(order);
await _context.SaveChangesAsync();

return CreatedAtAction(nameof(GetById), new { id = order.Id }, MapToDto(order));
}

[HttpPut("{id}")]
[ProducesResponseType(typeof(OrderDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> Update(int id, [FromBody] UpdateOrderDto dto)
{
// TODO: Implement — migrate logic from monolith
return Ok(new { service = "Order", id });
var order = await _context.Orders
.Include(o => o.OrderDetails)
.FirstOrDefaultAsync(o => o.Id == id);

if (order is null)
return NotFound();

order.Discount = dto.Discount;
order.Comments = dto.Comments;
order.CashierId = dto.CashierId;
order.CustomerId = dto.CustomerId;
order.UpdatedDate = DateTime.UtcNow;

await _context.SaveChangesAsync();

return Ok(MapToDto(order));
}
Comment on lines +86 to +107

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.

🚩 PUT endpoint only updates header fields, not order details — partial update semantics

The Update action uses [HttpPut] (OrderController.cs:86) but only modifies order-level fields (Discount, Comments, CashierId, CustomerId). The UpdateOrderDto (OrderDto.cs:22-28) deliberately omits OrderDetails, so existing line items are preserved unchanged. This is a partial update, which is conventionally a PATCH operation in REST APIs. If callers expect standard PUT semantics (full resource replacement), order details would never be removable or modifiable through this endpoint. The .Include(o => o.OrderDetails) at line 92 is correctly present to support the MapToDto response serialization.

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.

By design — UpdateOrderDto was deliberately scoped to header-level fields per the spec. Line item management (add/remove/update details on an existing order) can be added as a separate endpoint or expanded in a follow-up. The .Include on line 92 is for response serialization only.


[HttpDelete("{id}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> Delete(int id)
{
var order = await _context.Orders.FindAsync(id);

if (order is null)
return NotFound();

_context.Orders.Remove(order);
await _context.SaveChangesAsync();

return NoContent();
}

private static OrderDto MapToDto(Order.Domain.Entities.Order order) => new()
{
Id = order.Id,
Discount = order.Discount,
Comments = order.Comments,
CashierId = order.CashierId,
CustomerId = order.CustomerId,
OrderDetails = order.OrderDetails.Select(od => new OrderDetailDto
{
Id = od.Id,
UnitPrice = od.UnitPrice,
Quantity = od.Quantity,
Discount = od.Discount,
ProductId = od.ProductId
}).ToList()
};
}
45 changes: 45 additions & 0 deletions src/Services/Order/Order.API/DTOs/OrderDto.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
namespace Order.API.DTOs;

public class OrderDto
{
public int Id { get; set; }
public decimal Discount { get; set; }
public string? Comments { get; set; }
public string? CashierId { get; set; }
public int CustomerId { get; set; }
public List<OrderDetailDto> OrderDetails { get; set; } = [];
}

public class CreateOrderDto
{
public decimal Discount { get; set; }
public string? Comments { get; set; }
public string? CashierId { get; set; }
public int CustomerId { get; set; }
public List<CreateOrderDetailDto> OrderDetails { get; set; } = [];
}

public class UpdateOrderDto
{
public decimal Discount { get; set; }
public string? Comments { get; set; }
public string? CashierId { get; set; }
public int CustomerId { get; set; }
}

public class OrderDetailDto
{
public int Id { get; set; }
public decimal UnitPrice { get; set; }
public int Quantity { get; set; }
public decimal Discount { get; set; }
public int ProductId { get; set; }
}

public class CreateOrderDetailDto
{
public decimal UnitPrice { get; set; }
public int Quantity { get; set; }
public decimal Discount { get; set; }
public int ProductId { get; set; }
}
1 change: 1 addition & 0 deletions src/Services/Order/Order.API/Order.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
49 changes: 49 additions & 0 deletions src/Services/Order/Order.API/Program.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
using Order.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using System.Text;

var builder = WebApplication.CreateBuilder(args);

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

// JWT Authentication
var jwtSettings = builder.Configuration.GetSection("Jwt");
var key = Encoding.UTF8.GetBytes(jwtSettings["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 = jwtSettings["Issuer"],
ValidAudience = jwtSettings["Audience"],
IssuerSigningKey = new SymmetricSecurityKey(key)
};
});

builder.Services.AddAuthorization();

var app = builder.Build();

// Auto-create database schema (with retry for container startup ordering)
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<OrderDbContext>();
for (var i = 0; i < 10; i++)
{
try
{
db.Database.EnsureCreated();
break;
}
catch (Exception) when (i < 9)
{
Thread.Sleep(2000);
}
}
}

if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}

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

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

Expand Down
5 changes: 5 additions & 0 deletions src/Services/Order/Order.API/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,10 @@
},
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Database=orderdb;Username=postgres;Password=postgres"
},
"Jwt": {
"Secret": "QuickApp_Microservices_SuperSecret_Key_For_Dev_Only_Min_32_Chars!",
"Issuer": "quickapp-identity",
"Audience": "quickapp-api"
}
}
Empty file.
10 changes: 10 additions & 0 deletions src/Services/Order/Order.Domain/Entities/BaseEntity.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace Order.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; }
}
10 changes: 10 additions & 0 deletions src/Services/Order/Order.Domain/Entities/Order.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace Order.Domain.Entities;

public class Order : BaseEntity
{
public decimal Discount { get; set; }
public string? Comments { get; set; }
public string? CashierId { get; set; }
public int CustomerId { get; set; }
public ICollection<OrderDetail> OrderDetails { get; set; } = [];
}
11 changes: 11 additions & 0 deletions src/Services/Order/Order.Domain/Entities/OrderDetail.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
namespace Order.Domain.Entities;

public class OrderDetail : BaseEntity
{
public decimal UnitPrice { get; set; }
public int Quantity { get; set; }
public decimal Discount { get; set; }
public int ProductId { get; set; }
public int OrderId { get; set; }
public Order Order { get; set; } = null!;
}
Empty file.
30 changes: 29 additions & 1 deletion src/Services/Order/Order.Infrastructure/Data/OrderDbContext.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore;
using Order.Domain.Entities;

namespace Order.Infrastructure.Data;

Expand All @@ -8,9 +9,36 @@ public OrderDbContext(DbContextOptions<OrderDbContext> options) : base(options)
{
}

public DbSet<Order.Domain.Entities.Order> Orders { get; set; } = null!;
public DbSet<OrderDetail> OrderDetails { get; set; } = null!;

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// TODO: Configure entity mappings migrated from monolith

// Order entity config — ported verbatim from monolith
modelBuilder.Entity<Order.Domain.Entities.Order>(entity =>
{
entity.ToTable("AppOrders");
entity.Property(o => o.Comments).HasMaxLength(500);
entity.Property(o => o.Discount).HasColumnType("decimal(18,2)");
entity.Property(o => o.CreatedBy).HasMaxLength(40);
entity.Property(o => o.UpdatedBy).HasMaxLength(40);

entity.HasMany(o => o.OrderDetails)
.WithOne(od => od.Order)
.HasForeignKey(od => od.OrderId)
.OnDelete(DeleteBehavior.Cascade);
});

// OrderDetail entity config — ported verbatim from monolith
modelBuilder.Entity<OrderDetail>(entity =>
{
entity.ToTable("AppOrderDetails");
entity.Property(od => od.UnitPrice).HasColumnType("decimal(18,2)");
entity.Property(od => od.Discount).HasColumnType("decimal(18,2)");
entity.Property(od => od.CreatedBy).HasMaxLength(40);
entity.Property(od => od.UpdatedBy).HasMaxLength(40);
});
}
}
Loading