FnB Engine Service - Documentation
Auto-generated from source code audit on 2026-03-13
Overview
Service Name: fnb-engine-net (FnbEngine)
Port: 5019 (Development)
Database: fnb_engine (PostgreSQL via Neon)
SDK: .NET 10.0 (C# 14)
Architecture: Clean Architecture + CQRS (MediatR 12.4.1)
The FnB Engine is the core Food & Beverage operations microservice. It manages:
- Table Management -- CRUD, status tracking, QR code ordering, floor plan positioning
- Session Management -- Dining sessions (open/close) linked to tables
- Kitchen Display System (KDS) -- Kitchen tickets with station routing and status workflow
- Barista Queue -- Drink preparation queue for cafe/bar operations with priority, assignment, and stats
- Recipe Management -- Product recipes with ingredients, COGS tracking, and inventory item linking
- Reservation System -- Table reservations with guest info, status management
- Inventory Integration -- Auto-deduction of inventory when kitchen tickets are served (via inventory-service HTTP client)
- Multi-Tenant Isolation -- Row-level security via EF Core global query filters + PostgreSQL session variables
API Endpoints
Tables (/api/v1/tables)
| Method |
Path |
Description |
Request |
Response |
| GET |
/api/v1/tables?shopId={id} |
Get tables by shop |
Query: shopId (Guid) |
ApiResponse<IEnumerable<TableDto>> |
| POST |
/api/v1/tables |
Create table |
Body: CreateTableRequest |
ApiResponse<CreateTableResult> (201) |
| PUT |
/api/v1/tables/{id} |
Update table |
Body: UpdateTableRequest |
ApiResponse<bool> |
| PATCH |
/api/v1/tables/{id}/status |
Change table status |
Body: ChangeStatusRequest |
ApiResponse<bool> |
| POST |
/api/v1/tables/{id}/generate-qr |
Generate QR token |
- |
ApiResponse<{qrToken}> |
| GET |
/api/v1/tables/by-token/{token} |
Get table by QR token (public) |
Path: token |
ApiResponse<{Id,ShopId,TableNumber,Capacity,Zone}> |
Sessions (/api/v1/sessions)
| Method |
Path |
Description |
Request |
Response |
| POST |
/api/v1/sessions |
Open session |
Body: OpenSessionRequest |
ApiResponse<OpenSessionResult> (201) |
| GET |
/api/v1/sessions/{id} |
Get session by ID |
Path: id |
ApiResponse<SessionDto> |
| POST |
/api/v1/sessions/{id}/close |
Close session |
Path: id |
ApiResponse<bool> |
Kitchen (/api/v1/kitchen)
| Method |
Path |
Description |
Request |
Response |
| GET |
/api/v1/kitchen/tickets?shopId={id}&status={s} |
Get tickets by shop+status |
Query: shopId, status |
ApiResponse<IEnumerable<KitchenTicketDto>> |
| GET |
/api/v1/kitchen/tickets?station={s} |
Get pending tickets by station |
Query: station |
ApiResponse<IEnumerable<KitchenTicketDto>> |
| POST |
/api/v1/kitchen/tickets |
Create kitchen ticket |
Body: CreateTicketRequest |
CreateTicketResponse |
| PATCH |
/api/v1/kitchen/tickets/{id}/status |
Update ticket status |
Body: UpdateStatusRequest |
ApiResponse<bool> |
| GET |
/api/v1/kitchen/recipes?shopId={id} |
Get recipes by shop |
Query: shopId |
ApiResponse<IEnumerable<RecipeDto>> |
| GET |
/api/v1/kitchen/recipes/by-product?productId={id}&shopId={id} |
Get recipe by product |
Query: productId, shopId |
ApiResponse<RecipeDto> |
| POST |
/api/v1/kitchen/recipes |
Create recipe |
Body: CreateRecipeCommand |
ApiResponse<Guid> |
| PUT |
/api/v1/kitchen/recipes/{id} |
Update recipe |
Body: UpdateRecipeCommand |
ApiResponse<bool> |
| DELETE |
/api/v1/kitchen/recipes/{id} |
Delete (soft) recipe |
Path: id |
ApiResponse<bool> |
Barista (/api/v1/fnb/barista)
| Method |
Path |
Description |
Request |
Response |
| GET |
/api/v1/fnb/barista/queue?shopId={id} |
Get active barista queue |
Query: shopId |
ApiResponse<IEnumerable<BaristaQueueItemDto>> |
| GET |
/api/v1/fnb/barista/stats?shopId={id} |
Get queue statistics |
Query: shopId |
ApiResponse<QueueStatsDto> |
| POST |
/api/v1/fnb/barista/queue |
Queue a drink |
Body: QueueDrinkCommand |
ApiResponse<QueueDrinkResult> |
| PUT |
/api/v1/fnb/barista/queue/{id}/start |
Start preparing drink |
Body: StartPreparingRequest |
ApiResponse<bool> |
| PUT |
/api/v1/fnb/barista/queue/{id}/ready |
Mark drink ready |
- |
ApiResponse<bool> |
| PUT |
/api/v1/fnb/barista/queue/{id}/delivered |
Mark drink delivered |
- |
ApiResponse<bool> |
| DELETE |
/api/v1/fnb/barista/queue/{id} |
Cancel queue item |
Path: id |
ApiResponse<bool> |
Reservations (/api/v1/reservations)
| Method |
Path |
Description |
Request |
Response |
| GET |
/api/v1/reservations?shopId={id}&date={d} |
Get reservations |
Query: shopId, date? |
ApiResponse<IEnumerable<ReservationDto>> |
| POST |
/api/v1/reservations |
Create reservation |
Body: CreateReservationRequest |
ApiResponse<CreateReservationResult> (201) |
| PATCH |
/api/v1/reservations/{id}/status |
Update reservation status |
Body: UpdateReservationStatusRequest |
ApiResponse<bool> |
Health Checks
| Method |
Path |
Description |
| GET |
/health |
Full health check (PostgreSQL) |
| GET |
/health/live |
Liveness probe |
| GET |
/health/ready |
Readiness probe |
Commands
CreateTableCommand
- Input:
ShopId (Guid), TableNumber (string), Capacity (int), Zone? (string), HourlyRate? (decimal)
- Logic: Checks duplicate table number per shop, creates Table entity, optionally sets hourly rate
- Validator:
CreateTableValidator -- ShopId required, TableNumber required (max 20), Capacity > 0, Zone max 100
UpdateTableCommand
- Input:
TableId (Guid), Capacity? (int), Zone? (string), PositionX? (int), PositionY? (int), HourlyRate? (decimal)
- Logic: Loads table, sets position and/or hourly rate
- Validator:
UpdateTableCommandValidator -- TableId required, Capacity > 0, Zone max 100, HourlyRate >= 0
ChangeTableStatusCommand
- Input:
TableId (Guid), Status (string: "Available"/"Occupied"/"Cleaning")
- Logic: Loads table, calls domain methods (MarkAsAvailable/MarkAsOccupied/MarkAsCleaning)
- Validator:
ChangeTableStatusCommandValidator -- Valid statuses: Available, Occupied, Cleaning, Reserved, OutOfService
OpenSessionCommand
- Input:
TableId (Guid), ShopId (Guid), GuestCount (int, default 1)
- Logic: Validates table exists, checks no active session on table, creates Session, marks table as Occupied
- Validator:
OpenSessionCommandValidator -- TableId/ShopId required, GuestCount 1-100
CloseSessionCommand
- Input:
SessionId (Guid)
- Logic: Loads session, calls
session.Close(), marks table as Available
- Validator:
CloseSessionCommandValidator -- SessionId required
CreateKitchenTicketCommand
- Input:
SessionId (Guid), OrderItemId (Guid), ItemName (string), Station? (string), Priority (int, default 0), ProductId? (Guid), Quantity (int, default 1)
- Logic: Creates KitchenTicket entity with product ID for recipe lookup
- Validator:
CreateKitchenTicketCommandValidator -- SessionId/OrderItemId/ItemName required, Priority 0-10, Quantity > 0
UpdateTicketStatusCommand
- Input:
TicketId (Guid), Status (string: "InProgress"/"Ready"/"Served")
- Logic: Loads ticket, calls domain methods. When "Served", raises KitchenTicketServedDomainEvent for inventory deduction
- Validator:
UpdateTicketStatusCommandValidator -- Valid statuses: Pending, InProgress, Ready, Served, Cancelled
QueueDrinkCommand
- Input:
ShopId (Guid), OrderId (Guid), OrderItemId (Guid), DrinkName (string), Customizations? (string), Priority (int, default 0), EstimatedMinutes (int, default 5)
- Logic: Creates BaristaQueueItem, raises DrinkQueuedDomainEvent
- Validator:
QueueDrinkCommandValidator -- All IDs required, DrinkName required (max 200), Customizations max 2000, Priority 0-10, EstimatedMinutes 1-60
StartPreparingCommand
- Input:
QueueItemId (Guid), BaristaName (string)
- Logic: Loads queue item, calls
StartPreparing(baristaName) (status Queued -> Preparing)
- Validator:
StartPreparingCommandValidator -- QueueItemId required, BaristaName required (max 100)
MarkDrinkReadyCommand
- Input:
QueueItemId (Guid)
- Logic: Loads queue item, calls
MarkReady() (Preparing -> Ready), raises DrinkReadyDomainEvent
- Validator:
MarkDrinkReadyCommandValidator -- QueueItemId required
MarkDrinkDeliveredCommand
- Input:
QueueItemId (Guid)
- Logic: Loads queue item, calls
MarkDelivered() (Ready -> Delivered)
- Validator:
MarkDrinkDeliveredCommandValidator -- QueueItemId required
CancelQueueItemCommand
- Input:
QueueItemId (Guid)
- Logic: Loads queue item, calls
Cancel() (any status except Delivered/Cancelled -> Cancelled)
- Validator:
CancelQueueItemCommandValidator -- QueueItemId required
CreateRecipeCommand
- Input:
ShopId (Guid), ProductId (Guid), Name (string), Instructions? (string), PrepTimeMinutes (int), Ingredients? (List of IngredientItem)
- Logic: Creates Recipe entity, adds ingredients with optional inventory item links
- Validator: None (no dedicated validator)
UpdateRecipeCommand
- Input:
RecipeId (Guid), ShopId (Guid), ProductId (Guid), Name (string), Instructions? (string), PrepTimeMinutes (int), Ingredients? (List of IngredientItem)
- Logic: Loads recipe, updates fields, clears and re-adds ingredients
- Validator: None
DeleteRecipeCommand
- Input:
RecipeId (Guid)
- Logic: Loads recipe, calls
Deactivate() (soft delete via IsActive=false)
- Validator: None
Queries
GetTablesQuery
- Input:
ShopId (Guid)
- Logic: Gets all tables for shop, maps StatusId to status name via Enumeration pattern
- Output:
IEnumerable<TableDto> (Id, ShopId, TableNumber, Capacity, Zone, Status, HourlyRate, PositionX, PositionY, QrToken)
GetSessionQuery
- Input:
SessionId (Guid)
- Logic: Gets session by ID, returns null if not found
- Output:
SessionDto? (Id, TableId, ShopId, GuestCount, StartedAt, ClosedAt, Status)
GetPendingTicketsQuery
- Input:
Station? (string)
- Logic: Gets tickets with status Pending or InProgress, optionally filtered by station, sorted by priority desc then createdAt asc
- Output:
IEnumerable<KitchenTicketDto> (Id, SessionId, OrderItemId, ItemName, Station, Priority, Status, CreatedAt)
GetTicketsByShopQuery
- Input:
ShopId (Guid), Status? (string)
- Logic: Finds sessions by shop, then gets tickets by session IDs with optional status filter
- Output:
IEnumerable<KitchenTicketDto>
GetBaristaQueueQuery
- Input:
ShopId (Guid)
- Logic: Gets active queue items (Queued/Preparing/Ready) for shop, sorted by priority desc then createdAt asc
- Output:
IEnumerable<BaristaQueueItemDto> (Id, ShopId, OrderId, OrderItemId, DrinkName, Customizations, Priority, StatusId, StatusName, AssignedTo, EstimatedMinutes, CreatedAt, StartedAt, CompletedAt)
GetQueueStatsQuery
- Input:
ShopId (Guid)
- Logic: Aggregates counts by status and calculates average prep time from completed items
- Output:
QueueStatsDto (TotalQueued, TotalPreparing, TotalReady, TotalDelivered, TotalCancelled, AveragePrepTimeMinutes)
GetReservationsQuery
- Input:
ShopId (Guid), Date? (DateTime)
- Logic: Gets reservations by shop, optionally filtered to a single day, sorted by reservation time
- Output:
IEnumerable<ReservationDto> (Id, ShopId, TableId, GuestName, Phone, PartySize, ReservationTime, Status, Note, CreatedAt)
GetRecipesByShopQuery
- Input:
ShopId (Guid)
- Logic: Gets active recipes for shop with ingredients, ordered by name
- Output:
IEnumerable<RecipeDto> (Id, ProductId, ShopId, Name, Instructions, PrepTimeMinutes, IsActive, CreatedAt, Ingredients[])
GetRecipeByProductQuery
- Input:
ProductId (Guid), ShopId (Guid)
- Logic: Gets active recipe by product ID and shop ID (for inventory deduction lookup)
- Output:
RecipeDto?
Domain Model
Table (Aggregate Root)
- Fields:
_shopId, _tableNumber, _capacity, _zone, _status (TableStatus), StatusId, _positionX, _positionY, _qrToken, _hourlyRate, _createdAt, _updatedAt
- Methods:
MarkAsOccupied() -- Only from Available/Reserved
MarkAsAvailable() -- From any status
MarkAsCleaning() -- From any status
SetHourlyRate(decimal rate)
SetPosition(int x, int y)
GenerateQrToken() -- Returns 16-char hex token
ClearQrToken()
- Validation: ShopId not empty, TableNumber not blank, Capacity > 0
- Events: None
TableStatus (Enumeration)
- 1 = Available, 2 = Occupied, 3 = Reserved, 4 = Cleaning
Session (Aggregate Root)
- Fields:
_tableId, _shopId, _guestCount, _startedAt, _closedAt, _status
- Methods:
Close() -- Sets status to "Closed", records ClosedAt. Throws if already closed.
- Status Values: "Active", "Closed"
- Validation: TableId/ShopId not empty, GuestCount > 0
- Events: None
KitchenTicket (Aggregate Root)
- Fields:
_sessionId, _orderItemId, _productId, _itemName, _station, _priority, _quantity, _status, _createdAt, _completedAt
- Methods:
MarkAsInProgress() -- Sets status to "InProgress"
MarkAsReady() -- Sets status to "Ready", records CompletedAt
MarkAsServed() -- Sets status to "Served", raises KitchenTicketServedDomainEvent
- Status Values: "Pending" -> "InProgress" -> "Ready" -> "Served"
- Events:
KitchenTicketServedDomainEvent (on Served)
BaristaQueueItem (Aggregate Root)
- Fields:
_shopId, _orderId, _orderItemId, _drinkName, _customizations (jsonb), _priority, _statusId, _assignedTo, _estimatedMinutes, _createdAt, _startedAt, _completedAt
- Methods:
StartPreparing(string baristaName) -- Queued(1) -> Preparing(2), assigns barista
MarkReady() -- Preparing(2) -> Ready(3), raises DrinkReadyDomainEvent
MarkDelivered() -- Ready(3) -> Delivered(4)
Cancel() -- Any except Delivered/Cancelled -> Cancelled(5)
- Status IDs: 1=Queued, 2=Preparing, 3=Ready, 4=Delivered, 5=Cancelled
- Events:
DrinkQueuedDomainEvent (on creation), DrinkReadyDomainEvent (on ready)
Recipe (Aggregate Root)
- Fields:
_shopId, _productId, _name, _instructions, _prepTimeMinutes, _isActive, _createdAt, _updatedAt, _ingredients (List)
- Methods:
Update(productId, name, instructions, prepTimeMinutes)
AddIngredient(ingredientName, quantity, unit, costPerUnit, inventoryItemId?, quantityPerServing?)
ClearIngredients()
Deactivate() -- Soft delete
- Events: None
RecipeIngredient (Entity, child of Recipe)
- Fields:
_recipeId, _ingredientName, _quantity, _unit, _costPerUnit, _inventoryItemId (optional link to inventory-service), _quantityPerServing (for COGS)
Reservation (Aggregate Root)
- Fields:
_shopId, _tableId (optional), _guestName, _phone, _partySize, _reservationTime, _status, _note, _createdAt
- Methods:
Confirm(), Seat(), Cancel(), NoShow()
AssignTable(Guid tableId)
UpdateStatus(string status) -- Validates against allowed statuses
- Status Values: "pending", "confirmed", "seated", "cancelled", "no_show"
- Validation: ShopId not empty, GuestName not blank, PartySize > 0
Domain Events
| Event |
Trigger |
Handler |
DrinkQueuedDomainEvent |
BaristaQueueItem constructor |
No handler (available for SignalR notifications) |
DrinkReadyDomainEvent |
BaristaQueueItem.MarkReady() |
No handler (available for SignalR notifications) |
KitchenTicketServedDomainEvent |
KitchenTicket.MarkAsServed() |
KitchenTicketServedDomainEventHandler -- Looks up recipe, calls inventory-service for auto-deduction |
Integration Events
| Event |
Description |
KitchenTicketServedIntegrationEvent |
Defined but not currently published (available for RabbitMQ) |
Database Schema
Table: tables
| Column |
Type |
Constraints |
id |
uuid |
PK |
shop_id |
uuid |
NOT NULL, indexed |
table_number |
varchar(20) |
NOT NULL |
capacity |
int |
NOT NULL |
zone |
varchar(100) |
nullable |
status_id |
int |
NOT NULL |
position_x |
int |
nullable |
position_y |
int |
nullable |
qr_token |
varchar(64) |
nullable, unique (filtered) |
hourly_rate |
decimal(18,2) |
default 0 |
created_at |
timestamp |
NOT NULL |
updated_at |
timestamp |
nullable |
Indexes:
ix_tables_shop_id on (shop_id)
ix_tables_shop_table_number on (shop_id, table_number) UNIQUE
ix_tables_qr_token on (qr_token) UNIQUE WHERE qr_token IS NOT NULL
Table: table_statuses (seed data)
| Column |
Type |
Constraints |
id |
int |
PK (no auto-increment) |
name |
varchar(50) |
NOT NULL |
Seed Data: 1=Available, 2=Occupied, 3=Reserved, 4=Cleaning
Table: sessions
| Column |
Type |
Constraints |
id |
uuid |
PK |
table_id |
uuid |
NOT NULL |
shop_id |
uuid |
NOT NULL |
guest_count |
int |
NOT NULL |
started_at |
timestamp |
NOT NULL |
closed_at |
timestamp |
nullable |
status |
varchar(50) |
NOT NULL |
Indexes:
ix_sessions_table_status on (table_id, status)
ix_sessions_shop on (shop_id)
Table: kitchen_tickets
| Column |
Type |
Constraints |
id |
uuid |
PK |
session_id |
uuid |
NOT NULL |
order_item_id |
uuid |
NOT NULL |
product_id |
uuid |
NOT NULL |
item_name |
varchar(200) |
NOT NULL |
station |
varchar(100) |
nullable |
priority |
int |
NOT NULL |
quantity |
int |
NOT NULL, default 1 |
status |
varchar(50) |
NOT NULL |
created_at |
timestamp |
NOT NULL |
completed_at |
timestamp |
nullable |
Indexes:
ix_kitchen_tickets_session on (session_id)
ix_kitchen_tickets_station_status_priority on (station, status, priority)
Table: barista_queue_items
| Column |
Type |
Constraints |
id |
uuid |
PK |
shop_id |
uuid |
NOT NULL |
order_id |
uuid |
NOT NULL |
order_item_id |
uuid |
NOT NULL |
drink_name |
varchar(200) |
NOT NULL |
customizations |
jsonb |
nullable |
priority |
int |
NOT NULL, default 0 |
status_id |
int |
NOT NULL, default 1 |
assigned_to |
varchar(100) |
nullable |
estimated_minutes |
int |
NOT NULL, default 5 |
created_at |
timestamp |
NOT NULL |
started_at |
timestamp |
nullable |
completed_at |
timestamp |
nullable |
Indexes:
ix_barista_queue_shop_status_priority on (shop_id, status_id, priority)
ix_barista_queue_order_id on (order_id)
ix_barista_queue_created_at on (created_at)
Table: recipes
| Column |
Type |
Constraints |
id |
uuid |
PK (no auto-increment) |
shop_id |
uuid |
NOT NULL |
product_id |
uuid |
NOT NULL |
name |
varchar(255) |
NOT NULL |
instructions |
varchar(2000) |
nullable |
prep_time_minutes |
int |
|
is_active |
bool |
default true |
created_at |
timestamp |
NOT NULL |
updated_at |
timestamp |
nullable |
Indexes:
ix_recipes_shop_id on (shop_id)
ix_recipes_product_id on (product_id)
Table: recipe_ingredients
| Column |
Type |
Constraints |
id |
uuid |
PK (no auto-increment) |
recipe_id |
uuid |
NOT NULL, FK -> recipes (cascade delete) |
ingredient_name |
varchar(200) |
NOT NULL |
quantity |
decimal(18,4) |
NOT NULL |
unit |
varchar(50) |
NOT NULL |
cost_per_unit |
decimal(18,2) |
|
inventory_item_id |
uuid |
nullable (link to inventory-service) |
quantity_per_serving |
decimal(18,4) |
|
Table: reservations
| Column |
Type |
Constraints |
id |
uuid |
PK |
shop_id |
uuid |
NOT NULL |
table_id |
uuid |
nullable |
guest_name |
varchar(200) |
NOT NULL |
phone |
varchar(20) |
nullable |
party_size |
int |
NOT NULL |
reservation_time |
timestamp |
NOT NULL |
status |
varchar(20) |
NOT NULL |
note |
varchar(500) |
nullable |
created_at |
timestamp |
NOT NULL |
Indexes:
ix_reservations_shop_id on (shop_id)
ix_reservations_shop_time on (shop_id, reservation_time)
Tenant Global Query Filters
EF Core global query filters applied on: Table (shop_id), Session (shop_id), Reservation (shop_id), BaristaQueueItem (shop_id).
Bypassed for service-to-service calls and admin users.
Migrations
| Migration |
Date |
Description |
20260117194222_InitialCreate |
2026-01-17 |
Initial schema (tables, sessions, kitchen_tickets, table_statuses) |
20260304221850_AddReservations |
2026-03-04 |
Add reservations table |
20260305003518_AddTablePositionFields |
2026-03-05 |
Add position_x, position_y to tables |
20260305011822_AddQrTokenToTable |
2026-03-05 |
Add qr_token to tables |
20260305095701_AddInventoryLinkToRecipeIngredient |
2026-03-05 |
Add inventory_item_id to recipe_ingredients |
20260305100324_AddInventoryItemIdToRecipeIngredient |
2026-03-05 |
Refine inventory item ID column |
20260306175525_PhaseTwo |
2026-03-06 |
Barista queue, recipe enhancements, quantity on tickets |
Dependencies
NuGet Packages (API Layer)
- MediatR 12.4.1
- FluentValidation 11.11.0
- FluentValidation.DependencyInjectionExtensions 11.11.0
- Microsoft.AspNetCore.Authentication.JwtBearer 10.0.1
- Swashbuckle.AspNetCore 7.2.0
- AspNetCore.HealthChecks.NpgSql 8.0.2
- AspNetCore.HealthChecks.Redis 8.0.1
- Hellang.Middleware.ProblemDetails 6.5.1
- Serilog.AspNetCore 8.0.3
- Serilog.Sinks.Console 6.0.0
- Serilog.Sinks.Seq 8.0.0
- Microsoft.EntityFrameworkCore.Design 10.0.0
NuGet Packages (Domain Layer)
NuGet Packages (Infrastructure Layer)
- Microsoft.EntityFrameworkCore 10.0.0
- Npgsql.EntityFrameworkCore.PostgreSQL 10.0.0
- Microsoft.EntityFrameworkCore.Tools 10.0.0
- MediatR 12.4.1
- Dapper 2.1.35
- Polly 8.5.0
- Microsoft.Extensions.Http.Polly 9.0.0
- StackExchange.Redis 2.8.16
External Service Calls
- inventory-service: HTTP client with Polly retry (3 retries, exponential backoff) + circuit breaker (5 failures / 30s).
Endpoint:
POST /api/v1/inventory/deduct. Called from KitchenTicketServedDomainEventHandler when a ticket is served.
Default URL: http://inventory-service:8080 (configurable via Services:InventoryService:BaseUrl or INVENTORY_SERVICE_URL).
Configuration
appsettings.json
Environment Variables
| Variable |
Purpose |
Default |
DATABASE_URL |
Fallback DB connection string |
- |
INVENTORY_SERVICE_URL |
Inventory service base URL |
http://inventory-service:8080 |
Jwt:Authority |
OIDC discovery authority |
http://localhost:5001 |
Middleware Pipeline
- Serilog Request Logging
- ProblemDetails (RFC 7807)
- Swagger (Development only)
- CORS (AllowAny)
- Routing
- Authentication (JWT Bearer via IAM IdentityServer OIDC)
- Authorization
- TenantMiddleware (sets PostgreSQL RLS session variables)
- Health Checks (/health, /health/live, /health/ready)
- Controllers
MediatR Pipeline Behaviors
LoggingBehavior -- Logs request name + elapsed time (Stopwatch)
ValidatorBehavior -- Runs FluentValidation validators, throws ValidationException on failure
TransactionBehavior -- Wraps Commands in DB transaction (skips Queries by name convention), uses ExecutionStrategy
Multi-Tenant Architecture
- API Layer:
ITenantProvider extracts shop_id/merchant_id from JWT claims or X-Shop-Id header
- Infrastructure Layer:
IFnbTenantProvider provides shop_id to DbContext global query filters
- Middleware:
TenantMiddleware sets PostgreSQL session variables (app.current_shop_id, app.current_merchant_id) for RLS policies
- Bypass: Service-to-service calls (header
X-Service-Call: internal) and admin/system roles bypass tenant filtering
Tests
Unit Tests (tests/FnbEngine.UnitTests/)
CreateTableCommandHandlerTests
ChangeTableStatusCommandHandlerTests
OpenSessionCommandHandlerTests
CloseSessionCommandHandlerTests
CreateKitchenTicketCommandHandlerTests
UpdateTicketStatusCommandHandlerTests
CreateReservationCommandHandlerTests
RecipeCommandHandlersTests
KitchenTicketServedDomainEventHandlerTests
TableAggregateTests
SessionTests
KitchenTicketTests
ReservationTests
RecipeTests
Functional Tests (tests/FnbEngine.FunctionalTests/)
TablesControllerTests (with CustomWebApplicationFactory)
Key Design Decisions
- Kitchen tickets are linked to sessions, not directly to shops. Shop-level filtering for tickets goes through sessions (indirect isolation).
- Recipes are NOT tenant-filtered at the DbContext level. They are filtered by shopId in repository queries since recipes could theoretically be shared.
- Inventory deduction is fire-and-forget. Errors in inventory-service calls are logged but do not block the kitchen workflow (non-throwing handler).
- Barista queue uses integer status IDs (1-5) rather than string status like kitchen tickets, for more efficient querying and indexing.
- QR tokens are 16-char hex strings generated from GUIDs, with a unique filtered index.
- Auto-migration on startup -- EF Core migrations are applied automatically when the service starts (with error suppression for PendingModelChangesWarning).