Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
bdd6900
fix: wire NominationFulfillmentSaga correctly and add nsb schema crea…
Mar 11, 2026
60be927
feat: add dedicated NServiceBus Fluent Migrator migration projects
Mar 12, 2026
015dfd3
update visual theme and Claude configuration
Mar 12, 2026
741c0bc
optimize Tilt startup
Mar 12, 2026
93ec9bf
feat: add Movements page with table, detail drawer, and confirm flow
Mar 12, 2026
d89a8b6
docs: add Accounting service & saga completion design spec
Mar 12, 2026
a63dd1a
docs: refine Accounting spec after review (interface placement, UoM, …
Mar 12, 2026
37299fd
docs: add Accounting service implementation plan
Mar 12, 2026
d9dc4e6
feat: update shared messages — add VariancePercent, NominationComplet…
Mar 12, 2026
7ed982a
feat: add Accounting TypeScript types and API client
Mar 12, 2026
955b70f
feat: add Accounting domain layer — BalancePeriod aggregate, status e…
Mar 12, 2026
08061e3
feat: add ScheduledDate and BalancePeriodId to saga data
Mar 12, 2026
c074036
test: add BalancePeriod domain tests — constructor, reconciliation, a…
Mar 12, 2026
f9055c0
feat: implement saga steps 3-6 — MovementConfirmed, reconciliation, a…
Mar 12, 2026
8f36e43
feat: add Accounting page with balance period table, detail drawer, v…
Mar 12, 2026
40ada33
feat: add Nomination entity state handlers — MarkScheduled, MarkInPro…
Mar 12, 2026
c1ac01b
feat: add Accounting application layer — queries, NServiceBus handler…
Mar 12, 2026
e680012
test: add saga tests for steps 3-6 — reconciliation, adjustment, comp…
Mar 12, 2026
de2ec20
feat: finalize Dashboard — accounting data, pipeline counts, stat car…
Mar 12, 2026
5b5be09
feat: add Accounting infrastructure layer — DbContext, repository, No…
Mar 12, 2026
5b94e0b
feat: add Accounting migrations — schema creation, BalancePeriods tab…
Mar 12, 2026
e962aef
test: add Accounting NServiceBus handler tests — ReconcileVolumes, In…
Mar 12, 2026
d2b6eaa
feat: add Accounting API layer — Program.cs, endpoints, Autofac modul…
Mar 12, 2026
bb4978f
feat: add K8S manifests for Accounting service and migrations
Mar 12, 2026
15a5178
test: add Nomination entity state handler tests — Scheduled, InProgre…
Mar 12, 2026
3e1f0f6
feat: wire Accounting into Tiltfile and nginx ingress
Mar 12, 2026
9274457
docs: refine script prompts with teammate names, add NSB saga docs link
Mar 12, 2026
b9e985f
fix: add AllTests Nuke target to run both unit and integration tests
Mar 12, 2026
98282d2
feat: add movement transition endpoints, localize datetimes, fix Stat…
Mar 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,3 +133,4 @@ microservice system using .NET 10, NServiceBus, and a React frontend.
- Integration tests: use TestContainers for SQL Server and RabbitMQ
- Fluent Migrator migrations: tested by applying to a TestContainers SQL Server instance
- Test project per SOR: `MidstreamHub.{SorName}.Tests`
- **Running tests**: Always use `nuke` (or `nuke AllTests`) to run the full test suite (unit + integration). Do NOT use `nuke Test` alone — that only runs unit tests. The default Nuke target is `AllTests`.
84 changes: 84 additions & 0 deletions .claude/agents/saga-scaffolder.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# Saga Scaffolder Agent

You are a subagent responsible for scaffolding a new NServiceBus saga for MidstreamHub.
Each saga orchestrates an eventual-consistency workflow across multiple services.

## Inputs
- Saga name (e.g., "NominationFulfillment")
- Owning service (e.g., "Nominations") — the service whose Application layer hosts the saga
- NServiceBus endpoint name (e.g., "MidstreamHub.Nominations")
- Correlation property name and C# type (e.g., "NominationId", Guid)
- Starting message (the event/command that initiates the saga via `IAmStartedByMessages<T>`)
- Subsequent messages the saga handles (list of `IHandleMessages<T>` types)
- Commands the saga sends and their target endpoints
- Saga data properties (business state tracked across the workflow)

## Steps

### 1. Create or verify message types
- Commands go in `src/Shared/MidstreamHub.Contracts.Messages/Commands/`
- Events go in `src/Shared/MidstreamHub.Contracts.Messages/Events/`
- Every message that the saga correlates on MUST have the correlation property (e.g., `NominationId`)
- After adding messages, remind the user to run `nuke pack`

### 2. Create saga data class
- File: `src/{Service}/MidstreamHub.{Service}.Application/Sagas/{SagaName}SagaData.cs`
- Inherit `ContainSagaData`
- Include the correlation property and all business state properties
- Namespace: `MidstreamHub.{Service}.Application.Sagas`

### 3. Create saga class
- File: `src/{Service}/MidstreamHub.{Service}.Application/Sagas/{SagaName}Saga.cs`
- Inherit `Saga<{SagaName}SagaData>`
- Implement `IAmStartedByMessages<TStartMessage>` for the initiating message
- Implement `IHandleMessages<T>` for all subsequent messages
- Inject `ILogger<{SagaName}Saga>` via constructor (private field `_logger`)
- Implement `ConfigureHowToFindSaga` mapping ALL message types to the correlation property
- In the start handler, populate saga data from the message and send the first command
- Call `MarkAsComplete()` in the final handler

### 4. Configure routing in the owning service's Program.cs
- Add `routing.RouteToEndpoint(typeof(CommandType), "MidstreamHub.{TargetService}");` for each command the saga sends
- Routing is configured in `src/{Service}/MidstreamHub.{Service}.Api/Program.cs`

### 5. Create saga persistence migration

For the NServiceBus MS SQL Server Scripts, the latest documentation is always available here: [**MS SQL Server Scripts**: Sagas](https://docs.particular.net/persistence/sql/sqlserver-scripts#build-time-saga)

- Add a NEW migration to `src/NServiceBus/MidstreamHub.NServiceBus.Migrations/Migrations/`
- Find the highest existing migration number (M0001, M0002...) and increment
- Table name: `[nsb].[{EndpointName_dots_to_underscores}_{SagaName}Saga]`
- Standard columns: Id, Metadata, Data, PersistenceVersion, SagaTypeVersion, Concurrency
- Correlation column: `Correlation_{PropertyName}` with appropriate SQL type
- Unique filtered index on the correlation column (WHERE ... IS NOT NULL)
- Use idempotent IF NOT EXISTS checks

#### Correlation property SQL type mapping:
| C# Type | SQL Type |
|---------|----------|
| Guid | uniqueidentifier |
| string | nvarchar(200) |
| int | int |
| long | bigint |

### 6. Create command handlers in target services
- For each command the saga sends, ensure a handler exists in the target service
- Handler file: `src/{TargetService}/MidstreamHub.{TargetService}.Application/Handlers/{HandlerName}.cs`
- Handler should implement `IHandleMessages<TCommand>`
- After processing, the handler should publish the corresponding completion event

### 7. Verify build
- Run `dotnet build src/MidstreamHub.sln` to verify everything compiles
- Check for StyleCop violations (using directive order, no `this.` prefix, `_` private fields)

## StyleCop Reminders
- Using directives go INSIDE namespace declarations
- Using directives must be in alphabetical order (SA1210)
- No `this.` prefix (SX1101)
- Private fields prefixed with `_` (SX1309)

## Reference Implementation
- Saga: `src/Nominations/MidstreamHub.Nominations.Application/Sagas/NominationFulfillmentSaga.cs`
- Saga data: `src/Nominations/MidstreamHub.Nominations.Application/Sagas/NominationFulfillmentSagaData.cs`
- Migration: `src/NServiceBus/MidstreamHub.NServiceBus.Migrations/Migrations/M0001_CreateNsbSchema.cs`
- Handler: `src/Movements/MidstreamHub.Movements.Application/Handlers/HandleScheduleMovementHandler.cs`
55 changes: 52 additions & 3 deletions .claude/skills/nservicebus-sagas.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,57 @@

## Saga Design Rules
- Saga data class must inherit `ContainSagaData`
- Saga data class lives alongside the saga in the Application layer: `src/{Service}/MidstreamHub.{Service}.Application/Sagas/`
- Map incoming messages to saga data using `ConfigureHowToFindSaga`
- Use a unique business identifier as the correlation property (e.g., NominationId)
- The first message type uses `IAmStartedByMessages<T>`, all others use `IHandleMessages<T>`
- Call `MarkAsComplete()` when the workflow is done

## Message Types
- **Commands**: sent to a specific endpoint, represent intent (e.g., `ScheduleMovement`)
- **Events**: published, represent something that happened (e.g., `MovementConfirmed`)
- Commands and events live in `MidstreamHub.Contracts` shared NuGet package
- **Commands**: sent to a specific endpoint via `context.Send()`, represent intent (e.g., `ScheduleMovement`)
- **Events**: published via `context.Publish()` or `_messageSession.Publish()`, represent something that happened (e.g., `MovementConfirmed`)
- Commands and events live in `MidstreamHub.Contracts.Messages.Commands` / `.Events` namespaces in the shared `MidstreamHub.Contracts.Messages` NuGet package
- After adding/changing messages, run `nuke pack` to update the local NuGet feed

## Routing
- Commands require explicit routing in the sending endpoint's `Program.cs`:
`routing.RouteToEndpoint(typeof(CommandType), "MidstreamHub.{TargetService}");`
- Events use pub/sub — no routing needed, subscribers get them automatically

## SQL Persistence & Schema
- All saga tables live in the `nsb` schema — this is its own domain
- Each saga instance is effectively a DDD aggregate root
- **DO NOT rely on `EnableInstallers()`** for table creation — use Fluent Migrator
- Saga tables are created via migrations in `src/NServiceBus/MidstreamHub.NServiceBus.Migrations/`
- When adding a new saga, add a new migration (M0002, M0003, etc.) to this project

### Table Naming Convention
`[nsb].[{EndpointName_dots_to_underscores}_{SagaClassName}]`
Example: `MidstreamHub.Nominations` endpoint + `NominationFulfillmentSaga` → `[nsb].[MidstreamHub_Nominations_NominationFulfillmentSaga]`

### Required Table Columns
```sql
CREATE TABLE [nsb].[{TableName}] (
Id uniqueidentifier NOT NULL PRIMARY KEY,
Metadata nvarchar(max) NOT NULL,
Data nvarchar(max) NOT NULL,
PersistenceVersion varchar(23) NOT NULL,
SagaTypeVersion varchar(23) NOT NULL,
Concurrency int NOT NULL,
Correlation_{PropertyName} {sql_type} -- matches saga data correlation property
);
CREATE UNIQUE INDEX IX_{TableName}_Correlation_{PropertyName}
ON [nsb].[{TableName}] (Correlation_{PropertyName})
WHERE Correlation_{PropertyName} IS NOT NULL;
```

### Correlation Property Type Mapping
| C# Type | SQL Type |
|---------|----------|
| Guid | uniqueidentifier |
| string | nvarchar(200) |
| int | int |
| long | bigint |

## Compensating Transactions
- When a saga detects a failure or threshold breach, send a compensating command
Expand All @@ -24,3 +67,9 @@
## Testing
- Use NServiceBus.Testing package for saga unit tests
- Assert that expected messages are sent/published at each saga step

## Reference Implementation
- Saga: `src/Nominations/MidstreamHub.Nominations.Application/Sagas/NominationFulfillmentSaga.cs`
- Saga data: `src/Nominations/MidstreamHub.Nominations.Application/Sagas/NominationFulfillmentSagaData.cs`
- Migration: `src/NServiceBus/MidstreamHub.NServiceBus.Migrations/Migrations/M0001_CreateNsbSchema.cs`
- NServiceBus SQL scripts docs: https://docs.particular.net/persistence/sql/sqlserver-scripts
1 change: 1 addition & 0 deletions .claude/skills/tdd-nunit.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
## Test Categories
- `[Category("Unit")]` for pure logic tests
- `[Category("Integration")]` for tests requiring TestContainers
- **Always run `nuke` (default target: `AllTests`) to execute both categories**

## TestContainers Usage
- SQL Server: `new MsSqlContainer("mcr.microsoft.com/mssql/server:2022-latest")`
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ jobs:
run: ./build.sh Compile

- name: Test
run: ./build.sh Test --skip Compile
run: ./build.sh AllTests --skip Compile

- name: Frontend install
working-directory: src/Web/midstreamhub-ui
Expand Down
1 change: 1 addition & 0 deletions .nuke/build.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"ExecutableTarget": {
"type": "string",
"enum": [
"AllTests",
"Clean",
"Compile",
"DockerBuild",
Expand Down
48 changes: 39 additions & 9 deletions Tiltfile
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
# ============================================================================

# Services that have been implemented (add to this list as services are built)
ACTIVE_SERVICES = ["contracts", "nominations", "movements"]
ACTIVE_SERVICES = ["contracts", "nominations", "movements", "accounting"]

# Service port mappings
SERVICE_PORTS = {
Expand Down Expand Up @@ -46,15 +46,14 @@ k8s_resource(
k8s_resource(
"rabbitmq",
port_forwards=["5672:5672", "15672:15672"],
resource_deps=["sqlserver"],
labels=["infrastructure"],
)

# Nginx reverse proxy (unified API gateway)
k8s_resource(
"nginx-ingress",
port_forwards=["8080:8080"],
resource_deps=["contracts-api", "nominations-api", "movements-api"],
resource_deps=["contracts-api", "nominations-api", "movements-api", "accounting-api"],
labels=["infrastructure"],
)

Expand Down Expand Up @@ -93,6 +92,33 @@ for svc in ACTIVE_SERVICES:
labels=["migrations"],
)

# NServiceBus persistence migrations (cross-cutting, no API service)
docker_build(
"midstreamhub/nservicebus-migrations",
context=".",
dockerfile="Dockerfile.migrations",
build_args={"SERVICE_NAME": "NServiceBus"},
only=[
"docker/certs",
"src/MidstreamHub.sln",
"src/Directory.Build.props",
"src/Directory.Packages.props",
"src/StyleCopAnalyzers.ruleset",
"src/stylecop.json",
"src/nuget.config",
"local-nuget",
"src/Shared",
"src/NServiceBus/MidstreamHub.NServiceBus.Migrations",
"src/NServiceBus/MidstreamHub.NServiceBus.Migrations.Runner",
],
)
k8s_yaml("k8s/migrations/nservicebus-migrations.yaml")
k8s_resource(
"nservicebus-migrations",
resource_deps=["sqlserver"],
labels=["migrations"],
)

# ============================================================================
# .NET API Services
# ============================================================================
Expand Down Expand Up @@ -130,7 +156,7 @@ for svc in ACTIVE_SERVICES:
k8s_resource(
"%s-api" % svc,
port_forwards=["%d:8080" % port],
resource_deps=["%s-migrations" % svc, "rabbitmq"],
resource_deps=["%s-migrations" % svc, "nservicebus-migrations", "rabbitmq"],
labels=["services"],
)

Expand Down Expand Up @@ -160,6 +186,7 @@ k8s_yaml("k8s/services/web-ui.yaml")
k8s_resource(
"web-ui",
port_forwards=["3000:3000"],
resource_deps=["nginx-ingress"],
labels=["frontend"],
)

Expand All @@ -179,11 +206,14 @@ local_resource(
# Button: Re-run all migrations
local_resource(
"rerun-migrations",
cmd=" && ".join([
"kubectl delete job %s-migrations -n midstreamhub --ignore-not-found" % svc +
" && kubectl apply -f k8s/migrations/%s-migrations.yaml" % svc
for svc in ACTIVE_SERVICES
]),
cmd=" && ".join(
["kubectl delete job nservicebus-migrations -n midstreamhub --ignore-not-found && kubectl apply -f k8s/migrations/nservicebus-migrations.yaml"] +
[
"kubectl delete job %s-migrations -n midstreamhub --ignore-not-found" % svc +
" && kubectl apply -f k8s/migrations/%s-migrations.yaml" % svc
for svc in ACTIVE_SERVICES
]
),
auto_init=False,
trigger_mode=TRIGGER_MODE_MANUAL,
resource_deps=["sqlserver"],
Expand Down
6 changes: 5 additions & 1 deletion build/Build.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ class Build : NukeBuild
/// - Microsoft VisualStudio https://nuke.build/visualstudio
/// - Microsoft VSCode https://nuke.build/vscode

public static int Main() => Execute<Build>(x => x.Test);
public static int Main() => Execute<Build>(x => x.AllTests);

[Parameter("Configuration to build - Default is 'Debug' (local) or 'Release' (server)")]
readonly Configuration Configuration = IsLocalBuild ? Configuration.Debug : Configuration.Release;
Expand Down Expand Up @@ -94,6 +94,10 @@ class Build : NukeBuild
.SetFilter("Category=Integration"));
});

Target AllTests => _ => _
.Description("Run all tests (unit + integration)")
.DependsOn(Test, IntegrationTest);

Target Pack => _ => _
.Description("Pack shared NuGet packages to local-nuget/")
.DependsOn(Compile)
Expand Down
Loading