feat(restaurant-admin-bff): add admin workflow endpoints

This commit is contained in:
José René White Enciso 2026-03-31 16:37:06 -06:00
parent 4c61e61925
commit 8029c42b48
21 changed files with 381 additions and 25 deletions

View File

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

View File

@ -0,0 +1,27 @@
# Restaurant Admin Workflow API
## Purpose
This BFF exposes restaurant-admin control-plane workflows over REST while delegating orchestration to `operations-service`.
## Endpoints
- `GET /api/restaurant/admin/config?contextId=<value>`
- Returns configuration snapshot, feature flags, service windows, and recent changes.
- `GET /api/restaurant/admin/changes?contextId=<value>`
- Returns recent configuration change history.
- `POST /api/restaurant/admin/service-window`
- Updates a service window and returns the applied snapshot.
## Upstream Dependency
- Base address configuration: `OperationsService:BaseAddress`
- Default runtime target: `http://operations-service:8080`
- Internal upstream routes:
- `GET /internal/operations/admin/config`
- `POST /internal/operations/admin/service-window`
## Notes
- The explicit change-history route is projected from the same admin config snapshot returned by `operations-service`.
- Correlation IDs are preserved through Thalos session checks and operations-service calls.

View File

@ -1,6 +1,6 @@
# Restaurant Admin Ownership Boundary # Restaurant Admin Ownership Boundary
- Control-plane-facing BFF for policy, schedule, and operational configuration. - Control-plane-facing BFF for policy, schedule, and operational configuration.
- Owns admin edge contracts for feature flags, service windows, and overrides. - Owns admin edge contracts for feature flags, service windows, overrides, and recent change visibility.
- Does not own transactional order execution. - Does not own transactional order execution.
- Consumes service APIs only; no direct DAL access. - Consumes service APIs only; no direct DAL access.

View File

@ -13,6 +13,7 @@ restaurant-admin-bff
- Kitchen queue and dispatch optimization hooks. - Kitchen queue and dispatch optimization hooks.
- Operations control-plane policies (flags, service windows, overrides). - Operations control-plane policies (flags, service windows, overrides).
- POS closeout and settlement summary alignment. - POS closeout and settlement summary alignment.
- Admin change history and configuration snapshot workflows that stay aligned with operations-service.
## Documentation Contract ## Documentation Contract
Any code change in this repository must include docs updates in the same branch. Any code change in this repository must include docs updates in the same branch.

View File

@ -22,7 +22,8 @@ docker run --rm -p 8080:8080 --name restaurant-admin-bff agilewebs/restaurant-ad
## Runtime Notes ## Runtime Notes
- Exposes REST control-plane endpoints for admin configuration updates. - Exposes REST control-plane endpoints for admin configuration snapshots, recent change history, and service-window updates.
- Requires `OperationsService__BaseAddress` to resolve the upstream operations-service runtime.
- Requires `ThalosAuth__BaseAddress` to resolve Thalos session introspection endpoint. - Requires `ThalosAuth__BaseAddress` to resolve Thalos session introspection endpoint.
- Returns standardized auth failures (`401|403|503`) with `x-correlation-id` propagation. - Returns standardized auth failures (`401|403|503`) with `x-correlation-id` propagation.
@ -38,5 +39,5 @@ docker run --rm -p 8080:8080 --name restaurant-admin-bff agilewebs/restaurant-ad
- Integration artifact path: `greenfield/demo/restaurant/docker-compose.yml` - Integration artifact path: `greenfield/demo/restaurant/docker-compose.yml`
## Known Limitations ## Known Limitations
- Current runtime adapters are still predominantly in-memory for deterministic local/demo behavior. - Restaurant-admin now delegates control-plane 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. - Demo PostgreSQL seeds validate integration contracts and smoke determinism, but do not yet imply full persistence implementation parity.

View File

@ -7,6 +7,7 @@ This BFF enforces authenticated access on business endpoints using Thalos sessio
## Protected Endpoints ## Protected Endpoints
- `/api/restaurant/admin/config` - `/api/restaurant/admin/config`
- `/api/restaurant/admin/changes`
- `/api/restaurant/admin/service-window` - `/api/restaurant/admin/service-window`
## Anonymous Endpoints ## Anonymous Endpoints

View File

@ -1,17 +0,0 @@
using Restaurant.Admin.Bff.Contracts.Requests;
using Restaurant.Admin.Bff.Contracts.Responses;
namespace Restaurant.Admin.Bff.Application.Adapters;
public sealed class DefaultRestaurantAdminServiceClient : IRestaurantAdminServiceClient
{
public Task<GetRestaurantAdminConfigResponse> FetchAsync(GetRestaurantAdminConfigRequest request, CancellationToken cancellationToken)
{
return Task.FromResult(new GetRestaurantAdminConfigResponse(request.ContextId, "Default service-backed response."));
}
public Task<SetServiceWindowResponse> SetServiceWindowAsync(SetServiceWindowRequest request, CancellationToken cancellationToken)
{
return Task.FromResult(new SetServiceWindowResponse(request.ContextId, true, "Service window updated by default adapter."));
}
}

View File

@ -5,6 +5,7 @@ namespace Restaurant.Admin.Bff.Application.Adapters;
public interface IRestaurantAdminServiceClient public interface IRestaurantAdminServiceClient
{ {
Task<GetRestaurantAdminConfigResponse> FetchAsync(GetRestaurantAdminConfigRequest request, CancellationToken cancellationToken); Task<GetRestaurantAdminConfigResponse> FetchConfigAsync(GetRestaurantAdminConfigRequest request, CancellationToken cancellationToken);
Task<GetRecentAdminChangesResponse> FetchRecentChangesAsync(GetRestaurantAdminConfigRequest request, CancellationToken cancellationToken);
Task<SetServiceWindowResponse> SetServiceWindowAsync(SetServiceWindowRequest request, CancellationToken cancellationToken); Task<SetServiceWindowResponse> SetServiceWindowAsync(SetServiceWindowRequest request, CancellationToken cancellationToken);
} }

View File

@ -0,0 +1,128 @@
using System.Net.Http.Json;
using Restaurant.Admin.Bff.Contracts.Contracts;
using Restaurant.Admin.Bff.Contracts.Requests;
using Restaurant.Admin.Bff.Contracts.Responses;
namespace Restaurant.Admin.Bff.Application.Adapters;
public sealed class OperationsRestaurantAdminServiceClient(HttpClient httpClient) : IRestaurantAdminServiceClient
{
public async Task<GetRestaurantAdminConfigResponse> FetchConfigAsync(GetRestaurantAdminConfigRequest request, CancellationToken cancellationToken)
{
var payload = await GetConfigPayloadAsync(request.ContextId, cancellationToken);
return new GetRestaurantAdminConfigResponse(
payload.ContextId,
payload.Summary,
payload.Version,
payload.FeatureFlags
.Select(static flag => new FeatureFlagStateContract(flag.Key, flag.Enabled))
.ToArray(),
payload.ServiceWindows.Select(MapServiceWindow).ToArray(),
payload.RecentChanges.Select(MapConfigChange).ToArray());
}
public async Task<GetRecentAdminChangesResponse> FetchRecentChangesAsync(GetRestaurantAdminConfigRequest request, CancellationToken cancellationToken)
{
var payload = await GetConfigPayloadAsync(request.ContextId, cancellationToken);
return new GetRecentAdminChangesResponse(
payload.ContextId,
payload.Summary,
payload.Version,
payload.RecentChanges.Select(MapConfigChange).ToArray());
}
public async Task<SetServiceWindowResponse> SetServiceWindowAsync(SetServiceWindowRequest request, CancellationToken cancellationToken)
{
var payload = await SetServiceWindowPayloadAsync(
new SetServiceWindowPayload(
request.ContextId,
(int)request.Day,
request.OpenAt.ToString("HH:mm:ss"),
request.CloseAt.ToString("HH:mm:ss"),
request.UpdatedBy),
cancellationToken);
return new SetServiceWindowResponse(
payload.ContextId,
payload.Applied,
payload.Message,
MapServiceWindow(payload.ServiceWindow));
}
private async Task<GetRestaurantAdminConfigPayload> GetConfigPayloadAsync(string contextId, CancellationToken cancellationToken)
{
var payload = await httpClient.GetFromJsonAsync<GetRestaurantAdminConfigPayload>(
$"internal/operations/admin/config?contextId={Uri.EscapeDataString(contextId)}",
cancellationToken);
return payload ?? throw new InvalidOperationException("Operations service returned an empty restaurant admin config payload.");
}
private async Task<SetServiceWindowResponsePayload> SetServiceWindowPayloadAsync(
SetServiceWindowPayload request,
CancellationToken cancellationToken)
{
using var response = await httpClient.PostAsJsonAsync("internal/operations/admin/service-window", request, cancellationToken);
response.EnsureSuccessStatusCode();
var payload = await response.Content.ReadFromJsonAsync<SetServiceWindowResponsePayload>(cancellationToken);
return payload ?? throw new InvalidOperationException("Operations service returned an empty service-window payload.");
}
private static ServiceWindowContract MapServiceWindow(ServiceWindowPayload serviceWindow)
{
return new ServiceWindowContract(
(DayOfWeek)serviceWindow.DayOfWeek,
TimeOnly.Parse(serviceWindow.OpenAt),
TimeOnly.Parse(serviceWindow.CloseAt),
serviceWindow.IsClosed);
}
private static ConfigChangeContract MapConfigChange(ConfigChangePayload change)
{
return new ConfigChangeContract(
change.ChangeId,
change.Category,
change.Description,
change.UpdatedBy,
change.UpdatedAtUtc);
}
private sealed record GetRestaurantAdminConfigPayload(
string ContextId,
string Summary,
string Version,
IReadOnlyCollection<FeatureFlagPayload> FeatureFlags,
IReadOnlyCollection<ServiceWindowPayload> ServiceWindows,
IReadOnlyCollection<ConfigChangePayload> RecentChanges);
private sealed record FeatureFlagPayload(string Key, bool Enabled);
private sealed record ServiceWindowPayload(
int DayOfWeek,
string OpenAt,
string CloseAt,
bool IsClosed);
private sealed record ConfigChangePayload(
string ChangeId,
string Category,
string Description,
string UpdatedBy,
DateTime UpdatedAtUtc);
private sealed record SetServiceWindowPayload(
string ContextId,
int DayOfWeek,
string OpenAt,
string CloseAt,
string UpdatedBy);
private sealed record SetServiceWindowResponsePayload(
string ContextId,
bool Applied,
string Message,
ServiceWindowPayload ServiceWindow);
}

View File

@ -0,0 +1,13 @@
using Restaurant.Admin.Bff.Application.Adapters;
using Restaurant.Admin.Bff.Contracts.Requests;
using Restaurant.Admin.Bff.Contracts.Responses;
namespace Restaurant.Admin.Bff.Application.Handlers;
public sealed class GetRecentAdminChangesHandler(IRestaurantAdminServiceClient serviceClient) : IGetRecentAdminChangesHandler
{
public Task<GetRecentAdminChangesResponse> HandleAsync(GetRestaurantAdminConfigRequest request, CancellationToken cancellationToken)
{
return serviceClient.FetchRecentChangesAsync(request, cancellationToken);
}
}

View File

@ -8,6 +8,6 @@ public sealed class GetRestaurantAdminConfigHandler(IRestaurantAdminServiceClien
{ {
public Task<GetRestaurantAdminConfigResponse> HandleAsync(GetRestaurantAdminConfigRequest request, CancellationToken cancellationToken) public Task<GetRestaurantAdminConfigResponse> HandleAsync(GetRestaurantAdminConfigRequest request, CancellationToken cancellationToken)
{ {
return serviceClient.FetchAsync(request, cancellationToken); return serviceClient.FetchConfigAsync(request, cancellationToken);
} }
} }

View File

@ -0,0 +1,9 @@
using Restaurant.Admin.Bff.Contracts.Requests;
using Restaurant.Admin.Bff.Contracts.Responses;
namespace Restaurant.Admin.Bff.Application.Handlers;
public interface IGetRecentAdminChangesHandler
{
Task<GetRecentAdminChangesResponse> HandleAsync(GetRestaurantAdminConfigRequest request, CancellationToken cancellationToken);
}

View File

@ -0,0 +1,8 @@
namespace Restaurant.Admin.Bff.Contracts.Contracts;
public sealed record ConfigChangeContract(
string ChangeId,
string Category,
string Description,
string UpdatedBy,
DateTime UpdatedAtUtc);

View File

@ -0,0 +1,3 @@
namespace Restaurant.Admin.Bff.Contracts.Contracts;
public sealed record FeatureFlagStateContract(string Key, bool Enabled);

View File

@ -0,0 +1,7 @@
namespace Restaurant.Admin.Bff.Contracts.Contracts;
public sealed record ServiceWindowContract(
DayOfWeek Day,
TimeOnly OpenAt,
TimeOnly CloseAt,
bool IsClosed);

View File

@ -0,0 +1,9 @@
using Restaurant.Admin.Bff.Contracts.Contracts;
namespace Restaurant.Admin.Bff.Contracts.Responses;
public sealed record GetRecentAdminChangesResponse(
string ContextId,
string Summary,
string Version,
IReadOnlyCollection<ConfigChangeContract> RecentChanges);

View File

@ -1,3 +1,11 @@
using Restaurant.Admin.Bff.Contracts.Contracts;
namespace Restaurant.Admin.Bff.Contracts.Responses; namespace Restaurant.Admin.Bff.Contracts.Responses;
public sealed record GetRestaurantAdminConfigResponse(string ContextId, string Summary); public sealed record GetRestaurantAdminConfigResponse(
string ContextId,
string Summary,
string Version,
IReadOnlyCollection<FeatureFlagStateContract> FeatureFlags,
IReadOnlyCollection<ServiceWindowContract> ServiceWindows,
IReadOnlyCollection<ConfigChangeContract> RecentChanges);

View File

@ -1,6 +1,9 @@
using Restaurant.Admin.Bff.Contracts.Contracts;
namespace Restaurant.Admin.Bff.Contracts.Responses; namespace Restaurant.Admin.Bff.Contracts.Responses;
public sealed record SetServiceWindowResponse( public sealed record SetServiceWindowResponse(
string ContextId, string ContextId,
bool Applied, bool Applied,
string Message); string Message,
ServiceWindowContract ServiceWindow);

View File

@ -10,8 +10,14 @@ const string SessionAccessCookieName = "thalos_session";
const string SessionRefreshCookieName = "thalos_refresh"; const string SessionRefreshCookieName = "thalos_refresh";
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<IRestaurantAdminServiceClient, DefaultRestaurantAdminServiceClient>(); builder.Services.AddHttpClient<IRestaurantAdminServiceClient, OperationsRestaurantAdminServiceClient>((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<IGetRestaurantAdminConfigHandler, GetRestaurantAdminConfigHandler>(); builder.Services.AddSingleton<IGetRestaurantAdminConfigHandler, GetRestaurantAdminConfigHandler>();
builder.Services.AddSingleton<IGetRecentAdminChangesHandler, GetRecentAdminChangesHandler>();
builder.Services.AddSingleton<ISetServiceWindowHandler, SetServiceWindowHandler>(); builder.Services.AddSingleton<ISetServiceWindowHandler, SetServiceWindowHandler>();
builder.Services.AddHttpClient("ThalosAuth"); builder.Services.AddHttpClient("ThalosAuth");
@ -44,6 +50,24 @@ app.MapGet("/api/restaurant/admin/config", async (
return Results.Ok(await handler.HandleAsync(request, ct)); return Results.Ok(await handler.HandleAsync(request, ct));
}); });
app.MapGet("/api/restaurant/admin/changes", async (
string contextId,
HttpContext context,
IHttpClientFactory httpClientFactory,
IConfiguration configuration,
IGetRecentAdminChangesHandler handler,
CancellationToken ct) =>
{
var authError = await EnforceSessionAsync(context, httpClientFactory, configuration, ct);
if (authError is not null)
{
return authError;
}
var request = new GetRestaurantAdminConfigRequest(contextId);
return Results.Ok(await handler.HandleAsync(request, ct));
});
app.MapPost("/api/restaurant/admin/service-window", async ( app.MapPost("/api/restaurant/admin/service-window", async (
SetServiceWindowRequest request, SetServiceWindowRequest request,
HttpContext context, HttpContext context,

View File

@ -0,0 +1,108 @@
using System.Net;
using System.Text;
using Restaurant.Admin.Bff.Application.Adapters;
using Restaurant.Admin.Bff.Contracts.Requests;
namespace Restaurant.Admin.Bff.Application.UnitTests;
public sealed class OperationsRestaurantAdminServiceClientTests
{
[Fact]
public async Task FetchConfigAsync_MapsFlagsWindowsAndChanges()
{
var adapter = new OperationsRestaurantAdminServiceClient(CreateClient(ConfigPayload));
var response = await adapter.FetchConfigAsync(new GetRestaurantAdminConfigRequest("demo-context"), CancellationToken.None);
Assert.Equal("v2", response.Version);
Assert.NotEmpty(response.FeatureFlags);
Assert.NotEmpty(response.ServiceWindows);
Assert.NotEmpty(response.RecentChanges);
}
[Fact]
public async Task FetchRecentChangesAsync_ProjectsChangeHistory()
{
var adapter = new OperationsRestaurantAdminServiceClient(CreateClient(ConfigPayload));
var response = await adapter.FetchRecentChangesAsync(new GetRestaurantAdminConfigRequest("demo-context"), CancellationToken.None);
Assert.Equal(2, response.RecentChanges.Count);
}
[Fact]
public async Task SetServiceWindowAsync_MapsUpdatedServiceWindow()
{
var adapter = new OperationsRestaurantAdminServiceClient(CreateClient("""
{
"contextId": "demo-context",
"applied": true,
"message": "Service window updated by admin-operator.",
"serviceWindow": {
"dayOfWeek": 1,
"openAt": "08:00:00",
"closeAt": "22:00:00",
"isClosed": false
}
}
"""));
var response = await adapter.SetServiceWindowAsync(
new SetServiceWindowRequest("demo-context", DayOfWeek.Monday, new TimeOnly(8, 0), new TimeOnly(22, 0), "admin-operator"),
CancellationToken.None);
Assert.True(response.Applied);
Assert.Equal(DayOfWeek.Monday, response.ServiceWindow.Day);
}
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")
});
}
}
private const string ConfigPayload = """
{
"contextId": "demo-context",
"summary": "Restaurant admin snapshot includes current flags, service windows, and recent control-plane changes.",
"version": "v2",
"featureFlags": [
{ "key": "kitchen.dispatch.enabled", "enabled": true },
{ "key": "pos.closeout.preview", "enabled": true }
],
"serviceWindows": [
{ "dayOfWeek": 1, "openAt": "08:00:00", "closeAt": "22:00:00", "isClosed": false },
{ "dayOfWeek": 5, "openAt": "08:00:00", "closeAt": "23:30:00", "isClosed": false }
],
"recentChanges": [
{
"changeId": "CFG-100",
"category": "service-window",
"description": "Extended Friday dinner service window.",
"updatedBy": "admin-operator",
"updatedAtUtc": "2026-03-31T10:00:00Z"
},
{
"changeId": "CFG-101",
"category": "feature-flag",
"description": "Enabled POS closeout preview mode.",
"updatedBy": "ops-lead",
"updatedAtUtc": "2026-03-31T12:00:00Z"
}
]
}
""";
}

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/Restaurant.Admin.Bff.Application/Restaurant.Admin.Bff.Application.csproj" />
</ItemGroup>
</Project>