feat(customer-orders-bff): align lifecycle detail routes

This commit is contained in:
José René White Enciso 2026-03-31 19:59:32 -06:00
parent 59554a217f
commit 1e5e082061
3 changed files with 125 additions and 24 deletions

View File

@ -21,10 +21,13 @@ This BFF exposes customer-facing order submission, status, detail, and history w
- Default runtime target: `http://operations-service:8080` - Default runtime target: `http://operations-service:8080`
- Internal upstream routes: - Internal upstream routes:
- `GET /internal/operations/customer/status` - `GET /internal/operations/customer/status`
- `GET /internal/operations/customer/orders/{orderId}`
- `GET /internal/operations/customer/history`
- `POST /internal/operations/orders` - `POST /internal/operations/orders`
## Notes ## Notes
- Customer order submission currently maps `ItemIds.Count` into the upstream restaurant order workflow because the internal service contract still accepts aggregate item counts. - Customer order submission currently maps `ItemIds.Count` into the upstream restaurant order workflow because the internal service contract still accepts aggregate item counts.
- Successful submission writes into the same shared restaurant lifecycle later observed by waiter-floor, kitchen-ops, and POS projections. - Successful submission writes into the same shared restaurant lifecycle later observed by waiter-floor, kitchen-ops, and POS projections.
- Customer detail and history now come from dedicated lifecycle-backed upstream routes instead of projecting everything from the status snapshot.
- Correlation IDs are preserved through Thalos session checks and operations-service calls. - Correlation IDs are preserved through Thalos session checks and operations-service calls.

View File

@ -20,22 +20,18 @@ public sealed class OperationsCustomerOrdersServiceClient(HttpClient httpClient)
public async Task<GetCustomerOrderDetailResponse> FetchDetailAsync(GetCustomerOrderDetailRequest request, CancellationToken cancellationToken) public async Task<GetCustomerOrderDetailResponse> FetchDetailAsync(GetCustomerOrderDetailRequest request, CancellationToken cancellationToken)
{ {
var payload = await GetStatusPayloadAsync(request.ContextId, cancellationToken); var payload = await GetDetailPayloadAsync(request.ContextId, request.OrderId, cancellationToken);
var orders = MapOrders(payload.Orders);
var matchingOrder = orders.FirstOrDefault(summary => summary.OrderId == request.OrderId);
return new GetCustomerOrderDetailResponse( return new GetCustomerOrderDetailResponse(
payload.ContextId, payload.ContextId,
matchingOrder is null payload.Summary,
? $"Order {request.OrderId} is not visible in the current customer context." payload.Order is null ? null : MapOrder(payload.Order),
: $"Order {request.OrderId} is currently {matchingOrder.Status}.",
matchingOrder,
payload.RecentEvents); payload.RecentEvents);
} }
public async Task<GetCustomerOrderHistoryResponse> FetchHistoryAsync(GetCustomerOrderStatusRequest request, CancellationToken cancellationToken) public async Task<GetCustomerOrderHistoryResponse> FetchHistoryAsync(GetCustomerOrderStatusRequest request, CancellationToken cancellationToken)
{ {
var payload = await GetStatusPayloadAsync(request.ContextId, cancellationToken); var payload = await GetHistoryPayloadAsync(request.ContextId, cancellationToken);
return new GetCustomerOrderHistoryResponse( return new GetCustomerOrderHistoryResponse(
payload.ContextId, payload.ContextId,
@ -69,6 +65,27 @@ public sealed class OperationsCustomerOrdersServiceClient(HttpClient httpClient)
return payload ?? throw new InvalidOperationException("Operations service returned an empty customer order status payload."); return payload ?? throw new InvalidOperationException("Operations service returned an empty customer order status payload.");
} }
private async Task<GetCustomerOrderDetailPayload> GetDetailPayloadAsync(
string contextId,
string orderId,
CancellationToken cancellationToken)
{
var payload = await httpClient.GetFromJsonAsync<GetCustomerOrderDetailPayload>(
$"internal/operations/customer/orders/{Uri.EscapeDataString(orderId)}?contextId={Uri.EscapeDataString(contextId)}",
cancellationToken);
return payload ?? throw new InvalidOperationException("Operations service returned an empty customer order detail payload.");
}
private async Task<GetCustomerOrderHistoryPayload> GetHistoryPayloadAsync(string contextId, CancellationToken cancellationToken)
{
var payload = await httpClient.GetFromJsonAsync<GetCustomerOrderHistoryPayload>(
$"internal/operations/customer/history?contextId={Uri.EscapeDataString(contextId)}",
cancellationToken);
return payload ?? throw new InvalidOperationException("Operations service returned an empty customer order history payload.");
}
private async Task<SubmitRestaurantOrderResponsePayload> SubmitOrderPayloadAsync( private async Task<SubmitRestaurantOrderResponsePayload> SubmitOrderPayloadAsync(
SubmitRestaurantOrderPayload request, SubmitRestaurantOrderPayload request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@ -83,15 +100,20 @@ public sealed class OperationsCustomerOrdersServiceClient(HttpClient httpClient)
private static IReadOnlyCollection<CustomerOrderSummaryContract> MapOrders(IReadOnlyCollection<CustomerOrderPayload> orders) private static IReadOnlyCollection<CustomerOrderSummaryContract> MapOrders(IReadOnlyCollection<CustomerOrderPayload> orders)
{ {
return orders return orders
.Select(static order => new CustomerOrderSummaryContract( .Select(MapOrder)
order.OrderId,
order.TableId,
order.Status,
order.GuestCount,
order.ItemIds))
.ToArray(); .ToArray();
} }
private static CustomerOrderSummaryContract MapOrder(CustomerOrderPayload order)
{
return new CustomerOrderSummaryContract(
order.OrderId,
order.TableId,
order.Status,
order.GuestCount,
order.ItemIds);
}
private sealed record GetCustomerOrderStatusPayload( private sealed record GetCustomerOrderStatusPayload(
string ContextId, string ContextId,
string Summary, string Summary,
@ -105,6 +127,18 @@ public sealed class OperationsCustomerOrdersServiceClient(HttpClient httpClient)
int GuestCount, int GuestCount,
IReadOnlyCollection<string> ItemIds); IReadOnlyCollection<string> ItemIds);
private sealed record GetCustomerOrderDetailPayload(
string ContextId,
string Summary,
CustomerOrderPayload? Order,
IReadOnlyCollection<string> RecentEvents);
private sealed record GetCustomerOrderHistoryPayload(
string ContextId,
string Summary,
IReadOnlyCollection<CustomerOrderPayload> Orders,
IReadOnlyCollection<string> RecentEvents);
private sealed record SubmitRestaurantOrderPayload( private sealed record SubmitRestaurantOrderPayload(
string ContextId, string ContextId,
string OrderId, string OrderId,

View File

@ -10,7 +10,10 @@ public sealed class OperationsCustomerOrdersServiceClientTests
[Fact] [Fact]
public async Task FetchStatusAsync_MapsOrdersAndEvents() public async Task FetchStatusAsync_MapsOrdersAndEvents()
{ {
var adapter = new OperationsCustomerOrdersServiceClient(CreateClient(StatusPayload)); var adapter = new OperationsCustomerOrdersServiceClient(CreateClient(new Dictionary<string, string>
{
["/internal/operations/customer/status"] = StatusPayload
}));
var response = await adapter.FetchStatusAsync(new GetCustomerOrderStatusRequest("demo-context"), CancellationToken.None); var response = await adapter.FetchStatusAsync(new GetCustomerOrderStatusRequest("demo-context"), CancellationToken.None);
@ -22,7 +25,10 @@ public sealed class OperationsCustomerOrdersServiceClientTests
[Fact] [Fact]
public async Task FetchDetailAsync_ReturnsMatchingOrderWhenPresent() public async Task FetchDetailAsync_ReturnsMatchingOrderWhenPresent()
{ {
var adapter = new OperationsCustomerOrdersServiceClient(CreateClient(StatusPayload)); var adapter = new OperationsCustomerOrdersServiceClient(CreateClient(new Dictionary<string, string>
{
["/internal/operations/customer/orders/ORD-1001"] = DetailPayload
}));
var response = await adapter.FetchDetailAsync(new GetCustomerOrderDetailRequest("demo-context", "ORD-1001"), CancellationToken.None); var response = await adapter.FetchDetailAsync(new GetCustomerOrderDetailRequest("demo-context", "ORD-1001"), CancellationToken.None);
@ -32,20 +38,26 @@ public sealed class OperationsCustomerOrdersServiceClientTests
} }
[Fact] [Fact]
public async Task FetchHistoryAsync_ReturnsProjectedHistorySnapshot() public async Task FetchHistoryAsync_ReturnsLifecycleBackedHistorySnapshot()
{ {
var adapter = new OperationsCustomerOrdersServiceClient(CreateClient(StatusPayload)); var adapter = new OperationsCustomerOrdersServiceClient(CreateClient(new Dictionary<string, string>
{
["/internal/operations/customer/history"] = HistoryPayload
}));
var response = await adapter.FetchHistoryAsync(new GetCustomerOrderStatusRequest("demo-context"), CancellationToken.None); var response = await adapter.FetchHistoryAsync(new GetCustomerOrderStatusRequest("demo-context"), CancellationToken.None);
Assert.Equal(2, response.Orders.Count); Assert.Equal(2, response.Orders.Count);
Assert.Equal(2, response.RecentEvents.Count); Assert.Equal(2, response.RecentEvents.Count);
Assert.Contains("lifecycle-backed", response.Summary, StringComparison.OrdinalIgnoreCase);
} }
[Fact] [Fact]
public async Task SubmitOrderAsync_MapsSharedLifecycleAcceptanceUsingItemCount() public async Task SubmitOrderAsync_MapsSharedLifecycleAcceptanceUsingItemCount()
{ {
var adapter = new OperationsCustomerOrdersServiceClient(CreateClient(""" var adapter = new OperationsCustomerOrdersServiceClient(CreateClient(new Dictionary<string, string>
{
["/internal/operations/orders"] = """
{ {
"contextId": "demo-context", "contextId": "demo-context",
"orderId": "ORD-1009", "orderId": "ORD-1009",
@ -54,7 +66,8 @@ public sealed class OperationsCustomerOrdersServiceClientTests
"status": "accepted", "status": "accepted",
"submittedAtUtc": "2026-03-31T12:30:00Z" "submittedAtUtc": "2026-03-31T12:30:00Z"
} }
""")); """
}));
var response = await adapter.SubmitOrderAsync( var response = await adapter.SubmitOrderAsync(
new SubmitCustomerOrderRequest("demo-context", "ORD-1009", "T-18", 4, ["ITEM-101", "ITEM-202", "ITEM-303"]), new SubmitCustomerOrderRequest("demo-context", "ORD-1009", "T-18", 4, ["ITEM-101", "ITEM-202", "ITEM-303"]),
@ -65,18 +78,24 @@ public sealed class OperationsCustomerOrdersServiceClientTests
Assert.Contains("kitchen dispatch", response.Summary); Assert.Contains("kitchen dispatch", response.Summary);
} }
private static HttpClient CreateClient(string json) private static HttpClient CreateClient(IReadOnlyDictionary<string, string> routes)
{ {
return new HttpClient(new StubHttpMessageHandler(json)) return new HttpClient(new StubHttpMessageHandler(routes))
{ {
BaseAddress = new Uri("http://operations-service:8080/") BaseAddress = new Uri("http://operations-service:8080/")
}; };
} }
private sealed class StubHttpMessageHandler(string json) : HttpMessageHandler private sealed class StubHttpMessageHandler(IReadOnlyDictionary<string, string> routes) : HttpMessageHandler
{ {
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{ {
var path = request.RequestUri?.AbsolutePath ?? string.Empty;
if (!routes.TryGetValue(path, out var json))
{
throw new InvalidOperationException($"No stub payload configured for '{path}'.");
}
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{ {
Content = new StringContent(json, Encoding.UTF8, "application/json") Content = new StringContent(json, Encoding.UTF8, "application/json")
@ -87,7 +106,52 @@ public sealed class OperationsCustomerOrdersServiceClientTests
private const string StatusPayload = """ private const string StatusPayload = """
{ {
"contextId": "demo-context", "contextId": "demo-context",
"summary": "2 recent customer orders are visible for the active context.", "summary": "2 customer orders are currently tracked in the shared restaurant lifecycle.",
"orders": [
{
"orderId": "ORD-1001",
"tableId": "T-08",
"status": "preparing",
"guestCount": 2,
"itemIds": [ "ITEM-101", "ITEM-202" ]
},
{
"orderId": "ORD-1002",
"tableId": "T-15",
"status": "ready",
"guestCount": 4,
"itemIds": [ "ITEM-301", "ITEM-404", "ITEM-405" ]
}
],
"recentEvents": [
"Order ORD-1001 moved to preparing at the kitchen hot-line station.",
"Order ORD-1002 is ready for table pickup."
]
}
""";
private const string DetailPayload = """
{
"contextId": "demo-context",
"summary": "Order ORD-1001 is currently preparing in the shared restaurant lifecycle.",
"order": {
"orderId": "ORD-1001",
"tableId": "T-08",
"status": "preparing",
"guestCount": 2,
"itemIds": [ "ITEM-101", "ITEM-202" ]
},
"recentEvents": [
"Order ORD-1001 was accepted into the shared restaurant lifecycle.",
"Order ORD-1001 moved to preparing at the kitchen hot-line station."
]
}
""";
private const string HistoryPayload = """
{
"contextId": "demo-context",
"summary": "2 customer orders are currently available through lifecycle-backed history.",
"orders": [ "orders": [
{ {
"orderId": "ORD-1001", "orderId": "ORD-1001",