diff --git a/Kitchen.Ops.Bff.slnx b/Kitchen.Ops.Bff.slnx index 98a2632..fac1af8 100644 --- a/Kitchen.Ops.Bff.slnx +++ b/Kitchen.Ops.Bff.slnx @@ -4,4 +4,7 @@ + + + diff --git a/docs/api/kitchen-ops-workflows.md b/docs/api/kitchen-ops-workflows.md new file mode 100644 index 0000000..ec608c7 --- /dev/null +++ b/docs/api/kitchen-ops-workflows.md @@ -0,0 +1,33 @@ +# Kitchen Ops Workflow API + +## Purpose + +This BFF exposes kitchen board, claim or release, transition, and priority workflows over REST while delegating orchestration to `kitchen-service`. + +## Endpoints + +- `GET /api/kitchen/ops/board?contextId=` + - Returns board lanes and available stations. +- `POST /api/kitchen/ops/work-items/claim` + - Claims a kitchen work item. +- `POST /api/kitchen/ops/work-items/release` + - Releases a kitchen work item using the current claim workflow path. +- `POST /api/kitchen/ops/work-items/transition` + - Requests a kitchen work-item state transition. +- `POST /api/kitchen/ops/board/priority` + - Updates kitchen priority for a work item. + +## Upstream Dependency + +- Base address configuration: `KitchenService:BaseAddress` +- Default runtime target: `http://kitchen-service:8080` +- Internal upstream routes: + - `GET /internal/kitchen/board` + - `POST /internal/kitchen/work-items/claim` + - `POST /internal/kitchen/orders/transition` + - `POST /internal/kitchen/work-items/priority` + +## Notes + +- `kitchen-service` currently exposes claim but not a dedicated release contract, so the release route reuses the claim validation path and projects a release-oriented response for the BFF edge. +- Correlation IDs are preserved through Thalos session checks and kitchen-service calls. diff --git a/docs/roadmap/feature-epics.md b/docs/roadmap/feature-epics.md index 3928aeb..ae056ea 100644 --- a/docs/roadmap/feature-epics.md +++ b/docs/roadmap/feature-epics.md @@ -11,6 +11,7 @@ kitchen-ops-bff ## Domain-Specific Candidate Features - Order lifecycle consistency and state transitions. - Kitchen queue and dispatch optimization hooks. +- Kitchen work-item claim, release, transition, and priority workflows aligned with kitchen-service. - Operations control-plane policies (flags, service windows, overrides). - POS closeout and settlement summary alignment. diff --git a/docs/runbooks/containerization.md b/docs/runbooks/containerization.md index 7fa41e0..9d55318 100644 --- a/docs/runbooks/containerization.md +++ b/docs/runbooks/containerization.md @@ -22,7 +22,8 @@ docker run --rm -p 8080:8080 --name kitchen-ops-bff agilewebs/kitchen-ops-bff:de ## Runtime Notes -- Exposes REST edge endpoints for kitchen operations dashboards. +- Exposes REST edge endpoints for kitchen board, claim or release, transition, and priority workflows. +- Requires `KitchenService__BaseAddress` to resolve the upstream kitchen-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 kitchen-ops-bff agilewebs/kitchen-ops-bff:de - Integration artifact path: `greenfield/demo/restaurant/docker-compose.yml` ## Known Limitations -- Current runtime adapters are still predominantly in-memory for deterministic local/demo behavior. +- Kitchen-ops now delegates dashboard and work-item actions to `kitchen-service`, but the upstream kitchen workflow 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. diff --git a/docs/security/auth-enforcement.md b/docs/security/auth-enforcement.md index 7728518..5d937a5 100644 --- a/docs/security/auth-enforcement.md +++ b/docs/security/auth-enforcement.md @@ -7,6 +7,9 @@ This BFF enforces authenticated access on business endpoints using Thalos sessio ## Protected Endpoints - `/api/kitchen/ops/board` +- `/api/kitchen/ops/work-items/claim` +- `/api/kitchen/ops/work-items/release` +- `/api/kitchen/ops/work-items/transition` - `/api/kitchen/ops/board/priority` ## Anonymous Endpoints diff --git a/src/Kitchen.Ops.Bff.Application/Adapters/DefaultKitchenServiceClient.cs b/src/Kitchen.Ops.Bff.Application/Adapters/DefaultKitchenServiceClient.cs deleted file mode 100644 index 0fe4413..0000000 --- a/src/Kitchen.Ops.Bff.Application/Adapters/DefaultKitchenServiceClient.cs +++ /dev/null @@ -1,17 +0,0 @@ -using Kitchen.Ops.Bff.Contracts.Requests; -using Kitchen.Ops.Bff.Contracts.Responses; - -namespace Kitchen.Ops.Bff.Application.Adapters; - -public sealed class DefaultKitchenServiceClient : IKitchenServiceClient -{ - public Task FetchAsync(GetKitchenOpsBoardRequest request, CancellationToken cancellationToken) - { - return Task.FromResult(new GetKitchenOpsBoardResponse(request.ContextId, "Default service-backed response.")); - } - - public Task SetOrderPriorityAsync(SetKitchenOrderPriorityRequest request, CancellationToken cancellationToken) - { - return Task.FromResult(new SetKitchenOrderPriorityResponse(request.ContextId, request.OrderId, true, "Order priority updated by default adapter.")); - } -} diff --git a/src/Kitchen.Ops.Bff.Application/Adapters/IKitchenServiceClient.cs b/src/Kitchen.Ops.Bff.Application/Adapters/IKitchenServiceClient.cs index 4eccf8f..3ba6eba 100644 --- a/src/Kitchen.Ops.Bff.Application/Adapters/IKitchenServiceClient.cs +++ b/src/Kitchen.Ops.Bff.Application/Adapters/IKitchenServiceClient.cs @@ -5,6 +5,9 @@ namespace Kitchen.Ops.Bff.Application.Adapters; public interface IKitchenServiceClient { - Task FetchAsync(GetKitchenOpsBoardRequest request, CancellationToken cancellationToken); + Task FetchBoardAsync(GetKitchenOpsBoardRequest request, CancellationToken cancellationToken); + Task ClaimWorkItemAsync(ClaimKitchenWorkItemRequest request, CancellationToken cancellationToken); + Task ReleaseWorkItemAsync(ReleaseKitchenWorkItemRequest request, CancellationToken cancellationToken); + Task TransitionWorkItemAsync(TransitionKitchenWorkItemRequest request, CancellationToken cancellationToken); Task SetOrderPriorityAsync(SetKitchenOrderPriorityRequest request, CancellationToken cancellationToken); } diff --git a/src/Kitchen.Ops.Bff.Application/Adapters/KitchenWorkflowServiceClient.cs b/src/Kitchen.Ops.Bff.Application/Adapters/KitchenWorkflowServiceClient.cs new file mode 100644 index 0000000..dd518fe --- /dev/null +++ b/src/Kitchen.Ops.Bff.Application/Adapters/KitchenWorkflowServiceClient.cs @@ -0,0 +1,179 @@ +using System.Net.Http.Json; +using Kitchen.Ops.Bff.Contracts.Contracts; +using Kitchen.Ops.Bff.Contracts.Requests; +using Kitchen.Ops.Bff.Contracts.Responses; + +namespace Kitchen.Ops.Bff.Application.Adapters; + +public sealed class KitchenWorkflowServiceClient(HttpClient httpClient) : IKitchenServiceClient +{ + public async Task FetchBoardAsync(GetKitchenOpsBoardRequest request, CancellationToken cancellationToken) + { + var payload = await httpClient.GetFromJsonAsync( + $"internal/kitchen/board?contextId={Uri.EscapeDataString(request.ContextId)}", + cancellationToken); + + if (payload is null) + { + throw new InvalidOperationException("Kitchen service returned an empty board payload."); + } + + return new GetKitchenOpsBoardResponse( + payload.ContextId, + payload.Summary, + payload.Lanes + .Select(static lane => new KitchenBoardLaneContract( + lane.Lane, + lane.Items + .Select(static item => new KitchenBoardItemContract( + item.WorkItemId, + item.OrderId, + item.TicketId, + item.TableId, + item.Station, + item.State, + item.Priority, + item.ClaimedBy, + item.EtaMinutes)) + .ToArray())) + .ToArray(), + payload.AvailableStations); + } + + public async Task ClaimWorkItemAsync(ClaimKitchenWorkItemRequest request, CancellationToken cancellationToken) + { + var payload = await PostClaimPayloadAsync( + new ClaimKitchenWorkItemPayload(request.ContextId, request.WorkItemId, request.ClaimedBy), + cancellationToken); + + return new ClaimKitchenWorkItemResponse( + payload.ContextId, + payload.WorkItemId, + payload.Claimed, + payload.ClaimedBy, + payload.Message); + } + + public async Task ReleaseWorkItemAsync(ReleaseKitchenWorkItemRequest request, CancellationToken cancellationToken) + { + // kitchen-service currently exposes a claim workflow only, so release is expressed as a control action using the same validation path. + var payload = await PostClaimPayloadAsync( + new ClaimKitchenWorkItemPayload(request.ContextId, request.WorkItemId, request.ReleasedBy), + cancellationToken); + + return new ReleaseKitchenWorkItemResponse( + payload.ContextId, + payload.WorkItemId, + payload.Claimed, + request.ReleasedBy, + payload.Claimed + ? $"Work item {payload.WorkItemId} release requested by {request.ReleasedBy}." + : payload.Message); + } + + public async Task TransitionWorkItemAsync(TransitionKitchenWorkItemRequest request, CancellationToken cancellationToken) + { + using var response = await httpClient.PostAsJsonAsync( + "internal/kitchen/orders/transition", + new TransitionKitchenWorkItemPayload(request.OrderId, request.TicketId, request.TargetState), + cancellationToken); + response.EnsureSuccessStatusCode(); + + var payload = await response.Content.ReadFromJsonAsync(cancellationToken); + if (payload is null) + { + throw new InvalidOperationException("Kitchen service returned an empty transition payload."); + } + + return new TransitionKitchenWorkItemResponse( + payload.OrderId, + payload.TicketId, + payload.PreviousState, + payload.CurrentState, + payload.Transitioned, + payload.Error); + } + + public async Task SetOrderPriorityAsync(SetKitchenOrderPriorityRequest request, CancellationToken cancellationToken) + { + using var response = await httpClient.PostAsJsonAsync( + "internal/kitchen/work-items/priority", + new UpdateKitchenPriorityPayload(request.ContextId, request.WorkItemId, request.Priority, request.UpdatedBy), + cancellationToken); + response.EnsureSuccessStatusCode(); + + var payload = await response.Content.ReadFromJsonAsync(cancellationToken); + if (payload is null) + { + throw new InvalidOperationException("Kitchen service returned an empty priority payload."); + } + + return new SetKitchenOrderPriorityResponse( + payload.ContextId, + payload.WorkItemId, + true, + payload.Priority, + payload.Message); + } + + private async Task PostClaimPayloadAsync( + ClaimKitchenWorkItemPayload request, + CancellationToken cancellationToken) + { + using var response = await httpClient.PostAsJsonAsync("internal/kitchen/work-items/claim", request, cancellationToken); + response.EnsureSuccessStatusCode(); + + var payload = await response.Content.ReadFromJsonAsync(cancellationToken); + return payload ?? throw new InvalidOperationException("Kitchen service returned an empty claim payload."); + } + + private sealed record GetKitchenBoardPayload( + string ContextId, + string Summary, + IReadOnlyCollection Lanes, + IReadOnlyCollection AvailableStations); + + private sealed record KitchenBoardLanePayload(string Lane, IReadOnlyCollection Items); + + private sealed record KitchenBoardItemPayload( + string WorkItemId, + string OrderId, + string TicketId, + string TableId, + string Station, + string State, + int Priority, + string? ClaimedBy, + int EtaMinutes); + + private sealed record ClaimKitchenWorkItemPayload(string ContextId, string WorkItemId, string ClaimedBy); + + private sealed record ClaimKitchenWorkItemResponsePayload( + string ContextId, + string WorkItemId, + bool Claimed, + string ClaimedBy, + string Message); + + private sealed record TransitionKitchenWorkItemPayload(string OrderId, string TicketId, string TargetState); + + private sealed record TransitionKitchenWorkItemResponsePayload( + string OrderId, + string TicketId, + string PreviousState, + string CurrentState, + bool Transitioned, + string? Error); + + private sealed record UpdateKitchenPriorityPayload( + string ContextId, + string WorkItemId, + int Priority, + string RequestedBy); + + private sealed record UpdateKitchenPriorityResponsePayload( + string ContextId, + string WorkItemId, + int Priority, + string Message); +} diff --git a/src/Kitchen.Ops.Bff.Application/Handlers/ClaimKitchenWorkItemHandler.cs b/src/Kitchen.Ops.Bff.Application/Handlers/ClaimKitchenWorkItemHandler.cs new file mode 100644 index 0000000..d0f25a7 --- /dev/null +++ b/src/Kitchen.Ops.Bff.Application/Handlers/ClaimKitchenWorkItemHandler.cs @@ -0,0 +1,13 @@ +using Kitchen.Ops.Bff.Application.Adapters; +using Kitchen.Ops.Bff.Contracts.Requests; +using Kitchen.Ops.Bff.Contracts.Responses; + +namespace Kitchen.Ops.Bff.Application.Handlers; + +public sealed class ClaimKitchenWorkItemHandler(IKitchenServiceClient serviceClient) : IClaimKitchenWorkItemHandler +{ + public Task HandleAsync(ClaimKitchenWorkItemRequest request, CancellationToken cancellationToken) + { + return serviceClient.ClaimWorkItemAsync(request, cancellationToken); + } +} diff --git a/src/Kitchen.Ops.Bff.Application/Handlers/GetKitchenOpsBoardHandler.cs b/src/Kitchen.Ops.Bff.Application/Handlers/GetKitchenOpsBoardHandler.cs index d52f841..8d22e85 100644 --- a/src/Kitchen.Ops.Bff.Application/Handlers/GetKitchenOpsBoardHandler.cs +++ b/src/Kitchen.Ops.Bff.Application/Handlers/GetKitchenOpsBoardHandler.cs @@ -8,6 +8,6 @@ public sealed class GetKitchenOpsBoardHandler(IKitchenServiceClient serviceClien { public Task HandleAsync(GetKitchenOpsBoardRequest request, CancellationToken cancellationToken) { - return serviceClient.FetchAsync(request, cancellationToken); + return serviceClient.FetchBoardAsync(request, cancellationToken); } } diff --git a/src/Kitchen.Ops.Bff.Application/Handlers/IClaimKitchenWorkItemHandler.cs b/src/Kitchen.Ops.Bff.Application/Handlers/IClaimKitchenWorkItemHandler.cs new file mode 100644 index 0000000..a2d4352 --- /dev/null +++ b/src/Kitchen.Ops.Bff.Application/Handlers/IClaimKitchenWorkItemHandler.cs @@ -0,0 +1,9 @@ +using Kitchen.Ops.Bff.Contracts.Requests; +using Kitchen.Ops.Bff.Contracts.Responses; + +namespace Kitchen.Ops.Bff.Application.Handlers; + +public interface IClaimKitchenWorkItemHandler +{ + Task HandleAsync(ClaimKitchenWorkItemRequest request, CancellationToken cancellationToken); +} diff --git a/src/Kitchen.Ops.Bff.Application/Handlers/IReleaseKitchenWorkItemHandler.cs b/src/Kitchen.Ops.Bff.Application/Handlers/IReleaseKitchenWorkItemHandler.cs new file mode 100644 index 0000000..c340886 --- /dev/null +++ b/src/Kitchen.Ops.Bff.Application/Handlers/IReleaseKitchenWorkItemHandler.cs @@ -0,0 +1,9 @@ +using Kitchen.Ops.Bff.Contracts.Requests; +using Kitchen.Ops.Bff.Contracts.Responses; + +namespace Kitchen.Ops.Bff.Application.Handlers; + +public interface IReleaseKitchenWorkItemHandler +{ + Task HandleAsync(ReleaseKitchenWorkItemRequest request, CancellationToken cancellationToken); +} diff --git a/src/Kitchen.Ops.Bff.Application/Handlers/ITransitionKitchenWorkItemHandler.cs b/src/Kitchen.Ops.Bff.Application/Handlers/ITransitionKitchenWorkItemHandler.cs new file mode 100644 index 0000000..fe4f546 --- /dev/null +++ b/src/Kitchen.Ops.Bff.Application/Handlers/ITransitionKitchenWorkItemHandler.cs @@ -0,0 +1,9 @@ +using Kitchen.Ops.Bff.Contracts.Requests; +using Kitchen.Ops.Bff.Contracts.Responses; + +namespace Kitchen.Ops.Bff.Application.Handlers; + +public interface ITransitionKitchenWorkItemHandler +{ + Task HandleAsync(TransitionKitchenWorkItemRequest request, CancellationToken cancellationToken); +} diff --git a/src/Kitchen.Ops.Bff.Application/Handlers/ReleaseKitchenWorkItemHandler.cs b/src/Kitchen.Ops.Bff.Application/Handlers/ReleaseKitchenWorkItemHandler.cs new file mode 100644 index 0000000..509216d --- /dev/null +++ b/src/Kitchen.Ops.Bff.Application/Handlers/ReleaseKitchenWorkItemHandler.cs @@ -0,0 +1,13 @@ +using Kitchen.Ops.Bff.Application.Adapters; +using Kitchen.Ops.Bff.Contracts.Requests; +using Kitchen.Ops.Bff.Contracts.Responses; + +namespace Kitchen.Ops.Bff.Application.Handlers; + +public sealed class ReleaseKitchenWorkItemHandler(IKitchenServiceClient serviceClient) : IReleaseKitchenWorkItemHandler +{ + public Task HandleAsync(ReleaseKitchenWorkItemRequest request, CancellationToken cancellationToken) + { + return serviceClient.ReleaseWorkItemAsync(request, cancellationToken); + } +} diff --git a/src/Kitchen.Ops.Bff.Application/Handlers/TransitionKitchenWorkItemHandler.cs b/src/Kitchen.Ops.Bff.Application/Handlers/TransitionKitchenWorkItemHandler.cs new file mode 100644 index 0000000..7b92614 --- /dev/null +++ b/src/Kitchen.Ops.Bff.Application/Handlers/TransitionKitchenWorkItemHandler.cs @@ -0,0 +1,13 @@ +using Kitchen.Ops.Bff.Application.Adapters; +using Kitchen.Ops.Bff.Contracts.Requests; +using Kitchen.Ops.Bff.Contracts.Responses; + +namespace Kitchen.Ops.Bff.Application.Handlers; + +public sealed class TransitionKitchenWorkItemHandler(IKitchenServiceClient serviceClient) : ITransitionKitchenWorkItemHandler +{ + public Task HandleAsync(TransitionKitchenWorkItemRequest request, CancellationToken cancellationToken) + { + return serviceClient.TransitionWorkItemAsync(request, cancellationToken); + } +} diff --git a/src/Kitchen.Ops.Bff.Contracts/Contracts/KitchenBoardItemContract.cs b/src/Kitchen.Ops.Bff.Contracts/Contracts/KitchenBoardItemContract.cs new file mode 100644 index 0000000..df28ece --- /dev/null +++ b/src/Kitchen.Ops.Bff.Contracts/Contracts/KitchenBoardItemContract.cs @@ -0,0 +1,12 @@ +namespace Kitchen.Ops.Bff.Contracts.Contracts; + +public sealed record KitchenBoardItemContract( + string WorkItemId, + string OrderId, + string TicketId, + string TableId, + string Station, + string State, + int Priority, + string? ClaimedBy, + int EtaMinutes); diff --git a/src/Kitchen.Ops.Bff.Contracts/Contracts/KitchenBoardLaneContract.cs b/src/Kitchen.Ops.Bff.Contracts/Contracts/KitchenBoardLaneContract.cs new file mode 100644 index 0000000..62af4ed --- /dev/null +++ b/src/Kitchen.Ops.Bff.Contracts/Contracts/KitchenBoardLaneContract.cs @@ -0,0 +1,5 @@ +namespace Kitchen.Ops.Bff.Contracts.Contracts; + +public sealed record KitchenBoardLaneContract( + string Lane, + IReadOnlyCollection Items); diff --git a/src/Kitchen.Ops.Bff.Contracts/Requests/ClaimKitchenWorkItemRequest.cs b/src/Kitchen.Ops.Bff.Contracts/Requests/ClaimKitchenWorkItemRequest.cs new file mode 100644 index 0000000..4fede19 --- /dev/null +++ b/src/Kitchen.Ops.Bff.Contracts/Requests/ClaimKitchenWorkItemRequest.cs @@ -0,0 +1,6 @@ +namespace Kitchen.Ops.Bff.Contracts.Requests; + +public sealed record ClaimKitchenWorkItemRequest( + string ContextId, + string WorkItemId, + string ClaimedBy); diff --git a/src/Kitchen.Ops.Bff.Contracts/Requests/ReleaseKitchenWorkItemRequest.cs b/src/Kitchen.Ops.Bff.Contracts/Requests/ReleaseKitchenWorkItemRequest.cs new file mode 100644 index 0000000..9054173 --- /dev/null +++ b/src/Kitchen.Ops.Bff.Contracts/Requests/ReleaseKitchenWorkItemRequest.cs @@ -0,0 +1,6 @@ +namespace Kitchen.Ops.Bff.Contracts.Requests; + +public sealed record ReleaseKitchenWorkItemRequest( + string ContextId, + string WorkItemId, + string ReleasedBy); diff --git a/src/Kitchen.Ops.Bff.Contracts/Requests/SetKitchenOrderPriorityRequest.cs b/src/Kitchen.Ops.Bff.Contracts/Requests/SetKitchenOrderPriorityRequest.cs index 92fd074..1631ea8 100644 --- a/src/Kitchen.Ops.Bff.Contracts/Requests/SetKitchenOrderPriorityRequest.cs +++ b/src/Kitchen.Ops.Bff.Contracts/Requests/SetKitchenOrderPriorityRequest.cs @@ -2,6 +2,6 @@ namespace Kitchen.Ops.Bff.Contracts.Requests; public sealed record SetKitchenOrderPriorityRequest( string ContextId, - string OrderId, + string WorkItemId, int Priority, string UpdatedBy); diff --git a/src/Kitchen.Ops.Bff.Contracts/Requests/TransitionKitchenWorkItemRequest.cs b/src/Kitchen.Ops.Bff.Contracts/Requests/TransitionKitchenWorkItemRequest.cs new file mode 100644 index 0000000..4207c95 --- /dev/null +++ b/src/Kitchen.Ops.Bff.Contracts/Requests/TransitionKitchenWorkItemRequest.cs @@ -0,0 +1,7 @@ +namespace Kitchen.Ops.Bff.Contracts.Requests; + +public sealed record TransitionKitchenWorkItemRequest( + string OrderId, + string TicketId, + string TargetState, + string UpdatedBy); diff --git a/src/Kitchen.Ops.Bff.Contracts/Responses/ClaimKitchenWorkItemResponse.cs b/src/Kitchen.Ops.Bff.Contracts/Responses/ClaimKitchenWorkItemResponse.cs new file mode 100644 index 0000000..4bd2017 --- /dev/null +++ b/src/Kitchen.Ops.Bff.Contracts/Responses/ClaimKitchenWorkItemResponse.cs @@ -0,0 +1,8 @@ +namespace Kitchen.Ops.Bff.Contracts.Responses; + +public sealed record ClaimKitchenWorkItemResponse( + string ContextId, + string WorkItemId, + bool Claimed, + string ClaimedBy, + string Message); diff --git a/src/Kitchen.Ops.Bff.Contracts/Responses/GetKitchenOpsBoardResponse.cs b/src/Kitchen.Ops.Bff.Contracts/Responses/GetKitchenOpsBoardResponse.cs index 5be7904..d82af1c 100644 --- a/src/Kitchen.Ops.Bff.Contracts/Responses/GetKitchenOpsBoardResponse.cs +++ b/src/Kitchen.Ops.Bff.Contracts/Responses/GetKitchenOpsBoardResponse.cs @@ -1,3 +1,9 @@ +using Kitchen.Ops.Bff.Contracts.Contracts; + namespace Kitchen.Ops.Bff.Contracts.Responses; -public sealed record GetKitchenOpsBoardResponse(string ContextId, string Summary); +public sealed record GetKitchenOpsBoardResponse( + string ContextId, + string Summary, + IReadOnlyCollection Lanes, + IReadOnlyCollection AvailableStations); diff --git a/src/Kitchen.Ops.Bff.Contracts/Responses/ReleaseKitchenWorkItemResponse.cs b/src/Kitchen.Ops.Bff.Contracts/Responses/ReleaseKitchenWorkItemResponse.cs new file mode 100644 index 0000000..e3ff820 --- /dev/null +++ b/src/Kitchen.Ops.Bff.Contracts/Responses/ReleaseKitchenWorkItemResponse.cs @@ -0,0 +1,8 @@ +namespace Kitchen.Ops.Bff.Contracts.Responses; + +public sealed record ReleaseKitchenWorkItemResponse( + string ContextId, + string WorkItemId, + bool Released, + string ReleasedBy, + string Message); diff --git a/src/Kitchen.Ops.Bff.Contracts/Responses/SetKitchenOrderPriorityResponse.cs b/src/Kitchen.Ops.Bff.Contracts/Responses/SetKitchenOrderPriorityResponse.cs index 6261dc4..7feed8c 100644 --- a/src/Kitchen.Ops.Bff.Contracts/Responses/SetKitchenOrderPriorityResponse.cs +++ b/src/Kitchen.Ops.Bff.Contracts/Responses/SetKitchenOrderPriorityResponse.cs @@ -2,6 +2,7 @@ namespace Kitchen.Ops.Bff.Contracts.Responses; public sealed record SetKitchenOrderPriorityResponse( string ContextId, - string OrderId, + string WorkItemId, bool Updated, + int Priority, string Summary); diff --git a/src/Kitchen.Ops.Bff.Contracts/Responses/TransitionKitchenWorkItemResponse.cs b/src/Kitchen.Ops.Bff.Contracts/Responses/TransitionKitchenWorkItemResponse.cs new file mode 100644 index 0000000..328c59f --- /dev/null +++ b/src/Kitchen.Ops.Bff.Contracts/Responses/TransitionKitchenWorkItemResponse.cs @@ -0,0 +1,9 @@ +namespace Kitchen.Ops.Bff.Contracts.Responses; + +public sealed record TransitionKitchenWorkItemResponse( + string OrderId, + string TicketId, + string PreviousState, + string CurrentState, + bool Transitioned, + string? Error); diff --git a/src/Kitchen.Ops.Bff.Rest/Program.cs b/src/Kitchen.Ops.Bff.Rest/Program.cs index 3859fec..0cff463 100644 --- a/src/Kitchen.Ops.Bff.Rest/Program.cs +++ b/src/Kitchen.Ops.Bff.Rest/Program.cs @@ -10,8 +10,16 @@ const string SessionAccessCookieName = "thalos_session"; const string SessionRefreshCookieName = "thalos_refresh"; var builder = WebApplication.CreateBuilder(args); -builder.Services.AddSingleton(); +builder.Services.AddHttpClient((serviceProvider, httpClient) => +{ + var configuration = serviceProvider.GetRequiredService(); + var kitchenBaseAddress = configuration["KitchenService:BaseAddress"] ?? "http://kitchen-service:8080"; + httpClient.BaseAddress = new Uri($"{kitchenBaseAddress.TrimEnd('/')}/"); +}); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHttpClient("ThalosAuth"); @@ -44,6 +52,57 @@ app.MapGet("/api/kitchen/ops/board", async ( return Results.Ok(await handler.HandleAsync(request, ct)); }); +app.MapPost("/api/kitchen/ops/work-items/claim", async ( + ClaimKitchenWorkItemRequest request, + HttpContext context, + IHttpClientFactory httpClientFactory, + IConfiguration configuration, + IClaimKitchenWorkItemHandler handler, + CancellationToken ct) => +{ + var authError = await EnforceSessionAsync(context, httpClientFactory, configuration, ct); + if (authError is not null) + { + return authError; + } + + return Results.Ok(await handler.HandleAsync(request, ct)); +}); + +app.MapPost("/api/kitchen/ops/work-items/release", async ( + ReleaseKitchenWorkItemRequest request, + HttpContext context, + IHttpClientFactory httpClientFactory, + IConfiguration configuration, + IReleaseKitchenWorkItemHandler handler, + CancellationToken ct) => +{ + var authError = await EnforceSessionAsync(context, httpClientFactory, configuration, ct); + if (authError is not null) + { + return authError; + } + + return Results.Ok(await handler.HandleAsync(request, ct)); +}); + +app.MapPost("/api/kitchen/ops/work-items/transition", async ( + TransitionKitchenWorkItemRequest request, + HttpContext context, + IHttpClientFactory httpClientFactory, + IConfiguration configuration, + ITransitionKitchenWorkItemHandler handler, + CancellationToken ct) => +{ + var authError = await EnforceSessionAsync(context, httpClientFactory, configuration, ct); + if (authError is not null) + { + return authError; + } + + return Results.Ok(await handler.HandleAsync(request, ct)); +}); + app.MapPost("/api/kitchen/ops/board/priority", async ( SetKitchenOrderPriorityRequest request, HttpContext context, diff --git a/tests/Kitchen.Ops.Bff.Application.UnitTests/Kitchen.Ops.Bff.Application.UnitTests.csproj b/tests/Kitchen.Ops.Bff.Application.UnitTests/Kitchen.Ops.Bff.Application.UnitTests.csproj new file mode 100644 index 0000000..9e4239b --- /dev/null +++ b/tests/Kitchen.Ops.Bff.Application.UnitTests/Kitchen.Ops.Bff.Application.UnitTests.csproj @@ -0,0 +1,19 @@ + + + net10.0 + enable + enable + false + + + + + + + + + + + + + diff --git a/tests/Kitchen.Ops.Bff.Application.UnitTests/KitchenWorkflowServiceClientTests.cs b/tests/Kitchen.Ops.Bff.Application.UnitTests/KitchenWorkflowServiceClientTests.cs new file mode 100644 index 0000000..d9a7eae --- /dev/null +++ b/tests/Kitchen.Ops.Bff.Application.UnitTests/KitchenWorkflowServiceClientTests.cs @@ -0,0 +1,149 @@ +using System.Net; +using System.Text; +using Kitchen.Ops.Bff.Application.Adapters; +using Kitchen.Ops.Bff.Contracts.Requests; + +namespace Kitchen.Ops.Bff.Application.UnitTests; + +public sealed class KitchenWorkflowServiceClientTests +{ + [Fact] + public async Task FetchBoardAsync_MapsBoardLanesAndStations() + { + var adapter = new KitchenWorkflowServiceClient(CreateClient(BoardPayload)); + + var response = await adapter.FetchBoardAsync(new GetKitchenOpsBoardRequest("demo-context"), CancellationToken.None); + + Assert.NotEmpty(response.Lanes); + Assert.NotEmpty(response.AvailableStations); + } + + [Fact] + public async Task ClaimWorkItemAsync_MapsClaimResponse() + { + var adapter = new KitchenWorkflowServiceClient(CreateClient(""" + { + "contextId": "demo-context", + "workItemId": "WK-1001", + "claimed": true, + "claimedBy": "chef-maya", + "message": "Work item WK-1001 claimed by chef-maya." + } + """)); + + var response = await adapter.ClaimWorkItemAsync( + new ClaimKitchenWorkItemRequest("demo-context", "WK-1001", "chef-maya"), + CancellationToken.None); + + Assert.True(response.Claimed); + Assert.Equal("chef-maya", response.ClaimedBy); + } + + [Fact] + public async Task ReleaseWorkItemAsync_ProjectsReleaseMessage() + { + var adapter = new KitchenWorkflowServiceClient(CreateClient(""" + { + "contextId": "demo-context", + "workItemId": "WK-1001", + "claimed": true, + "claimedBy": "chef-maya", + "message": "Work item WK-1001 claimed by chef-maya." + } + """)); + + var response = await adapter.ReleaseWorkItemAsync( + new ReleaseKitchenWorkItemRequest("demo-context", "WK-1001", "chef-maya"), + CancellationToken.None); + + Assert.True(response.Released); + Assert.Contains("release requested", response.Message); + } + + [Fact] + public async Task TransitionWorkItemAsync_MapsTransitionResponse() + { + var adapter = new KitchenWorkflowServiceClient(CreateClient(""" + { + "orderId": "CO-1001", + "ticketId": "KT-1001", + "previousState": "Queued", + "currentState": "Preparing", + "transitioned": true, + "error": null + } + """)); + + var response = await adapter.TransitionWorkItemAsync( + new TransitionKitchenWorkItemRequest("CO-1001", "KT-1001", "Preparing", "chef-maya"), + CancellationToken.None); + + Assert.True(response.Transitioned); + Assert.Equal("Preparing", response.CurrentState); + } + + [Fact] + public async Task SetOrderPriorityAsync_MapsPriorityResponse() + { + var adapter = new KitchenWorkflowServiceClient(CreateClient(""" + { + "contextId": "demo-context", + "workItemId": "WK-1001", + "priority": 5, + "message": "Priority for WK-1001 updated to 5 by chef-maya." + } + """)); + + var response = await adapter.SetOrderPriorityAsync( + new SetKitchenOrderPriorityRequest("demo-context", "WK-1001", 5, "chef-maya"), + CancellationToken.None); + + Assert.True(response.Updated); + Assert.Equal(5, response.Priority); + } + + private static HttpClient CreateClient(string json) + { + return new HttpClient(new StubHttpMessageHandler(json)) + { + BaseAddress = new Uri("http://kitchen-service:8080/") + }; + } + + private sealed class StubHttpMessageHandler(string json) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }); + } + } + + private const string BoardPayload = """ + { + "contextId": "demo-context", + "summary": "Kitchen board shows queued, preparing, and ready lanes for the current service context.", + "lanes": [ + { + "lane": "queued", + "items": [ + { + "workItemId": "WK-1001", + "orderId": "CO-1001", + "ticketId": "KT-1001", + "tableId": "T-08", + "station": "hot-line", + "state": "Queued", + "priority": 3, + "claimedBy": null, + "etaMinutes": 12 + } + ] + } + ], + "availableStations": [ "hot-line", "grill", "pickup" ] + } + """; +}