feat(waiter-floor-bff): add waiter workflow endpoints

This commit is contained in:
José René White Enciso 2026-03-31 16:26:52 -06:00
parent 33616af7b8
commit bf78706997
23 changed files with 435 additions and 25 deletions

View File

@ -4,4 +4,7 @@
<Project Path="src/Waiter.Floor.Bff.Contracts/Waiter.Floor.Bff.Contracts.csproj" />
<Project Path="src/Waiter.Floor.Bff.Rest/Waiter.Floor.Bff.Rest.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/Waiter.Floor.Bff.Application.UnitTests/Waiter.Floor.Bff.Application.UnitTests.csproj" />
</Folder>
</Solution>

View File

@ -0,0 +1,29 @@
# Waiter Floor Workflow API
## Purpose
This BFF exposes execution-facing waiter workflows over REST while delegating orchestration to `operations-service`.
## Endpoints
- `GET /api/waiter/floor/assignments?contextId=<value>`
- Returns assignment summary, location context, current assignments, and recent activity.
- `GET /api/waiter/floor/activity?contextId=<value>`
- Returns recent waiter activity projected from the same operations workflow snapshot.
- `POST /api/waiter/floor/orders`
- Submits a waiter order snapshot for processing.
- `PUT /api/waiter/floor/orders/{orderId}`
- Updates an existing waiter order snapshot using the same operations workflow contract.
## Upstream Dependency
- Base address configuration: `OperationsService:BaseAddress`
- Default runtime target: `http://operations-service:8080`
- Internal upstream routes:
- `GET /internal/operations/waiter/assignments`
- `POST /internal/operations/orders`
## Notes
- The update route currently reuses the operations order submission contract so waiter-floor can expose update semantics without introducing a new cross-repo dependency.
- Correlation IDs are preserved through Thalos session checks and operations-service calls.

View File

@ -1,6 +1,6 @@
# Waiter Floor Ownership Boundary
- Execution-facing BFF for floor staff workflows.
- Owns edge contracts for assignment views and order-taking orchestration.
- Owns edge contracts for assignment views, recent floor activity, and order submit or update orchestration.
- Does not own policy/config control-plane concerns.
- Consumes service APIs only; no direct DAL access.

View File

@ -10,6 +10,8 @@ waiter-floor-bff
## Domain-Specific Candidate Features
- Order lifecycle consistency and state transitions.
- Waiter assignment visibility with recent floor activity context.
- Waiter order update workflows that stay aligned with service-level restaurant order orchestration.
- Kitchen queue and dispatch optimization hooks.
- Operations control-plane policies (flags, service windows, overrides).
- POS closeout and settlement summary alignment.

View File

@ -22,7 +22,8 @@ docker run --rm -p 8080:8080 --name waiter-floor-bff agilewebs/waiter-floor-bff:
## Runtime Notes
- Exposes REST edge endpoints for waiter assignment and order submission flows.
- Exposes REST edge endpoints for waiter assignment, recent activity, and order submit or update flows.
- Requires `OperationsService__BaseAddress` to resolve the upstream operations-service runtime.
- Requires `ThalosAuth__BaseAddress` to resolve Thalos session introspection endpoint.
- Returns standardized auth failures (`401|403|503`) with `x-correlation-id` propagation.
@ -38,5 +39,5 @@ docker run --rm -p 8080:8080 --name waiter-floor-bff agilewebs/waiter-floor-bff:
- Integration artifact path: `greenfield/demo/restaurant/docker-compose.yml`
## Known Limitations
- Current runtime adapters are still predominantly in-memory for deterministic local/demo behavior.
- Waiter-floor now delegates workflow snapshots to `operations-service`, but the upstream operations adapter still serves deterministic demo data rather than database-backed state.
- Demo PostgreSQL seeds validate integration contracts and smoke determinism, but do not yet imply full persistence implementation parity.

View File

@ -7,7 +7,9 @@ This BFF enforces authenticated access on business endpoints using Thalos sessio
## Protected Endpoints
- `/api/waiter/floor/assignments`
- `/api/waiter/floor/activity`
- `/api/waiter/floor/orders`
- `/api/waiter/floor/orders/{orderId}`
## Anonymous Endpoints

View File

@ -1,17 +0,0 @@
using Waiter.Floor.Bff.Contracts.Requests;
using Waiter.Floor.Bff.Contracts.Responses;
namespace Waiter.Floor.Bff.Application.Adapters;
public sealed class DefaultWaiterServiceClient : IWaiterServiceClient
{
public Task<GetWaiterAssignmentsResponse> FetchAsync(GetWaiterAssignmentsRequest request, CancellationToken cancellationToken)
{
return Task.FromResult(new GetWaiterAssignmentsResponse(request.ContextId, "Default service-backed response."));
}
public Task<SubmitFloorOrderResponse> SubmitOrderAsync(SubmitFloorOrderRequest request, CancellationToken cancellationToken)
{
return Task.FromResult(new SubmitFloorOrderResponse(request.OrderId, true, "Order accepted by waiter-floor default adapter."));
}
}

View File

@ -5,6 +5,8 @@ namespace Waiter.Floor.Bff.Application.Adapters;
public interface IWaiterServiceClient
{
Task<GetWaiterAssignmentsResponse> FetchAsync(GetWaiterAssignmentsRequest request, CancellationToken cancellationToken);
Task<GetWaiterAssignmentsResponse> FetchAssignmentsAsync(GetWaiterAssignmentsRequest request, CancellationToken cancellationToken);
Task<GetWaiterRecentActivityResponse> FetchRecentActivityAsync(GetWaiterAssignmentsRequest request, CancellationToken cancellationToken);
Task<SubmitFloorOrderResponse> SubmitOrderAsync(SubmitFloorOrderRequest request, CancellationToken cancellationToken);
Task<UpdateFloorOrderResponse> UpdateOrderAsync(UpdateFloorOrderRequest request, CancellationToken cancellationToken);
}

View File

@ -0,0 +1,120 @@
using System.Net.Http.Json;
using Waiter.Floor.Bff.Contracts.Contracts;
using Waiter.Floor.Bff.Contracts.Requests;
using Waiter.Floor.Bff.Contracts.Responses;
namespace Waiter.Floor.Bff.Application.Adapters;
public sealed class OperationsWaiterServiceClient(HttpClient httpClient) : IWaiterServiceClient
{
public async Task<GetWaiterAssignmentsResponse> FetchAssignmentsAsync(GetWaiterAssignmentsRequest request, CancellationToken cancellationToken)
{
var payload = await GetAssignmentsPayloadAsync(request.ContextId, cancellationToken);
return new GetWaiterAssignmentsResponse(
payload.ContextId,
payload.LocationId,
payload.Summary,
payload.Assignments
.Select(static assignment => new WaiterAssignmentContract(
assignment.WaiterId,
assignment.TableId,
assignment.Status,
assignment.ActiveOrders))
.ToArray(),
payload.RecentActivity);
}
public async Task<GetWaiterRecentActivityResponse> FetchRecentActivityAsync(GetWaiterAssignmentsRequest request, CancellationToken cancellationToken)
{
var payload = await GetAssignmentsPayloadAsync(request.ContextId, cancellationToken);
return new GetWaiterRecentActivityResponse(
payload.ContextId,
payload.LocationId,
payload.Summary,
payload.RecentActivity);
}
public async Task<SubmitFloorOrderResponse> SubmitOrderAsync(SubmitFloorOrderRequest request, CancellationToken cancellationToken)
{
var payload = await SubmitOrderPayloadAsync(
new SubmitRestaurantOrderPayload(request.ContextId, request.OrderId, request.TableId, request.ItemCount),
cancellationToken);
return new SubmitFloorOrderResponse(
payload.ContextId,
payload.OrderId,
payload.Accepted,
payload.Summary,
payload.Status,
payload.SubmittedAtUtc);
}
public async Task<UpdateFloorOrderResponse> UpdateOrderAsync(UpdateFloorOrderRequest request, CancellationToken cancellationToken)
{
var payload = await SubmitOrderPayloadAsync(
new SubmitRestaurantOrderPayload(request.ContextId, request.OrderId, request.TableId, request.ItemCount),
cancellationToken);
// operations-service currently accepts a full order snapshot for both submit and update semantics.
var summary = payload.Accepted
? $"Updated order {payload.OrderId}. {payload.Summary}"
: payload.Summary;
return new UpdateFloorOrderResponse(
payload.ContextId,
payload.OrderId,
payload.Accepted,
summary,
payload.Status,
payload.SubmittedAtUtc);
}
private async Task<GetWaiterAssignmentsPayload> GetAssignmentsPayloadAsync(string contextId, CancellationToken cancellationToken)
{
var payload = await httpClient.GetFromJsonAsync<GetWaiterAssignmentsPayload>(
$"internal/operations/waiter/assignments?contextId={Uri.EscapeDataString(contextId)}",
cancellationToken);
return payload ?? throw new InvalidOperationException("Operations service returned an empty waiter assignments payload.");
}
private async Task<SubmitRestaurantOrderResponsePayload> SubmitOrderPayloadAsync(
SubmitRestaurantOrderPayload request,
CancellationToken cancellationToken)
{
using var response = await httpClient.PostAsJsonAsync("internal/operations/orders", request, cancellationToken);
response.EnsureSuccessStatusCode();
var payload = await response.Content.ReadFromJsonAsync<SubmitRestaurantOrderResponsePayload>(cancellationToken);
return payload ?? throw new InvalidOperationException("Operations service returned an empty order workflow payload.");
}
private sealed record GetWaiterAssignmentsPayload(
string ContextId,
string LocationId,
string Summary,
IReadOnlyCollection<WaiterAssignmentPayload> Assignments,
IReadOnlyCollection<string> RecentActivity);
private sealed record WaiterAssignmentPayload(
string WaiterId,
string TableId,
string Status,
int ActiveOrders);
private sealed record SubmitRestaurantOrderPayload(
string ContextId,
string OrderId,
string TableId,
int ItemCount);
private sealed record SubmitRestaurantOrderResponsePayload(
string ContextId,
string OrderId,
bool Accepted,
string Summary,
string Status,
DateTime SubmittedAtUtc);
}

View File

@ -8,6 +8,6 @@ public sealed class GetWaiterAssignmentsHandler(IWaiterServiceClient serviceClie
{
public Task<GetWaiterAssignmentsResponse> HandleAsync(GetWaiterAssignmentsRequest request, CancellationToken cancellationToken)
{
return serviceClient.FetchAsync(request, cancellationToken);
return serviceClient.FetchAssignmentsAsync(request, cancellationToken);
}
}

View File

@ -0,0 +1,13 @@
using Waiter.Floor.Bff.Application.Adapters;
using Waiter.Floor.Bff.Contracts.Requests;
using Waiter.Floor.Bff.Contracts.Responses;
namespace Waiter.Floor.Bff.Application.Handlers;
public sealed class GetWaiterRecentActivityHandler(IWaiterServiceClient serviceClient) : IGetWaiterRecentActivityHandler
{
public Task<GetWaiterRecentActivityResponse> HandleAsync(GetWaiterAssignmentsRequest request, CancellationToken cancellationToken)
{
return serviceClient.FetchRecentActivityAsync(request, cancellationToken);
}
}

View File

@ -0,0 +1,9 @@
using Waiter.Floor.Bff.Contracts.Requests;
using Waiter.Floor.Bff.Contracts.Responses;
namespace Waiter.Floor.Bff.Application.Handlers;
public interface IGetWaiterRecentActivityHandler
{
Task<GetWaiterRecentActivityResponse> HandleAsync(GetWaiterAssignmentsRequest request, CancellationToken cancellationToken);
}

View File

@ -0,0 +1,9 @@
using Waiter.Floor.Bff.Contracts.Requests;
using Waiter.Floor.Bff.Contracts.Responses;
namespace Waiter.Floor.Bff.Application.Handlers;
public interface IUpdateFloorOrderHandler
{
Task<UpdateFloorOrderResponse> HandleAsync(UpdateFloorOrderRequest request, CancellationToken cancellationToken);
}

View File

@ -0,0 +1,13 @@
using Waiter.Floor.Bff.Application.Adapters;
using Waiter.Floor.Bff.Contracts.Requests;
using Waiter.Floor.Bff.Contracts.Responses;
namespace Waiter.Floor.Bff.Application.Handlers;
public sealed class UpdateFloorOrderHandler(IWaiterServiceClient serviceClient) : IUpdateFloorOrderHandler
{
public Task<UpdateFloorOrderResponse> HandleAsync(UpdateFloorOrderRequest request, CancellationToken cancellationToken)
{
return serviceClient.UpdateOrderAsync(request, cancellationToken);
}
}

View File

@ -0,0 +1,7 @@
namespace Waiter.Floor.Bff.Contracts.Contracts;
public sealed record WaiterAssignmentContract(
string WaiterId,
string TableId,
string Status,
int ActiveOrders);

View File

@ -0,0 +1,7 @@
namespace Waiter.Floor.Bff.Contracts.Requests;
public sealed record UpdateFloorOrderRequest(
string ContextId,
string TableId,
string OrderId,
int ItemCount);

View File

@ -1,3 +1,10 @@
using Waiter.Floor.Bff.Contracts.Contracts;
namespace Waiter.Floor.Bff.Contracts.Responses;
public sealed record GetWaiterAssignmentsResponse(string ContextId, string Summary);
public sealed record GetWaiterAssignmentsResponse(
string ContextId,
string LocationId,
string Summary,
IReadOnlyCollection<WaiterAssignmentContract> Assignments,
IReadOnlyCollection<string> RecentActivity);

View File

@ -0,0 +1,7 @@
namespace Waiter.Floor.Bff.Contracts.Responses;
public sealed record GetWaiterRecentActivityResponse(
string ContextId,
string LocationId,
string Summary,
IReadOnlyCollection<string> RecentActivity);

View File

@ -1,6 +1,9 @@
namespace Waiter.Floor.Bff.Contracts.Responses;
public sealed record SubmitFloorOrderResponse(
string ContextId,
string OrderId,
bool Accepted,
string Message);
string Summary,
string Status,
DateTime ProcessedAtUtc);

View File

@ -0,0 +1,9 @@
namespace Waiter.Floor.Bff.Contracts.Responses;
public sealed record UpdateFloorOrderResponse(
string ContextId,
string OrderId,
bool Accepted,
string Summary,
string Status,
DateTime ProcessedAtUtc);

View File

@ -10,9 +10,16 @@ const string SessionAccessCookieName = "thalos_session";
const string SessionRefreshCookieName = "thalos_refresh";
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<IWaiterServiceClient, DefaultWaiterServiceClient>();
builder.Services.AddHttpClient<IWaiterServiceClient, OperationsWaiterServiceClient>((serviceProvider, httpClient) =>
{
var configuration = serviceProvider.GetRequiredService<IConfiguration>();
var operationsBaseAddress = configuration["OperationsService:BaseAddress"] ?? "http://operations-service:8080";
httpClient.BaseAddress = new Uri($"{operationsBaseAddress.TrimEnd('/')}/");
});
builder.Services.AddSingleton<IGetWaiterAssignmentsHandler, GetWaiterAssignmentsHandler>();
builder.Services.AddSingleton<IGetWaiterRecentActivityHandler, GetWaiterRecentActivityHandler>();
builder.Services.AddSingleton<ISubmitFloorOrderHandler, SubmitFloorOrderHandler>();
builder.Services.AddSingleton<IUpdateFloorOrderHandler, UpdateFloorOrderHandler>();
builder.Services.AddHttpClient("ThalosAuth");
var app = builder.Build();
@ -44,6 +51,24 @@ app.MapGet("/api/waiter/floor/assignments", async (
return Results.Ok(await handler.HandleAsync(request, ct));
});
app.MapGet("/api/waiter/floor/activity", async (
string contextId,
HttpContext context,
IHttpClientFactory httpClientFactory,
IConfiguration configuration,
IGetWaiterRecentActivityHandler handler,
CancellationToken ct) =>
{
var authError = await EnforceSessionAsync(context, httpClientFactory, configuration, ct);
if (authError is not null)
{
return authError;
}
var request = new GetWaiterAssignmentsRequest(contextId);
return Results.Ok(await handler.HandleAsync(request, ct));
});
app.MapPost("/api/waiter/floor/orders", async (
SubmitFloorOrderRequest request,
HttpContext context,
@ -61,6 +86,25 @@ app.MapPost("/api/waiter/floor/orders", async (
return Results.Ok(await handler.HandleAsync(request, ct));
});
app.MapPut("/api/waiter/floor/orders/{orderId}", async (
string orderId,
UpdateFloorOrderBody requestBody,
HttpContext context,
IHttpClientFactory httpClientFactory,
IConfiguration configuration,
IUpdateFloorOrderHandler handler,
CancellationToken ct) =>
{
var authError = await EnforceSessionAsync(context, httpClientFactory, configuration, ct);
if (authError is not null)
{
return authError;
}
var request = new UpdateFloorOrderRequest(requestBody.ContextId, requestBody.TableId, orderId, requestBody.ItemCount);
return Results.Ok(await handler.HandleAsync(request, ct));
});
app.MapGet("/health", () => Results.Ok(new { status = "ok", service = "waiter-floor-bff" }));
app.MapGet("/healthz", () => Results.Ok(new { status = "ok", service = "waiter-floor-bff" }));
@ -184,3 +228,4 @@ static IResult ErrorResponse(int statusCode, string code, string message, string
}
sealed record AuthErrorResponse(string Code, string Message, string CorrelationId);
sealed record UpdateFloorOrderBody(string ContextId, string TableId, int ItemCount);

View File

@ -0,0 +1,120 @@
using System.Net;
using System.Net.Http;
using System.Text;
using Waiter.Floor.Bff.Application.Adapters;
using Waiter.Floor.Bff.Contracts.Requests;
namespace Waiter.Floor.Bff.Application.UnitTests;
public sealed class OperationsWaiterServiceClientTests
{
[Fact]
public async Task FetchAssignmentsAsync_MapsAssignmentsAndRecentActivity()
{
var client = CreateClient("""
{
"contextId": "demo-context",
"locationId": "restaurant-demo",
"summary": "2 active waiter assignments are currently visible.",
"assignments": [
{ "waiterId": "waiter-01", "tableId": "T-12", "status": "serving", "activeOrders": 2 }
],
"recentActivity": [
"demo-context: table T-12 requested dessert menus"
]
}
""");
var adapter = new OperationsWaiterServiceClient(client);
var response = await adapter.FetchAssignmentsAsync(new GetWaiterAssignmentsRequest("demo-context"), CancellationToken.None);
Assert.Equal("restaurant-demo", response.LocationId);
Assert.Single(response.Assignments);
Assert.Single(response.RecentActivity);
Assert.Equal("waiter-01", response.Assignments.Single().WaiterId);
}
[Fact]
public async Task FetchRecentActivityAsync_ProjectsActivityOnlyResponse()
{
var client = CreateClient("""
{
"contextId": "demo-context",
"locationId": "restaurant-demo",
"summary": "2 active waiter assignments are currently visible.",
"assignments": [],
"recentActivity": [
"demo-context: table T-08 is waiting for payment capture"
]
}
""");
var adapter = new OperationsWaiterServiceClient(client);
var response = await adapter.FetchRecentActivityAsync(new GetWaiterAssignmentsRequest("demo-context"), CancellationToken.None);
Assert.Equal("demo-context", response.ContextId);
Assert.Single(response.RecentActivity);
}
[Fact]
public async Task SubmitOrderAsync_MapsOperationsPayloadToSubmitResponse()
{
var client = CreateClient("""
{
"contextId": "demo-context",
"orderId": "ORD-42",
"accepted": true,
"summary": "Order ORD-42 for table T-12 was accepted with 3 items.",
"status": "queued",
"submittedAtUtc": "2026-03-31T10:15:00Z"
}
""");
var adapter = new OperationsWaiterServiceClient(client);
var response = await adapter.SubmitOrderAsync(new SubmitFloorOrderRequest("demo-context", "T-12", "ORD-42", 3), CancellationToken.None);
Assert.True(response.Accepted);
Assert.Equal("queued", response.Status);
Assert.Equal("demo-context", response.ContextId);
}
[Fact]
public async Task UpdateOrderAsync_PrefixesAcceptedUpdateSummary()
{
var client = CreateClient("""
{
"contextId": "demo-context",
"orderId": "ORD-42",
"accepted": true,
"summary": "Order ORD-42 for table T-12 was accepted with 4 items.",
"status": "queued",
"submittedAtUtc": "2026-03-31T10:15:00Z"
}
""");
var adapter = new OperationsWaiterServiceClient(client);
var response = await adapter.UpdateOrderAsync(new UpdateFloorOrderRequest("demo-context", "T-12", "ORD-42", 4), CancellationToken.None);
Assert.Contains("Updated order ORD-42.", response.Summary);
Assert.Equal("queued", response.Status);
}
private static HttpClient CreateClient(string json)
{
return new HttpClient(new StubHttpMessageHandler(json))
{
BaseAddress = new Uri("http://operations-service:8080/")
};
}
private sealed class StubHttpMessageHandler(string json) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
});
}
}
}

View File

@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../src/Waiter.Floor.Bff.Application/Waiter.Floor.Bff.Application.csproj" />
</ItemGroup>
</Project>