From 1605ae8b57e776038f0e59ae89c72171e1719f53 Mon Sep 17 00:00:00 2001 From: SadPossum Date: Wed, 12 Aug 2026 05:07:11 +0000 Subject: [PATCH 1/6] Add durable Reservations stay amendment convergence --- BunkFy.slnx | 1 + docs/README.md | 1 + ...vations-stay-amendment-convergence-task.md | 178 ++ .../ReservationsAdminOperationNames.cs | 4 + .../ReservationsAdminApiModule.cs | 177 +- ...ReservationStayAmendmentAdminCliCommand.cs | 406 +++ .../ReservationsAdminCliModule.cs | 1 + .../ReservationsModule.cs | 142 + .../Commands/AmendReservationStayCommand.cs | 17 + ...econcileReservationStayAmendmentCommand.cs | 12 + .../DependencyInjection.cs | 2 + .../AmendReservationStayCommandHandler.cs | 17 + .../ExternalReservationOperationHandlers.cs | 1 + ...GetReservationStayAmendmentQueryHandler.cs | 31 + ...ntoryAllocationAmendmentOutcomeHandlers.cs | 162 +- ...vationStayAmendmentRecoveryQueryHandler.cs | 32 + ...ssignReservationInventoryCommandHandler.cs | 149 +- ...eReservationStayAmendmentCommandHandler.cs | 108 + ...eservationManagementOperationRepository.cs | 15 +- ...rvationStayAmendmentOperationRepository.cs | 32 + ...vationStayAmendmentRecoveryCursorRecord.cs | 9 + ...ervationStayAmendmentRecoveryPageRecord.cs | 7 + .../GetReservationStayAmendmentQuery.cs | 10 + ...stReservationStayAmendmentRecoveryQuery.cs | 10 + .../ReservationsApplicationErrors.cs | 16 + ...rvationStayAmendmentCoordinationSupport.cs | 49 + .../ReservationStayAmendmentCoordinator.cs | 344 +++ .../ReservationStayAmendmentFingerprint.cs | 44 + .../ReservationStayAmendmentMappings.cs | 95 + .../AmendReservationStayCommandValidator.cs | 61 + ...tReservationStayAmendmentQueryValidator.cs | 26 + ...tionStayAmendmentRecoveryQueryValidator.cs | 35 + ...eservationStayAmendmentCommandValidator.cs | 39 + .../Api/ReservationStayAmendmentOutcome.cs | 10 + .../Api/ReservationStayAmendmentReceiptDto.cs | 22 + ...servationStayAmendmentRecoveryCursorDto.cs | 7 + ...ReservationStayAmendmentRecoveryItemDto.cs | 12 + ...ReservationStayAmendmentRecoveryPageDto.cs | 5 + .../Api/ReservationStayAmendmentTargetDto.cs | 8 + .../ReservationsTenantTerminationMetadata.cs | 10 +- ...Reservation.AllocationAmendmentRecovery.cs | 78 + .../Reservation.AllocationAmendments.cs | 30 +- .../Aggregates/Reservation.cs | 2 + .../Errors/ReservationsDomainErrors.cs | 6 + ...onStayAmendmentOperation.Reconciliation.cs | 51 + .../ReservationStayAmendmentOperation.cs | 367 +++ ...eservationStayAmendmentOperationOutcome.cs | 10 + ...vationStayAmendmentConvergence.Designer.cs | 2621 +++++++++++++++++ ..._AddReservationStayAmendmentConvergence.cs | 219 ++ ...vationStayAmendmentConvergence.Protocol.cs | 943 ++++++ .../ReservationsDbContextModelSnapshot.cs | 138 +- .../ReservationConfiguration.cs | 8 + ...rvationManagementOperationConfiguration.cs | 8 +- ...tionStayAmendmentOperationConfiguration.cs | 183 ++ .../DependencyInjection.cs | 3 + .../ReservationDataRightsExportContributor.cs | 94 +- .../ReservationDataRightsExportModels.cs | 27 + .../ReservationDataRightsExportSchema.cs | 14 +- .../Repositories/ReservationRepository.cs | 9 +- ...rvationStayAmendmentOperationRepository.cs | 184 ++ .../ReservationVisibilityQuery.cs | 18 + ...ntTerminationContributor.DestroyBatches.cs | 80 +- ...ntTerminationContributor.HistoryRecords.cs | 71 + ...onsTenantTerminationContributor.Records.cs | 1 + ...servationsTenantTerminationExportModels.cs | 44 + ...servationsTenantTerminationExportSchema.cs | 9 + .../ReservationDetailsHistoryEntry.cs | 3 +- .../ReservationExternalOperation.cs | 3 +- .../ReservationGuestProfileProjection.cs | 3 +- .../ReservationManagementOperation.cs | 3 +- .../ReservationsDbContext.cs | 17 +- src/Modules/Reservations/docs/README.md | 47 +- .../docs/personal-data-catalog.v1.json | 1897 +++++++++++- .../docs/personal-data-inventory.v1.md | 162 +- .../ReservationStayAmendmentSurfaceTests.cs | 330 +++ ...AmendReservationStayCommandHandlerTests.cs | 270 ++ ...ReservationInventoryCommandHandlerTests.cs | 102 +- .../ReservationMutationCoordinatorTests.cs | 14 +- ...tionStayAmendmentApplicationTestContext.cs | 283 ++ ...eservationStayAmendmentFingerprintTests.cs | 113 + ...rvationStayAmendmentOutcomeHandlerTests.cs | 465 +++ ...vationStayAmendmentRecoveryHandlerTests.cs | 150 + .../ReservationsPersonalDataCatalogTests.cs | 5 + ...rvationStayAmendmentLifecycleStateTests.cs | 167 ++ .../ReservationStayAmendmentOperationTests.cs | 253 ++ .../Domain/ReservationTests.cs | 18 +- ...tionDataRightsDiscoveryContributorTests.cs | 1 + ...rvationDataRightsExportContributorTests.cs | 307 +- ...eservationStayAmendmentPersistenceTests.cs | 635 ++++ .../Persistence/ReservationsModelTests.cs | 30 + ...ationsTenantTerminationContributorTests.cs | 277 +- .../ReservationDataRightsIntegrationTests.cs | 307 ++ ...StayAmendmentDispatcherIntegrationTests.cs | 561 ++++ ...nStayAmendmentMigrationIntegrationTests.cs | 2288 ++++++++++++++ ...agaIntegrationTests.LostOutcomeRecovery.cs | 697 +++++ .../ReservationsSagaIntegrationTests.cs | 613 +++- ...ationsTenantTerminationIntegrationTests.cs | 159 +- .../Support/AdminCliTestApplication.cs | 22 +- 98 files changed, 17460 insertions(+), 267 deletions(-) create mode 100644 docs/planning/reservations-stay-amendment-convergence-task.md create mode 100644 src/Modules/Reservations/BunkFy.Modules.Reservations.AdminCli/ReservationStayAmendmentAdminCliCommand.cs create mode 100644 src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Commands/AmendReservationStayCommand.cs create mode 100644 src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Commands/ReconcileReservationStayAmendmentCommand.cs create mode 100644 src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Handlers/AmendReservationStayCommandHandler.cs create mode 100644 src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Handlers/GetReservationStayAmendmentQueryHandler.cs create mode 100644 src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Handlers/ListReservationStayAmendmentRecoveryQueryHandler.cs create mode 100644 src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Handlers/ReconcileReservationStayAmendmentCommandHandler.cs create mode 100644 src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Ports/IReservationStayAmendmentOperationRepository.cs create mode 100644 src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Ports/ReservationStayAmendmentRecoveryCursorRecord.cs create mode 100644 src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Ports/ReservationStayAmendmentRecoveryPageRecord.cs create mode 100644 src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Queries/GetReservationStayAmendmentQuery.cs create mode 100644 src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Queries/ListReservationStayAmendmentRecoveryQuery.cs create mode 100644 src/Modules/Reservations/BunkFy.Modules.Reservations.Application/StayAmendments/ReservationStayAmendmentCoordinationSupport.cs create mode 100644 src/Modules/Reservations/BunkFy.Modules.Reservations.Application/StayAmendments/ReservationStayAmendmentCoordinator.cs create mode 100644 src/Modules/Reservations/BunkFy.Modules.Reservations.Application/StayAmendments/ReservationStayAmendmentFingerprint.cs create mode 100644 src/Modules/Reservations/BunkFy.Modules.Reservations.Application/StayAmendments/ReservationStayAmendmentMappings.cs create mode 100644 src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Validation/AmendReservationStayCommandValidator.cs create mode 100644 src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Validation/GetReservationStayAmendmentQueryValidator.cs create mode 100644 src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Validation/ListReservationStayAmendmentRecoveryQueryValidator.cs create mode 100644 src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Validation/ReconcileReservationStayAmendmentCommandValidator.cs create mode 100644 src/Modules/Reservations/BunkFy.Modules.Reservations.Contracts/Api/ReservationStayAmendmentOutcome.cs create mode 100644 src/Modules/Reservations/BunkFy.Modules.Reservations.Contracts/Api/ReservationStayAmendmentReceiptDto.cs create mode 100644 src/Modules/Reservations/BunkFy.Modules.Reservations.Contracts/Api/ReservationStayAmendmentRecoveryCursorDto.cs create mode 100644 src/Modules/Reservations/BunkFy.Modules.Reservations.Contracts/Api/ReservationStayAmendmentRecoveryItemDto.cs create mode 100644 src/Modules/Reservations/BunkFy.Modules.Reservations.Contracts/Api/ReservationStayAmendmentRecoveryPageDto.cs create mode 100644 src/Modules/Reservations/BunkFy.Modules.Reservations.Contracts/Api/ReservationStayAmendmentTargetDto.cs create mode 100644 src/Modules/Reservations/BunkFy.Modules.Reservations.Domain/Aggregates/Reservation.AllocationAmendmentRecovery.cs create mode 100644 src/Modules/Reservations/BunkFy.Modules.Reservations.Domain/StayAmendments/ReservationStayAmendmentOperation.Reconciliation.cs create mode 100644 src/Modules/Reservations/BunkFy.Modules.Reservations.Domain/StayAmendments/ReservationStayAmendmentOperation.cs create mode 100644 src/Modules/Reservations/BunkFy.Modules.Reservations.Domain/StayAmendments/ReservationStayAmendmentOperationOutcome.cs create mode 100644 src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence.PostgreSqlMigrations/Migrations/20260812011925_AddReservationStayAmendmentConvergence.Designer.cs create mode 100644 src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence.PostgreSqlMigrations/Migrations/20260812011925_AddReservationStayAmendmentConvergence.cs create mode 100644 src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence.PostgreSqlMigrations/Migrations/AddReservationStayAmendmentConvergence.Protocol.cs create mode 100644 src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/Configurations/ReservationStayAmendmentOperationConfiguration.cs create mode 100644 src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/Repositories/ReservationStayAmendmentOperationRepository.cs create mode 100644 src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/Repositories/ReservationVisibilityQuery.cs create mode 100644 src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Api/ReservationStayAmendmentSurfaceTests.cs create mode 100644 src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Application/AmendReservationStayCommandHandlerTests.cs create mode 100644 src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Application/ReservationStayAmendmentApplicationTestContext.cs create mode 100644 src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Application/ReservationStayAmendmentFingerprintTests.cs create mode 100644 src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Application/ReservationStayAmendmentOutcomeHandlerTests.cs create mode 100644 src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Application/ReservationStayAmendmentRecoveryHandlerTests.cs create mode 100644 src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Domain/ReservationStayAmendmentLifecycleStateTests.cs create mode 100644 src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Domain/ReservationStayAmendmentOperationTests.cs create mode 100644 src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Persistence/ReservationStayAmendmentPersistenceTests.cs create mode 100644 tests/Integration.Tests/Reservations/ReservationStayAmendmentDispatcherIntegrationTests.cs create mode 100644 tests/Integration.Tests/Reservations/ReservationStayAmendmentMigrationIntegrationTests.cs create mode 100644 tests/Integration.Tests/Reservations/ReservationsSagaIntegrationTests.LostOutcomeRecovery.cs diff --git a/BunkFy.slnx b/BunkFy.slnx index c6fb70c9..7d122b37 100644 --- a/BunkFy.slnx +++ b/BunkFy.slnx @@ -185,6 +185,7 @@ + diff --git a/docs/README.md b/docs/README.md index f931c3ae..1182deb0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -140,6 +140,7 @@ Useful entry points: - [Reservation Guest Record Convergence Task](planning/reservation-guest-record-convergence-task.md) - [Reservation Guest Record Resume Attribution Task](planning/reservation-guest-record-resume-attribution-task.md) - [Reservations Management Inventory Amendment Replay Task](planning/reservations-management-inventory-amendment-replay-task.md) +- [Reservations Stay Amendment Convergence Task](planning/reservations-stay-amendment-convergence-task.md) - [Reservations Data Rights Workflow Task](planning/reservations-data-rights-workflow-task.md) - [Reservation Record Retention Task](planning/reservation-record-retention-task.md) - [Executable Personal-Data Catalogue Task](planning/executable-personal-data-catalog-task.md) diff --git a/docs/planning/reservations-stay-amendment-convergence-task.md b/docs/planning/reservations-stay-amendment-convergence-task.md new file mode 100644 index 00000000..9276c65b --- /dev/null +++ b/docs/planning/reservations-stay-amendment-convergence-task.md @@ -0,0 +1,178 @@ +# Reservations Stay Amendment Convergence Task + +Status: active +Date: 2026-08-12 + +## Goal + +Let authorized staff change the complete desired pre-arrival stay of a confirmed +reservation: arrival, departure, expected arrival/departure times, and concrete +Inventory units. Keep the existing booking and allocation authoritative until +Inventory atomically confirms the replacement. Make the asynchronous outcome, +exact retry, operator status, and lost-delivery recovery truthful and durable. + +This slice belongs to Reservations. Inventory's existing amendment contract and +decision ledger already accept the desired dates and units and remain the sole +no-overbooking authority. No GMA change or generic process abstraction is +required. + +## Ownership + +- Reservations owns the desired stay, amendment operation coordinate, request + equivalence, pending candidate, operator receipt, outcome, and recovery + decision. +- The operator-facing operation identifier is reservation-local. Reservations + also owns a separate, system-generated globally unique Inventory request + coordinate because Inventory's durable decision ledger is global by request + identifier. That internal coordinate is never accepted from the caller. +- Inventory owns allocation validity, conflicts, atomic replacement, and exact + decision replay for an existing amendment coordinate. +- The current Reservation and current Inventory allocation remain authoritative + until a correlated confirmation commits. +- GMA owns the existing command transaction, operation lock, inbox/outbox, and + delivery substrate. It does not own hostel stay-amendment policy. + +## Supported Workflow + +The new desired-state command carries: + +- property and reservation identifiers; +- one stable amendment operation identifier; +- target arrival/departure dates using half-open `[arrival, departure)` + semantics; +- optional target expected arrival/departure times at minute precision; +- the complete target Inventory-unit set; +- expected Reservation details revision; +- authenticated actor provenance. + +Only a `Confirmed` reservation with an active allocation may start an +amendment. Date-only, unit-only, expected-time-only, and combined changes use +the same protocol. The existing unit-reassignment surface remains compatible. + +The slice deliberately excludes: + +- checked-in room moves; +- no-show, checkout, cancellation, or lifecycle reversal; +- guest/contact/notes changes; +- partial reservation or per-guest stays; +- group reservations, pooled capacity, rates, billing, and housekeeping. + +## Durable Outcome + +Each material staff amendment has a Reservations-owned operation record with a +privacy-minimal target and one of these outcomes: + +- `Pending`: Inventory has not yet produced a committed correlated outcome; +- `Applied`: Inventory confirmed and Reservations applied the target once; +- `Rejected`: Inventory rejected and Reservations retained the old booking; +- `OutcomeUnknown`: historical evidence cannot prove applied versus rejected. + +The record stores the target dates/times/unit identifiers, request-schema +version and fingerprint, expected details revision, operation version, +requested/completed timestamps, resulting Reservation revisions, rejection +code, and reconciliation audit coordinates. It stores no guest/contact data. + +Existing historical inventory-amendment journal entries are migrated honestly: +the currently pending candidate can be reconstructed as `Pending`; older +terminal entries become `OutcomeUnknown` rather than being guessed. + +## Idempotency And Concurrency + +1. Authentication, property scope, processing restrictions, and the Reservation + operation lock are evaluated before any replay is disclosed. +2. Reusing an operation identifier with different target data or expected + revision returns a stable conflict. +3. Exact retry returns the stored amendment receipt while pending and after + applied/rejected completion; it never returns an unrelated generic + Reservation receipt. +4. A material first request writes the pending Reservation candidate, generic + management coordinate, amendment operation, and outbox request atomically. +5. A desired state that is already current records an immediate `Applied` + operation, reserving the idempotency coordinate without contacting + Inventory. +6. Confirmation updates the Reservation, local Inventory projection, details + history, and amendment operation in one inbox transaction. +7. Rejection updates only the pending candidate and amendment operation; every + current stay field and allocation remains unchanged. +8. Cancellation, check-in, no-show, guest updates, and another amendment + serialize on the existing Reservation coordinate and fail closed while an + amendment is pending. + +## Recovery And Operator Usability + +- Authorized readers can fetch one amendment receipt by exact property, + reservation, and operation identifiers. +- A bounded, keyset-paged recovery query exposes `Pending` and + `OutcomeUnknown` operations without guest PII. +- A separately permissioned reconcile command accepts the observed operation + version. It works only for `Pending`, enforces a minimum retry interval, and + republishes the exact durable Reservation candidate under the same internal + Inventory request identifier while preserving the caller's local operation + identifier for status and replay. +- Inventory then replays its existing committed decision or evaluates the + still-new request exactly once. Reconcile never creates a different + amendment and never mutates a terminal operation. +- Receipts expose whether recovery is currently eligible and the next eligible + timestamp. Operators are not told that asynchronous work is complete while + it is pending. + +## Persistence + +Add a Reservations-owned stay-amendment operation table keyed by tenant, +reservation, and operation identifier, with a one-to-one relationship to the +existing management journal. Add: + +- exact target/outcome/timestamp/revision check constraints; +- immutable request and monotonic outcome transition protection; +- an indexed `(ScopeId, PropertyId, Outcome, UpdatedAtUtc, OperationId, + ReservationId)` + recovery path; +- optimistic operation versioning; +- Data Rights and tenant-termination export/catalog bindings; +- tenant destruction through the existing Reservation-owned cascade/lifecycle; +- downgrade guards where exact outcome evidence cannot be represented. + +Provider-neutral domain and application code remains free of PostgreSQL SQL. + +## Contracts And Surfaces + +- Add a dedicated stay-amendment request and receipt contract. +- Add public management amend/status/reconcile surfaces under the existing + property-scoped Reservations `Manage`/`Read` permissions. Finer-grained + recovery delegation remains a later policy slice so this Reservations-only + branch does not change Workspaces access-profile composition. +- Add equivalent Admin API and Admin CLI commands; destructive-looking or + recovery actions require explicit Admin confirmation. +- Preserve the current inventory-only reassignment route and command behavior, + but route new material operations through the same durable outcome model. +- Keep list responses bounded and cursor based. Never return guest/contact data + from the recovery queue. + +## Verification + +- Domain tests cover date-only, unit-only, time-only, combined, no-op, invalid + range, stale revision, pending conflict, exact replay, changed reuse, and every + forbidden lifecycle state. +- Confirmation changes dates/times/units and advances details revision once; + rejection changes none of them. +- Fingerprints bind reservation, operation, expected revision, dates, times, + and sorted units. +- Outcome handlers reject stale/mismatched facts and atomically update the + aggregate, projection, history, and amendment operation. +- Reconcile is version checked, rate bounded, and emits one exact replay request + per accepted attempt. +- PostgreSQL migration/backfill tests cover pending and historical operations, + malformed shapes, transition attacks, cross-tenant ids, indexes, and + downgrade behavior. +- A real PostgreSQL/JetStream scenario proves extension and unit change through + HTTP, old-truth-while-pending, confirmation, rejection, exact retry, worker + restart, lost-outcome reconciliation, and no second Inventory mutation. +- Public/Admin authorization, API error mapping, Admin CLI confirmation, + exports/catalogs, migration drift, architecture boundaries, and the complete + repository gate pass. + +## Completion Boundary + +Implementation and local/provider proof do not establish hosted readiness. +The draft PR must distinguish code evidence from deployed migration, worker +health, queue/backlog, backup/restore, and operator rehearsal evidence. diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Admin.Contracts/Operations/ReservationsAdminOperationNames.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Admin.Contracts/Operations/ReservationsAdminOperationNames.cs index e6d248c1..e87747f3 100644 --- a/src/Modules/Reservations/BunkFy.Modules.Reservations.Admin.Contracts/Operations/ReservationsAdminOperationNames.cs +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Admin.Contracts/Operations/ReservationsAdminOperationNames.cs @@ -12,4 +12,8 @@ public static class ReservationsAdminOperationNames public const string CheckOut = "reservations.check-out"; public const string LinkGuest = "reservations.link-guest"; public const string ReassignInventory = "reservations.reassign-inventory"; + public const string AmendStay = "reservations.amend-stay"; + public const string GetStayAmendment = "reservations.get-stay-amendment"; + public const string ListStayAmendmentRecovery = "reservations.list-stay-amendment-recovery"; + public const string ReconcileStayAmendment = "reservations.reconcile-stay-amendment"; } diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.AdminApi/ReservationsAdminApiModule.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.AdminApi/ReservationsAdminApiModule.cs index d9c964fc..8347ba65 100644 --- a/src/Modules/Reservations/BunkFy.Modules.Reservations.AdminApi/ReservationsAdminApiModule.cs +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.AdminApi/ReservationsAdminApiModule.cs @@ -66,6 +66,40 @@ public void MapEndpoints(IEndpointRouteBuilder endpoints) }) .Produces(StatusCodes.Status200OK); + group.MapGet("/stay-amendments/recovery", async ( + Guid propertyId, + ReservationStayAmendmentOutcome? cursorOutcome, + DateTimeOffset? cursorUpdatedAtUtc, + Guid? cursorOperationId, + Guid? cursorReservationId, + int? pageSize, + HttpContext httpContext, + AdminApiExecutor executor, + IRequestDispatcher dispatcher, + CancellationToken cancellationToken) => + { + MarkPersonalDataResponse(httpContext); + return await executor.ExecuteAsync( + httpContext, + AdminOperation.Create( + ReservationsAdminOperationNames.ListStayAmendmentRecovery, + ReservationsAdminPermissions.Read), + requireTenant: true, + token => dispatcher.QueryAsync( + new ListReservationStayAmendmentRecoveryQuery( + propertyId, + CreateStayAmendmentRecoveryCursor( + cursorOutcome, + cursorUpdatedAtUtc, + cursorOperationId, + cursorReservationId), + pageSize ?? StayAmendmentRecoveryDefaultPageSize), + token), + cancellationToken, + errorStatusCodes: ErrorStatusCodes).ConfigureAwait(false); + }) + .Produces(StatusCodes.Status200OK); + group.MapGet("/{reservationId:guid}", async ( Guid propertyId, Guid reservationId, @@ -112,6 +146,30 @@ public void MapEndpoints(IEndpointRouteBuilder endpoints) }) .Produces(StatusCodes.Status200OK); + group.MapGet("/{reservationId:guid}/stay-amendments/{operationId:guid}", async ( + Guid propertyId, + Guid reservationId, + Guid operationId, + HttpContext httpContext, + AdminApiExecutor executor, + IRequestDispatcher dispatcher, + CancellationToken cancellationToken) => + { + MarkPersonalDataResponse(httpContext); + return await executor.ExecuteAsync( + httpContext, + AdminOperation.Create( + ReservationsAdminOperationNames.GetStayAmendment, + ReservationsAdminPermissions.Read), + requireTenant: true, + token => dispatcher.QueryAsync( + new GetReservationStayAmendmentQuery(propertyId, reservationId, operationId), + token), + cancellationToken, + errorStatusCodes: ErrorStatusCodes).ConfigureAwait(false); + }) + .Produces(StatusCodes.Status200OK); + group.MapPost("", async ( Guid propertyId, CreateReservationRequest request, @@ -119,7 +177,8 @@ public void MapEndpoints(IEndpointRouteBuilder endpoints) AdminApiExecutor executor, IRequestDispatcher dispatcher, CancellationToken cancellationToken) => - await executor.ExecuteAsync( + { + return await executor.ExecuteAsync( httpContext, AdminOperation.Create(ReservationsAdminOperationNames.Create, ReservationsAdminPermissions.Create), requireTenant: true, @@ -142,7 +201,8 @@ await executor.ExecuteAsync( request.ExpectedDepartureTime), token), cancellationToken, - errorStatusCodes: ErrorStatusCodes).ConfigureAwait(false)) + errorStatusCodes: ErrorStatusCodes).ConfigureAwait(false); + }) .Produces(StatusCodes.Status200OK); group.MapPost("/{reservationId:guid}/cancel", async ( @@ -153,7 +213,8 @@ await executor.ExecuteAsync( AdminApiExecutor executor, IRequestDispatcher dispatcher, CancellationToken cancellationToken) => - await executor.ExecuteAsync( + { + return await executor.ExecuteAsync( httpContext, AdminOperation.Create(ReservationsAdminOperationNames.Cancel, ReservationsAdminPermissions.Cancel), requireTenant: true, @@ -165,7 +226,8 @@ await executor.ExecuteAsync( request.ExpectedVersion), token), cancellationToken, - errorStatusCodes: ErrorStatusCodes).ConfigureAwait(false)) + errorStatusCodes: ErrorStatusCodes).ConfigureAwait(false); + }) .Produces(StatusCodes.Status200OK); group.MapPut("/{reservationId:guid}/guests", async ( @@ -221,6 +283,79 @@ await executor.ExecuteAsync( errorStatusCodes: ErrorStatusCodes).ConfigureAwait(false)) .Produces(StatusCodes.Status200OK); + group.MapPut("/{reservationId:guid}/stay-amendments/{operationId:guid}", async ( + Guid propertyId, + Guid reservationId, + Guid operationId, + AmendReservationStayRequest request, + HttpContext httpContext, + AdminApiExecutor executor, + IRequestDispatcher dispatcher, + CancellationToken cancellationToken) => + { + MarkPersonalDataResponse(httpContext); + return await executor.ExecuteAsync( + httpContext, + AdminOperation.Create( + ReservationsAdminOperationNames.AmendStay, + ReservationsAdminPermissions.Manage), + requireTenant: true, + token => request.Confirmed + ? dispatcher.SendAsync( + new AmendReservationStayCommand( + operationId, + propertyId, + reservationId, + request.Arrival, + request.Departure, + request.ExpectedArrivalTime, + request.ExpectedDepartureTime, + request.InventoryUnitIds, + request.ExpectedDetailsRevision, + Actor(httpContext)), + token) + : Task.FromResult( + Gma.Framework.Results.Result.Failure( + AdminErrors.ConfirmationRequired)), + cancellationToken, + errorStatusCodes: ErrorStatusCodes).ConfigureAwait(false); + }) + .Produces(StatusCodes.Status200OK); + + group.MapPost("/{reservationId:guid}/stay-amendments/{operationId:guid}/reconcile", async ( + Guid propertyId, + Guid reservationId, + Guid operationId, + ReconcileReservationStayAmendmentRequest request, + HttpContext httpContext, + AdminApiExecutor executor, + IRequestDispatcher dispatcher, + CancellationToken cancellationToken) => + { + MarkPersonalDataResponse(httpContext); + return await executor.ExecuteAsync( + httpContext, + AdminOperation.Create( + ReservationsAdminOperationNames.ReconcileStayAmendment, + ReservationsAdminPermissions.Manage), + requireTenant: true, + token => request.Confirmed + ? dispatcher.SendAsync( + new ReconcileReservationStayAmendmentCommand( + propertyId, + reservationId, + operationId, + request.ExpectedOperationVersion, + Actor(httpContext)), + token) + : Task.FromResult( + Gma.Framework.Results.Result.Failure( + AdminErrors.ConfirmationRequired)), + cancellationToken, + errorStatusCodes: ErrorStatusCodes).ConfigureAwait(false); + }) + .Produces(StatusCodes.Status200OK); + group.MapPost("/{reservationId:guid}/check-in", async ( Guid propertyId, Guid reservationId, @@ -338,6 +473,19 @@ public sealed record ReassignReservationInventoryRequest( IReadOnlyCollection InventoryUnitIds, long ExpectedDetailsRevision); + public sealed record AmendReservationStayRequest( + DateOnly Arrival, + DateOnly Departure, + TimeOnly? ExpectedArrivalTime, + TimeOnly? ExpectedDepartureTime, + IReadOnlyCollection InventoryUnitIds, + long ExpectedDetailsRevision, + bool Confirmed); + + public sealed record ReconcileReservationStayAmendmentRequest( + long ExpectedOperationVersion, + bool Confirmed); + private static readonly ApiErrorStatusCodeMap ErrorStatusCodes = ApiErrorStatusCodeMap.Create( new(ReservationsApplicationErrors.WorkspaceProcessingRestricted.Code, StatusCodes.Status423Locked), new(ReservationsApplicationErrors.WorkspaceProcessingAdmissionUnavailable.Code, StatusCodes.Status503ServiceUnavailable), @@ -355,6 +503,12 @@ public sealed record ReassignReservationInventoryRequest( new(ReservationsApplicationErrors.DetailsChangeProvenanceInvalid.Code, StatusCodes.Status400BadRequest), new(ReservationsApplicationErrors.AllocationAmendmentInProgress.Code, StatusCodes.Status409Conflict), new(ReservationsApplicationErrors.AllocationAmendmentInvalid.Code, StatusCodes.Status409Conflict), + new(ReservationsApplicationErrors.StayAmendmentOperationNotFound.Code, StatusCodes.Status404NotFound), + new(ReservationsApplicationErrors.StayAmendmentOperationConflict.Code, StatusCodes.Status409Conflict), + new(ReservationsApplicationErrors.StayAmendmentOperationVersionConflict.Code, StatusCodes.Status409Conflict), + new(ReservationsApplicationErrors.StayAmendmentReconcileInvalid.Code, StatusCodes.Status409Conflict), + new(ReservationsApplicationErrors.StayAmendmentReconcileTooSoon.Code, StatusCodes.Status429TooManyRequests), + new(ReservationsApplicationErrors.StayAmendmentRequestInvalid.Code, StatusCodes.Status400BadRequest), new(ReservationsApplicationErrors.StayBusinessDateInvalid.Code, StatusCodes.Status400BadRequest), new(ReservationsApplicationErrors.StayProvenanceInvalid.Code, StatusCodes.Status400BadRequest), new(ReservationsApplicationErrors.GuestNotLinkable.Code, StatusCodes.Status409Conflict), @@ -377,4 +531,19 @@ private static string Actor(HttpContext context) ?? $"authenticated:{context.User.Identity?.AuthenticationType ?? "unknown"}"; return $"admin-api:{identity}"; } + + private static ReservationStayAmendmentRecoveryCursorDto? CreateStayAmendmentRecoveryCursor( + ReservationStayAmendmentOutcome? outcome, + DateTimeOffset? updatedAtUtc, + Guid? operationId, + Guid? reservationId) => + outcome is null && updatedAtUtc is null && operationId is null && reservationId is null + ? null + : new ReservationStayAmendmentRecoveryCursorDto( + outcome ?? ReservationStayAmendmentOutcome.Unknown, + updatedAtUtc ?? default, + operationId ?? Guid.Empty, + reservationId ?? Guid.Empty); + + private const int StayAmendmentRecoveryDefaultPageSize = 50; } diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.AdminCli/ReservationStayAmendmentAdminCliCommand.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.AdminCli/ReservationStayAmendmentAdminCliCommand.cs new file mode 100644 index 00000000..f74f88bf --- /dev/null +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.AdminCli/ReservationStayAmendmentAdminCliCommand.cs @@ -0,0 +1,406 @@ +namespace BunkFy.Modules.Reservations.AdminCli; + +using System.CommandLine; +using System.CommandLine.Parsing; +using System.Globalization; +using BunkFy.Modules.Reservations.Admin.Contracts; +using BunkFy.Modules.Reservations.Application; +using BunkFy.Modules.Reservations.Application.Commands; +using BunkFy.Modules.Reservations.Application.Queries; +using BunkFy.Modules.Reservations.Contracts; +using Gma.Framework.Administration; +using Gma.Framework.Administration.Cli; +using Gma.Framework.Cqrs; +using Gma.Framework.Results; +using Microsoft.Extensions.DependencyInjection; + +internal static class ReservationStayAmendmentAdminCliCommand +{ + public static Command Create(IServiceProvider services, AdminCliGlobalOptions globalOptions) => + new("stay-amendments", "Manage desired reservation-stay amendments and recovery.") + { + CreateAmendCommand(services, globalOptions), + CreateStatusCommand(services, globalOptions), + CreateListRecoveryCommand(services, globalOptions), + CreateReconcileCommand(services, globalOptions) + }; + + private static Command CreateAmendCommand( + IServiceProvider services, + AdminCliGlobalOptions globalOptions) + { + Option operationOption = OperationOption(); + Option propertyOption = PropertyOption(); + Option reservationOption = ReservationOption(); + Option arrivalOption = RequiredString("--arrival"); + Option departureOption = RequiredString("--departure"); + Option expectedArrivalTimeOption = new("--expected-arrival-time"); + Option expectedDepartureTimeOption = new("--expected-departure-time"); + Option unitIdsOption = RequiredString("--unit-ids"); + Option revisionOption = new("--expected-details-revision") { Required = true }; + Option yesOption = new("--yes"); + Command command = new("amend", "Submit a complete desired pre-arrival stay.") + { + operationOption, + propertyOption, + reservationOption, + arrivalOption, + departureOption, + expectedArrivalTimeOption, + expectedDepartureTimeOption, + unitIdsOption, + revisionOption, + yesOption + }; + command.SetAction((parseResult, cancellationToken) => ExecuteReceiptAsync( + services, + globalOptions, + parseResult, + ReservationsAdminOperationNames.AmendStay, + ReservationsAdminPermissions.Manage, + (provider, token) => + { + if (!parseResult.GetValue(yesOption)) + { + return Task.FromResult(Result.Failure( + AdminErrors.ConfirmationRequired)); + } + + if (!TryParseDate(parseResult.GetRequiredValue(arrivalOption), out DateOnly arrival) || + !TryParseDate(parseResult.GetRequiredValue(departureOption), out DateOnly departure) || + arrival >= departure || + !TryParseOptionalTime( + parseResult.GetValue(expectedArrivalTimeOption), + out TimeOnly? expectedArrivalTime) || + !TryParseOptionalTime( + parseResult.GetValue(expectedDepartureTimeOption), + out TimeOnly? expectedDepartureTime) || + !TryParseUnitIds(parseResult.GetRequiredValue(unitIdsOption), out Guid[] unitIds)) + { + return Task.FromResult(Result.Failure( + ReservationsApplicationErrors.StayAmendmentRequestInvalid)); + } + + return provider.GetRequiredService().SendAsync( + new AmendReservationStayCommand( + parseResult.GetRequiredValue(operationOption), + parseResult.GetRequiredValue(propertyOption), + parseResult.GetRequiredValue(reservationOption), + arrival, + departure, + expectedArrivalTime, + expectedDepartureTime, + unitIds, + parseResult.GetRequiredValue(revisionOption), + ResolveActor(parseResult, globalOptions)), + token); + }, + cancellationToken)); + return command; + } + + private static Command CreateStatusCommand( + IServiceProvider services, + AdminCliGlobalOptions globalOptions) + { + Option operationOption = OperationOption(); + Option propertyOption = PropertyOption(); + Option reservationOption = ReservationOption(); + Command command = new("status", "Get one stay-amendment outcome.") + { + operationOption, + propertyOption, + reservationOption + }; + command.SetAction((parseResult, cancellationToken) => ExecuteReceiptAsync( + services, + globalOptions, + parseResult, + ReservationsAdminOperationNames.GetStayAmendment, + ReservationsAdminPermissions.Read, + (provider, token) => provider.GetRequiredService().QueryAsync( + new GetReservationStayAmendmentQuery( + parseResult.GetRequiredValue(propertyOption), + parseResult.GetRequiredValue(reservationOption), + parseResult.GetRequiredValue(operationOption)), + token), + cancellationToken)); + return command; + } + + private static Command CreateListRecoveryCommand( + IServiceProvider services, + AdminCliGlobalOptions globalOptions) + { + Option propertyOption = PropertyOption(); + Option cursorOutcomeOption = new("--cursor-outcome"); + Option cursorUpdatedAtOption = new("--cursor-updated-at-utc"); + Option cursorOperationOption = new("--cursor-operation-id"); + Option cursorReservationOption = new("--cursor-reservation-id"); + Option pageSizeOption = new("--page-size") { DefaultValueFactory = _ => DefaultPageSize }; + Command command = new("list-recovery", "List bounded pending or outcome-unknown amendments.") + { + propertyOption, + cursorOutcomeOption, + cursorUpdatedAtOption, + cursorOperationOption, + cursorReservationOption, + pageSizeOption + }; + command.SetAction((parseResult, cancellationToken) => + { + AdminCliExecutor executor = services.GetRequiredService(); + return executor.ExecuteAsync( + parseResult, + AdminOperation.Create( + ReservationsAdminOperationNames.ListStayAmendmentRecovery, + ReservationsAdminPermissions.Read), + parseResult.GetValue(globalOptions.TenantOption), + requireTenant: true, + async (provider, token) => + { + if (!TryCreateCursor( + parseResult.GetValue(cursorOutcomeOption), + parseResult.GetValue(cursorUpdatedAtOption), + parseResult.GetValue(cursorOperationOption), + parseResult.GetValue(cursorReservationOption), + out ReservationStayAmendmentRecoveryCursorDto? cursor)) + { + return Result.Failure( + ReservationsApplicationErrors.StayAmendmentRequestInvalid); + } + + Result result = await provider + .GetRequiredService() + .QueryAsync( + new ListReservationStayAmendmentRecoveryQuery( + parseResult.GetRequiredValue(propertyOption), + cursor, + parseResult.GetValue(pageSizeOption)), + token) + .ConfigureAwait(false); + if (result.IsSuccess) + { + WriteRecoveryPage( + result.Value, + parseResult.GetValue(globalOptions.OutputOption) ?? AdminCliOutput.Table); + } + + return result; + }, + cancellationToken); + }); + return command; + } + + private static Command CreateReconcileCommand( + IServiceProvider services, + AdminCliGlobalOptions globalOptions) + { + Option operationOption = OperationOption(); + Option propertyOption = PropertyOption(); + Option reservationOption = ReservationOption(); + Option operationVersionOption = new("--expected-operation-version") { Required = true }; + Option yesOption = new("--yes"); + Command command = new("reconcile", "Republish the exact durable pending amendment.") + { + operationOption, + propertyOption, + reservationOption, + operationVersionOption, + yesOption + }; + command.SetAction((parseResult, cancellationToken) => ExecuteReceiptAsync( + services, + globalOptions, + parseResult, + ReservationsAdminOperationNames.ReconcileStayAmendment, + ReservationsAdminPermissions.Manage, + (provider, token) => parseResult.GetValue(yesOption) + ? provider.GetRequiredService().SendAsync( + new ReconcileReservationStayAmendmentCommand( + parseResult.GetRequiredValue(propertyOption), + parseResult.GetRequiredValue(reservationOption), + parseResult.GetRequiredValue(operationOption), + parseResult.GetRequiredValue(operationVersionOption), + ResolveActor(parseResult, globalOptions)), + token) + : Task.FromResult(Result.Failure( + AdminErrors.ConfirmationRequired)), + cancellationToken)); + return command; + } + + private static async Task ExecuteReceiptAsync( + IServiceProvider services, + AdminCliGlobalOptions globalOptions, + ParseResult parseResult, + string operationName, + AdminPermission permission, + Func>> execute, + CancellationToken cancellationToken) + { + AdminCliExecutor executor = services.GetRequiredService(); + return await executor.ExecuteAsync( + parseResult, + AdminOperation.Create(operationName, permission), + parseResult.GetValue(globalOptions.TenantOption), + requireTenant: true, + async (provider, token) => + { + Result result = await execute(provider, token) + .ConfigureAwait(false); + if (result.IsSuccess) + { + WriteReceipts( + [result.Value], + parseResult.GetValue(globalOptions.OutputOption) ?? AdminCliOutput.Table); + } + + return result; + }, + cancellationToken).ConfigureAwait(false); + } + + private static void WriteReceipts( + IReadOnlyCollection receipts, + string output) => + AdminCliOutput.WriteRows( + receipts, + output, + [ + ("OperationId", receipt => receipt.OperationId.ToString()), + ("PropertyId", receipt => receipt.PropertyId.ToString()), + ("ReservationId", receipt => receipt.ReservationId.ToString()), + ("Outcome", receipt => receipt.Outcome.ToString()), + ("Arrival", receipt => receipt.Target?.Arrival.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) ?? string.Empty), + ("Departure", receipt => receipt.Target?.Departure.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) ?? string.Empty), + ("ExpectedArrival", receipt => receipt.Target?.ExpectedArrivalTime?.ToString("HH:mm", CultureInfo.InvariantCulture) ?? string.Empty), + ("ExpectedDeparture", receipt => receipt.Target?.ExpectedDepartureTime?.ToString("HH:mm", CultureInfo.InvariantCulture) ?? string.Empty), + ("Units", receipt => receipt.Target is null ? string.Empty : string.Join(',', receipt.Target.InventoryUnitIds)), + ("ExpectedDetailsRevision", receipt => receipt.ExpectedDetailsRevision.ToString(CultureInfo.InvariantCulture)), + ("OperationVersion", receipt => receipt.OperationVersion.ToString(CultureInfo.InvariantCulture)), + ("RecoveryEligible", receipt => receipt.RecoveryEligible.ToString(CultureInfo.InvariantCulture)), + ("NextRecoveryEligibleAtUtc", receipt => FormatTimestamp(receipt.NextRecoveryEligibleAtUtc)) + ]); + + private static void WriteRecoveryPage(ReservationStayAmendmentRecoveryPageDto page, string output) + { + if (AdminCliOutput.NormalizeFormat(output) == AdminCliOutput.Json) + { + AdminCliOutput.WriteObject(page, output); + return; + } + + AdminCliOutput.WriteRows( + page.Operations, + output, + [ + ("OperationId", operation => operation.OperationId.ToString()), + ("ReservationId", operation => operation.ReservationId.ToString()), + ("Outcome", operation => operation.Outcome.ToString()), + ("OperationVersion", operation => operation.OperationVersion.ToString(CultureInfo.InvariantCulture)), + ("UpdatedAtUtc", operation => FormatTimestamp(operation.UpdatedAtUtc)), + ("RecoveryEligible", operation => operation.RecoveryEligible.ToString(CultureInfo.InvariantCulture)), + ("NextRecoveryEligibleAtUtc", operation => FormatTimestamp(operation.NextRecoveryEligibleAtUtc)) + ]); + + if (page.NextCursor is not null) + { + AdminCliOutput.WriteMessage( + $"Next cursor: --cursor-outcome {page.NextCursor.Outcome} " + + $"--cursor-updated-at-utc {FormatTimestamp(page.NextCursor.UpdatedAtUtc)} " + + $"--cursor-operation-id {page.NextCursor.OperationId} " + + $"--cursor-reservation-id {page.NextCursor.ReservationId}"); + } + } + + private static bool TryCreateCursor( + string? outcomeText, + string? updatedAtText, + Guid? operationId, + Guid? reservationId, + out ReservationStayAmendmentRecoveryCursorDto? cursor) + { + bool hasOutcome = !string.IsNullOrWhiteSpace(outcomeText); + bool hasUpdatedAt = !string.IsNullOrWhiteSpace(updatedAtText); + bool hasOperation = operationId.HasValue; + bool hasReservation = reservationId.HasValue; + if (!hasOutcome && !hasUpdatedAt && !hasOperation && !hasReservation) + { + cursor = null; + return true; + } + + if (!hasOutcome || !hasUpdatedAt || !hasOperation || !hasReservation || + !Enum.TryParse( + outcomeText, + ignoreCase: true, + out ReservationStayAmendmentOutcome outcome) || + !DateTimeOffset.TryParse( + updatedAtText, + CultureInfo.InvariantCulture, + DateTimeStyles.RoundtripKind, + out DateTimeOffset updatedAtUtc)) + { + cursor = null; + return false; + } + + cursor = new ReservationStayAmendmentRecoveryCursorDto( + outcome, + updatedAtUtc, + operationId!.Value, + reservationId!.Value); + return true; + } + + private static bool TryParseDate(string value, out DateOnly date) => + DateOnly.TryParseExact(value, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out date); + + private static bool TryParseOptionalTime(string? value, out TimeOnly? time) + { + if (string.IsNullOrWhiteSpace(value)) + { + time = null; + return true; + } + + bool parsed = TimeOnly.TryParseExact( + value.Trim(), + "HH:mm", + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out TimeOnly result); + time = parsed ? result : null; + return parsed; + } + + private static bool TryParseUnitIds(string value, out Guid[] unitIds) + { + string[] values = value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + unitIds = values.Select(item => Guid.TryParse(item, out Guid id) ? id : Guid.Empty).ToArray(); + return unitIds.Length is > 0 and <= MaxUnitCount && + unitIds.All(id => id != Guid.Empty) && + unitIds.Distinct().Count() == unitIds.Length; + } + + private static string ResolveActor(ParseResult parseResult, AdminCliGlobalOptions globalOptions) => + string.IsNullOrWhiteSpace(parseResult.GetValue(globalOptions.ActorOption)) + ? $"{Environment.UserDomainName}\\{Environment.UserName}" + : parseResult.GetValue(globalOptions.ActorOption)!.Trim(); + + private static string FormatTimestamp(DateTimeOffset value) => + value.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture); + + private static string FormatTimestamp(DateTimeOffset? value) => + value.HasValue ? FormatTimestamp(value.Value) : string.Empty; + + private static Option OperationOption() => new("--operation-id") { Required = true }; + private static Option PropertyOption() => new("--property-id") { Required = true }; + private static Option ReservationOption() => new("--reservation-id") { Required = true }; + private static Option RequiredString(string name) => new(name) { Required = true }; + + private const int DefaultPageSize = 50; + private const int MaxUnitCount = 100; +} diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.AdminCli/ReservationsAdminCliModule.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.AdminCli/ReservationsAdminCliModule.cs index b6224510..8a81205b 100644 --- a/src/Modules/Reservations/BunkFy.Modules.Reservations.AdminCli/ReservationsAdminCliModule.cs +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.AdminCli/ReservationsAdminCliModule.cs @@ -40,6 +40,7 @@ public void MapCommands(IAdminCliCommandRegistry commands) CreateCancelCommand(commands.Services, globalOptions), CreateLinkGuestCommand(commands.Services, globalOptions), ReservationInventoryAdminCliCommand.Create(commands.Services, globalOptions), + ReservationStayAmendmentAdminCliCommand.Create(commands.Services, globalOptions), CreateStayLifecycleCommand( commands.Services, globalOptions, diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Api/ReservationsModule.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Api/ReservationsModule.cs index e5f01280..7efefebc 100644 --- a/src/Modules/Reservations/BunkFy.Modules.Reservations.Api/ReservationsModule.cs +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Api/ReservationsModule.cs @@ -150,6 +150,35 @@ public void MapEndpoints(IEndpointRouteBuilder endpoints) ReservationsAdminPermissionCodes.Read, ReservationsPropertyAccessScopeResolver.ResolverName); + group.MapGet("/stay-amendments/recovery", async ( + Guid propertyId, + ReservationStayAmendmentOutcome? cursorOutcome, + DateTimeOffset? cursorUpdatedAtUtc, + Guid? cursorOperationId, + Guid? cursorReservationId, + int? pageSize, + HttpContext httpContext, + IRequestDispatcher dispatcher, + CancellationToken cancellationToken) => + { + MarkPersonalDataResponse(httpContext); + return (await dispatcher.QueryAsync( + new ListReservationStayAmendmentRecoveryQuery( + propertyId, + CreateStayAmendmentRecoveryCursor( + cursorOutcome, + cursorUpdatedAtUtc, + cursorOperationId, + cursorReservationId), + pageSize ?? StayAmendmentRecoveryDefaultPageSize), + cancellationToken).ConfigureAwait(false)).ToHttpResult(ErrorStatusCodes); + }) + .Produces(StatusCodes.Status200OK) + .RequireTenant() + .RequireResolvedScopePermission( + ReservationsAdminPermissionCodes.Read, + ReservationsPropertyAccessScopeResolver.ResolverName); + group.MapGet("/{reservationId:guid}", async ( Guid propertyId, Guid reservationId, @@ -192,6 +221,25 @@ public void MapEndpoints(IEndpointRouteBuilder endpoints) ReservationsAdminPermissionCodes.Read, ReservationsPropertyAccessScopeResolver.ResolverName); + group.MapGet("/{reservationId:guid}/stay-amendments/{operationId:guid}", async ( + Guid propertyId, + Guid reservationId, + Guid operationId, + HttpContext httpContext, + IRequestDispatcher dispatcher, + CancellationToken cancellationToken) => + { + MarkPersonalDataResponse(httpContext); + return (await dispatcher.QueryAsync( + new GetReservationStayAmendmentQuery(propertyId, reservationId, operationId), + cancellationToken).ConfigureAwait(false)).ToHttpResult(ErrorStatusCodes); + }) + .Produces(StatusCodes.Status200OK) + .RequireTenant() + .RequireResolvedScopePermission( + ReservationsAdminPermissionCodes.Read, + ReservationsPropertyAccessScopeResolver.ResolverName); + group.MapPut("/{reservationId:guid}/guest-details", async ( Guid propertyId, Guid reservationId, @@ -258,6 +306,69 @@ public void MapEndpoints(IEndpointRouteBuilder endpoints) ReservationsAdminPermissionCodes.Manage, ReservationsPropertyAccessScopeResolver.ResolverName); + group.MapPut("/{reservationId:guid}/stay-amendments/{operationId:guid}", async ( + Guid propertyId, + Guid reservationId, + Guid operationId, + AmendReservationStayRequest request, + HttpContext httpContext, + IAccessHttpSubjectResolver subjectResolver, + IRequestDispatcher dispatcher, + CancellationToken cancellationToken) => + { + MarkPersonalDataResponse(httpContext); + string? actorId = ResolveActor(httpContext, subjectResolver); + return actorId is null + ? Results.Unauthorized() + : (await dispatcher.SendAsync( + new AmendReservationStayCommand( + operationId, + propertyId, + reservationId, + request.Arrival, + request.Departure, + request.ExpectedArrivalTime, + request.ExpectedDepartureTime, + request.InventoryUnitIds, + request.ExpectedDetailsRevision, + actorId), + cancellationToken).ConfigureAwait(false)).ToHttpResult(ErrorStatusCodes); + }) + .Produces(StatusCodes.Status200OK) + .RequireTenant() + .RequireResolvedScopePermission( + ReservationsAdminPermissionCodes.Manage, + ReservationsPropertyAccessScopeResolver.ResolverName); + + group.MapPost("/{reservationId:guid}/stay-amendments/{operationId:guid}/reconcile", async ( + Guid propertyId, + Guid reservationId, + Guid operationId, + ReconcileReservationStayAmendmentRequest request, + HttpContext httpContext, + IAccessHttpSubjectResolver subjectResolver, + IRequestDispatcher dispatcher, + CancellationToken cancellationToken) => + { + MarkPersonalDataResponse(httpContext); + string? actorId = ResolveActor(httpContext, subjectResolver); + return actorId is null + ? Results.Unauthorized() + : (await dispatcher.SendAsync( + new ReconcileReservationStayAmendmentCommand( + propertyId, + reservationId, + operationId, + request.ExpectedOperationVersion, + actorId), + cancellationToken).ConfigureAwait(false)).ToHttpResult(ErrorStatusCodes); + }) + .Produces(StatusCodes.Status200OK) + .RequireTenant() + .RequireResolvedScopePermission( + ReservationsAdminPermissionCodes.Manage, + ReservationsPropertyAccessScopeResolver.ResolverName); + group.MapPut("/{reservationId:guid}/guests", async ( Guid propertyId, Guid reservationId, @@ -454,6 +565,16 @@ public sealed record ReassignReservationInventoryRequest( IReadOnlyCollection InventoryUnitIds, long ExpectedDetailsRevision); + public sealed record AmendReservationStayRequest( + DateOnly Arrival, + DateOnly Departure, + TimeOnly? ExpectedArrivalTime, + TimeOnly? ExpectedDepartureTime, + IReadOnlyCollection InventoryUnitIds, + long ExpectedDetailsRevision); + + public sealed record ReconcileReservationStayAmendmentRequest(long ExpectedOperationVersion); + private static readonly ApiErrorStatusCodeMap ErrorStatusCodes = CreateErrorStatusCodes( new(ReservationsApplicationErrors.WorkspaceProcessingRestricted.Code, StatusCodes.Status423Locked), new(ReservationsApplicationErrors.WorkspaceProcessingAdmissionUnavailable.Code, StatusCodes.Status503ServiceUnavailable), @@ -475,6 +596,12 @@ public sealed record ReassignReservationInventoryRequest( new(ReservationsApplicationErrors.CorrectionNoChanges.Code, StatusCodes.Status409Conflict), new(ReservationsApplicationErrors.AllocationAmendmentInProgress.Code, StatusCodes.Status409Conflict), new(ReservationsApplicationErrors.AllocationAmendmentInvalid.Code, StatusCodes.Status409Conflict), + new(ReservationsApplicationErrors.StayAmendmentOperationNotFound.Code, StatusCodes.Status404NotFound), + new(ReservationsApplicationErrors.StayAmendmentOperationConflict.Code, StatusCodes.Status409Conflict), + new(ReservationsApplicationErrors.StayAmendmentOperationVersionConflict.Code, StatusCodes.Status409Conflict), + new(ReservationsApplicationErrors.StayAmendmentReconcileInvalid.Code, StatusCodes.Status409Conflict), + new(ReservationsApplicationErrors.StayAmendmentReconcileTooSoon.Code, StatusCodes.Status429TooManyRequests), + new(ReservationsApplicationErrors.StayAmendmentRequestInvalid.Code, StatusCodes.Status400BadRequest), new(ReservationsApplicationErrors.StayBusinessDateInvalid.Code, StatusCodes.Status400BadRequest), new(ReservationsApplicationErrors.StayProvenanceInvalid.Code, StatusCodes.Status400BadRequest), new(ReservationsApplicationErrors.GuestNotLinkable.Code, StatusCodes.Status409Conflict), @@ -508,4 +635,19 @@ requirement is null ? null : $"{Gma.Framework.AccessControl.AccessSubjectKindNames.GetName(subject.Kind)}:{subject.Id}"; } + + private static ReservationStayAmendmentRecoveryCursorDto? CreateStayAmendmentRecoveryCursor( + ReservationStayAmendmentOutcome? outcome, + DateTimeOffset? updatedAtUtc, + Guid? operationId, + Guid? reservationId) => + outcome is null && updatedAtUtc is null && operationId is null && reservationId is null + ? null + : new ReservationStayAmendmentRecoveryCursorDto( + outcome ?? ReservationStayAmendmentOutcome.Unknown, + updatedAtUtc ?? default, + operationId ?? Guid.Empty, + reservationId ?? Guid.Empty); + + private const int StayAmendmentRecoveryDefaultPageSize = 50; } diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Commands/AmendReservationStayCommand.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Commands/AmendReservationStayCommand.cs new file mode 100644 index 00000000..5dacd8ac --- /dev/null +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Commands/AmendReservationStayCommand.cs @@ -0,0 +1,17 @@ +namespace BunkFy.Modules.Reservations.Application.Commands; + +using BunkFy.Modules.Reservations.Contracts; +using Gma.Framework.Cqrs; + +public sealed record AmendReservationStayCommand( + Guid OperationId, + Guid PropertyId, + Guid ReservationId, + DateOnly Arrival, + DateOnly Departure, + TimeOnly? ExpectedArrivalTime, + TimeOnly? ExpectedDepartureTime, + IReadOnlyCollection InventoryUnitIds, + long ExpectedDetailsRevision, + string ActorId) + : ITransactionalCommand; diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Commands/ReconcileReservationStayAmendmentCommand.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Commands/ReconcileReservationStayAmendmentCommand.cs new file mode 100644 index 00000000..9cfd4475 --- /dev/null +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Commands/ReconcileReservationStayAmendmentCommand.cs @@ -0,0 +1,12 @@ +namespace BunkFy.Modules.Reservations.Application.Commands; + +using BunkFy.Modules.Reservations.Contracts; +using Gma.Framework.Cqrs; + +public sealed record ReconcileReservationStayAmendmentCommand( + Guid PropertyId, + Guid ReservationId, + Guid OperationId, + long ExpectedOperationVersion, + string ActorId) + : ITransactionalCommand; diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/DependencyInjection.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/DependencyInjection.cs index 661b7fd2..3307b8c7 100644 --- a/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/DependencyInjection.cs +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/DependencyInjection.cs @@ -11,6 +11,7 @@ namespace BunkFy.Modules.Reservations.Application; using BunkFy.Modules.Reservations.Application.Handlers; using BunkFy.Modules.Reservations.Application.Policies; using BunkFy.Modules.Reservations.Application.Ports; +using BunkFy.Modules.Reservations.Application.StayAmendments; using BunkFy.Modules.Reservations.Application.Tasks; using BunkFy.Modules.Reservations.Contracts; using BunkFy.Modules.Retention.Contracts; @@ -146,6 +147,7 @@ public static IServiceCollection AddReservationsApplication(this IServiceCollect services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); + services.TryAddScoped(); services.TryAddScoped< IReservationGuestRecordLinkCapability, ReservationGuestRecordLinkCapability>(); diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Handlers/AmendReservationStayCommandHandler.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Handlers/AmendReservationStayCommandHandler.cs new file mode 100644 index 00000000..42723991 --- /dev/null +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Handlers/AmendReservationStayCommandHandler.cs @@ -0,0 +1,17 @@ +namespace BunkFy.Modules.Reservations.Application.Handlers; + +using BunkFy.Modules.Reservations.Application.Commands; +using BunkFy.Modules.Reservations.Application.StayAmendments; +using BunkFy.Modules.Reservations.Contracts; +using Gma.Framework.Cqrs; +using Gma.Framework.Results; + +internal sealed class AmendReservationStayCommandHandler( + ReservationStayAmendmentCoordinator coordinator) + : ICommandHandler +{ + public Task> HandleAsync( + AmendReservationStayCommand command, + CancellationToken cancellationToken) => + coordinator.AmendAsync(command, cancellationToken); +} diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Handlers/ExternalReservationOperationHandlers.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Handlers/ExternalReservationOperationHandlers.cs index 0d9b2652..5e2da778 100644 --- a/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Handlers/ExternalReservationOperationHandlers.cs +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Handlers/ExternalReservationOperationHandlers.cs @@ -348,6 +348,7 @@ await CompleteAsync(ExternalReservationOperationOutcome.ValidationRejected, rese Result begun = reservation.BeginAllocationAmendment( request.OperationId, + idGenerator.NewId(), fingerprint, request.Arrival, request.Departure, diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Handlers/GetReservationStayAmendmentQueryHandler.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Handlers/GetReservationStayAmendmentQueryHandler.cs new file mode 100644 index 00000000..bafdf712 --- /dev/null +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Handlers/GetReservationStayAmendmentQueryHandler.cs @@ -0,0 +1,31 @@ +namespace BunkFy.Modules.Reservations.Application.Handlers; + +using BunkFy.Modules.Reservations.Application.Ports; +using BunkFy.Modules.Reservations.Application.Queries; +using BunkFy.Modules.Reservations.Application.StayAmendments; +using BunkFy.Modules.Reservations.Contracts; +using BunkFy.Modules.Reservations.Domain.StayAmendments; +using Gma.Framework.Cqrs; +using Gma.Framework.Results; +using Gma.Framework.Runtime.Time; + +internal sealed class GetReservationStayAmendmentQueryHandler( + IReservationStayAmendmentOperationRepository operations, + ISystemClock clock) + : IQueryHandler +{ + public async Task> HandleAsync( + GetReservationStayAmendmentQuery query, + CancellationToken cancellationToken) + { + ReservationStayAmendmentOperation? operation = await operations.GetVisibleAsync( + query.PropertyId, + query.ReservationId, + query.OperationId, + cancellationToken).ConfigureAwait(false); + return operation is null + ? Result.Failure( + ReservationsApplicationErrors.StayAmendmentOperationNotFound) + : Result.Success(operation.ToReceipt(clock.UtcNow)); + } +} diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Handlers/InventoryAllocationAmendmentOutcomeHandlers.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Handlers/InventoryAllocationAmendmentOutcomeHandlers.cs index 73256acf..a2911ab4 100644 --- a/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Handlers/InventoryAllocationAmendmentOutcomeHandlers.cs +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Handlers/InventoryAllocationAmendmentOutcomeHandlers.cs @@ -8,11 +8,13 @@ namespace BunkFy.Modules.Reservations.Application.Handlers; using BunkFy.Modules.Reservations.Application.Ports; using BunkFy.Modules.Reservations.Contracts; using BunkFy.Modules.Reservations.Domain.Aggregates; +using BunkFy.Modules.Reservations.Domain.StayAmendments; [IntegrationEventHandler(ReservationsModuleMetadata.AllocationAmendmentConfirmedHandlerName)] internal sealed class InventoryAllocationAmendmentConfirmedHandler( ReservationMutationCoordinator mutations, IInventoryProjectionRepository projection, + IReservationStayAmendmentOperationRepository stayOperations, ExternalReservationOperationCoordinator coordinator, ReservationInboxDomainEventDispatcher domainEvents, ISystemClock clock, @@ -33,15 +35,85 @@ public async Task HandleAsync( $"Reservation '{outcome.ReservationId}' was not found for allocation amendment confirmation."); } - if (reservation.PendingAllocationAmendmentId != outcome.AmendmentRequestId) + if (reservation.PendingInventoryAmendmentRequestId != outcome.AmendmentRequestId) { - return; + ReservationStayAmendmentOperation? completedOperation = + await stayOperations.GetByInventoryRequestIdAsync( + outcome.AmendmentRequestId, + cancellationToken).ConfigureAwait(false); + if (completedOperation is null) + { + return; + } + + if (completedOperation.Outcome == ReservationStayAmendmentOperationOutcome.Applied && + completedOperation.ReservationId == outcome.ReservationId && + reservation.AllocationId == outcome.AllocationId && + completedOperation.ResultingAllocationVersion == outcome.AllocationVersion && + completedOperation.TargetArrival == outcome.Arrival && + completedOperation.TargetDeparture == outcome.Departure && + completedOperation.GetTargetInventoryUnitIds().Order().SequenceEqual( + outcome.InventoryUnitIds.Order())) + { + return; + } + + throw new InvalidOperationException( + "The confirmed allocation amendment contradicts the durable stay outcome."); } Guid? externalOperationId = reservation.PendingDetailsExternalOperationId; Guid? connectionId = reservation.PendingDetailsAdapterConnectionId; Guid? receiptId = reservation.PendingDetailsCorrelationId; string? fingerprint = reservation.PendingAllocationAmendmentRequestFingerprint; + ReservationStayAmendmentOperation? stayOperation = null; + if (reservation.PendingDetailsChangeOrigin == ReservationDetailsChangeOrigin.Staff) + { + if (!reservation.PendingAllocationAmendmentId.HasValue) + { + throw new InvalidOperationException( + "The confirmed allocation amendment has no local stay coordinate."); + } + + stayOperation = await stayOperations.GetAsync( + outcome.PropertyId, + outcome.ReservationId, + reservation.PendingAllocationAmendmentId.Value, + cancellationToken).ConfigureAwait(false); + if (stayOperation is null || + stayOperation.InventoryRequestId != outcome.AmendmentRequestId) + { + throw new InvalidOperationException( + "The confirmed allocation amendment has no durable stay evidence."); + } + } + DateTimeOffset nowUtc = clock.UtcNow; + if (stayOperation is not null && + (!stayOperation.TargetArrival.HasValue || + !stayOperation.TargetDeparture.HasValue || + stayOperation.TargetInventoryUnitIds is null || + stayOperation.TargetArrival != outcome.Arrival || + stayOperation.TargetDeparture != outcome.Departure || + !stayOperation.GetTargetInventoryUnitIds().Order().SequenceEqual( + outcome.InventoryUnitIds.Order()))) + { + throw new InvalidOperationException( + "The confirmed allocation amendment did not match the durable stay target."); + } + + if (reservation.CompleteAllocationAmendment( + outcome.AmendmentRequestId, + outcome.AllocationId, + outcome.Arrival, + outcome.Departure, + outcome.InventoryUnitIds, + outcome.AllocationVersion, + idGenerator.NewId(), + nowUtc).IsFailure) + { + throw new InvalidOperationException("The confirmed allocation amendment did not match Reservations state."); + } + await projection.ApplyAllocationAsync( new( outcome.ScopeId, @@ -54,17 +126,23 @@ await projection.ApplyAllocationAsync( outcome.InventoryUnitIds, outcome.AllocationVersion), cancellationToken).ConfigureAwait(false); - if (reservation.CompleteAllocationAmendment( - outcome.AmendmentRequestId, - outcome.AllocationId, - outcome.Arrival, - outcome.Departure, - outcome.InventoryUnitIds, - outcome.AllocationVersion, - idGenerator.NewId(), - clock.UtcNow).IsFailure) + + if (stayOperation is not null && + (reservation.ExpectedArrivalTime != stayOperation.TargetExpectedArrivalTime || + reservation.ExpectedDepartureTime != stayOperation.TargetExpectedDepartureTime || + stayOperation.MarkApplied( + outcome.Arrival, + outcome.Departure, + reservation.ExpectedArrivalTime, + reservation.ExpectedDepartureTime, + outcome.InventoryUnitIds, + outcome.AllocationVersion, + reservation.DetailsRevision, + reservation.Version, + nowUtc).IsFailure)) { - throw new InvalidOperationException("The confirmed allocation amendment did not match Reservations state."); + throw new InvalidOperationException( + "The confirmed allocation amendment did not match the durable stay operation."); } await domainEvents.DispatchAsync(reservation, cancellationToken).ConfigureAwait(false); @@ -93,6 +171,7 @@ await coordinator.CompleteAsync( [IntegrationEventHandler(ReservationsModuleMetadata.AllocationAmendmentRejectedHandlerName)] internal sealed class InventoryAllocationAmendmentRejectedHandler( ReservationMutationCoordinator mutations, + IReservationStayAmendmentOperationRepository stayOperations, ExternalReservationOperationCoordinator coordinator, ISystemClock clock) : IIntegrationEventHandler @@ -111,22 +190,73 @@ public async Task HandleAsync( $"Reservation '{outcome.ReservationId}' was not found for allocation amendment rejection."); } - if (reservation.PendingAllocationAmendmentId != outcome.AmendmentRequestId) + if (reservation.PendingInventoryAmendmentRequestId != outcome.AmendmentRequestId) { - return; + ReservationStayAmendmentOperation? completedOperation = + await stayOperations.GetByInventoryRequestIdAsync( + outcome.AmendmentRequestId, + cancellationToken).ConfigureAwait(false); + if (completedOperation is null) + { + return; + } + + if (completedOperation.Outcome == ReservationStayAmendmentOperationOutcome.Rejected && + completedOperation.ReservationId == outcome.ReservationId && + reservation.AllocationId == outcome.AllocationId && + completedOperation.RejectionCode == (int)outcome.Reason) + { + return; + } + + throw new InvalidOperationException( + "The rejected allocation amendment contradicts the durable stay outcome."); } Guid? externalOperationId = reservation.PendingDetailsExternalOperationId; Guid? connectionId = reservation.PendingDetailsAdapterConnectionId; Guid? receiptId = reservation.PendingDetailsCorrelationId; string? fingerprint = reservation.PendingAllocationAmendmentRequestFingerprint; + ReservationStayAmendmentOperation? stayOperation = null; + if (reservation.PendingDetailsChangeOrigin == ReservationDetailsChangeOrigin.Staff) + { + if (!reservation.PendingAllocationAmendmentId.HasValue) + { + throw new InvalidOperationException( + "The rejected allocation amendment has no local stay coordinate."); + } + + stayOperation = await stayOperations.GetAsync( + outcome.PropertyId, + outcome.ReservationId, + reservation.PendingAllocationAmendmentId.Value, + cancellationToken).ConfigureAwait(false); + if (stayOperation is null || + stayOperation.InventoryRequestId != outcome.AmendmentRequestId) + { + throw new InvalidOperationException( + "The rejected allocation amendment has no durable stay evidence."); + } + } + DateTimeOffset nowUtc = clock.UtcNow; if (reservation.RejectAllocationAmendment( outcome.AmendmentRequestId, outcome.AllocationId, (int)outcome.Reason, - clock.UtcNow).IsFailure) + nowUtc).IsFailure) { - return; + throw new InvalidOperationException( + "The rejected allocation amendment did not match Reservations state."); + } + + if (stayOperation is not null && stayOperation.MarkRejected( + (int)outcome.Reason, + reservation.DetailsRevision, + reservation.Version, + nowUtc).IsFailure) + { + throw new InvalidOperationException( + "The rejected allocation amendment did not match the durable stay operation."); } if (externalOperationId.HasValue && connectionId.HasValue && receiptId.HasValue && fingerprint is not null) diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Handlers/ListReservationStayAmendmentRecoveryQueryHandler.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Handlers/ListReservationStayAmendmentRecoveryQueryHandler.cs new file mode 100644 index 00000000..86846786 --- /dev/null +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Handlers/ListReservationStayAmendmentRecoveryQueryHandler.cs @@ -0,0 +1,32 @@ +namespace BunkFy.Modules.Reservations.Application.Handlers; + +using BunkFy.Modules.Reservations.Application.Ports; +using BunkFy.Modules.Reservations.Application.Queries; +using BunkFy.Modules.Reservations.Application.StayAmendments; +using BunkFy.Modules.Reservations.Contracts; +using Gma.Framework.Cqrs; +using Gma.Framework.Results; +using Gma.Framework.Runtime.Time; + +internal sealed class ListReservationStayAmendmentRecoveryQueryHandler( + IReservationStayAmendmentOperationRepository operations, + ISystemClock clock) + : IQueryHandler< + ListReservationStayAmendmentRecoveryQuery, + ReservationStayAmendmentRecoveryPageDto> +{ + public async Task> HandleAsync( + ListReservationStayAmendmentRecoveryQuery query, + CancellationToken cancellationToken) + { + ReservationStayAmendmentRecoveryPageRecord page = await operations.ListRecoveryAsync( + query.PropertyId, + query.Cursor?.ToRecord(), + query.PageSize, + cancellationToken).ConfigureAwait(false); + DateTimeOffset nowUtc = clock.UtcNow; + return Result.Success(new ReservationStayAmendmentRecoveryPageDto( + page.Operations.Select(operation => operation.ToRecoveryItem(nowUtc)).ToArray(), + page.NextCursor?.ToDto())); + } +} diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Handlers/ReassignReservationInventoryCommandHandler.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Handlers/ReassignReservationInventoryCommandHandler.cs index a38203ca..6582f3f1 100644 --- a/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Handlers/ReassignReservationInventoryCommandHandler.cs +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Handlers/ReassignReservationInventoryCommandHandler.cs @@ -1,156 +1,17 @@ namespace BunkFy.Modules.Reservations.Application.Handlers; -using System.Security.Cryptography; -using System.Text; using BunkFy.Modules.Reservations.Application.Commands; -using BunkFy.Modules.Reservations.Application.Ports; +using BunkFy.Modules.Reservations.Application.StayAmendments; using BunkFy.Modules.Reservations.Contracts; -using BunkFy.Modules.Reservations.Domain.Aggregates; using Gma.Framework.Cqrs; using Gma.Framework.Results; -using Gma.Framework.Runtime.Identity; -using Gma.Framework.Runtime.Time; internal sealed class ReassignReservationInventoryCommandHandler( - ReservationMutationCoordinator mutations, - IInventoryProjectionRepository inventoryProjection, - IReservationManagementOperationRepository operations, - ISystemClock clock, - IIdGenerator idGenerator) + ReservationStayAmendmentCoordinator coordinator) : ICommandHandler { - public async Task> HandleAsync( + public Task> HandleAsync( ReassignReservationInventoryCommand command, - CancellationToken cancellationToken) - { - if (command.AmendmentRequestId == Guid.Empty) - { - return Result.Failure( - ReservationsApplicationErrors.ManagementOperationInvalid); - } - - Reservation? reservation = await mutations.AcquireOperationalAsync( - command.PropertyId, - command.ReservationId, - cancellationToken).ConfigureAwait(false); - if (reservation is null) - { - return Result.Failure(ReservationsApplicationErrors.ReservationNotFound); - } - - string actorId = command.ActorId?.Trim() ?? string.Empty; - if (actorId.Length is 0 or > Reservation.ActorIdMaxLength) - { - return Result.Failure( - ReservationsApplicationErrors.DetailsChangeProvenanceInvalid); - } - - string fingerprint = Fingerprint(command); - ReservationManagementOperationRecord? existing = await operations - .GetAsync(command.ReservationId, command.AmendmentRequestId, cancellationToken) - .ConfigureAwait(false); - if (existing is not null) - { - return existing.MatchesInventoryAmendment( - command.ExpectedDetailsRevision, - fingerprint) - ? Result.Success(reservation.ToMutationReceipt()) - : Result.Failure( - ReservationsApplicationErrors.ManagementOperationConflict); - } - - if (reservation.PendingAllocationAmendmentId == command.AmendmentRequestId && - string.Equals( - reservation.PendingAllocationAmendmentRequestFingerprint, - fingerprint, - StringComparison.Ordinal)) - { - if (reservation.PendingDetailsChangeOrigin != - ReservationDetailsChangeOrigin.Staff) - { - return Result.Failure( - ReservationsApplicationErrors.AllocationAmendmentInProgress); - } - - await operations.AddAsync( - CreateOperation(reservation, command, fingerprint, clock.UtcNow), - cancellationToken).ConfigureAwait(false); - return Result.Success(reservation.ToMutationReceipt()); - } - - InventoryUnitSelectionValidation selection = await inventoryProjection.ValidateSelectionAsync( - command.PropertyId, - command.InventoryUnitIds, - cancellationToken).ConfigureAwait(false); - if (selection != InventoryUnitSelectionValidation.Valid) - { - return Result.Failure( - selection == InventoryUnitSelectionValidation.UnitNotFound - ? ReservationsApplicationErrors.InventoryUnitNotFound - : ReservationsApplicationErrors.InventoryUnitPropertyMismatch); - } - - DateTimeOffset nowUtc = clock.UtcNow; - Result begun = reservation.BeginAllocationAmendment( - command.AmendmentRequestId, - fingerprint, - reservation.Arrival, - reservation.Departure, - command.InventoryUnitIds, - reservation.PrimaryGuestName, - reservation.Email, - reservation.Phone, - reservation.GuestCount, - reservation.Notes, - command.ExpectedDetailsRevision, - ReservationDetailsChangeOrigin.Staff, - actorId, - adapterConnectionId: null, - externalOperationId: null, - command.AmendmentRequestId, - idGenerator.NewId(), - nowUtc, - reservation.ExpectedArrivalTime, - reservation.ExpectedDepartureTime); - if (begun.IsFailure) - { - return Result.Failure(begun.Error); - } - - if (begun.Value == ReservationDetailsChangeOutcome.Changed) - { - await operations.AddAsync( - CreateOperation(reservation, command, fingerprint, nowUtc), - cancellationToken).ConfigureAwait(false); - } - - return Result.Success(reservation.ToMutationReceipt()); - } - - private static string Fingerprint(ReassignReservationInventoryCommand command) - { - string canonical = string.Join( - '|', - command.ReservationId.ToString("N"), - command.AmendmentRequestId.ToString("N"), - command.ExpectedDetailsRevision.ToString(System.Globalization.CultureInfo.InvariantCulture), - string.Join(',', command.InventoryUnitIds.Order().Select(id => id.ToString("N")))); - return Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(canonical))); - } - - private static ReservationManagementOperationRecord CreateOperation( - Reservation reservation, - ReassignReservationInventoryCommand command, - string fingerprint, - DateTimeOffset createdAtUtc) => new( - command.AmendmentRequestId, - reservation.ScopeId, - command.PropertyId, - command.ReservationId, - ReservationManagementOperationKind.InventoryAmendment, - ExpectedVersion: null, - command.ExpectedDetailsRevision, - BusinessDate: null, - createdAtUtc, - fingerprint); + CancellationToken cancellationToken) => + coordinator.ReassignInventoryAsync(command, cancellationToken); } diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Handlers/ReconcileReservationStayAmendmentCommandHandler.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Handlers/ReconcileReservationStayAmendmentCommandHandler.cs new file mode 100644 index 00000000..45adb058 --- /dev/null +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Handlers/ReconcileReservationStayAmendmentCommandHandler.cs @@ -0,0 +1,108 @@ +namespace BunkFy.Modules.Reservations.Application.Handlers; + +using BunkFy.Modules.Reservations.Application.Commands; +using BunkFy.Modules.Reservations.Application.Ports; +using BunkFy.Modules.Reservations.Application.StayAmendments; +using BunkFy.Modules.Reservations.Contracts; +using BunkFy.Modules.Reservations.Domain.Aggregates; +using BunkFy.Modules.Reservations.Domain.Errors; +using BunkFy.Modules.Reservations.Domain.StayAmendments; +using Gma.Framework.Cqrs; +using Gma.Framework.Results; +using Gma.Framework.Runtime.Identity; +using Gma.Framework.Runtime.Time; + +internal sealed class ReconcileReservationStayAmendmentCommandHandler( + ReservationMutationCoordinator mutations, + IReservationStayAmendmentOperationRepository operations, + ISystemClock clock, + IIdGenerator idGenerator) + : ICommandHandler< + ReconcileReservationStayAmendmentCommand, + ReservationStayAmendmentReceiptDto> +{ + public async Task> HandleAsync( + ReconcileReservationStayAmendmentCommand command, + CancellationToken cancellationToken) + { + Reservation? reservation = await mutations.AcquireOperationalAsync( + command.PropertyId, + command.ReservationId, + cancellationToken).ConfigureAwait(false); + if (reservation is null) + { + return Result.Failure( + ReservationsApplicationErrors.ReservationNotFound); + } + + ReservationStayAmendmentOperation? operation = await operations.GetAsync( + command.PropertyId, + command.ReservationId, + command.OperationId, + cancellationToken).ConfigureAwait(false); + if (operation is null) + { + return Result.Failure( + ReservationsApplicationErrors.StayAmendmentOperationNotFound); + } + + DateTimeOffset nowUtc = clock.UtcNow; + Result valid = operation.CanReconcile( + command.ExpectedOperationVersion, + command.ActorId, + nowUtc); + if (valid.IsFailure) + { + return Result.Failure(MapReconcileError(valid.Error)); + } + + if (!operation.TargetArrival.HasValue || !operation.TargetDeparture.HasValue || + operation.TargetInventoryUnitIds is null || + !operation.InventoryRequestId.HasValue || + reservation.PendingInventoryAmendmentRequestId != operation.InventoryRequestId) + { + return Result.Failure( + ReservationsApplicationErrors.StayAmendmentReconcileInvalid); + } + + Result republished = reservation.RepublishPendingAllocationAmendment( + operation.Id, + operation.RequestFingerprint, + operation.TargetArrival.Value, + operation.TargetDeparture.Value, + operation.TargetExpectedArrivalTime, + operation.TargetExpectedDepartureTime, + operation.GetTargetInventoryUnitIds(), + idGenerator.NewId(), + nowUtc); + if (republished.IsFailure) + { + return Result.Failure( + ReservationsApplicationErrors.StayAmendmentReconcileInvalid); + } + + Result reconciled = operation.Reconcile( + command.ExpectedOperationVersion, + command.ActorId, + nowUtc); + if (reconciled.IsFailure) + { + throw new InvalidOperationException( + "A validated stay-amendment reconciliation changed before completion."); + } + + return Result.Success(operation.ToReceipt(nowUtc)); + } + + private static Error MapReconcileError(Error error) + { + if (error == ReservationsDomainErrors.StayAmendmentOperationVersionConflict) + { + return ReservationsApplicationErrors.StayAmendmentOperationVersionConflict; + } + + return error == ReservationsDomainErrors.StayAmendmentReconcileTooSoon + ? ReservationsApplicationErrors.StayAmendmentReconcileTooSoon + : ReservationsApplicationErrors.StayAmendmentReconcileInvalid; + } +} diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Ports/IReservationManagementOperationRepository.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Ports/IReservationManagementOperationRepository.cs index ef60fff6..674868cb 100644 --- a/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Ports/IReservationManagementOperationRepository.cs +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Ports/IReservationManagementOperationRepository.cs @@ -52,6 +52,18 @@ this.BusinessDate is null && this.RequestFingerprint, requestFingerprint, StringComparison.Ordinal); + + public bool MatchesStayAmendment( + long expectedDetailsRevision, + string requestFingerprint) => + this.Kind == ReservationManagementOperationKind.StayAmendment && + this.ExpectedVersion is null && + this.ExpectedDetailsRevision == expectedDetailsRevision && + this.BusinessDate is null && + string.Equals( + this.RequestFingerprint, + requestFingerprint, + StringComparison.Ordinal); } public enum ReservationManagementOperationKind @@ -62,5 +74,6 @@ public enum ReservationManagementOperationKind NoShow = 3, CheckOut = 4, GuestDetails = 5, - InventoryAmendment = 6 + InventoryAmendment = 6, + StayAmendment = 7 } diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Ports/IReservationStayAmendmentOperationRepository.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Ports/IReservationStayAmendmentOperationRepository.cs new file mode 100644 index 00000000..71b9c211 --- /dev/null +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Ports/IReservationStayAmendmentOperationRepository.cs @@ -0,0 +1,32 @@ +namespace BunkFy.Modules.Reservations.Application.Ports; + +using BunkFy.Modules.Reservations.Domain.StayAmendments; + +public interface IReservationStayAmendmentOperationRepository +{ + Task GetAsync( + Guid propertyId, + Guid reservationId, + Guid operationId, + CancellationToken cancellationToken); + + Task GetVisibleAsync( + Guid propertyId, + Guid reservationId, + Guid operationId, + CancellationToken cancellationToken); + + Task GetByInventoryRequestIdAsync( + Guid inventoryRequestId, + CancellationToken cancellationToken); + + Task AddAsync( + ReservationStayAmendmentOperation operation, + CancellationToken cancellationToken); + + Task ListRecoveryAsync( + Guid propertyId, + ReservationStayAmendmentRecoveryCursorRecord? cursor, + int pageSize, + CancellationToken cancellationToken); +} diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Ports/ReservationStayAmendmentRecoveryCursorRecord.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Ports/ReservationStayAmendmentRecoveryCursorRecord.cs new file mode 100644 index 00000000..32ddfb3e --- /dev/null +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Ports/ReservationStayAmendmentRecoveryCursorRecord.cs @@ -0,0 +1,9 @@ +namespace BunkFy.Modules.Reservations.Application.Ports; + +using BunkFy.Modules.Reservations.Domain.StayAmendments; + +public sealed record ReservationStayAmendmentRecoveryCursorRecord( + ReservationStayAmendmentOperationOutcome Outcome, + DateTimeOffset UpdatedAtUtc, + Guid OperationId, + Guid ReservationId); diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Ports/ReservationStayAmendmentRecoveryPageRecord.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Ports/ReservationStayAmendmentRecoveryPageRecord.cs new file mode 100644 index 00000000..7ccfd0f0 --- /dev/null +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Ports/ReservationStayAmendmentRecoveryPageRecord.cs @@ -0,0 +1,7 @@ +namespace BunkFy.Modules.Reservations.Application.Ports; + +using BunkFy.Modules.Reservations.Domain.StayAmendments; + +public sealed record ReservationStayAmendmentRecoveryPageRecord( + IReadOnlyCollection Operations, + ReservationStayAmendmentRecoveryCursorRecord? NextCursor); diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Queries/GetReservationStayAmendmentQuery.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Queries/GetReservationStayAmendmentQuery.cs new file mode 100644 index 00000000..271dfe9e --- /dev/null +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Queries/GetReservationStayAmendmentQuery.cs @@ -0,0 +1,10 @@ +namespace BunkFy.Modules.Reservations.Application.Queries; + +using BunkFy.Modules.Reservations.Contracts; +using Gma.Framework.Cqrs; + +public sealed record GetReservationStayAmendmentQuery( + Guid PropertyId, + Guid ReservationId, + Guid OperationId) + : IQuery; diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Queries/ListReservationStayAmendmentRecoveryQuery.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Queries/ListReservationStayAmendmentRecoveryQuery.cs new file mode 100644 index 00000000..5fe19733 --- /dev/null +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Queries/ListReservationStayAmendmentRecoveryQuery.cs @@ -0,0 +1,10 @@ +namespace BunkFy.Modules.Reservations.Application.Queries; + +using BunkFy.Modules.Reservations.Contracts; +using Gma.Framework.Cqrs; + +public sealed record ListReservationStayAmendmentRecoveryQuery( + Guid PropertyId, + ReservationStayAmendmentRecoveryCursorDto? Cursor, + int PageSize) + : IQuery; diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/ReservationsApplicationErrors.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/ReservationsApplicationErrors.cs index da76d129..f0a3045b 100644 --- a/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/ReservationsApplicationErrors.cs +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/ReservationsApplicationErrors.cs @@ -45,6 +45,22 @@ public static class ReservationsApplicationErrors public static Error DetailsChangeProvenanceInvalid => ReservationsDomainErrors.DetailsChangeProvenanceInvalid; public static Error AllocationAmendmentInProgress => ReservationsDomainErrors.AllocationAmendmentInProgress; public static Error AllocationAmendmentInvalid => ReservationsDomainErrors.AllocationAmendmentInvalid; + public static readonly Error StayAmendmentRequestInvalid = new( + "Reservations.StayAmendmentRequestInvalid", + "The stay-amendment request is invalid."); + public static readonly Error StayAmendmentOperationNotFound = new( + "Reservations.StayAmendmentOperationNotFound", + "The stay-amendment operation was not found."); + public static readonly Error StayAmendmentOperationConflict = new( + "Reservations.StayAmendmentOperationConflict", + "The stay-amendment operation id is already bound to a different request."); + public static Error StayAmendmentOperationVersionConflict => + ReservationsDomainErrors.StayAmendmentOperationVersionConflict; + public static readonly Error StayAmendmentReconcileInvalid = new( + "Reservations.StayAmendmentReconcileInvalid", + "Only a pending stay-amendment operation can be reconciled."); + public static Error StayAmendmentReconcileTooSoon => + ReservationsDomainErrors.StayAmendmentReconcileTooSoon; public static Error StayBusinessDateInvalid => ReservationsDomainErrors.StayBusinessDateInvalid; public static Error StayProvenanceInvalid => ReservationsDomainErrors.StayProvenanceInvalid; public static readonly Error GuestNotLinkable = new("Reservations.GuestNotLinkable", "The guest is not active and visible at this property."); diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/StayAmendments/ReservationStayAmendmentCoordinationSupport.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/StayAmendments/ReservationStayAmendmentCoordinationSupport.cs new file mode 100644 index 00000000..8e444886 --- /dev/null +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/StayAmendments/ReservationStayAmendmentCoordinationSupport.cs @@ -0,0 +1,49 @@ +namespace BunkFy.Modules.Reservations.Application.StayAmendments; + +using BunkFy.Modules.Reservations.Application.Ports; +using BunkFy.Modules.Reservations.Domain.Aggregates; +using Gma.Framework.Results; + +internal static class ReservationStayAmendmentCoordinationSupport +{ + public static async Task ValidateSelectionAsync( + IInventoryProjectionRepository inventoryProjection, + Guid propertyId, + IReadOnlyCollection inventoryUnitIds, + CancellationToken cancellationToken) + { + InventoryUnitSelectionValidation selection = await inventoryProjection.ValidateSelectionAsync( + propertyId, + inventoryUnitIds, + cancellationToken).ConfigureAwait(false); + return selection switch + { + InventoryUnitSelectionValidation.Valid => Result.Success(), + InventoryUnitSelectionValidation.UnitNotFound => Result.Failure( + ReservationsApplicationErrors.InventoryUnitNotFound), + _ => Result.Failure(ReservationsApplicationErrors.InventoryUnitPropertyMismatch) + }; + } + + public static ReservationManagementOperationRecord CreateManagementOperation( + Reservation reservation, + Guid operationId, + ReservationManagementOperationKind kind, + long expectedDetailsRevision, + string fingerprint, + DateTimeOffset createdAtUtc) => new( + operationId, + reservation.ScopeId, + reservation.PropertyId, + reservation.Id, + kind, + ExpectedVersion: null, + expectedDetailsRevision, + BusinessDate: null, + createdAtUtc, + fingerprint); + + public static bool IsValidActor(string actorId) => + actorId.Length is > 0 and <= Reservation.ActorIdMaxLength && + !actorId.Any(char.IsControl); +} diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/StayAmendments/ReservationStayAmendmentCoordinator.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/StayAmendments/ReservationStayAmendmentCoordinator.cs new file mode 100644 index 00000000..8c134c3c --- /dev/null +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/StayAmendments/ReservationStayAmendmentCoordinator.cs @@ -0,0 +1,344 @@ +namespace BunkFy.Modules.Reservations.Application.StayAmendments; + +using BunkFy.Modules.Reservations.Application.Commands; +using BunkFy.Modules.Reservations.Application.Handlers; +using BunkFy.Modules.Reservations.Application.Ports; +using BunkFy.Modules.Reservations.Contracts; +using BunkFy.Modules.Reservations.Domain.Aggregates; +using BunkFy.Modules.Reservations.Domain.StayAmendments; +using Gma.Framework.Results; +using Gma.Framework.Runtime.Identity; +using Gma.Framework.Runtime.Time; + +internal sealed class ReservationStayAmendmentCoordinator( + ReservationMutationCoordinator mutations, + IInventoryProjectionRepository inventoryProjection, + IReservationManagementOperationRepository managementOperations, + IReservationStayAmendmentOperationRepository stayOperations, + ISystemClock clock, + IIdGenerator idGenerator) +{ + public async Task> AmendAsync( + AmendReservationStayCommand command, + CancellationToken cancellationToken) + { + Reservation? reservation = await mutations.AcquireOperationalAsync( + command.PropertyId, + command.ReservationId, + cancellationToken).ConfigureAwait(false); + if (reservation is null) + { + return Result.Failure( + ReservationsApplicationErrors.ReservationNotFound); + } + + string actorId = command.ActorId?.Trim() ?? string.Empty; + if (command.OperationId == Guid.Empty || + !ReservationStayAmendmentCoordinationSupport.IsValidActor(actorId)) + { + return Result.Failure( + ReservationsApplicationErrors.StayAmendmentRequestInvalid); + } + + string fingerprint = ReservationStayAmendmentFingerprint.ComputeV2(command); + ReservationManagementOperationRecord? management = await managementOperations + .GetAsync(command.ReservationId, command.OperationId, cancellationToken) + .ConfigureAwait(false); + ReservationStayAmendmentOperation? existing = await stayOperations + .GetAsync(command.PropertyId, command.ReservationId, command.OperationId, cancellationToken) + .ConfigureAwait(false); + if (management is not null || existing is not null) + { + return management?.MatchesStayAmendment( + command.ExpectedDetailsRevision, + fingerprint) == true && + existing?.MatchesRequest( + ReservationStayAmendmentOperation.CurrentRequestSchemaVersion, + fingerprint) == true + ? Result.Success(existing.ToReceipt(clock.UtcNow)) + : Result.Failure( + ReservationsApplicationErrors.StayAmendmentOperationConflict); + } + + if (reservation.PendingAllocationAmendmentId == command.OperationId && + (reservation.PendingDetailsChangeOrigin != ReservationDetailsChangeOrigin.Staff || + !string.Equals( + reservation.PendingAllocationAmendmentRequestFingerprint, + fingerprint, + StringComparison.Ordinal))) + { + return Result.Failure( + ReservationsApplicationErrors.AllocationAmendmentInProgress); + } + + if (reservation.PendingAllocationAmendmentId == command.OperationId) + { + return Result.Failure( + ReservationsApplicationErrors.StayAmendmentOperationConflict); + } + + Result selection = await ReservationStayAmendmentCoordinationSupport.ValidateSelectionAsync( + inventoryProjection, + command.PropertyId, + command.InventoryUnitIds, + cancellationToken).ConfigureAwait(false); + if (selection.IsFailure) + { + return Result.Failure(selection.Error); + } + + DateTimeOffset nowUtc = clock.UtcNow; + Guid inventoryRequestId = idGenerator.NewId(); + Result begun = reservation.BeginAllocationAmendment( + command.OperationId, + inventoryRequestId, + fingerprint, + command.Arrival, + command.Departure, + command.InventoryUnitIds, + reservation.PrimaryGuestName, + reservation.Email, + reservation.Phone, + reservation.GuestCount, + reservation.Notes, + command.ExpectedDetailsRevision, + ReservationDetailsChangeOrigin.Staff, + actorId, + adapterConnectionId: null, + externalOperationId: null, + command.OperationId, + idGenerator.NewId(), + nowUtc, + command.ExpectedArrivalTime, + command.ExpectedDepartureTime); + if (begun.IsFailure) + { + return Result.Failure(begun.Error); + } + + if (begun.Value == ReservationDetailsChangeOutcome.Unchanged) + { + Result advanced = reservation.AdvanceStayAmendmentEvidenceCoordinate( + nowUtc); + if (advanced.IsFailure) + { + return Result.Failure( + ReservationsApplicationErrors.StayAmendmentRequestInvalid); + } + } + + Result created = begun.Value == ReservationDetailsChangeOutcome.Changed + ? ReservationStayAmendmentOperation.CreatePending( + command.OperationId, + reservation.ScopeId, + command.PropertyId, + command.ReservationId, + inventoryRequestId, + ReservationStayAmendmentOperation.CurrentRequestSchemaVersion, + fingerprint, + command.Arrival, + command.Departure, + command.ExpectedArrivalTime, + command.ExpectedDepartureTime, + command.InventoryUnitIds, + command.ExpectedDetailsRevision, + actorId, + nowUtc) + : ReservationStayAmendmentOperation.CreateAppliedNoOp( + command.OperationId, + reservation.ScopeId, + command.PropertyId, + command.ReservationId, + ReservationStayAmendmentOperation.CurrentRequestSchemaVersion, + fingerprint, + command.Arrival, + command.Departure, + command.ExpectedArrivalTime, + command.ExpectedDepartureTime, + command.InventoryUnitIds, + command.ExpectedDetailsRevision, + actorId, + reservation.DetailsRevision, + reservation.Version, + reservation.AllocationVersion!.Value, + nowUtc); + if (created.IsFailure) + { + return Result.Failure( + ReservationsApplicationErrors.StayAmendmentRequestInvalid); + } + + await managementOperations.AddAsync( + ReservationStayAmendmentCoordinationSupport.CreateManagementOperation( + reservation, + command.OperationId, + ReservationManagementOperationKind.StayAmendment, + command.ExpectedDetailsRevision, + fingerprint, + nowUtc), + cancellationToken).ConfigureAwait(false); + await stayOperations.AddAsync(created.Value, cancellationToken).ConfigureAwait(false); + return Result.Success(created.Value.ToReceipt(nowUtc)); + } + + public async Task> ReassignInventoryAsync( + ReassignReservationInventoryCommand command, + CancellationToken cancellationToken) + { + Reservation? reservation = await mutations.AcquireOperationalAsync( + command.PropertyId, + command.ReservationId, + cancellationToken).ConfigureAwait(false); + if (reservation is null) + { + return Result.Failure( + ReservationsApplicationErrors.ReservationNotFound); + } + + string actorId = command.ActorId?.Trim() ?? string.Empty; + if (command.AmendmentRequestId == Guid.Empty || + !ReservationStayAmendmentCoordinationSupport.IsValidActor(actorId)) + { + return Result.Failure( + command.AmendmentRequestId == Guid.Empty + ? ReservationsApplicationErrors.ManagementOperationInvalid + : ReservationsApplicationErrors.DetailsChangeProvenanceInvalid); + } + + string fingerprint = ReservationStayAmendmentFingerprint.ComputeLegacyV1(command); + ReservationManagementOperationRecord? management = await managementOperations + .GetAsync(command.ReservationId, command.AmendmentRequestId, cancellationToken) + .ConfigureAwait(false); + if (management is not null) + { + ReservationStayAmendmentOperation? durableOperation = await stayOperations + .GetAsync( + command.PropertyId, + command.ReservationId, + command.AmendmentRequestId, + cancellationToken).ConfigureAwait(false); + return management.MatchesInventoryAmendment( + command.ExpectedDetailsRevision, + fingerprint) && + durableOperation?.MatchesRequest( + ReservationStayAmendmentOperation.LegacyRequestSchemaVersion, + fingerprint) == true + ? Result.Success(reservation.ToMutationReceipt()) + : Result.Failure( + ReservationsApplicationErrors.ManagementOperationConflict); + } + + ReservationStayAmendmentOperation? existing = await stayOperations + .GetAsync( + command.PropertyId, + command.ReservationId, + command.AmendmentRequestId, + cancellationToken).ConfigureAwait(false); + if (existing is not null) + { + return Result.Failure( + ReservationsApplicationErrors.ManagementOperationConflict); + } + + if (reservation.PendingAllocationAmendmentId == command.AmendmentRequestId && + (reservation.PendingDetailsChangeOrigin != ReservationDetailsChangeOrigin.Staff || + !string.Equals( + reservation.PendingAllocationAmendmentRequestFingerprint, + fingerprint, + StringComparison.Ordinal))) + { + return Result.Failure( + ReservationsApplicationErrors.AllocationAmendmentInProgress); + } + + if (reservation.PendingAllocationAmendmentId == command.AmendmentRequestId) + { + // The migration backfills every exact predecessor row, and current + // writes persist the reservation, parent, and child atomically. + // A pending reservation without its evidence is therefore a + // corrupt graph; do not manufacture attribution on a retry. + return Result.Failure( + ReservationsApplicationErrors.ManagementOperationConflict); + } + + Result selection = await ReservationStayAmendmentCoordinationSupport.ValidateSelectionAsync( + inventoryProjection, + command.PropertyId, + command.InventoryUnitIds, + cancellationToken).ConfigureAwait(false); + if (selection.IsFailure) + { + return Result.Failure(selection.Error); + } + + DateTimeOffset nowUtc = clock.UtcNow; + Guid inventoryRequestId = idGenerator.NewId(); + Result begun = reservation.BeginAllocationAmendment( + command.AmendmentRequestId, + inventoryRequestId, + fingerprint, + reservation.Arrival, + reservation.Departure, + command.InventoryUnitIds, + reservation.PrimaryGuestName, + reservation.Email, + reservation.Phone, + reservation.GuestCount, + reservation.Notes, + command.ExpectedDetailsRevision, + ReservationDetailsChangeOrigin.Staff, + actorId, + adapterConnectionId: null, + externalOperationId: null, + command.AmendmentRequestId, + idGenerator.NewId(), + nowUtc, + reservation.ExpectedArrivalTime, + reservation.ExpectedDepartureTime); + if (begun.IsFailure) + { + return Result.Failure(begun.Error); + } + + if (begun.Value == ReservationDetailsChangeOutcome.Unchanged) + { + return Result.Success(reservation.ToMutationReceipt()); + } + + Result created = + ReservationStayAmendmentOperation.CreatePending( + command.AmendmentRequestId, + reservation.ScopeId, + command.PropertyId, + command.ReservationId, + inventoryRequestId, + ReservationStayAmendmentOperation.LegacyRequestSchemaVersion, + fingerprint, + reservation.PendingArrival!.Value, + reservation.PendingDeparture!.Value, + reservation.PendingExpectedArrivalTime, + reservation.PendingExpectedDepartureTime, + command.InventoryUnitIds, + command.ExpectedDetailsRevision, + actorId, + nowUtc); + if (created.IsFailure) + { + return Result.Failure( + ReservationsApplicationErrors.ManagementOperationInvalid); + } + + await managementOperations.AddAsync( + ReservationStayAmendmentCoordinationSupport.CreateManagementOperation( + reservation, + command.AmendmentRequestId, + ReservationManagementOperationKind.InventoryAmendment, + command.ExpectedDetailsRevision, + fingerprint, + nowUtc), + cancellationToken).ConfigureAwait(false); + await stayOperations.AddAsync(created.Value, cancellationToken).ConfigureAwait(false); + return Result.Success(reservation.ToMutationReceipt()); + } + +} diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/StayAmendments/ReservationStayAmendmentFingerprint.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/StayAmendments/ReservationStayAmendmentFingerprint.cs new file mode 100644 index 00000000..5b34589e --- /dev/null +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/StayAmendments/ReservationStayAmendmentFingerprint.cs @@ -0,0 +1,44 @@ +namespace BunkFy.Modules.Reservations.Application.StayAmendments; + +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using BunkFy.Modules.Reservations.Application.Commands; + +internal static class ReservationStayAmendmentFingerprint +{ + public static string ComputeV2(AmendReservationStayCommand command) + { + ArgumentNullException.ThrowIfNull(command); + string canonical = string.Join( + '|', + "v2", + $"reservation={command.ReservationId:N}", + $"operation={command.OperationId:N}", + $"expected-details-revision={command.ExpectedDetailsRevision.ToString(CultureInfo.InvariantCulture)}", + $"arrival={command.Arrival.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)}", + $"departure={command.Departure.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)}", + $"expected-arrival-time={FormatTime(command.ExpectedArrivalTime)}", + $"expected-departure-time={FormatTime(command.ExpectedDepartureTime)}", + $"units={string.Join(',', command.InventoryUnitIds.Order().Select(id => id.ToString("N")))}"); + return Hash(canonical); + } + + public static string ComputeLegacyV1(ReassignReservationInventoryCommand command) + { + ArgumentNullException.ThrowIfNull(command); + string canonical = string.Join( + '|', + command.ReservationId.ToString("N"), + command.AmendmentRequestId.ToString("N"), + command.ExpectedDetailsRevision.ToString(CultureInfo.InvariantCulture), + string.Join(',', command.InventoryUnitIds.Order().Select(id => id.ToString("N")))); + return Hash(canonical); + } + + private static string FormatTime(TimeOnly? time) => + time?.ToString("HH:mm", CultureInfo.InvariantCulture) ?? "-"; + + private static string Hash(string canonical) => + Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(canonical))); +} diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/StayAmendments/ReservationStayAmendmentMappings.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/StayAmendments/ReservationStayAmendmentMappings.cs new file mode 100644 index 00000000..e2abff9a --- /dev/null +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/StayAmendments/ReservationStayAmendmentMappings.cs @@ -0,0 +1,95 @@ +namespace BunkFy.Modules.Reservations.Application.StayAmendments; + +using BunkFy.Modules.Inventory.Contracts; +using BunkFy.Modules.Reservations.Application.Ports; +using BunkFy.Modules.Reservations.Contracts; +using BunkFy.Modules.Reservations.Domain.StayAmendments; + +internal static class ReservationStayAmendmentMappings +{ + public static ReservationStayAmendmentReceiptDto ToReceipt( + this ReservationStayAmendmentOperation operation, + DateTimeOffset nowUtc) => new( + operation.Id, + operation.PropertyId, + operation.ReservationId, + ToTarget(operation), + ToContractOutcome(operation.Outcome), + operation.ExpectedDetailsRevision, + operation.OperationVersion, + operation.RequestedAtUtc, + operation.UpdatedAtUtc, + operation.CompletedAtUtc, + operation.ResultingDetailsRevision, + operation.ResultingReservationVersion, + ToRejectionReason(operation.RejectionCode), + operation.ReconciliationCount, + operation.LastReconciledAtUtc, + operation.IsReconciliationEligible(nowUtc), + operation.NextReconciliationEligibleAtUtc); + + public static ReservationStayAmendmentRecoveryItemDto ToRecoveryItem( + this ReservationStayAmendmentOperation operation, + DateTimeOffset nowUtc) => new( + operation.Id, + operation.PropertyId, + operation.ReservationId, + ToContractOutcome(operation.Outcome), + operation.OperationVersion, + operation.RequestedAtUtc, + operation.UpdatedAtUtc, + operation.IsReconciliationEligible(nowUtc), + operation.NextReconciliationEligibleAtUtc); + + public static ReservationStayAmendmentRecoveryCursorDto ToDto( + this ReservationStayAmendmentRecoveryCursorRecord cursor) => new( + ToContractOutcome(cursor.Outcome), + cursor.UpdatedAtUtc, + cursor.OperationId, + cursor.ReservationId); + + public static ReservationStayAmendmentRecoveryCursorRecord ToRecord( + this ReservationStayAmendmentRecoveryCursorDto cursor) => new( + ToDomainOutcome(cursor.Outcome), + cursor.UpdatedAtUtc, + cursor.OperationId, + cursor.ReservationId); + + private static ReservationStayAmendmentTargetDto? ToTarget( + ReservationStayAmendmentOperation operation) => + operation.TargetArrival.HasValue && operation.TargetDeparture.HasValue && + operation.TargetInventoryUnitIds is not null + ? new ReservationStayAmendmentTargetDto( + operation.TargetArrival.Value, + operation.TargetDeparture.Value, + operation.TargetExpectedArrivalTime, + operation.TargetExpectedDepartureTime, + operation.GetTargetInventoryUnitIds()) + : null; + + private static InventoryAllocationRejectionReason? ToRejectionReason(int? code) => + code.HasValue && Enum.IsDefined((InventoryAllocationRejectionReason)code.Value) && + (InventoryAllocationRejectionReason)code.Value != InventoryAllocationRejectionReason.Unknown + ? (InventoryAllocationRejectionReason)code.Value + : null; + + private static ReservationStayAmendmentOutcome ToContractOutcome( + ReservationStayAmendmentOperationOutcome outcome) => outcome switch + { + ReservationStayAmendmentOperationOutcome.Pending => ReservationStayAmendmentOutcome.Pending, + ReservationStayAmendmentOperationOutcome.Applied => ReservationStayAmendmentOutcome.Applied, + ReservationStayAmendmentOperationOutcome.Rejected => ReservationStayAmendmentOutcome.Rejected, + ReservationStayAmendmentOperationOutcome.OutcomeUnknown => ReservationStayAmendmentOutcome.OutcomeUnknown, + _ => ReservationStayAmendmentOutcome.Unknown + }; + + private static ReservationStayAmendmentOperationOutcome ToDomainOutcome( + ReservationStayAmendmentOutcome outcome) => outcome switch + { + ReservationStayAmendmentOutcome.Pending => ReservationStayAmendmentOperationOutcome.Pending, + ReservationStayAmendmentOutcome.OutcomeUnknown => ReservationStayAmendmentOperationOutcome.OutcomeUnknown, + ReservationStayAmendmentOutcome.Applied => ReservationStayAmendmentOperationOutcome.Applied, + ReservationStayAmendmentOutcome.Rejected => ReservationStayAmendmentOperationOutcome.Rejected, + _ => ReservationStayAmendmentOperationOutcome.Unknown + }; +} diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Validation/AmendReservationStayCommandValidator.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Validation/AmendReservationStayCommandValidator.cs new file mode 100644 index 00000000..070ebbac --- /dev/null +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Validation/AmendReservationStayCommandValidator.cs @@ -0,0 +1,61 @@ +namespace BunkFy.Modules.Reservations.Application.Validation; + +using BunkFy.Modules.Reservations.Application.Commands; +using BunkFy.Modules.Reservations.Domain.Aggregates; +using Gma.Framework.Cqrs; + +internal sealed class AmendReservationStayCommandValidator + : ICommandValidator +{ + public IEnumerable Validate(AmendReservationStayCommand command) + { + if (command.OperationId == Guid.Empty) + { + yield return "OperationId is required."; + } + + if (command.PropertyId == Guid.Empty) + { + yield return "PropertyId is required."; + } + + if (command.ReservationId == Guid.Empty) + { + yield return "ReservationId is required."; + } + + if (command.Arrival == default || command.Departure == default || command.Arrival >= command.Departure) + { + yield return "Arrival must be before Departure."; + } + + if (!HasMinutePrecision(command.ExpectedArrivalTime) || + !HasMinutePrecision(command.ExpectedDepartureTime)) + { + yield return "Expected stay times must use minute precision."; + } + + if (command.InventoryUnitIds is null || + command.InventoryUnitIds.Count is 0 or > Reservation.MaximumRequestedUnits || + command.InventoryUnitIds.Any(id => id == Guid.Empty) || + command.InventoryUnitIds.Distinct().Count() != command.InventoryUnitIds.Count) + { + yield return "InventoryUnitIds must contain unique, non-empty ids within the supported limit."; + } + + if (command.ExpectedDetailsRevision <= 0) + { + yield return "ExpectedDetailsRevision must be greater than zero."; + } + + if (string.IsNullOrWhiteSpace(command.ActorId) || + command.ActorId.Trim().Length > Reservation.ActorIdMaxLength || + command.ActorId.Any(char.IsControl)) + { + yield return "ActorId is required, bounded, and control-character free."; + } + } + + private static bool HasMinutePrecision(TimeOnly? time) => + !time.HasValue || time.Value.Ticks % TimeSpan.TicksPerMinute == 0; +} diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Validation/GetReservationStayAmendmentQueryValidator.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Validation/GetReservationStayAmendmentQueryValidator.cs new file mode 100644 index 00000000..4ae9aab2 --- /dev/null +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Validation/GetReservationStayAmendmentQueryValidator.cs @@ -0,0 +1,26 @@ +namespace BunkFy.Modules.Reservations.Application.Validation; + +using BunkFy.Modules.Reservations.Application.Queries; +using Gma.Framework.Cqrs; + +internal sealed class GetReservationStayAmendmentQueryValidator + : IQueryValidator +{ + public IEnumerable Validate(GetReservationStayAmendmentQuery query) + { + if (query.PropertyId == Guid.Empty) + { + yield return "PropertyId is required."; + } + + if (query.ReservationId == Guid.Empty) + { + yield return "ReservationId is required."; + } + + if (query.OperationId == Guid.Empty) + { + yield return "OperationId is required."; + } + } +} diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Validation/ListReservationStayAmendmentRecoveryQueryValidator.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Validation/ListReservationStayAmendmentRecoveryQueryValidator.cs new file mode 100644 index 00000000..cc1154b2 --- /dev/null +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Validation/ListReservationStayAmendmentRecoveryQueryValidator.cs @@ -0,0 +1,35 @@ +namespace BunkFy.Modules.Reservations.Application.Validation; + +using BunkFy.Modules.Reservations.Application.Queries; +using BunkFy.Modules.Reservations.Contracts; +using Gma.Framework.Cqrs; + +internal sealed class ListReservationStayAmendmentRecoveryQueryValidator + : IQueryValidator +{ + public const int MaximumPageSize = 100; + + public IEnumerable Validate(ListReservationStayAmendmentRecoveryQuery query) + { + if (query.PropertyId == Guid.Empty) + { + yield return "PropertyId is required."; + } + + if (query.PageSize is < 1 or > MaximumPageSize) + { + yield return $"PageSize must be between 1 and {MaximumPageSize}."; + } + + if (query.Cursor is not null && + (query.Cursor.Outcome is not ( + ReservationStayAmendmentOutcome.Pending or + ReservationStayAmendmentOutcome.OutcomeUnknown) || + query.Cursor.UpdatedAtUtc == default || + query.Cursor.OperationId == Guid.Empty || + query.Cursor.ReservationId == Guid.Empty)) + { + yield return "Cursor is invalid."; + } + } +} diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Validation/ReconcileReservationStayAmendmentCommandValidator.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Validation/ReconcileReservationStayAmendmentCommandValidator.cs new file mode 100644 index 00000000..f717f087 --- /dev/null +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Validation/ReconcileReservationStayAmendmentCommandValidator.cs @@ -0,0 +1,39 @@ +namespace BunkFy.Modules.Reservations.Application.Validation; + +using BunkFy.Modules.Reservations.Application.Commands; +using BunkFy.Modules.Reservations.Domain.Aggregates; +using Gma.Framework.Cqrs; + +internal sealed class ReconcileReservationStayAmendmentCommandValidator + : ICommandValidator +{ + public IEnumerable Validate(ReconcileReservationStayAmendmentCommand command) + { + if (command.PropertyId == Guid.Empty) + { + yield return "PropertyId is required."; + } + + if (command.ReservationId == Guid.Empty) + { + yield return "ReservationId is required."; + } + + if (command.OperationId == Guid.Empty) + { + yield return "OperationId is required."; + } + + if (command.ExpectedOperationVersion <= 0) + { + yield return "ExpectedOperationVersion must be greater than zero."; + } + + if (string.IsNullOrWhiteSpace(command.ActorId) || + command.ActorId.Trim().Length > Reservation.ActorIdMaxLength || + command.ActorId.Any(char.IsControl)) + { + yield return "ActorId is required, bounded, and control-character free."; + } + } +} diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Contracts/Api/ReservationStayAmendmentOutcome.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Contracts/Api/ReservationStayAmendmentOutcome.cs new file mode 100644 index 00000000..c0bfd1c9 --- /dev/null +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Contracts/Api/ReservationStayAmendmentOutcome.cs @@ -0,0 +1,10 @@ +namespace BunkFy.Modules.Reservations.Contracts; + +public enum ReservationStayAmendmentOutcome +{ + Unknown = 0, + Pending = 1, + Applied = 2, + Rejected = 3, + OutcomeUnknown = 4 +} diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Contracts/Api/ReservationStayAmendmentReceiptDto.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Contracts/Api/ReservationStayAmendmentReceiptDto.cs new file mode 100644 index 00000000..8813d0ef --- /dev/null +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Contracts/Api/ReservationStayAmendmentReceiptDto.cs @@ -0,0 +1,22 @@ +namespace BunkFy.Modules.Reservations.Contracts; + +using BunkFy.Modules.Inventory.Contracts; + +public sealed record ReservationStayAmendmentReceiptDto( + Guid OperationId, + Guid PropertyId, + Guid ReservationId, + ReservationStayAmendmentTargetDto? Target, + ReservationStayAmendmentOutcome Outcome, + long ExpectedDetailsRevision, + long OperationVersion, + DateTimeOffset RequestedAtUtc, + DateTimeOffset UpdatedAtUtc, + DateTimeOffset? CompletedAtUtc, + long? ResultingDetailsRevision, + long? ResultingReservationVersion, + InventoryAllocationRejectionReason? RejectionReason, + int ReconciliationCount, + DateTimeOffset? LastReconciledAtUtc, + bool RecoveryEligible, + DateTimeOffset? NextRecoveryEligibleAtUtc); diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Contracts/Api/ReservationStayAmendmentRecoveryCursorDto.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Contracts/Api/ReservationStayAmendmentRecoveryCursorDto.cs new file mode 100644 index 00000000..6a0a4f64 --- /dev/null +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Contracts/Api/ReservationStayAmendmentRecoveryCursorDto.cs @@ -0,0 +1,7 @@ +namespace BunkFy.Modules.Reservations.Contracts; + +public sealed record ReservationStayAmendmentRecoveryCursorDto( + ReservationStayAmendmentOutcome Outcome, + DateTimeOffset UpdatedAtUtc, + Guid OperationId, + Guid ReservationId); diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Contracts/Api/ReservationStayAmendmentRecoveryItemDto.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Contracts/Api/ReservationStayAmendmentRecoveryItemDto.cs new file mode 100644 index 00000000..66d51737 --- /dev/null +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Contracts/Api/ReservationStayAmendmentRecoveryItemDto.cs @@ -0,0 +1,12 @@ +namespace BunkFy.Modules.Reservations.Contracts; + +public sealed record ReservationStayAmendmentRecoveryItemDto( + Guid OperationId, + Guid PropertyId, + Guid ReservationId, + ReservationStayAmendmentOutcome Outcome, + long OperationVersion, + DateTimeOffset RequestedAtUtc, + DateTimeOffset UpdatedAtUtc, + bool RecoveryEligible, + DateTimeOffset? NextRecoveryEligibleAtUtc); diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Contracts/Api/ReservationStayAmendmentRecoveryPageDto.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Contracts/Api/ReservationStayAmendmentRecoveryPageDto.cs new file mode 100644 index 00000000..952f8225 --- /dev/null +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Contracts/Api/ReservationStayAmendmentRecoveryPageDto.cs @@ -0,0 +1,5 @@ +namespace BunkFy.Modules.Reservations.Contracts; + +public sealed record ReservationStayAmendmentRecoveryPageDto( + IReadOnlyCollection Operations, + ReservationStayAmendmentRecoveryCursorDto? NextCursor); diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Contracts/Api/ReservationStayAmendmentTargetDto.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Contracts/Api/ReservationStayAmendmentTargetDto.cs new file mode 100644 index 00000000..95603656 --- /dev/null +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Contracts/Api/ReservationStayAmendmentTargetDto.cs @@ -0,0 +1,8 @@ +namespace BunkFy.Modules.Reservations.Contracts; + +public sealed record ReservationStayAmendmentTargetDto( + DateOnly Arrival, + DateOnly Departure, + TimeOnly? ExpectedArrivalTime, + TimeOnly? ExpectedDepartureTime, + IReadOnlyCollection InventoryUnitIds); diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Contracts/Metadata/ReservationsTenantTerminationMetadata.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Contracts/Metadata/ReservationsTenantTerminationMetadata.cs index baaa7053..8d4e3efc 100644 --- a/src/Modules/Reservations/BunkFy.Modules.Reservations.Contracts/Metadata/ReservationsTenantTerminationMetadata.cs +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Contracts/Metadata/ReservationsTenantTerminationMetadata.cs @@ -7,14 +7,14 @@ public static class ReservationsTenantTerminationMetadata { public const string OwnerKey = ReservationsDataRightsCoordinates.Owner; public const string DependencyOwnerKey = "inventory"; - public const int CatalogVersion = 4; - public const int PersonalDataCatalogVersion = 16; + public const int CatalogVersion = 7; + public const int PersonalDataCatalogVersion = 19; public const string ExportCatalogId = "reservations.tenant-portability"; public const int ExportCatalogSchemaVersion = 1; public const string ExportSchemaId = "reservations.tenant-termination-export"; - public const int ExportSchemaVersion = 5; + public const int ExportSchemaVersion = 8; public const string ReservationRecordType = "reservation"; public const string RequestedInventoryUnitRecordType = @@ -27,6 +27,8 @@ public static class ReservationsTenantTerminationMetadata public const string ExternalOperationRecordType = "external-operation"; public const string ManagementOperationRecordType = "management-operation"; + public const string StayAmendmentOperationRecordType = + "stay-amendment-operation"; public const string ArrivalReminderRecordType = "arrival-reminder"; public const string DataRightsCorrectionReceiptRecordType = "data-rights-correction-receipt"; @@ -58,6 +60,7 @@ public static class ReservationsTenantTerminationMetadata DetailsHistoryRecordType, ExternalOperationRecordType, ManagementOperationRecordType, + StayAmendmentOperationRecordType, ArrivalReminderRecordType, DataRightsCorrectionReceiptRecordType, ProcessingRestrictionRecordType, @@ -88,6 +91,7 @@ public static class ReservationsTenantTerminationMetadata "reservations.guest-record-link-process", "reservations.details-history", "reservations.management-operation", + "reservations.stay-amendment-operation", "reservations.reminder-state", "reservations.data-rights-proof", "reservations.processing-restriction", diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Domain/Aggregates/Reservation.AllocationAmendmentRecovery.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Domain/Aggregates/Reservation.AllocationAmendmentRecovery.cs new file mode 100644 index 00000000..51ab6ef6 --- /dev/null +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Domain/Aggregates/Reservation.AllocationAmendmentRecovery.cs @@ -0,0 +1,78 @@ +namespace BunkFy.Modules.Reservations.Domain.Aggregates; + +using BunkFy.Modules.Reservations.Domain.Errors; +using BunkFy.Modules.Reservations.Domain.Events; +using Gma.Framework.Results; + +public sealed partial class Reservation +{ + public Result AdvanceStayAmendmentEvidenceCoordinate(DateTimeOffset nowUtc) + { + if (nowUtc == default || nowUtc < this.UpdatedAtUtc) + { + return Result.Failure(ReservationsDomainErrors.AllocationAmendmentInvalid); + } + + this.Version++; + this.UpdatedAtUtc = nowUtc; + return Result.Success(); + } + + public Result RepublishPendingAllocationAmendment( + Guid amendmentRequestId, + string requestFingerprint, + DateOnly arrival, + DateOnly departure, + TimeOnly? expectedArrivalTime, + TimeOnly? expectedDepartureTime, + IReadOnlyCollection inventoryUnitIds, + Guid eventId, + DateTimeOffset nowUtc) + { + ArgumentNullException.ThrowIfNull(inventoryUnitIds); + Guid[] units = inventoryUnitIds.ToArray(); + if (amendmentRequestId == Guid.Empty || eventId == Guid.Empty || nowUtc == default || + nowUtc < this.UpdatedAtUtc || + this.Status != ReservationState.Confirmed || this.AllocationId is null || this.AllocationVersion is null || + this.PendingAllocationAmendmentId != amendmentRequestId || + !this.PendingInventoryAmendmentRequestId.HasValue || + this.PendingDetailsChangeOrigin != ReservationDetailsChangeOrigin.Staff || + !string.Equals( + this.PendingAllocationAmendmentRequestFingerprint, + requestFingerprint, + StringComparison.Ordinal) || + this.PendingArrival != arrival || this.PendingDeparture != departure || + this.PendingExpectedArrivalTime != expectedArrivalTime || + this.PendingExpectedDepartureTime != expectedDepartureTime || + string.IsNullOrWhiteSpace(this.PendingInventoryUnitIds) || + units.Length is 0 or > MaximumRequestedUnits || units.Any(id => id == Guid.Empty) || + units.Distinct().Count() != units.Length) + { + return Result.Failure(ReservationsDomainErrors.AllocationAmendmentInvalid); + } + + Guid[] pendingUnits = this.PendingInventoryUnitIds.Split(',') + .Select(value => Guid.ParseExact(value, "N")) + .ToArray(); + if (!pendingUnits.Order().SequenceEqual(units.Order())) + { + return Result.Failure(ReservationsDomainErrors.AllocationAmendmentInvalid); + } + + this.Version++; + this.UpdatedAtUtc = nowUtc; + this.RaiseDomainEvent(new ReservationAllocationAmendmentRequestedDomainEvent( + eventId, + nowUtc, + this.ScopeId, + this.Id, + this.PropertyId, + this.PendingInventoryAmendmentRequestId.Value, + this.AllocationId.Value, + this.AllocationVersion.Value, + arrival, + departure, + units)); + return Result.Success(); + } +} diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Domain/Aggregates/Reservation.AllocationAmendments.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Domain/Aggregates/Reservation.AllocationAmendments.cs index d6506548..0a5c527d 100644 --- a/src/Modules/Reservations/BunkFy.Modules.Reservations.Domain/Aggregates/Reservation.AllocationAmendments.cs +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Domain/Aggregates/Reservation.AllocationAmendments.cs @@ -9,6 +9,7 @@ public sealed partial class Reservation { public Result BeginAllocationAmendment( Guid amendmentRequestId, + Guid inventoryRequestId, string requestFingerprint, DateOnly arrival, DateOnly departure, @@ -42,7 +43,9 @@ public Result BeginAllocationAmendment( } if (this.Status != ReservationState.Confirmed || this.AllocationId is null || this.AllocationVersion is null || - expectedDetailsRevision != this.DetailsRevision || amendmentRequestId == Guid.Empty || eventId == Guid.Empty || + expectedDetailsRevision != this.DetailsRevision || amendmentRequestId == Guid.Empty || + inventoryRequestId == Guid.Empty || eventId == Guid.Empty || + nowUtc == default || nowUtc < this.UpdatedAtUtc || normalizedFingerprint.Length != RequestFingerprintLength || !normalizedFingerprint.All(Uri.IsHexDigit)) { return Result.Failure( @@ -100,6 +103,7 @@ public Result BeginAllocationAmendment( } this.PendingAllocationAmendmentId = amendmentRequestId; + this.PendingInventoryAmendmentRequestId = inventoryRequestId; this.PendingAllocationAmendmentRequestFingerprint = normalizedFingerprint; this.PendingArrival = arrival; this.PendingDeparture = departure; @@ -128,7 +132,7 @@ public Result BeginAllocationAmendment( this.ScopeId, this.Id, this.PropertyId, - amendmentRequestId, + inventoryRequestId, this.AllocationId.Value, this.AllocationVersion.Value, arrival, @@ -138,7 +142,7 @@ public Result BeginAllocationAmendment( } public Result CompleteAllocationAmendment( - Guid amendmentRequestId, + Guid inventoryRequestId, Guid allocationId, DateOnly arrival, DateOnly departure, @@ -149,7 +153,7 @@ public Result CompleteAllocationAmendment( { ArgumentNullException.ThrowIfNull(inventoryUnitIds); Guid[] confirmedUnits = inventoryUnitIds.ToArray(); - if (this.PendingAllocationAmendmentId != amendmentRequestId || this.AllocationId != allocationId || + if (this.PendingInventoryAmendmentRequestId != inventoryRequestId || this.AllocationId != allocationId || allocationVersion <= 0 || detailsEventId == Guid.Empty || !this.PendingArrival.HasValue || !this.PendingDeparture.HasValue || string.IsNullOrWhiteSpace(this.PendingInventoryUnitIds) || !this.PendingGuestCount.HasValue || @@ -165,6 +169,20 @@ public Result CompleteAllocationAmendment( { return Result.Failure(ReservationsDomainErrors.AllocationCorrelationMismatch); } + + bool allocationTargetUnchanged = this.Arrival == arrival && + this.Departure == departure && + this.requestedUnits.Select(unit => unit.InventoryUnitId).Order() + .SequenceEqual(confirmedUnits.Order()); + if (!this.AllocationVersion.HasValue || + (!allocationTargetUnchanged && this.AllocationVersion == long.MaxValue) || + allocationVersion != (allocationTargetUnchanged + ? this.AllocationVersion.Value + : this.AllocationVersion.Value + 1)) + { + return Result.Failure(ReservationsDomainErrors.AllocationCorrelationMismatch); + } + ReservationDetailsSnapshot before = this.CaptureDetails(); List changedFields = []; AddChanged(changedFields, nameof(this.Arrival), this.Arrival, this.PendingArrival.Value); @@ -228,12 +246,12 @@ public Result CompleteAllocationAmendment( } public Result RejectAllocationAmendment( - Guid amendmentRequestId, + Guid inventoryRequestId, Guid allocationId, int rejectionCode, DateTimeOffset nowUtc) { - if (this.PendingAllocationAmendmentId != amendmentRequestId || this.AllocationId != allocationId) + if (this.PendingInventoryAmendmentRequestId != inventoryRequestId || this.AllocationId != allocationId) { return Result.Failure(ReservationsDomainErrors.AllocationCorrelationMismatch); } diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Domain/Aggregates/Reservation.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Domain/Aggregates/Reservation.cs index e1964494..abc06018 100644 --- a/src/Modules/Reservations/BunkFy.Modules.Reservations.Domain/Aggregates/Reservation.cs +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Domain/Aggregates/Reservation.cs @@ -94,6 +94,7 @@ private Reservation( public TimeOnly? ExpectedArrivalTime { get; private set; } public TimeOnly? ExpectedDepartureTime { get; private set; } public Guid? PendingAllocationAmendmentId { get; private set; } + public Guid? PendingInventoryAmendmentRequestId { get; private set; } public string? PendingAllocationAmendmentRequestFingerprint { get; private set; } public DateOnly? PendingArrival { get; private set; } public DateOnly? PendingDeparture { get; private set; } @@ -177,6 +178,7 @@ private void MarkTerminal(DateTimeOffset occurredAtUtc) private void ClearPendingAllocationAmendment() { this.PendingAllocationAmendmentId = null; + this.PendingInventoryAmendmentRequestId = null; this.PendingAllocationAmendmentRequestFingerprint = null; this.PendingArrival = null; this.PendingDeparture = null; diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Domain/Errors/ReservationsDomainErrors.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Domain/Errors/ReservationsDomainErrors.cs index 433c3881..0438bb94 100644 --- a/src/Modules/Reservations/BunkFy.Modules.Reservations.Domain/Errors/ReservationsDomainErrors.cs +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Domain/Errors/ReservationsDomainErrors.cs @@ -63,6 +63,12 @@ public static class ReservationsDomainErrors public static readonly Error RetentionReceiptInvalid = new("Reservations.RetentionReceiptInvalid", "The reservation retention receipt is invalid."); public static readonly Error AllocationAmendmentInProgress = new("Reservations.AllocationAmendmentInProgress", "An allocation-affecting reservation amendment is already in progress."); public static readonly Error AllocationAmendmentInvalid = new("Reservations.AllocationAmendmentInvalid", "The allocation-affecting reservation amendment is invalid."); + public static readonly Error StayAmendmentOperationIdentityInvalid = new("Reservations.StayAmendmentOperationIdentityInvalid", "The stay-amendment operation identity is invalid."); + public static readonly Error StayAmendmentOperationRequestInvalid = new("Reservations.StayAmendmentOperationRequestInvalid", "The stay-amendment operation request is invalid."); + public static readonly Error StayAmendmentOperationTransitionInvalid = new("Reservations.StayAmendmentOperationTransitionInvalid", "The stay-amendment operation cannot perform this transition."); + public static readonly Error StayAmendmentOperationVersionConflict = new("Reservations.StayAmendmentOperationVersionConflict", "The stay-amendment operation has changed."); + public static readonly Error StayAmendmentReconcileTooSoon = new("Reservations.StayAmendmentReconcileTooSoon", "The stay-amendment operation is not yet eligible for reconciliation."); + public static readonly Error StayAmendmentOutcomeMismatch = new("Reservations.StayAmendmentOutcomeMismatch", "The allocation outcome does not match the stay-amendment operation."); public static readonly Error StayBusinessDateInvalid = new("Reservations.StayBusinessDateInvalid", "The business date is not valid for this stay transition."); public static readonly Error StayProvenanceInvalid = new("Reservations.StayProvenanceInvalid", "Stay lifecycle actor provenance is invalid."); public static readonly Error ReservationGuestLinkInvalid = new("Reservations.ReservationGuestLinkInvalid", "The reservation guest link is invalid."); diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Domain/StayAmendments/ReservationStayAmendmentOperation.Reconciliation.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Domain/StayAmendments/ReservationStayAmendmentOperation.Reconciliation.cs new file mode 100644 index 00000000..bb92df14 --- /dev/null +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Domain/StayAmendments/ReservationStayAmendmentOperation.Reconciliation.cs @@ -0,0 +1,51 @@ +namespace BunkFy.Modules.Reservations.Domain.StayAmendments; + +using BunkFy.Modules.Reservations.Domain.Aggregates; +using BunkFy.Modules.Reservations.Domain.Errors; +using Gma.Framework.Results; + +public sealed partial class ReservationStayAmendmentOperation +{ + public Result Reconcile( + long expectedOperationVersion, + string actorId, + DateTimeOffset nowUtc) + { + Result valid = this.CanReconcile(expectedOperationVersion, actorId, nowUtc); + if (valid.IsFailure) + { + return valid; + } + + string normalizedActor = actorId!.Trim(); + this.ReconciliationCount++; + this.LastReconciledAtUtc = nowUtc; + this.LastReconciledBy = normalizedActor; + this.Advance(nowUtc); + return Result.Success(); + } + + public Result CanReconcile( + long expectedOperationVersion, + string actorId, + DateTimeOffset nowUtc) + { + if (expectedOperationVersion != this.OperationVersion) + { + return Result.Failure(ReservationsDomainErrors.StayAmendmentOperationVersionConflict); + } + + string normalizedActor = actorId?.Trim() ?? string.Empty; + if (this.Outcome != ReservationStayAmendmentOperationOutcome.Pending || + normalizedActor.Length is 0 or > Reservation.ActorIdMaxLength || + normalizedActor.Any(char.IsControl) || + !this.IsValidAdvance(nowUtc) || this.ReconciliationCount == int.MaxValue) + { + return Result.Failure(ReservationsDomainErrors.StayAmendmentOperationTransitionInvalid); + } + + return this.IsReconciliationEligible(nowUtc) + ? Result.Success() + : Result.Failure(ReservationsDomainErrors.StayAmendmentReconcileTooSoon); + } +} diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Domain/StayAmendments/ReservationStayAmendmentOperation.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Domain/StayAmendments/ReservationStayAmendmentOperation.cs new file mode 100644 index 00000000..57d928da --- /dev/null +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Domain/StayAmendments/ReservationStayAmendmentOperation.cs @@ -0,0 +1,367 @@ +namespace BunkFy.Modules.Reservations.Domain.StayAmendments; + +using BunkFy.Modules.Reservations.Domain.Aggregates; +using BunkFy.Modules.Reservations.Domain.Errors; +using Gma.Framework.Domain.Models; +using Gma.Framework.Naming; +using Gma.Framework.Results; + +public sealed partial class ReservationStayAmendmentOperation : ScopedAggregateRoot +{ + public const int LegacyRequestSchemaVersion = 1; + public const int CurrentRequestSchemaVersion = 2; + public const int RequestFingerprintLength = 64; + public const int TargetInventoryUnitIdsMaxLength = Reservation.MaximumRequestedUnits * 33; + public static readonly TimeSpan MinimumReconciliationInterval = TimeSpan.FromMinutes(5); + + private ReservationStayAmendmentOperation() { } + + private ReservationStayAmendmentOperation(Guid id, string scopeId) + : base(id, scopeId) + { + } + + public Guid PropertyId { get; private set; } + public Guid ReservationId { get; private set; } + public Guid? InventoryRequestId { get; private set; } + public int RequestSchemaVersion { get; private set; } + public string RequestFingerprint { get; private set; } = string.Empty; + public DateOnly? TargetArrival { get; private set; } + public DateOnly? TargetDeparture { get; private set; } + public TimeOnly? TargetExpectedArrivalTime { get; private set; } + public TimeOnly? TargetExpectedDepartureTime { get; private set; } + public string? TargetInventoryUnitIds { get; private set; } + public long ExpectedDetailsRevision { get; private set; } + public string? RequestedBy { get; private set; } + public ReservationStayAmendmentOperationOutcome Outcome { get; private set; } + public long OperationVersion { get; private set; } + public DateTimeOffset RequestedAtUtc { get; private set; } + public DateTimeOffset UpdatedAtUtc { get; private set; } + public DateTimeOffset? CompletedAtUtc { get; private set; } + public long? ResultingDetailsRevision { get; private set; } + public long? ResultingReservationVersion { get; private set; } + public long? ResultingAllocationVersion { get; private set; } + public int? RejectionCode { get; private set; } + public int ReconciliationCount { get; private set; } + public DateTimeOffset? LastReconciledAtUtc { get; private set; } + public string? LastReconciledBy { get; private set; } + + public static Result CreatePending( + Guid operationId, + string tenantId, + Guid propertyId, + Guid reservationId, + Guid inventoryRequestId, + int requestSchemaVersion, + string requestFingerprint, + DateOnly targetArrival, + DateOnly targetDeparture, + TimeOnly? targetExpectedArrivalTime, + TimeOnly? targetExpectedDepartureTime, + IReadOnlyCollection targetInventoryUnitIds, + long expectedDetailsRevision, + string actorId, + DateTimeOffset requestedAtUtc) => Create( + operationId, + tenantId, + propertyId, + reservationId, + inventoryRequestId, + requestSchemaVersion, + requestFingerprint, + targetArrival, + targetDeparture, + targetExpectedArrivalTime, + targetExpectedDepartureTime, + targetInventoryUnitIds, + expectedDetailsRevision, + actorId, + ReservationStayAmendmentOperationOutcome.Pending, + resultingDetailsRevision: null, + resultingReservationVersion: null, + resultingAllocationVersion: null, + requestedAtUtc); + + public static Result CreateAppliedNoOp( + Guid operationId, + string tenantId, + Guid propertyId, + Guid reservationId, + int requestSchemaVersion, + string requestFingerprint, + DateOnly targetArrival, + DateOnly targetDeparture, + TimeOnly? targetExpectedArrivalTime, + TimeOnly? targetExpectedDepartureTime, + IReadOnlyCollection targetInventoryUnitIds, + long expectedDetailsRevision, + string actorId, + long resultingDetailsRevision, + long resultingReservationVersion, + long resultingAllocationVersion, + DateTimeOffset requestedAtUtc) => Create( + operationId, + tenantId, + propertyId, + reservationId, + inventoryRequestId: null, + requestSchemaVersion, + requestFingerprint, + targetArrival, + targetDeparture, + targetExpectedArrivalTime, + targetExpectedDepartureTime, + targetInventoryUnitIds, + expectedDetailsRevision, + actorId, + ReservationStayAmendmentOperationOutcome.Applied, + resultingDetailsRevision, + resultingReservationVersion, + resultingAllocationVersion, + requestedAtUtc); + + public static Result CreateOutcomeUnknown( + Guid operationId, + string tenantId, + Guid propertyId, + Guid reservationId, + int requestSchemaVersion, + string requestFingerprint, + long expectedDetailsRevision, + DateTimeOffset requestedAtUtc) => Create( + operationId, + tenantId, + propertyId, + reservationId, + inventoryRequestId: null, + requestSchemaVersion, + requestFingerprint, + targetArrival: null, + targetDeparture: null, + targetExpectedArrivalTime: null, + targetExpectedDepartureTime: null, + targetInventoryUnitIds: null, + expectedDetailsRevision, + actorId: null, + ReservationStayAmendmentOperationOutcome.OutcomeUnknown, + resultingDetailsRevision: null, + resultingReservationVersion: null, + resultingAllocationVersion: null, + requestedAtUtc); + + public bool MatchesRequest(int requestSchemaVersion, string requestFingerprint) => + this.RequestSchemaVersion == requestSchemaVersion && + string.Equals( + this.RequestFingerprint, + NormalizeFingerprint(requestFingerprint), + StringComparison.Ordinal); + + public IReadOnlyCollection GetTargetInventoryUnitIds() => + (this.TargetInventoryUnitIds ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries) + .Select(value => Guid.ParseExact(value, "N")) + .ToArray(); + + public DateTimeOffset? NextReconciliationEligibleAtUtc => + this.Outcome == ReservationStayAmendmentOperationOutcome.Pending + ? this.UpdatedAtUtc + MinimumReconciliationInterval + : null; + + public bool IsReconciliationEligible(DateTimeOffset nowUtc) => + this.NextReconciliationEligibleAtUtc is DateTimeOffset eligibleAtUtc && + nowUtc >= eligibleAtUtc; + + public Result MarkApplied( + DateOnly arrival, + DateOnly departure, + TimeOnly? expectedArrivalTime, + TimeOnly? expectedDepartureTime, + IReadOnlyCollection inventoryUnitIds, + long resultingAllocationVersion, + long resultingDetailsRevision, + long resultingReservationVersion, + DateTimeOffset nowUtc) + { + if (!this.MatchesTarget( + arrival, + departure, + expectedArrivalTime, + expectedDepartureTime, + inventoryUnitIds) || + resultingAllocationVersion <= 0 || resultingDetailsRevision <= 0 || + resultingReservationVersion <= 0) + { + return Result.Failure(ReservationsDomainErrors.StayAmendmentOutcomeMismatch); + } + + if (this.Outcome == ReservationStayAmendmentOperationOutcome.Applied) + { + return this.ResultingAllocationVersion == resultingAllocationVersion && + this.ResultingDetailsRevision == resultingDetailsRevision && + this.ResultingReservationVersion == resultingReservationVersion + ? Result.Success(false) + : Result.Failure(ReservationsDomainErrors.StayAmendmentOutcomeMismatch); + } + + if (this.Outcome != ReservationStayAmendmentOperationOutcome.Pending || !this.IsValidAdvance(nowUtc)) + { + return Result.Failure(ReservationsDomainErrors.StayAmendmentOperationTransitionInvalid); + } + + this.Outcome = ReservationStayAmendmentOperationOutcome.Applied; + this.ResultingAllocationVersion = resultingAllocationVersion; + this.ResultingDetailsRevision = resultingDetailsRevision; + this.ResultingReservationVersion = resultingReservationVersion; + this.CompletedAtUtc = nowUtc; + this.Advance(nowUtc); + return Result.Success(true); + } + + public Result MarkRejected( + int rejectionCode, + long resultingDetailsRevision, + long resultingReservationVersion, + DateTimeOffset nowUtc) + { + if (rejectionCode <= 0 || resultingDetailsRevision <= 0 || resultingReservationVersion <= 0) + { + return Result.Failure(ReservationsDomainErrors.StayAmendmentOutcomeMismatch); + } + + if (this.Outcome == ReservationStayAmendmentOperationOutcome.Rejected) + { + return this.RejectionCode == rejectionCode && + this.ResultingDetailsRevision == resultingDetailsRevision && + this.ResultingReservationVersion == resultingReservationVersion + ? Result.Success(false) + : Result.Failure(ReservationsDomainErrors.StayAmendmentOutcomeMismatch); + } + + if (this.Outcome != ReservationStayAmendmentOperationOutcome.Pending || !this.IsValidAdvance(nowUtc)) + { + return Result.Failure(ReservationsDomainErrors.StayAmendmentOperationTransitionInvalid); + } + + this.Outcome = ReservationStayAmendmentOperationOutcome.Rejected; + this.RejectionCode = rejectionCode; + this.ResultingDetailsRevision = resultingDetailsRevision; + this.ResultingReservationVersion = resultingReservationVersion; + this.CompletedAtUtc = nowUtc; + this.Advance(nowUtc); + return Result.Success(true); + } + + private static Result Create( + Guid operationId, + string tenantId, + Guid propertyId, + Guid reservationId, + Guid? inventoryRequestId, + int requestSchemaVersion, + string requestFingerprint, + DateOnly? targetArrival, + DateOnly? targetDeparture, + TimeOnly? targetExpectedArrivalTime, + TimeOnly? targetExpectedDepartureTime, + IReadOnlyCollection? targetInventoryUnitIds, + long expectedDetailsRevision, + string? actorId, + ReservationStayAmendmentOperationOutcome outcome, + long? resultingDetailsRevision, + long? resultingReservationVersion, + long? resultingAllocationVersion, + DateTimeOffset requestedAtUtc) + { + if (operationId == Guid.Empty || propertyId == Guid.Empty || reservationId == Guid.Empty || + !TenantIds.TryNormalize(tenantId, out string? scopeId)) + { + return Result.Failure( + ReservationsDomainErrors.StayAmendmentOperationIdentityInvalid); + } + + string fingerprint = NormalizeFingerprint(requestFingerprint); + string normalizedActor = actorId?.Trim() ?? string.Empty; + Guid[] units = targetInventoryUnitIds?.ToArray() ?? []; + bool targetUnknown = outcome == ReservationStayAmendmentOperationOutcome.OutcomeUnknown; + if (requestSchemaVersion is not (LegacyRequestSchemaVersion or CurrentRequestSchemaVersion) || + (targetUnknown && requestSchemaVersion != LegacyRequestSchemaVersion) || + (outcome == ReservationStayAmendmentOperationOutcome.Applied && + requestSchemaVersion != CurrentRequestSchemaVersion) || + outcome == ReservationStayAmendmentOperationOutcome.Pending != + (inventoryRequestId.HasValue && inventoryRequestId != Guid.Empty) || + fingerprint.Length != RequestFingerprintLength || !fingerprint.All(Uri.IsHexDigit) || + (!targetUnknown && (!targetArrival.HasValue || !targetDeparture.HasValue || + targetArrival.Value >= targetDeparture.Value || + !HasMinutePrecision(targetExpectedArrivalTime) || !HasMinutePrecision(targetExpectedDepartureTime) || + units.Length is 0 or > Reservation.MaximumRequestedUnits || units.Any(id => id == Guid.Empty) || + units.Distinct().Count() != units.Length)) || + (targetUnknown && (targetArrival.HasValue || targetDeparture.HasValue || + targetExpectedArrivalTime.HasValue || targetExpectedDepartureTime.HasValue || units.Length != 0)) || + expectedDetailsRevision <= 0 || + (!targetUnknown && (normalizedActor.Length is 0 or > Reservation.ActorIdMaxLength || + normalizedActor.Any(char.IsControl))) || + (targetUnknown && normalizedActor.Length != 0) || requestedAtUtc == default || + outcome is ReservationStayAmendmentOperationOutcome.Unknown or ReservationStayAmendmentOperationOutcome.Rejected || + outcome == ReservationStayAmendmentOperationOutcome.Applied != resultingDetailsRevision.HasValue || + outcome == ReservationStayAmendmentOperationOutcome.Applied != resultingReservationVersion.HasValue || + outcome == ReservationStayAmendmentOperationOutcome.Applied != resultingAllocationVersion.HasValue || + resultingDetailsRevision <= 0 || resultingReservationVersion <= 0 || + resultingAllocationVersion <= 0) + { + return Result.Failure( + ReservationsDomainErrors.StayAmendmentOperationRequestInvalid); + } + + return Result.Success(new ReservationStayAmendmentOperation(operationId, scopeId) + { + PropertyId = propertyId, + ReservationId = reservationId, + InventoryRequestId = inventoryRequestId, + RequestSchemaVersion = requestSchemaVersion, + RequestFingerprint = fingerprint, + TargetArrival = targetArrival, + TargetDeparture = targetDeparture, + TargetExpectedArrivalTime = targetExpectedArrivalTime, + TargetExpectedDepartureTime = targetExpectedDepartureTime, + TargetInventoryUnitIds = targetUnknown + ? null + : string.Join(',', units.Order().Select(id => id.ToString("N"))), + ExpectedDetailsRevision = expectedDetailsRevision, + RequestedBy = targetUnknown ? null : normalizedActor, + Outcome = outcome, + OperationVersion = 1, + RequestedAtUtc = requestedAtUtc, + UpdatedAtUtc = requestedAtUtc, + CompletedAtUtc = outcome == ReservationStayAmendmentOperationOutcome.Applied ? requestedAtUtc : null, + ResultingDetailsRevision = resultingDetailsRevision, + ResultingReservationVersion = resultingReservationVersion, + ResultingAllocationVersion = resultingAllocationVersion + }); + } + + private bool MatchesTarget( + DateOnly arrival, + DateOnly departure, + TimeOnly? expectedArrivalTime, + TimeOnly? expectedDepartureTime, + IReadOnlyCollection inventoryUnitIds) => + this.TargetArrival == arrival && + this.TargetDeparture == departure && + this.TargetExpectedArrivalTime == expectedArrivalTime && + this.TargetExpectedDepartureTime == expectedDepartureTime && + this.GetTargetInventoryUnitIds().Order().SequenceEqual(inventoryUnitIds.Order()); + + private bool IsValidAdvance(DateTimeOffset nowUtc) => + nowUtc != default && nowUtc >= this.UpdatedAtUtc; + + private void Advance(DateTimeOffset nowUtc) + { + this.OperationVersion++; + this.UpdatedAtUtc = nowUtc; + } + + private static bool HasMinutePrecision(TimeOnly? time) => + !time.HasValue || time.Value.Ticks % TimeSpan.TicksPerMinute == 0; + + private static string NormalizeFingerprint(string? fingerprint) => + fingerprint?.Trim().ToLowerInvariant() ?? string.Empty; +} diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Domain/StayAmendments/ReservationStayAmendmentOperationOutcome.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Domain/StayAmendments/ReservationStayAmendmentOperationOutcome.cs new file mode 100644 index 00000000..55e998aa --- /dev/null +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Domain/StayAmendments/ReservationStayAmendmentOperationOutcome.cs @@ -0,0 +1,10 @@ +namespace BunkFy.Modules.Reservations.Domain.StayAmendments; + +public enum ReservationStayAmendmentOperationOutcome +{ + Unknown = 0, + Pending = 1, + Applied = 2, + Rejected = 3, + OutcomeUnknown = 4 +} diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence.PostgreSqlMigrations/Migrations/20260812011925_AddReservationStayAmendmentConvergence.Designer.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence.PostgreSqlMigrations/Migrations/20260812011925_AddReservationStayAmendmentConvergence.Designer.cs new file mode 100644 index 00000000..8197216c --- /dev/null +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence.PostgreSqlMigrations/Migrations/20260812011925_AddReservationStayAmendmentConvergence.Designer.cs @@ -0,0 +1,2621 @@ +// +using System; +using BunkFy.Modules.Reservations.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace BunkFy.Modules.Reservations.Persistence.PostgreSqlMigrations.Migrations +{ + [DbContext(typeof(ReservationsDbContext))] + [Migration("20260812011925_AddReservationStayAmendmentConvergence")] + partial class AddReservationStayAmendmentConvergence + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("reservations") + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("BunkFy.Modules.Reservations.Domain.Aggregates.Reservation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllocationId") + .HasColumnType("uuid"); + + b.Property("AllocationRejection") + .HasColumnType("integer"); + + b.Property("AllocationRequestId") + .HasColumnType("uuid"); + + b.Property("AllocationVersion") + .HasColumnType("bigint"); + + b.Property("AnonymisedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Arrival") + .HasColumnType("date"); + + b.Property("CheckedInAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CheckedInBusinessDate") + .HasColumnType("date"); + + b.Property("CheckedInBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CheckedOutAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CheckedOutBusinessDate") + .HasColumnType("date"); + + b.Property("CheckedOutBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Departure") + .HasColumnType("date"); + + b.Property("DetailsRevision") + .HasColumnType("bigint"); + + b.Property("Email") + .HasMaxLength(320) + .HasColumnType("character varying(320)"); + + b.Property("EmailSearch") + .HasMaxLength(320) + .HasColumnType("character varying(320)"); + + b.Property("ExpectedArrivalTime") + .HasColumnType("time(0) without time zone"); + + b.Property("ExpectedDepartureTime") + .HasColumnType("time(0) without time zone"); + + b.Property("GuestCount") + .HasColumnType("integer"); + + b.Property("IsAnonymised") + .HasColumnType("boolean"); + + b.Property("LastAllocationAmendmentRejectionCode") + .HasColumnType("integer"); + + b.Property("LastDetailsActorId") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("LastDetailsAdapterConnectionId") + .HasColumnType("uuid"); + + b.Property("LastDetailsChangeOrigin") + .HasColumnType("integer"); + + b.Property("LastDetailsChangedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LastDetailsExternalOperationId") + .HasColumnType("uuid"); + + b.Property("LastReleaseRejectionCode") + .HasColumnType("integer"); + + b.Property("NoShowAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("NoShowBusinessDate") + .HasColumnType("date"); + + b.Property("NoShowBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Notes") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("PendingAllocationAmendmentId") + .HasColumnType("uuid"); + + b.Property("PendingAllocationAmendmentRequestFingerprint") + .HasMaxLength(64) + .HasColumnType("character(64)") + .IsFixedLength(); + + b.Property("PendingArrival") + .HasColumnType("date"); + + b.Property("PendingCancellationActorId") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PendingDeparture") + .HasColumnType("date"); + + b.Property("PendingDetailsActorId") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PendingDetailsAdapterConnectionId") + .HasColumnType("uuid"); + + b.Property("PendingDetailsChangeOrigin") + .HasColumnType("integer"); + + b.Property("PendingDetailsCorrelationId") + .HasColumnType("uuid"); + + b.Property("PendingDetailsExternalOperationId") + .HasColumnType("uuid"); + + b.Property("PendingEmail") + .HasMaxLength(320) + .HasColumnType("character varying(320)"); + + b.Property("PendingEmailSearch") + .HasMaxLength(320) + .HasColumnType("character varying(320)"); + + b.Property("PendingExpectedArrivalTime") + .HasColumnType("time(0) without time zone"); + + b.Property("PendingExpectedDepartureTime") + .HasColumnType("time(0) without time zone"); + + b.Property("PendingGuestCount") + .HasColumnType("integer"); + + b.Property("PendingInventoryAmendmentRequestId") + .HasColumnType("uuid"); + + b.Property("PendingInventoryUnitIds") + .HasMaxLength(3300) + .HasColumnType("character varying(3300)"); + + b.Property("PendingNotes") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("PendingPhone") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PendingPhoneSearch") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PendingPrimaryGuestName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PendingPrimaryGuestNameSearch") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PendingStayActorId") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PendingStayBusinessDate") + .HasColumnType("date"); + + b.Property("Phone") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PhoneSearch") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PrimaryGuestName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PrimaryGuestNameSearch") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ProjectionOrdinal") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ProjectionOrdinal")); + + b.Property("PropertyId") + .HasColumnType("uuid"); + + b.Property("ReleaseRequestId") + .HasColumnType("uuid"); + + b.Property("ScopeId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Source") + .HasColumnType("integer"); + + b.Property("SourceReference") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SourceSystem") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TerminalAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("PendingInventoryAmendmentRequestId") + .IsUnique(); + + b.HasIndex("ProjectionOrdinal") + .IsUnique(); + + b.HasIndex("ScopeId", "AllocationRequestId") + .IsUnique(); + + b.HasIndex("ScopeId", "SourceSystem", "SourceReference") + .IsUnique(); + + b.HasIndex("ScopeId", "ProjectionOrdinal", "TerminalAtUtc", "IsAnonymised"); + + b.HasIndex("ScopeId", "PropertyId", "EmailSearch", "Id"); + + b.HasIndex("ScopeId", "PropertyId", "PendingEmailSearch", "Id"); + + b.HasIndex("ScopeId", "PropertyId", "PendingPhoneSearch", "Id"); + + b.HasIndex("ScopeId", "PropertyId", "PhoneSearch", "Id"); + + b.HasIndex("ScopeId", "PropertyId", "Status", "Arrival", "Departure"); + + b.ToTable("reservations", "reservations", t => + { + t.HasCheckConstraint("CK_reservations_anonymisation_state", "(\"IsAnonymised\" = TRUE AND \"AnonymisedAtUtc\" IS NOT NULL) OR (\"IsAnonymised\" = FALSE AND \"AnonymisedAtUtc\" IS NULL)"); + + t.HasCheckConstraint("CK_reservations_checked_in_complete", "(\"Status\" IN (6, 9, 10) AND \"CheckedInBusinessDate\" IS NOT NULL AND \"CheckedInAtUtc\" IS NOT NULL AND \"CheckedInBy\" IS NOT NULL AND length(trim(\"CheckedInBy\")) > 0) OR (\"Status\" NOT IN (6, 9, 10) AND \"CheckedInBusinessDate\" IS NULL AND \"CheckedInAtUtc\" IS NULL AND \"CheckedInBy\" IS NULL)"); + + t.HasCheckConstraint("CK_reservations_checked_out_complete", "(\"Status\" = 10 AND \"CheckedOutBusinessDate\" IS NOT NULL AND \"CheckedOutAtUtc\" IS NOT NULL AND \"CheckedOutBy\" IS NOT NULL AND length(trim(\"CheckedOutBy\")) > 0) OR (\"Status\" <> 10 AND \"CheckedOutBusinessDate\" IS NULL AND \"CheckedOutAtUtc\" IS NULL AND \"CheckedOutBy\" IS NULL)"); + + t.HasCheckConstraint("CK_reservations_no_show_complete", "(\"Status\" = 8 AND \"NoShowBusinessDate\" IS NOT NULL AND \"NoShowAtUtc\" IS NOT NULL AND \"NoShowBy\" IS NOT NULL AND length(trim(\"NoShowBy\")) > 0) OR (\"Status\" <> 8 AND \"NoShowBusinessDate\" IS NULL AND \"NoShowAtUtc\" IS NULL AND \"NoShowBy\" IS NULL)"); + + t.HasCheckConstraint("CK_reservations_pending_inventory_request", "(\"PendingAllocationAmendmentId\" IS NULL AND \"PendingInventoryAmendmentRequestId\" IS NULL) OR (\"PendingAllocationAmendmentId\" IS NOT NULL AND \"PendingInventoryAmendmentRequestId\" IS NOT NULL)"); + + t.HasCheckConstraint("CK_reservations_pending_stay_complete", "(\"Status\" IN (7, 9) AND \"PendingStayBusinessDate\" IS NOT NULL AND \"PendingStayActorId\" IS NOT NULL AND length(trim(\"PendingStayActorId\")) > 0 AND \"ReleaseRequestId\" IS NOT NULL) OR (\"Status\" NOT IN (7, 9) AND \"PendingStayBusinessDate\" IS NULL AND \"PendingStayActorId\" IS NULL)"); + + t.HasCheckConstraint("CK_reservations_terminal_time", "(\"Status\" IN (3, 5, 8, 10) AND \"TerminalAtUtc\" IS NOT NULL) OR (\"Status\" NOT IN (3, 5, 8, 10) AND \"TerminalAtUtc\" IS NULL)"); + }); + }); + + modelBuilder.Entity("BunkFy.Modules.Reservations.Domain.DataRights.ReservationAnonymisationReceipt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActorId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ApprovalEvidenceSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character(64)") + .IsFixedLength(); + + b.Property("ApprovalRevision") + .HasColumnType("bigint"); + + b.Property("CanonicalSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character(64)") + .IsFixedLength(); + + b.Property("CaseId") + .HasColumnType("uuid"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ContractVersion") + .HasColumnType("integer"); + + b.Property("Disposition") + .HasColumnType("integer"); + + b.Property("EventId") + .HasColumnType("uuid"); + + b.Property("IdempotencyKey") + .HasColumnType("uuid"); + + b.Property("OperationRevision") + .HasColumnType("bigint"); + + b.Property("PolicyEvidenceSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character(64)") + .IsFixedLength(); + + b.Property("PropertyId") + .HasColumnType("uuid"); + + b.Property("Reason") + .HasColumnType("integer"); + + b.Property("RedactedHistoryCount") + .HasColumnType("integer"); + + b.Property("ReducedExternalOperationCount") + .HasColumnType("integer"); + + b.Property("RemovedGuestLinkCount") + .HasColumnType("integer"); + + b.Property("ReservationId") + .HasColumnType("uuid"); + + b.Property("ResultingDetailsRevision") + .HasColumnType("bigint"); + + b.Property("ResultingReservationVersion") + .HasColumnType("bigint"); + + b.Property("ScopeId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("SelectedDetailsRevision") + .HasColumnType("bigint"); + + b.Property("SelectedReservationVersion") + .HasColumnType("bigint"); + + b.Property("SuppressedReminderCount") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasAlternateKey("ScopeId", "CanonicalSha256"); + + b.HasAlternateKey("ScopeId", "Id"); + + b.HasIndex("ScopeId", "IdempotencyKey") + .IsUnique(); + + b.HasIndex("ScopeId", "ReservationId"); + + b.HasIndex("ScopeId", "PropertyId", "ReservationId", "ResultingReservationVersion") + .IsUnique(); + + b.HasIndex("ScopeId", "CaseId", "ApprovalRevision", "OperationRevision", "ReservationId") + .IsUnique(); + + b.ToTable("reservation_anonymisation_receipts", "reservations", t => + { + t.HasCheckConstraint("CK_reservation_anonymisation_receipts_actor", "length(trim(\"ActorId\")) > 0"); + + t.HasCheckConstraint("CK_reservation_anonymisation_receipts_contract", "\"ContractVersion\" = 1"); + + t.HasCheckConstraint("CK_reservation_anonymisation_receipts_counts", "\"RedactedHistoryCount\" >= 1 AND \"RemovedGuestLinkCount\" >= 0 AND \"ReducedExternalOperationCount\" >= 0 AND \"SuppressedReminderCount\" >= 0"); + + t.HasCheckConstraint("CK_reservation_anonymisation_receipts_digests", "char_length(\"ApprovalEvidenceSha256\") = 64 AND char_length(\"PolicyEvidenceSha256\") = 64 AND char_length(\"CanonicalSha256\") = 64"); + + t.HasCheckConstraint("CK_reservation_anonymisation_receipts_outcome", "\"Disposition\" = 1 AND \"Reason\" = 1"); + + t.HasCheckConstraint("CK_reservation_anonymisation_receipts_revisions", "\"ApprovalRevision\" >= 1 AND \"OperationRevision\" > \"ApprovalRevision\""); + + t.HasCheckConstraint("CK_reservation_anonymisation_receipts_versions", "\"SelectedReservationVersion\" >= 1 AND \"ResultingReservationVersion\" = \"SelectedReservationVersion\" + 1 AND \"SelectedDetailsRevision\" >= 1 AND \"ResultingDetailsRevision\" = \"SelectedDetailsRevision\" + 1"); + }); + }); + + modelBuilder.Entity("BunkFy.Modules.Reservations.Domain.DataRights.ReservationAnonymisationRestoreReceipt", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CanonicalSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character(64)") + .IsFixedLength(); + + b.Property("ContractVersion") + .HasColumnType("integer"); + + b.Property("LedgerEntryId") + .HasColumnType("uuid"); + + b.Property("LedgerEntrySha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character(64)") + .IsFixedLength(); + + b.Property("OriginallyCompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("OwnerReceiptContractVersion") + .HasColumnType("integer"); + + b.Property("OwnerReceiptId") + .HasColumnType("uuid"); + + b.Property("OwnerReceiptSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character(64)") + .IsFixedLength(); + + b.Property("PropertyId") + .HasColumnType("uuid"); + + b.Property("ReplayedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ReservationId") + .HasColumnType("uuid"); + + b.Property("ResultingDetailsRevision") + .HasColumnType("bigint"); + + b.Property("ResultingReservationVersion") + .HasColumnType("bigint"); + + b.Property("ScopeId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TenantSequence") + .HasColumnType("bigint"); + + b.Property("TombstoneRevision") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasAlternateKey("ScopeId", "Id"); + + b.HasIndex("ScopeId", "ReservationId"); + + b.HasIndex("ScopeId", "PropertyId", "ReservationId", "LedgerEntryId") + .IsUnique(); + + b.ToTable("reservation_anonymisation_restore_receipts", "reservations", t => + { + t.HasCheckConstraint("CK_reservation_anonymisation_restore_receipts_contract", "\"ContractVersion\" = 1"); + + t.HasCheckConstraint("CK_reservation_anonymisation_restore_receipts_coordinates", "\"TenantSequence\" >= 1 AND \"OwnerReceiptContractVersion\" >= 1 AND \"ResultingReservationVersion\" >= 1 AND \"TombstoneRevision\" >= 1"); + + t.HasCheckConstraint("CK_reservation_anonymisation_restore_receipts_details", "\"ResultingDetailsRevision\" IS NULL OR \"ResultingDetailsRevision\" >= 1"); + + t.HasCheckConstraint("CK_reservation_anonymisation_restore_receipts_digests", "char_length(\"LedgerEntrySha256\") = 64 AND char_length(\"OwnerReceiptSha256\") = 64 AND char_length(\"CanonicalSha256\") = 64"); + + t.HasCheckConstraint("CK_reservation_anonymisation_restore_receipts_identity", "\"LedgerEntryId\" = \"Id\""); + + t.HasCheckConstraint("CK_reservation_anonymisation_restore_receipts_times", "\"ReplayedAtUtc\" >= \"OriginallyCompletedAtUtc\""); + }); + }); + + modelBuilder.Entity("BunkFy.Modules.Reservations.Domain.DataRights.ReservationAnonymisationTombstone", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Authority") + .HasColumnType("integer"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ContractVersion") + .HasColumnType("integer"); + + b.Property("LastReplayedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LedgerEntryId") + .HasColumnType("uuid"); + + b.Property("OwnerReceiptContractVersion") + .HasColumnType("integer"); + + b.Property("OwnerReceiptId") + .HasColumnType("uuid"); + + b.Property("OwnerReceiptSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character(64)") + .IsFixedLength(); + + b.Property("PropertyId") + .HasColumnType("uuid"); + + b.Property("ResultingDetailsRevision") + .HasColumnType("bigint"); + + b.Property("ResultingReservationVersion") + .HasColumnType("bigint"); + + b.Property("Revision") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.Property("ScopeId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ScopeId", "LedgerEntryId") + .IsUnique(); + + b.HasIndex("ScopeId", "PropertyId", "CompletedAtUtc", "Id"); + + b.ToTable("reservation_anonymisation_tombstones", "reservations", t => + { + t.HasCheckConstraint("CK_reservation_anonymisation_tombstones_authority", "\"Authority\" IN (1, 2)"); + + t.HasCheckConstraint("CK_reservation_anonymisation_tombstones_contract", "\"ContractVersion\" = 2"); + + t.HasCheckConstraint("CK_reservation_anonymisation_tombstones_receipt", "\"OwnerReceiptContractVersion\" >= 1 AND char_length(\"OwnerReceiptSha256\") = 64"); + + t.HasCheckConstraint("CK_reservation_anonymisation_tombstones_replay", "(\"LedgerEntryId\" IS NULL AND \"LastReplayedAtUtc\" IS NULL) OR (\"LedgerEntryId\" IS NOT NULL AND \"LastReplayedAtUtc\" IS NOT NULL AND \"LastReplayedAtUtc\" >= \"CompletedAtUtc\")"); + + t.HasCheckConstraint("CK_reservation_anonymisation_tombstones_revision", "\"Revision\" >= 1"); + + t.HasCheckConstraint("CK_reservation_anonymisation_tombstones_versions", "\"ResultingReservationVersion\" >= 1 AND (\"ResultingDetailsRevision\" IS NULL OR \"ResultingDetailsRevision\" >= 1)"); + }); + }); + + modelBuilder.Entity("BunkFy.Modules.Reservations.Domain.DataRights.ReservationDataHold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("PlacedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PlacedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PropertyId") + .HasColumnType("uuid"); + + b.Property("ReasonCode") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ReleasedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ReleasedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ReservationId") + .HasColumnType("uuid"); + + b.Property("ScopeId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("State") + .HasColumnType("integer"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("ScopeId", "PropertyId", "ReservationId", "PlacedAtUtc", "Id"); + + b.HasIndex("ScopeId", "ReservationId", "State", "PropertyId", "Id"); + + b.HasIndex("ScopeId", "PropertyId", "ReservationId", "State", "PlacedAtUtc", "Id"); + + b.ToTable("reservation_data_holds", "reservations", t => + { + t.HasCheckConstraint("CK_reservation_data_holds_lifecycle", "(\"State\" = 1 AND \"ReleasedBy\" IS NULL AND \"ReleasedAtUtc\" IS NULL AND \"Version\" = 1) OR (\"State\" = 2 AND \"ReleasedBy\" IS NOT NULL AND \"ReleasedAtUtc\" IS NOT NULL AND \"ReleasedAtUtc\" >= \"PlacedAtUtc\" AND \"Version\" = 2)"); + + t.HasCheckConstraint("CK_reservation_data_holds_version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("BunkFy.Modules.Reservations.Domain.DataRights.ReservationDataHoldReceipt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Action") + .HasColumnType("integer"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("HoldId") + .HasColumnType("uuid"); + + b.Property("IdempotencyKey") + .HasColumnType("uuid"); + + b.Property("PropertyId") + .HasColumnType("uuid"); + + b.Property("ReasonCode") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ReservationId") + .HasColumnType("uuid"); + + b.Property("ResultingHoldVersion") + .HasColumnType("bigint"); + + b.Property("ScopeId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("SelectedDetailsRevision") + .HasColumnType("bigint"); + + b.Property("SelectedReservationVersion") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasAlternateKey("ScopeId", "Id"); + + b.HasIndex("ScopeId", "IdempotencyKey") + .IsUnique(); + + b.HasIndex("ScopeId", "HoldId", "Action") + .IsUnique(); + + b.HasIndex("ScopeId", "PropertyId", "ReservationId", "HoldId", "CompletedAtUtc"); + + b.ToTable("reservation_data_hold_receipts", "reservations", t => + { + t.HasCheckConstraint("CK_reservation_data_hold_receipts_versions", "\"SelectedReservationVersion\" >= 1 AND \"SelectedDetailsRevision\" >= 1 AND ((\"Action\" = 1 AND \"ResultingHoldVersion\" = 1) OR (\"Action\" = 2 AND \"ResultingHoldVersion\" = 2))"); + }); + }); + + modelBuilder.Entity("BunkFy.Modules.Reservations.Domain.DataRights.ReservationDataRightsCorrectionReceipt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovalRevision") + .HasColumnType("bigint"); + + b.Property("CaseId") + .HasColumnType("uuid"); + + b.Property("ChangedFieldsMask") + .HasColumnType("integer"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CompletionEventId") + .HasColumnType("uuid"); + + b.Property("ContractVersion") + .HasColumnType("integer"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("CurrentDetailsRevision") + .HasColumnType("bigint"); + + b.Property("CurrentRecordVersion") + .HasColumnType("bigint"); + + b.Property("DetailsChangeEventId") + .HasColumnType("uuid"); + + b.Property("EventId") + .HasColumnType("uuid"); + + b.Property("IdempotencyKey") + .HasColumnType("uuid"); + + b.Property("PropertyId") + .HasColumnType("uuid"); + + b.Property("ReservationId") + .HasColumnType("uuid"); + + b.Property("ScopeId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("SelectedDetailsRevision") + .HasColumnType("bigint"); + + b.Property("SelectedRecordVersion") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasAlternateKey("ScopeId", "Id"); + + b.HasIndex("ScopeId", "CompletionEventId") + .IsUnique(); + + b.HasIndex("ScopeId", "DetailsChangeEventId") + .IsUnique(); + + b.HasIndex("ScopeId", "EventId") + .IsUnique(); + + b.HasIndex("ScopeId", "IdempotencyKey") + .IsUnique(); + + b.HasIndex("ScopeId", "ReservationId", "CurrentRecordVersion"); + + b.HasIndex("ScopeId", "PropertyId", "CaseId", "ApprovalRevision"); + + b.ToTable("reservation_data_rights_correction_receipts", "reservations", t => + { + t.HasCheckConstraint("CK_reservation_data_rights_correction_receipts_approval", "\"ApprovalRevision\" >= 1"); + + t.HasCheckConstraint("CK_reservation_data_rights_correction_receipts_contract", "\"ContractVersion\" = 1"); + + t.HasCheckConstraint("CK_reservation_data_rights_correction_receipts_fields", "\"ChangedFieldsMask\" BETWEEN 1 AND 127"); + + t.HasCheckConstraint("CK_reservation_data_rights_correction_receipts_versions", "\"SelectedRecordVersion\" >= 1 AND \"CurrentRecordVersion\" = \"SelectedRecordVersion\" + 1 AND \"SelectedDetailsRevision\" >= 0 AND \"CurrentDetailsRevision\" = \"SelectedDetailsRevision\" + 1"); + }); + }); + + modelBuilder.Entity("BunkFy.Modules.Reservations.Domain.DataRights.ReservationProcessingRestriction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppliedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AppliedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ApplyApprovalRevision") + .HasColumnType("bigint"); + + b.Property("ApplyCaseId") + .HasColumnType("uuid"); + + b.Property("ApplySelectedReservationVersion") + .HasColumnType("bigint"); + + b.Property("PropertyId") + .HasColumnType("uuid"); + + b.Property("ReleaseApprovalRevision") + .HasColumnType("bigint"); + + b.Property("ReleaseCaseId") + .HasColumnType("uuid"); + + b.Property("ReleaseSelectedReservationVersion") + .HasColumnType("bigint"); + + b.Property("ReleasedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ReleasedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ReservationId") + .HasColumnType("uuid"); + + b.Property("ScopeId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasAlternateKey("ScopeId", "Id"); + + b.HasIndex("ScopeId", "PropertyId", "ReservationId", "ApplyCaseId", "ApplyApprovalRevision") + .IsUnique(); + + b.HasIndex("ScopeId", "PropertyId", "ReservationId", "ReleaseCaseId", "ReleaseApprovalRevision") + .IsUnique() + .HasDatabaseName("IX_reservation_processing_restrictions_ScopeId_PropertyId_Res~1"); + + b.HasIndex("ScopeId", "PropertyId", "ReservationId", "Status", "AppliedAtUtc") + .HasDatabaseName("IX_reservation_processing_restrictions_ScopeId_PropertyId_Res~2"); + + b.ToTable("reservation_processing_restrictions", "reservations", t => + { + t.HasCheckConstraint("CK_reservation_processing_restrictions_apply_approval", "\"ApplyApprovalRevision\" >= 1 AND \"ApplySelectedReservationVersion\" >= 1"); + + t.HasCheckConstraint("CK_reservation_processing_restrictions_lifecycle", "(\"Status\" = 1 AND \"ReleaseCaseId\" IS NULL AND \"ReleaseApprovalRevision\" IS NULL AND \"ReleaseSelectedReservationVersion\" IS NULL AND \"ReleasedBy\" IS NULL AND \"ReleasedAtUtc\" IS NULL AND \"Version\" = 1) OR (\"Status\" = 2 AND \"ReleaseCaseId\" IS NOT NULL AND \"ReleaseApprovalRevision\" >= 1 AND \"ReleaseSelectedReservationVersion\" >= 1 AND \"ReleasedBy\" IS NOT NULL AND \"ReleasedAtUtc\" IS NOT NULL AND \"ReleasedAtUtc\" >= \"AppliedAtUtc\" AND \"Version\" >= 2)"); + }); + }); + + modelBuilder.Entity("BunkFy.Modules.Reservations.Domain.DataRights.ReservationProcessingRestrictionProjection", b => + { + b.Property("ScopeId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("PropertyId") + .HasColumnType("uuid"); + + b.Property("ReservationId") + .HasColumnType("uuid"); + + b.Property("ActiveRestrictionCount") + .HasColumnType("integer"); + + b.Property("ContractVersion") + .HasColumnType("integer"); + + b.Property("IsRestricted") + .HasColumnType("boolean"); + + b.Property("LastTransitionAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ProjectionOrdinal") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ProjectionOrdinal")); + + b.Property("Revision") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("ScopeId", "PropertyId", "ReservationId"); + + b.HasIndex("ProjectionOrdinal") + .IsUnique(); + + b.HasIndex("ScopeId", "PropertyId", "IsRestricted", "ReservationId"); + + b.ToTable("reservation_processing_restriction_state", "reservations", t => + { + t.HasCheckConstraint("CK_reservation_processing_restriction_state_contract", "\"ContractVersion\" >= 1"); + + t.HasCheckConstraint("CK_reservation_processing_restriction_state_effective", "(\"ActiveRestrictionCount\" = 0 AND NOT \"IsRestricted\") OR (\"ActiveRestrictionCount\" > 0 AND \"IsRestricted\")"); + + t.HasCheckConstraint("CK_reservation_processing_restriction_state_revision", "\"Revision\" >= 0 AND \"ActiveRestrictionCount\" >= 0 AND \"ActiveRestrictionCount\" <= \"Revision\""); + }); + }); + + modelBuilder.Entity("BunkFy.Modules.Reservations.Domain.DataRights.ReservationProcessingRestrictionReceipt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Action") + .HasColumnType("integer"); + + b.Property("ApprovalRevision") + .HasColumnType("bigint"); + + b.Property("CaseId") + .HasColumnType("uuid"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ContractVersion") + .HasColumnType("integer"); + + b.Property("EffectiveRestricted") + .HasColumnType("boolean"); + + b.Property("EventId") + .HasColumnType("uuid"); + + b.Property("IdempotencyKey") + .HasColumnType("uuid"); + + b.Property("PropertyId") + .HasColumnType("uuid"); + + b.Property("ReservationId") + .HasColumnType("uuid"); + + b.Property("RestrictionId") + .HasColumnType("uuid"); + + b.Property("ResultingProjectionRevision") + .HasColumnType("bigint"); + + b.Property("ResultingRestrictionVersion") + .HasColumnType("bigint"); + + b.Property("ScopeId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("SelectedReservationVersion") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasAlternateKey("ScopeId", "Id"); + + b.HasIndex("ScopeId", "EventId") + .IsUnique(); + + b.HasIndex("ScopeId", "IdempotencyKey") + .IsUnique(); + + b.HasIndex("ScopeId", "PropertyId", "CaseId", "ApprovalRevision"); + + b.HasIndex("ScopeId", "PropertyId", "ReservationId", "CompletedAtUtc") + .HasDatabaseName("IX_reservation_processing_restriction_receipts_ScopeId_Proper~1"); + + b.ToTable("reservation_processing_restriction_receipts", "reservations", t => + { + t.HasCheckConstraint("CK_reservation_processing_restriction_receipts_versions", "\"ApprovalRevision\" >= 1 AND \"SelectedReservationVersion\" >= 1 AND \"ContractVersion\" >= 1 AND \"ResultingProjectionRevision\" >= 1 AND ((\"Action\" = 1 AND \"ResultingRestrictionVersion\" = 1 AND \"EffectiveRestricted\") OR (\"Action\" = 2 AND \"ResultingRestrictionVersion\" >= 2))"); + }); + }); + + modelBuilder.Entity("BunkFy.Modules.Reservations.Domain.Entities.RequestedInventoryUnit", b => + { + b.Property("ScopeId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ReservationId") + .HasColumnType("uuid"); + + b.Property("Id") + .HasColumnType("uuid"); + + b.HasKey("ScopeId", "ReservationId", "Id"); + + b.HasIndex("ScopeId", "Id", "ReservationId"); + + b.ToTable("requested_inventory_units", "reservations"); + }); + + modelBuilder.Entity("BunkFy.Modules.Reservations.Domain.Entities.ReservationGuest", b => + { + b.Property("ScopeId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ReservationId") + .HasColumnType("uuid"); + + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("IsCurrent") + .HasColumnType("boolean"); + + b.Property("LinkVersion") + .HasColumnType("bigint"); + + b.Property("LinkedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LinkedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Role") + .HasColumnType("integer"); + + b.Property("UnlinkedArrival") + .HasColumnType("date"); + + b.Property("UnlinkedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UnlinkedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UnlinkedCheckedInBusinessDate") + .HasColumnType("date"); + + b.Property("UnlinkedCheckedOutBusinessDate") + .HasColumnType("date"); + + b.Property("UnlinkedDeparture") + .HasColumnType("date"); + + b.Property("UnlinkedNoShowBusinessDate") + .HasColumnType("date"); + + b.Property("UnlinkedReservationStatus") + .HasColumnType("integer"); + + b.HasKey("ScopeId", "ReservationId", "Id"); + + b.HasIndex("ScopeId", "Id", "ReservationId"); + + b.HasIndex("ScopeId", "ReservationId", "IsCurrent", "Role"); + + b.ToTable("reservation_guests", "reservations", t => + { + t.HasCheckConstraint("CK_reservation_guests_link_version", "\"LinkVersion\" >= 1"); + + t.HasCheckConstraint("CK_reservation_guests_unlink_snapshot", "(\"IsCurrent\" = TRUE AND \"UnlinkedBy\" IS NULL AND \"UnlinkedAtUtc\" IS NULL AND \"UnlinkedArrival\" IS NULL AND \"UnlinkedDeparture\" IS NULL AND \"UnlinkedReservationStatus\" IS NULL) OR (\"IsCurrent\" = FALSE AND \"UnlinkedBy\" IS NOT NULL AND \"UnlinkedAtUtc\" IS NOT NULL AND \"UnlinkedArrival\" IS NOT NULL AND \"UnlinkedDeparture\" IS NOT NULL AND \"UnlinkedReservationStatus\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("BunkFy.Modules.Reservations.Domain.GuestRecords.ReservationGuestRecordLinkProcess", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreationConfirmationId") + .HasColumnType("uuid"); + + b.Property("DispatchRevision") + .HasColumnType("integer"); + + b.Property("ExpectedReservationVersion") + .HasColumnType("bigint"); + + b.Property("PropertyId") + .HasColumnType("uuid"); + + b.Property("RequestedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ReservationId") + .HasColumnType("uuid"); + + b.Property("ReviewReason") + .HasColumnType("integer"); + + b.Property("Revision") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.Property("ScopeId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("State") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasAlternateKey("ScopeId", "Id"); + + b.HasIndex("ScopeId", "CreationConfirmationId") + .IsUnique(); + + b.HasIndex("ScopeId", "ReservationId") + .IsUnique(); + + b.HasIndex("ScopeId", "PropertyId", "ReservationId", "State"); + + b.HasIndex("ScopeId", "PropertyId", "State", "UpdatedAtUtc", "Id") + .HasDatabaseName("IX_reservation_guest_record_link_processes_ScopeId_PropertyId~1"); + + b.ToTable("reservation_guest_record_link_processes", "reservations", t => + { + t.HasCheckConstraint("CK_reservation_guest_record_link_processes_lifecycle", "(\"State\" = 1 AND \"ReviewReason\" = 1 AND \"DispatchRevision\" = 0 AND \"RequestedBy\" IS NOT NULL) OR (\"State\" = 2 AND \"ReviewReason\" = 1 AND \"DispatchRevision\" >= 1 AND \"RequestedBy\" IS NOT NULL) OR (\"State\" = 3 AND \"ReviewReason\" = 1 AND \"DispatchRevision\" >= 1 AND \"RequestedBy\" IS NULL) OR (\"State\" = 4 AND \"ReviewReason\" >= 2 AND \"DispatchRevision\" >= 0 AND \"RequestedBy\" IS NOT NULL)"); + + t.HasCheckConstraint("CK_reservation_guest_record_link_processes_timestamps", "\"UpdatedAtUtc\" >= \"CreatedAtUtc\""); + + t.HasCheckConstraint("CK_reservation_guest_record_link_processes_versions", "\"ExpectedReservationVersion\" >= 1 AND \"Revision\" >= 1 AND \"DispatchRevision\" >= 0"); + }); + }); + + modelBuilder.Entity("BunkFy.Modules.Reservations.Domain.Retention.ReservationRetentionAnonymisationReceipt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActorId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CanonicalSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character(64)") + .IsFixedLength(); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ContractVersion") + .HasColumnType("integer"); + + b.Property("EventId") + .HasColumnType("uuid"); + + b.Property("ExecutionId") + .HasColumnType("uuid"); + + b.Property("PolicyEvidenceSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character(64)") + .IsFixedLength(); + + b.Property("PropertyId") + .HasColumnType("uuid"); + + b.Property("RedactedHistoryCount") + .HasColumnType("integer"); + + b.Property("ReducedExternalOperationCount") + .HasColumnType("integer"); + + b.Property("RemovedGuestLinkCount") + .HasColumnType("integer"); + + b.Property("ReservationId") + .HasColumnType("uuid"); + + b.Property("ResultingDetailsRevision") + .HasColumnType("bigint"); + + b.Property("ResultingReservationVersion") + .HasColumnType("bigint"); + + b.Property("RetentionDeadlineUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ScopeId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("SelectedDetailsRevision") + .HasColumnType("bigint"); + + b.Property("SelectedReservationVersion") + .HasColumnType("bigint"); + + b.Property("SuppressedReminderCount") + .HasColumnType("integer"); + + b.Property("TerminalAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasAlternateKey("ScopeId", "Id"); + + b.HasIndex("ScopeId", "ReservationId") + .IsUnique(); + + b.HasIndex("ScopeId", "ExecutionId", "ReservationId") + .IsUnique(); + + b.ToTable("reservation_retention_anonymisation_receipts", "reservations", t => + { + t.HasCheckConstraint("CK_reservation_retention_receipts_actor", "\"ActorId\" = 'system:retention'"); + + t.HasCheckConstraint("CK_reservation_retention_receipts_contract", "\"ContractVersion\" = 1"); + + t.HasCheckConstraint("CK_reservation_retention_receipts_counts", "\"RedactedHistoryCount\" >= 1 AND \"RemovedGuestLinkCount\" >= 0 AND \"ReducedExternalOperationCount\" >= 0 AND \"SuppressedReminderCount\" >= 0"); + + t.HasCheckConstraint("CK_reservation_retention_receipts_deadline", "\"RetentionDeadlineUtc\" >= \"TerminalAtUtc\" AND \"CompletedAtUtc\" >= \"RetentionDeadlineUtc\""); + + t.HasCheckConstraint("CK_reservation_retention_receipts_digests", "char_length(\"PolicyEvidenceSha256\") = 64 AND char_length(\"CanonicalSha256\") = 64"); + + t.HasCheckConstraint("CK_reservation_retention_receipts_versions", "\"SelectedReservationVersion\" >= 1 AND \"ResultingReservationVersion\" = \"SelectedReservationVersion\" + 1 AND \"SelectedDetailsRevision\" >= 1 AND \"ResultingDetailsRevision\" = \"SelectedDetailsRevision\" + 1"); + }); + }); + + modelBuilder.Entity("BunkFy.Modules.Reservations.Domain.Retention.ReservationRetentionExecution", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AffectedCount") + .HasColumnType("integer"); + + b.Property("Attempt") + .HasColumnType("integer"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DataClassKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("DeadlineUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExecutionPolicyVersion") + .HasColumnType("integer"); + + b.Property("HoldReviewDueAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("OutcomeCode") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RemainingCount") + .HasColumnType("integer"); + + b.Property("ScannedCount") + .HasColumnType("integer"); + + b.Property("ScopeId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("StartedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("StartingProjectionOrdinal") + .HasColumnType("bigint"); + + b.Property("State") + .HasColumnType("integer"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("ScopeId", "DataClassKey", "CompletedAtUtc", "Id"); + + b.ToTable("reservation_retention_executions", "reservations", t => + { + t.HasCheckConstraint("CK_reservation_retention_executions_counts", "\"AffectedCount\" >= 0 AND (\"ScannedCount\" IS NULL OR \"ScannedCount\" >= 0) AND (\"RemainingCount\" IS NULL OR \"RemainingCount\" >= 0) AND (\"ScannedCount\" IS NULL OR \"AffectedCount\" <= \"ScannedCount\")"); + + t.HasCheckConstraint("CK_reservation_retention_executions_cursor", "\"StartingProjectionOrdinal\" >= 0"); + + t.HasCheckConstraint("CK_reservation_retention_executions_key", "\"DataClassKey\" ~ '^[A-Za-z0-9.-]+$'"); + + t.HasCheckConstraint("CK_reservation_retention_executions_policy", "\"ExecutionPolicyVersion\" >= 1 AND \"Attempt\" >= 1 AND \"DeadlineUtc\" > \"StartedAtUtc\""); + + t.HasCheckConstraint("CK_reservation_retention_executions_state", "(\"State\" = 1 AND \"CompletedAtUtc\" IS NULL AND \"ScannedCount\" IS NULL AND \"RemainingCount\" IS NULL AND \"OutcomeCode\" IS NULL AND \"HoldReviewDueAtUtc\" IS NULL) OR (\"State\" IN (2, 3, 4) AND \"CompletedAtUtc\" BETWEEN \"StartedAtUtc\" AND \"DeadlineUtc\" AND \"ScannedCount\" IS NOT NULL AND \"RemainingCount\" IS NOT NULL AND \"OutcomeCode\" ~ '^[A-Za-z0-9.-]+$' AND ((\"State\" = 3 AND \"HoldReviewDueAtUtc\" IS NOT NULL) OR (\"State\" <> 3 AND \"HoldReviewDueAtUtc\" IS NULL)))"); + + t.HasCheckConstraint("CK_reservation_retention_executions_version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("BunkFy.Modules.Reservations.Domain.Retention.ReservationRetentionSweepCheckpoint", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AfterProjectionOrdinal") + .HasColumnType("bigint"); + + b.Property("DataClassKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ExecutionPolicyVersion") + .HasColumnType("integer"); + + b.Property("LastExecutionId") + .HasColumnType("uuid"); + + b.Property("ScopeId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasAlternateKey("ScopeId", "Id"); + + b.HasIndex("ScopeId", "DataClassKey", "ExecutionPolicyVersion") + .IsUnique(); + + b.ToTable("reservation_retention_sweep_checkpoints", "reservations", t => + { + t.HasCheckConstraint("CK_reservation_retention_checkpoints_cursor", "\"AfterProjectionOrdinal\" >= 0"); + + t.HasCheckConstraint("CK_reservation_retention_checkpoints_key", "\"DataClassKey\" ~ '^[A-Za-z0-9.-]+$'"); + + t.HasCheckConstraint("CK_reservation_retention_checkpoints_policy", "\"ExecutionPolicyVersion\" >= 1"); + + t.HasCheckConstraint("CK_reservation_retention_checkpoints_version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("BunkFy.Modules.Reservations.Domain.StayAmendments.ReservationStayAmendmentOperation", b => + { + b.Property("ScopeId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ReservationId") + .HasColumnType("uuid"); + + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpectedDetailsRevision") + .HasColumnType("bigint"); + + b.Property("InventoryRequestId") + .HasColumnType("uuid"); + + b.Property("LastReconciledAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LastReconciledBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OperationVersion") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.Property("Outcome") + .HasColumnType("integer"); + + b.Property("PropertyId") + .HasColumnType("uuid"); + + b.Property("ReconciliationCount") + .HasColumnType("integer"); + + b.Property("RejectionCode") + .HasColumnType("integer"); + + b.Property("RequestFingerprint") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character(64)") + .IsFixedLength(); + + b.Property("RequestSchemaVersion") + .HasColumnType("integer"); + + b.Property("RequestedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RequestedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ResultingAllocationVersion") + .HasColumnType("bigint"); + + b.Property("ResultingDetailsRevision") + .HasColumnType("bigint"); + + b.Property("ResultingReservationVersion") + .HasColumnType("bigint"); + + b.Property("TargetArrival") + .HasColumnType("date"); + + b.Property("TargetDeparture") + .HasColumnType("date"); + + b.Property("TargetExpectedArrivalTime") + .HasPrecision(0) + .HasColumnType("time(0) without time zone"); + + b.Property("TargetExpectedDepartureTime") + .HasPrecision(0) + .HasColumnType("time(0) without time zone"); + + b.Property("TargetInventoryUnitIds") + .HasMaxLength(3300) + .HasColumnType("character varying(3300)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("ScopeId", "ReservationId", "Id"); + + b.HasIndex("InventoryRequestId") + .IsUnique(); + + b.HasIndex("ScopeId", "PropertyId", "Outcome", "UpdatedAtUtc", "Id", "ReservationId"); + + b.ToTable("stay_amendment_operations", "reservations", t => + { + t.HasCheckConstraint("CK_stay_amendment_operations_outcome", "(\"Outcome\" = 1 AND \"CompletedAtUtc\" IS NULL AND \"ResultingDetailsRevision\" IS NULL AND \"ResultingReservationVersion\" IS NULL AND \"ResultingAllocationVersion\" IS NULL AND \"RejectionCode\" IS NULL) OR (\"Outcome\" = 2 AND \"CompletedAtUtc\" IS NOT NULL AND \"ResultingDetailsRevision\" >= 1 AND \"ResultingReservationVersion\" >= 1 AND \"ResultingAllocationVersion\" >= 1 AND \"RejectionCode\" IS NULL) OR (\"Outcome\" = 3 AND \"CompletedAtUtc\" IS NOT NULL AND \"ResultingDetailsRevision\" >= 1 AND \"ResultingReservationVersion\" >= 1 AND \"ResultingAllocationVersion\" IS NULL AND \"RejectionCode\" >= 1) OR (\"Outcome\" = 4 AND \"CompletedAtUtc\" IS NULL AND \"ResultingDetailsRevision\" IS NULL AND \"ResultingReservationVersion\" IS NULL AND \"ResultingAllocationVersion\" IS NULL AND \"RejectionCode\" IS NULL AND \"ReconciliationCount\" = 0)"); + + t.HasCheckConstraint("CK_stay_amendment_operations_reconciliation", "(\"ReconciliationCount\" = 0 AND \"LastReconciledAtUtc\" IS NULL AND \"LastReconciledBy\" IS NULL) OR (\"ReconciliationCount\" >= 1 AND \"LastReconciledAtUtc\" IS NOT NULL AND \"LastReconciledBy\" IS NOT NULL AND \"LastReconciledBy\" = trim(\"LastReconciledBy\") AND char_length(\"LastReconciledBy\") > 0 AND \"LastReconciledBy\" !~ '[[:cntrl:]]')"); + + t.HasCheckConstraint("CK_stay_amendment_operations_request", "\"RequestSchemaVersion\" IN (1, 2) AND char_length(\"RequestFingerprint\") = 64 AND \"RequestFingerprint\" ~ '^[0-9a-f]{64}$' AND \"ExpectedDetailsRevision\" >= 1 AND ((\"Outcome\" = 4 AND \"RequestSchemaVersion\" = 1 AND \"RequestedBy\" IS NULL AND \"InventoryRequestId\" IS NULL) OR (\"Outcome\" = 1 AND \"InventoryRequestId\" IS NOT NULL AND \"RequestedBy\" IS NOT NULL AND \"RequestedBy\" = trim(\"RequestedBy\") AND char_length(\"RequestedBy\") > 0 AND \"RequestedBy\" !~ '[[:cntrl:]]') OR (\"Outcome\" IN (2, 3) AND ((\"OperationVersion\" = 1 AND \"InventoryRequestId\" IS NULL) OR (\"OperationVersion\" > 1 AND \"InventoryRequestId\" IS NOT NULL)) AND \"RequestedBy\" IS NOT NULL AND \"RequestedBy\" = trim(\"RequestedBy\") AND char_length(\"RequestedBy\") > 0 AND \"RequestedBy\" !~ '[[:cntrl:]]'))"); + + t.HasCheckConstraint("CK_stay_amendment_operations_target", "(\"Outcome\" = 4 AND \"TargetArrival\" IS NULL AND \"TargetDeparture\" IS NULL AND \"TargetExpectedArrivalTime\" IS NULL AND \"TargetExpectedDepartureTime\" IS NULL AND \"TargetInventoryUnitIds\" IS NULL) OR (\"Outcome\" IN (1, 2, 3) AND \"TargetArrival\" IS NOT NULL AND \"TargetDeparture\" IS NOT NULL AND \"TargetArrival\" < \"TargetDeparture\" AND (\"TargetExpectedArrivalTime\" IS NULL OR date_part('second', \"TargetExpectedArrivalTime\") = 0) AND (\"TargetExpectedDepartureTime\" IS NULL OR date_part('second', \"TargetExpectedDepartureTime\") = 0) AND \"TargetInventoryUnitIds\" IS NOT NULL AND \"TargetInventoryUnitIds\" ~ '^[0-9a-f]{32}(,[0-9a-f]{32}){0,99}$')"); + + t.HasCheckConstraint("CK_stay_amendment_operations_timestamps", "\"UpdatedAtUtc\" >= \"RequestedAtUtc\" AND (\"CompletedAtUtc\" IS NULL OR (\"CompletedAtUtc\" >= \"RequestedAtUtc\" AND \"CompletedAtUtc\" <= \"UpdatedAtUtc\")) AND (\"LastReconciledAtUtc\" IS NULL OR (\"LastReconciledAtUtc\" >= \"RequestedAtUtc\" AND \"LastReconciledAtUtc\" <= \"UpdatedAtUtc\"))"); + + t.HasCheckConstraint("CK_stay_amendment_operations_version", "(\"Outcome\" = 1 AND \"OperationVersion\" = \"ReconciliationCount\" + 1) OR (\"Outcome\" = 2 AND ((\"OperationVersion\" = 1 AND \"ReconciliationCount\" = 0) OR \"OperationVersion\" = \"ReconciliationCount\" + 2)) OR (\"Outcome\" = 3 AND \"OperationVersion\" = \"ReconciliationCount\" + 2) OR (\"Outcome\" = 4 AND \"OperationVersion\" = 1 AND \"ReconciliationCount\" = 0)"); + }); + }); + + modelBuilder.Entity("BunkFy.Modules.Reservations.Persistence.ReservationArrivalReminder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Arrival") + .HasColumnType("date"); + + b.Property("DetailsRevision") + .HasColumnType("bigint"); + + b.Property("DispatchedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DueAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpectedArrivalAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpectedArrivalTime") + .HasColumnType("time(0) without time zone"); + + b.Property("LeadTimeMinutes") + .HasColumnType("integer"); + + b.Property("PropertyId") + .HasColumnType("uuid"); + + b.Property("ReservationId") + .HasColumnType("uuid"); + + b.Property("ScopeId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("State") + .HasColumnType("integer"); + + b.Property("TimeZoneId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasAlternateKey("ScopeId", "Id"); + + b.HasIndex("ScopeId", "PropertyId", "State"); + + b.HasIndex("ScopeId", "State", "DueAtUtc"); + + b.HasIndex("ScopeId", "ReservationId", "DetailsRevision", "TimeZoneId", "LeadTimeMinutes") + .IsUnique(); + + b.ToTable("arrival_reminders", "reservations", t => + { + t.HasCheckConstraint("CK_arrival_reminders_details_revision", "\"DetailsRevision\" >= 1"); + + t.HasCheckConstraint("CK_arrival_reminders_dispatch_state", "(\"State\" = 2 AND \"DispatchedAtUtc\" IS NOT NULL) OR (\"State\" <> 2 AND \"DispatchedAtUtc\" IS NULL)"); + + t.HasCheckConstraint("CK_arrival_reminders_lead_time", "\"LeadTimeMinutes\" > 0"); + }); + }); + + modelBuilder.Entity("BunkFy.Modules.Reservations.Persistence.ReservationDetailsHistoryEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActorId") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("AdapterConnectionId") + .HasColumnType("uuid"); + + b.Property("AfterSnapshotHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("AfterSnapshotJson") + .IsRequired() + .HasMaxLength(32768) + .HasColumnType("character varying(32768)"); + + b.Property("BeforeSnapshotJson") + .HasMaxLength(32768) + .HasColumnType("character varying(32768)"); + + b.Property("ChangedFieldsJson") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("ExternalOperationId") + .HasColumnType("uuid"); + + b.Property("FromRevision") + .HasColumnType("bigint"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("OperationDeduplicationKey") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("Origin") + .HasColumnType("integer"); + + b.Property("PropertyId") + .HasColumnType("uuid"); + + b.Property("ReservationId") + .HasColumnType("uuid"); + + b.Property("ScopeId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ToRevision") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("ScopeId", "OperationDeduplicationKey") + .IsUnique(); + + b.HasIndex("ScopeId", "PropertyId", "OccurredAtUtc"); + + b.HasIndex("ScopeId", "ReservationId", "ToRevision") + .IsUnique(); + + b.ToTable("reservation_details_history", "reservations"); + }); + + modelBuilder.Entity("BunkFy.Modules.Reservations.Persistence.ReservationExternalOperation", b => + { + b.Property("ScopeId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ConnectionId") + .HasColumnType("uuid"); + + b.Property("DetailsRevision") + .HasColumnType("bigint"); + + b.Property("ErrorCode") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("Outcome") + .HasColumnType("integer"); + + b.Property("PropertyId") + .HasColumnType("uuid"); + + b.Property("ReceiptId") + .HasColumnType("uuid"); + + b.Property("RequestFingerprint") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ReservationId") + .HasColumnType("uuid"); + + b.Property("ReservationVersion") + .HasColumnType("bigint"); + + b.HasKey("ScopeId", "Id"); + + b.HasIndex("ScopeId", "ReceiptId"); + + b.HasIndex("ScopeId", "ConnectionId", "CompletedAtUtc"); + + b.ToTable("external_operations", "reservations"); + }); + + modelBuilder.Entity("BunkFy.Modules.Reservations.Persistence.ReservationGuestProcessingRestrictionProjection", b => + { + b.Property("ScopeId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("PropertyId") + .HasColumnType("uuid"); + + b.Property("GuestId") + .HasColumnType("uuid"); + + b.Property("ContractVersion") + .HasColumnType("integer"); + + b.Property("IsRestricted") + .HasColumnType("boolean"); + + b.Property("Revision") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("ScopeId", "PropertyId", "GuestId"); + + b.HasIndex("ScopeId", "PropertyId", "IsRestricted", "GuestId") + .HasDatabaseName("IX_guest_restriction_linkable"); + + b.ToTable("guest_processing_restriction_projection", "reservations", t => + { + t.HasCheckConstraint("CK_reservations_guest_restriction_contract_version", "\"ContractVersion\" >= 1"); + + t.HasCheckConstraint("CK_reservations_guest_restriction_revision", "\"Revision\" >= 0"); + }); + }); + + modelBuilder.Entity("BunkFy.Modules.Reservations.Persistence.ReservationGuestProfileProjection", b => + { + b.Property("ScopeId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("OriginPropertyId") + .HasColumnType("uuid"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("ScopeId", "Id"); + + b.HasIndex("ScopeId", "OriginPropertyId", "Status", "Id"); + + b.ToTable("guest_profile_projection", "reservations", t => + { + t.HasCheckConstraint("CK_reservations_guest_profile_projection_version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("BunkFy.Modules.Reservations.Persistence.ReservationInventoryAllocationProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Arrival") + .HasColumnType("date"); + + b.Property("Departure") + .HasColumnType("date"); + + b.Property("IsKnown") + .HasColumnType("boolean"); + + b.Property("PropertyId") + .HasColumnType("uuid"); + + b.Property("ReservationId") + .HasColumnType("uuid"); + + b.Property("ScopeId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("ScopeId", "PropertyId", "Status", "Arrival", "Departure"); + + b.ToTable("inventory_allocation_projections", "reservations"); + }); + + modelBuilder.Entity("BunkFy.Modules.Reservations.Persistence.ReservationInventoryAllocationUnitProjection", b => + { + b.Property("ScopeId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("AllocationId") + .HasColumnType("uuid"); + + b.Property("Id") + .HasColumnType("uuid"); + + b.HasKey("ScopeId", "AllocationId", "Id"); + + b.HasIndex("ScopeId", "Id", "AllocationId"); + + b.ToTable("inventory_allocation_unit_projections", "reservations"); + }); + + modelBuilder.Entity("BunkFy.Modules.Reservations.Persistence.ReservationInventoryBlockProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Arrival") + .HasColumnType("date"); + + b.Property("Departure") + .HasColumnType("date"); + + b.Property("InventoryUnitId") + .HasColumnType("uuid"); + + b.Property("IsKnown") + .HasColumnType("boolean"); + + b.Property("PropertyId") + .HasColumnType("uuid"); + + b.Property("ScopeId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasAlternateKey("ScopeId", "Id"); + + b.HasIndex("ScopeId", "PropertyId", "InventoryUnitId", "Status", "Arrival", "Departure"); + + b.ToTable("inventory_block_projections", "reservations"); + }); + + modelBuilder.Entity("BunkFy.Modules.Reservations.Persistence.ReservationInventoryUnitProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BedId") + .HasColumnType("uuid"); + + b.Property("ConfigurationVersion") + .HasColumnType("bigint"); + + b.Property("IsSellable") + .HasColumnType("boolean"); + + b.Property("IsTopologyActive") + .HasColumnType("boolean"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PropertyId") + .HasColumnType("uuid"); + + b.Property("RoomId") + .HasColumnType("uuid"); + + b.Property("ScopeId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UnitVersion") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasAlternateKey("ScopeId", "Id"); + + b.HasIndex("ScopeId", "PropertyId", "RoomId", "IsSellable"); + + b.ToTable("inventory_unit_projections", "reservations"); + }); + + modelBuilder.Entity("BunkFy.Modules.Reservations.Persistence.ReservationManagementOperation", b => + { + b.Property("ScopeId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ReservationId") + .HasColumnType("uuid"); + + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BusinessDate") + .HasColumnType("date"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpectedDetailsRevision") + .HasColumnType("bigint"); + + b.Property("ExpectedVersion") + .HasColumnType("bigint"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("PropertyId") + .HasColumnType("uuid"); + + b.Property("RequestFingerprint") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("ScopeId", "ReservationId", "Id"); + + b.HasIndex("ScopeId", "PropertyId", "CreatedAtUtc", "Id"); + + b.ToTable("management_operations", "reservations", t => + { + t.HasCheckConstraint("CK_management_operations_business_date", "(\"Kind\" IN (1, 5, 6, 7) AND \"BusinessDate\" IS NULL) OR (\"Kind\" IN (2, 3, 4) AND \"BusinessDate\" IS NOT NULL)"); + + t.HasCheckConstraint("CK_management_operations_expected_revision", "(\"Kind\" IN (1, 2, 3, 4) AND \"ExpectedVersion\" > 0 AND \"ExpectedDetailsRevision\" IS NULL) OR (\"Kind\" IN (5, 6, 7) AND \"ExpectedVersion\" IS NULL AND \"ExpectedDetailsRevision\" > 0)"); + + t.HasCheckConstraint("CK_management_operations_kind", "\"Kind\" IN (1, 2, 3, 4, 5, 6, 7)"); + + t.HasCheckConstraint("CK_management_operations_request_fingerprint", "(\"Kind\" IN (1, 2, 3, 4, 5) AND \"RequestFingerprint\" IS NULL) OR (\"Kind\" IN (6, 7) AND \"RequestFingerprint\" IS NOT NULL AND char_length(\"RequestFingerprint\") = 64 AND \"RequestFingerprint\" ~ '^[0-9a-f]{64}$')"); + }); + }); + + modelBuilder.Entity("BunkFy.Modules.Reservations.Persistence.ReservationOperationLock", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ReservationId") + .HasColumnType("uuid"); + + b.Property("Revision") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.Property("ScopeId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasAlternateKey("ScopeId", "Id"); + + b.HasIndex("ScopeId", "ReservationId") + .IsUnique(); + + b.ToTable("reservation_operation_locks", "reservations", t => + { + t.HasCheckConstraint("CK_reservation_operation_locks_revision", "\"Revision\" >= 1"); + }); + }); + + modelBuilder.Entity("BunkFy.Modules.Reservations.Persistence.ReservationPropertyProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsKnown") + .HasColumnType("boolean"); + + b.Property("PolicySourceVersion") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.Property("ProcessingStatus") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.Property("ScopeId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TimeZoneId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TopologySourceVersion") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasAlternateKey("ScopeId", "Id"); + + b.HasIndex("ScopeId", "IsActive"); + + b.ToTable("property_projection", "reservations", t => + { + t.HasCheckConstraint("CK_reservations_property_projection_governance_policy", "(\"ProcessingStatus\" = 1 AND \"OperatingCountryCode\" IS NULL AND \"JurisdictionPolicyId\" IS NULL AND \"JurisdictionPolicyVersion\" IS NULL AND \"DataRegionId\" IS NULL AND \"TransferProfileId\" IS NULL AND \"RetentionPolicyId\" IS NULL AND \"RetentionPolicyVersion\" IS NULL AND \"PolicyContentSha256\" IS NULL AND \"PolicyEffectiveAtUtc\" IS NULL AND \"PolicyExpiresAtUtc\" IS NULL AND \"PolicyActivatedAtUtc\" IS NULL) OR (\"ProcessingStatus\" IN (2, 3) AND \"OperatingCountryCode\" IS NOT NULL AND \"JurisdictionPolicyId\" IS NOT NULL AND \"JurisdictionPolicyVersion\" IS NOT NULL AND \"DataRegionId\" IS NOT NULL AND \"TransferProfileId\" IS NOT NULL AND \"RetentionPolicyId\" IS NOT NULL AND \"RetentionPolicyVersion\" IS NOT NULL AND \"PolicyContentSha256\" IS NOT NULL AND \"PolicyEffectiveAtUtc\" IS NOT NULL AND \"PolicyExpiresAtUtc\" IS NOT NULL AND \"PolicyActivatedAtUtc\" IS NOT NULL AND \"JurisdictionPolicyVersion\" > 0 AND \"RetentionPolicyVersion\" > 0 AND char_length(\"OperatingCountryCode\") = 2 AND char_length(\"PolicyContentSha256\") = 64 AND \"PolicyEffectiveAtUtc\" < \"PolicyExpiresAtUtc\" AND \"PolicyActivatedAtUtc\" >= \"PolicyEffectiveAtUtc\" AND \"PolicyActivatedAtUtc\" < \"PolicyExpiresAtUtc\")"); + + t.HasCheckConstraint("CK_reservations_property_projection_processing_status", "\"ProcessingStatus\" BETWEEN 1 AND 3"); + }); + }); + + modelBuilder.Entity("BunkFy.Modules.Reservations.Persistence.ReservationsProjectionRebuildCheckpoint", b => + { + b.Property("ScopeId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ProjectionName") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("RunId") + .HasColumnType("uuid"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Cursor") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("FailedCount") + .HasColumnType("bigint"); + + b.Property("ProcessedCount") + .HasColumnType("bigint"); + + b.Property("ProjectionVersion") + .HasColumnType("integer"); + + b.Property("SkippedCount") + .HasColumnType("bigint"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("WrittenCount") + .HasColumnType("bigint"); + + b.HasKey("ScopeId", "ProjectionName", "RunId"); + + b.ToTable("projection_rebuild_checkpoints", "reservations"); + }); + + modelBuilder.Entity("BunkFy.Modules.Reservations.Persistence.ReservationsTenantRevision", b => + { + b.Property("ScopeId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("DestroyCompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DestroyOperationId") + .HasColumnType("uuid"); + + b.Property("DestroyRequestSha256") + .HasMaxLength(64) + .HasColumnType("character(64)") + .IsFixedLength(); + + b.Property("DestroyStartedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LifecycleStatus") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.Property("Revision") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("ScopeId"); + + b.ToTable("tenant_revisions", "reservations", t => + { + t.HasCheckConstraint("CK_reservations_tenant_revision_lifecycle", "(\"LifecycleStatus\" = 1 AND \"DestroyOperationId\" IS NULL AND \"DestroyRequestSha256\" IS NULL AND \"DestroyStartedAtUtc\" IS NULL AND \"DestroyCompletedAtUtc\" IS NULL) OR (\"LifecycleStatus\" = 2 AND \"DestroyOperationId\" IS NOT NULL AND \"DestroyRequestSha256\" IS NOT NULL AND \"DestroyStartedAtUtc\" IS NOT NULL AND \"DestroyCompletedAtUtc\" IS NULL) OR (\"LifecycleStatus\" = 3 AND \"DestroyOperationId\" IS NOT NULL AND \"DestroyRequestSha256\" IS NOT NULL AND \"DestroyStartedAtUtc\" IS NOT NULL AND \"DestroyCompletedAtUtc\" >= \"DestroyStartedAtUtc\")"); + + t.HasCheckConstraint("CK_reservations_tenant_revision_positive", "\"Revision\" > 0"); + }); + }); + + modelBuilder.Entity("BunkFy.Modules.Reservations.Persistence.TenantTermination.ReservationsTenantDestroyOperation", b => + { + b.Property("OperationId") + .HasColumnType("uuid"); + + b.Property("BatchSize") + .HasColumnType("integer"); + + b.Property("CompletedBatchCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyVersion") + .IsConcurrencyToken() + .HasColumnType("integer"); + + b.Property("ProofVersion") + .HasColumnType("integer"); + + b.Property("RemovalProofSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character(64)") + .IsFixedLength(); + + b.Property("RemovedRecordCount") + .HasColumnType("bigint"); + + b.Property("RequestSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character(64)") + .IsFixedLength(); + + b.Property("ResultingRevision") + .HasColumnType("bigint"); + + b.Property("ScopeId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("SelectedRevision") + .HasColumnType("bigint"); + + b.Property("Stage") + .HasColumnType("integer"); + + b.Property("StartedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("OperationId"); + + b.HasIndex("ScopeId") + .IsUnique(); + + b.ToTable("tenant_destroy_operations", "reservations", t => + { + t.HasCheckConstraint("CK_reservations_tenant_destroy_operation_batch", "\"BatchSize\" BETWEEN 1 AND 500"); + + t.HasCheckConstraint("CK_reservations_tenant_destroy_operation_progress", "\"Stage\" BETWEEN 1 AND 31 AND \"RemovedRecordCount\" >= 0 AND \"CompletedBatchCount\" >= 0 AND \"ProofVersion\" = 1 AND \"ConcurrencyVersion\" >= 1"); + + t.HasCheckConstraint("CK_reservations_tenant_destroy_operation_revisions", "\"SelectedRevision\" >= 0 AND \"ResultingRevision\" = \"SelectedRevision\" + 1"); + + t.HasCheckConstraint("CK_reservations_tenant_destroy_operation_times", "\"UpdatedAtUtc\" >= \"StartedAtUtc\""); + }); + }); + + modelBuilder.Entity("BunkFy.Modules.Reservations.Persistence.TenantTermination.ReservationsTenantDestroyReceipt", b => + { + b.Property("OperationId") + .HasColumnType("uuid"); + + b.Property("BatchSize") + .HasColumnType("integer"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CompletedBatchCount") + .HasColumnType("integer"); + + b.Property("RemovalProofSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character(64)") + .IsFixedLength(); + + b.Property("RemovalProofVersion") + .HasColumnType("integer"); + + b.Property("RemovedRecordCount") + .HasColumnType("bigint"); + + b.Property("RequestSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character(64)") + .IsFixedLength(); + + b.Property("ResultingRevision") + .HasColumnType("bigint"); + + b.Property("ScopeId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("SelectedRevision") + .HasColumnType("bigint"); + + b.Property("StartedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("OperationId"); + + b.HasIndex("ScopeId") + .IsUnique(); + + b.ToTable("tenant_destroy_receipts", "reservations", t => + { + t.HasCheckConstraint("CK_reservations_tenant_destroy_receipt_progress", "((\"RemovedRecordCount\" = 0 AND \"CompletedBatchCount\" = 0) OR (\"RemovedRecordCount\" > 0 AND \"CompletedBatchCount\" > 0)) AND \"BatchSize\" BETWEEN 1 AND 500 AND \"RemovalProofVersion\" = 1"); + + t.HasCheckConstraint("CK_reservations_tenant_destroy_receipt_revisions", "\"SelectedRevision\" >= 0 AND \"ResultingRevision\" = \"SelectedRevision\" + 1"); + + t.HasCheckConstraint("CK_reservations_tenant_destroy_receipt_times", "\"CompletedAtUtc\" >= \"StartedAtUtc\""); + }); + }); + + modelBuilder.Entity("Gma.Framework.Messaging.Infrastructure.InboxMessage", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Handler") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Attempts") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("FailedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LastError") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("LockedBy") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ProcessedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ProcessingStartedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ScopeId") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("ScopeId"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id", "Handler"); + + b.HasIndex("Handler", "Status"); + + b.HasIndex("Status", "ProcessedAtUtc"); + + b.ToTable("inbox_messages", "reservations"); + }); + + modelBuilder.Entity("Gma.Framework.Messaging.Infrastructure.OutboxMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Attempts") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Error") + .HasColumnType("text"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("LockedBy") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LockedUntilUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("NextAttemptAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProcessedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ScopeId") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("ScopeId"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProcessedAtUtc", "NextAttemptAtUtc", "LockedUntilUtc", "CreatedAtUtc"); + + b.ToTable("outbox_messages", "reservations"); + }); + + modelBuilder.Entity("BunkFy.Modules.Reservations.Domain.DataRights.ReservationAnonymisationReceipt", b => + { + b.HasOne("BunkFy.Modules.Reservations.Domain.Aggregates.Reservation", null) + .WithMany() + .HasForeignKey("ScopeId", "ReservationId") + .HasPrincipalKey("ScopeId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("BunkFy.Modules.Reservations.Domain.DataRights.ReservationAnonymisationRestoreReceipt", b => + { + b.HasOne("BunkFy.Modules.Reservations.Domain.DataRights.ReservationAnonymisationTombstone", null) + .WithMany() + .HasForeignKey("ScopeId", "ReservationId") + .HasPrincipalKey("ScopeId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("BunkFy.Modules.Reservations.Domain.DataRights.ReservationDataHoldReceipt", b => + { + b.HasOne("BunkFy.Modules.Reservations.Domain.DataRights.ReservationDataHold", null) + .WithMany() + .HasForeignKey("ScopeId", "HoldId") + .HasPrincipalKey("ScopeId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("BunkFy.Modules.Reservations.Domain.Entities.RequestedInventoryUnit", b => + { + b.HasOne("BunkFy.Modules.Reservations.Domain.Aggregates.Reservation", null) + .WithMany("RequestedUnits") + .HasForeignKey("ScopeId", "ReservationId") + .HasPrincipalKey("ScopeId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("BunkFy.Modules.Reservations.Domain.Entities.ReservationGuest", b => + { + b.HasOne("BunkFy.Modules.Reservations.Domain.Aggregates.Reservation", null) + .WithMany("Guests") + .HasForeignKey("ScopeId", "ReservationId") + .HasPrincipalKey("ScopeId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("BunkFy.Modules.Reservations.Domain.Retention.ReservationRetentionAnonymisationReceipt", b => + { + b.HasOne("BunkFy.Modules.Reservations.Domain.Retention.ReservationRetentionExecution", null) + .WithMany() + .HasForeignKey("ScopeId", "ExecutionId") + .HasPrincipalKey("ScopeId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("BunkFy.Modules.Reservations.Domain.Aggregates.Reservation", null) + .WithOne() + .HasForeignKey("BunkFy.Modules.Reservations.Domain.Retention.ReservationRetentionAnonymisationReceipt", "ScopeId", "ReservationId") + .HasPrincipalKey("BunkFy.Modules.Reservations.Domain.Aggregates.Reservation", "ScopeId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("BunkFy.Modules.Reservations.Domain.StayAmendments.ReservationStayAmendmentOperation", b => + { + b.HasOne("BunkFy.Modules.Reservations.Persistence.ReservationManagementOperation", null) + .WithOne() + .HasForeignKey("BunkFy.Modules.Reservations.Domain.StayAmendments.ReservationStayAmendmentOperation", "ScopeId", "ReservationId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("BunkFy.Modules.Reservations.Persistence.ReservationInventoryAllocationUnitProjection", b => + { + b.HasOne("BunkFy.Modules.Reservations.Persistence.ReservationInventoryAllocationProjection", null) + .WithMany("Units") + .HasForeignKey("ScopeId", "AllocationId") + .HasPrincipalKey("ScopeId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("BunkFy.Modules.Reservations.Persistence.ReservationManagementOperation", b => + { + b.HasOne("BunkFy.Modules.Reservations.Domain.Aggregates.Reservation", null) + .WithMany() + .HasForeignKey("ScopeId", "ReservationId") + .HasPrincipalKey("ScopeId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("BunkFy.Modules.Reservations.Persistence.ReservationPropertyProjection", b => + { + b.OwnsOne("BunkFy.Modules.Reservations.Persistence.ReservationPropertyPolicyBinding", "GovernancePolicy", b1 => + { + b1.Property("ReservationPropertyProjectionId") + .HasColumnType("uuid"); + + b1.Property("ActivatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("PolicyActivatedAtUtc"); + + b1.Property("ContentSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("PolicyContentSha256"); + + b1.Property("DataRegionId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("DataRegionId"); + + b1.Property("OperatingCountryCode") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("character varying(2)") + .HasColumnName("OperatingCountryCode"); + + b1.Property("PolicyEffectiveAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("PolicyEffectiveAtUtc"); + + b1.Property("PolicyExpiresAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("PolicyExpiresAtUtc"); + + b1.Property("PolicyId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("JurisdictionPolicyId"); + + b1.Property("PolicyVersion") + .HasColumnType("integer") + .HasColumnName("JurisdictionPolicyVersion"); + + b1.Property("RetentionPolicyId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("RetentionPolicyId"); + + b1.Property("RetentionPolicyVersion") + .HasColumnType("integer") + .HasColumnName("RetentionPolicyVersion"); + + b1.Property("TransferProfileId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("TransferProfileId"); + + b1.HasKey("ReservationPropertyProjectionId"); + + b1.ToTable("property_projection", "reservations"); + + b1.WithOwner() + .HasForeignKey("ReservationPropertyProjectionId"); + + b1.OwnsMany("BunkFy.Modules.Reservations.Persistence.ReservationPropertyPolicyAcknowledgement", "Acknowledgements", b2 => + { + b2.Property("PropertyId") + .HasColumnType("uuid"); + + b2.Property("AcknowledgementId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b2.Property("AcknowledgementVersion") + .HasColumnType("integer"); + + b2.HasKey("PropertyId", "AcknowledgementId", "AcknowledgementVersion"); + + b2.ToTable("property_policy_acknowledgements", "reservations"); + + b2.WithOwner() + .HasForeignKey("PropertyId"); + }); + + b1.Navigation("Acknowledgements"); + }); + + b.Navigation("GovernancePolicy"); + }); + + modelBuilder.Entity("BunkFy.Modules.Reservations.Domain.Aggregates.Reservation", b => + { + b.Navigation("Guests"); + + b.Navigation("RequestedUnits"); + }); + + modelBuilder.Entity("BunkFy.Modules.Reservations.Persistence.ReservationInventoryAllocationProjection", b => + { + b.Navigation("Units"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence.PostgreSqlMigrations/Migrations/20260812011925_AddReservationStayAmendmentConvergence.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence.PostgreSqlMigrations/Migrations/20260812011925_AddReservationStayAmendmentConvergence.cs new file mode 100644 index 00000000..262e3336 --- /dev/null +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence.PostgreSqlMigrations/Migrations/20260812011925_AddReservationStayAmendmentConvergence.cs @@ -0,0 +1,219 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace BunkFy.Modules.Reservations.Persistence.PostgreSqlMigrations.Migrations +{ + /// + public partial class AddReservationStayAmendmentConvergence : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql(UpLockSql); + + migrationBuilder.DropCheckConstraint( + name: "CK_management_operations_business_date", + schema: "reservations", + table: "management_operations"); + + migrationBuilder.DropCheckConstraint( + name: "CK_management_operations_expected_revision", + schema: "reservations", + table: "management_operations"); + + migrationBuilder.DropCheckConstraint( + name: "CK_management_operations_kind", + schema: "reservations", + table: "management_operations"); + + migrationBuilder.DropCheckConstraint( + name: "CK_management_operations_request_fingerprint", + schema: "reservations", + table: "management_operations"); + + migrationBuilder.AddColumn( + name: "PendingInventoryAmendmentRequestId", + schema: "reservations", + table: "reservations", + type: "uuid", + nullable: true); + + migrationBuilder.CreateTable( + name: "stay_amendment_operations", + schema: "reservations", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + ScopeId = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + ReservationId = table.Column(type: "uuid", nullable: false), + PropertyId = table.Column(type: "uuid", nullable: false), + InventoryRequestId = table.Column(type: "uuid", nullable: true), + RequestSchemaVersion = table.Column(type: "integer", nullable: false), + RequestFingerprint = table.Column(type: "character(64)", fixedLength: true, maxLength: 64, nullable: false), + TargetArrival = table.Column(type: "date", nullable: true), + TargetDeparture = table.Column(type: "date", nullable: true), + TargetExpectedArrivalTime = table.Column(type: "time(0) without time zone", precision: 0, nullable: true), + TargetExpectedDepartureTime = table.Column(type: "time(0) without time zone", precision: 0, nullable: true), + TargetInventoryUnitIds = table.Column(type: "character varying(3300)", maxLength: 3300, nullable: true), + ExpectedDetailsRevision = table.Column(type: "bigint", nullable: false), + RequestedBy = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + Outcome = table.Column(type: "integer", nullable: false), + OperationVersion = table.Column(type: "bigint", nullable: false), + RequestedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + CompletedAtUtc = table.Column(type: "timestamp with time zone", nullable: true), + ResultingDetailsRevision = table.Column(type: "bigint", nullable: true), + ResultingReservationVersion = table.Column(type: "bigint", nullable: true), + ResultingAllocationVersion = table.Column(type: "bigint", nullable: true), + RejectionCode = table.Column(type: "integer", nullable: true), + ReconciliationCount = table.Column(type: "integer", nullable: false), + LastReconciledAtUtc = table.Column(type: "timestamp with time zone", nullable: true), + LastReconciledBy = table.Column(type: "character varying(200)", maxLength: 200, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_stay_amendment_operations", x => new { x.ScopeId, x.ReservationId, x.Id }); + table.CheckConstraint("CK_stay_amendment_operations_outcome", "(\"Outcome\" = 1 AND \"CompletedAtUtc\" IS NULL AND \"ResultingDetailsRevision\" IS NULL AND \"ResultingReservationVersion\" IS NULL AND \"ResultingAllocationVersion\" IS NULL AND \"RejectionCode\" IS NULL) OR (\"Outcome\" = 2 AND \"CompletedAtUtc\" IS NOT NULL AND \"ResultingDetailsRevision\" >= 1 AND \"ResultingReservationVersion\" >= 1 AND \"ResultingAllocationVersion\" >= 1 AND \"RejectionCode\" IS NULL) OR (\"Outcome\" = 3 AND \"CompletedAtUtc\" IS NOT NULL AND \"ResultingDetailsRevision\" >= 1 AND \"ResultingReservationVersion\" >= 1 AND \"ResultingAllocationVersion\" IS NULL AND \"RejectionCode\" >= 1) OR (\"Outcome\" = 4 AND \"CompletedAtUtc\" IS NULL AND \"ResultingDetailsRevision\" IS NULL AND \"ResultingReservationVersion\" IS NULL AND \"ResultingAllocationVersion\" IS NULL AND \"RejectionCode\" IS NULL AND \"ReconciliationCount\" = 0)"); + table.CheckConstraint("CK_stay_amendment_operations_reconciliation", "(\"ReconciliationCount\" = 0 AND \"LastReconciledAtUtc\" IS NULL AND \"LastReconciledBy\" IS NULL) OR (\"ReconciliationCount\" >= 1 AND \"LastReconciledAtUtc\" IS NOT NULL AND \"LastReconciledBy\" IS NOT NULL AND \"LastReconciledBy\" = trim(\"LastReconciledBy\") AND char_length(\"LastReconciledBy\") > 0 AND \"LastReconciledBy\" !~ '[[:cntrl:]]')"); + table.CheckConstraint("CK_stay_amendment_operations_request", "\"RequestSchemaVersion\" IN (1, 2) AND char_length(\"RequestFingerprint\") = 64 AND \"RequestFingerprint\" ~ '^[0-9a-f]{64}$' AND \"ExpectedDetailsRevision\" >= 1 AND ((\"Outcome\" = 4 AND \"RequestSchemaVersion\" = 1 AND \"RequestedBy\" IS NULL AND \"InventoryRequestId\" IS NULL) OR (\"Outcome\" = 1 AND \"InventoryRequestId\" IS NOT NULL AND \"RequestedBy\" IS NOT NULL AND \"RequestedBy\" = trim(\"RequestedBy\") AND char_length(\"RequestedBy\") > 0 AND \"RequestedBy\" !~ '[[:cntrl:]]') OR (\"Outcome\" IN (2, 3) AND ((\"OperationVersion\" = 1 AND \"InventoryRequestId\" IS NULL) OR (\"OperationVersion\" > 1 AND \"InventoryRequestId\" IS NOT NULL)) AND \"RequestedBy\" IS NOT NULL AND \"RequestedBy\" = trim(\"RequestedBy\") AND char_length(\"RequestedBy\") > 0 AND \"RequestedBy\" !~ '[[:cntrl:]]'))"); + table.CheckConstraint("CK_stay_amendment_operations_target", "(\"Outcome\" = 4 AND \"TargetArrival\" IS NULL AND \"TargetDeparture\" IS NULL AND \"TargetExpectedArrivalTime\" IS NULL AND \"TargetExpectedDepartureTime\" IS NULL AND \"TargetInventoryUnitIds\" IS NULL) OR (\"Outcome\" IN (1, 2, 3) AND \"TargetArrival\" IS NOT NULL AND \"TargetDeparture\" IS NOT NULL AND \"TargetArrival\" < \"TargetDeparture\" AND (\"TargetExpectedArrivalTime\" IS NULL OR date_part('second', \"TargetExpectedArrivalTime\") = 0) AND (\"TargetExpectedDepartureTime\" IS NULL OR date_part('second', \"TargetExpectedDepartureTime\") = 0) AND \"TargetInventoryUnitIds\" IS NOT NULL AND \"TargetInventoryUnitIds\" ~ '^[0-9a-f]{32}(,[0-9a-f]{32}){0,99}$')"); + table.CheckConstraint("CK_stay_amendment_operations_timestamps", "\"UpdatedAtUtc\" >= \"RequestedAtUtc\" AND (\"CompletedAtUtc\" IS NULL OR (\"CompletedAtUtc\" >= \"RequestedAtUtc\" AND \"CompletedAtUtc\" <= \"UpdatedAtUtc\")) AND (\"LastReconciledAtUtc\" IS NULL OR (\"LastReconciledAtUtc\" >= \"RequestedAtUtc\" AND \"LastReconciledAtUtc\" <= \"UpdatedAtUtc\"))"); + table.CheckConstraint("CK_stay_amendment_operations_version", "(\"Outcome\" = 1 AND \"OperationVersion\" = \"ReconciliationCount\" + 1) OR (\"Outcome\" = 2 AND ((\"OperationVersion\" = 1 AND \"ReconciliationCount\" = 0) OR \"OperationVersion\" = \"ReconciliationCount\" + 2)) OR (\"Outcome\" = 3 AND \"OperationVersion\" = \"ReconciliationCount\" + 2) OR (\"Outcome\" = 4 AND \"OperationVersion\" = 1 AND \"ReconciliationCount\" = 0)"); + table.ForeignKey( + name: "FK_stay_amendment_operations_management_operations_ScopeId_Res~", + columns: x => new { x.ScopeId, x.ReservationId, x.Id }, + principalSchema: "reservations", + principalTable: "management_operations", + principalColumns: new[] { "ScopeId", "ReservationId", "Id" }, + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.Sql(UpBackfillSql); + + migrationBuilder.CreateIndex( + name: "IX_reservations_PendingInventoryAmendmentRequestId", + schema: "reservations", + table: "reservations", + column: "PendingInventoryAmendmentRequestId", + unique: true); + + migrationBuilder.AddCheckConstraint( + name: "CK_reservations_pending_inventory_request", + schema: "reservations", + table: "reservations", + sql: "(\"PendingAllocationAmendmentId\" IS NULL AND \"PendingInventoryAmendmentRequestId\" IS NULL) OR (\"PendingAllocationAmendmentId\" IS NOT NULL AND \"PendingInventoryAmendmentRequestId\" IS NOT NULL)"); + + migrationBuilder.AddCheckConstraint( + name: "CK_management_operations_business_date", + schema: "reservations", + table: "management_operations", + sql: "(\"Kind\" IN (1, 5, 6, 7) AND \"BusinessDate\" IS NULL) OR (\"Kind\" IN (2, 3, 4) AND \"BusinessDate\" IS NOT NULL)"); + + migrationBuilder.AddCheckConstraint( + name: "CK_management_operations_expected_revision", + schema: "reservations", + table: "management_operations", + sql: "(\"Kind\" IN (1, 2, 3, 4) AND \"ExpectedVersion\" > 0 AND \"ExpectedDetailsRevision\" IS NULL) OR (\"Kind\" IN (5, 6, 7) AND \"ExpectedVersion\" IS NULL AND \"ExpectedDetailsRevision\" > 0)"); + + migrationBuilder.AddCheckConstraint( + name: "CK_management_operations_kind", + schema: "reservations", + table: "management_operations", + sql: "\"Kind\" IN (1, 2, 3, 4, 5, 6, 7)"); + + migrationBuilder.AddCheckConstraint( + name: "CK_management_operations_request_fingerprint", + schema: "reservations", + table: "management_operations", + sql: "(\"Kind\" IN (1, 2, 3, 4, 5) AND \"RequestFingerprint\" IS NULL) OR (\"Kind\" IN (6, 7) AND \"RequestFingerprint\" IS NOT NULL AND char_length(\"RequestFingerprint\") = 64 AND \"RequestFingerprint\" ~ '^[0-9a-f]{64}$')"); + + migrationBuilder.CreateIndex( + name: "IX_stay_amendment_operations_InventoryRequestId", + schema: "reservations", + table: "stay_amendment_operations", + column: "InventoryRequestId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_stay_amendment_operations_ScopeId_PropertyId_Outcome_Update~", + schema: "reservations", + table: "stay_amendment_operations", + columns: new[] { "ScopeId", "PropertyId", "Outcome", "UpdatedAtUtc", "Id", "ReservationId" }); + + migrationBuilder.Sql(UpProtocolSql); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql(DownGuardSql); + + migrationBuilder.DropTable( + name: "stay_amendment_operations", + schema: "reservations"); + + migrationBuilder.DropIndex( + name: "IX_reservations_PendingInventoryAmendmentRequestId", + schema: "reservations", + table: "reservations"); + + migrationBuilder.DropCheckConstraint( + name: "CK_reservations_pending_inventory_request", + schema: "reservations", + table: "reservations"); + + migrationBuilder.DropCheckConstraint( + name: "CK_management_operations_business_date", + schema: "reservations", + table: "management_operations"); + + migrationBuilder.DropCheckConstraint( + name: "CK_management_operations_expected_revision", + schema: "reservations", + table: "management_operations"); + + migrationBuilder.DropCheckConstraint( + name: "CK_management_operations_kind", + schema: "reservations", + table: "management_operations"); + + migrationBuilder.DropCheckConstraint( + name: "CK_management_operations_request_fingerprint", + schema: "reservations", + table: "management_operations"); + + migrationBuilder.DropColumn( + name: "PendingInventoryAmendmentRequestId", + schema: "reservations", + table: "reservations"); + + migrationBuilder.Sql(DownCleanupSql); + + migrationBuilder.AddCheckConstraint( + name: "CK_management_operations_business_date", + schema: "reservations", + table: "management_operations", + sql: "(\"Kind\" IN (1, 5, 6) AND \"BusinessDate\" IS NULL) OR (\"Kind\" IN (2, 3, 4) AND \"BusinessDate\" IS NOT NULL)"); + + migrationBuilder.AddCheckConstraint( + name: "CK_management_operations_expected_revision", + schema: "reservations", + table: "management_operations", + sql: "(\"Kind\" IN (1, 2, 3, 4) AND \"ExpectedVersion\" > 0 AND \"ExpectedDetailsRevision\" IS NULL) OR (\"Kind\" IN (5, 6) AND \"ExpectedVersion\" IS NULL AND \"ExpectedDetailsRevision\" > 0)"); + + migrationBuilder.AddCheckConstraint( + name: "CK_management_operations_kind", + schema: "reservations", + table: "management_operations", + sql: "\"Kind\" IN (1, 2, 3, 4, 5, 6)"); + + migrationBuilder.AddCheckConstraint( + name: "CK_management_operations_request_fingerprint", + schema: "reservations", + table: "management_operations", + sql: "(\"Kind\" IN (1, 2, 3, 4, 5) AND \"RequestFingerprint\" IS NULL) OR (\"Kind\" = 6 AND \"RequestFingerprint\" IS NOT NULL AND char_length(\"RequestFingerprint\") = 64 AND \"RequestFingerprint\" ~ '^[0-9a-f]{64}$')"); + } + } +} diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence.PostgreSqlMigrations/Migrations/AddReservationStayAmendmentConvergence.Protocol.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence.PostgreSqlMigrations/Migrations/AddReservationStayAmendmentConvergence.Protocol.cs new file mode 100644 index 00000000..e6db7c8a --- /dev/null +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence.PostgreSqlMigrations/Migrations/AddReservationStayAmendmentConvergence.Protocol.cs @@ -0,0 +1,943 @@ +namespace BunkFy.Modules.Reservations.Persistence.PostgreSqlMigrations.Migrations; + +public partial class AddReservationStayAmendmentConvergence +{ + internal const string UpLockSql = + """ + LOCK TABLE reservations.tenant_revisions + IN SHARE MODE NOWAIT; + LOCK TABLE reservations.management_operations + IN SHARE MODE NOWAIT; + LOCK TABLE reservations.reservations + IN SHARE MODE NOWAIT; + """; + + internal const string UpBackfillSql = + """ + DO $$ + BEGIN + IF EXISTS ( + SELECT reservation."PendingAllocationAmendmentId" + FROM reservations.reservations reservation + WHERE reservation."PendingAllocationAmendmentId" IS NOT NULL + GROUP BY reservation."PendingAllocationAmendmentId" + HAVING count(*) > 1) + THEN + RAISE EXCEPTION USING + ERRCODE = 'P0001', + MESSAGE = 'duplicate active Inventory amendment coordinate prevents upgrade'; + END IF; + END; + $$; + + UPDATE reservations.reservations + SET "PendingInventoryAmendmentRequestId" = + "PendingAllocationAmendmentId" + WHERE "PendingAllocationAmendmentId" IS NOT NULL; + """; + + internal const string UpProtocolSql = + """ + CREATE OR REPLACE FUNCTION + reservations.is_authorized_tenant_destruction( + target_scope_id text) + RETURNS boolean + LANGUAGE sql + STABLE + AS $$ + SELECT EXISTS ( + SELECT 1 + FROM reservations.tenant_destroy_operations operation + INNER JOIN reservations.tenant_revisions state + ON state."ScopeId" = operation."ScopeId" + WHERE operation."OperationId"::text = current_setting( + 'bunkfy.reservations_tenant_destroy_operation_id', + true) + AND operation."ScopeId" = target_scope_id + AND state."LifecycleStatus" = 2 + AND state."DestroyOperationId" = operation."OperationId" + AND state."DestroyRequestSha256" = operation."RequestSha256"); + $$; + + CREATE OR REPLACE FUNCTION + reservations.enforce_stay_amendment_transition() + RETURNS trigger + LANGUAGE plpgsql + AS $$ + BEGIN + IF TG_OP = 'DELETE' THEN + IF reservations.is_authorized_tenant_destruction( + OLD."ScopeId") + THEN + RETURN OLD; + END IF; + + RAISE EXCEPTION USING + ERRCODE = 'P0001', + MESSAGE = 'stay-amendment evidence cannot be deleted'; + END IF; + + IF TG_OP = 'INSERT' THEN + IF NEW."OperationVersion" <> 1 OR + NEW."ReconciliationCount" <> 0 OR + NEW."UpdatedAtUtc" <> NEW."RequestedAtUtc" OR + NEW."LastReconciledAtUtc" IS NOT NULL OR + NEW."LastReconciledBy" IS NOT NULL + THEN + RAISE EXCEPTION USING + ERRCODE = 'P0001', + MESSAGE = 'stay-amendment initial evidence is invalid'; + END IF; + + IF NEW."Outcome" = 1 AND + NEW."CompletedAtUtc" IS NULL AND + NEW."ResultingDetailsRevision" IS NULL AND + NEW."ResultingReservationVersion" IS NULL AND + NEW."ResultingAllocationVersion" IS NULL AND + NEW."RejectionCode" IS NULL + THEN + RETURN NEW; + END IF; + + IF NEW."Outcome" = 2 AND + NEW."RequestSchemaVersion" = 2 AND + NEW."CompletedAtUtc" = NEW."RequestedAtUtc" AND + NEW."ResultingDetailsRevision" IS NOT NULL AND + NEW."ResultingReservationVersion" IS NOT NULL AND + NEW."ResultingAllocationVersion" IS NOT NULL AND + NEW."RejectionCode" IS NULL + THEN + RETURN NEW; + END IF; + + IF NEW."Outcome" = 4 AND + NEW."RequestSchemaVersion" = 1 AND + NEW."CompletedAtUtc" IS NULL AND + NEW."ResultingDetailsRevision" IS NULL AND + NEW."ResultingReservationVersion" IS NULL AND + NEW."ResultingAllocationVersion" IS NULL AND + NEW."RejectionCode" IS NULL + THEN + RETURN NEW; + END IF; + + RAISE EXCEPTION USING + ERRCODE = 'P0001', + MESSAGE = 'stay-amendment initial outcome is invalid'; + END IF; + + IF (NEW."ScopeId", NEW."ReservationId", NEW."Id", + NEW."PropertyId", NEW."InventoryRequestId", + NEW."RequestSchemaVersion", + NEW."RequestFingerprint", NEW."TargetArrival", + NEW."TargetDeparture", NEW."TargetExpectedArrivalTime", + NEW."TargetExpectedDepartureTime", + NEW."TargetInventoryUnitIds", + NEW."ExpectedDetailsRevision", NEW."RequestedBy", + NEW."RequestedAtUtc") + IS DISTINCT FROM + (OLD."ScopeId", OLD."ReservationId", OLD."Id", + OLD."PropertyId", OLD."InventoryRequestId", + OLD."RequestSchemaVersion", + OLD."RequestFingerprint", OLD."TargetArrival", + OLD."TargetDeparture", OLD."TargetExpectedArrivalTime", + OLD."TargetExpectedDepartureTime", + OLD."TargetInventoryUnitIds", + OLD."ExpectedDetailsRevision", OLD."RequestedBy", + OLD."RequestedAtUtc") + THEN + RAISE EXCEPTION USING + ERRCODE = 'P0001', + MESSAGE = 'stay-amendment request evidence is immutable'; + END IF; + + IF OLD."Outcome" <> 1 THEN + RAISE EXCEPTION USING + ERRCODE = 'P0001', + MESSAGE = 'terminal stay-amendment evidence is immutable'; + END IF; + + IF NEW."Outcome" = 1 THEN + IF NEW."OperationVersion" <> OLD."OperationVersion" + 1 OR + NEW."ReconciliationCount" <> + OLD."ReconciliationCount" + 1 OR + NEW."UpdatedAtUtc" < + OLD."UpdatedAtUtc" + interval '5 minutes' OR + NEW."LastReconciledAtUtc" <> NEW."UpdatedAtUtc" OR + NEW."LastReconciledBy" IS NULL OR + (NEW."CompletedAtUtc", NEW."ResultingDetailsRevision", + NEW."ResultingReservationVersion", + NEW."ResultingAllocationVersion", NEW."RejectionCode") + IS DISTINCT FROM + (OLD."CompletedAtUtc", OLD."ResultingDetailsRevision", + OLD."ResultingReservationVersion", + OLD."ResultingAllocationVersion", OLD."RejectionCode") + THEN + RAISE EXCEPTION USING + ERRCODE = 'P0001', + MESSAGE = 'stay-amendment reconciliation advance is invalid'; + END IF; + + RETURN NEW; + END IF; + + IF NEW."Outcome" IN (2, 3) THEN + IF NEW."OperationVersion" <> OLD."OperationVersion" + 1 OR + NEW."ReconciliationCount" <> + OLD."ReconciliationCount" OR + NEW."LastReconciledAtUtc" IS DISTINCT FROM + OLD."LastReconciledAtUtc" OR + NEW."LastReconciledBy" IS DISTINCT FROM + OLD."LastReconciledBy" OR + NEW."UpdatedAtUtc" < OLD."UpdatedAtUtc" OR + NEW."CompletedAtUtc" <> NEW."UpdatedAtUtc" + THEN + RAISE EXCEPTION USING + ERRCODE = 'P0001', + MESSAGE = 'stay-amendment terminal advance is invalid'; + END IF; + + RETURN NEW; + END IF; + + RAISE EXCEPTION USING + ERRCODE = 'P0001', + MESSAGE = 'stay-amendment outcome transition is invalid'; + END; + $$; + + CREATE OR REPLACE FUNCTION + reservations.enforce_reservation_version_advance() + RETURNS trigger + LANGUAGE plpgsql + AS $$ + BEGIN + IF NEW."Version" <> OLD."Version" + 1 THEN + RAISE EXCEPTION USING + ERRCODE = 'P0001', + MESSAGE = 'reservation updates require a version advance'; + END IF; + + RETURN NEW; + END; + $$; + + CREATE OR REPLACE FUNCTION + reservations.validate_stay_amendment_coordinates() + RETURNS trigger + LANGUAGE plpgsql + AS $$ + DECLARE + parent_kind integer; + parent_property_id uuid; + parent_expected_revision bigint; + parent_fingerprint text; + reservation_property_id uuid; + reservation_status integer; + reservation_details_revision bigint; + reservation_version bigint; + reservation_allocation_version bigint; + reservation_arrival date; + reservation_departure date; + reservation_expected_arrival time without time zone; + reservation_expected_departure time without time zone; + reservation_pending_id uuid; + reservation_pending_inventory_id uuid; + reservation_pending_fingerprint text; + reservation_pending_arrival date; + reservation_pending_departure date; + reservation_pending_expected_arrival time without time zone; + reservation_pending_expected_departure time without time zone; + reservation_pending_units text; + reservation_pending_origin integer; + reservation_pending_actor text; + reservation_last_rejection integer; + reservation_xmin xid; + reservation_xmin_status text; + target_unit_count integer; + current_unit_count integer; + canonical_units text; + expected_fingerprint text; + BEGIN + SELECT parent."Kind", parent."PropertyId", + parent."ExpectedDetailsRevision", + parent."RequestFingerprint" + INTO parent_kind, parent_property_id, + parent_expected_revision, parent_fingerprint + FROM reservations.management_operations parent + WHERE parent."ScopeId" = NEW."ScopeId" + AND parent."ReservationId" = NEW."ReservationId" + AND parent."Id" = NEW."Id" + FOR UPDATE; + + IF NOT FOUND OR + parent_kind <> (CASE + WHEN NEW."RequestSchemaVersion" = 1 THEN 6 + WHEN NEW."RequestSchemaVersion" = 2 THEN 7 + ELSE 0 + END) OR + parent_property_id <> NEW."PropertyId" OR + parent_expected_revision <> + NEW."ExpectedDetailsRevision" OR + parent_fingerprint <> NEW."RequestFingerprint" + THEN + RAISE EXCEPTION USING + ERRCODE = 'P0001', + MESSAGE = 'stay-amendment parent coordinates do not match'; + END IF; + + SELECT reservation."PropertyId", reservation."Status", + reservation."DetailsRevision", reservation."Version", + reservation."AllocationVersion", + reservation."Arrival", reservation."Departure", + reservation."ExpectedArrivalTime", + reservation."ExpectedDepartureTime", + reservation."PendingAllocationAmendmentId", + reservation."PendingInventoryAmendmentRequestId", + reservation."PendingAllocationAmendmentRequestFingerprint", + reservation."PendingArrival", + reservation."PendingDeparture", + reservation."PendingExpectedArrivalTime", + reservation."PendingExpectedDepartureTime", + reservation."PendingInventoryUnitIds", + reservation."PendingDetailsChangeOrigin", + reservation."PendingDetailsActorId", + reservation."LastAllocationAmendmentRejectionCode", + reservation.xmin + INTO reservation_property_id, reservation_status, + reservation_details_revision, reservation_version, + reservation_allocation_version, + reservation_arrival, reservation_departure, + reservation_expected_arrival, + reservation_expected_departure, + reservation_pending_id, + reservation_pending_inventory_id, + reservation_pending_fingerprint, + reservation_pending_arrival, + reservation_pending_departure, + reservation_pending_expected_arrival, + reservation_pending_expected_departure, + reservation_pending_units, + reservation_pending_origin, + reservation_pending_actor, + reservation_last_rejection, + reservation_xmin + FROM reservations.reservations reservation + WHERE reservation."ScopeId" = NEW."ScopeId" + AND reservation."Id" = NEW."ReservationId" + FOR UPDATE; + + IF NOT FOUND OR + reservation_property_id <> NEW."PropertyId" + THEN + RAISE EXCEPTION USING + ERRCODE = 'P0001', + MESSAGE = 'stay-amendment reservation coordinates do not match'; + END IF; + + reservation_xmin_status := pg_xact_status( + reservation_xmin::text::xid8); + + IF NEW."Outcome" = 4 THEN + RETURN NEW; + END IF; + + target_unit_count := cardinality( + string_to_array(NEW."TargetInventoryUnitIds", ',')); + SELECT string_agg(target.value, ',' ORDER BY target.value) + INTO canonical_units + FROM unnest(string_to_array( + NEW."TargetInventoryUnitIds", ',')) target(value); + + IF target_unit_count <> ( + SELECT count(DISTINCT target.value)::integer + FROM unnest(string_to_array( + NEW."TargetInventoryUnitIds", ',')) target(value)) OR + NEW."TargetInventoryUnitIds" <> canonical_units + THEN + RAISE EXCEPTION USING + ERRCODE = 'P0001', + MESSAGE = 'stay-amendment target units must be canonical'; + END IF; + + expected_fingerprint := CASE NEW."RequestSchemaVersion" + WHEN 1 THEN encode(sha256(convert_to( + replace(NEW."ReservationId"::text, '-', '') || '|' || + replace(NEW."Id"::text, '-', '') || '|' || + NEW."ExpectedDetailsRevision"::text || '|' || + canonical_units, + 'UTF8')), 'hex') + WHEN 2 THEN encode(sha256(convert_to( + 'v2|reservation=' || + replace(NEW."ReservationId"::text, '-', '') || + '|operation=' || replace(NEW."Id"::text, '-', '') || + '|expected-details-revision=' || + NEW."ExpectedDetailsRevision"::text || + '|arrival=' || to_char(NEW."TargetArrival", 'YYYY-MM-DD') || + '|departure=' || to_char(NEW."TargetDeparture", 'YYYY-MM-DD') || + '|expected-arrival-time=' || coalesce( + to_char(NEW."TargetExpectedArrivalTime", 'HH24:MI'), '-') || + '|expected-departure-time=' || coalesce( + to_char(NEW."TargetExpectedDepartureTime", 'HH24:MI'), '-') || + '|units=' || canonical_units, + 'UTF8')), 'hex') + ELSE NULL + END; + + IF NEW."RequestFingerprint" IS DISTINCT FROM expected_fingerprint + THEN + RAISE EXCEPTION USING + ERRCODE = 'P0001', + MESSAGE = 'stay-amendment request fingerprint does not match'; + END IF; + + IF reservation_status <> 2 THEN + RAISE EXCEPTION USING + ERRCODE = 'P0001', + MESSAGE = 'stay-amendment requires a confirmed reservation'; + END IF; + + IF NEW."Outcome" = 1 THEN + IF reservation_pending_id IS DISTINCT FROM NEW."Id" OR + reservation_pending_inventory_id IS DISTINCT FROM + NEW."InventoryRequestId" OR + reservation_pending_fingerprint IS DISTINCT FROM + NEW."RequestFingerprint" OR + reservation_pending_arrival IS DISTINCT FROM + NEW."TargetArrival" OR + reservation_pending_departure IS DISTINCT FROM + NEW."TargetDeparture" OR + reservation_pending_expected_arrival IS DISTINCT FROM + NEW."TargetExpectedArrivalTime" OR + reservation_pending_expected_departure IS DISTINCT FROM + NEW."TargetExpectedDepartureTime" OR + reservation_pending_units IS DISTINCT FROM + NEW."TargetInventoryUnitIds" OR + reservation_pending_origin <> 1 OR + reservation_pending_actor IS DISTINCT FROM + NEW."RequestedBy" + THEN + RAISE EXCEPTION USING + ERRCODE = 'P0001', + MESSAGE = 'pending stay-amendment candidate does not match'; + END IF; + + IF reservation_xmin_status <> 'in progress' + THEN + RAISE EXCEPTION USING + ERRCODE = 'P0001', + MESSAGE = 'stay-amendment graph change requires a reservation version advance'; + END IF; + + RETURN NEW; + END IF; + + IF reservation_pending_id IS NOT NULL OR + reservation_pending_inventory_id IS NOT NULL OR + reservation_details_revision IS DISTINCT FROM + NEW."ResultingDetailsRevision" OR + reservation_version IS DISTINCT FROM + NEW."ResultingReservationVersion" + THEN + RAISE EXCEPTION USING + ERRCODE = 'P0001', + MESSAGE = 'terminal stay-amendment result does not match'; + END IF; + + IF NEW."Outcome" = 3 THEN + IF reservation_last_rejection IS DISTINCT FROM + NEW."RejectionCode" + THEN + RAISE EXCEPTION USING + ERRCODE = 'P0001', + MESSAGE = 'rejected stay-amendment result does not match'; + END IF; + + IF reservation_xmin_status <> 'in progress' + THEN + RAISE EXCEPTION USING + ERRCODE = 'P0001', + MESSAGE = 'stay-amendment graph change requires a reservation version advance'; + END IF; + + RETURN NEW; + END IF; + + IF reservation_allocation_version IS DISTINCT FROM + NEW."ResultingAllocationVersion" + THEN + RAISE EXCEPTION USING + ERRCODE = 'P0001', + MESSAGE = 'applied stay-amendment allocation version does not match'; + END IF; + + IF reservation_arrival IS DISTINCT FROM + NEW."TargetArrival" OR + reservation_departure IS DISTINCT FROM + NEW."TargetDeparture" OR + reservation_expected_arrival IS DISTINCT FROM + NEW."TargetExpectedArrivalTime" OR + reservation_expected_departure IS DISTINCT FROM + NEW."TargetExpectedDepartureTime" + THEN + RAISE EXCEPTION USING + ERRCODE = 'P0001', + MESSAGE = 'applied stay-amendment target does not match'; + END IF; + + SELECT count(*)::integer + INTO current_unit_count + FROM reservations.requested_inventory_units unit + WHERE unit."ScopeId" = NEW."ScopeId" + AND unit."ReservationId" = NEW."ReservationId"; + + IF current_unit_count <> target_unit_count OR EXISTS ( + SELECT 1 + FROM unnest(string_to_array( + NEW."TargetInventoryUnitIds", ',')) target(value) + WHERE NOT EXISTS ( + SELECT 1 + FROM reservations.requested_inventory_units unit + WHERE unit."ScopeId" = NEW."ScopeId" + AND unit."ReservationId" = NEW."ReservationId" + AND replace(unit."Id"::text, '-', '') = + target.value)) + THEN + RAISE EXCEPTION USING + ERRCODE = 'P0001', + MESSAGE = 'applied stay-amendment units do not match'; + END IF; + + IF reservation_xmin_status <> 'in progress' + THEN + RAISE EXCEPTION USING + ERRCODE = 'P0001', + MESSAGE = 'stay-amendment graph change requires a reservation version advance'; + END IF; + + RETURN NEW; + END; + $$; + + CREATE OR REPLACE FUNCTION + reservations.protect_stay_amendment_parent() + RETURNS trigger + LANGUAGE plpgsql + AS $$ + BEGIN + IF TG_OP = 'UPDATE' AND + (OLD."Kind" IN (6, 7) OR NEW."Kind" IN (6, 7)) THEN + IF (NEW."ScopeId", NEW."ReservationId", NEW."Id", + NEW."PropertyId", NEW."Kind", + NEW."ExpectedVersion", + NEW."ExpectedDetailsRevision", NEW."BusinessDate", + NEW."CreatedAtUtc", NEW."RequestFingerprint") + IS DISTINCT FROM + (OLD."ScopeId", OLD."ReservationId", OLD."Id", + OLD."PropertyId", OLD."Kind", + OLD."ExpectedVersion", + OLD."ExpectedDetailsRevision", OLD."BusinessDate", + OLD."CreatedAtUtc", OLD."RequestFingerprint") + THEN + RAISE EXCEPTION USING + ERRCODE = 'P0001', + MESSAGE = 'amendment parent coordinates are immutable'; + END IF; + END IF; + + IF EXISTS ( + SELECT 1 + FROM reservations.stay_amendment_operations child + WHERE child."ScopeId" = OLD."ScopeId" + AND child."ReservationId" = OLD."ReservationId" + AND child."Id" = OLD."Id") + THEN + IF TG_OP = 'DELETE' AND + reservations.is_authorized_tenant_destruction( + OLD."ScopeId") + THEN + RETURN OLD; + END IF; + + RAISE EXCEPTION USING + ERRCODE = 'P0001', + MESSAGE = 'stay-amendment parent evidence is immutable'; + END IF; + + IF TG_OP = 'DELETE' THEN + RETURN OLD; + END IF; + + RETURN NEW; + END; + $$; + + CREATE OR REPLACE FUNCTION + reservations.require_stay_amendment_child() + RETURNS trigger + LANGUAGE plpgsql + AS $$ + BEGIN + IF NEW."Kind" IN (6, 7) AND NOT EXISTS ( + SELECT 1 + FROM reservations.stay_amendment_operations child + WHERE child."ScopeId" = NEW."ScopeId" + AND child."ReservationId" = NEW."ReservationId" + AND child."Id" = NEW."Id") + THEN + RAISE EXCEPTION USING + ERRCODE = 'P0001', + MESSAGE = 'stay-amendment parent requires exact child evidence'; + END IF; + + RETURN NEW; + END; + $$; + + CREATE OR REPLACE FUNCTION + reservations.validate_reservation_pending_stay_amendment() + RETURNS trigger + LANGUAGE plpgsql + AS $$ + DECLARE + live_reservation reservations.reservations%ROWTYPE; + BEGIN + IF OLD."PendingAllocationAmendmentId" IS NOT NULL AND + NEW."PendingAllocationAmendmentId" IS NOT NULL AND + (NEW."PendingAllocationAmendmentId" IS DISTINCT FROM + OLD."PendingAllocationAmendmentId" OR + (NEW."PendingInventoryAmendmentRequestId" IS DISTINCT FROM + OLD."PendingInventoryAmendmentRequestId" AND NOT ( + OLD."PendingInventoryAmendmentRequestId" IS NULL AND + NEW."PendingInventoryAmendmentRequestId" = + NEW."PendingAllocationAmendmentId"))) + THEN + RAISE EXCEPTION USING + ERRCODE = 'P0001', + MESSAGE = 'active reservation amendment coordinates are immutable'; + END IF; + + SELECT reservation.* + INTO live_reservation + FROM reservations.reservations reservation + WHERE reservation."ScopeId" = NEW."ScopeId" + AND reservation."Id" = NEW."Id" + FOR UPDATE; + + IF NOT FOUND THEN + RETURN NEW; + END IF; + + IF EXISTS ( + SELECT 1 + FROM reservations.stay_amendment_operations child + WHERE child."ScopeId" = live_reservation."ScopeId" + AND child."ReservationId" = live_reservation."Id" + AND child."Outcome" = 1 + AND (live_reservation."PropertyId" <> child."PropertyId" OR + live_reservation."Status" <> 2 OR + live_reservation."PendingAllocationAmendmentId" IS DISTINCT FROM + child."Id" OR + live_reservation."PendingInventoryAmendmentRequestId" IS DISTINCT FROM + child."InventoryRequestId" OR + live_reservation."PendingAllocationAmendmentRequestFingerprint" + IS DISTINCT FROM child."RequestFingerprint" OR + live_reservation."PendingArrival" IS DISTINCT FROM + child."TargetArrival" OR + live_reservation."PendingDeparture" IS DISTINCT FROM + child."TargetDeparture" OR + live_reservation."PendingExpectedArrivalTime" IS DISTINCT FROM + child."TargetExpectedArrivalTime" OR + live_reservation."PendingExpectedDepartureTime" IS DISTINCT FROM + child."TargetExpectedDepartureTime" OR + live_reservation."PendingInventoryUnitIds" IS DISTINCT FROM + child."TargetInventoryUnitIds" OR + live_reservation."PendingDetailsChangeOrigin" <> 1 OR + live_reservation."PendingDetailsActorId" IS DISTINCT FROM + child."RequestedBy")) + THEN + RAISE EXCEPTION USING + ERRCODE = 'P0001', + MESSAGE = 'reservation pending stay-amendment evidence does not match'; + END IF; + + IF live_reservation."PendingAllocationAmendmentId" IS NOT NULL AND + live_reservation."PendingDetailsChangeOrigin" = 1 AND + NOT EXISTS ( + SELECT 1 + FROM reservations.stay_amendment_operations child + WHERE child."ScopeId" = live_reservation."ScopeId" + AND child."ReservationId" = live_reservation."Id" + AND child."Id" = + live_reservation."PendingAllocationAmendmentId" + AND child."InventoryRequestId" = + live_reservation."PendingInventoryAmendmentRequestId" + AND child."Outcome" = 1) + THEN + RAISE EXCEPTION USING + ERRCODE = 'P0001', + MESSAGE = 'Staff stay-amendment pending state requires durable evidence'; + END IF; + + RETURN NEW; + END; + $$; + + CREATE TRIGGER stay_amendment_operations_transition + BEFORE INSERT OR UPDATE OR DELETE + ON reservations.stay_amendment_operations + FOR EACH ROW + EXECUTE FUNCTION + reservations.enforce_stay_amendment_transition(); + + CREATE TRIGGER reservation_version_monotonic + BEFORE UPDATE + ON reservations.reservations + FOR EACH ROW + EXECUTE FUNCTION + reservations.enforce_reservation_version_advance(); + + CREATE CONSTRAINT TRIGGER + stay_amendment_operations_coordinates + AFTER INSERT OR UPDATE + ON reservations.stay_amendment_operations + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW + EXECUTE FUNCTION + reservations.validate_stay_amendment_coordinates(); + + CREATE TRIGGER stay_amendment_parent_immutable + BEFORE UPDATE OR DELETE + ON reservations.management_operations + FOR EACH ROW + EXECUTE FUNCTION + reservations.protect_stay_amendment_parent(); + + CREATE CONSTRAINT TRIGGER stay_amendment_parent_complete + AFTER INSERT OR UPDATE + ON reservations.management_operations + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW + EXECUTE FUNCTION + reservations.require_stay_amendment_child(); + + CREATE CONSTRAINT TRIGGER reservation_pending_stay_amendment + AFTER INSERT OR UPDATE + ON reservations.reservations + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW + EXECUTE FUNCTION + reservations.validate_reservation_pending_stay_amendment(); + + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM reservations.reservations reservation + WHERE (reservation."PendingAllocationAmendmentId" IS NOT NULL OR + EXISTS ( + SELECT 1 + FROM reservations.management_operations parent + WHERE parent."ScopeId" = reservation."ScopeId" + AND parent."ReservationId" = reservation."Id" + AND parent."Kind" = 6)) + AND reservation."Version" = 9223372036854775807) + THEN + RAISE EXCEPTION USING + ERRCODE = 'P0001', + MESSAGE = 'reservation version overflow prevents stay-amendment backfill'; + END IF; + END; + $$; + + WITH affected_reservations AS ( + SELECT reservation."ScopeId", reservation."Id" + FROM reservations.reservations reservation + WHERE reservation."PendingAllocationAmendmentId" IS NOT NULL OR + EXISTS ( + SELECT 1 + FROM reservations.management_operations parent + WHERE parent."ScopeId" = reservation."ScopeId" + AND parent."ReservationId" = reservation."Id" + AND parent."Kind" = 6)) + UPDATE reservations.reservations target + SET "Version" = target."Version" + 1 + FROM affected_reservations affected + WHERE target."ScopeId" = affected."ScopeId" + AND target."Id" = affected."Id"; + + INSERT INTO reservations.stay_amendment_operations + ("Id", "ScopeId", "ReservationId", "PropertyId", + "InventoryRequestId", + "RequestSchemaVersion", "RequestFingerprint", + "TargetArrival", "TargetDeparture", + "TargetExpectedArrivalTime", + "TargetExpectedDepartureTime", + "TargetInventoryUnitIds", "ExpectedDetailsRevision", + "RequestedBy", "Outcome", "OperationVersion", + "RequestedAtUtc", "UpdatedAtUtc", "CompletedAtUtc", + "ResultingDetailsRevision", + "ResultingReservationVersion", "ResultingAllocationVersion", + "RejectionCode", + "ReconciliationCount", "LastReconciledAtUtc", + "LastReconciledBy") + SELECT parent."Id", parent."ScopeId", parent."ReservationId", + parent."PropertyId", + CASE WHEN pending.is_exact + THEN reservation."PendingInventoryAmendmentRequestId" + ELSE NULL END, + 1, parent."RequestFingerprint", + CASE WHEN pending.is_exact THEN reservation."PendingArrival" + ELSE NULL END, + CASE WHEN pending.is_exact THEN reservation."PendingDeparture" + ELSE NULL END, + CASE WHEN pending.is_exact + THEN reservation."PendingExpectedArrivalTime" + ELSE NULL END, + CASE WHEN pending.is_exact + THEN reservation."PendingExpectedDepartureTime" + ELSE NULL END, + CASE WHEN pending.is_exact + THEN reservation."PendingInventoryUnitIds" + ELSE NULL END, + parent."ExpectedDetailsRevision", + CASE WHEN pending.is_exact + THEN reservation."PendingDetailsActorId" + ELSE NULL END, + CASE WHEN pending.is_exact THEN 1 ELSE 4 END, + 1, + parent."CreatedAtUtc", parent."CreatedAtUtc", NULL, + NULL, NULL, NULL, NULL, 0, NULL, NULL + FROM reservations.management_operations parent + INNER JOIN reservations.reservations reservation + ON reservation."ScopeId" = parent."ScopeId" + AND reservation."Id" = parent."ReservationId" + CROSS JOIN LATERAL ( + SELECT + parent."PropertyId" = reservation."PropertyId" AND + reservation."Status" = 2 AND + reservation."PendingAllocationAmendmentId" = parent."Id" AND + reservation."PendingInventoryAmendmentRequestId" IS NOT NULL AND + reservation."PendingAllocationAmendmentRequestFingerprint" = + parent."RequestFingerprint" AND + reservation."PendingArrival" IS NOT NULL AND + reservation."PendingDeparture" IS NOT NULL AND + reservation."PendingArrival" < + reservation."PendingDeparture" AND + reservation."PendingInventoryUnitIds" ~ + '^[0-9a-f]{32}(,[0-9a-f]{32}){0,99}$' AND + reservation."PendingDetailsChangeOrigin" = 1 AND + reservation."PendingDetailsActorId" IS NOT NULL AND + reservation."PendingDetailsActorId" = + trim(reservation."PendingDetailsActorId") AND + char_length(reservation."PendingDetailsActorId") > 0 AND + reservation."PendingDetailsActorId" !~ '[[:cntrl:]]' AND + parent."ExpectedDetailsRevision" = + reservation."DetailsRevision" + AS is_exact) pending + WHERE parent."Kind" = 6; + + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM reservations.reservations reservation + WHERE reservation."PendingAllocationAmendmentId" IS NOT NULL + AND reservation."PendingDetailsChangeOrigin" = 1 + AND NOT EXISTS ( + SELECT 1 + FROM reservations.stay_amendment_operations child + WHERE child."ScopeId" = reservation."ScopeId" + AND child."ReservationId" = reservation."Id" + AND child."Id" = + reservation."PendingAllocationAmendmentId" + AND child."InventoryRequestId" = + reservation."PendingInventoryAmendmentRequestId" + AND child."Outcome" = 1 + AND child."PropertyId" = reservation."PropertyId" + AND child."RequestFingerprint" = + reservation."PendingAllocationAmendmentRequestFingerprint" + AND child."TargetArrival" = reservation."PendingArrival" + AND child."TargetDeparture" = reservation."PendingDeparture" + AND child."TargetExpectedArrivalTime" IS NOT DISTINCT FROM + reservation."PendingExpectedArrivalTime" + AND child."TargetExpectedDepartureTime" IS NOT DISTINCT FROM + reservation."PendingExpectedDepartureTime" + AND child."TargetInventoryUnitIds" = + reservation."PendingInventoryUnitIds" + AND child."RequestedBy" = + reservation."PendingDetailsActorId")) + THEN + RAISE EXCEPTION USING + ERRCODE = 'P0001', + MESSAGE = 'nonexact active Staff stay amendment prevents upgrade'; + END IF; + END; + $$; + """; + + internal const string DownGuardSql = + """ + LOCK TABLE reservations.tenant_revisions + IN SHARE MODE NOWAIT; + LOCK TABLE reservations.management_operations + IN SHARE MODE NOWAIT; + LOCK TABLE reservations.reservations + IN SHARE MODE NOWAIT; + LOCK TABLE reservations.stay_amendment_operations + IN SHARE MODE NOWAIT; + + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM reservations.management_operations parent + WHERE parent."Kind" = 7) OR EXISTS ( + SELECT 1 + FROM reservations.stay_amendment_operations child + WHERE child."RequestSchemaVersion" <> 1 + OR child."Outcome" <> 4 + OR child."OperationVersion" <> 1 + OR child."ReconciliationCount" <> 0) OR EXISTS ( + SELECT 1 + FROM reservations.reservations reservation + WHERE reservation."PendingInventoryAmendmentRequestId" IS NOT NULL + AND reservation."PendingInventoryAmendmentRequestId" + IS DISTINCT FROM + reservation."PendingAllocationAmendmentId") + THEN + RAISE EXCEPTION USING + ERRCODE = 'P0001', + MESSAGE = 'stay-amendment convergence evidence prevents downgrade'; + END IF; + END; + $$; + + DROP TRIGGER IF EXISTS stay_amendment_parent_immutable + ON reservations.management_operations; + DROP TRIGGER IF EXISTS stay_amendment_parent_complete + ON reservations.management_operations; + DROP TRIGGER IF EXISTS reservation_pending_stay_amendment + ON reservations.reservations; + DROP TRIGGER IF EXISTS reservation_version_monotonic + ON reservations.reservations; + DROP FUNCTION IF EXISTS + reservations.protect_stay_amendment_parent(); + DROP FUNCTION IF EXISTS + reservations.require_stay_amendment_child(); + DROP FUNCTION IF EXISTS + reservations.validate_reservation_pending_stay_amendment(); + DROP FUNCTION IF EXISTS + reservations.enforce_reservation_version_advance(); + """; + + internal const string DownCleanupSql = + """ + DROP FUNCTION IF EXISTS + reservations.validate_stay_amendment_coordinates(); + DROP FUNCTION IF EXISTS + reservations.enforce_stay_amendment_transition(); + DROP FUNCTION IF EXISTS + reservations.is_authorized_tenant_destruction(text); + """; +} diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence.PostgreSqlMigrations/Migrations/ReservationsDbContextModelSnapshot.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence.PostgreSqlMigrations/Migrations/ReservationsDbContextModelSnapshot.cs index 259794a4..d3626bc2 100644 --- a/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence.PostgreSqlMigrations/Migrations/ReservationsDbContextModelSnapshot.cs +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence.PostgreSqlMigrations/Migrations/ReservationsDbContextModelSnapshot.cs @@ -183,6 +183,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("PendingGuestCount") .HasColumnType("integer"); + b.Property("PendingInventoryAmendmentRequestId") + .HasColumnType("uuid"); + b.Property("PendingInventoryUnitIds") .HasMaxLength(3300) .HasColumnType("character varying(3300)"); @@ -275,6 +278,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); + b.HasIndex("PendingInventoryAmendmentRequestId") + .IsUnique(); + b.HasIndex("ProjectionOrdinal") .IsUnique(); @@ -306,6 +312,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) t.HasCheckConstraint("CK_reservations_no_show_complete", "(\"Status\" = 8 AND \"NoShowBusinessDate\" IS NOT NULL AND \"NoShowAtUtc\" IS NOT NULL AND \"NoShowBy\" IS NOT NULL AND length(trim(\"NoShowBy\")) > 0) OR (\"Status\" <> 8 AND \"NoShowBusinessDate\" IS NULL AND \"NoShowAtUtc\" IS NULL AND \"NoShowBy\" IS NULL)"); + t.HasCheckConstraint("CK_reservations_pending_inventory_request", "(\"PendingAllocationAmendmentId\" IS NULL AND \"PendingInventoryAmendmentRequestId\" IS NULL) OR (\"PendingAllocationAmendmentId\" IS NOT NULL AND \"PendingInventoryAmendmentRequestId\" IS NOT NULL)"); + t.HasCheckConstraint("CK_reservations_pending_stay_complete", "(\"Status\" IN (7, 9) AND \"PendingStayBusinessDate\" IS NOT NULL AND \"PendingStayActorId\" IS NOT NULL AND length(trim(\"PendingStayActorId\")) > 0 AND \"ReleaseRequestId\" IS NOT NULL) OR (\"Status\" NOT IN (7, 9) AND \"PendingStayBusinessDate\" IS NULL AND \"PendingStayActorId\" IS NULL)"); t.HasCheckConstraint("CK_reservations_terminal_time", "(\"Status\" IN (3, 5, 8, 10) AND \"TerminalAtUtc\" IS NOT NULL) OR (\"Status\" NOT IN (3, 5, 8, 10) AND \"TerminalAtUtc\" IS NULL)"); @@ -1404,6 +1412,119 @@ protected override void BuildModel(ModelBuilder modelBuilder) }); }); + modelBuilder.Entity("BunkFy.Modules.Reservations.Domain.StayAmendments.ReservationStayAmendmentOperation", b => + { + b.Property("ScopeId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ReservationId") + .HasColumnType("uuid"); + + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpectedDetailsRevision") + .HasColumnType("bigint"); + + b.Property("InventoryRequestId") + .HasColumnType("uuid"); + + b.Property("LastReconciledAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LastReconciledBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OperationVersion") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.Property("Outcome") + .HasColumnType("integer"); + + b.Property("PropertyId") + .HasColumnType("uuid"); + + b.Property("ReconciliationCount") + .HasColumnType("integer"); + + b.Property("RejectionCode") + .HasColumnType("integer"); + + b.Property("RequestFingerprint") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character(64)") + .IsFixedLength(); + + b.Property("RequestSchemaVersion") + .HasColumnType("integer"); + + b.Property("RequestedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RequestedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ResultingAllocationVersion") + .HasColumnType("bigint"); + + b.Property("ResultingDetailsRevision") + .HasColumnType("bigint"); + + b.Property("ResultingReservationVersion") + .HasColumnType("bigint"); + + b.Property("TargetArrival") + .HasColumnType("date"); + + b.Property("TargetDeparture") + .HasColumnType("date"); + + b.Property("TargetExpectedArrivalTime") + .HasPrecision(0) + .HasColumnType("time(0) without time zone"); + + b.Property("TargetExpectedDepartureTime") + .HasPrecision(0) + .HasColumnType("time(0) without time zone"); + + b.Property("TargetInventoryUnitIds") + .HasMaxLength(3300) + .HasColumnType("character varying(3300)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("ScopeId", "ReservationId", "Id"); + + b.HasIndex("InventoryRequestId") + .IsUnique(); + + b.HasIndex("ScopeId", "PropertyId", "Outcome", "UpdatedAtUtc", "Id", "ReservationId"); + + b.ToTable("stay_amendment_operations", "reservations", t => + { + t.HasCheckConstraint("CK_stay_amendment_operations_outcome", "(\"Outcome\" = 1 AND \"CompletedAtUtc\" IS NULL AND \"ResultingDetailsRevision\" IS NULL AND \"ResultingReservationVersion\" IS NULL AND \"ResultingAllocationVersion\" IS NULL AND \"RejectionCode\" IS NULL) OR (\"Outcome\" = 2 AND \"CompletedAtUtc\" IS NOT NULL AND \"ResultingDetailsRevision\" >= 1 AND \"ResultingReservationVersion\" >= 1 AND \"ResultingAllocationVersion\" >= 1 AND \"RejectionCode\" IS NULL) OR (\"Outcome\" = 3 AND \"CompletedAtUtc\" IS NOT NULL AND \"ResultingDetailsRevision\" >= 1 AND \"ResultingReservationVersion\" >= 1 AND \"ResultingAllocationVersion\" IS NULL AND \"RejectionCode\" >= 1) OR (\"Outcome\" = 4 AND \"CompletedAtUtc\" IS NULL AND \"ResultingDetailsRevision\" IS NULL AND \"ResultingReservationVersion\" IS NULL AND \"ResultingAllocationVersion\" IS NULL AND \"RejectionCode\" IS NULL AND \"ReconciliationCount\" = 0)"); + + t.HasCheckConstraint("CK_stay_amendment_operations_reconciliation", "(\"ReconciliationCount\" = 0 AND \"LastReconciledAtUtc\" IS NULL AND \"LastReconciledBy\" IS NULL) OR (\"ReconciliationCount\" >= 1 AND \"LastReconciledAtUtc\" IS NOT NULL AND \"LastReconciledBy\" IS NOT NULL AND \"LastReconciledBy\" = trim(\"LastReconciledBy\") AND char_length(\"LastReconciledBy\") > 0 AND \"LastReconciledBy\" !~ '[[:cntrl:]]')"); + + t.HasCheckConstraint("CK_stay_amendment_operations_request", "\"RequestSchemaVersion\" IN (1, 2) AND char_length(\"RequestFingerprint\") = 64 AND \"RequestFingerprint\" ~ '^[0-9a-f]{64}$' AND \"ExpectedDetailsRevision\" >= 1 AND ((\"Outcome\" = 4 AND \"RequestSchemaVersion\" = 1 AND \"RequestedBy\" IS NULL AND \"InventoryRequestId\" IS NULL) OR (\"Outcome\" = 1 AND \"InventoryRequestId\" IS NOT NULL AND \"RequestedBy\" IS NOT NULL AND \"RequestedBy\" = trim(\"RequestedBy\") AND char_length(\"RequestedBy\") > 0 AND \"RequestedBy\" !~ '[[:cntrl:]]') OR (\"Outcome\" IN (2, 3) AND ((\"OperationVersion\" = 1 AND \"InventoryRequestId\" IS NULL) OR (\"OperationVersion\" > 1 AND \"InventoryRequestId\" IS NOT NULL)) AND \"RequestedBy\" IS NOT NULL AND \"RequestedBy\" = trim(\"RequestedBy\") AND char_length(\"RequestedBy\") > 0 AND \"RequestedBy\" !~ '[[:cntrl:]]'))"); + + t.HasCheckConstraint("CK_stay_amendment_operations_target", "(\"Outcome\" = 4 AND \"TargetArrival\" IS NULL AND \"TargetDeparture\" IS NULL AND \"TargetExpectedArrivalTime\" IS NULL AND \"TargetExpectedDepartureTime\" IS NULL AND \"TargetInventoryUnitIds\" IS NULL) OR (\"Outcome\" IN (1, 2, 3) AND \"TargetArrival\" IS NOT NULL AND \"TargetDeparture\" IS NOT NULL AND \"TargetArrival\" < \"TargetDeparture\" AND (\"TargetExpectedArrivalTime\" IS NULL OR date_part('second', \"TargetExpectedArrivalTime\") = 0) AND (\"TargetExpectedDepartureTime\" IS NULL OR date_part('second', \"TargetExpectedDepartureTime\") = 0) AND \"TargetInventoryUnitIds\" IS NOT NULL AND \"TargetInventoryUnitIds\" ~ '^[0-9a-f]{32}(,[0-9a-f]{32}){0,99}$')"); + + t.HasCheckConstraint("CK_stay_amendment_operations_timestamps", "\"UpdatedAtUtc\" >= \"RequestedAtUtc\" AND (\"CompletedAtUtc\" IS NULL OR (\"CompletedAtUtc\" >= \"RequestedAtUtc\" AND \"CompletedAtUtc\" <= \"UpdatedAtUtc\")) AND (\"LastReconciledAtUtc\" IS NULL OR (\"LastReconciledAtUtc\" >= \"RequestedAtUtc\" AND \"LastReconciledAtUtc\" <= \"UpdatedAtUtc\"))"); + + t.HasCheckConstraint("CK_stay_amendment_operations_version", "(\"Outcome\" = 1 AND \"OperationVersion\" = \"ReconciliationCount\" + 1) OR (\"Outcome\" = 2 AND ((\"OperationVersion\" = 1 AND \"ReconciliationCount\" = 0) OR \"OperationVersion\" = \"ReconciliationCount\" + 2)) OR (\"Outcome\" = 3 AND \"OperationVersion\" = \"ReconciliationCount\" + 2) OR (\"Outcome\" = 4 AND \"OperationVersion\" = 1 AND \"ReconciliationCount\" = 0)"); + }); + }); + modelBuilder.Entity("BunkFy.Modules.Reservations.Persistence.ReservationArrivalReminder", b => { b.Property("Id") @@ -1861,13 +1982,13 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("management_operations", "reservations", t => { - t.HasCheckConstraint("CK_management_operations_business_date", "(\"Kind\" IN (1, 5, 6) AND \"BusinessDate\" IS NULL) OR (\"Kind\" IN (2, 3, 4) AND \"BusinessDate\" IS NOT NULL)"); + t.HasCheckConstraint("CK_management_operations_business_date", "(\"Kind\" IN (1, 5, 6, 7) AND \"BusinessDate\" IS NULL) OR (\"Kind\" IN (2, 3, 4) AND \"BusinessDate\" IS NOT NULL)"); - t.HasCheckConstraint("CK_management_operations_expected_revision", "(\"Kind\" IN (1, 2, 3, 4) AND \"ExpectedVersion\" > 0 AND \"ExpectedDetailsRevision\" IS NULL) OR (\"Kind\" IN (5, 6) AND \"ExpectedVersion\" IS NULL AND \"ExpectedDetailsRevision\" > 0)"); + t.HasCheckConstraint("CK_management_operations_expected_revision", "(\"Kind\" IN (1, 2, 3, 4) AND \"ExpectedVersion\" > 0 AND \"ExpectedDetailsRevision\" IS NULL) OR (\"Kind\" IN (5, 6, 7) AND \"ExpectedVersion\" IS NULL AND \"ExpectedDetailsRevision\" > 0)"); - t.HasCheckConstraint("CK_management_operations_kind", "\"Kind\" IN (1, 2, 3, 4, 5, 6)"); + t.HasCheckConstraint("CK_management_operations_kind", "\"Kind\" IN (1, 2, 3, 4, 5, 6, 7)"); - t.HasCheckConstraint("CK_management_operations_request_fingerprint", "(\"Kind\" IN (1, 2, 3, 4, 5) AND \"RequestFingerprint\" IS NULL) OR (\"Kind\" = 6 AND \"RequestFingerprint\" IS NOT NULL AND char_length(\"RequestFingerprint\") = 64 AND \"RequestFingerprint\" ~ '^[0-9a-f]{64}$')"); + t.HasCheckConstraint("CK_management_operations_request_fingerprint", "(\"Kind\" IN (1, 2, 3, 4, 5) AND \"RequestFingerprint\" IS NULL) OR (\"Kind\" IN (6, 7) AND \"RequestFingerprint\" IS NOT NULL AND char_length(\"RequestFingerprint\") = 64 AND \"RequestFingerprint\" ~ '^[0-9a-f]{64}$')"); }); }); @@ -2355,6 +2476,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired(); }); + modelBuilder.Entity("BunkFy.Modules.Reservations.Domain.StayAmendments.ReservationStayAmendmentOperation", b => + { + b.HasOne("BunkFy.Modules.Reservations.Persistence.ReservationManagementOperation", null) + .WithOne() + .HasForeignKey("BunkFy.Modules.Reservations.Domain.StayAmendments.ReservationStayAmendmentOperation", "ScopeId", "ReservationId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("BunkFy.Modules.Reservations.Persistence.ReservationInventoryAllocationUnitProjection", b => { b.HasOne("BunkFy.Modules.Reservations.Persistence.ReservationInventoryAllocationProjection", null) diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/Configurations/ReservationConfiguration.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/Configurations/ReservationConfiguration.cs index 4418d5a6..0ffdad35 100644 --- a/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/Configurations/ReservationConfiguration.cs +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/Configurations/ReservationConfiguration.cs @@ -39,6 +39,12 @@ public void Configure(EntityTypeBuilder builder) "CK_reservations_terminal_time", "(\"Status\" IN (3, 5, 8, 10) AND \"TerminalAtUtc\" IS NOT NULL) OR " + "(\"Status\" NOT IN (3, 5, 8, 10) AND \"TerminalAtUtc\" IS NULL)"); + table.HasCheckConstraint( + "CK_reservations_pending_inventory_request", + "(\"PendingAllocationAmendmentId\" IS NULL AND " + + "\"PendingInventoryAmendmentRequestId\" IS NULL) OR " + + "(\"PendingAllocationAmendmentId\" IS NOT NULL AND " + + "\"PendingInventoryAmendmentRequestId\" IS NOT NULL)"); }); builder.HasKey(reservation => reservation.Id); builder.HasAlternateKey(reservation => new { reservation.ScopeId, reservation.Id }); @@ -55,6 +61,8 @@ public void Configure(EntityTypeBuilder builder) builder.Property(reservation => reservation.SourceReference).HasMaxLength(Reservation.SourceReferenceMaxLength); builder.Property(reservation => reservation.Notes).HasMaxLength(Reservation.NotesMaxLength); builder.Property(reservation => reservation.PendingAllocationAmendmentRequestFingerprint).HasMaxLength(Reservation.RequestFingerprintLength).IsFixedLength(); + builder.HasIndex(reservation => reservation.PendingInventoryAmendmentRequestId) + .IsUnique(); builder.Property(reservation => reservation.PendingInventoryUnitIds).HasMaxLength(Reservation.PendingInventoryUnitIdsMaxLength); builder.Property(reservation => reservation.PendingPrimaryGuestName).HasMaxLength(Reservation.PrimaryGuestNameMaxLength); builder.Property(reservation => reservation.PendingPrimaryGuestNameSearch).HasMaxLength(Reservation.PrimaryGuestNameMaxLength); diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/Configurations/ReservationManagementOperationConfiguration.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/Configurations/ReservationManagementOperationConfiguration.cs index a7997033..cdc9512f 100644 --- a/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/Configurations/ReservationManagementOperationConfiguration.cs +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/Configurations/ReservationManagementOperationConfiguration.cs @@ -13,21 +13,21 @@ public void Configure(EntityTypeBuilder builder) { table.HasCheckConstraint( "CK_management_operations_business_date", - "(\"Kind\" IN (1, 5, 6) AND \"BusinessDate\" IS NULL) OR " + + "(\"Kind\" IN (1, 5, 6, 7) AND \"BusinessDate\" IS NULL) OR " + "(\"Kind\" IN (2, 3, 4) AND \"BusinessDate\" IS NOT NULL)"); table.HasCheckConstraint( "CK_management_operations_kind", - "\"Kind\" IN (1, 2, 3, 4, 5, 6)"); + "\"Kind\" IN (1, 2, 3, 4, 5, 6, 7)"); table.HasCheckConstraint( "CK_management_operations_expected_revision", "(\"Kind\" IN (1, 2, 3, 4) AND \"ExpectedVersion\" > 0 AND " + "\"ExpectedDetailsRevision\" IS NULL) OR " + - "(\"Kind\" IN (5, 6) AND \"ExpectedVersion\" IS NULL AND " + + "(\"Kind\" IN (5, 6, 7) AND \"ExpectedVersion\" IS NULL AND " + "\"ExpectedDetailsRevision\" > 0)"); table.HasCheckConstraint( "CK_management_operations_request_fingerprint", "(\"Kind\" IN (1, 2, 3, 4, 5) AND \"RequestFingerprint\" IS NULL) OR " + - "(\"Kind\" = 6 AND \"RequestFingerprint\" IS NOT NULL AND " + + "(\"Kind\" IN (6, 7) AND \"RequestFingerprint\" IS NOT NULL AND " + "char_length(\"RequestFingerprint\") = 64 AND " + "\"RequestFingerprint\" ~ '^[0-9a-f]{64}$')"); }); diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/Configurations/ReservationStayAmendmentOperationConfiguration.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/Configurations/ReservationStayAmendmentOperationConfiguration.cs new file mode 100644 index 00000000..f2364632 --- /dev/null +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/Configurations/ReservationStayAmendmentOperationConfiguration.cs @@ -0,0 +1,183 @@ +namespace BunkFy.Modules.Reservations.Persistence.Configurations; + +using BunkFy.Modules.Reservations.Domain.Aggregates; +using BunkFy.Modules.Reservations.Domain.StayAmendments; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +internal sealed class ReservationStayAmendmentOperationConfiguration + : IEntityTypeConfiguration +{ + public void Configure( + EntityTypeBuilder builder) + { + builder.ToTable("stay_amendment_operations", table => + { + table.HasCheckConstraint( + "CK_stay_amendment_operations_request", + $"\"RequestSchemaVersion\" IN (" + + $"{ReservationStayAmendmentOperation.LegacyRequestSchemaVersion}, " + + $"{ReservationStayAmendmentOperation.CurrentRequestSchemaVersion}) AND " + + $"char_length(\"RequestFingerprint\") = {ReservationStayAmendmentOperation.RequestFingerprintLength} AND " + + "\"RequestFingerprint\" ~ '^[0-9a-f]{64}$' AND " + + "\"ExpectedDetailsRevision\" >= 1 AND " + + $"((\"Outcome\" = {(int)ReservationStayAmendmentOperationOutcome.OutcomeUnknown} AND " + + $"\"RequestSchemaVersion\" = {ReservationStayAmendmentOperation.LegacyRequestSchemaVersion} AND " + + "\"RequestedBy\" IS NULL AND \"InventoryRequestId\" IS NULL) OR " + + $"(\"Outcome\" = {(int)ReservationStayAmendmentOperationOutcome.Pending} AND " + + "\"InventoryRequestId\" IS NOT NULL AND " + + "\"RequestedBy\" IS NOT NULL AND " + + "\"RequestedBy\" = trim(\"RequestedBy\") AND " + + "char_length(\"RequestedBy\") > 0 AND " + + "\"RequestedBy\" !~ '[[:cntrl:]]') OR " + + $"(\"Outcome\" IN ({(int)ReservationStayAmendmentOperationOutcome.Applied}, " + + $"{(int)ReservationStayAmendmentOperationOutcome.Rejected}) AND " + + "((\"OperationVersion\" = 1 AND \"InventoryRequestId\" IS NULL) OR " + + "(\"OperationVersion\" > 1 AND \"InventoryRequestId\" IS NOT NULL)) AND " + + "\"RequestedBy\" IS NOT NULL AND " + + "\"RequestedBy\" = trim(\"RequestedBy\") AND " + + "char_length(\"RequestedBy\") > 0 AND " + + "\"RequestedBy\" !~ '[[:cntrl:]]'))"); + table.HasCheckConstraint( + "CK_stay_amendment_operations_target", + $"(\"Outcome\" = {(int)ReservationStayAmendmentOperationOutcome.OutcomeUnknown} AND " + + "\"TargetArrival\" IS NULL AND \"TargetDeparture\" IS NULL AND " + + "\"TargetExpectedArrivalTime\" IS NULL AND " + + "\"TargetExpectedDepartureTime\" IS NULL AND " + + "\"TargetInventoryUnitIds\" IS NULL) OR " + + $"(\"Outcome\" IN ({(int)ReservationStayAmendmentOperationOutcome.Pending}, " + + $"{(int)ReservationStayAmendmentOperationOutcome.Applied}, " + + $"{(int)ReservationStayAmendmentOperationOutcome.Rejected}) AND " + + "\"TargetArrival\" IS NOT NULL AND \"TargetDeparture\" IS NOT NULL AND " + + "\"TargetArrival\" < \"TargetDeparture\" AND " + + "(\"TargetExpectedArrivalTime\" IS NULL OR " + + "date_part('second', \"TargetExpectedArrivalTime\") = 0) AND " + + "(\"TargetExpectedDepartureTime\" IS NULL OR " + + "date_part('second', \"TargetExpectedDepartureTime\") = 0) AND " + + "\"TargetInventoryUnitIds\" IS NOT NULL AND " + + "\"TargetInventoryUnitIds\" ~ " + + "'^[0-9a-f]{32}(,[0-9a-f]{32}){0,99}$')"); + table.HasCheckConstraint( + "CK_stay_amendment_operations_outcome", + $"(\"Outcome\" = {(int)ReservationStayAmendmentOperationOutcome.Pending} AND " + + "\"CompletedAtUtc\" IS NULL AND \"ResultingDetailsRevision\" IS NULL AND " + + "\"ResultingReservationVersion\" IS NULL AND " + + "\"ResultingAllocationVersion\" IS NULL AND \"RejectionCode\" IS NULL) OR " + + $"(\"Outcome\" = {(int)ReservationStayAmendmentOperationOutcome.Applied} AND " + + "\"CompletedAtUtc\" IS NOT NULL AND \"ResultingDetailsRevision\" >= 1 AND " + + "\"ResultingReservationVersion\" >= 1 AND " + + "\"ResultingAllocationVersion\" >= 1 AND \"RejectionCode\" IS NULL) OR " + + $"(\"Outcome\" = {(int)ReservationStayAmendmentOperationOutcome.Rejected} AND " + + "\"CompletedAtUtc\" IS NOT NULL AND \"ResultingDetailsRevision\" >= 1 AND " + + "\"ResultingReservationVersion\" >= 1 AND " + + "\"ResultingAllocationVersion\" IS NULL AND \"RejectionCode\" >= 1) OR " + + $"(\"Outcome\" = {(int)ReservationStayAmendmentOperationOutcome.OutcomeUnknown} AND " + + "\"CompletedAtUtc\" IS NULL AND \"ResultingDetailsRevision\" IS NULL AND " + + "\"ResultingReservationVersion\" IS NULL AND " + + "\"ResultingAllocationVersion\" IS NULL AND \"RejectionCode\" IS NULL AND " + + "\"ReconciliationCount\" = 0)"); + table.HasCheckConstraint( + "CK_stay_amendment_operations_timestamps", + "\"UpdatedAtUtc\" >= \"RequestedAtUtc\" AND " + + "(\"CompletedAtUtc\" IS NULL OR " + + "(\"CompletedAtUtc\" >= \"RequestedAtUtc\" AND " + + "\"CompletedAtUtc\" <= \"UpdatedAtUtc\")) AND " + + "(\"LastReconciledAtUtc\" IS NULL OR " + + "(\"LastReconciledAtUtc\" >= \"RequestedAtUtc\" AND " + + "\"LastReconciledAtUtc\" <= \"UpdatedAtUtc\"))"); + table.HasCheckConstraint( + "CK_stay_amendment_operations_reconciliation", + "(\"ReconciliationCount\" = 0 AND " + + "\"LastReconciledAtUtc\" IS NULL AND \"LastReconciledBy\" IS NULL) OR " + + "(\"ReconciliationCount\" >= 1 AND " + + "\"LastReconciledAtUtc\" IS NOT NULL AND \"LastReconciledBy\" IS NOT NULL AND " + + "\"LastReconciledBy\" = trim(\"LastReconciledBy\") AND " + + "char_length(\"LastReconciledBy\") > 0 AND " + + "\"LastReconciledBy\" !~ '[[:cntrl:]]')"); + table.HasCheckConstraint( + "CK_stay_amendment_operations_version", + $"(\"Outcome\" = {(int)ReservationStayAmendmentOperationOutcome.Pending} AND " + + "\"OperationVersion\" = \"ReconciliationCount\" + 1) OR " + + $"(\"Outcome\" = {(int)ReservationStayAmendmentOperationOutcome.Applied} AND " + + "((\"OperationVersion\" = 1 AND \"ReconciliationCount\" = 0) OR " + + "\"OperationVersion\" = \"ReconciliationCount\" + 2)) OR " + + $"(\"Outcome\" = {(int)ReservationStayAmendmentOperationOutcome.Rejected} AND " + + "\"OperationVersion\" = \"ReconciliationCount\" + 2) OR " + + $"(\"Outcome\" = {(int)ReservationStayAmendmentOperationOutcome.OutcomeUnknown} AND " + + "\"OperationVersion\" = 1 AND \"ReconciliationCount\" = 0)"); + }); + builder.HasKey(operation => new + { + operation.ScopeId, + operation.ReservationId, + operation.Id + }); + builder.Property(operation => operation.ScopeId) + .HasMaxLength(128) + .IsRequired(); + builder.Property(operation => operation.PropertyId) + .IsRequired(); + builder.Property(operation => operation.ReservationId) + .IsRequired(); + builder.Property(operation => operation.InventoryRequestId); + builder.Property(operation => operation.RequestSchemaVersion) + .IsRequired(); + builder.Property(operation => operation.RequestFingerprint) + .HasMaxLength( + ReservationStayAmendmentOperation.RequestFingerprintLength) + .IsFixedLength() + .IsRequired(); + builder.Property(operation => operation.TargetExpectedArrivalTime) + .HasPrecision(0); + builder.Property(operation => operation.TargetExpectedDepartureTime) + .HasPrecision(0); + builder.Property(operation => operation.TargetInventoryUnitIds) + .HasMaxLength( + ReservationStayAmendmentOperation.TargetInventoryUnitIdsMaxLength); + builder.Property(operation => operation.ExpectedDetailsRevision) + .IsRequired(); + builder.Property(operation => operation.RequestedBy) + .HasMaxLength(Reservation.ActorIdMaxLength); + builder.Property(operation => operation.LastReconciledBy) + .HasMaxLength(Reservation.ActorIdMaxLength); + builder.Property(operation => operation.Outcome) + .HasConversion() + .IsRequired(); + builder.Property(operation => operation.OperationVersion) + .IsConcurrencyToken() + .IsRequired(); + builder.Property(operation => operation.RequestedAtUtc) + .IsRequired(); + builder.Property(operation => operation.UpdatedAtUtc) + .IsRequired(); + builder.Property(operation => operation.ReconciliationCount) + .IsRequired(); + builder.HasOne() + .WithOne() + .HasForeignKey(operation => new + { + operation.ScopeId, + operation.ReservationId, + operation.Id + }) + .HasPrincipalKey(operation => new + { + operation.ScopeId, + operation.ReservationId, + operation.Id + }) + .OnDelete(DeleteBehavior.Cascade); + builder.HasIndex(operation => operation.InventoryRequestId) + .IsUnique(); + builder.HasIndex(operation => new + { + operation.ScopeId, + operation.PropertyId, + operation.Outcome, + operation.UpdatedAtUtc, + operation.Id, + operation.ReservationId + }); + builder.Ignore(operation => operation.DomainEvents); + } +} diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/DependencyInjection.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/DependencyInjection.cs index 686e7715..44a330b4 100644 --- a/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/DependencyInjection.cs +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/DependencyInjection.cs @@ -79,6 +79,9 @@ public static IHostApplicationBuilder AddReservationsPersistence(this IHostAppli builder.Services.TryAddScoped< IReservationManagementOperationRepository, ReservationManagementOperationRepository>(); + builder.Services.TryAddScoped< + IReservationStayAmendmentOperationRepository, + ReservationStayAmendmentOperationRepository>(); builder.Services.TryAddScoped(); builder.Services.TryAddScoped(); builder.Services.TryAddScoped(); diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/Repositories/ReservationDataRightsExportContributor.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/Repositories/ReservationDataRightsExportContributor.cs index bc68e62a..152506d0 100644 --- a/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/Repositories/ReservationDataRightsExportContributor.cs +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/Repositories/ReservationDataRightsExportContributor.cs @@ -1,10 +1,13 @@ namespace BunkFy.Modules.Reservations.Persistence.Repositories; +using System.Data; using BunkFy.Modules.DataRights.Contracts; using BunkFy.Modules.Reservations.Domain.Aggregates; using BunkFy.Modules.Reservations.Domain.DataRights; +using BunkFy.Modules.Reservations.Domain.StayAmendments; using Gma.Framework.Scoping; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage; internal sealed class ReservationDataRightsExportContributor( ReservationsDbContext dbContext, @@ -16,6 +19,8 @@ internal sealed class ReservationDataRightsExportContributor( public const string ExternalOperationRecordType = "reservation-external-operation"; public const string ManagementOperationRecordType = "reservation-management-operation"; + public const string StayAmendmentOperationRecordType = + "reservation-stay-amendment-operation"; public const string ArrivalReminderRecordType = "reservation-arrival-reminder"; public const string DataRightsCorrectionReceiptRecordType = "reservation-data-rights-correction-receipt"; @@ -46,6 +51,33 @@ public async Task ExportAsync( ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(sink); + if (!dbContext.Database.IsRelational()) + { + return await this.ExportSnapshotAsync(request, sink, cancellationToken) + .ConfigureAwait(false); + } + + if (dbContext.Database.CurrentTransaction is not null) + { + return DataRightsSubjectExportResult.ScopeUnavailable(); + } + + await using IDbContextTransaction transaction = await dbContext.Database + .BeginTransactionAsync(IsolationLevel.RepeatableRead, cancellationToken) + .ConfigureAwait(false); + DataRightsSubjectExportResult result = await this.ExportSnapshotAsync( + request, + sink, + cancellationToken).ConfigureAwait(false); + await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); + return result; + } + + private async Task ExportSnapshotAsync( + DataRightsSubjectExportRequest request, + IDataRightsExportSink sink, + CancellationToken cancellationToken) + { if (!this.IsValidScope(request.CaseType, request.TenantId, request.PropertyId)) { return DataRightsSubjectExportResult.ScopeUnavailable(); @@ -102,6 +134,7 @@ public async Task ExportAsync( reservation.ReleaseRequestId, reservation.LastReleaseRejectionCode, reservation.LastAllocationAmendmentRejectionCode, + reservation.PendingInventoryAmendmentRequestId, reservation.PendingStayBusinessDate, reservation.CheckedInBusinessDate, reservation.CheckedInAtUtc, @@ -540,6 +573,55 @@ await sink.WriteAsync( recordCount = checked(recordCount + 1); } + IQueryable + stayAmendmentOperations = dbContext.StayAmendmentOperations + .AsNoTracking() + .Where(operation => + operation.PropertyId == propertyId && + operation.ReservationId == reservation.Id) + .OrderBy(operation => operation.RequestedAtUtc) + .ThenBy(operation => operation.Id); + await foreach (ReservationStayAmendmentOperation operation in + stayAmendmentOperations + .AsAsyncEnumerable() + .WithCancellation(cancellationToken) + .ConfigureAwait(false)) + { + ReservationStayAmendmentOperationDataRightsExport export = new( + operation.Id, + operation.PropertyId, + operation.ReservationId, + operation.InventoryRequestId, + operation.RequestSchemaVersion, + operation.RequestFingerprint, + operation.TargetArrival, + operation.TargetDeparture, + operation.TargetExpectedArrivalTime, + operation.TargetExpectedDepartureTime, + operation.TargetInventoryUnitIds is null + ? null + : operation.GetTargetInventoryUnitIds() + .Order() + .ToArray(), + operation.ExpectedDetailsRevision, + operation.Outcome, + operation.OperationVersion, + operation.RequestedAtUtc, + operation.UpdatedAtUtc, + operation.CompletedAtUtc, + operation.ResultingDetailsRevision, + operation.ResultingReservationVersion, + operation.ResultingAllocationVersion, + operation.RejectionCode, + operation.ReconciliationCount, + operation.LastReconciledAtUtc); + await sink.WriteAsync( + ReservationDataRightsExportSchema + .CreateStayAmendmentOperationRecord(export), + cancellationToken).ConfigureAwait(false); + recordCount = checked(recordCount + 1); + } + IQueryable reminders = dbContext.ArrivalReminders .AsNoTracking() @@ -573,7 +655,17 @@ await sink.WriteAsync( recordCount = checked(recordCount + 1); } - return DataRightsSubjectExportResult.Success(recordCount); + long? finalReservationVersion = await dbContext.Reservations + .AsNoTracking() + .Where(candidate => + candidate.PropertyId == propertyId && + candidate.Id == coordinate.RecordId) + .Select(candidate => (long?)candidate.Version) + .SingleOrDefaultAsync(cancellationToken) + .ConfigureAwait(false); + return finalReservationVersion == coordinate.RecordVersion + ? DataRightsSubjectExportResult.Success(recordCount) + : DataRightsSubjectExportResult.Stale(); } private Task IsKnownPropertyAsync( diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/Repositories/ReservationDataRightsExportModels.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/Repositories/ReservationDataRightsExportModels.cs index 1fb9367e..151dc210 100644 --- a/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/Repositories/ReservationDataRightsExportModels.cs +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/Repositories/ReservationDataRightsExportModels.cs @@ -4,6 +4,7 @@ namespace BunkFy.Modules.Reservations.Persistence.Repositories; using BunkFy.Modules.Reservations.Application.Ports; using BunkFy.Modules.Reservations.Domain.Aggregates; using BunkFy.Modules.Reservations.Domain.Models; +using BunkFy.Modules.Reservations.Domain.StayAmendments; using DomainDataHoldAction = BunkFy.Modules.Reservations.Domain.Models.ReservationDataHoldAction; using DomainAnonymisationDisposition = @@ -21,6 +22,7 @@ internal sealed record ReservationDataRightsExport( Guid? ReleaseRequestId, int? LastReleaseRejectionCode, int? LastAllocationAmendmentRejectionCode, + Guid? PendingInventoryAmendmentRequestId, DateOnly? PendingStayBusinessDate, DateOnly? CheckedInBusinessDate, DateTimeOffset? CheckedInAtUtc, @@ -131,6 +133,31 @@ internal sealed record ReservationManagementOperationDataRightsExport( string? RequestFingerprint, DateTimeOffset CreatedAtUtc); +internal sealed record ReservationStayAmendmentOperationDataRightsExport( + Guid OperationId, + Guid PropertyId, + Guid ReservationId, + Guid? InventoryRequestId, + int RequestSchemaVersion, + string RequestFingerprint, + DateOnly? TargetArrival, + DateOnly? TargetDeparture, + TimeOnly? TargetExpectedArrivalTime, + TimeOnly? TargetExpectedDepartureTime, + IReadOnlyCollection? TargetInventoryUnitIds, + long ExpectedDetailsRevision, + ReservationStayAmendmentOperationOutcome Outcome, + long OperationVersion, + DateTimeOffset RequestedAtUtc, + DateTimeOffset UpdatedAtUtc, + DateTimeOffset? CompletedAtUtc, + long? ResultingDetailsRevision, + long? ResultingReservationVersion, + long? ResultingAllocationVersion, + int? RejectionCode, + int ReconciliationCount, + DateTimeOffset? LastReconciledAtUtc); + internal sealed record ReservationArrivalReminderDataRightsExport( Guid ReminderId, Guid ReservationId, diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/Repositories/ReservationDataRightsExportSchema.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/Repositories/ReservationDataRightsExportSchema.cs index 4eccecf9..a256ca51 100644 --- a/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/Repositories/ReservationDataRightsExportSchema.cs +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/Repositories/ReservationDataRightsExportSchema.cs @@ -10,7 +10,7 @@ namespace BunkFy.Modules.Reservations.Persistence.Repositories; internal static class ReservationDataRightsExportSchema { public const string ExportSchemaId = "reservations.subject-export"; - public const int ExportSchemaVersion = 6; + public const int ExportSchemaVersion = 9; private const string CatalogResourceName = "BunkFy.Modules.Reservations.Persistence.DataGovernance.personal-data-catalog.v1.json"; @@ -50,6 +50,7 @@ internal static class ReservationDataRightsExportSchema typeof(ReservationAnonymisationReceiptDataRightsExport), typeof(ReservationExternalOperationDataRightsExport), typeof(ReservationManagementOperationDataRightsExport), + typeof(ReservationStayAmendmentOperationDataRightsExport), typeof(ReservationArrivalReminderDataRightsExport) ]; @@ -109,6 +110,17 @@ public static DataRightsExportRecord CreateManagementOperationRecord( recordVersion: 3, operation); + public static DataRightsExportRecord CreateStayAmendmentOperationRecord( + ReservationStayAmendmentOperationDataRightsExport operation) => + CreateRecord( + ReservationDataRightsExportContributor + .StayAmendmentOperationRecordType, + DataRightsExportRecordIds.CreateDeterministicChild( + operation.ReservationId, + operation.OperationId.ToString("N")), + operation.OperationVersion, + operation); + public static DataRightsExportRecord CreateDataRightsCorrectionReceiptRecord( ReservationDataRightsCorrectionReceiptDataRightsExport receipt) => CreateRecord( diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/Repositories/ReservationRepository.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/Repositories/ReservationRepository.cs index cfc3535e..f13eab67 100644 --- a/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/Repositories/ReservationRepository.cs +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/Repositories/ReservationRepository.cs @@ -220,14 +220,7 @@ public async Task ListAsync( } private IQueryable OrdinaryReservations() => - dbContext.Reservations.Where(reservation => - !reservation.IsAnonymised && - dbContext.ProcessingRestrictionProjections.Any(projection => - projection.PropertyId == reservation.PropertyId && - projection.ReservationId == reservation.Id && - projection.ContractVersion == - ReservationProcessingRestrictionContract.CurrentVersion && - !projection.IsRestricted)); + ReservationVisibilityQueries.Ordinary(dbContext); private static ReservationListItemDto Map(ReservationListRow reservation) => new( reservation.ReservationId, diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/Repositories/ReservationStayAmendmentOperationRepository.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/Repositories/ReservationStayAmendmentOperationRepository.cs new file mode 100644 index 00000000..2990e5bc --- /dev/null +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/Repositories/ReservationStayAmendmentOperationRepository.cs @@ -0,0 +1,184 @@ +namespace BunkFy.Modules.Reservations.Persistence.Repositories; + +using BunkFy.Modules.Reservations.Application.Ports; +using BunkFy.Modules.Reservations.Domain.Aggregates; +using BunkFy.Modules.Reservations.Domain.StayAmendments; +using Gma.Framework.Pagination; +using Microsoft.EntityFrameworkCore; + +internal sealed class ReservationStayAmendmentOperationRepository( + ReservationsDbContext dbContext) + : IReservationStayAmendmentOperationRepository +{ + public Task GetAsync( + Guid propertyId, + Guid reservationId, + Guid operationId, + CancellationToken cancellationToken) => + dbContext.StayAmendmentOperations.SingleOrDefaultAsync( + operation => + operation.PropertyId == propertyId && + operation.ReservationId == reservationId && + operation.Id == operationId, + cancellationToken); + + public Task GetVisibleAsync( + Guid propertyId, + Guid reservationId, + Guid operationId, + CancellationToken cancellationToken) => + BuildVisibleQuery( + dbContext, + propertyId, + reservationId, + operationId) + .SingleOrDefaultAsync(cancellationToken); + + public Task GetByInventoryRequestIdAsync( + Guid inventoryRequestId, + CancellationToken cancellationToken) => + dbContext.StayAmendmentOperations.SingleOrDefaultAsync( + operation => operation.InventoryRequestId == inventoryRequestId, + cancellationToken); + + internal static IQueryable + BuildVisibleQuery( + ReservationsDbContext dbContext, + Guid propertyId, + Guid reservationId, + Guid operationId) + { + IQueryable ordinaryReservations = + ReservationVisibilityQueries.Ordinary(dbContext); + return dbContext.StayAmendmentOperations + .AsNoTracking() + .Where(operation => + operation.PropertyId == propertyId && + operation.ReservationId == reservationId && + operation.Id == operationId && + ordinaryReservations.Any(reservation => + reservation.PropertyId == operation.PropertyId && + reservation.Id == operation.ReservationId)); + } + + public Task AddAsync( + ReservationStayAmendmentOperation operation, + CancellationToken cancellationToken) + { + dbContext.StayAmendmentOperations.Add(operation); + return Task.CompletedTask; + } + + public async Task + ListRecoveryAsync( + Guid propertyId, + ReservationStayAmendmentRecoveryCursorRecord? cursor, + int pageSize, + CancellationToken cancellationToken) + { + if (propertyId == Guid.Empty) + { + throw new ArgumentException( + "A property identifier is required.", + nameof(propertyId)); + } + + if (pageSize is < 1 or > PageRequest.MaxPageSize) + { + throw new ArgumentOutOfRangeException(nameof(pageSize)); + } + + if (cursor is not null && + (cursor.Outcome is not ( + ReservationStayAmendmentOperationOutcome.Pending or + ReservationStayAmendmentOperationOutcome.OutcomeUnknown) || + cursor.UpdatedAtUtc == default || + cursor.OperationId == Guid.Empty || + cursor.ReservationId == Guid.Empty)) + { + throw new ArgumentOutOfRangeException(nameof(cursor)); + } + + ReservationStayAmendmentOperation[] lookahead = await BuildRecoveryQuery( + dbContext, + propertyId, + cursor, + pageSize) + .ToArrayAsync(cancellationToken) + .ConfigureAwait(false); + ReservationStayAmendmentOperation[] operations = lookahead + .Take(pageSize) + .ToArray(); + ReservationStayAmendmentRecoveryCursorRecord? nextCursor = + lookahead.Length > pageSize + ? ToCursor(operations[^1]) + : null; + return new(operations, nextCursor); + } + + internal static IQueryable + BuildRecoveryQuery( + ReservationsDbContext dbContext, + Guid propertyId, + ReservationStayAmendmentRecoveryCursorRecord? cursor, + int pageSize) + { + IQueryable ordinaryReservations = + ReservationVisibilityQueries.Ordinary(dbContext); + IQueryable query = dbContext + .StayAmendmentOperations + .AsNoTracking() + .Where(operation => + operation.PropertyId == propertyId && + (operation.Outcome == + ReservationStayAmendmentOperationOutcome.Pending || + operation.Outcome == + ReservationStayAmendmentOperationOutcome.OutcomeUnknown) && + ordinaryReservations.Any(reservation => + reservation.PropertyId == operation.PropertyId && + reservation.Id == operation.ReservationId)); + + if (cursor?.Outcome == + ReservationStayAmendmentOperationOutcome.Pending) + { + query = query.Where(operation => + operation.Outcome == + ReservationStayAmendmentOperationOutcome.OutcomeUnknown || + (operation.Outcome == + ReservationStayAmendmentOperationOutcome.Pending && + (operation.UpdatedAtUtc > cursor.UpdatedAtUtc || + (operation.UpdatedAtUtc == cursor.UpdatedAtUtc && + (operation.Id.CompareTo(cursor.OperationId) > 0 || + (operation.Id == cursor.OperationId && + operation.ReservationId.CompareTo( + cursor.ReservationId) > 0)))))); + } + else if (cursor?.Outcome == + ReservationStayAmendmentOperationOutcome.OutcomeUnknown) + { + query = query.Where(operation => + operation.Outcome == + ReservationStayAmendmentOperationOutcome.OutcomeUnknown && + (operation.UpdatedAtUtc > cursor.UpdatedAtUtc || + (operation.UpdatedAtUtc == cursor.UpdatedAtUtc && + (operation.Id.CompareTo(cursor.OperationId) > 0 || + (operation.Id == cursor.OperationId && + operation.ReservationId.CompareTo( + cursor.ReservationId) > 0))))); + } + + return query + .OrderBy(operation => operation.Outcome) + .ThenBy(operation => operation.UpdatedAtUtc) + .ThenBy(operation => operation.Id) + .ThenBy(operation => operation.ReservationId) + .Take(checked(pageSize + 1)); + } + + private static ReservationStayAmendmentRecoveryCursorRecord ToCursor( + ReservationStayAmendmentOperation operation) => new( + operation.Outcome, + operation.UpdatedAtUtc, + operation.Id, + operation.ReservationId); +} diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/Repositories/ReservationVisibilityQuery.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/Repositories/ReservationVisibilityQuery.cs new file mode 100644 index 00000000..cdce7f82 --- /dev/null +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/Repositories/ReservationVisibilityQuery.cs @@ -0,0 +1,18 @@ +namespace BunkFy.Modules.Reservations.Persistence.Repositories; + +using BunkFy.Modules.Reservations.Contracts; +using BunkFy.Modules.Reservations.Domain.Aggregates; + +internal static class ReservationVisibilityQueries +{ + internal static IQueryable Ordinary( + ReservationsDbContext dbContext) => + dbContext.Reservations.Where(reservation => + !reservation.IsAnonymised && + dbContext.ProcessingRestrictionProjections.Any(projection => + projection.PropertyId == reservation.PropertyId && + projection.ReservationId == reservation.Id && + projection.ContractVersion == + ReservationProcessingRestrictionContract.CurrentVersion && + !projection.IsRestricted)); +} diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/Repositories/ReservationsTenantTerminationContributor.DestroyBatches.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/Repositories/ReservationsTenantTerminationContributor.DestroyBatches.cs index 891918ad..2a9f2d93 100644 --- a/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/Repositories/ReservationsTenantTerminationContributor.DestroyBatches.cs +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/Repositories/ReservationsTenantTerminationContributor.DestroyBatches.cs @@ -4,6 +4,7 @@ namespace BunkFy.Modules.Reservations.Persistence.Repositories; using BunkFy.Modules.Reservations.Domain.DataRights; using BunkFy.Modules.Reservations.Domain.GuestRecords; using BunkFy.Modules.Reservations.Domain.Retention; +using BunkFy.Modules.Reservations.Domain.StayAmendments; using BunkFy.Modules.Reservations.Persistence.TenantTermination; using Gma.Framework.Messaging.Infrastructure; using Microsoft.EntityFrameworkCore; @@ -85,21 +86,20 @@ private Task RemoveCurrentStageAsync( ReservationsTenantDestroyStage.ExternalOperations => this.RemoveGuidBatchAsync( operation, - dbContext.ExternalOperations, + dbContext.ExternalOperations + .Where(item => item.ScopeId == tenantId), externalOperation => externalOperation.Id, cancellationToken), ReservationsTenantDestroyStage.ManagementOperations => - this.RemoveBatchAsync( + this.RemoveManagementOperationBatchAsync( operation, - dbContext.ManagementOperations - .OrderBy(item => item.ReservationId) - .ThenBy(item => item.Id), - item => $"{item.ReservationId:N}|{item.Id:N}", + tenantId, cancellationToken), ReservationsTenantDestroyStage.DetailsHistory => this.RemoveGuidBatchAsync( operation, - dbContext.ReservationDetailsHistory, + dbContext.ReservationDetailsHistory + .Where(item => item.ScopeId == tenantId), entry => entry.Id, cancellationToken), ReservationsTenantDestroyStage.DataHolds => @@ -177,7 +177,8 @@ private Task RemoveCurrentStageAsync( ReservationsTenantDestroyStage.GuestProfileProjections => this.RemoveGuidBatchAsync( operation, - dbContext.GuestProfileProjections, + dbContext.GuestProfileProjections + .Where(item => item.ScopeId == tenantId), profile => profile.Id, cancellationToken), ReservationsTenantDestroyStage.ProcessingRestrictionProjections => @@ -237,6 +238,46 @@ private Task RemoveGuidBatchAsync( cancellationToken); } + private async Task RemoveManagementOperationBatchAsync( + ReservationsTenantDestroyOperation operation, + string tenantId, + CancellationToken cancellationToken) + { + ReservationStayAmendmentOperation[] children = await dbContext + .StayAmendmentOperations + .Where(item => item.ScopeId == tenantId) + .OrderBy(item => item.ReservationId) + .ThenBy(item => item.Id) + .Take(operation.BatchSize + 1) + .ToArrayAsync(cancellationToken) + .ConfigureAwait(false); + if (children.Length > 0) + { + ReservationStayAmendmentOperation[] selected = children + .Take(operation.BatchSize) + .ToArray(); + dbContext.StayAmendmentOperations.RemoveRange(selected); + EnsureBatchRecorded( + operation, + selected + .Select(item => + $"stay-amendment:{item.ReservationId:N}|{item.Id:N}") + .ToArray(), + stageCompleted: false, + clock.UtcNow); + return true; + } + + return await this.RemoveBatchAsync( + operation, + dbContext.ManagementOperations + .Where(item => item.ScopeId == tenantId) + .OrderBy(item => item.ReservationId) + .ThenBy(item => item.Id), + item => $"management:{item.ReservationId:N}|{item.Id:N}", + cancellationToken).ConfigureAwait(false); + } + private async Task RemoveReservationGuestRecordsBatchAsync( ReservationsTenantDestroyOperation operation, CancellationToken cancellationToken) @@ -323,12 +364,18 @@ await dbContext.DataRightsCorrectionReceipts.AnyAsync(cancellationToken) .ConfigureAwait(false) || await dbContext.ArrivalReminders.AnyAsync(cancellationToken) .ConfigureAwait(false) || - await dbContext.ExternalOperations.AnyAsync(cancellationToken) - .ConfigureAwait(false) || - await dbContext.ManagementOperations.AnyAsync(cancellationToken) - .ConfigureAwait(false) || - await dbContext.ReservationDetailsHistory.AnyAsync(cancellationToken) - .ConfigureAwait(false) || + await dbContext.ExternalOperations.AnyAsync( + item => item.ScopeId == tenantId, + cancellationToken).ConfigureAwait(false) || + await dbContext.StayAmendmentOperations.AnyAsync( + item => item.ScopeId == tenantId, + cancellationToken).ConfigureAwait(false) || + await dbContext.ManagementOperations.AnyAsync( + item => item.ScopeId == tenantId, + cancellationToken).ConfigureAwait(false) || + await dbContext.ReservationDetailsHistory.AnyAsync( + item => item.ScopeId == tenantId, + cancellationToken).ConfigureAwait(false) || await dbContext.DataHolds.AnyAsync(cancellationToken) .ConfigureAwait(false) || await dbContext.ProcessingRestrictions.AnyAsync(cancellationToken) @@ -353,8 +400,9 @@ await dbContext.InventoryUnitProjections.AnyAsync(cancellationToken) .ConfigureAwait(false) || await dbContext.GuestProcessingRestrictionProjections .AnyAsync(cancellationToken).ConfigureAwait(false) || - await dbContext.GuestProfileProjections.AnyAsync(cancellationToken) - .ConfigureAwait(false) || + await dbContext.GuestProfileProjections.AnyAsync( + item => item.ScopeId == tenantId, + cancellationToken).ConfigureAwait(false) || await dbContext.ProcessingRestrictionProjections .AnyAsync(cancellationToken).ConfigureAwait(false) || await dbContext.PropertyProjections.AnyAsync(cancellationToken) diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/Repositories/ReservationsTenantTerminationContributor.HistoryRecords.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/Repositories/ReservationsTenantTerminationContributor.HistoryRecords.cs index 63c8ba11..a9adc36f 100644 --- a/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/Repositories/ReservationsTenantTerminationContributor.HistoryRecords.cs +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/Repositories/ReservationsTenantTerminationContributor.HistoryRecords.cs @@ -2,6 +2,7 @@ namespace BunkFy.Modules.Reservations.Persistence.Repositories; using BunkFy.Modules.DataRights.Contracts; using BunkFy.Modules.Reservations.Contracts; +using BunkFy.Modules.Reservations.Domain.StayAmendments; using Microsoft.EntityFrameworkCore; internal sealed partial class ReservationsTenantTerminationContributor @@ -27,6 +28,11 @@ private async Task ExportHistoryRecordsAsync( sink, count, cancellationToken).ConfigureAwait(false); + count = await this.ExportStayAmendmentOperationsAsync( + tenantId, + sink, + count, + cancellationToken).ConfigureAwait(false); return await this.ExportArrivalRemindersAsync( tenantId, sink, @@ -172,6 +178,71 @@ await WriteAsync( return count; } + private async Task ExportStayAmendmentOperationsAsync( + string tenantId, + IDataRightsExportSink sink, + long count, + CancellationToken cancellationToken) + { + await foreach (ReservationStayAmendmentOperation operation in + dbContext.StayAmendmentOperations + .AsNoTracking() + .Where(item => item.ScopeId == tenantId) + .OrderBy(item => item.ReservationId) + .ThenBy(item => item.Id) + .AsAsyncEnumerable() + .WithCancellation(cancellationToken) + .ConfigureAwait(false)) + { + ReservationStayAmendmentOperationTenantExport record = new( + operation.ScopeId, + operation.PropertyId, + operation.ReservationId, + operation.Id, + new ReservationStayAmendmentOperationStateTenantExport( + operation.InventoryRequestId, + operation.RequestSchemaVersion, + operation.RequestFingerprint, + operation.TargetArrival, + operation.TargetDeparture, + operation.TargetExpectedArrivalTime, + operation.TargetExpectedDepartureTime, + operation.TargetInventoryUnitIds is null + ? null + : operation.GetTargetInventoryUnitIds() + .Order() + .ToArray(), + operation.ExpectedDetailsRevision, + operation.Outcome, + operation.OperationVersion, + operation.RequestedAtUtc, + operation.UpdatedAtUtc, + operation.CompletedAtUtc, + operation.ResultingDetailsRevision, + operation.ResultingReservationVersion, + operation.ResultingAllocationVersion, + operation.RejectionCode, + operation.ReconciliationCount, + operation.LastReconciledAtUtc), + new ReservationStayAmendmentOperationStaffTenantExport( + operation.RequestedBy, + operation.LastReconciledBy)); + await WriteAsync( + ReservationsTenantTerminationMetadata + .StayAmendmentOperationRecordType, + DataRightsExportRecordIds.CreateDeterministicChild( + operation.ReservationId, + operation.Id.ToString("N")), + operation.OperationVersion, + record, + sink, + cancellationToken).ConfigureAwait(false); + count = checked(count + 1); + } + + return count; + } + private async Task ExportArrivalRemindersAsync( string tenantId, IDataRightsExportSink sink, diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/Repositories/ReservationsTenantTerminationContributor.Records.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/Repositories/ReservationsTenantTerminationContributor.Records.cs index 72fe2e91..298bb570 100644 --- a/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/Repositories/ReservationsTenantTerminationContributor.Records.cs +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/Repositories/ReservationsTenantTerminationContributor.Records.cs @@ -82,6 +82,7 @@ private async Task ExportReservationsAsync( reservation.ReleaseRequestId, reservation.LastReleaseRejectionCode, reservation.PendingAllocationAmendmentId, + reservation.PendingInventoryAmendmentRequestId, reservation.LastAllocationAmendmentRejectionCode, reservation.PendingStayBusinessDate, reservation.CheckedInBusinessDate, diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/Repositories/ReservationsTenantTerminationExportModels.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/Repositories/ReservationsTenantTerminationExportModels.cs index 1ed4a0eb..c72bffb3 100644 --- a/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/Repositories/ReservationsTenantTerminationExportModels.cs +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/Repositories/ReservationsTenantTerminationExportModels.cs @@ -7,6 +7,7 @@ namespace BunkFy.Modules.Reservations.Persistence.Repositories; using BunkFy.Modules.Reservations.Domain.GuestRecords; using BunkFy.Modules.Reservations.Domain.Models; using BunkFy.Modules.Reservations.Domain.Retention; +using BunkFy.Modules.Reservations.Domain.StayAmendments; using DomainAnonymisationDisposition = BunkFy.Modules.Reservations.Domain.DataRights .ReservationAnonymisationDisposition; @@ -52,6 +53,7 @@ internal sealed record ReservationBookingStateTenantExport( Guid? ReleaseRequestId, int? LastReleaseRejectionCode, Guid? PendingAllocationAmendmentId, + Guid? PendingInventoryAmendmentRequestId, int? LastAllocationAmendmentRejectionCode, DateOnly? PendingStayBusinessDate, DateOnly? CheckedInBusinessDate, @@ -291,6 +293,48 @@ internal sealed record ReservationManagementOperationStateTenantExport( string? RequestFingerprint, DateTimeOffset CreatedAtUtc); +internal sealed record ReservationStayAmendmentOperationTenantExport( + [property: ReservationsTenantExportField("reservations.scope-id")] + string ScopeId, + [property: ReservationsTenantExportField("reservations.property-id")] + Guid PropertyId, + [property: ReservationsTenantExportField("reservations.reservation-id")] + Guid ReservationId, + [property: ReservationsTenantExportField("reservations.record-id")] + Guid OperationId, + [property: ReservationsTenantExportField( + "reservations.stay-amendment-operation")] + ReservationStayAmendmentOperationStateTenantExport StayAmendmentOperation, + [property: ReservationsTenantExportField( + "reservations.staff-attribution")] + ReservationStayAmendmentOperationStaffTenantExport StaffAttribution); + +internal sealed record ReservationStayAmendmentOperationStateTenantExport( + Guid? InventoryRequestId, + int RequestSchemaVersion, + string RequestFingerprint, + DateOnly? TargetArrival, + DateOnly? TargetDeparture, + TimeOnly? TargetExpectedArrivalTime, + TimeOnly? TargetExpectedDepartureTime, + IReadOnlyCollection? TargetInventoryUnitIds, + long ExpectedDetailsRevision, + ReservationStayAmendmentOperationOutcome Outcome, + long OperationVersion, + DateTimeOffset RequestedAtUtc, + DateTimeOffset UpdatedAtUtc, + DateTimeOffset? CompletedAtUtc, + long? ResultingDetailsRevision, + long? ResultingReservationVersion, + long? ResultingAllocationVersion, + int? RejectionCode, + int ReconciliationCount, + DateTimeOffset? LastReconciledAtUtc); + +internal sealed record ReservationStayAmendmentOperationStaffTenantExport( + string? RequestedBy, + string? LastReconciledBy); + internal sealed record ReservationArrivalReminderTenantExport( [property: ReservationsTenantExportField("reservations.scope-id")] string ScopeId, diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/Repositories/ReservationsTenantTerminationExportSchema.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/Repositories/ReservationsTenantTerminationExportSchema.cs index 1f60c363..e121f313 100644 --- a/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/Repositories/ReservationsTenantTerminationExportSchema.cs +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/Repositories/ReservationsTenantTerminationExportSchema.cs @@ -26,6 +26,7 @@ internal static class ReservationsTenantTerminationExportSchema typeof(ReservationDetailsHistoryTenantExport), typeof(ReservationExternalOperationTenantExport), typeof(ReservationManagementOperationTenantExport), + typeof(ReservationStayAmendmentOperationTenantExport), typeof(ReservationArrivalReminderTenantExport), typeof(ReservationDataRightsCorrectionReceiptTenantExport), typeof(ReservationProcessingRestrictionTenantExport), @@ -91,6 +92,14 @@ internal static class ReservationsTenantTerminationExportSchema .ManagementOperation), "reservations.management-operation", "include-in-authorized-guest-or-tenant-export"), + Binding( + nameof(ReservationStayAmendmentOperationTenantExport + .StayAmendmentOperation), + "reservations.stay-amendment-operation", + "include-in-authorized-guest-or-tenant-export"), + Staff( + nameof(ReservationStayAmendmentOperationTenantExport + .StaffAttribution)), GuestData( nameof(ReservationArrivalReminderTenantExport.ReminderState), "reservations.reminder-state"), diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/ReservationDetailsHistoryEntry.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/ReservationDetailsHistoryEntry.cs index 56de4376..34a0e3d8 100644 --- a/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/ReservationDetailsHistoryEntry.cs +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/ReservationDetailsHistoryEntry.cs @@ -1,8 +1,9 @@ namespace BunkFy.Modules.Reservations.Persistence; using BunkFy.Modules.Reservations.Domain.Aggregates; +using Gma.Framework.Domain; -public sealed class ReservationDetailsHistoryEntry +public sealed class ReservationDetailsHistoryEntry : IScopedEntity { private ReservationDetailsHistoryEntry() { } diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/ReservationExternalOperation.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/ReservationExternalOperation.cs index 51df0d8f..e6d1c4b0 100644 --- a/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/ReservationExternalOperation.cs +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/ReservationExternalOperation.cs @@ -2,8 +2,9 @@ namespace BunkFy.Modules.Reservations.Persistence; using BunkFy.Modules.Reservations.Application.Ports; using BunkFy.Modules.Reservations.Contracts; +using Gma.Framework.Domain; -public sealed class ReservationExternalOperation +public sealed class ReservationExternalOperation : IScopedEntity { public const string RedactedRequestFingerprint = "0000000000000000000000000000000000000000000000000000000000000000"; diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/ReservationGuestProfileProjection.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/ReservationGuestProfileProjection.cs index c9ae9b0d..e5989be4 100644 --- a/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/ReservationGuestProfileProjection.cs +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/ReservationGuestProfileProjection.cs @@ -1,8 +1,9 @@ namespace BunkFy.Modules.Reservations.Persistence; using BunkFy.Modules.Guests.Contracts; +using Gma.Framework.Domain; -public sealed class ReservationGuestProfileProjection +public sealed class ReservationGuestProfileProjection : IScopedEntity { private ReservationGuestProfileProjection() { } diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/ReservationManagementOperation.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/ReservationManagementOperation.cs index 0e59fbb0..b45afe52 100644 --- a/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/ReservationManagementOperation.cs +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/ReservationManagementOperation.cs @@ -1,8 +1,9 @@ namespace BunkFy.Modules.Reservations.Persistence; using BunkFy.Modules.Reservations.Application.Ports; +using Gma.Framework.Domain; -internal sealed class ReservationManagementOperation +internal sealed class ReservationManagementOperation : IScopedEntity { private ReservationManagementOperation() { } diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/ReservationsDbContext.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/ReservationsDbContext.cs index 679c6199..ad86faa4 100644 --- a/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/ReservationsDbContext.cs +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/ReservationsDbContext.cs @@ -5,6 +5,7 @@ namespace BunkFy.Modules.Reservations.Persistence; using BunkFy.Modules.Reservations.Domain.Entities; using BunkFy.Modules.Reservations.Domain.GuestRecords; using BunkFy.Modules.Reservations.Domain.Retention; +using BunkFy.Modules.Reservations.Domain.StayAmendments; using BunkFy.Modules.Reservations.Persistence.TenantTermination; using BunkFy.Modules.Workspaces.Contracts; using Gma.Framework.Messaging.Infrastructure; @@ -67,6 +68,8 @@ public DbSet public DbSet ExternalOperations => this.Set(); internal DbSet ManagementOperations => this.Set(); + internal DbSet StayAmendmentOperations => + this.Set(); internal DbSet OperationLocks => this.Set(); public DbSet InventoryUnitProjections => this.Set(); @@ -179,8 +182,18 @@ await ownedTransaction.CommitAsync(cancellationToken) { if (ownedTransaction is not null) { - await ownedTransaction.RollbackAsync(CancellationToken.None) - .ConfigureAwait(false); + try + { + await ownedTransaction.RollbackAsync(CancellationToken.None) + .ConfigureAwait(false); + } + catch + { + // A failed deferred-constraint commit can already have + // completed the provider transaction. Preserve the + // original commit exception instead of masking it with a + // secondary rollback failure. + } } throw; diff --git a/src/Modules/Reservations/docs/README.md b/src/Modules/Reservations/docs/README.md index 8c72fc04..b6ee7112 100644 --- a/src/Modules/Reservations/docs/README.md +++ b/src/Modules/Reservations/docs/README.md @@ -51,8 +51,9 @@ retention, and rights policies receive production approval. configured recent-authentication assurance in addition to scoped execution permission and approved-claim validation; - catalogue-driven, transient DataRights export of Reservations-owned booking, - pending amendment, Guest-link, details-history, adapter-receipt, and reminder - records without staff actor attribution; + pending amendment, durable stay-amendment operation, Guest-link, + details-history, adapter-receipt, and reminder records without staff actor + attribution; - immutable terminal lifecycle timestamps plus tenant-fair, country-policy retention scans that recheck property policy, processing restriction, pending operations, and owner-local holds before mutation; @@ -78,9 +79,10 @@ section is omitted. ## Tenant Termination Reservations is the versioned `reservations` mandatory export owner and runs -after Inventory. It streams 19 deterministic, flat record types directly from -Reservations-owned booking, requested-unit, amendment, Guest-link, history, -adapter-operation, reminder, data-rights, anonymisation, and retention tables. +after Inventory. It streams 20 deterministic, flat record types directly from +Reservations-owned booking, requested-unit, amendment operation, Guest-link, +history, adapter-operation, reminder, data-rights, anonymisation, and retention +tables. Consumer projections, inbox/outbox state, rebuild checkpoints, and the internal tenant-revision row are excluded. @@ -109,8 +111,41 @@ controls, and termination admission remain disabled until every mandatory owner and the cross-owner recovery contract are complete. The Guest restriction projection starts empty after its migration by design. + +### Stay-amendment convergence deployment contract + +The stay-amendment convergence migration and Reservations binaries are a coordinated, +stop/drain deployment. They are not rolling-compatible with the preceding Reservations +binary. Before applying `20260812011925_AddReservationStayAmendmentConvergence`, stop every +Reservations API, Admin API, Admin CLI mutation, message consumer, data-rights export, and +tenant-termination worker, then drain active Reservations database transactions and inbox +handler executions. Apply the migration only while those processes remain stopped, deploy +the same new artifact to every Reservations host, and only then resume traffic and workers. + +This fence is required because a new stay request needs a child-aware outcome consumer, and +the migration backfills operation evidence that predecessor export and destruction code does +not understand. The migration's `NOWAIT` locks prove that active writers make the upgrade fail +without a partial schema; they do not prove that old processes cannot write again afterward. +Predecessor-shaped writes after the schema exists fail closed. Historical Kind 6 operations +committed before the drain are backfilled during the migration; there is intentionally no +post-migration bridge that would make a mixed-version rollout safe. + +Before resuming, verify the migration history, resolve the Reservations service graph from the +new artifact, confirm no old Reservations process remains, and check that Pending and +OutcomeUnknown recovery queries are healthy. + +Downgrade uses the same stop/drain boundary in reverse. Stop every Reservations surface and +worker listed above, drain transactions and inbox handlers, verify and back up the state, and +run the migration guard while the processes remain stopped. Apply `Down` only when the guard +finds no non-representable stay-amendment evidence. Deploy the predecessor artifact to every +Reservations host, verify that no new-binary process remains, and only then resume traffic or +workers. A new-binary writer must never be allowed to queue behind the downgrade locks and run +after the stay-amendment schema has been removed. + +This runbook is a release contract, not evidence that any particular environment has executed +it. Until `rebuild-reservation-guest-restrictions` completes for a tenant, missing state denies new canonical Guest links. Existing factual links are preserved; Guests transition events maintain the rebuilt projection afterward. -Expected times are minute-precision local wall-clock values and never replace the actual UTC timestamps recorded by check-in/check-out. Allocation-affecting adapter changes use a correlated Inventory amendment of the existing allocation and are not accepted by the guest-details command. Direct staff date/unit amendment surfaces, property-level expected-time defaults, rates, billing, identity documents, business-day close/reopen policy, room moves, and temporary holds remain later slices. +Expected times are minute-precision local wall-clock values and never replace the actual UTC timestamps recorded by check-in/check-out. Allocation-affecting staff and adapter changes use a correlated Inventory amendment of the existing allocation and are not accepted by the guest-details command. Property-level expected-time defaults, rates, billing, identity documents, business-day close/reopen policy, checked-in room moves, and temporary holds remain later slices. diff --git a/src/Modules/Reservations/docs/personal-data-catalog.v1.json b/src/Modules/Reservations/docs/personal-data-catalog.v1.json index b0c74751..f36ca5e6 100644 --- a/src/Modules/Reservations/docs/personal-data-catalog.v1.json +++ b/src/Modules/Reservations/docs/personal-data-catalog.v1.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, "catalogId": "reservations.personal-data", - "catalogVersion": 16, + "catalogVersion": 19, "module": "reservations", "approvalState": "engineering-default", "accessPolicies": [ @@ -240,7 +240,7 @@ { "id": "reservation-management-operation-journal", "approvalState": "engineering-default", - "startsAt": "successful-lifecycle-operation", + "startsAt": "management-operation-coordinate-recorded", "endsAt": "owning-reservation-removal-or-tenant-termination", "legalHoldBehavior": "retain-with-owning-reservation" }, @@ -16943,6 +16943,13 @@ "member": "StaffAttribution", "surface": "data-rights-export", "retentionPolicy": "reservations-tenant-termination-export-fragment" + }, + { + "assembly": "BunkFy.Modules.Reservations.Persistence", + "type": "BunkFy.Modules.Reservations.Persistence.Repositories.ReservationStayAmendmentOperationTenantExport", + "member": "StaffAttribution", + "surface": "data-rights-export", + "retentionPolicy": "reservations-tenant-termination-export-fragment" } ] }, @@ -17423,6 +17430,1892 @@ "retentionPolicy": "reservations-tenant-termination-export-fragment" } ] + }, + { + "id": "reservation.stay-amendment.operation-id", + "dataSubject": "guest", + "classification": "pseudonymous-identifier", + "sensitivity": "elevated", + "purposes": [ + "reservation-stay-amendment", + "workflow-recovery" + ], + "sources": [ + "staff-action", + "system-generated" + ], + "authoritativeOwner": "reservations", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "reservation-records", + "countryPolicyKey": "reservation.details.history", + "retentionPolicy": "reservation-management-operation-journal", + "rightsPolicy": "guest-reservation-data", + "allowedSurfaces": [ + "application-command", + "application-query", + "api-response", + "persistence", + "data-rights-export" + ], + "allowedBoundaries": [ + "intra-module", + "customer-api", + "cross-module" + ], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Reservations.Application", + "type": "BunkFy.Modules.Reservations.Application.Commands.AmendReservationStayCommand", + "member": "OperationId", + "surface": "application-command", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Reservations.Application", + "type": "BunkFy.Modules.Reservations.Application.Commands.ReconcileReservationStayAmendmentCommand", + "member": "OperationId", + "surface": "application-command", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Reservations.Application", + "type": "BunkFy.Modules.Reservations.Application.Queries.GetReservationStayAmendmentQuery", + "member": "OperationId", + "surface": "application-query", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationStayAmendmentReceiptDto", + "member": "OperationId", + "surface": "api-response", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationStayAmendmentRecoveryCursorDto", + "member": "OperationId", + "surface": "api-response", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationStayAmendmentRecoveryItemDto", + "member": "OperationId", + "surface": "api-response", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Reservations.Application", + "type": "BunkFy.Modules.Reservations.Application.Ports.ReservationStayAmendmentRecoveryCursorRecord", + "member": "OperationId", + "surface": "application-query", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Reservations.Domain", + "type": "BunkFy.Modules.Reservations.Domain.StayAmendments.ReservationStayAmendmentOperation", + "member": "Id", + "surface": "persistence" + }, + { + "assembly": "BunkFy.Modules.Reservations.Persistence", + "type": "BunkFy.Modules.Reservations.Persistence.Repositories.ReservationStayAmendmentOperationDataRightsExport", + "member": "OperationId", + "surface": "data-rights-export", + "retentionPolicy": "reservation-data-rights-export-fragment" + } + ] + }, + { + "id": "reservation.stay-amendment.property-id", + "dataSubject": "guest", + "classification": "linked-operational", + "sensitivity": "standard", + "purposes": [ + "reservation-stay-amendment", + "workflow-recovery" + ], + "sources": [ + "staff-action", + "system-generated" + ], + "authoritativeOwner": "reservations", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "reservation-records", + "countryPolicyKey": "reservation.details.history", + "retentionPolicy": "reservation-management-operation-journal", + "rightsPolicy": "guest-reservation-data", + "allowedSurfaces": [ + "application-command", + "application-query", + "api-response", + "persistence", + "data-rights-export" + ], + "allowedBoundaries": [ + "intra-module", + "customer-api", + "cross-module" + ], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Reservations.Application", + "type": "BunkFy.Modules.Reservations.Application.Commands.AmendReservationStayCommand", + "member": "PropertyId", + "surface": "application-command", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Reservations.Application", + "type": "BunkFy.Modules.Reservations.Application.Commands.ReconcileReservationStayAmendmentCommand", + "member": "PropertyId", + "surface": "application-command", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Reservations.Application", + "type": "BunkFy.Modules.Reservations.Application.Queries.GetReservationStayAmendmentQuery", + "member": "PropertyId", + "surface": "application-query", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Reservations.Application", + "type": "BunkFy.Modules.Reservations.Application.Queries.ListReservationStayAmendmentRecoveryQuery", + "member": "PropertyId", + "surface": "application-query", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationStayAmendmentReceiptDto", + "member": "PropertyId", + "surface": "api-response", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationStayAmendmentRecoveryItemDto", + "member": "PropertyId", + "surface": "api-response", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Reservations.Domain", + "type": "BunkFy.Modules.Reservations.Domain.StayAmendments.ReservationStayAmendmentOperation", + "member": "PropertyId", + "surface": "persistence" + }, + { + "assembly": "BunkFy.Modules.Reservations.Persistence", + "type": "BunkFy.Modules.Reservations.Persistence.Repositories.ReservationStayAmendmentOperationDataRightsExport", + "member": "PropertyId", + "surface": "data-rights-export", + "retentionPolicy": "reservation-data-rights-export-fragment" + } + ] + }, + { + "id": "reservation.stay-amendment.reservation-id", + "dataSubject": "guest", + "classification": "linked-operational", + "sensitivity": "standard", + "purposes": [ + "reservation-stay-amendment", + "workflow-recovery" + ], + "sources": [ + "staff-action", + "system-generated" + ], + "authoritativeOwner": "reservations", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "reservation-records", + "countryPolicyKey": "reservation.details.history", + "retentionPolicy": "reservation-management-operation-journal", + "rightsPolicy": "guest-reservation-data", + "allowedSurfaces": [ + "application-command", + "application-query", + "api-response", + "persistence", + "data-rights-export" + ], + "allowedBoundaries": [ + "intra-module", + "customer-api", + "cross-module" + ], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Reservations.Application", + "type": "BunkFy.Modules.Reservations.Application.Commands.AmendReservationStayCommand", + "member": "ReservationId", + "surface": "application-command", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Reservations.Application", + "type": "BunkFy.Modules.Reservations.Application.Commands.ReconcileReservationStayAmendmentCommand", + "member": "ReservationId", + "surface": "application-command", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Reservations.Application", + "type": "BunkFy.Modules.Reservations.Application.Queries.GetReservationStayAmendmentQuery", + "member": "ReservationId", + "surface": "application-query", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationStayAmendmentReceiptDto", + "member": "ReservationId", + "surface": "api-response", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationStayAmendmentRecoveryCursorDto", + "member": "ReservationId", + "surface": "api-response", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationStayAmendmentRecoveryItemDto", + "member": "ReservationId", + "surface": "api-response", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Reservations.Application", + "type": "BunkFy.Modules.Reservations.Application.Ports.ReservationStayAmendmentRecoveryCursorRecord", + "member": "ReservationId", + "surface": "application-query", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Reservations.Domain", + "type": "BunkFy.Modules.Reservations.Domain.StayAmendments.ReservationStayAmendmentOperation", + "member": "ReservationId", + "surface": "persistence" + }, + { + "assembly": "BunkFy.Modules.Reservations.Persistence", + "type": "BunkFy.Modules.Reservations.Persistence.Repositories.ReservationStayAmendmentOperationDataRightsExport", + "member": "ReservationId", + "surface": "data-rights-export", + "retentionPolicy": "reservation-data-rights-export-fragment" + } + ] + }, + { + "id": "reservation.stay-amendment.scope-id", + "dataSubject": "guest", + "classification": "linked-operational", + "sensitivity": "standard", + "purposes": [ + "reservation-stay-amendment", + "workflow-recovery" + ], + "sources": [ + "staff-action", + "system-generated" + ], + "authoritativeOwner": "reservations", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "reservation-records", + "countryPolicyKey": "reservation.details.history", + "retentionPolicy": "reservation-management-operation-journal", + "rightsPolicy": "guest-reservation-data", + "allowedSurfaces": [ + "persistence" + ], + "allowedBoundaries": [ + "intra-module" + ], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Reservations.Domain", + "type": "BunkFy.Modules.Reservations.Domain.StayAmendments.ReservationStayAmendmentOperation", + "member": "ScopeId", + "surface": "persistence" + } + ] + }, + { + "id": "reservation.stay-amendment.request-schema-version", + "dataSubject": "guest", + "classification": "lifecycle", + "sensitivity": "standard", + "purposes": [ + "reservation-stay-amendment", + "workflow-recovery" + ], + "sources": [ + "staff-action", + "system-generated" + ], + "authoritativeOwner": "reservations", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "reservation-records", + "countryPolicyKey": "reservation.details.history", + "retentionPolicy": "reservation-management-operation-journal", + "rightsPolicy": "guest-reservation-data", + "allowedSurfaces": [ + "persistence", + "data-rights-export" + ], + "allowedBoundaries": [ + "intra-module", + "cross-module" + ], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Reservations.Domain", + "type": "BunkFy.Modules.Reservations.Domain.StayAmendments.ReservationStayAmendmentOperation", + "member": "RequestSchemaVersion", + "surface": "persistence" + }, + { + "assembly": "BunkFy.Modules.Reservations.Persistence", + "type": "BunkFy.Modules.Reservations.Persistence.Repositories.ReservationStayAmendmentOperationDataRightsExport", + "member": "RequestSchemaVersion", + "surface": "data-rights-export", + "retentionPolicy": "reservation-data-rights-export-fragment" + } + ] + }, + { + "id": "reservation.stay-amendment.request-fingerprint", + "dataSubject": "guest", + "classification": "pseudonymous-identifier", + "sensitivity": "elevated", + "purposes": [ + "reservation-stay-amendment", + "workflow-recovery" + ], + "sources": [ + "staff-action", + "system-generated" + ], + "authoritativeOwner": "reservations", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "reservation-records", + "countryPolicyKey": "reservation.details.history", + "retentionPolicy": "reservation-management-operation-journal", + "rightsPolicy": "guest-reservation-data", + "allowedSurfaces": [ + "persistence", + "data-rights-export" + ], + "allowedBoundaries": [ + "intra-module", + "cross-module" + ], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Reservations.Domain", + "type": "BunkFy.Modules.Reservations.Domain.StayAmendments.ReservationStayAmendmentOperation", + "member": "RequestFingerprint", + "surface": "persistence" + }, + { + "assembly": "BunkFy.Modules.Reservations.Persistence", + "type": "BunkFy.Modules.Reservations.Persistence.Repositories.ReservationStayAmendmentOperationDataRightsExport", + "member": "RequestFingerprint", + "surface": "data-rights-export", + "retentionPolicy": "reservation-data-rights-export-fragment" + } + ] + }, + { + "id": "reservation.stay-amendment.target-arrival", + "dataSubject": "guest", + "classification": "lifecycle", + "sensitivity": "standard", + "purposes": [ + "reservation-stay-amendment", + "workflow-recovery" + ], + "sources": [ + "staff-action", + "system-generated" + ], + "authoritativeOwner": "reservations", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "reservation-records", + "countryPolicyKey": "reservation.details.history", + "retentionPolicy": "reservation-management-operation-journal", + "rightsPolicy": "guest-reservation-data", + "allowedSurfaces": [ + "application-command", + "api-input", + "api-response", + "persistence", + "data-rights-export" + ], + "allowedBoundaries": [ + "intra-module", + "customer-api", + "cross-module" + ], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Reservations.Application", + "type": "BunkFy.Modules.Reservations.Application.Commands.AmendReservationStayCommand", + "member": "Arrival", + "surface": "application-command", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Reservations.Api", + "type": "BunkFy.Modules.Reservations.Api.ReservationsModule+AmendReservationStayRequest", + "member": "Arrival", + "surface": "api-input", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationStayAmendmentTargetDto", + "member": "Arrival", + "surface": "api-response", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Reservations.Domain", + "type": "BunkFy.Modules.Reservations.Domain.StayAmendments.ReservationStayAmendmentOperation", + "member": "TargetArrival", + "surface": "persistence" + }, + { + "assembly": "BunkFy.Modules.Reservations.Persistence", + "type": "BunkFy.Modules.Reservations.Persistence.Repositories.ReservationStayAmendmentOperationDataRightsExport", + "member": "TargetArrival", + "surface": "data-rights-export", + "retentionPolicy": "reservation-data-rights-export-fragment" + } + ] + }, + { + "id": "reservation.stay-amendment.target-departure", + "dataSubject": "guest", + "classification": "lifecycle", + "sensitivity": "standard", + "purposes": [ + "reservation-stay-amendment", + "workflow-recovery" + ], + "sources": [ + "staff-action", + "system-generated" + ], + "authoritativeOwner": "reservations", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "reservation-records", + "countryPolicyKey": "reservation.details.history", + "retentionPolicy": "reservation-management-operation-journal", + "rightsPolicy": "guest-reservation-data", + "allowedSurfaces": [ + "application-command", + "api-input", + "api-response", + "persistence", + "data-rights-export" + ], + "allowedBoundaries": [ + "intra-module", + "customer-api", + "cross-module" + ], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Reservations.Application", + "type": "BunkFy.Modules.Reservations.Application.Commands.AmendReservationStayCommand", + "member": "Departure", + "surface": "application-command", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Reservations.Api", + "type": "BunkFy.Modules.Reservations.Api.ReservationsModule+AmendReservationStayRequest", + "member": "Departure", + "surface": "api-input", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationStayAmendmentTargetDto", + "member": "Departure", + "surface": "api-response", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Reservations.Domain", + "type": "BunkFy.Modules.Reservations.Domain.StayAmendments.ReservationStayAmendmentOperation", + "member": "TargetDeparture", + "surface": "persistence" + }, + { + "assembly": "BunkFy.Modules.Reservations.Persistence", + "type": "BunkFy.Modules.Reservations.Persistence.Repositories.ReservationStayAmendmentOperationDataRightsExport", + "member": "TargetDeparture", + "surface": "data-rights-export", + "retentionPolicy": "reservation-data-rights-export-fragment" + } + ] + }, + { + "id": "reservation.stay-amendment.target-expected-arrival-time", + "dataSubject": "guest", + "classification": "lifecycle", + "sensitivity": "standard", + "purposes": [ + "reservation-stay-amendment", + "workflow-recovery" + ], + "sources": [ + "staff-action", + "system-generated" + ], + "authoritativeOwner": "reservations", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "reservation-records", + "countryPolicyKey": "reservation.details.history", + "retentionPolicy": "reservation-management-operation-journal", + "rightsPolicy": "guest-reservation-data", + "allowedSurfaces": [ + "application-command", + "api-input", + "api-response", + "persistence", + "data-rights-export" + ], + "allowedBoundaries": [ + "intra-module", + "customer-api", + "cross-module" + ], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Reservations.Application", + "type": "BunkFy.Modules.Reservations.Application.Commands.AmendReservationStayCommand", + "member": "ExpectedArrivalTime", + "surface": "application-command", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Reservations.Api", + "type": "BunkFy.Modules.Reservations.Api.ReservationsModule+AmendReservationStayRequest", + "member": "ExpectedArrivalTime", + "surface": "api-input", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationStayAmendmentTargetDto", + "member": "ExpectedArrivalTime", + "surface": "api-response", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Reservations.Domain", + "type": "BunkFy.Modules.Reservations.Domain.StayAmendments.ReservationStayAmendmentOperation", + "member": "TargetExpectedArrivalTime", + "surface": "persistence" + }, + { + "assembly": "BunkFy.Modules.Reservations.Persistence", + "type": "BunkFy.Modules.Reservations.Persistence.Repositories.ReservationStayAmendmentOperationDataRightsExport", + "member": "TargetExpectedArrivalTime", + "surface": "data-rights-export", + "retentionPolicy": "reservation-data-rights-export-fragment" + } + ] + }, + { + "id": "reservation.stay-amendment.target-expected-departure-time", + "dataSubject": "guest", + "classification": "lifecycle", + "sensitivity": "standard", + "purposes": [ + "reservation-stay-amendment", + "workflow-recovery" + ], + "sources": [ + "staff-action", + "system-generated" + ], + "authoritativeOwner": "reservations", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "reservation-records", + "countryPolicyKey": "reservation.details.history", + "retentionPolicy": "reservation-management-operation-journal", + "rightsPolicy": "guest-reservation-data", + "allowedSurfaces": [ + "application-command", + "api-input", + "api-response", + "persistence", + "data-rights-export" + ], + "allowedBoundaries": [ + "intra-module", + "customer-api", + "cross-module" + ], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Reservations.Application", + "type": "BunkFy.Modules.Reservations.Application.Commands.AmendReservationStayCommand", + "member": "ExpectedDepartureTime", + "surface": "application-command", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Reservations.Api", + "type": "BunkFy.Modules.Reservations.Api.ReservationsModule+AmendReservationStayRequest", + "member": "ExpectedDepartureTime", + "surface": "api-input", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationStayAmendmentTargetDto", + "member": "ExpectedDepartureTime", + "surface": "api-response", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Reservations.Domain", + "type": "BunkFy.Modules.Reservations.Domain.StayAmendments.ReservationStayAmendmentOperation", + "member": "TargetExpectedDepartureTime", + "surface": "persistence" + }, + { + "assembly": "BunkFy.Modules.Reservations.Persistence", + "type": "BunkFy.Modules.Reservations.Persistence.Repositories.ReservationStayAmendmentOperationDataRightsExport", + "member": "TargetExpectedDepartureTime", + "surface": "data-rights-export", + "retentionPolicy": "reservation-data-rights-export-fragment" + } + ] + }, + { + "id": "reservation.stay-amendment.target-inventory-unit-ids", + "dataSubject": "guest", + "classification": "linked-operational", + "sensitivity": "standard", + "purposes": [ + "reservation-stay-amendment", + "workflow-recovery" + ], + "sources": [ + "staff-action", + "system-generated" + ], + "authoritativeOwner": "reservations", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "reservation-records", + "countryPolicyKey": "reservation.details.history", + "retentionPolicy": "reservation-management-operation-journal", + "rightsPolicy": "guest-reservation-data", + "allowedSurfaces": [ + "application-command", + "api-input", + "api-response", + "persistence", + "data-rights-export" + ], + "allowedBoundaries": [ + "intra-module", + "customer-api", + "cross-module" + ], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Reservations.Application", + "type": "BunkFy.Modules.Reservations.Application.Commands.AmendReservationStayCommand", + "member": "InventoryUnitIds", + "surface": "application-command", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Reservations.Api", + "type": "BunkFy.Modules.Reservations.Api.ReservationsModule+AmendReservationStayRequest", + "member": "InventoryUnitIds", + "surface": "api-input", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationStayAmendmentTargetDto", + "member": "InventoryUnitIds", + "surface": "api-response", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Reservations.Domain", + "type": "BunkFy.Modules.Reservations.Domain.StayAmendments.ReservationStayAmendmentOperation", + "member": "TargetInventoryUnitIds", + "surface": "persistence" + }, + { + "assembly": "BunkFy.Modules.Reservations.Persistence", + "type": "BunkFy.Modules.Reservations.Persistence.Repositories.ReservationStayAmendmentOperationDataRightsExport", + "member": "TargetInventoryUnitIds", + "surface": "data-rights-export", + "retentionPolicy": "reservation-data-rights-export-fragment" + } + ] + }, + { + "id": "reservation.stay-amendment.expected-details-revision", + "dataSubject": "guest", + "classification": "lifecycle", + "sensitivity": "standard", + "purposes": [ + "reservation-stay-amendment", + "workflow-recovery" + ], + "sources": [ + "staff-action", + "system-generated" + ], + "authoritativeOwner": "reservations", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "reservation-records", + "countryPolicyKey": "reservation.details.history", + "retentionPolicy": "reservation-management-operation-journal", + "rightsPolicy": "guest-reservation-data", + "allowedSurfaces": [ + "application-command", + "api-input", + "api-response", + "persistence", + "data-rights-export" + ], + "allowedBoundaries": [ + "intra-module", + "customer-api", + "cross-module" + ], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Reservations.Application", + "type": "BunkFy.Modules.Reservations.Application.Commands.AmendReservationStayCommand", + "member": "ExpectedDetailsRevision", + "surface": "application-command", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Reservations.Api", + "type": "BunkFy.Modules.Reservations.Api.ReservationsModule+AmendReservationStayRequest", + "member": "ExpectedDetailsRevision", + "surface": "api-input", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationStayAmendmentReceiptDto", + "member": "ExpectedDetailsRevision", + "surface": "api-response", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Reservations.Domain", + "type": "BunkFy.Modules.Reservations.Domain.StayAmendments.ReservationStayAmendmentOperation", + "member": "ExpectedDetailsRevision", + "surface": "persistence" + }, + { + "assembly": "BunkFy.Modules.Reservations.Persistence", + "type": "BunkFy.Modules.Reservations.Persistence.Repositories.ReservationStayAmendmentOperationDataRightsExport", + "member": "ExpectedDetailsRevision", + "surface": "data-rights-export", + "retentionPolicy": "reservation-data-rights-export-fragment" + } + ] + }, + { + "id": "reservation.stay-amendment.requested-by", + "dataSubject": "staff", + "classification": "audit-attribution", + "sensitivity": "standard", + "purposes": [ + "reservation-stay-amendment", + "workflow-recovery" + ], + "sources": [ + "authenticated-subject" + ], + "authoritativeOwner": "reservations", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "reservation-records", + "countryPolicyKey": "staff.audit-attribution", + "retentionPolicy": "reservation-management-operation-journal", + "rightsPolicy": "staff-audit-attribution", + "allowedSurfaces": [ + "application-command", + "persistence" + ], + "allowedBoundaries": [ + "intra-module" + ], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Reservations.Application", + "type": "BunkFy.Modules.Reservations.Application.Commands.AmendReservationStayCommand", + "member": "ActorId", + "surface": "application-command", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Reservations.Domain", + "type": "BunkFy.Modules.Reservations.Domain.StayAmendments.ReservationStayAmendmentOperation", + "member": "RequestedBy", + "surface": "persistence" + } + ] + }, + { + "id": "reservation.stay-amendment.outcome", + "dataSubject": "guest", + "classification": "lifecycle", + "sensitivity": "standard", + "purposes": [ + "reservation-stay-amendment", + "workflow-recovery" + ], + "sources": [ + "staff-action", + "system-generated" + ], + "authoritativeOwner": "reservations", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "reservation-records", + "countryPolicyKey": "reservation.details.history", + "retentionPolicy": "reservation-management-operation-journal", + "rightsPolicy": "guest-reservation-data", + "allowedSurfaces": [ + "api-response", + "application-query", + "persistence", + "data-rights-export" + ], + "allowedBoundaries": [ + "customer-api", + "intra-module", + "cross-module" + ], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationStayAmendmentReceiptDto", + "member": "Outcome", + "surface": "api-response", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationStayAmendmentRecoveryCursorDto", + "member": "Outcome", + "surface": "api-response", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationStayAmendmentRecoveryItemDto", + "member": "Outcome", + "surface": "api-response", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Reservations.Application", + "type": "BunkFy.Modules.Reservations.Application.Ports.ReservationStayAmendmentRecoveryCursorRecord", + "member": "Outcome", + "surface": "application-query", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Reservations.Domain", + "type": "BunkFy.Modules.Reservations.Domain.StayAmendments.ReservationStayAmendmentOperation", + "member": "Outcome", + "surface": "persistence" + }, + { + "assembly": "BunkFy.Modules.Reservations.Persistence", + "type": "BunkFy.Modules.Reservations.Persistence.Repositories.ReservationStayAmendmentOperationDataRightsExport", + "member": "Outcome", + "surface": "data-rights-export", + "retentionPolicy": "reservation-data-rights-export-fragment" + } + ] + }, + { + "id": "reservation.stay-amendment.operation-version", + "dataSubject": "guest", + "classification": "lifecycle", + "sensitivity": "standard", + "purposes": [ + "reservation-stay-amendment", + "workflow-recovery" + ], + "sources": [ + "staff-action", + "system-generated" + ], + "authoritativeOwner": "reservations", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "reservation-records", + "countryPolicyKey": "reservation.details.history", + "retentionPolicy": "reservation-management-operation-journal", + "rightsPolicy": "guest-reservation-data", + "allowedSurfaces": [ + "application-command", + "api-input", + "api-response", + "persistence", + "data-rights-export" + ], + "allowedBoundaries": [ + "intra-module", + "customer-api", + "cross-module" + ], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Reservations.Application", + "type": "BunkFy.Modules.Reservations.Application.Commands.ReconcileReservationStayAmendmentCommand", + "member": "ExpectedOperationVersion", + "surface": "application-command", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Reservations.Api", + "type": "BunkFy.Modules.Reservations.Api.ReservationsModule+ReconcileReservationStayAmendmentRequest", + "member": "ExpectedOperationVersion", + "surface": "api-input", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationStayAmendmentReceiptDto", + "member": "OperationVersion", + "surface": "api-response", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationStayAmendmentRecoveryItemDto", + "member": "OperationVersion", + "surface": "api-response", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Reservations.Domain", + "type": "BunkFy.Modules.Reservations.Domain.StayAmendments.ReservationStayAmendmentOperation", + "member": "OperationVersion", + "surface": "persistence" + }, + { + "assembly": "BunkFy.Modules.Reservations.Persistence", + "type": "BunkFy.Modules.Reservations.Persistence.Repositories.ReservationStayAmendmentOperationDataRightsExport", + "member": "OperationVersion", + "surface": "data-rights-export", + "retentionPolicy": "reservation-data-rights-export-fragment" + } + ] + }, + { + "id": "reservation.stay-amendment.requested-at-utc", + "dataSubject": "guest", + "classification": "lifecycle", + "sensitivity": "standard", + "purposes": [ + "reservation-stay-amendment", + "workflow-recovery" + ], + "sources": [ + "staff-action", + "system-generated" + ], + "authoritativeOwner": "reservations", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "reservation-records", + "countryPolicyKey": "reservation.details.history", + "retentionPolicy": "reservation-management-operation-journal", + "rightsPolicy": "guest-reservation-data", + "allowedSurfaces": [ + "api-response", + "persistence", + "data-rights-export" + ], + "allowedBoundaries": [ + "customer-api", + "intra-module", + "cross-module" + ], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationStayAmendmentReceiptDto", + "member": "RequestedAtUtc", + "surface": "api-response", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationStayAmendmentRecoveryItemDto", + "member": "RequestedAtUtc", + "surface": "api-response", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Reservations.Domain", + "type": "BunkFy.Modules.Reservations.Domain.StayAmendments.ReservationStayAmendmentOperation", + "member": "RequestedAtUtc", + "surface": "persistence" + }, + { + "assembly": "BunkFy.Modules.Reservations.Persistence", + "type": "BunkFy.Modules.Reservations.Persistence.Repositories.ReservationStayAmendmentOperationDataRightsExport", + "member": "RequestedAtUtc", + "surface": "data-rights-export", + "retentionPolicy": "reservation-data-rights-export-fragment" + } + ] + }, + { + "id": "reservation.stay-amendment.updated-at-utc", + "dataSubject": "guest", + "classification": "lifecycle", + "sensitivity": "standard", + "purposes": [ + "reservation-stay-amendment", + "workflow-recovery" + ], + "sources": [ + "staff-action", + "system-generated" + ], + "authoritativeOwner": "reservations", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "reservation-records", + "countryPolicyKey": "reservation.details.history", + "retentionPolicy": "reservation-management-operation-journal", + "rightsPolicy": "guest-reservation-data", + "allowedSurfaces": [ + "api-response", + "application-query", + "persistence", + "data-rights-export" + ], + "allowedBoundaries": [ + "customer-api", + "intra-module", + "cross-module" + ], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationStayAmendmentReceiptDto", + "member": "UpdatedAtUtc", + "surface": "api-response", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationStayAmendmentRecoveryCursorDto", + "member": "UpdatedAtUtc", + "surface": "api-response", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationStayAmendmentRecoveryItemDto", + "member": "UpdatedAtUtc", + "surface": "api-response", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Reservations.Application", + "type": "BunkFy.Modules.Reservations.Application.Ports.ReservationStayAmendmentRecoveryCursorRecord", + "member": "UpdatedAtUtc", + "surface": "application-query", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Reservations.Domain", + "type": "BunkFy.Modules.Reservations.Domain.StayAmendments.ReservationStayAmendmentOperation", + "member": "UpdatedAtUtc", + "surface": "persistence" + }, + { + "assembly": "BunkFy.Modules.Reservations.Persistence", + "type": "BunkFy.Modules.Reservations.Persistence.Repositories.ReservationStayAmendmentOperationDataRightsExport", + "member": "UpdatedAtUtc", + "surface": "data-rights-export", + "retentionPolicy": "reservation-data-rights-export-fragment" + } + ] + }, + { + "id": "reservation.stay-amendment.completed-at-utc", + "dataSubject": "guest", + "classification": "lifecycle", + "sensitivity": "standard", + "purposes": [ + "reservation-stay-amendment", + "workflow-recovery" + ], + "sources": [ + "staff-action", + "system-generated" + ], + "authoritativeOwner": "reservations", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "reservation-records", + "countryPolicyKey": "reservation.details.history", + "retentionPolicy": "reservation-management-operation-journal", + "rightsPolicy": "guest-reservation-data", + "allowedSurfaces": [ + "api-response", + "persistence", + "data-rights-export" + ], + "allowedBoundaries": [ + "customer-api", + "intra-module", + "cross-module" + ], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationStayAmendmentReceiptDto", + "member": "CompletedAtUtc", + "surface": "api-response", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Reservations.Domain", + "type": "BunkFy.Modules.Reservations.Domain.StayAmendments.ReservationStayAmendmentOperation", + "member": "CompletedAtUtc", + "surface": "persistence" + }, + { + "assembly": "BunkFy.Modules.Reservations.Persistence", + "type": "BunkFy.Modules.Reservations.Persistence.Repositories.ReservationStayAmendmentOperationDataRightsExport", + "member": "CompletedAtUtc", + "surface": "data-rights-export", + "retentionPolicy": "reservation-data-rights-export-fragment" + } + ] + }, + { + "id": "reservation.stay-amendment.resulting-details-revision", + "dataSubject": "guest", + "classification": "lifecycle", + "sensitivity": "standard", + "purposes": [ + "reservation-stay-amendment", + "workflow-recovery" + ], + "sources": [ + "staff-action", + "system-generated" + ], + "authoritativeOwner": "reservations", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "reservation-records", + "countryPolicyKey": "reservation.details.history", + "retentionPolicy": "reservation-management-operation-journal", + "rightsPolicy": "guest-reservation-data", + "allowedSurfaces": [ + "api-response", + "persistence", + "data-rights-export" + ], + "allowedBoundaries": [ + "customer-api", + "intra-module", + "cross-module" + ], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationStayAmendmentReceiptDto", + "member": "ResultingDetailsRevision", + "surface": "api-response", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Reservations.Domain", + "type": "BunkFy.Modules.Reservations.Domain.StayAmendments.ReservationStayAmendmentOperation", + "member": "ResultingDetailsRevision", + "surface": "persistence" + }, + { + "assembly": "BunkFy.Modules.Reservations.Persistence", + "type": "BunkFy.Modules.Reservations.Persistence.Repositories.ReservationStayAmendmentOperationDataRightsExport", + "member": "ResultingDetailsRevision", + "surface": "data-rights-export", + "retentionPolicy": "reservation-data-rights-export-fragment" + } + ] + }, + { + "id": "reservation.stay-amendment.resulting-reservation-version", + "dataSubject": "guest", + "classification": "lifecycle", + "sensitivity": "standard", + "purposes": [ + "reservation-stay-amendment", + "workflow-recovery" + ], + "sources": [ + "staff-action", + "system-generated" + ], + "authoritativeOwner": "reservations", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "reservation-records", + "countryPolicyKey": "reservation.details.history", + "retentionPolicy": "reservation-management-operation-journal", + "rightsPolicy": "guest-reservation-data", + "allowedSurfaces": [ + "api-response", + "persistence", + "data-rights-export" + ], + "allowedBoundaries": [ + "customer-api", + "intra-module", + "cross-module" + ], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationStayAmendmentReceiptDto", + "member": "ResultingReservationVersion", + "surface": "api-response", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Reservations.Domain", + "type": "BunkFy.Modules.Reservations.Domain.StayAmendments.ReservationStayAmendmentOperation", + "member": "ResultingReservationVersion", + "surface": "persistence" + }, + { + "assembly": "BunkFy.Modules.Reservations.Persistence", + "type": "BunkFy.Modules.Reservations.Persistence.Repositories.ReservationStayAmendmentOperationDataRightsExport", + "member": "ResultingReservationVersion", + "surface": "data-rights-export", + "retentionPolicy": "reservation-data-rights-export-fragment" + } + ] + }, + { + "id": "reservation.stay-amendment.resulting-allocation-version", + "dataSubject": "guest", + "classification": "lifecycle", + "sensitivity": "standard", + "purposes": [ + "reservation-stay-amendment", + "workflow-recovery", + "inventory-allocation-consistency" + ], + "sources": [ + "system-generated" + ], + "authoritativeOwner": "reservations", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "reservation-records", + "countryPolicyKey": "reservation.allocation", + "retentionPolicy": "reservation-management-operation-journal", + "rightsPolicy": "guest-reservation-data", + "allowedSurfaces": [ + "persistence", + "data-rights-export" + ], + "allowedBoundaries": [ + "cross-module", + "intra-module" + ], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Reservations.Domain", + "type": "BunkFy.Modules.Reservations.Domain.StayAmendments.ReservationStayAmendmentOperation", + "member": "ResultingAllocationVersion", + "surface": "persistence" + }, + { + "assembly": "BunkFy.Modules.Reservations.Persistence", + "type": "BunkFy.Modules.Reservations.Persistence.Repositories.ReservationStayAmendmentOperationDataRightsExport", + "member": "ResultingAllocationVersion", + "surface": "data-rights-export", + "retentionPolicy": "reservation-data-rights-export-fragment" + }, + { + "assembly": "BunkFy.Modules.Reservations.Persistence", + "type": "BunkFy.Modules.Reservations.Persistence.Repositories.ReservationStayAmendmentOperationStateTenantExport", + "member": "ResultingAllocationVersion", + "surface": "data-rights-export", + "retentionPolicy": "reservations-tenant-termination-export-fragment" + } + ] + }, + { + "id": "reservation.stay-amendment.rejection-reason", + "dataSubject": "guest", + "classification": "linked-operational", + "sensitivity": "standard", + "purposes": [ + "reservation-stay-amendment", + "workflow-recovery" + ], + "sources": [ + "staff-action", + "system-generated" + ], + "authoritativeOwner": "reservations", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "reservation-records", + "countryPolicyKey": "reservation.details.history", + "retentionPolicy": "reservation-management-operation-journal", + "rightsPolicy": "guest-reservation-data", + "allowedSurfaces": [ + "api-response", + "persistence", + "data-rights-export" + ], + "allowedBoundaries": [ + "customer-api", + "intra-module", + "cross-module" + ], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationStayAmendmentReceiptDto", + "member": "RejectionReason", + "surface": "api-response", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Reservations.Domain", + "type": "BunkFy.Modules.Reservations.Domain.StayAmendments.ReservationStayAmendmentOperation", + "member": "RejectionCode", + "surface": "persistence" + }, + { + "assembly": "BunkFy.Modules.Reservations.Persistence", + "type": "BunkFy.Modules.Reservations.Persistence.Repositories.ReservationStayAmendmentOperationDataRightsExport", + "member": "RejectionCode", + "surface": "data-rights-export", + "retentionPolicy": "reservation-data-rights-export-fragment" + } + ] + }, + { + "id": "reservation.stay-amendment.reconciliation-count", + "dataSubject": "guest", + "classification": "lifecycle", + "sensitivity": "standard", + "purposes": [ + "reservation-stay-amendment", + "workflow-recovery" + ], + "sources": [ + "staff-action", + "system-generated" + ], + "authoritativeOwner": "reservations", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "reservation-records", + "countryPolicyKey": "reservation.details.history", + "retentionPolicy": "reservation-management-operation-journal", + "rightsPolicy": "guest-reservation-data", + "allowedSurfaces": [ + "api-response", + "persistence", + "data-rights-export" + ], + "allowedBoundaries": [ + "customer-api", + "intra-module", + "cross-module" + ], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationStayAmendmentReceiptDto", + "member": "ReconciliationCount", + "surface": "api-response", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Reservations.Domain", + "type": "BunkFy.Modules.Reservations.Domain.StayAmendments.ReservationStayAmendmentOperation", + "member": "ReconciliationCount", + "surface": "persistence" + }, + { + "assembly": "BunkFy.Modules.Reservations.Persistence", + "type": "BunkFy.Modules.Reservations.Persistence.Repositories.ReservationStayAmendmentOperationDataRightsExport", + "member": "ReconciliationCount", + "surface": "data-rights-export", + "retentionPolicy": "reservation-data-rights-export-fragment" + } + ] + }, + { + "id": "reservation.stay-amendment.last-reconciled-at-utc", + "dataSubject": "guest", + "classification": "lifecycle", + "sensitivity": "standard", + "purposes": [ + "reservation-stay-amendment", + "workflow-recovery" + ], + "sources": [ + "staff-action", + "system-generated" + ], + "authoritativeOwner": "reservations", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "reservation-records", + "countryPolicyKey": "reservation.details.history", + "retentionPolicy": "reservation-management-operation-journal", + "rightsPolicy": "guest-reservation-data", + "allowedSurfaces": [ + "api-response", + "persistence", + "data-rights-export" + ], + "allowedBoundaries": [ + "customer-api", + "intra-module", + "cross-module" + ], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationStayAmendmentReceiptDto", + "member": "LastReconciledAtUtc", + "surface": "api-response", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Reservations.Domain", + "type": "BunkFy.Modules.Reservations.Domain.StayAmendments.ReservationStayAmendmentOperation", + "member": "LastReconciledAtUtc", + "surface": "persistence" + }, + { + "assembly": "BunkFy.Modules.Reservations.Persistence", + "type": "BunkFy.Modules.Reservations.Persistence.Repositories.ReservationStayAmendmentOperationDataRightsExport", + "member": "LastReconciledAtUtc", + "surface": "data-rights-export", + "retentionPolicy": "reservation-data-rights-export-fragment" + } + ] + }, + { + "id": "reservation.stay-amendment.last-reconciled-by", + "dataSubject": "staff", + "classification": "audit-attribution", + "sensitivity": "standard", + "purposes": [ + "reservation-stay-amendment", + "workflow-recovery" + ], + "sources": [ + "authenticated-subject" + ], + "authoritativeOwner": "reservations", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "reservation-records", + "countryPolicyKey": "staff.audit-attribution", + "retentionPolicy": "reservation-management-operation-journal", + "rightsPolicy": "staff-audit-attribution", + "allowedSurfaces": [ + "application-command", + "persistence" + ], + "allowedBoundaries": [ + "intra-module" + ], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Reservations.Application", + "type": "BunkFy.Modules.Reservations.Application.Commands.ReconcileReservationStayAmendmentCommand", + "member": "ActorId", + "surface": "application-command", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Reservations.Domain", + "type": "BunkFy.Modules.Reservations.Domain.StayAmendments.ReservationStayAmendmentOperation", + "member": "LastReconciledBy", + "surface": "persistence" + } + ] + }, + { + "id": "reservation.stay-amendment.recovery-eligible", + "dataSubject": "guest", + "classification": "lifecycle", + "sensitivity": "standard", + "purposes": [ + "reservation-stay-amendment", + "workflow-recovery" + ], + "sources": [ + "staff-action", + "system-generated" + ], + "authoritativeOwner": "reservations", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "reservation-records", + "countryPolicyKey": "reservation.details.history", + "retentionPolicy": "reservation-management-operation-journal", + "rightsPolicy": "guest-reservation-data", + "allowedSurfaces": [ + "api-response" + ], + "allowedBoundaries": [ + "customer-api" + ], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationStayAmendmentReceiptDto", + "member": "RecoveryEligible", + "surface": "api-response", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationStayAmendmentRecoveryItemDto", + "member": "RecoveryEligible", + "surface": "api-response", + "retentionPolicy": "transient-response" + } + ] + }, + { + "id": "reservation.stay-amendment.next-recovery-eligible-at-utc", + "dataSubject": "guest", + "classification": "lifecycle", + "sensitivity": "standard", + "purposes": [ + "reservation-stay-amendment", + "workflow-recovery" + ], + "sources": [ + "staff-action", + "system-generated" + ], + "authoritativeOwner": "reservations", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "reservation-records", + "countryPolicyKey": "reservation.details.history", + "retentionPolicy": "reservation-management-operation-journal", + "rightsPolicy": "guest-reservation-data", + "allowedSurfaces": [ + "api-response" + ], + "allowedBoundaries": [ + "customer-api" + ], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationStayAmendmentReceiptDto", + "member": "NextRecoveryEligibleAtUtc", + "surface": "api-response", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationStayAmendmentRecoveryItemDto", + "member": "NextRecoveryEligibleAtUtc", + "surface": "api-response", + "retentionPolicy": "transient-response" + } + ] + }, + { + "id": "reservation.stay-amendment.target", + "dataSubject": "guest", + "classification": "structured-payload", + "sensitivity": "unstructured", + "purposes": [ + "reservation-stay-amendment", + "workflow-recovery" + ], + "sources": [ + "staff-action", + "system-generated" + ], + "authoritativeOwner": "reservations", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "reservation-records", + "countryPolicyKey": "reservation.details.history", + "retentionPolicy": "reservation-management-operation-journal", + "rightsPolicy": "guest-reservation-data", + "allowedSurfaces": [ + "api-response" + ], + "allowedBoundaries": [ + "customer-api" + ], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationStayAmendmentReceiptDto", + "member": "Target", + "surface": "api-response", + "retentionPolicy": "transient-response" + } + ] + }, + { + "id": "reservation.stay-amendment.recovery-cursor", + "dataSubject": "guest", + "classification": "structured-payload", + "sensitivity": "unstructured", + "purposes": [ + "reservation-stay-amendment", + "workflow-recovery" + ], + "sources": [ + "staff-action", + "system-generated" + ], + "authoritativeOwner": "reservations", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "reservation-records", + "countryPolicyKey": "reservation.details.history", + "retentionPolicy": "reservation-management-operation-journal", + "rightsPolicy": "guest-reservation-data", + "allowedSurfaces": [ + "application-query", + "api-response" + ], + "allowedBoundaries": [ + "intra-module", + "customer-api" + ], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Reservations.Application", + "type": "BunkFy.Modules.Reservations.Application.Queries.ListReservationStayAmendmentRecoveryQuery", + "member": "Cursor", + "surface": "application-query", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationStayAmendmentRecoveryPageDto", + "member": "NextCursor", + "surface": "api-response", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Reservations.Application", + "type": "BunkFy.Modules.Reservations.Application.Ports.ReservationStayAmendmentRecoveryPageRecord", + "member": "NextCursor", + "surface": "application-query", + "retentionPolicy": "transient-request" + } + ] + }, + { + "id": "reservation.stay-amendment.recovery-operations", + "dataSubject": "guest", + "classification": "structured-payload", + "sensitivity": "unstructured", + "purposes": [ + "reservation-stay-amendment", + "workflow-recovery" + ], + "sources": [ + "staff-action", + "system-generated" + ], + "authoritativeOwner": "reservations", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "reservation-records", + "countryPolicyKey": "reservation.details.history", + "retentionPolicy": "reservation-management-operation-journal", + "rightsPolicy": "guest-reservation-data", + "allowedSurfaces": [ + "api-response", + "application-query" + ], + "allowedBoundaries": [ + "customer-api", + "intra-module" + ], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationStayAmendmentRecoveryPageDto", + "member": "Operations", + "surface": "api-response", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Reservations.Application", + "type": "BunkFy.Modules.Reservations.Application.Ports.ReservationStayAmendmentRecoveryPageRecord", + "member": "Operations", + "surface": "application-query", + "retentionPolicy": "transient-request" + } + ] + }, + { + "id": "reservation.stay-amendment.inventory-request-id", + "dataSubject": "guest", + "classification": "pseudonymous-identifier", + "sensitivity": "elevated", + "purposes": [ + "inventory-amendment-correlation", + "workflow-recovery" + ], + "sources": [ + "system-generated" + ], + "authoritativeOwner": "reservations", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "reservation-records", + "countryPolicyKey": "reservation.allocation", + "retentionPolicy": "reservation-management-operation-journal", + "rightsPolicy": "guest-reservation-data", + "allowedSurfaces": [ + "persistence", + "data-rights-export" + ], + "allowedBoundaries": [ + "intra-module", + "cross-module" + ], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Reservations.Domain", + "type": "BunkFy.Modules.Reservations.Domain.Aggregates.Reservation", + "member": "PendingInventoryAmendmentRequestId", + "surface": "persistence", + "retentionPolicy": "reservation-record-lifecycle" + }, + { + "assembly": "BunkFy.Modules.Reservations.Domain", + "type": "BunkFy.Modules.Reservations.Domain.StayAmendments.ReservationStayAmendmentOperation", + "member": "InventoryRequestId", + "surface": "persistence" + }, + { + "assembly": "BunkFy.Modules.Reservations.Persistence", + "type": "BunkFy.Modules.Reservations.Persistence.Repositories.ReservationDataRightsExport", + "member": "PendingInventoryAmendmentRequestId", + "surface": "data-rights-export", + "retentionPolicy": "reservation-data-rights-export-fragment" + }, + { + "assembly": "BunkFy.Modules.Reservations.Persistence", + "type": "BunkFy.Modules.Reservations.Persistence.Repositories.ReservationStayAmendmentOperationDataRightsExport", + "member": "InventoryRequestId", + "surface": "data-rights-export", + "retentionPolicy": "reservation-data-rights-export-fragment" + }, + { + "assembly": "BunkFy.Modules.Reservations.Persistence", + "type": "BunkFy.Modules.Reservations.Persistence.Repositories.ReservationBookingStateTenantExport", + "member": "PendingInventoryAmendmentRequestId", + "surface": "data-rights-export", + "retentionPolicy": "reservations-tenant-termination-export-fragment" + }, + { + "assembly": "BunkFy.Modules.Reservations.Persistence", + "type": "BunkFy.Modules.Reservations.Persistence.Repositories.ReservationStayAmendmentOperationStateTenantExport", + "member": "InventoryRequestId", + "surface": "data-rights-export", + "retentionPolicy": "reservations-tenant-termination-export-fragment" + } + ] + }, + { + "id": "reservations.stay-amendment-operation", + "dataSubject": "guest", + "classification": "linked-operational", + "sensitivity": "standard", + "purposes": [ + "authorized-export", + "workflow-recovery" + ], + "sources": [ + "staff-action", + "system-generated" + ], + "authoritativeOwner": "reservations", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "reservation-records", + "countryPolicyKey": "reservation.details.history", + "retentionPolicy": "reservation-management-operation-journal", + "rightsPolicy": "guest-reservation-data", + "allowedSurfaces": [ + "data-rights-export" + ], + "allowedBoundaries": [ + "cross-module" + ], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Reservations.Persistence", + "type": "BunkFy.Modules.Reservations.Persistence.Repositories.ReservationStayAmendmentOperationTenantExport", + "member": "StayAmendmentOperation", + "surface": "data-rights-export", + "retentionPolicy": "reservations-tenant-termination-export-fragment" + } + ] } ] } diff --git a/src/Modules/Reservations/docs/personal-data-inventory.v1.md b/src/Modules/Reservations/docs/personal-data-inventory.v1.md index 5016e331..67fb42b7 100644 --- a/src/Modules/Reservations/docs/personal-data-inventory.v1.md +++ b/src/Modules/Reservations/docs/personal-data-inventory.v1.md @@ -1,4 +1,4 @@ -# reservations Personal-Data Inventory v16 +# reservations Personal-Data Inventory v19 Generated from `reservations.personal-data` schema v1. Catalogue approval: `engineering-default`. @@ -39,7 +39,7 @@ Engineering metadata is not legal or country-launch approval. | reservation-details-history-lifecycle | engineering-default | reservation-details-changed | approved-erasure-or-tenant-termination | pause-approved-erasure | | reservation-guest-record-link-process-lifecycle | engineering-default | reservation-guest-record-link-prepared | owning-reservation-anonymisation-or-tenant-termination | retain-with-owning-reservation | | reservation-guest-restriction-projection-lifecycle | engineering-default | guest-profile-created-or-restriction-state-received | approved-guest-erasure-or-tenant-termination | pause-approved-erasure | -| reservation-management-operation-journal | engineering-default | successful-lifecycle-operation | owning-reservation-removal-or-tenant-termination | retain-with-owning-reservation | +| reservation-management-operation-journal | engineering-default | management-operation-coordinate-recorded | owning-reservation-removal-or-tenant-termination | retain-with-owning-reservation | | reservation-processing-restriction-lifecycle | engineering-default | reservation-processing-restriction-applied | approved-data-rights-accountability-retention-completed | retain-approved-minimum-restriction-proof | | reservation-record-lifecycle | engineering-default | reservation-created | approved-erasure-or-tenant-termination | pause-approved-erasure | | reservation-reminder-lifecycle | engineering-default | arrival-reminder-scheduled | approved-reminder-retention-completed | no-payload-hold | @@ -263,6 +263,37 @@ Engineering metadata is not legal or country-launch approval. | reservation.source.kind | guest | linked-operational | standard | booking-source-reconciliation
reservation-provenance | adapter-observation
staff-entry | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.adapter.provenance | reservation-record-lifecycle | adapter-provenance | api-input
api-response
application-command
data-rights-export
persistence | cross-module
customer-api
intra-module | engineering-default | | reservation.source.reference | guest | pseudonymous-identifier | elevated | booking-source-reconciliation
reservation-provenance | adapter-observation
staff-entry | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.adapter.provenance | reservation-record-lifecycle | adapter-provenance | adapter-ingress
api-input
api-response
application-command
data-rights-export
persistence | adapter
cross-module
customer-api
intra-module | engineering-default | | reservation.source.system | guest | linked-operational | standard | booking-source-reconciliation
reservation-provenance | adapter-observation
staff-entry | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.adapter.provenance | reservation-record-lifecycle | adapter-provenance | adapter-ingress
api-input
api-response
application-command
data-rights-export
persistence | adapter
cross-module
customer-api
intra-module | engineering-default | +| reservation.stay-amendment.completed-at-utc | guest | lifecycle | standard | reservation-stay-amendment
workflow-recovery | staff-action
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.details.history | reservation-management-operation-journal | guest-reservation-data | api-response
data-rights-export
persistence | cross-module
customer-api
intra-module | engineering-default | +| reservation.stay-amendment.expected-details-revision | guest | lifecycle | standard | reservation-stay-amendment
workflow-recovery | staff-action
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.details.history | reservation-management-operation-journal | guest-reservation-data | api-input
api-response
application-command
data-rights-export
persistence | cross-module
customer-api
intra-module | engineering-default | +| reservation.stay-amendment.inventory-request-id | guest | pseudonymous-identifier | elevated | inventory-amendment-correlation
workflow-recovery | system-generated | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.allocation | reservation-management-operation-journal | guest-reservation-data | data-rights-export
persistence | cross-module
intra-module | engineering-default | +| reservation.stay-amendment.last-reconciled-at-utc | guest | lifecycle | standard | reservation-stay-amendment
workflow-recovery | staff-action
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.details.history | reservation-management-operation-journal | guest-reservation-data | api-response
data-rights-export
persistence | cross-module
customer-api
intra-module | engineering-default | +| reservation.stay-amendment.last-reconciled-by | staff | audit-attribution | standard | reservation-stay-amendment
workflow-recovery | authenticated-subject | reservations | customer-controller-bunk-fy-processor | reservation-records | staff.audit-attribution | reservation-management-operation-journal | staff-audit-attribution | application-command
persistence | intra-module | engineering-default | +| reservation.stay-amendment.next-recovery-eligible-at-utc | guest | lifecycle | standard | reservation-stay-amendment
workflow-recovery | staff-action
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.details.history | reservation-management-operation-journal | guest-reservation-data | api-response | customer-api | engineering-default | +| reservation.stay-amendment.operation-id | guest | pseudonymous-identifier | elevated | reservation-stay-amendment
workflow-recovery | staff-action
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.details.history | reservation-management-operation-journal | guest-reservation-data | api-response
application-command
application-query
data-rights-export
persistence | cross-module
customer-api
intra-module | engineering-default | +| reservation.stay-amendment.operation-version | guest | lifecycle | standard | reservation-stay-amendment
workflow-recovery | staff-action
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.details.history | reservation-management-operation-journal | guest-reservation-data | api-input
api-response
application-command
data-rights-export
persistence | cross-module
customer-api
intra-module | engineering-default | +| reservation.stay-amendment.outcome | guest | lifecycle | standard | reservation-stay-amendment
workflow-recovery | staff-action
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.details.history | reservation-management-operation-journal | guest-reservation-data | api-response
application-query
data-rights-export
persistence | cross-module
customer-api
intra-module | engineering-default | +| reservation.stay-amendment.property-id | guest | linked-operational | standard | reservation-stay-amendment
workflow-recovery | staff-action
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.details.history | reservation-management-operation-journal | guest-reservation-data | api-response
application-command
application-query
data-rights-export
persistence | cross-module
customer-api
intra-module | engineering-default | +| reservation.stay-amendment.reconciliation-count | guest | lifecycle | standard | reservation-stay-amendment
workflow-recovery | staff-action
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.details.history | reservation-management-operation-journal | guest-reservation-data | api-response
data-rights-export
persistence | cross-module
customer-api
intra-module | engineering-default | +| reservation.stay-amendment.recovery-cursor | guest | structured-payload | unstructured | reservation-stay-amendment
workflow-recovery | staff-action
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.details.history | reservation-management-operation-journal | guest-reservation-data | api-response
application-query | customer-api
intra-module | engineering-default | +| reservation.stay-amendment.recovery-eligible | guest | lifecycle | standard | reservation-stay-amendment
workflow-recovery | staff-action
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.details.history | reservation-management-operation-journal | guest-reservation-data | api-response | customer-api | engineering-default | +| reservation.stay-amendment.recovery-operations | guest | structured-payload | unstructured | reservation-stay-amendment
workflow-recovery | staff-action
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.details.history | reservation-management-operation-journal | guest-reservation-data | api-response
application-query | customer-api
intra-module | engineering-default | +| reservation.stay-amendment.rejection-reason | guest | linked-operational | standard | reservation-stay-amendment
workflow-recovery | staff-action
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.details.history | reservation-management-operation-journal | guest-reservation-data | api-response
data-rights-export
persistence | cross-module
customer-api
intra-module | engineering-default | +| reservation.stay-amendment.request-fingerprint | guest | pseudonymous-identifier | elevated | reservation-stay-amendment
workflow-recovery | staff-action
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.details.history | reservation-management-operation-journal | guest-reservation-data | data-rights-export
persistence | cross-module
intra-module | engineering-default | +| reservation.stay-amendment.request-schema-version | guest | lifecycle | standard | reservation-stay-amendment
workflow-recovery | staff-action
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.details.history | reservation-management-operation-journal | guest-reservation-data | data-rights-export
persistence | cross-module
intra-module | engineering-default | +| reservation.stay-amendment.requested-at-utc | guest | lifecycle | standard | reservation-stay-amendment
workflow-recovery | staff-action
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.details.history | reservation-management-operation-journal | guest-reservation-data | api-response
data-rights-export
persistence | cross-module
customer-api
intra-module | engineering-default | +| reservation.stay-amendment.requested-by | staff | audit-attribution | standard | reservation-stay-amendment
workflow-recovery | authenticated-subject | reservations | customer-controller-bunk-fy-processor | reservation-records | staff.audit-attribution | reservation-management-operation-journal | staff-audit-attribution | application-command
persistence | intra-module | engineering-default | +| reservation.stay-amendment.reservation-id | guest | linked-operational | standard | reservation-stay-amendment
workflow-recovery | staff-action
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.details.history | reservation-management-operation-journal | guest-reservation-data | api-response
application-command
application-query
data-rights-export
persistence | cross-module
customer-api
intra-module | engineering-default | +| reservation.stay-amendment.resulting-allocation-version | guest | lifecycle | standard | inventory-allocation-consistency
reservation-stay-amendment
workflow-recovery | system-generated | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.allocation | reservation-management-operation-journal | guest-reservation-data | data-rights-export
persistence | cross-module
intra-module | engineering-default | +| reservation.stay-amendment.resulting-details-revision | guest | lifecycle | standard | reservation-stay-amendment
workflow-recovery | staff-action
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.details.history | reservation-management-operation-journal | guest-reservation-data | api-response
data-rights-export
persistence | cross-module
customer-api
intra-module | engineering-default | +| reservation.stay-amendment.resulting-reservation-version | guest | lifecycle | standard | reservation-stay-amendment
workflow-recovery | staff-action
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.details.history | reservation-management-operation-journal | guest-reservation-data | api-response
data-rights-export
persistence | cross-module
customer-api
intra-module | engineering-default | +| reservation.stay-amendment.scope-id | guest | linked-operational | standard | reservation-stay-amendment
workflow-recovery | staff-action
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.details.history | reservation-management-operation-journal | guest-reservation-data | persistence | intra-module | engineering-default | +| reservation.stay-amendment.target | guest | structured-payload | unstructured | reservation-stay-amendment
workflow-recovery | staff-action
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.details.history | reservation-management-operation-journal | guest-reservation-data | api-response | customer-api | engineering-default | +| reservation.stay-amendment.target-arrival | guest | lifecycle | standard | reservation-stay-amendment
workflow-recovery | staff-action
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.details.history | reservation-management-operation-journal | guest-reservation-data | api-input
api-response
application-command
data-rights-export
persistence | cross-module
customer-api
intra-module | engineering-default | +| reservation.stay-amendment.target-departure | guest | lifecycle | standard | reservation-stay-amendment
workflow-recovery | staff-action
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.details.history | reservation-management-operation-journal | guest-reservation-data | api-input
api-response
application-command
data-rights-export
persistence | cross-module
customer-api
intra-module | engineering-default | +| reservation.stay-amendment.target-expected-arrival-time | guest | lifecycle | standard | reservation-stay-amendment
workflow-recovery | staff-action
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.details.history | reservation-management-operation-journal | guest-reservation-data | api-input
api-response
application-command
data-rights-export
persistence | cross-module
customer-api
intra-module | engineering-default | +| reservation.stay-amendment.target-expected-departure-time | guest | lifecycle | standard | reservation-stay-amendment
workflow-recovery | staff-action
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.details.history | reservation-management-operation-journal | guest-reservation-data | api-input
api-response
application-command
data-rights-export
persistence | cross-module
customer-api
intra-module | engineering-default | +| reservation.stay-amendment.target-inventory-unit-ids | guest | linked-operational | standard | reservation-stay-amendment
workflow-recovery | staff-action
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.details.history | reservation-management-operation-journal | guest-reservation-data | api-input
api-response
application-command
data-rights-export
persistence | cross-module
customer-api
intra-module | engineering-default | +| reservation.stay-amendment.updated-at-utc | guest | lifecycle | standard | reservation-stay-amendment
workflow-recovery | staff-action
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.details.history | reservation-management-operation-journal | guest-reservation-data | api-response
application-query
data-rights-export
persistence | cross-module
customer-api
intra-module | engineering-default | | reservation.stay.arrival-date | guest | lifecycle | standard | front-desk-operations
stay-lifecycle-management | adapter-observation
staff-entry
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.stay | reservation-record-lifecycle | guest-reservation-data | adapter-ingress
api-input
api-response
application-command
data-rights-export
domain-event
integration-event
persistence | adapter
cross-module
customer-api
intra-module | engineering-default | | reservation.stay.business-date | guest | lifecycle | standard | front-desk-operations
stay-lifecycle-management | adapter-observation
staff-entry
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.stay | reservation-record-lifecycle | guest-reservation-data | api-input
api-response
application-command
data-rights-export
domain-event
integration-event
persistence | cross-module
customer-api
intra-module | engineering-default | | reservation.stay.checked-in-at-utc | guest | lifecycle | standard | front-desk-operations
stay-lifecycle-management | adapter-observation
staff-entry
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.stay | reservation-record-lifecycle | guest-reservation-data | api-response
data-rights-export
persistence | cross-module
customer-api
intra-module | engineering-default | @@ -286,6 +317,7 @@ Engineering metadata is not legal or country-launch approval. | reservations.retention-execution | guest | pseudonymous-identifier | elevated | operational-audit
retention-execution
retention-idempotency | reservations-persistence
retention-control-plane | reservations | customer-controller-bunk-fy-processor | reservation-retention-execution | reservation.retention.reservation-operational | reservation-retention-execution-evidence | reservation-retention-control | data-rights-export | cross-module | engineering-default | | reservations.retention-proof | guest | linked-operational | elevated | immutable-owner-proof
operational-audit
retention-anonymisation | country-policy-evaluation
reservations-retention-execution
system-clock | reservations | customer-controller-bunk-fy-processor | reservation-retention-proof | reservation.retention.reservation-operational | reservation-retention-anonymisation-proof | reservation-retention-proof-control | data-rights-export | cross-module | engineering-default | | reservations.staff-attribution | staff | audit-attribution | standard | accountability
authorized-change-traceability | authenticated-subject | reservations | customer-controller-bunk-fy-processor | reservation-records | staff.audit-attribution | reservation-record-lifecycle | staff-audit-attribution | data-rights-export | cross-module | engineering-default | +| reservations.stay-amendment-operation | guest | linked-operational | standard | authorized-export
workflow-recovery | staff-action
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.details.history | reservation-management-operation-journal | guest-reservation-data | data-rights-export | cross-module | engineering-default | ## Code Bindings @@ -1605,6 +1637,130 @@ Engineering metadata is not legal or country-launch approval. | reservation.source.system | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationDto | SourceSystem | api-response | transient-response | | reservation.source.system | BunkFy.Modules.Reservations.Domain | BunkFy.Modules.Reservations.Domain.Aggregates.Reservation | SourceSystem | persistence | reservation-record-lifecycle | | reservation.source.system | BunkFy.Modules.Reservations.Persistence | BunkFy.Modules.Reservations.Persistence.Repositories.ReservationDataRightsExport | SourceSystem | data-rights-export | reservation-data-rights-export-fragment | +| reservation.stay-amendment.completed-at-utc | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationStayAmendmentReceiptDto | CompletedAtUtc | api-response | transient-response | +| reservation.stay-amendment.completed-at-utc | BunkFy.Modules.Reservations.Domain | BunkFy.Modules.Reservations.Domain.StayAmendments.ReservationStayAmendmentOperation | CompletedAtUtc | persistence | reservation-management-operation-journal | +| reservation.stay-amendment.completed-at-utc | BunkFy.Modules.Reservations.Persistence | BunkFy.Modules.Reservations.Persistence.Repositories.ReservationStayAmendmentOperationDataRightsExport | CompletedAtUtc | data-rights-export | reservation-data-rights-export-fragment | +| reservation.stay-amendment.expected-details-revision | BunkFy.Modules.Reservations.Api | BunkFy.Modules.Reservations.Api.ReservationsModule+AmendReservationStayRequest | ExpectedDetailsRevision | api-input | transient-request | +| reservation.stay-amendment.expected-details-revision | BunkFy.Modules.Reservations.Application | BunkFy.Modules.Reservations.Application.Commands.AmendReservationStayCommand | ExpectedDetailsRevision | application-command | transient-request | +| reservation.stay-amendment.expected-details-revision | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationStayAmendmentReceiptDto | ExpectedDetailsRevision | api-response | transient-response | +| reservation.stay-amendment.expected-details-revision | BunkFy.Modules.Reservations.Domain | BunkFy.Modules.Reservations.Domain.StayAmendments.ReservationStayAmendmentOperation | ExpectedDetailsRevision | persistence | reservation-management-operation-journal | +| reservation.stay-amendment.expected-details-revision | BunkFy.Modules.Reservations.Persistence | BunkFy.Modules.Reservations.Persistence.Repositories.ReservationStayAmendmentOperationDataRightsExport | ExpectedDetailsRevision | data-rights-export | reservation-data-rights-export-fragment | +| reservation.stay-amendment.inventory-request-id | BunkFy.Modules.Reservations.Domain | BunkFy.Modules.Reservations.Domain.Aggregates.Reservation | PendingInventoryAmendmentRequestId | persistence | reservation-record-lifecycle | +| reservation.stay-amendment.inventory-request-id | BunkFy.Modules.Reservations.Domain | BunkFy.Modules.Reservations.Domain.StayAmendments.ReservationStayAmendmentOperation | InventoryRequestId | persistence | reservation-management-operation-journal | +| reservation.stay-amendment.inventory-request-id | BunkFy.Modules.Reservations.Persistence | BunkFy.Modules.Reservations.Persistence.Repositories.ReservationBookingStateTenantExport | PendingInventoryAmendmentRequestId | data-rights-export | reservations-tenant-termination-export-fragment | +| reservation.stay-amendment.inventory-request-id | BunkFy.Modules.Reservations.Persistence | BunkFy.Modules.Reservations.Persistence.Repositories.ReservationDataRightsExport | PendingInventoryAmendmentRequestId | data-rights-export | reservation-data-rights-export-fragment | +| reservation.stay-amendment.inventory-request-id | BunkFy.Modules.Reservations.Persistence | BunkFy.Modules.Reservations.Persistence.Repositories.ReservationStayAmendmentOperationDataRightsExport | InventoryRequestId | data-rights-export | reservation-data-rights-export-fragment | +| reservation.stay-amendment.inventory-request-id | BunkFy.Modules.Reservations.Persistence | BunkFy.Modules.Reservations.Persistence.Repositories.ReservationStayAmendmentOperationStateTenantExport | InventoryRequestId | data-rights-export | reservations-tenant-termination-export-fragment | +| reservation.stay-amendment.last-reconciled-at-utc | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationStayAmendmentReceiptDto | LastReconciledAtUtc | api-response | transient-response | +| reservation.stay-amendment.last-reconciled-at-utc | BunkFy.Modules.Reservations.Domain | BunkFy.Modules.Reservations.Domain.StayAmendments.ReservationStayAmendmentOperation | LastReconciledAtUtc | persistence | reservation-management-operation-journal | +| reservation.stay-amendment.last-reconciled-at-utc | BunkFy.Modules.Reservations.Persistence | BunkFy.Modules.Reservations.Persistence.Repositories.ReservationStayAmendmentOperationDataRightsExport | LastReconciledAtUtc | data-rights-export | reservation-data-rights-export-fragment | +| reservation.stay-amendment.last-reconciled-by | BunkFy.Modules.Reservations.Application | BunkFy.Modules.Reservations.Application.Commands.ReconcileReservationStayAmendmentCommand | ActorId | application-command | transient-request | +| reservation.stay-amendment.last-reconciled-by | BunkFy.Modules.Reservations.Domain | BunkFy.Modules.Reservations.Domain.StayAmendments.ReservationStayAmendmentOperation | LastReconciledBy | persistence | reservation-management-operation-journal | +| reservation.stay-amendment.next-recovery-eligible-at-utc | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationStayAmendmentReceiptDto | NextRecoveryEligibleAtUtc | api-response | transient-response | +| reservation.stay-amendment.next-recovery-eligible-at-utc | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationStayAmendmentRecoveryItemDto | NextRecoveryEligibleAtUtc | api-response | transient-response | +| reservation.stay-amendment.operation-id | BunkFy.Modules.Reservations.Application | BunkFy.Modules.Reservations.Application.Commands.AmendReservationStayCommand | OperationId | application-command | transient-request | +| reservation.stay-amendment.operation-id | BunkFy.Modules.Reservations.Application | BunkFy.Modules.Reservations.Application.Commands.ReconcileReservationStayAmendmentCommand | OperationId | application-command | transient-request | +| reservation.stay-amendment.operation-id | BunkFy.Modules.Reservations.Application | BunkFy.Modules.Reservations.Application.Ports.ReservationStayAmendmentRecoveryCursorRecord | OperationId | application-query | transient-request | +| reservation.stay-amendment.operation-id | BunkFy.Modules.Reservations.Application | BunkFy.Modules.Reservations.Application.Queries.GetReservationStayAmendmentQuery | OperationId | application-query | transient-request | +| reservation.stay-amendment.operation-id | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationStayAmendmentReceiptDto | OperationId | api-response | transient-response | +| reservation.stay-amendment.operation-id | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationStayAmendmentRecoveryCursorDto | OperationId | api-response | transient-response | +| reservation.stay-amendment.operation-id | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationStayAmendmentRecoveryItemDto | OperationId | api-response | transient-response | +| reservation.stay-amendment.operation-id | BunkFy.Modules.Reservations.Domain | BunkFy.Modules.Reservations.Domain.StayAmendments.ReservationStayAmendmentOperation | Id | persistence | reservation-management-operation-journal | +| reservation.stay-amendment.operation-id | BunkFy.Modules.Reservations.Persistence | BunkFy.Modules.Reservations.Persistence.Repositories.ReservationStayAmendmentOperationDataRightsExport | OperationId | data-rights-export | reservation-data-rights-export-fragment | +| reservation.stay-amendment.operation-version | BunkFy.Modules.Reservations.Api | BunkFy.Modules.Reservations.Api.ReservationsModule+ReconcileReservationStayAmendmentRequest | ExpectedOperationVersion | api-input | transient-request | +| reservation.stay-amendment.operation-version | BunkFy.Modules.Reservations.Application | BunkFy.Modules.Reservations.Application.Commands.ReconcileReservationStayAmendmentCommand | ExpectedOperationVersion | application-command | transient-request | +| reservation.stay-amendment.operation-version | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationStayAmendmentReceiptDto | OperationVersion | api-response | transient-response | +| reservation.stay-amendment.operation-version | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationStayAmendmentRecoveryItemDto | OperationVersion | api-response | transient-response | +| reservation.stay-amendment.operation-version | BunkFy.Modules.Reservations.Domain | BunkFy.Modules.Reservations.Domain.StayAmendments.ReservationStayAmendmentOperation | OperationVersion | persistence | reservation-management-operation-journal | +| reservation.stay-amendment.operation-version | BunkFy.Modules.Reservations.Persistence | BunkFy.Modules.Reservations.Persistence.Repositories.ReservationStayAmendmentOperationDataRightsExport | OperationVersion | data-rights-export | reservation-data-rights-export-fragment | +| reservation.stay-amendment.outcome | BunkFy.Modules.Reservations.Application | BunkFy.Modules.Reservations.Application.Ports.ReservationStayAmendmentRecoveryCursorRecord | Outcome | application-query | transient-request | +| reservation.stay-amendment.outcome | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationStayAmendmentReceiptDto | Outcome | api-response | transient-response | +| reservation.stay-amendment.outcome | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationStayAmendmentRecoveryCursorDto | Outcome | api-response | transient-response | +| reservation.stay-amendment.outcome | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationStayAmendmentRecoveryItemDto | Outcome | api-response | transient-response | +| reservation.stay-amendment.outcome | BunkFy.Modules.Reservations.Domain | BunkFy.Modules.Reservations.Domain.StayAmendments.ReservationStayAmendmentOperation | Outcome | persistence | reservation-management-operation-journal | +| reservation.stay-amendment.outcome | BunkFy.Modules.Reservations.Persistence | BunkFy.Modules.Reservations.Persistence.Repositories.ReservationStayAmendmentOperationDataRightsExport | Outcome | data-rights-export | reservation-data-rights-export-fragment | +| reservation.stay-amendment.property-id | BunkFy.Modules.Reservations.Application | BunkFy.Modules.Reservations.Application.Commands.AmendReservationStayCommand | PropertyId | application-command | transient-request | +| reservation.stay-amendment.property-id | BunkFy.Modules.Reservations.Application | BunkFy.Modules.Reservations.Application.Commands.ReconcileReservationStayAmendmentCommand | PropertyId | application-command | transient-request | +| reservation.stay-amendment.property-id | BunkFy.Modules.Reservations.Application | BunkFy.Modules.Reservations.Application.Queries.GetReservationStayAmendmentQuery | PropertyId | application-query | transient-request | +| reservation.stay-amendment.property-id | BunkFy.Modules.Reservations.Application | BunkFy.Modules.Reservations.Application.Queries.ListReservationStayAmendmentRecoveryQuery | PropertyId | application-query | transient-request | +| reservation.stay-amendment.property-id | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationStayAmendmentReceiptDto | PropertyId | api-response | transient-response | +| reservation.stay-amendment.property-id | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationStayAmendmentRecoveryItemDto | PropertyId | api-response | transient-response | +| reservation.stay-amendment.property-id | BunkFy.Modules.Reservations.Domain | BunkFy.Modules.Reservations.Domain.StayAmendments.ReservationStayAmendmentOperation | PropertyId | persistence | reservation-management-operation-journal | +| reservation.stay-amendment.property-id | BunkFy.Modules.Reservations.Persistence | BunkFy.Modules.Reservations.Persistence.Repositories.ReservationStayAmendmentOperationDataRightsExport | PropertyId | data-rights-export | reservation-data-rights-export-fragment | +| reservation.stay-amendment.reconciliation-count | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationStayAmendmentReceiptDto | ReconciliationCount | api-response | transient-response | +| reservation.stay-amendment.reconciliation-count | BunkFy.Modules.Reservations.Domain | BunkFy.Modules.Reservations.Domain.StayAmendments.ReservationStayAmendmentOperation | ReconciliationCount | persistence | reservation-management-operation-journal | +| reservation.stay-amendment.reconciliation-count | BunkFy.Modules.Reservations.Persistence | BunkFy.Modules.Reservations.Persistence.Repositories.ReservationStayAmendmentOperationDataRightsExport | ReconciliationCount | data-rights-export | reservation-data-rights-export-fragment | +| reservation.stay-amendment.recovery-cursor | BunkFy.Modules.Reservations.Application | BunkFy.Modules.Reservations.Application.Ports.ReservationStayAmendmentRecoveryPageRecord | NextCursor | application-query | transient-request | +| reservation.stay-amendment.recovery-cursor | BunkFy.Modules.Reservations.Application | BunkFy.Modules.Reservations.Application.Queries.ListReservationStayAmendmentRecoveryQuery | Cursor | application-query | transient-request | +| reservation.stay-amendment.recovery-cursor | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationStayAmendmentRecoveryPageDto | NextCursor | api-response | transient-response | +| reservation.stay-amendment.recovery-eligible | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationStayAmendmentReceiptDto | RecoveryEligible | api-response | transient-response | +| reservation.stay-amendment.recovery-eligible | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationStayAmendmentRecoveryItemDto | RecoveryEligible | api-response | transient-response | +| reservation.stay-amendment.recovery-operations | BunkFy.Modules.Reservations.Application | BunkFy.Modules.Reservations.Application.Ports.ReservationStayAmendmentRecoveryPageRecord | Operations | application-query | transient-request | +| reservation.stay-amendment.recovery-operations | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationStayAmendmentRecoveryPageDto | Operations | api-response | transient-response | +| reservation.stay-amendment.rejection-reason | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationStayAmendmentReceiptDto | RejectionReason | api-response | transient-response | +| reservation.stay-amendment.rejection-reason | BunkFy.Modules.Reservations.Domain | BunkFy.Modules.Reservations.Domain.StayAmendments.ReservationStayAmendmentOperation | RejectionCode | persistence | reservation-management-operation-journal | +| reservation.stay-amendment.rejection-reason | BunkFy.Modules.Reservations.Persistence | BunkFy.Modules.Reservations.Persistence.Repositories.ReservationStayAmendmentOperationDataRightsExport | RejectionCode | data-rights-export | reservation-data-rights-export-fragment | +| reservation.stay-amendment.request-fingerprint | BunkFy.Modules.Reservations.Domain | BunkFy.Modules.Reservations.Domain.StayAmendments.ReservationStayAmendmentOperation | RequestFingerprint | persistence | reservation-management-operation-journal | +| reservation.stay-amendment.request-fingerprint | BunkFy.Modules.Reservations.Persistence | BunkFy.Modules.Reservations.Persistence.Repositories.ReservationStayAmendmentOperationDataRightsExport | RequestFingerprint | data-rights-export | reservation-data-rights-export-fragment | +| reservation.stay-amendment.request-schema-version | BunkFy.Modules.Reservations.Domain | BunkFy.Modules.Reservations.Domain.StayAmendments.ReservationStayAmendmentOperation | RequestSchemaVersion | persistence | reservation-management-operation-journal | +| reservation.stay-amendment.request-schema-version | BunkFy.Modules.Reservations.Persistence | BunkFy.Modules.Reservations.Persistence.Repositories.ReservationStayAmendmentOperationDataRightsExport | RequestSchemaVersion | data-rights-export | reservation-data-rights-export-fragment | +| reservation.stay-amendment.requested-at-utc | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationStayAmendmentReceiptDto | RequestedAtUtc | api-response | transient-response | +| reservation.stay-amendment.requested-at-utc | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationStayAmendmentRecoveryItemDto | RequestedAtUtc | api-response | transient-response | +| reservation.stay-amendment.requested-at-utc | BunkFy.Modules.Reservations.Domain | BunkFy.Modules.Reservations.Domain.StayAmendments.ReservationStayAmendmentOperation | RequestedAtUtc | persistence | reservation-management-operation-journal | +| reservation.stay-amendment.requested-at-utc | BunkFy.Modules.Reservations.Persistence | BunkFy.Modules.Reservations.Persistence.Repositories.ReservationStayAmendmentOperationDataRightsExport | RequestedAtUtc | data-rights-export | reservation-data-rights-export-fragment | +| reservation.stay-amendment.requested-by | BunkFy.Modules.Reservations.Application | BunkFy.Modules.Reservations.Application.Commands.AmendReservationStayCommand | ActorId | application-command | transient-request | +| reservation.stay-amendment.requested-by | BunkFy.Modules.Reservations.Domain | BunkFy.Modules.Reservations.Domain.StayAmendments.ReservationStayAmendmentOperation | RequestedBy | persistence | reservation-management-operation-journal | +| reservation.stay-amendment.reservation-id | BunkFy.Modules.Reservations.Application | BunkFy.Modules.Reservations.Application.Commands.AmendReservationStayCommand | ReservationId | application-command | transient-request | +| reservation.stay-amendment.reservation-id | BunkFy.Modules.Reservations.Application | BunkFy.Modules.Reservations.Application.Commands.ReconcileReservationStayAmendmentCommand | ReservationId | application-command | transient-request | +| reservation.stay-amendment.reservation-id | BunkFy.Modules.Reservations.Application | BunkFy.Modules.Reservations.Application.Ports.ReservationStayAmendmentRecoveryCursorRecord | ReservationId | application-query | transient-request | +| reservation.stay-amendment.reservation-id | BunkFy.Modules.Reservations.Application | BunkFy.Modules.Reservations.Application.Queries.GetReservationStayAmendmentQuery | ReservationId | application-query | transient-request | +| reservation.stay-amendment.reservation-id | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationStayAmendmentReceiptDto | ReservationId | api-response | transient-response | +| reservation.stay-amendment.reservation-id | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationStayAmendmentRecoveryCursorDto | ReservationId | api-response | transient-response | +| reservation.stay-amendment.reservation-id | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationStayAmendmentRecoveryItemDto | ReservationId | api-response | transient-response | +| reservation.stay-amendment.reservation-id | BunkFy.Modules.Reservations.Domain | BunkFy.Modules.Reservations.Domain.StayAmendments.ReservationStayAmendmentOperation | ReservationId | persistence | reservation-management-operation-journal | +| reservation.stay-amendment.reservation-id | BunkFy.Modules.Reservations.Persistence | BunkFy.Modules.Reservations.Persistence.Repositories.ReservationStayAmendmentOperationDataRightsExport | ReservationId | data-rights-export | reservation-data-rights-export-fragment | +| reservation.stay-amendment.resulting-allocation-version | BunkFy.Modules.Reservations.Domain | BunkFy.Modules.Reservations.Domain.StayAmendments.ReservationStayAmendmentOperation | ResultingAllocationVersion | persistence | reservation-management-operation-journal | +| reservation.stay-amendment.resulting-allocation-version | BunkFy.Modules.Reservations.Persistence | BunkFy.Modules.Reservations.Persistence.Repositories.ReservationStayAmendmentOperationDataRightsExport | ResultingAllocationVersion | data-rights-export | reservation-data-rights-export-fragment | +| reservation.stay-amendment.resulting-allocation-version | BunkFy.Modules.Reservations.Persistence | BunkFy.Modules.Reservations.Persistence.Repositories.ReservationStayAmendmentOperationStateTenantExport | ResultingAllocationVersion | data-rights-export | reservations-tenant-termination-export-fragment | +| reservation.stay-amendment.resulting-details-revision | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationStayAmendmentReceiptDto | ResultingDetailsRevision | api-response | transient-response | +| reservation.stay-amendment.resulting-details-revision | BunkFy.Modules.Reservations.Domain | BunkFy.Modules.Reservations.Domain.StayAmendments.ReservationStayAmendmentOperation | ResultingDetailsRevision | persistence | reservation-management-operation-journal | +| reservation.stay-amendment.resulting-details-revision | BunkFy.Modules.Reservations.Persistence | BunkFy.Modules.Reservations.Persistence.Repositories.ReservationStayAmendmentOperationDataRightsExport | ResultingDetailsRevision | data-rights-export | reservation-data-rights-export-fragment | +| reservation.stay-amendment.resulting-reservation-version | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationStayAmendmentReceiptDto | ResultingReservationVersion | api-response | transient-response | +| reservation.stay-amendment.resulting-reservation-version | BunkFy.Modules.Reservations.Domain | BunkFy.Modules.Reservations.Domain.StayAmendments.ReservationStayAmendmentOperation | ResultingReservationVersion | persistence | reservation-management-operation-journal | +| reservation.stay-amendment.resulting-reservation-version | BunkFy.Modules.Reservations.Persistence | BunkFy.Modules.Reservations.Persistence.Repositories.ReservationStayAmendmentOperationDataRightsExport | ResultingReservationVersion | data-rights-export | reservation-data-rights-export-fragment | +| reservation.stay-amendment.scope-id | BunkFy.Modules.Reservations.Domain | BunkFy.Modules.Reservations.Domain.StayAmendments.ReservationStayAmendmentOperation | ScopeId | persistence | reservation-management-operation-journal | +| reservation.stay-amendment.target | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationStayAmendmentReceiptDto | Target | api-response | transient-response | +| reservation.stay-amendment.target-arrival | BunkFy.Modules.Reservations.Api | BunkFy.Modules.Reservations.Api.ReservationsModule+AmendReservationStayRequest | Arrival | api-input | transient-request | +| reservation.stay-amendment.target-arrival | BunkFy.Modules.Reservations.Application | BunkFy.Modules.Reservations.Application.Commands.AmendReservationStayCommand | Arrival | application-command | transient-request | +| reservation.stay-amendment.target-arrival | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationStayAmendmentTargetDto | Arrival | api-response | transient-response | +| reservation.stay-amendment.target-arrival | BunkFy.Modules.Reservations.Domain | BunkFy.Modules.Reservations.Domain.StayAmendments.ReservationStayAmendmentOperation | TargetArrival | persistence | reservation-management-operation-journal | +| reservation.stay-amendment.target-arrival | BunkFy.Modules.Reservations.Persistence | BunkFy.Modules.Reservations.Persistence.Repositories.ReservationStayAmendmentOperationDataRightsExport | TargetArrival | data-rights-export | reservation-data-rights-export-fragment | +| reservation.stay-amendment.target-departure | BunkFy.Modules.Reservations.Api | BunkFy.Modules.Reservations.Api.ReservationsModule+AmendReservationStayRequest | Departure | api-input | transient-request | +| reservation.stay-amendment.target-departure | BunkFy.Modules.Reservations.Application | BunkFy.Modules.Reservations.Application.Commands.AmendReservationStayCommand | Departure | application-command | transient-request | +| reservation.stay-amendment.target-departure | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationStayAmendmentTargetDto | Departure | api-response | transient-response | +| reservation.stay-amendment.target-departure | BunkFy.Modules.Reservations.Domain | BunkFy.Modules.Reservations.Domain.StayAmendments.ReservationStayAmendmentOperation | TargetDeparture | persistence | reservation-management-operation-journal | +| reservation.stay-amendment.target-departure | BunkFy.Modules.Reservations.Persistence | BunkFy.Modules.Reservations.Persistence.Repositories.ReservationStayAmendmentOperationDataRightsExport | TargetDeparture | data-rights-export | reservation-data-rights-export-fragment | +| reservation.stay-amendment.target-expected-arrival-time | BunkFy.Modules.Reservations.Api | BunkFy.Modules.Reservations.Api.ReservationsModule+AmendReservationStayRequest | ExpectedArrivalTime | api-input | transient-request | +| reservation.stay-amendment.target-expected-arrival-time | BunkFy.Modules.Reservations.Application | BunkFy.Modules.Reservations.Application.Commands.AmendReservationStayCommand | ExpectedArrivalTime | application-command | transient-request | +| reservation.stay-amendment.target-expected-arrival-time | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationStayAmendmentTargetDto | ExpectedArrivalTime | api-response | transient-response | +| reservation.stay-amendment.target-expected-arrival-time | BunkFy.Modules.Reservations.Domain | BunkFy.Modules.Reservations.Domain.StayAmendments.ReservationStayAmendmentOperation | TargetExpectedArrivalTime | persistence | reservation-management-operation-journal | +| reservation.stay-amendment.target-expected-arrival-time | BunkFy.Modules.Reservations.Persistence | BunkFy.Modules.Reservations.Persistence.Repositories.ReservationStayAmendmentOperationDataRightsExport | TargetExpectedArrivalTime | data-rights-export | reservation-data-rights-export-fragment | +| reservation.stay-amendment.target-expected-departure-time | BunkFy.Modules.Reservations.Api | BunkFy.Modules.Reservations.Api.ReservationsModule+AmendReservationStayRequest | ExpectedDepartureTime | api-input | transient-request | +| reservation.stay-amendment.target-expected-departure-time | BunkFy.Modules.Reservations.Application | BunkFy.Modules.Reservations.Application.Commands.AmendReservationStayCommand | ExpectedDepartureTime | application-command | transient-request | +| reservation.stay-amendment.target-expected-departure-time | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationStayAmendmentTargetDto | ExpectedDepartureTime | api-response | transient-response | +| reservation.stay-amendment.target-expected-departure-time | BunkFy.Modules.Reservations.Domain | BunkFy.Modules.Reservations.Domain.StayAmendments.ReservationStayAmendmentOperation | TargetExpectedDepartureTime | persistence | reservation-management-operation-journal | +| reservation.stay-amendment.target-expected-departure-time | BunkFy.Modules.Reservations.Persistence | BunkFy.Modules.Reservations.Persistence.Repositories.ReservationStayAmendmentOperationDataRightsExport | TargetExpectedDepartureTime | data-rights-export | reservation-data-rights-export-fragment | +| reservation.stay-amendment.target-inventory-unit-ids | BunkFy.Modules.Reservations.Api | BunkFy.Modules.Reservations.Api.ReservationsModule+AmendReservationStayRequest | InventoryUnitIds | api-input | transient-request | +| reservation.stay-amendment.target-inventory-unit-ids | BunkFy.Modules.Reservations.Application | BunkFy.Modules.Reservations.Application.Commands.AmendReservationStayCommand | InventoryUnitIds | application-command | transient-request | +| reservation.stay-amendment.target-inventory-unit-ids | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationStayAmendmentTargetDto | InventoryUnitIds | api-response | transient-response | +| reservation.stay-amendment.target-inventory-unit-ids | BunkFy.Modules.Reservations.Domain | BunkFy.Modules.Reservations.Domain.StayAmendments.ReservationStayAmendmentOperation | TargetInventoryUnitIds | persistence | reservation-management-operation-journal | +| reservation.stay-amendment.target-inventory-unit-ids | BunkFy.Modules.Reservations.Persistence | BunkFy.Modules.Reservations.Persistence.Repositories.ReservationStayAmendmentOperationDataRightsExport | TargetInventoryUnitIds | data-rights-export | reservation-data-rights-export-fragment | +| reservation.stay-amendment.updated-at-utc | BunkFy.Modules.Reservations.Application | BunkFy.Modules.Reservations.Application.Ports.ReservationStayAmendmentRecoveryCursorRecord | UpdatedAtUtc | application-query | transient-request | +| reservation.stay-amendment.updated-at-utc | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationStayAmendmentReceiptDto | UpdatedAtUtc | api-response | transient-response | +| reservation.stay-amendment.updated-at-utc | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationStayAmendmentRecoveryCursorDto | UpdatedAtUtc | api-response | transient-response | +| reservation.stay-amendment.updated-at-utc | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationStayAmendmentRecoveryItemDto | UpdatedAtUtc | api-response | transient-response | +| reservation.stay-amendment.updated-at-utc | BunkFy.Modules.Reservations.Domain | BunkFy.Modules.Reservations.Domain.StayAmendments.ReservationStayAmendmentOperation | UpdatedAtUtc | persistence | reservation-management-operation-journal | +| reservation.stay-amendment.updated-at-utc | BunkFy.Modules.Reservations.Persistence | BunkFy.Modules.Reservations.Persistence.Repositories.ReservationStayAmendmentOperationDataRightsExport | UpdatedAtUtc | data-rights-export | reservation-data-rights-export-fragment | | reservation.stay.arrival-date | BunkFy.Modules.Reservations.Api | BunkFy.Modules.Reservations.Api.ReservationsModule+CreateReservationRequest | Arrival | api-input | transient-request | | reservation.stay.arrival-date | BunkFy.Modules.Reservations.Application | BunkFy.Modules.Reservations.Application.Commands.CreateReservationCommand | Arrival | application-command | transient-request | | reservation.stay.arrival-date | BunkFy.Modules.Reservations.Application | BunkFy.Modules.Reservations.Application.Ports.ReservationArrivalReminderDispatch | Arrival | application-command | transient-request | @@ -1768,4 +1924,6 @@ Engineering metadata is not legal or country-launch approval. | reservations.staff-attribution | BunkFy.Modules.Reservations.Persistence | BunkFy.Modules.Reservations.Persistence.Repositories.ReservationPendingAmendmentTenantExport | StaffAttribution | data-rights-export | reservations-tenant-termination-export-fragment | | reservations.staff-attribution | BunkFy.Modules.Reservations.Persistence | BunkFy.Modules.Reservations.Persistence.Repositories.ReservationProcessingRestrictionTenantExport | StaffAttribution | data-rights-export | reservations-tenant-termination-export-fragment | | reservations.staff-attribution | BunkFy.Modules.Reservations.Persistence | BunkFy.Modules.Reservations.Persistence.Repositories.ReservationRetentionAnonymisationReceiptTenantExport | StaffAttribution | data-rights-export | reservations-tenant-termination-export-fragment | +| reservations.staff-attribution | BunkFy.Modules.Reservations.Persistence | BunkFy.Modules.Reservations.Persistence.Repositories.ReservationStayAmendmentOperationTenantExport | StaffAttribution | data-rights-export | reservations-tenant-termination-export-fragment | | reservations.staff-attribution | BunkFy.Modules.Reservations.Persistence | BunkFy.Modules.Reservations.Persistence.Repositories.ReservationTenantExport | StaffAttribution | data-rights-export | reservations-tenant-termination-export-fragment | +| reservations.stay-amendment-operation | BunkFy.Modules.Reservations.Persistence | BunkFy.Modules.Reservations.Persistence.Repositories.ReservationStayAmendmentOperationTenantExport | StayAmendmentOperation | data-rights-export | reservations-tenant-termination-export-fragment | diff --git a/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Api/ReservationStayAmendmentSurfaceTests.cs b/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Api/ReservationStayAmendmentSurfaceTests.cs new file mode 100644 index 00000000..a099d063 --- /dev/null +++ b/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Api/ReservationStayAmendmentSurfaceTests.cs @@ -0,0 +1,330 @@ +namespace BunkFy.Modules.Reservations.Tests.Api; + +using System.CommandLine; +using System.Reflection; +using BunkFy.Modules.Reservations.Admin.Contracts; +using BunkFy.Modules.Reservations.AdminApi; +using BunkFy.Modules.Reservations.AdminCli; +using BunkFy.Modules.Reservations.Api; +using BunkFy.Modules.Reservations.Application; +using BunkFy.Modules.Reservations.Contracts; +using Gma.Framework.AccessControl.AspNetCore; +using Gma.Framework.Administration.Api; +using Gma.Framework.Administration.Cli; +using Gma.Framework.Api.Results; +using Gma.Framework.Cqrs; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Metadata; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +[Trait("Category", "Unit")] +public sealed class ReservationStayAmendmentSurfaceTests +{ + [Fact] + public void Http_write_contracts_keep_operation_identity_in_the_route() + { + AssertProperties( + "Arrival", + "Departure", + "ExpectedArrivalTime", + "ExpectedDepartureTime", + "ExpectedDetailsRevision", + "InventoryUnitIds"); + AssertProperties( + "ExpectedOperationVersion"); + AssertProperties( + "Arrival", + "Confirmed", + "Departure", + "ExpectedArrivalTime", + "ExpectedDepartureTime", + "ExpectedDetailsRevision", + "InventoryUnitIds"); + AssertProperties( + "Confirmed", + "ExpectedOperationVersion"); + + Assert.Null(typeof(ReservationsModule.AmendReservationStayRequest).GetProperty("OperationId")); + Assert.Null(typeof(ReservationsAdminApiModule.AmendReservationStayRequest).GetProperty("OperationId")); + Assert.Equal( + typeof(bool), + typeof(ReservationsAdminApiModule.AmendReservationStayRequest) + .GetProperty("Confirmed")?.PropertyType); + Assert.Equal( + typeof(bool), + typeof(ReservationsAdminApiModule.ReconcileReservationStayAmendmentRequest) + .GetProperty("Confirmed")?.PropertyType); + } + + [Fact] + public void Recovery_contract_is_privacy_minimal_and_has_a_total_keyset_cursor() + { + AssertProperties( + "OperationId", + "Outcome", + "ReservationId", + "UpdatedAtUtc"); + AssertProperties( + "NextRecoveryEligibleAtUtc", + "OperationId", + "OperationVersion", + "Outcome", + "PropertyId", + "RecoveryEligible", + "RequestedAtUtc", + "ReservationId", + "UpdatedAtUtc"); + AssertProperties("NextCursor", "Operations"); + Assert.DoesNotContain( + typeof(ReservationStayAmendmentRecoveryItemDto).GetProperties(), + property => property.Name.Contains("Guest", StringComparison.Ordinal) || + property.Name.Contains("Email", StringComparison.Ordinal) || + property.Name.Contains("Phone", StringComparison.Ordinal)); + } + + [Fact] + public async Task Public_and_admin_routes_publish_dedicated_bounded_contracts() + { + RouteEndpoint[] endpoints = await MapEndpointsAsync(); + const string publicBase = "/api/reservations/properties/{propertyId:guid}"; + const string adminBase = "/api/admin/reservations/properties/{propertyId:guid}"; + const string resource = "/{reservationId:guid}/stay-amendments/{operationId:guid}"; + + AssertPublicEndpoint( + endpoints, + HttpMethods.Put, + publicBase + resource, + ReservationsAdminPermissionCodes.Manage); + AssertPublicEndpoint( + endpoints, + HttpMethods.Get, + publicBase + resource, + ReservationsAdminPermissionCodes.Read); + AssertPublicEndpoint( + endpoints, + HttpMethods.Post, + publicBase + resource + "/reconcile", + ReservationsAdminPermissionCodes.Manage); + AssertPublicEndpoint( + endpoints, + HttpMethods.Get, + publicBase + "/stay-amendments/recovery", + ReservationsAdminPermissionCodes.Read); + + AssertResponse( + endpoints, + HttpMethods.Put, + adminBase + resource); + AssertResponse( + endpoints, + HttpMethods.Get, + adminBase + resource); + AssertResponse( + endpoints, + HttpMethods.Post, + adminBase + resource + "/reconcile"); + AssertResponse( + endpoints, + HttpMethods.Get, + adminBase + "/stay-amendments/recovery"); + + AssertPublicEndpoint( + endpoints, + HttpMethods.Put, + publicBase + "/{reservationId:guid}/inventory", + ReservationsAdminPermissionCodes.Manage); + AssertResponse( + endpoints, + HttpMethods.Put, + adminBase + "/{reservationId:guid}/inventory"); + } + + [Fact] + public void Admin_operations_have_stable_audit_names() + { + Assert.Equal("reservations.amend-stay", ReservationsAdminOperationNames.AmendStay); + Assert.Equal("reservations.get-stay-amendment", ReservationsAdminOperationNames.GetStayAmendment); + Assert.Equal( + "reservations.list-stay-amendment-recovery", + ReservationsAdminOperationNames.ListStayAmendmentRecovery); + Assert.Equal( + "reservations.reconcile-stay-amendment", + ReservationsAdminOperationNames.ReconcileStayAmendment); + } + + [Fact] + public void Stay_amendment_errors_have_actionable_http_statuses() + { + ApiErrorStatusCodeMap publicMap = ErrorMap(typeof(ReservationsModule)); + ApiErrorStatusCodeMap adminMap = ErrorMap(typeof(ReservationsAdminApiModule)); + + foreach (ApiErrorStatusCodeMap map in new[] { publicMap, adminMap }) + { + Assert.Equal( + StatusCodes.Status404NotFound, + map.GetStatusCode(ReservationsApplicationErrors.StayAmendmentOperationNotFound)); + Assert.Equal( + StatusCodes.Status409Conflict, + map.GetStatusCode(ReservationsApplicationErrors.StayAmendmentOperationConflict)); + Assert.Equal( + StatusCodes.Status409Conflict, + map.GetStatusCode(ReservationsApplicationErrors.StayAmendmentOperationVersionConflict)); + Assert.Equal( + StatusCodes.Status409Conflict, + map.GetStatusCode(ReservationsApplicationErrors.StayAmendmentReconcileInvalid)); + Assert.Equal( + StatusCodes.Status429TooManyRequests, + map.GetStatusCode(ReservationsApplicationErrors.StayAmendmentReconcileTooSoon)); + Assert.Equal( + StatusCodes.Status400BadRequest, + map.GetStatusCode(ReservationsApplicationErrors.StayAmendmentRequestInvalid)); + } + } + + [Fact] + public void Admin_cli_groups_amend_status_recovery_and_reconcile_with_mutation_confirmation() + { + ServiceCollection services = new(); + services.AddSingleton(); + using ServiceProvider provider = services.BuildServiceProvider(); + AdminCliGlobalOptions options = provider.GetRequiredService(); + RootCommand root = new("admin") + { + options.ActorOption, + options.TenantOption, + options.OutputOption + }; + AdminCliCommandRegistry registry = new(root, provider); + new ReservationsAdminCliModule().MapCommands(registry); + + Command reservations = Assert.Single(root.Subcommands, command => command.Name == "reservations"); + Command amendments = Assert.Single( + reservations.Subcommands, + command => command.Name == "stay-amendments"); + Command amend = Assert.Single(amendments.Subcommands, command => command.Name == "amend"); + Command status = Assert.Single(amendments.Subcommands, command => command.Name == "status"); + Command list = Assert.Single(amendments.Subcommands, command => command.Name == "list-recovery"); + Command reconcile = Assert.Single( + amendments.Subcommands, + command => command.Name == "reconcile"); + + Assert.True(HasOption(amend, "yes")); + Assert.True(HasOption(reconcile, "yes")); + Assert.False(HasOption(status, "yes")); + Assert.False(HasOption(list, "yes")); + Assert.True(HasOption(list, "cursor-reservation-id")); + Assert.True(HasOption(list, "page-size")); + + const string propertyId = "71000000-0000-0000-0000-000000000001"; + const string reservationId = "72000000-0000-0000-0000-000000000001"; + const string operationId = "73000000-0000-0000-0000-000000000001"; + const string unitId = "74000000-0000-0000-0000-000000000001"; + Assert.Empty(root.Parse([ + "reservations", "stay-amendments", "amend", + "--property-id", propertyId, + "--reservation-id", reservationId, + "--operation-id", operationId, + "--arrival", "2026-10-01", + "--departure", "2026-10-03", + "--unit-ids", unitId, + "--expected-details-revision", "4", + "--yes" + ]).Errors); + Assert.Empty(root.Parse([ + "reservations", "stay-amendments", "status", + "--property-id", propertyId, + "--reservation-id", reservationId, + "--operation-id", operationId + ]).Errors); + Assert.Empty(root.Parse([ + "reservations", "stay-amendments", "list-recovery", + "--property-id", propertyId, + "--page-size", "25" + ]).Errors); + Assert.Empty(root.Parse([ + "reservations", "stay-amendments", "reconcile", + "--property-id", propertyId, + "--reservation-id", reservationId, + "--operation-id", operationId, + "--expected-operation-version", "2", + "--yes" + ]).Errors); + } + + private static async Task MapEndpointsAsync() + { + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + builder.Services.AddOptions(); + builder.Services.AddSingleton(_ => null!); + builder.Services.AddSingleton(_ => null!); + builder.Services.AddSingleton(_ => null!); + await using WebApplication app = builder.Build(); + + new ReservationsModule().MapEndpoints(app); + new ReservationsAdminApiModule().MapEndpoints(app); + + return [ + .. ((IEndpointRouteBuilder)app).DataSources + .SelectMany(source => source.Endpoints) + .OfType() + ]; + } + + private static void AssertPublicEndpoint( + IReadOnlyCollection endpoints, + string method, + string route, + string permissionCode) + { + RouteEndpoint endpoint = AssertResponse(endpoints, method, route); + AccessPermissionMetadata permission = + Assert.Single(endpoint.Metadata.OfType()); + Assert.Equal(permissionCode, permission.Permission.Value); + Assert.Equal("reservations-property", permission.ScopeResolverName); + } + + private static RouteEndpoint AssertResponse( + IReadOnlyCollection endpoints, + string method, + string route) + { + RouteEndpoint endpoint = Assert.Single(endpoints, candidate => + string.Equals( + candidate.RoutePattern.RawText?.Trim('/'), + route.Trim('/'), + StringComparison.Ordinal) && + candidate.Metadata.GetMetadata()?.HttpMethods.Contains( + method, + StringComparer.Ordinal) == true); + IProducesResponseTypeMetadata response = Assert.Single( + endpoint.Metadata.OfType(), + metadata => metadata.StatusCode == StatusCodes.Status200OK); + Assert.Equal(typeof(TResponse), response.Type); + return endpoint; + } + + private static ApiErrorStatusCodeMap ErrorMap(Type moduleType) => + Assert.IsType(moduleType.GetField( + "ErrorStatusCodes", + BindingFlags.NonPublic | BindingFlags.Static)!.GetValue(null)); + + private static bool HasOption(Command command, string name) => + command.Options.Any(option => + string.Equals(option.Name.TrimStart('-'), name, StringComparison.Ordinal) || + option.Aliases.Any(alias => + string.Equals(alias.TrimStart('-'), name, StringComparison.Ordinal))); + + private static void AssertProperties(params string[] expected) + { + string[] actual = typeof(T) + .GetProperties() + .Select(property => property.Name) + .Order(StringComparer.Ordinal) + .ToArray(); + + Assert.Equal(expected, actual); + } +} diff --git a/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Application/AmendReservationStayCommandHandlerTests.cs b/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Application/AmendReservationStayCommandHandlerTests.cs new file mode 100644 index 00000000..ef981893 --- /dev/null +++ b/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Application/AmendReservationStayCommandHandlerTests.cs @@ -0,0 +1,270 @@ +namespace BunkFy.Modules.Reservations.Tests; + +using BunkFy.Modules.Reservations.Application; +using BunkFy.Modules.Reservations.Application.Commands; +using BunkFy.Modules.Reservations.Application.Ports; +using BunkFy.Modules.Reservations.Contracts; +using BunkFy.Modules.Reservations.Domain.Aggregates; +using BunkFy.Modules.Reservations.Domain.Events; +using BunkFy.Modules.Reservations.Domain.StayAmendments; +using Gma.Framework.Results; +using Xunit; + +[Trait("Category", "Unit")] +public sealed class AmendReservationStayCommandHandlerTests +{ + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Date_only_and_unit_only_changes_use_the_same_pending_protocol( + bool dateOnly) + { + Guid currentUnitId = Guid.NewGuid(); + Guid targetUnitId = dateOnly ? currentUnitId : Guid.NewGuid(); + ReservationStayAmendmentApplicationTestContext context = new([currentUnitId]); + AmendReservationStayCommand command = Command( + context.Reservation, + Guid.NewGuid(), + dateOnly ? context.Reservation.Arrival.AddDays(1) : context.Reservation.Arrival, + dateOnly ? context.Reservation.Departure.AddDays(1) : context.Reservation.Departure, + context.Reservation.ExpectedArrivalTime, + context.Reservation.ExpectedDepartureTime, + [targetUnitId]); + + Result result = + await context.AmendHandler.HandleAsync(command, CancellationToken.None); + + Assert.True(result.IsSuccess, result.Error.Code); + Assert.Equal(ReservationStayAmendmentOutcome.Pending, result.Value.Outcome); + Assert.Equal(command.Arrival, context.Reservation.PendingArrival); + Assert.Equal(command.Departure, context.Reservation.PendingDeparture); + Assert.Equal( + [targetUnitId], + context.Reservation.PendingInventoryUnitIds! + .Split(',') + .Select(value => Guid.ParseExact(value, "N"))); + } + + [Fact] + public async Task Invalid_target_range_is_rejected_without_reserving_an_operation() + { + Guid currentUnitId = Guid.NewGuid(); + ReservationStayAmendmentApplicationTestContext context = new([currentUnitId]); + AmendReservationStayCommand command = Command( + context.Reservation, + Guid.NewGuid(), + context.Reservation.Departure, + context.Reservation.Arrival, + expectedArrivalTime: null, + expectedDepartureTime: null, + [currentUnitId]); + + Result result = + await context.AmendHandler.HandleAsync(command, CancellationToken.None); + + Assert.Equal(ReservationsApplicationErrors.StayRangeInvalid, result.Error); + Assert.Empty(context.ManagementOperations.Items); + Assert.Empty(context.StayOperations.Items); + Assert.Null(context.Reservation.PendingAllocationAmendmentId); + } + + [Fact] + public async Task Combined_change_records_pending_target_while_current_stay_remains_authoritative() + { + Guid currentUnitId = Guid.NewGuid(); + Guid targetUnitId = Guid.NewGuid(); + ReservationStayAmendmentApplicationTestContext context = new([currentUnitId]); + AmendReservationStayCommand command = Command( + context.Reservation, + Guid.NewGuid(), + context.Reservation.Arrival.AddDays(1), + context.Reservation.Departure.AddDays(2), + new TimeOnly(16, 30), + new TimeOnly(9, 15), + [targetUnitId]); + + Result result = await context.AmendHandler.HandleAsync( + command, + CancellationToken.None); + + Assert.True(result.IsSuccess, result.Error.Code); + Assert.Equal(ReservationStayAmendmentOutcome.Pending, result.Value.Outcome); + Assert.Equal(command.Arrival, result.Value.Target!.Arrival); + Assert.Equal(command.Departure, result.Value.Target.Departure); + Assert.Equal(command.ExpectedArrivalTime, result.Value.Target.ExpectedArrivalTime); + Assert.Equal(command.ExpectedDepartureTime, result.Value.Target.ExpectedDepartureTime); + Assert.Equal([targetUnitId], result.Value.Target.InventoryUnitIds); + Assert.Equal(ReservationStayAmendmentApplicationTestContext.Arrival, context.Reservation.Arrival); + Assert.Equal(ReservationStayAmendmentApplicationTestContext.Departure, context.Reservation.Departure); + Assert.Null(context.Reservation.ExpectedArrivalTime); + Assert.Null(context.Reservation.ExpectedDepartureTime); + Assert.Equal([currentUnitId], context.Reservation.RequestedUnits.Select(item => item.InventoryUnitId)); + Assert.Equal(command.Arrival, context.Reservation.PendingArrival); + Assert.Equal(command.ExpectedArrivalTime, context.Reservation.PendingExpectedArrivalTime); + ReservationAllocationAmendmentRequestedDomainEvent requested = + Assert.IsType( + Assert.Single(context.Reservation.DomainEvents)); + ReservationManagementOperationRecord journal = Assert.Single(context.ManagementOperations.Items); + Assert.Equal(ReservationManagementOperationKind.StayAmendment, journal.Kind); + ReservationStayAmendmentOperation operation = Assert.Single(context.StayOperations.Items); + Assert.Equal(ReservationStayAmendmentOperationOutcome.Pending, operation.Outcome); + Assert.NotEqual(command.OperationId, operation.InventoryRequestId); + Assert.Equal(operation.InventoryRequestId, context.Reservation.PendingInventoryAmendmentRequestId); + Assert.Equal(operation.InventoryRequestId, requested.AmendmentRequestId); + } + + [Fact] + public async Task Already_current_desired_state_is_durably_applied_without_inventory_request() + { + Guid currentUnitId = Guid.NewGuid(); + TimeOnly arrivalTime = new(15, 0); + TimeOnly departureTime = new(10, 0); + ReservationStayAmendmentApplicationTestContext context = new( + [currentUnitId], + arrivalTime, + departureTime); + AmendReservationStayCommand command = Command( + context.Reservation, + Guid.NewGuid(), + context.Reservation.Arrival, + context.Reservation.Departure, + arrivalTime, + departureTime, + [currentUnitId]); + long selectedReservationVersion = context.Reservation.Version; + + Result first = await context.AmendHandler.HandleAsync( + command, + CancellationToken.None); + Result replay = await context.AmendHandler.HandleAsync( + command, + CancellationToken.None); + + Assert.True(first.IsSuccess, first.Error.Code); + Assert.Equal(ReservationStayAmendmentOutcome.Applied, first.Value.Outcome); + Assert.Equal(context.Reservation.DetailsRevision, first.Value.ResultingDetailsRevision); + Assert.Equal(context.Reservation.Version, first.Value.ResultingReservationVersion); + Assert.Equal(selectedReservationVersion + 1, context.Reservation.Version); + Assert.True(replay.IsSuccess, replay.Error.Code); + Assert.Equal(first.Value with { Target = null }, replay.Value with { Target = null }); + Guid[] ignoredUnits = []; + Assert.Equal( + first.Value.Target! with { InventoryUnitIds = ignoredUnits }, + replay.Value.Target! with { InventoryUnitIds = ignoredUnits }); + Assert.Equal( + first.Value.Target.InventoryUnitIds, + replay.Value.Target.InventoryUnitIds); + Assert.Single(context.ManagementOperations.Items); + Assert.Single(context.StayOperations.Items); + Assert.Empty(context.Reservation.DomainEvents); + Assert.Equal(1, context.Inventory.ValidationCount); + } + + [Fact] + public async Task Exact_terminal_retry_returns_stored_rejection_receipt() + { + Guid targetUnitId = Guid.NewGuid(); + ReservationStayAmendmentApplicationTestContext context = new([Guid.NewGuid()]); + AmendReservationStayCommand command = Command( + context.Reservation, + Guid.NewGuid(), + context.Reservation.Arrival, + context.Reservation.Departure, + expectedArrivalTime: null, + expectedDepartureTime: null, + [targetUnitId]); + Assert.True((await context.AmendHandler.HandleAsync(command, CancellationToken.None)).IsSuccess); + context.Reservation.ClearDomainEvents(); + Guid inventoryRequestId = Assert.Single(context.StayOperations.Items).InventoryRequestId!.Value; + Assert.True(context.Reservation.RejectAllocationAmendment( + inventoryRequestId, + context.Reservation.AllocationId!.Value, + rejectionCode: 2, + context.Clock.UtcNow.AddMinutes(1)).IsSuccess); + ReservationStayAmendmentOperation operation = Assert.Single(context.StayOperations.Items); + Assert.True(operation.MarkRejected( + rejectionCode: 2, + context.Reservation.DetailsRevision, + context.Reservation.Version, + context.Clock.UtcNow.AddMinutes(1)).IsSuccess); + + Result replay = await context.AmendHandler.HandleAsync( + command, + CancellationToken.None); + + Assert.True(replay.IsSuccess, replay.Error.Code); + Assert.Equal(ReservationStayAmendmentOutcome.Rejected, replay.Value.Outcome); + Assert.Equal(2, (int?)replay.Value.RejectionReason); + Assert.Equal(operation.OperationVersion, replay.Value.OperationVersion); + Assert.Equal(1, context.Inventory.ValidationCount); + Assert.Empty(context.Reservation.DomainEvents); + } + + [Fact] + public async Task Reusing_operation_with_changed_target_conflicts_before_inventory_validation() + { + ReservationStayAmendmentApplicationTestContext context = new([Guid.NewGuid()]); + AmendReservationStayCommand command = Command( + context.Reservation, + Guid.NewGuid(), + context.Reservation.Arrival.AddDays(1), + context.Reservation.Departure.AddDays(1), + expectedArrivalTime: null, + expectedDepartureTime: null, + [Guid.NewGuid()]); + Assert.True((await context.AmendHandler.HandleAsync(command, CancellationToken.None)).IsSuccess); + + Result changed = await context.AmendHandler.HandleAsync( + command with { Departure = command.Departure.AddDays(1) }, + CancellationToken.None); + + Assert.Equal(ReservationsApplicationErrors.StayAmendmentOperationConflict, changed.Error); + Assert.Equal(1, context.Inventory.ValidationCount); + Assert.Single(context.ManagementOperations.Items); + Assert.Single(context.StayOperations.Items); + } + + [Fact] + public async Task Stale_details_revision_and_pending_competitor_fail_closed() + { + ReservationStayAmendmentApplicationTestContext context = new([Guid.NewGuid()]); + AmendReservationStayCommand first = Command( + context.Reservation, + Guid.NewGuid(), + context.Reservation.Arrival.AddDays(1), + context.Reservation.Departure.AddDays(1), + expectedArrivalTime: null, + expectedDepartureTime: null, + [Guid.NewGuid()]); + Result stale = await context.AmendHandler.HandleAsync( + first with { ExpectedDetailsRevision = first.ExpectedDetailsRevision + 1 }, + CancellationToken.None); + Assert.Equal(ReservationsApplicationErrors.DetailsRevisionConflict, stale.Error); + + Assert.True((await context.AmendHandler.HandleAsync(first, CancellationToken.None)).IsSuccess); + Result competitor = await context.AmendHandler.HandleAsync( + first with { OperationId = Guid.NewGuid() }, + CancellationToken.None); + + Assert.Equal(ReservationsApplicationErrors.AllocationAmendmentInProgress, competitor.Error); + } + + private static AmendReservationStayCommand Command( + Reservation reservation, + Guid operationId, + DateOnly arrival, + DateOnly departure, + TimeOnly? expectedArrivalTime, + TimeOnly? expectedDepartureTime, + IReadOnlyCollection units) => new( + operationId, + reservation.PropertyId, + reservation.Id, + arrival, + departure, + expectedArrivalTime, + expectedDepartureTime, + units, + reservation.DetailsRevision, + "user:operator-a"); +} diff --git a/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Application/ReassignReservationInventoryCommandHandlerTests.cs b/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Application/ReassignReservationInventoryCommandHandlerTests.cs index c6096dff..bfd98202 100644 --- a/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Application/ReassignReservationInventoryCommandHandlerTests.cs +++ b/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Application/ReassignReservationInventoryCommandHandlerTests.cs @@ -4,9 +4,11 @@ namespace BunkFy.Modules.Reservations.Tests; using BunkFy.Modules.Reservations.Application.Commands; using BunkFy.Modules.Reservations.Application.Handlers; using BunkFy.Modules.Reservations.Application.Ports; +using BunkFy.Modules.Reservations.Application.StayAmendments; using BunkFy.Modules.Reservations.Contracts; using BunkFy.Modules.Reservations.Domain.Aggregates; using BunkFy.Modules.Reservations.Domain.Events; +using BunkFy.Modules.Reservations.Domain.StayAmendments; using Gma.Framework.Pagination; using Gma.Framework.Results; using Gma.Framework.Runtime.Identity; @@ -88,13 +90,13 @@ public async Task Exact_pending_retry_does_not_revalidate_inventory_or_publish_a Assert.True(replay.IsSuccess, replay.Error.Code); Assert.Equal(first.Value.Version, replay.Value.Version); Assert.Equal(1, projection.ValidationCount); - Assert.Equal(1, ids.Count); + Assert.Equal(2, ids.Count); Assert.Single(operations.Items); Assert.Empty(reservation.DomainEvents); } [Fact] - public async Task Legacy_pending_staff_retry_backfills_journal_without_revalidating_or_republishing() + public async Task Legacy_pending_staff_retry_without_durable_evidence_fails_closed() { Guid targetUnitId = Guid.NewGuid(); Reservation reservation = CreateConfirmedReservation(Guid.NewGuid()); @@ -104,6 +106,7 @@ public async Task Legacy_pending_staff_retry_backfills_journal_without_revalidat targetUnitId); Assert.True(reservation.BeginAllocationAmendment( command.AmendmentRequestId, + Guid.NewGuid(), Fingerprint(command), reservation.Arrival, reservation.Departure, @@ -118,9 +121,9 @@ public async Task Legacy_pending_staff_retry_backfills_journal_without_revalidat command.ActorId, adapterConnectionId: null, externalOperationId: null, - command.AmendmentRequestId, - Guid.NewGuid(), - Now).IsSuccess); + correlationId: command.AmendmentRequestId, + eventId: Guid.NewGuid(), + nowUtc: Now).IsSuccess); reservation.ClearDomainEvents(); FakeInventoryProjectionRepository projection = new(); FakeReservationManagementOperationRepository operations = new(); @@ -135,8 +138,10 @@ public async Task Legacy_pending_staff_retry_backfills_journal_without_revalidat command, CancellationToken.None); - Assert.True(replay.IsSuccess, replay.Error.Code); - Assert.Single(operations.Items); + Assert.Equal( + ReservationsApplicationErrors.ManagementOperationConflict, + replay.Error); + Assert.Empty(operations.Items); Assert.Equal(0, projection.ValidationCount); Assert.Equal(0, ids.Count); Assert.Empty(reservation.DomainEvents); @@ -152,6 +157,7 @@ public async Task Pending_adapter_amendment_is_never_backfilled_into_management_ Guid.NewGuid()); Assert.True(reservation.BeginAllocationAmendment( command.AmendmentRequestId, + Guid.NewGuid(), Fingerprint(command), reservation.Arrival, reservation.Departure, @@ -208,8 +214,9 @@ public async Task Exact_confirmed_retry_returns_current_receipt_without_new_requ Guid.NewGuid(), targetUnitId); Assert.True((await handler.HandleAsync(command, CancellationToken.None)).IsSuccess); + Guid inventoryRequestId = reservation.PendingInventoryAmendmentRequestId!.Value; Assert.True(reservation.CompleteAllocationAmendment( - command.AmendmentRequestId, + inventoryRequestId, reservation.AllocationId!.Value, reservation.Arrival, reservation.Departure, @@ -227,7 +234,7 @@ public async Task Exact_confirmed_retry_returns_current_receipt_without_new_requ Assert.Equal(2, replay.Value.DetailsRevision); Assert.Equal([targetUnitId], reservation.RequestedUnits.Select(unit => unit.InventoryUnitId)); Assert.Equal(1, projection.ValidationCount); - Assert.Equal(1, ids.Count); + Assert.Equal(2, ids.Count); Assert.Empty(reservation.DomainEvents); } @@ -249,8 +256,9 @@ public async Task Exact_rejected_retry_does_not_restart_amendment() Guid.NewGuid(), Guid.NewGuid()); Assert.True((await handler.HandleAsync(command, CancellationToken.None)).IsSuccess); + Guid inventoryRequestId = reservation.PendingInventoryAmendmentRequestId!.Value; Assert.True(reservation.RejectAllocationAmendment( - command.AmendmentRequestId, + inventoryRequestId, reservation.AllocationId!.Value, rejectionCode: 2, Now.AddMinutes(1)).IsSuccess); @@ -263,7 +271,7 @@ public async Task Exact_rejected_retry_does_not_restart_amendment() Assert.Null(reservation.PendingAllocationAmendmentId); Assert.Equal(2, reservation.LastAllocationAmendmentRejectionCode); Assert.Equal(1, projection.ValidationCount); - Assert.Equal(1, ids.Count); + Assert.Equal(2, ids.Count); Assert.Single(operations.Items); } @@ -311,15 +319,18 @@ public async Task Unchanged_request_does_not_bind_amendment_request_id() projection, operations); Guid operationId = Guid.NewGuid(); + long originalVersion = reservation.Version; Result unchanged = await handler.HandleAsync( Command(reservation, operationId, currentUnitId), CancellationToken.None); + long versionAfterNoOp = reservation.Version; Result changed = await handler.HandleAsync( Command(reservation, operationId, Guid.NewGuid()), CancellationToken.None); Assert.True(unchanged.IsSuccess, unchanged.Error.Code); + Assert.Equal(originalVersion, versionAfterNoOp); Assert.True(changed.IsSuccess, changed.Error.Code); Assert.Single(operations.Items); Assert.NotNull(reservation.PendingAllocationAmendmentId); @@ -349,15 +360,28 @@ public async Task Replay_reads_journal_after_reservation_lock_and_reload() BusinessDate: null, Now, fingerprint)); - ReassignReservationInventoryCommandHandler handler = new( + FakeReservationStayAmendmentOperationRepository stayOperations = new(); + stayOperations.Items.Add( + ReservationStayAmendmentOperation.CreateOutcomeUnknown( + operationId, + reservation.ScopeId, + reservation.PropertyId, + reservation.Id, + ReservationStayAmendmentOperation.LegacyRequestSchemaVersion, + fingerprint, + reservation.DetailsRevision, + Now).Value); + ReservationStayAmendmentCoordinator coordinator = new( ReservationMutationTestSupport.Create( new FakeReservationRepository(reservation, trace), new RecordingReservationOperationLock(trace), new TestScopeContext()), new FakeInventoryProjectionRepository(), operations, + stayOperations, new TestClock(), new TestIdGenerator()); + ReassignReservationInventoryCommandHandler handler = new(coordinator); Result replay = await handler.HandleAsync( command, @@ -371,12 +395,17 @@ private static ReassignReservationInventoryCommandHandler CreateHandler( Reservation reservation, IInventoryProjectionRepository projection, IReservationManagementOperationRepository operations, - IIdGenerator? ids = null) => new( + IIdGenerator? ids = null) + { + ReservationStayAmendmentCoordinator coordinator = new( ReservationMutationTestSupport.Create(new FakeReservationRepository(reservation)), projection, operations, + new FakeReservationStayAmendmentOperationRepository(), new TestClock(), ids ?? new TestIdGenerator()); + return new ReassignReservationInventoryCommandHandler(coordinator); + } private static ReassignReservationInventoryCommand Command( Reservation reservation, @@ -563,6 +592,53 @@ public Task AddAsync( } } + private sealed class FakeReservationStayAmendmentOperationRepository + : IReservationStayAmendmentOperationRepository + { + public List Items { get; } = []; + + public Task GetAsync( + Guid propertyId, + Guid reservationId, + Guid operationId, + CancellationToken cancellationToken) => Task.FromResult( + this.Items.SingleOrDefault(item => + item.PropertyId == propertyId && + item.ReservationId == reservationId && + item.Id == operationId)); + + public Task GetVisibleAsync( + Guid propertyId, + Guid reservationId, + Guid operationId, + CancellationToken cancellationToken) => + this.GetAsync( + propertyId, + reservationId, + operationId, + cancellationToken); + + public Task GetByInventoryRequestIdAsync( + Guid inventoryRequestId, + CancellationToken cancellationToken) => Task.FromResult( + this.Items.SingleOrDefault(item => + item.InventoryRequestId == inventoryRequestId)); + + public Task AddAsync( + ReservationStayAmendmentOperation operation, + CancellationToken cancellationToken) + { + this.Items.Add(operation); + return Task.CompletedTask; + } + + public Task ListRecoveryAsync( + Guid propertyId, + ReservationStayAmendmentRecoveryCursorRecord? cursor, + int pageSize, + CancellationToken cancellationToken) => throw new NotSupportedException(); + } + private sealed class RecordingReservationOperationLock(List trace) : IReservationOperationLock { diff --git a/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Application/ReservationMutationCoordinatorTests.cs b/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Application/ReservationMutationCoordinatorTests.cs index 6c5ccdab..ad226bf9 100644 --- a/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Application/ReservationMutationCoordinatorTests.cs +++ b/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Application/ReservationMutationCoordinatorTests.cs @@ -3,6 +3,7 @@ namespace BunkFy.Modules.Reservations.Tests; using System.Reflection; using BunkFy.Modules.Reservations.Application.Handlers; using BunkFy.Modules.Reservations.Application.Ports; +using BunkFy.Modules.Reservations.Application.StayAmendments; using BunkFy.Modules.Reservations.Contracts; using BunkFy.Modules.Reservations.Domain.Aggregates; using Gma.Framework.Pagination; @@ -109,7 +110,10 @@ public void Existing_reservation_writers_require_mutation_coordinator( typeof(ReservationMutationCoordinator)) || HasConstructorDependency( handlerType, - typeof(ReservationManagementLifecycleCoordinator)); + typeof(ReservationManagementLifecycleCoordinator)) || + HasConstructorDependency( + handlerType, + typeof(ReservationStayAmendmentCoordinator)); Assert.True( hasCoordinator, @@ -125,6 +129,14 @@ public void Management_lifecycle_coordinator_owns_the_mutation_boundary() typeof(ReservationMutationCoordinator))); } + [Fact] + public void Stay_amendment_coordinator_owns_the_mutation_boundary() + { + Assert.True(HasConstructorDependency( + typeof(ReservationStayAmendmentCoordinator), + typeof(ReservationMutationCoordinator))); + } + [Fact] public void Restore_keeps_an_explicit_coordinate_lock() { diff --git a/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Application/ReservationStayAmendmentApplicationTestContext.cs b/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Application/ReservationStayAmendmentApplicationTestContext.cs new file mode 100644 index 00000000..ffc4c4a7 --- /dev/null +++ b/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Application/ReservationStayAmendmentApplicationTestContext.cs @@ -0,0 +1,283 @@ +namespace BunkFy.Modules.Reservations.Tests; + +using BunkFy.Modules.Reservations.Application.Handlers; +using BunkFy.Modules.Reservations.Application.Ports; +using BunkFy.Modules.Reservations.Application.StayAmendments; +using BunkFy.Modules.Reservations.Contracts; +using BunkFy.Modules.Reservations.Domain.Aggregates; +using BunkFy.Modules.Reservations.Domain.StayAmendments; +using Gma.Framework.Pagination; +using Gma.Framework.Runtime.Identity; +using Gma.Framework.Runtime.Time; + +internal sealed class ReservationStayAmendmentApplicationTestContext +{ + public ReservationStayAmendmentApplicationTestContext( + IReadOnlyCollection currentUnitIds, + TimeOnly? expectedArrivalTime = null, + TimeOnly? expectedDepartureTime = null) + { + this.Reservation = CreateConfirmedReservation( + currentUnitIds, + expectedArrivalTime, + expectedDepartureTime); + this.Reservation.ClearDomainEvents(); + this.Reservations = new FakeReservationRepository(this.Reservation); + this.Mutations = ReservationMutationTestSupport.Create(this.Reservations); + this.Coordinator = new ReservationStayAmendmentCoordinator( + this.Mutations, + this.Inventory, + this.ManagementOperations, + this.StayOperations, + this.Clock, + this.Ids); + } + + public Reservation Reservation { get; } + public FakeReservationRepository Reservations { get; } + public FakeInventoryProjectionRepository Inventory { get; } = new(); + public FakeManagementOperationRepository ManagementOperations { get; } = new(); + public FakeStayOperationRepository StayOperations { get; } = new(); + public MutableClock Clock { get; } = new(); + public CountingIdGenerator Ids { get; } = new(); + public ReservationMutationCoordinator Mutations { get; } + public ReservationStayAmendmentCoordinator Coordinator { get; } + public AmendReservationStayCommandHandler AmendHandler => new(this.Coordinator); + + public sealed class FakeReservationRepository(Reservation reservation) + : IReservationRepository + { + public Task AddAsync(Reservation value, CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public Task GetAsync( + Guid propertyId, + Guid reservationId, + CancellationToken cancellationToken) => Task.FromResult( + reservation.PropertyId == propertyId && reservation.Id == reservationId + ? reservation + : null); + + public Task GetForDataRightsAsync( + Guid propertyId, + Guid reservationId, + CancellationToken cancellationToken) => + this.GetAsync(propertyId, reservationId, cancellationToken); + + public Task GetAsyncByReservationId( + Guid reservationId, + CancellationToken cancellationToken) => Task.FromResult( + reservation.Id == reservationId ? reservation : null); + + public Task GetByExternalSourceAsync( + string sourceSystem, + string sourceReference, + CancellationToken cancellationToken) => throw new NotSupportedException(); + + public Task ExternalSourceExistsAsync( + string sourceSystem, + string sourceReference, + CancellationToken cancellationToken) => throw new NotSupportedException(); + + public Task ListAsync( + Guid propertyId, + IReadOnlyCollection? statuses, + string? search, + ReservationListOrder order, + PageRequest pageRequest, + CancellationToken cancellationToken) => throw new NotSupportedException(); + } + + public sealed class FakeInventoryProjectionRepository : IInventoryProjectionRepository + { + public InventoryUnitSelectionValidation Selection { get; set; } = + InventoryUnitSelectionValidation.Valid; + public int ValidationCount { get; private set; } + public int AllocationApplyCount { get; private set; } + public ReservationInventoryAllocationWriteModel? LastAppliedAllocation { get; private set; } + + public Task ValidateSelectionAsync( + Guid propertyId, + IReadOnlyCollection inventoryUnitIds, + CancellationToken cancellationToken) + { + this.ValidationCount++; + return Task.FromResult(this.Selection); + } + + public Task ApplyUnitAsync( + ReservationInventoryUnitWriteModel unit, + CancellationToken cancellationToken) => throw new NotSupportedException(); + + public Task ApplyBlockAsync( + ReservationInventoryBlockWriteModel block, + CancellationToken cancellationToken) => throw new NotSupportedException(); + + public Task ReleaseBlockAsync( + string scopeId, + Guid propertyId, + Guid inventoryUnitId, + Guid blockId, + long version, + CancellationToken cancellationToken) => throw new NotSupportedException(); + + public Task ApplyAllocationAsync( + ReservationInventoryAllocationWriteModel allocation, + CancellationToken cancellationToken) + { + this.AllocationApplyCount++; + this.LastAppliedAllocation = allocation; + return Task.CompletedTask; + } + + public Task ReleaseAllocationAsync( + string scopeId, + Guid allocationId, + Guid reservationId, + long version, + CancellationToken cancellationToken) => throw new NotSupportedException(); + } + + public sealed class FakeManagementOperationRepository + : IReservationManagementOperationRepository + { + public List Items { get; } = []; + + public Task GetAsync( + Guid reservationId, + Guid operationId, + CancellationToken cancellationToken) => Task.FromResult( + this.Items.SingleOrDefault(item => + item.ReservationId == reservationId && item.OperationId == operationId)); + + public Task AddAsync( + ReservationManagementOperationRecord operation, + CancellationToken cancellationToken) + { + this.Items.Add(operation); + return Task.CompletedTask; + } + } + + public sealed class FakeStayOperationRepository + : IReservationStayAmendmentOperationRepository + { + public List Items { get; } = []; + + public Task GetAsync( + Guid propertyId, + Guid reservationId, + Guid operationId, + CancellationToken cancellationToken) => Task.FromResult( + this.Items.SingleOrDefault(item => + item.PropertyId == propertyId && + item.ReservationId == reservationId && + item.Id == operationId)); + + public Task GetVisibleAsync( + Guid propertyId, + Guid reservationId, + Guid operationId, + CancellationToken cancellationToken) => + this.GetAsync( + propertyId, + reservationId, + operationId, + cancellationToken); + + public Task GetByInventoryRequestIdAsync( + Guid inventoryRequestId, + CancellationToken cancellationToken) => Task.FromResult( + this.Items.SingleOrDefault(item => + item.InventoryRequestId == inventoryRequestId)); + + public Task AddAsync( + ReservationStayAmendmentOperation operation, + CancellationToken cancellationToken) + { + this.Items.Add(operation); + return Task.CompletedTask; + } + + public Task ListRecoveryAsync( + Guid propertyId, + ReservationStayAmendmentRecoveryCursorRecord? cursor, + int pageSize, + CancellationToken cancellationToken) => Task.FromResult(new ReservationStayAmendmentRecoveryPageRecord( + this.Items.Where(item => + item.PropertyId == propertyId && + item.Outcome is ReservationStayAmendmentOperationOutcome.Pending or + ReservationStayAmendmentOperationOutcome.OutcomeUnknown) + .Take(pageSize) + .ToArray(), + NextCursor: null)); + } + + public sealed class MutableClock : ISystemClock + { + public DateTimeOffset UtcNow { get; set; } = Now; + } + + public sealed class CountingIdGenerator : IIdGenerator + { + public int Count { get; private set; } + + public Guid NewId() + { + this.Count++; + return Guid.NewGuid(); + } + } + + private static Reservation CreateConfirmedReservation( + IReadOnlyCollection unitIds, + TimeOnly? expectedArrivalTime, + TimeOnly? expectedDepartureTime) + { + Reservation reservation = Reservation.Create( + Guid.NewGuid(), + "tenant-a", + Guid.NewGuid(), + Guid.NewGuid(), + Arrival, + Departure, + unitIds, + "Ada Guest", + "ada@example.test", + null, + 1, + ReservationSource.Direct, + sourceSystem: null, + sourceReference: null, + notes: null, + Guid.NewGuid(), + Guid.NewGuid(), + ReservationDetailsChangeOrigin.Staff, + initialDetailsActorId: null, + initialAdapterConnectionId: null, + initialExternalOperationId: null, + Guid.NewGuid(), + Now, + expectedArrivalTime, + expectedDepartureTime).Value; + AssertSuccess(reservation.ConfirmAllocation( + reservation.AllocationRequestId, + Guid.NewGuid(), + allocationVersion: 1, + Guid.NewGuid(), + Now)); + return reservation; + } + + private static void AssertSuccess(Gma.Framework.Results.Result result) + { + if (result.IsFailure) + { + throw new InvalidOperationException(result.Error.Code); + } + } + + public static readonly DateOnly Arrival = new(2026, 8, 20); + public static readonly DateOnly Departure = new(2026, 8, 23); + public static readonly DateTimeOffset Now = new(2026, 8, 12, 12, 0, 0, TimeSpan.Zero); +} diff --git a/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Application/ReservationStayAmendmentFingerprintTests.cs b/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Application/ReservationStayAmendmentFingerprintTests.cs new file mode 100644 index 00000000..cddf019c --- /dev/null +++ b/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Application/ReservationStayAmendmentFingerprintTests.cs @@ -0,0 +1,113 @@ +namespace BunkFy.Modules.Reservations.Tests; + +using System.Globalization; +using BunkFy.Modules.Reservations.Application.Commands; +using BunkFy.Modules.Reservations.Application.StayAmendments; +using Xunit; + +[Trait("Category", "Unit")] +public sealed class ReservationStayAmendmentFingerprintTests +{ + [Fact] + public void V2_is_order_independent_for_units() + { + Guid firstUnitId = Guid.NewGuid(); + Guid secondUnitId = Guid.NewGuid(); + AmendReservationStayCommand command = Command([firstUnitId, secondUnitId]); + + string forward = ReservationStayAmendmentFingerprint.ComputeV2(command); + string reverse = ReservationStayAmendmentFingerprint.ComputeV2( + command with { InventoryUnitIds = [secondUnitId, firstUnitId] }); + + Assert.Equal(forward, reverse); + Assert.Equal(64, forward.Length); + Assert.Equal(forward.ToLowerInvariant(), forward); + } + + [Fact] + public void V2_binds_every_durable_request_coordinate_and_target_field() + { + AmendReservationStayCommand command = Command([Guid.NewGuid()]); + string baseline = ReservationStayAmendmentFingerprint.ComputeV2(command); + string[] changed = + [ + ReservationStayAmendmentFingerprint.ComputeV2( + command with { ReservationId = Guid.NewGuid() }), + ReservationStayAmendmentFingerprint.ComputeV2( + command with { OperationId = Guid.NewGuid() }), + ReservationStayAmendmentFingerprint.ComputeV2( + command with { ExpectedDetailsRevision = command.ExpectedDetailsRevision + 1 }), + ReservationStayAmendmentFingerprint.ComputeV2( + command with { Arrival = command.Arrival.AddDays(1) }), + ReservationStayAmendmentFingerprint.ComputeV2( + command with { Departure = command.Departure.AddDays(1) }), + ReservationStayAmendmentFingerprint.ComputeV2( + command with { ExpectedArrivalTime = new TimeOnly(16, 0) }), + ReservationStayAmendmentFingerprint.ComputeV2( + command with { ExpectedDepartureTime = new TimeOnly(9, 0) }), + ReservationStayAmendmentFingerprint.ComputeV2( + command with { InventoryUnitIds = [Guid.NewGuid()] }) + ]; + + Assert.All(changed, fingerprint => Assert.NotEqual(baseline, fingerprint)); + Assert.Equal(changed.Length, changed.Distinct().Count()); + } + + [Theory] + [InlineData("fa-IR")] + [InlineData("th-TH")] + public void V2_is_invariant_across_non_Gregorian_current_cultures( + string cultureName) + { + AmendReservationStayCommand command = Command([Guid.NewGuid()]); + string baseline = ReservationStayAmendmentFingerprint.ComputeV2(command); + CultureInfo originalCulture = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo(cultureName); + Assert.Equal( + baseline, + ReservationStayAmendmentFingerprint.ComputeV2(command)); + } + finally + { + CultureInfo.CurrentCulture = originalCulture; + } + } + + [Fact] + public void Legacy_v1_preserves_existing_inventory_reassignment_fingerprint() + { + ReassignReservationInventoryCommand command = new( + Guid.NewGuid(), + Guid.Parse("11111111-1111-1111-1111-111111111111"), + Guid.Parse("22222222-2222-2222-2222-222222222222"), + [Guid.Parse("33333333-3333-3333-3333-333333333333")], + 7, + "user:operator-a"); + string canonical = string.Join( + '|', + command.ReservationId.ToString("N"), + command.AmendmentRequestId.ToString("N"), + "7", + "33333333333333333333333333333333"); + string expected = Convert.ToHexStringLower( + System.Security.Cryptography.SHA256.HashData( + System.Text.Encoding.UTF8.GetBytes(canonical))); + + Assert.Equal(expected, ReservationStayAmendmentFingerprint.ComputeLegacyV1(command)); + } + + private static AmendReservationStayCommand Command( + IReadOnlyCollection unitIds) => new( + Guid.NewGuid(), + Guid.NewGuid(), + Guid.NewGuid(), + new DateOnly(2026, 8, 20), + new DateOnly(2026, 8, 23), + new TimeOnly(15, 0), + new TimeOnly(10, 0), + unitIds, + 3, + "user:operator-a"); +} diff --git a/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Application/ReservationStayAmendmentOutcomeHandlerTests.cs b/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Application/ReservationStayAmendmentOutcomeHandlerTests.cs new file mode 100644 index 00000000..30c23713 --- /dev/null +++ b/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Application/ReservationStayAmendmentOutcomeHandlerTests.cs @@ -0,0 +1,465 @@ +namespace BunkFy.Modules.Reservations.Tests; + +using BunkFy.Modules.Inventory.Contracts; +using BunkFy.Modules.Reservations.Application.Commands; +using BunkFy.Modules.Reservations.Application.External; +using BunkFy.Modules.Reservations.Application.Handlers; +using BunkFy.Modules.Reservations.Application.Ports; +using BunkFy.Modules.Reservations.Contracts; +using BunkFy.Modules.Reservations.Domain.StayAmendments; +using Gma.Framework.Application.Events; +using Gma.Framework.Domain; +using Gma.Framework.Messaging; +using Xunit; + +[Trait("Category", "Unit")] +public sealed class ReservationStayAmendmentOutcomeHandlerTests +{ + [Fact] + public async Task Confirmation_applies_complete_target_and_finishes_durable_operation() + { + Guid currentUnitId = Guid.NewGuid(); + Guid targetUnitId = Guid.NewGuid(); + ReservationStayAmendmentApplicationTestContext context = new([currentUnitId]); + AmendReservationStayCommand command = Command(context, targetUnitId); + Assert.True((await context.AmendHandler.HandleAsync( + command, + CancellationToken.None)).IsSuccess); + Guid inventoryRequestId = Assert.Single(context.StayOperations.Items).InventoryRequestId!.Value; + context.Reservation.ClearDomainEvents(); + context.Clock.UtcNow = context.Clock.UtcNow.AddMinutes(1); + InventoryAllocationAmendmentConfirmedHandler handler = CreateConfirmedHandler(context); + + await handler.HandleAsync(new InventoryAllocationAmendmentConfirmedIntegrationEvent( + Guid.NewGuid(), + context.Reservation.ScopeId, + context.Clock.UtcNow, + inventoryRequestId, + context.Reservation.AllocationId!.Value, + context.Reservation.Id, + context.Reservation.PropertyId, + command.Arrival, + command.Departure, + command.InventoryUnitIds, + allocationVersion: 2), CancellationToken.None); + + Assert.Equal(command.Arrival, context.Reservation.Arrival); + Assert.Equal(command.Departure, context.Reservation.Departure); + Assert.Equal(command.ExpectedArrivalTime, context.Reservation.ExpectedArrivalTime); + Assert.Equal(command.ExpectedDepartureTime, context.Reservation.ExpectedDepartureTime); + Assert.Equal([targetUnitId], context.Reservation.RequestedUnits.Select(item => item.InventoryUnitId)); + Assert.Equal(2, context.Reservation.DetailsRevision); + Assert.Null(context.Reservation.PendingAllocationAmendmentId); + Assert.Equal(1, context.Inventory.AllocationApplyCount); + ReservationStayAmendmentOperation operation = Assert.Single(context.StayOperations.Items); + Assert.Equal(ReservationStayAmendmentOperationOutcome.Applied, operation.Outcome); + Assert.Equal(2, operation.ResultingAllocationVersion); + Assert.Equal(context.Reservation.DetailsRevision, operation.ResultingDetailsRevision); + Assert.Equal(context.Reservation.Version, operation.ResultingReservationVersion); + Assert.Empty(context.Reservation.DomainEvents); + + await Assert.ThrowsAsync(() => handler.HandleAsync( + new InventoryAllocationAmendmentConfirmedIntegrationEvent( + Guid.NewGuid(), + context.Reservation.ScopeId, + context.Clock.UtcNow, + inventoryRequestId, + context.Reservation.AllocationId!.Value, + context.Reservation.Id, + context.Reservation.PropertyId, + command.Arrival, + command.Departure, + command.InventoryUnitIds, + allocationVersion: 3), + CancellationToken.None)); + Assert.Equal(1, context.Inventory.AllocationApplyCount); + } + + [Fact] + public async Task Delayed_exact_confirmation_uses_the_operation_result_after_a_later_amendment() + { + ReservationStayAmendmentApplicationTestContext context = new([Guid.NewGuid()]); + InventoryAllocationAmendmentConfirmedHandler handler = CreateConfirmedHandler(context); + AmendReservationStayCommand firstCommand = Command(context, Guid.NewGuid()); + Assert.True((await context.AmendHandler.HandleAsync( + firstCommand, + CancellationToken.None)).IsSuccess); + ReservationStayAmendmentOperation firstOperation = Assert.Single(context.StayOperations.Items); + Guid firstInventoryRequestId = firstOperation.InventoryRequestId!.Value; + context.Clock.UtcNow = context.Clock.UtcNow.AddMinutes(1); + InventoryAllocationAmendmentConfirmedIntegrationEvent firstOutcome = new( + Guid.NewGuid(), + context.Reservation.ScopeId, + context.Clock.UtcNow, + firstInventoryRequestId, + context.Reservation.AllocationId!.Value, + context.Reservation.Id, + context.Reservation.PropertyId, + firstCommand.Arrival, + firstCommand.Departure, + firstCommand.InventoryUnitIds, + allocationVersion: 2); + await handler.HandleAsync(firstOutcome, CancellationToken.None); + + AmendReservationStayCommand secondCommand = Command(context, Guid.NewGuid()); + Assert.True((await context.AmendHandler.HandleAsync( + secondCommand, + CancellationToken.None)).IsSuccess); + ReservationStayAmendmentOperation secondOperation = context.StayOperations.Items.Single( + operation => operation.Id == secondCommand.OperationId); + context.Clock.UtcNow = context.Clock.UtcNow.AddMinutes(1); + await handler.HandleAsync(new InventoryAllocationAmendmentConfirmedIntegrationEvent( + Guid.NewGuid(), + context.Reservation.ScopeId, + context.Clock.UtcNow, + secondOperation.InventoryRequestId!.Value, + context.Reservation.AllocationId!.Value, + context.Reservation.Id, + context.Reservation.PropertyId, + secondCommand.Arrival, + secondCommand.Departure, + secondCommand.InventoryUnitIds, + allocationVersion: 3), CancellationToken.None); + + await handler.HandleAsync(new InventoryAllocationAmendmentConfirmedIntegrationEvent( + Guid.NewGuid(), + context.Reservation.ScopeId, + context.Clock.UtcNow, + firstInventoryRequestId, + context.Reservation.AllocationId!.Value, + context.Reservation.Id, + context.Reservation.PropertyId, + firstCommand.Arrival, + firstCommand.Departure, + firstCommand.InventoryUnitIds, + allocationVersion: 2), CancellationToken.None); + + Assert.Equal(3, context.Reservation.AllocationVersion); + Assert.Equal(2, context.Inventory.AllocationApplyCount); + Assert.Equal(2, firstOperation.ResultingAllocationVersion); + Assert.Equal(3, secondOperation.ResultingAllocationVersion); + } + + [Fact] + public async Task Rejection_keeps_all_current_stay_fields_and_finishes_durable_operation() + { + Guid currentUnitId = Guid.NewGuid(); + ReservationStayAmendmentApplicationTestContext context = new([currentUnitId]); + AmendReservationStayCommand command = Command(context, Guid.NewGuid()); + Assert.True((await context.AmendHandler.HandleAsync( + command, + CancellationToken.None)).IsSuccess); + Guid inventoryRequestId = Assert.Single(context.StayOperations.Items).InventoryRequestId!.Value; + DateOnly currentArrival = context.Reservation.Arrival; + DateOnly currentDeparture = context.Reservation.Departure; + TimeOnly? currentArrivalTime = context.Reservation.ExpectedArrivalTime; + TimeOnly? currentDepartureTime = context.Reservation.ExpectedDepartureTime; + long currentDetailsRevision = context.Reservation.DetailsRevision; + context.Clock.UtcNow = context.Clock.UtcNow.AddMinutes(1); + InventoryAllocationAmendmentRejectedHandler handler = new( + context.Mutations, + context.StayOperations, + CreateExternalCoordinator(context), + context.Clock); + + await handler.HandleAsync(new InventoryAllocationAmendmentRejectedIntegrationEvent( + Guid.NewGuid(), + context.Reservation.ScopeId, + context.Clock.UtcNow, + inventoryRequestId, + context.Reservation.AllocationId!.Value, + context.Reservation.Id, + context.Reservation.PropertyId, + InventoryAllocationRejectionReason.AllocationConflict), CancellationToken.None); + + Assert.Equal(currentArrival, context.Reservation.Arrival); + Assert.Equal(currentDeparture, context.Reservation.Departure); + Assert.Equal(currentArrivalTime, context.Reservation.ExpectedArrivalTime); + Assert.Equal(currentDepartureTime, context.Reservation.ExpectedDepartureTime); + Assert.Equal([currentUnitId], context.Reservation.RequestedUnits.Select(item => item.InventoryUnitId)); + Assert.Equal(currentDetailsRevision, context.Reservation.DetailsRevision); + Assert.Null(context.Reservation.PendingAllocationAmendmentId); + ReservationStayAmendmentOperation operation = Assert.Single(context.StayOperations.Items); + Assert.Equal(ReservationStayAmendmentOperationOutcome.Rejected, operation.Outcome); + Assert.Equal((int)InventoryAllocationRejectionReason.AllocationConflict, operation.RejectionCode); + Assert.Equal(context.Reservation.Version, operation.ResultingReservationVersion); + } + + [Fact] + public async Task Mismatched_confirmation_never_completes_reservation_or_operation() + { + ReservationStayAmendmentApplicationTestContext context = new([Guid.NewGuid()]); + AmendReservationStayCommand command = Command(context, Guid.NewGuid()); + Assert.True((await context.AmendHandler.HandleAsync( + command, + CancellationToken.None)).IsSuccess); + Guid inventoryRequestId = Assert.Single(context.StayOperations.Items).InventoryRequestId!.Value; + context.Clock.UtcNow = context.Clock.UtcNow.AddMinutes(1); + InventoryAllocationAmendmentConfirmedHandler handler = CreateConfirmedHandler(context); + + await Assert.ThrowsAsync(() => handler.HandleAsync( + new InventoryAllocationAmendmentConfirmedIntegrationEvent( + Guid.NewGuid(), + context.Reservation.ScopeId, + context.Clock.UtcNow, + inventoryRequestId, + context.Reservation.AllocationId!.Value, + context.Reservation.Id, + context.Reservation.PropertyId, + command.Arrival, + command.Departure.AddDays(1), + command.InventoryUnitIds, + allocationVersion: 2), + CancellationToken.None)); + + Assert.Equal(command.OperationId, context.Reservation.PendingAllocationAmendmentId); + Assert.Equal(0, context.Inventory.AllocationApplyCount); + Assert.Equal( + ReservationStayAmendmentOperationOutcome.Pending, + Assert.Single(context.StayOperations.Items).Outcome); + } + + [Theory] + [InlineData(1)] + [InlineData(3)] + public async Task Confirmation_rejects_a_nonexact_resulting_allocation_version( + long allocationVersion) + { + ReservationStayAmendmentApplicationTestContext context = new([Guid.NewGuid()]); + AmendReservationStayCommand command = Command(context, Guid.NewGuid()); + Assert.True((await context.AmendHandler.HandleAsync( + command, + CancellationToken.None)).IsSuccess); + Guid inventoryRequestId = Assert.Single(context.StayOperations.Items).InventoryRequestId!.Value; + context.Clock.UtcNow = context.Clock.UtcNow.AddMinutes(1); + + await Assert.ThrowsAsync(() => CreateConfirmedHandler(context).HandleAsync( + new InventoryAllocationAmendmentConfirmedIntegrationEvent( + Guid.NewGuid(), + context.Reservation.ScopeId, + context.Clock.UtcNow, + inventoryRequestId, + context.Reservation.AllocationId!.Value, + context.Reservation.Id, + context.Reservation.PropertyId, + command.Arrival, + command.Departure, + command.InventoryUnitIds, + allocationVersion), + CancellationToken.None)); + + Assert.Equal(1, context.Reservation.AllocationVersion); + Assert.Equal(command.OperationId, context.Reservation.PendingAllocationAmendmentId); + Assert.Equal(0, context.Inventory.AllocationApplyCount); + Assert.Equal( + ReservationStayAmendmentOperationOutcome.Pending, + Assert.Single(context.StayOperations.Items).Outcome); + } + + [Fact] + public async Task Expected_time_only_confirmation_keeps_the_current_allocation_version() + { + Guid currentUnitId = Guid.NewGuid(); + ReservationStayAmendmentApplicationTestContext context = new([currentUnitId]); + AmendReservationStayCommand command = new( + Guid.NewGuid(), + context.Reservation.PropertyId, + context.Reservation.Id, + context.Reservation.Arrival, + context.Reservation.Departure, + new TimeOnly(16, 0), + new TimeOnly(9, 0), + [currentUnitId], + context.Reservation.DetailsRevision, + "user:operator-a"); + Assert.True((await context.AmendHandler.HandleAsync( + command, + CancellationToken.None)).IsSuccess); + Guid inventoryRequestId = Assert.Single(context.StayOperations.Items).InventoryRequestId!.Value; + context.Clock.UtcNow = context.Clock.UtcNow.AddMinutes(1); + + await CreateConfirmedHandler(context).HandleAsync( + new InventoryAllocationAmendmentConfirmedIntegrationEvent( + Guid.NewGuid(), + context.Reservation.ScopeId, + context.Clock.UtcNow, + inventoryRequestId, + context.Reservation.AllocationId!.Value, + context.Reservation.Id, + context.Reservation.PropertyId, + command.Arrival, + command.Departure, + command.InventoryUnitIds, + allocationVersion: 1), + CancellationToken.None); + + Assert.Equal(1, context.Reservation.AllocationVersion); + Assert.Equal(command.ExpectedArrivalTime, context.Reservation.ExpectedArrivalTime); + Assert.Equal(command.ExpectedDepartureTime, context.Reservation.ExpectedDepartureTime); + Assert.Equal(1, context.Inventory.AllocationApplyCount); + Assert.Equal( + ReservationStayAmendmentOperationOutcome.Applied, + Assert.Single(context.StayOperations.Items).Outcome); + Assert.Equal( + 1, + Assert.Single(context.StayOperations.Items).ResultingAllocationVersion); + } + + [Fact] + public async Task Applied_operation_rejects_a_late_contradictory_rejection() + { + ReservationStayAmendmentApplicationTestContext context = new([Guid.NewGuid()]); + AmendReservationStayCommand command = Command(context, Guid.NewGuid()); + Assert.True((await context.AmendHandler.HandleAsync( + command, + CancellationToken.None)).IsSuccess); + Guid inventoryRequestId = Assert.Single(context.StayOperations.Items).InventoryRequestId!.Value; + context.Clock.UtcNow = context.Clock.UtcNow.AddMinutes(1); + InventoryAllocationAmendmentConfirmedHandler confirmedHandler = CreateConfirmedHandler(context); + await confirmedHandler.HandleAsync(new InventoryAllocationAmendmentConfirmedIntegrationEvent( + Guid.NewGuid(), + context.Reservation.ScopeId, + context.Clock.UtcNow, + inventoryRequestId, + context.Reservation.AllocationId!.Value, + context.Reservation.Id, + context.Reservation.PropertyId, + command.Arrival, + command.Departure, + command.InventoryUnitIds, + allocationVersion: 2), CancellationToken.None); + + InventoryAllocationAmendmentRejectedHandler rejectedHandler = new( + context.Mutations, + context.StayOperations, + CreateExternalCoordinator(context), + context.Clock); + await Assert.ThrowsAsync(() => rejectedHandler.HandleAsync( + new InventoryAllocationAmendmentRejectedIntegrationEvent( + Guid.NewGuid(), + context.Reservation.ScopeId, + context.Clock.UtcNow, + inventoryRequestId, + context.Reservation.AllocationId!.Value, + context.Reservation.Id, + context.Reservation.PropertyId, + InventoryAllocationRejectionReason.AllocationConflict), + CancellationToken.None)); + + Assert.Equal( + ReservationStayAmendmentOperationOutcome.Applied, + Assert.Single(context.StayOperations.Items).Outcome); + } + + [Fact] + public async Task Rejected_operation_rejects_a_late_contradictory_confirmation() + { + ReservationStayAmendmentApplicationTestContext context = new([Guid.NewGuid()]); + AmendReservationStayCommand command = Command(context, Guid.NewGuid()); + Assert.True((await context.AmendHandler.HandleAsync( + command, + CancellationToken.None)).IsSuccess); + Guid inventoryRequestId = Assert.Single(context.StayOperations.Items).InventoryRequestId!.Value; + context.Clock.UtcNow = context.Clock.UtcNow.AddMinutes(1); + InventoryAllocationAmendmentRejectedHandler rejectedHandler = new( + context.Mutations, + context.StayOperations, + CreateExternalCoordinator(context), + context.Clock); + await rejectedHandler.HandleAsync(new InventoryAllocationAmendmentRejectedIntegrationEvent( + Guid.NewGuid(), + context.Reservation.ScopeId, + context.Clock.UtcNow, + inventoryRequestId, + context.Reservation.AllocationId!.Value, + context.Reservation.Id, + context.Reservation.PropertyId, + InventoryAllocationRejectionReason.AllocationConflict), CancellationToken.None); + + InventoryAllocationAmendmentConfirmedHandler confirmedHandler = CreateConfirmedHandler(context); + await Assert.ThrowsAsync(() => confirmedHandler.HandleAsync( + new InventoryAllocationAmendmentConfirmedIntegrationEvent( + Guid.NewGuid(), + context.Reservation.ScopeId, + context.Clock.UtcNow, + inventoryRequestId, + context.Reservation.AllocationId!.Value, + context.Reservation.Id, + context.Reservation.PropertyId, + command.Arrival, + command.Departure, + command.InventoryUnitIds, + allocationVersion: 2), + CancellationToken.None)); + + Assert.Equal( + ReservationStayAmendmentOperationOutcome.Rejected, + Assert.Single(context.StayOperations.Items).Outcome); + Assert.Equal(0, context.Inventory.AllocationApplyCount); + } + + private static AmendReservationStayCommand Command( + ReservationStayAmendmentApplicationTestContext context, + Guid targetUnitId) => new( + Guid.NewGuid(), + context.Reservation.PropertyId, + context.Reservation.Id, + context.Reservation.Arrival.AddDays(1), + context.Reservation.Departure.AddDays(2), + new TimeOnly(16, 0), + new TimeOnly(9, 0), + [targetUnitId], + context.Reservation.DetailsRevision, + "user:operator-a"); + + private static ExternalReservationOperationCoordinator CreateExternalCoordinator( + ReservationStayAmendmentApplicationTestContext context) => new( + new NoOpExternalOperationRepository(), + new NoOpOutboxRegistry(), + context.Clock, + context.Ids); + + private static InventoryAllocationAmendmentConfirmedHandler CreateConfirmedHandler( + ReservationStayAmendmentApplicationTestContext context) => new( + context.Mutations, + context.Inventory, + context.StayOperations, + CreateExternalCoordinator(context), + new ReservationInboxDomainEventDispatcher(new NoOpDomainEventDispatcher()), + context.Clock, + context.Ids); + + private sealed class NoOpExternalOperationRepository + : IReservationExternalOperationRepository + { + public Task GetAsync( + Guid operationId, + CancellationToken cancellationToken) => Task.FromResult(null); + + public Task AddAsync( + ReservationExternalOperationRecord operation, + CancellationToken cancellationToken) => Task.CompletedTask; + } + + private sealed class NoOpOutboxRegistry : IOutboxWriterRegistry + { + public IOutboxWriter GetRequired(string moduleName) => new NoOpOutboxWriter(moduleName); + } + + private sealed class NoOpOutboxWriter(string moduleName) : IOutboxWriter + { + public string ModuleName { get; } = moduleName; + + public Task EnqueueAsync( + TEvent integrationEvent, + CancellationToken cancellationToken) + where TEvent : IIntegrationEvent => Task.CompletedTask; + } + + private sealed class NoOpDomainEventDispatcher : IDomainEventDispatcher + { + public Task DispatchAsync( + IReadOnlyCollection domainEvents, + CancellationToken cancellationToken) => Task.CompletedTask; + } +} diff --git a/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Application/ReservationStayAmendmentRecoveryHandlerTests.cs b/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Application/ReservationStayAmendmentRecoveryHandlerTests.cs new file mode 100644 index 00000000..f4227bc8 --- /dev/null +++ b/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Application/ReservationStayAmendmentRecoveryHandlerTests.cs @@ -0,0 +1,150 @@ +namespace BunkFy.Modules.Reservations.Tests; + +using BunkFy.Modules.Reservations.Application; +using BunkFy.Modules.Reservations.Application.Commands; +using BunkFy.Modules.Reservations.Application.Handlers; +using BunkFy.Modules.Reservations.Application.Queries; +using BunkFy.Modules.Reservations.Contracts; +using BunkFy.Modules.Reservations.Domain.Events; +using BunkFy.Modules.Reservations.Domain.StayAmendments; +using Gma.Framework.Results; +using Xunit; + +[Trait("Category", "Unit")] +public sealed class ReservationStayAmendmentRecoveryHandlerTests +{ + [Fact] + public async Task Reconcile_is_version_checked_rate_bounded_and_republishes_exact_candidate() + { + Guid targetUnitId = Guid.NewGuid(); + ReservationStayAmendmentApplicationTestContext context = new([Guid.NewGuid()]); + AmendReservationStayCommand amend = AmendCommand(context, targetUnitId); + ReservationStayAmendmentReceiptDto pending = (await context.AmendHandler.HandleAsync( + amend, + CancellationToken.None)).Value; + context.Reservation.ClearDomainEvents(); + ReconcileReservationStayAmendmentCommandHandler handler = new( + context.Mutations, + context.StayOperations, + context.Clock, + context.Ids); + + Result tooSoon = await handler.HandleAsync( + ReconcileCommand(context, amend.OperationId, pending.OperationVersion), + CancellationToken.None); + Assert.Equal(ReservationsApplicationErrors.StayAmendmentReconcileTooSoon, tooSoon.Error); + Assert.Empty(context.Reservation.DomainEvents); + Assert.Equal(pending.OperationVersion, Assert.Single(context.StayOperations.Items).OperationVersion); + + long selectedReservationVersion = context.Reservation.Version; + context.Clock.UtcNow = context.Clock.UtcNow.AddMinutes(5); + Result accepted = await handler.HandleAsync( + ReconcileCommand(context, amend.OperationId, pending.OperationVersion), + CancellationToken.None); + + Assert.True(accepted.IsSuccess, accepted.Error.Code); + Assert.Equal(pending.OperationVersion + 1, accepted.Value.OperationVersion); + Assert.Equal(1, accepted.Value.ReconciliationCount); + Assert.Equal(selectedReservationVersion + 1, context.Reservation.Version); + ReservationAllocationAmendmentRequestedDomainEvent replay = + Assert.IsType( + Assert.Single(context.Reservation.DomainEvents)); + Assert.Equal( + Assert.Single(context.StayOperations.Items).InventoryRequestId, + replay.AmendmentRequestId); + Assert.Equal(amend.Arrival, replay.Arrival); + Assert.Equal(amend.Departure, replay.Departure); + Assert.Equal([targetUnitId], replay.InventoryUnitIds); + + context.Reservation.ClearDomainEvents(); + Result stale = await handler.HandleAsync( + ReconcileCommand(context, amend.OperationId, pending.OperationVersion), + CancellationToken.None); + Assert.Equal(ReservationsApplicationErrors.StayAmendmentOperationVersionConflict, stale.Error); + Assert.Empty(context.Reservation.DomainEvents); + } + + [Fact] + public async Task Exact_status_returns_nullable_target_for_historical_unknown() + { + ReservationStayAmendmentApplicationTestContext context = new([Guid.NewGuid()]); + Guid operationId = Guid.NewGuid(); + ReservationStayAmendmentOperation unknown = ReservationStayAmendmentOperation.CreateOutcomeUnknown( + operationId, + context.Reservation.ScopeId, + context.Reservation.PropertyId, + context.Reservation.Id, + ReservationStayAmendmentOperation.LegacyRequestSchemaVersion, + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + context.Reservation.DetailsRevision, + context.Clock.UtcNow).Value; + context.StayOperations.Items.Add(unknown); + GetReservationStayAmendmentQueryHandler handler = new( + context.StayOperations, + context.Clock); + + Result result = await handler.HandleAsync( + new GetReservationStayAmendmentQuery( + context.Reservation.PropertyId, + context.Reservation.Id, + operationId), + CancellationToken.None); + + Assert.True(result.IsSuccess, result.Error.Code); + Assert.Equal(ReservationStayAmendmentOutcome.OutcomeUnknown, result.Value.Outcome); + Assert.Null(result.Value.Target); + Assert.False(result.Value.RecoveryEligible); + Assert.Null(result.Value.NextRecoveryEligibleAtUtc); + } + + [Fact] + public async Task Recovery_list_is_privacy_minimal_and_maps_only_recovery_outcomes() + { + ReservationStayAmendmentApplicationTestContext context = new([Guid.NewGuid()]); + AmendReservationStayCommand amend = AmendCommand(context, Guid.NewGuid()); + Assert.True((await context.AmendHandler.HandleAsync(amend, CancellationToken.None)).IsSuccess); + ListReservationStayAmendmentRecoveryQueryHandler handler = new( + context.StayOperations, + context.Clock); + + Result result = await handler.HandleAsync( + new ListReservationStayAmendmentRecoveryQuery( + context.Reservation.PropertyId, + Cursor: null, + PageSize: 20), + CancellationToken.None); + + ReservationStayAmendmentRecoveryItemDto item = Assert.Single(result.Value.Operations); + Assert.Equal(amend.OperationId, item.OperationId); + Assert.Equal(ReservationStayAmendmentOutcome.Pending, item.Outcome); + Assert.DoesNotContain( + typeof(ReservationStayAmendmentRecoveryItemDto).GetProperties(), + property => property.Name.Contains("Guest", StringComparison.OrdinalIgnoreCase) || + property.Name.Contains("Email", StringComparison.OrdinalIgnoreCase) || + property.Name.Contains("Phone", StringComparison.OrdinalIgnoreCase)); + } + + private static AmendReservationStayCommand AmendCommand( + ReservationStayAmendmentApplicationTestContext context, + Guid targetUnitId) => new( + Guid.NewGuid(), + context.Reservation.PropertyId, + context.Reservation.Id, + context.Reservation.Arrival.AddDays(1), + context.Reservation.Departure.AddDays(1), + new TimeOnly(16, 0), + new TimeOnly(9, 0), + [targetUnitId], + context.Reservation.DetailsRevision, + "user:operator-a"); + + private static ReconcileReservationStayAmendmentCommand ReconcileCommand( + ReservationStayAmendmentApplicationTestContext context, + Guid operationId, + long expectedOperationVersion) => new( + context.Reservation.PropertyId, + context.Reservation.Id, + operationId, + expectedOperationVersion, + "user:operator-b"); +} diff --git a/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Contracts/ReservationsPersonalDataCatalogTests.cs b/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Contracts/ReservationsPersonalDataCatalogTests.cs index 378b9b9e..ad644fff 100644 --- a/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Contracts/ReservationsPersonalDataCatalogTests.cs +++ b/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Contracts/ReservationsPersonalDataCatalogTests.cs @@ -15,6 +15,7 @@ namespace BunkFy.Modules.Reservations.Tests; using BunkFy.Modules.Reservations.Domain.Entities; using BunkFy.Modules.Reservations.Domain.GuestRecords; using BunkFy.Modules.Reservations.Domain.Retention; +using BunkFy.Modules.Reservations.Domain.StayAmendments; using BunkFy.Modules.Reservations.Persistence; using Gma.Framework.Messaging; using Gma.Framework.Scoping; @@ -85,6 +86,9 @@ public sealed class ReservationsPersonalDataCatalogTests nameof(ReservationGuestRecordWriteRequest.PreferredLanguageTag), nameof(ReservationGuestRecordWriteRequest.Notes) ], + StringComparer.Ordinal), + [typeof(ListReservationStayAmendmentRecoveryQuery)] = new( + [nameof(ListReservationStayAmendmentRecoveryQuery.PageSize)], StringComparer.Ordinal) }; @@ -399,6 +403,7 @@ private static HashSet PersonalPersistenceTypes() => typeof(ReservationDetailsHistoryEntry), typeof(ReservationExternalOperation), typeof(ReservationManagementOperation), + typeof(ReservationStayAmendmentOperation), typeof(ReservationGuestProfileProjection), typeof(ReservationGuestProcessingRestrictionProjection), typeof(ReservationArrivalReminder), diff --git a/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Domain/ReservationStayAmendmentLifecycleStateTests.cs b/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Domain/ReservationStayAmendmentLifecycleStateTests.cs new file mode 100644 index 00000000..ac35f96b --- /dev/null +++ b/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Domain/ReservationStayAmendmentLifecycleStateTests.cs @@ -0,0 +1,167 @@ +namespace BunkFy.Modules.Reservations.Tests; + +using BunkFy.Modules.Reservations.Domain.Aggregates; +using BunkFy.Modules.Reservations.Domain.Errors; +using Gma.Framework.Results; +using Xunit; + +[Trait("Category", "Unit")] +public sealed class ReservationStayAmendmentLifecycleStateTests +{ + [Theory] + [InlineData(ReservationState.PendingAllocation)] + [InlineData(ReservationState.AllocationRejected)] + [InlineData(ReservationState.CancellationPending)] + [InlineData(ReservationState.Cancelled)] + [InlineData(ReservationState.CheckedIn)] + [InlineData(ReservationState.NoShowPending)] + [InlineData(ReservationState.NoShow)] + [InlineData(ReservationState.CheckoutPending)] + [InlineData(ReservationState.CheckedOut)] + public void Every_nonconfirmed_lifecycle_state_rejects_a_stay_amendment( + ReservationState state) + { + Reservation reservation = CreateInState(state); + + Result result = + reservation.BeginAllocationAmendment( + Guid.NewGuid(), + Guid.NewGuid(), + new string('a', Reservation.RequestFingerprintLength), + Arrival.AddDays(1), + Departure.AddDays(1), + [Guid.NewGuid()], + reservation.PrimaryGuestName, + reservation.Email, + reservation.Phone, + reservation.GuestCount, + reservation.Notes, + reservation.DetailsRevision, + ReservationDetailsChangeOrigin.Staff, + "user:operator", + adapterConnectionId: null, + externalOperationId: null, + Guid.NewGuid(), + Guid.NewGuid(), + Now.AddMinutes(10)); + + Assert.Equal(state, reservation.Status); + Assert.Equal(ReservationsDomainErrors.AllocationAmendmentInvalid, result.Error); + Assert.Null(reservation.PendingAllocationAmendmentId); + } + + private static Reservation CreateInState(ReservationState target) + { + Guid unitId = Guid.NewGuid(); + Reservation reservation = Reservation.Create( + Guid.NewGuid(), + "tenant-a", + Guid.NewGuid(), + Guid.NewGuid(), + Arrival, + Departure, + [unitId], + "Ada Guest", + "ada@example.test", + null, + 1, + ReservationSource.Direct, + sourceSystem: null, + sourceReference: null, + notes: null, + Guid.NewGuid(), + Guid.NewGuid(), + ReservationDetailsChangeOrigin.Staff, + initialDetailsActorId: null, + initialAdapterConnectionId: null, + initialExternalOperationId: null, + Guid.NewGuid(), + Now).Value; + if (target == ReservationState.PendingAllocation) + { + return reservation; + } + + if (target == ReservationState.AllocationRejected) + { + Assert.True(reservation.RejectAllocation( + reservation.AllocationRequestId, + ReservationAllocationRejection.AllocationConflict, + Guid.NewGuid(), + Now.AddMinutes(1)).IsSuccess); + return reservation; + } + + Assert.True(reservation.ConfirmAllocation( + reservation.AllocationRequestId, + Guid.NewGuid(), + allocationVersion: 1, + Guid.NewGuid(), + Now.AddMinutes(1)).IsSuccess); + Guid releaseRequestId = Guid.NewGuid(); + switch (target) + { + case ReservationState.CancellationPending: + case ReservationState.Cancelled: + Assert.True(reservation.RequestCancellation( + reservation.Version, + releaseRequestId, + Guid.NewGuid(), + Now.AddMinutes(2), + "user:operator").IsSuccess); + break; + case ReservationState.CheckedIn: + case ReservationState.CheckoutPending: + case ReservationState.CheckedOut: + Assert.True(reservation.CheckIn( + reservation.Version, + Arrival, + "user:operator", + Guid.NewGuid(), + Now.AddMinutes(2)).IsSuccess); + if (target is ReservationState.CheckoutPending or ReservationState.CheckedOut) + { + Assert.True(reservation.RequestCheckout( + reservation.Version, + Arrival, + "user:operator", + releaseRequestId, + Guid.NewGuid(), + Now.AddMinutes(3)).IsSuccess); + } + + break; + case ReservationState.NoShowPending: + case ReservationState.NoShow: + Assert.True(reservation.RequestNoShow( + reservation.Version, + Arrival, + "user:operator", + releaseRequestId, + Guid.NewGuid(), + Now.AddMinutes(2)).IsSuccess); + break; + case ReservationState.PendingAllocation: + case ReservationState.Confirmed: + case ReservationState.AllocationRejected: + default: + throw new ArgumentOutOfRangeException(nameof(target)); + } + + if (target is ReservationState.Cancelled or + ReservationState.NoShow or ReservationState.CheckedOut) + { + Assert.True(reservation.CompleteAllocationRelease( + releaseRequestId, + Guid.NewGuid(), + Now.AddMinutes(4)).IsSuccess); + } + + return reservation; + } + + private static readonly DateOnly Arrival = new(2026, 8, 20); + private static readonly DateOnly Departure = new(2026, 8, 23); + private static readonly DateTimeOffset Now = + new(2026, 8, 12, 12, 0, 0, TimeSpan.Zero); +} diff --git a/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Domain/ReservationStayAmendmentOperationTests.cs b/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Domain/ReservationStayAmendmentOperationTests.cs new file mode 100644 index 00000000..0b1caf6e --- /dev/null +++ b/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Domain/ReservationStayAmendmentOperationTests.cs @@ -0,0 +1,253 @@ +namespace BunkFy.Modules.Reservations.Tests; + +using BunkFy.Modules.Reservations.Domain.Errors; +using BunkFy.Modules.Reservations.Domain.StayAmendments; +using Gma.Framework.Results; +using Xunit; + +[Trait("Category", "Unit")] +public sealed class ReservationStayAmendmentOperationTests +{ + [Fact] + public void Pending_operation_canonicalizes_target_and_accepts_one_rate_bounded_reconcile() + { + Guid firstUnitId = Guid.Parse("00000000-0000-0000-0000-000000000001"); + Guid secondUnitId = Guid.Parse("00000000-0000-0000-0000-000000000002"); + ReservationStayAmendmentOperation operation = CreatePending([secondUnitId, firstUnitId]); + + Assert.Equal(ReservationStayAmendmentOperationOutcome.Pending, operation.Outcome); + Assert.Equal(1, operation.OperationVersion); + Assert.Equal([firstUnitId, secondUnitId], operation.GetTargetInventoryUnitIds()); + Assert.False(operation.IsReconciliationEligible(Now.AddMinutes(4))); + Assert.Equal( + ReservationsDomainErrors.StayAmendmentReconcileTooSoon, + operation.Reconcile(1, Actor, Now.AddMinutes(4)).Error); + + Result reconciled = operation.Reconcile(1, Actor, Now.AddMinutes(5)); + + Assert.True(reconciled.IsSuccess, reconciled.Error.Code); + Assert.Equal(2, operation.OperationVersion); + Assert.Equal(1, operation.ReconciliationCount); + Assert.Equal(Now.AddMinutes(5), operation.LastReconciledAtUtc); + Assert.Equal(Actor, operation.LastReconciledBy); + Assert.Equal(Now.AddMinutes(10), operation.NextReconciliationEligibleAtUtc); + } + + [Fact] + public void Reconcile_rejects_stale_version_and_terminal_outcomes() + { + ReservationStayAmendmentOperation operation = CreatePending([Guid.NewGuid()]); + + Assert.Equal( + ReservationsDomainErrors.StayAmendmentOperationVersionConflict, + operation.Reconcile(2, Actor, Now.AddMinutes(5)).Error); + Assert.True(operation.MarkRejected( + rejectionCode: 2, + resultingDetailsRevision: 1, + resultingReservationVersion: 4, + Now.AddMinutes(1)).IsSuccess); + + Assert.Equal( + ReservationsDomainErrors.StayAmendmentOperationTransitionInvalid, + operation.Reconcile(2, Actor, Now.AddMinutes(6)).Error); + } + + [Fact] + public void Confirmation_must_match_the_durable_target_and_is_exactly_idempotent() + { + Guid unitId = Guid.NewGuid(); + ReservationStayAmendmentOperation operation = CreatePending([unitId]); + + Assert.Equal( + ReservationsDomainErrors.StayAmendmentOutcomeMismatch, + operation.MarkApplied( + Arrival, + Departure.AddDays(1), + new TimeOnly(15, 0), + new TimeOnly(10, 0), + [unitId], + resultingAllocationVersion: 2, + resultingDetailsRevision: 2, + resultingReservationVersion: 4, + Now.AddMinutes(1)).Error); + + Result applied = operation.MarkApplied( + Arrival, + Departure, + new TimeOnly(15, 0), + new TimeOnly(10, 0), + [unitId], + resultingAllocationVersion: 2, + resultingDetailsRevision: 2, + resultingReservationVersion: 4, + Now.AddMinutes(1)); + Result replay = operation.MarkApplied( + Arrival, + Departure, + new TimeOnly(15, 0), + new TimeOnly(10, 0), + [unitId], + resultingAllocationVersion: 2, + resultingDetailsRevision: 2, + resultingReservationVersion: 4, + Now.AddMinutes(2)); + + Assert.True(applied.Value); + Assert.False(replay.Value); + Assert.Equal(ReservationStayAmendmentOperationOutcome.Applied, operation.Outcome); + Assert.Equal(2, operation.OperationVersion); + Assert.Equal(Now.AddMinutes(1), operation.CompletedAtUtc); + } + + [Fact] + public void Rejection_records_resulting_reservation_truth_without_changing_target() + { + Guid unitId = Guid.NewGuid(); + ReservationStayAmendmentOperation operation = CreatePending([unitId]); + + Result rejected = operation.MarkRejected( + rejectionCode: 3, + resultingDetailsRevision: 1, + resultingReservationVersion: 4, + Now.AddMinutes(1)); + + Assert.True(rejected.Value); + Assert.Equal(ReservationStayAmendmentOperationOutcome.Rejected, operation.Outcome); + Assert.Equal(3, operation.RejectionCode); + Assert.Equal(1, operation.ResultingDetailsRevision); + Assert.Equal(4, operation.ResultingReservationVersion); + Assert.Equal([unitId], operation.GetTargetInventoryUnitIds()); + } + + [Fact] + public void No_op_is_created_applied_and_never_becomes_pending() + { + Result created = + ReservationStayAmendmentOperation.CreateAppliedNoOp( + OperationId, + TenantId, + PropertyId, + ReservationId, + ReservationStayAmendmentOperation.CurrentRequestSchemaVersion, + Fingerprint, + Arrival, + Departure, + new TimeOnly(15, 0), + new TimeOnly(10, 0), + [Guid.NewGuid()], + expectedDetailsRevision: 3, + Actor, + resultingDetailsRevision: 3, + resultingReservationVersion: 8, + resultingAllocationVersion: 5, + Now); + + Assert.True(created.IsSuccess, created.Error.Code); + Assert.Equal(ReservationStayAmendmentOperationOutcome.Applied, created.Value.Outcome); + Assert.Equal(Now, created.Value.CompletedAtUtc); + Assert.False(created.Value.IsReconciliationEligible(Now.AddDays(1))); + Assert.Equal( + ReservationsDomainErrors.StayAmendmentOperationTransitionInvalid, + created.Value.Reconcile(1, Actor, Now.AddDays(1)).Error); + } + + [Fact] + public void Historical_unknown_preserves_absent_target_and_actor() + { + Result created = + ReservationStayAmendmentOperation.CreateOutcomeUnknown( + OperationId, + TenantId, + PropertyId, + ReservationId, + ReservationStayAmendmentOperation.LegacyRequestSchemaVersion, + Fingerprint, + expectedDetailsRevision: 1, + Now); + + Assert.True(created.IsSuccess, created.Error.Code); + Assert.Equal(ReservationStayAmendmentOperationOutcome.OutcomeUnknown, created.Value.Outcome); + Assert.Null(created.Value.TargetArrival); + Assert.Null(created.Value.TargetDeparture); + Assert.Null(created.Value.TargetInventoryUnitIds); + Assert.Null(created.Value.RequestedBy); + Assert.Empty(created.Value.GetTargetInventoryUnitIds()); + } + + [Fact] + public void Historical_unknown_rejects_the_current_request_schema() + { + Result created = + ReservationStayAmendmentOperation.CreateOutcomeUnknown( + OperationId, + TenantId, + PropertyId, + ReservationId, + ReservationStayAmendmentOperation.CurrentRequestSchemaVersion, + Fingerprint, + expectedDetailsRevision: 1, + Now); + + Assert.Equal( + ReservationsDomainErrors.StayAmendmentOperationRequestInvalid, + created.Error); + } + + [Fact] + public void Applied_no_op_rejects_the_legacy_request_schema() + { + Result created = + ReservationStayAmendmentOperation.CreateAppliedNoOp( + OperationId, + TenantId, + PropertyId, + ReservationId, + ReservationStayAmendmentOperation.LegacyRequestSchemaVersion, + Fingerprint, + Arrival, + Departure, + new TimeOnly(15, 0), + new TimeOnly(10, 0), + [Guid.NewGuid()], + expectedDetailsRevision: 3, + Actor, + resultingDetailsRevision: 3, + resultingReservationVersion: 8, + resultingAllocationVersion: 5, + Now); + + Assert.Equal( + ReservationsDomainErrors.StayAmendmentOperationRequestInvalid, + created.Error); + } + + private static ReservationStayAmendmentOperation CreatePending( + IReadOnlyCollection unitIds) => + ReservationStayAmendmentOperation.CreatePending( + OperationId, + TenantId, + PropertyId, + ReservationId, + Guid.NewGuid(), + ReservationStayAmendmentOperation.CurrentRequestSchemaVersion, + Fingerprint, + Arrival, + Departure, + new TimeOnly(15, 0), + new TimeOnly(10, 0), + unitIds, + expectedDetailsRevision: 1, + actorId: Actor, + requestedAtUtc: Now).Value; + + private const string TenantId = "tenant-a"; + private const string Actor = "user:operator-a"; + private const string Fingerprint = + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + private static readonly Guid OperationId = Guid.Parse("10000000-0000-0000-0000-000000000001"); + private static readonly Guid PropertyId = Guid.Parse("20000000-0000-0000-0000-000000000001"); + private static readonly Guid ReservationId = Guid.Parse("30000000-0000-0000-0000-000000000001"); + private static readonly DateOnly Arrival = new(2026, 8, 20); + private static readonly DateOnly Departure = new(2026, 8, 23); + private static readonly DateTimeOffset Now = new(2026, 8, 12, 12, 0, 0, TimeSpan.Zero); +} diff --git a/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Domain/ReservationTests.cs b/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Domain/ReservationTests.cs index 81f1a393..00ec03eb 100644 --- a/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Domain/ReservationTests.cs +++ b/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Domain/ReservationTests.cs @@ -450,10 +450,12 @@ public void Allocation_amendment_keeps_current_details_until_confirmation_and_se Assert.True(reservation.ConfirmAllocation(RequestId, allocationId, 1, Guid.NewGuid(), Now).IsSuccess); reservation.ClearDomainEvents(); Guid amendmentId = Guid.NewGuid(); + Guid inventoryRequestId = Guid.NewGuid(); Guid replacementUnit = Guid.NewGuid(); Result begun = reservation.BeginAllocationAmendment( amendmentId, + inventoryRequestId, new string('a', Reservation.RequestFingerprintLength), new DateOnly(2026, 8, 2), new DateOnly(2026, 8, 5), @@ -464,13 +466,13 @@ public void Allocation_amendment_keeps_current_details_until_confirmation_and_se 2, "Changed stay", expectedDetailsRevision: 1, - ReservationDetailsChangeOrigin.Adapter, - "adapter:integration", - Guid.NewGuid(), - Guid.NewGuid(), - Guid.NewGuid(), - Guid.NewGuid(), - Now.AddMinutes(1)); + origin: ReservationDetailsChangeOrigin.Adapter, + actorId: "adapter:integration", + adapterConnectionId: Guid.NewGuid(), + externalOperationId: Guid.NewGuid(), + correlationId: Guid.NewGuid(), + eventId: Guid.NewGuid(), + nowUtc: Now.AddMinutes(1)); Assert.Equal(ReservationDetailsChangeOutcome.Changed, begun.Value); Assert.Equal(new DateOnly(2026, 8, 1), reservation.Arrival); @@ -490,7 +492,7 @@ public void Allocation_amendment_keeps_current_details_until_confirmation_and_se reservation.ClearDomainEvents(); Assert.True(reservation.CompleteAllocationAmendment( - amendmentId, + inventoryRequestId, allocationId, new DateOnly(2026, 8, 2), new DateOnly(2026, 8, 5), diff --git a/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Persistence/ReservationDataRightsDiscoveryContributorTests.cs b/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Persistence/ReservationDataRightsDiscoveryContributorTests.cs index ca61cc6e..97d17384 100644 --- a/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Persistence/ReservationDataRightsDiscoveryContributorTests.cs +++ b/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Persistence/ReservationDataRightsDiscoveryContributorTests.cs @@ -90,6 +90,7 @@ public async Task Exact_pending_phone_uses_the_pending_subject_preview() Guid.NewGuid(), Now.AddMinutes(1)).IsSuccess); Assert.True(reservation.BeginAllocationAmendment( + Guid.NewGuid(), Guid.NewGuid(), new string('a', Reservation.RequestFingerprintLength), reservation.Arrival, diff --git a/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Persistence/ReservationDataRightsExportContributorTests.cs b/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Persistence/ReservationDataRightsExportContributorTests.cs index bd0deefa..071477c3 100644 --- a/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Persistence/ReservationDataRightsExportContributorTests.cs +++ b/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Persistence/ReservationDataRightsExportContributorTests.cs @@ -1,11 +1,13 @@ namespace BunkFy.Modules.Reservations.Tests.Persistence; +using System.Text.Json; using BunkFy.Modules.DataRights.Contracts; using BunkFy.Modules.Reservations.Application.Ports; using BunkFy.Modules.Reservations.Contracts; using BunkFy.Modules.Reservations.Domain.Aggregates; using BunkFy.Modules.Reservations.Domain.DataRights; using BunkFy.Modules.Reservations.Domain.Models; +using BunkFy.Modules.Reservations.Domain.StayAmendments; using BunkFy.Modules.Reservations.Persistence; using BunkFy.Modules.Reservations.Persistence.Repositories; using Gma.Framework.Scoping; @@ -67,8 +69,10 @@ public async Task Export_writes_every_owned_record_once_and_excludes_unrelated_r Now.AddMinutes(2)).IsSuccess); Guid adapterConnectionId = Guid.NewGuid(); Guid externalOperationId = Guid.NewGuid(); + Guid inventoryRequestId = Guid.NewGuid(); Assert.True(reservation.BeginAllocationAmendment( Guid.NewGuid(), + inventoryRequestId, new string('a', Reservation.RequestFingerprintLength), reservation.Arrival, reservation.Departure.AddDays(1), @@ -225,12 +229,32 @@ public async Task Export_writes_every_owned_record_once_and_excludes_unrelated_r "tenant-a", propertyId, reservation.Id, - ReservationManagementOperationKind.InventoryAmendment, + ReservationManagementOperationKind.StayAmendment, ExpectedVersion: null, reservation.DetailsRevision, BusinessDate: null, Now.AddMinutes(5), managementRequestFingerprint))); + ReservationStayAmendmentOperation stayAmendment = + ReservationStayAmendmentOperation.CreatePending( + managementOperationId, + "tenant-a", + propertyId, + reservation.Id, + inventoryRequestId, + ReservationStayAmendmentOperation.CurrentRequestSchemaVersion, + managementRequestFingerprint, + reservation.Arrival, + reservation.Departure.AddDays(1), + reservation.ExpectedArrivalTime, + reservation.ExpectedDepartureTime, + reservation.RequestedUnits + .Select(unit => unit.InventoryUnitId) + .ToArray(), + reservation.DetailsRevision, + "user:stay-operator", + Now.AddMinutes(5)).Value; + dbContext.StayAmendmentOperations.Add(stayAmendment); dbContext.ArrivalReminders.Add(ReservationArrivalReminder.Create( Guid.NewGuid(), @@ -268,8 +292,8 @@ public async Task Export_writes_every_owned_record_once_and_excludes_unrelated_r CancellationToken.None); Assert.Equal(DataRightsSubjectExportStatus.Succeeded, result.Status); - Assert.Equal(14, result.RecordCount); - Assert.Equal(14, sink.Records.Count); + Assert.Equal(15, result.RecordCount); + Assert.Equal(15, sink.Records.Count); Assert.Equal( [ ReservationDataRightsDiscoveryContributor.ReservationRecordType, @@ -285,6 +309,7 @@ public async Task Export_writes_every_owned_record_once_and_excludes_unrelated_r ReservationDataRightsExportContributor.DataHoldReceiptRecordType, ReservationDataRightsExportContributor.ExternalOperationRecordType, ReservationDataRightsExportContributor.ManagementOperationRecordType, + ReservationDataRightsExportContributor.StayAmendmentOperationRecordType, ReservationDataRightsExportContributor.ArrivalReminderRecordType ], sink.Records.Select(record => record.RecordType)); @@ -313,6 +338,56 @@ record => record.RecordType == Assert.DoesNotContain( sink.Records.SelectMany(record => record.Fields), field => field.FieldId == "reservation.audit.actor-id"); + DataRightsExportRecord reservationExport = Assert.Single( + sink.Records, + record => record.RecordType == + ReservationDataRightsDiscoveryContributor.ReservationRecordType); + Assert.Equal( + inventoryRequestId, + Assert.Single( + reservationExport.Fields, + field => field.FieldId == + "reservation.stay-amendment.inventory-request-id") + .Value.GetGuid()); + DataRightsExportRecord stayAmendmentExport = Assert.Single( + sink.Records, + record => record.RecordType == + ReservationDataRightsExportContributor + .StayAmendmentOperationRecordType); + Assert.Equal( + DataRightsExportRecordIds.CreateDeterministicChild( + reservation.Id, + managementOperationId.ToString("N")), + stayAmendmentExport.RecordId); + Assert.Equal( + stayAmendment.OperationVersion, + stayAmendmentExport.RecordVersion); + Assert.Equal( + inventoryRequestId, + Assert.Single( + stayAmendmentExport.Fields, + field => field.FieldId == + "reservation.stay-amendment.inventory-request-id") + .Value.GetGuid()); + Assert.Equal( + "pending", + Assert.Single( + stayAmendmentExport.Fields, + field => field.FieldId == + "reservation.stay-amendment.outcome") + .Value.GetString()); + Assert.Equal( + "2026-08-04", + Assert.Single( + stayAmendmentExport.Fields, + field => field.FieldId == + "reservation.stay-amendment.target-departure") + .Value.GetString()); + Assert.DoesNotContain( + stayAmendmentExport.Fields, + field => field.FieldId is + "reservation.stay-amendment.requested-by" or + "reservation.stay-amendment.last-reconciled-by"); DataRightsExportRecord correctionExport = Assert.Single( sink.Records, record => record.RecordType == @@ -407,6 +482,228 @@ public async Task Export_rejects_stale_cross_property_and_unknown_scope_without_ Assert.Empty(unknownPropertySink.Records); } + [Fact] + public async Task Export_preserves_truthful_unknown_legacy_stay_amendment_evidence() + { + await using ReservationsDbContext dbContext = CreateDbContext("tenant-a"); + Guid propertyId = AddKnownProperty(dbContext); + Reservation reservation = CreateReservation(propertyId, "Legacy Guest"); + Guid operationId = Guid.NewGuid(); + string fingerprint = new('e', Reservation.RequestFingerprintLength); + dbContext.Reservations.Add(reservation); + dbContext.ManagementOperations.Add(new ReservationManagementOperation( + new ReservationManagementOperationRecord( + operationId, + "tenant-a", + propertyId, + reservation.Id, + ReservationManagementOperationKind.StayAmendment, + ExpectedVersion: null, + reservation.DetailsRevision, + BusinessDate: null, + Now, + fingerprint))); + dbContext.StayAmendmentOperations.Add( + ReservationStayAmendmentOperation.CreateOutcomeUnknown( + operationId, + "tenant-a", + propertyId, + reservation.Id, + ReservationStayAmendmentOperation.LegacyRequestSchemaVersion, + fingerprint, + reservation.DetailsRevision, + Now).Value); + await dbContext.SaveChangesAsync(); + ReservationDataRightsExportContributor contributor = + new(dbContext, new TestScopeContext("tenant-a")); + CollectingSink sink = new(); + + DataRightsSubjectExportResult result = await contributor.ExportAsync( + CreateRequest(propertyId, reservation), + sink, + CancellationToken.None); + + Assert.Equal(DataRightsSubjectExportStatus.Succeeded, result.Status); + DataRightsExportRecord operation = Assert.Single( + sink.Records, + record => record.RecordType == + ReservationDataRightsExportContributor + .StayAmendmentOperationRecordType); + Assert.Equal( + "outcome-unknown", + Assert.Single( + operation.Fields, + field => field.FieldId == + "reservation.stay-amendment.outcome") + .Value.GetString()); + Assert.Equal( + JsonValueKind.Null, + Assert.Single( + operation.Fields, + field => field.FieldId == + "reservation.stay-amendment.target-arrival") + .Value.ValueKind); + Assert.Equal( + JsonValueKind.Null, + Assert.Single( + operation.Fields, + field => field.FieldId == + "reservation.stay-amendment.target-inventory-unit-ids") + .Value.ValueKind); + Assert.Equal( + JsonValueKind.Null, + Assert.Single( + operation.Fields, + field => field.FieldId == + "reservation.stay-amendment.inventory-request-id") + .Value.ValueKind); + Assert.DoesNotContain( + operation.Fields, + field => field.FieldId is + "reservation.stay-amendment.requested-by" or + "reservation.stay-amendment.last-reconciled-by"); + } + + [Fact] + public async Task Export_retains_terminal_inventory_request_identity_and_nulls_applied_no_op() + { + await using ReservationsDbContext dbContext = CreateDbContext("tenant-a"); + Guid propertyId = AddKnownProperty(dbContext); + Reservation reservation = CreateReservation(propertyId, "Terminal Guest"); + Guid terminalOperationId = Guid.NewGuid(); + Guid noOpOperationId = Guid.NewGuid(); + Guid inventoryRequestId = Guid.NewGuid(); + string terminalFingerprint = new('f', Reservation.RequestFingerprintLength); + string noOpFingerprint = new('a', Reservation.RequestFingerprintLength); + Assert.True(reservation.ConfirmAllocation( + reservation.AllocationRequestId, + Guid.NewGuid(), + allocationVersion: 1, + Guid.NewGuid(), + Now).IsSuccess); + dbContext.Reservations.Add(reservation); + dbContext.ManagementOperations.AddRange( + new ReservationManagementOperation( + new ReservationManagementOperationRecord( + terminalOperationId, + "tenant-a", + propertyId, + reservation.Id, + ReservationManagementOperationKind.StayAmendment, + ExpectedVersion: null, + reservation.DetailsRevision, + BusinessDate: null, + Now, + terminalFingerprint)), + new ReservationManagementOperation( + new ReservationManagementOperationRecord( + noOpOperationId, + "tenant-a", + propertyId, + reservation.Id, + ReservationManagementOperationKind.StayAmendment, + ExpectedVersion: null, + reservation.DetailsRevision, + BusinessDate: null, + Now.AddMinutes(2), + noOpFingerprint))); + ReservationStayAmendmentOperation terminal = + ReservationStayAmendmentOperation.CreatePending( + terminalOperationId, + "tenant-a", + propertyId, + reservation.Id, + inventoryRequestId, + ReservationStayAmendmentOperation.CurrentRequestSchemaVersion, + terminalFingerprint, + reservation.Arrival, + reservation.Departure.AddDays(1), + reservation.ExpectedArrivalTime, + reservation.ExpectedDepartureTime, + reservation.RequestedUnits + .Select(unit => unit.InventoryUnitId) + .ToArray(), + reservation.DetailsRevision, + "user:stay-operator", + Now).Value; + Assert.True(terminal.MarkRejected( + rejectionCode: 1, + reservation.DetailsRevision, + reservation.Version, + Now.AddMinutes(1)).IsSuccess); + ReservationStayAmendmentOperation noOp = + ReservationStayAmendmentOperation.CreateAppliedNoOp( + noOpOperationId, + "tenant-a", + propertyId, + reservation.Id, + ReservationStayAmendmentOperation.CurrentRequestSchemaVersion, + noOpFingerprint, + reservation.Arrival, + reservation.Departure, + reservation.ExpectedArrivalTime, + reservation.ExpectedDepartureTime, + reservation.RequestedUnits + .Select(unit => unit.InventoryUnitId) + .ToArray(), + reservation.DetailsRevision, + "user:stay-operator", + reservation.DetailsRevision, + reservation.Version, + reservation.AllocationVersion!.Value, + Now.AddMinutes(2)).Value; + dbContext.StayAmendmentOperations.AddRange(terminal, noOp); + await dbContext.SaveChangesAsync(); + ReservationDataRightsExportContributor contributor = + new(dbContext, new TestScopeContext("tenant-a")); + CollectingSink sink = new(); + + DataRightsSubjectExportResult result = await contributor.ExportAsync( + CreateRequest(propertyId, reservation), + sink, + CancellationToken.None); + + Assert.Equal(DataRightsSubjectExportStatus.Succeeded, result.Status); + DataRightsExportRecord terminalExport = Assert.Single( + sink.Records, + record => record.RecordId == + DataRightsExportRecordIds.CreateDeterministicChild( + reservation.Id, + terminalOperationId.ToString("N"))); + Assert.Equal( + inventoryRequestId, + Assert.Single( + terminalExport.Fields, + field => field.FieldId == + "reservation.stay-amendment.inventory-request-id") + .Value.GetGuid()); + Assert.Equal( + "rejected", + Assert.Single( + terminalExport.Fields, + field => field.FieldId == + "reservation.stay-amendment.outcome") + .Value.GetString()); + DataRightsExportRecord noOpExport = Assert.Single( + sink.Records, + record => record.RecordId == + DataRightsExportRecordIds.CreateDeterministicChild( + reservation.Id, + noOpOperationId.ToString("N"))); + Assert.Equal( + JsonValueKind.Null, + Assert.Single( + noOpExport.Fields, + field => field.FieldId == + "reservation.stay-amendment.inventory-request-id") + .Value.ValueKind); + Assert.DoesNotContain( + terminalExport.Fields.Concat(noOpExport.Fields), + field => field.FieldId is + "reservation.stay-amendment.requested-by" or + "reservation.stay-amendment.last-reconciled-by"); + } + [Fact] public async Task Export_includes_minimum_anonymisation_owner_proof() { @@ -508,9 +805,9 @@ public void Export_schema_is_catalogue_bound_and_versioned() ReservationDataRightsExportSchema.Descriptor; Assert.Equal(ReservationDataRightsDiscoveryContributor.Owner, descriptor.OwnerKey); Assert.Equal("reservations.personal-data", descriptor.CatalogId); - Assert.Equal(16, descriptor.CatalogVersion); + Assert.Equal(19, descriptor.CatalogVersion); Assert.Equal("reservations.subject-export", descriptor.ExportSchemaId); - Assert.Equal(6, descriptor.ExportSchemaVersion); + Assert.Equal(9, descriptor.ExportSchemaVersion); Assert.NotEmpty(descriptor.FieldIds); } diff --git a/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Persistence/ReservationStayAmendmentPersistenceTests.cs b/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Persistence/ReservationStayAmendmentPersistenceTests.cs new file mode 100644 index 00000000..3f206481 --- /dev/null +++ b/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Persistence/ReservationStayAmendmentPersistenceTests.cs @@ -0,0 +1,635 @@ +namespace BunkFy.Modules.Reservations.Tests.Persistence; + +using BunkFy.Modules.Reservations.Application.Ports; +using BunkFy.Modules.Reservations.Contracts; +using BunkFy.Modules.Reservations.Domain.Aggregates; +using BunkFy.Modules.Reservations.Domain.DataRights; +using BunkFy.Modules.Reservations.Domain.StayAmendments; +using BunkFy.Modules.Reservations.Persistence; +using BunkFy.Modules.Reservations.Persistence.Repositories; +using Gma.Framework.Scoping; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage; +using Xunit; + +[Trait("Category", "Unit")] +public sealed class ReservationStayAmendmentPersistenceTests +{ + private static readonly Guid PropertyId = Guid.Parse( + "10000000-0000-0000-0000-000000000001"); + private static readonly DateTimeOffset RequestedAtUtc = new( + 2026, + 8, + 12, + 12, + 0, + 0, + TimeSpan.Zero); + + [Fact] + public void Model_is_scoped_concurrency_guarded_and_owned_by_management_journal() + { + using ReservationsDbContext dbContext = CreateInMemoryContext(); + IModel designModel = dbContext.GetService().Model; + IEntityType amendment = designModel.FindEntityType( + typeof(ReservationStayAmendmentOperation))!; + IEntityType management = designModel.FindEntityType( + typeof(ReservationManagementOperation))!; + + Assert.Equal("stay_amendment_operations", amendment.GetTableName()); + Assert.Equal( + [ + nameof(ReservationStayAmendmentOperation.ScopeId), + nameof(ReservationStayAmendmentOperation.ReservationId), + nameof(ReservationStayAmendmentOperation.Id) + ], + amendment.FindPrimaryKey()!.Properties.Select( + property => property.Name)); + Assert.NotEmpty(amendment.GetDeclaredQueryFilters()); + Assert.True(amendment.FindProperty( + nameof(ReservationStayAmendmentOperation.OperationVersion))! + .IsConcurrencyToken); + Assert.True(amendment.FindProperty( + nameof(ReservationStayAmendmentOperation.RequestedBy))! + .IsNullable); + Assert.Equal( + ReservationStayAmendmentOperation.RequestFingerprintLength, + amendment.FindProperty( + nameof(ReservationStayAmendmentOperation.RequestFingerprint))! + .GetMaxLength()); + Assert.True(amendment.FindProperty( + nameof(ReservationStayAmendmentOperation.RequestFingerprint))! + .IsFixedLength()); + Assert.Equal( + ReservationStayAmendmentOperation.TargetInventoryUnitIdsMaxLength, + amendment.FindProperty( + nameof(ReservationStayAmendmentOperation.TargetInventoryUnitIds))! + .GetMaxLength()); + Assert.Equal( + 0, + amendment.FindProperty( + nameof(ReservationStayAmendmentOperation.TargetExpectedArrivalTime))! + .GetPrecision()); + Assert.Equal( + 0, + amendment.FindProperty( + nameof(ReservationStayAmendmentOperation.TargetExpectedDepartureTime))! + .GetPrecision()); + + IForeignKey owner = Assert.Single( + amendment.GetForeignKeys(), + candidate => candidate.PrincipalEntityType == management); + Assert.True(owner.IsUnique); + Assert.True(owner.IsRequired); + Assert.Equal(DeleteBehavior.Cascade, owner.DeleteBehavior); + Assert.Equal( + [ + nameof(ReservationStayAmendmentOperation.ScopeId), + nameof(ReservationStayAmendmentOperation.ReservationId), + nameof(ReservationStayAmendmentOperation.Id) + ], + owner.Properties.Select(property => property.Name)); + Assert.Contains( + amendment.GetIndexes(), + index => index.Properties.Select(property => property.Name) + .SequenceEqual([ + nameof(ReservationStayAmendmentOperation.ScopeId), + nameof(ReservationStayAmendmentOperation.PropertyId), + nameof(ReservationStayAmendmentOperation.Outcome), + nameof(ReservationStayAmendmentOperation.UpdatedAtUtc), + nameof(ReservationStayAmendmentOperation.Id), + nameof(ReservationStayAmendmentOperation.ReservationId) + ])); + + string[] constraints = + [ + "CK_stay_amendment_operations_request", + "CK_stay_amendment_operations_target", + "CK_stay_amendment_operations_outcome", + "CK_stay_amendment_operations_timestamps", + "CK_stay_amendment_operations_reconciliation", + "CK_stay_amendment_operations_version" + ]; + Assert.All( + constraints, + constraint => Assert.Contains( + amendment.GetCheckConstraints(), + candidate => candidate.Name == constraint)); + IReadOnlyCheckConstraint requestConstraint = Assert.Single( + amendment.GetCheckConstraints(), + constraint => constraint.Name == + "CK_stay_amendment_operations_request"); + IReadOnlyCheckConstraint targetConstraint = Assert.Single( + amendment.GetCheckConstraints(), + constraint => constraint.Name == + "CK_stay_amendment_operations_target"); + Assert.Contains( + "\"RequestedBy\" IS NULL", + requestConstraint.Sql, + StringComparison.Ordinal); + Assert.Contains( + "\"TargetInventoryUnitIds\" IS NULL", + targetConstraint.Sql, + StringComparison.Ordinal); + Assert.Contains( + "\"TargetInventoryUnitIds\" IS NOT NULL", + targetConstraint.Sql, + StringComparison.Ordinal); + } + + [Fact] + public async Task Get_tracks_operation_version_for_unit_of_work_concurrency() + { + InMemoryDatabaseRoot databaseRoot = new(); + string databaseName = $"stay-amendment-concurrency-{Guid.NewGuid():N}"; + Guid operationId = Guid.Parse( + "20000000-0000-0000-0000-000000000001"); + Guid reservationId = Guid.Parse( + "30000000-0000-0000-0000-000000000001"); + + await using (ReservationsDbContext seed = CreateInMemoryContext( + databaseName, + databaseRoot)) + { + ReservationStayAmendmentOperationRepository repository = new(seed); + await repository.AddAsync( + CreatePending(operationId, reservationId, RequestedAtUtc), + CancellationToken.None); + await seed.SaveChangesAsync(); + } + + await using ReservationsDbContext firstContext = CreateInMemoryContext( + databaseName, + databaseRoot); + await using ReservationsDbContext competingContext = + CreateInMemoryContext(databaseName, databaseRoot); + ReservationStayAmendmentOperationRepository firstRepository = + new(firstContext); + ReservationStayAmendmentOperationRepository competingRepository = + new(competingContext); + ReservationStayAmendmentOperation first = Assert.IsType< + ReservationStayAmendmentOperation>( + await firstRepository.GetAsync( + PropertyId, + reservationId, + operationId, + CancellationToken.None)); + ReservationStayAmendmentOperation competing = Assert.IsType< + ReservationStayAmendmentOperation>( + await competingRepository.GetAsync( + PropertyId, + reservationId, + operationId, + CancellationToken.None)); + + Assert.Equal(EntityState.Unchanged, firstContext.Entry(first).State); + Assert.True(first.Reconcile( + expectedOperationVersion: 1, + "staff:first", + RequestedAtUtc.AddMinutes(5)).IsSuccess); + Assert.Equal(EntityState.Modified, firstContext.Entry(first).State); + Assert.Equal( + 1L, + firstContext.Entry(first).Property( + operation => operation.OperationVersion).OriginalValue); + Assert.Equal(2L, first.OperationVersion); + await firstContext.SaveChangesAsync(); + + Assert.True(competing.Reconcile( + expectedOperationVersion: 1, + "staff:competing", + RequestedAtUtc.AddMinutes(6)).IsSuccess); + await Assert.ThrowsAsync( + () => competingContext.SaveChangesAsync()); + } + + [Fact] + public async Task Recovery_query_is_bounded_exact_and_excludes_terminal_rows() + { + await using ReservationsDbContext dbContext = CreateInMemoryContext(); + ReservationStayAmendmentOperationRepository repository = new(dbContext); + Guid sharedOperationId = Guid.Parse( + "40000000-0000-0000-0000-000000000001"); + Guid firstReservationId = Guid.Parse( + "50000000-0000-0000-0000-000000000001"); + Guid secondReservationId = Guid.Parse( + "50000000-0000-0000-0000-000000000002"); + Guid thirdReservationId = Guid.Parse( + "50000000-0000-0000-0000-000000000003"); + ReservationStayAmendmentOperation[] expected = + [ + CreatePending( + sharedOperationId, + firstReservationId, + RequestedAtUtc), + CreatePending( + sharedOperationId, + secondReservationId, + RequestedAtUtc), + CreatePending( + Guid.Parse("40000000-0000-0000-0000-000000000002"), + thirdReservationId, + RequestedAtUtc), + CreateOutcomeUnknown( + Guid.Parse("40000000-0000-0000-0000-000000000003"), + Guid.Parse("50000000-0000-0000-0000-000000000004"), + RequestedAtUtc.AddHours(-2)), + CreateOutcomeUnknown( + Guid.Parse("40000000-0000-0000-0000-000000000004"), + Guid.Parse("50000000-0000-0000-0000-000000000005"), + RequestedAtUtc.AddHours(-1)) + ]; + foreach (ReservationStayAmendmentOperation operation in expected) + { + await repository.AddAsync(operation, CancellationToken.None); + } + + ReservationStayAmendmentOperation applied = CreateApplied( + Guid.Parse("40000000-0000-0000-0000-000000000005"), + Guid.Parse("50000000-0000-0000-0000-000000000006"), + RequestedAtUtc); + ReservationStayAmendmentOperation otherProperty = CreatePending( + Guid.Parse("40000000-0000-0000-0000-000000000006"), + Guid.Parse("50000000-0000-0000-0000-000000000007"), + RequestedAtUtc, + propertyId: Guid.Parse( + "10000000-0000-0000-0000-000000000002")); + await repository.AddAsync(applied, CancellationToken.None); + await repository.AddAsync(otherProperty, CancellationToken.None); + foreach (ReservationStayAmendmentOperation operation in expected + .Append(applied) + .Append(otherProperty)) + { + AddOrdinaryReservation( + dbContext, + operation.PropertyId, + operation.ReservationId, + operation.RequestedAtUtc); + } + + await dbContext.SaveChangesAsync(); + dbContext.ChangeTracker.Clear(); + + ReservationStayAmendmentRecoveryPageRecord firstPage = + await repository.ListRecoveryAsync( + PropertyId, + cursor: null, + pageSize: 2, + CancellationToken.None); + ReservationStayAmendmentRecoveryPageRecord secondPage = + await repository.ListRecoveryAsync( + PropertyId, + firstPage.NextCursor, + pageSize: 2, + CancellationToken.None); + ReservationStayAmendmentRecoveryPageRecord thirdPage = + await repository.ListRecoveryAsync( + PropertyId, + secondPage.NextCursor, + pageSize: 2, + CancellationToken.None); + + Assert.Equal( + expected.Select(operation => (operation.Id, operation.ReservationId)), + firstPage.Operations + .Concat(secondPage.Operations) + .Concat(thirdPage.Operations) + .Select(operation => (operation.Id, operation.ReservationId))); + Assert.NotNull(firstPage.NextCursor); + Assert.Equal(secondReservationId, firstPage.NextCursor!.ReservationId); + Assert.NotNull(secondPage.NextCursor); + Assert.Equal( + ReservationStayAmendmentOperationOutcome.OutcomeUnknown, + secondPage.NextCursor!.Outcome); + Assert.Null(thirdPage.NextCursor); + Assert.Empty(dbContext.ChangeTracker.Entries< + ReservationStayAmendmentOperation>()); + } + + [Fact] + public async Task Recovery_query_does_not_disclose_restricted_or_anonymised_reservations() + { + await using ReservationsDbContext dbContext = CreateInMemoryContext(); + ReservationStayAmendmentOperationRepository repository = new(dbContext); + ReservationStayAmendmentOperation visible = CreatePending( + Guid.Parse("90000000-0000-0000-0000-000000000001"), + Guid.Parse("91000000-0000-0000-0000-000000000001"), + RequestedAtUtc); + ReservationStayAmendmentOperation restricted = CreatePending( + Guid.Parse("90000000-0000-0000-0000-000000000002"), + Guid.Parse("91000000-0000-0000-0000-000000000002"), + RequestedAtUtc); + ReservationStayAmendmentOperation anonymised = CreatePending( + Guid.Parse("90000000-0000-0000-0000-000000000003"), + Guid.Parse("91000000-0000-0000-0000-000000000003"), + RequestedAtUtc); + await repository.AddAsync(visible, CancellationToken.None); + await repository.AddAsync(restricted, CancellationToken.None); + await repository.AddAsync(anonymised, CancellationToken.None); + + AddOrdinaryReservation( + dbContext, + PropertyId, + visible.ReservationId, + RequestedAtUtc); + (_, ReservationProcessingRestrictionProjection restriction) = + AddOrdinaryReservation( + dbContext, + PropertyId, + restricted.ReservationId, + RequestedAtUtc); + Assert.True(restriction.Apply( + expectedRevision: 0, + ReservationProcessingRestrictionContract.CurrentVersion, + RequestedAtUtc.AddMinutes(1)).IsSuccess); + (Reservation anonymisedReservation, _) = AddOrdinaryReservation( + dbContext, + PropertyId, + anonymised.ReservationId, + RequestedAtUtc); + dbContext.Entry(anonymisedReservation) + .Property(reservation => reservation.IsAnonymised) + .CurrentValue = true; + await dbContext.SaveChangesAsync(); + dbContext.ChangeTracker.Clear(); + + ReservationStayAmendmentRecoveryPageRecord page = + await repository.ListRecoveryAsync( + PropertyId, + cursor: null, + pageSize: 10, + CancellationToken.None); + + Assert.Equal(visible.Id, Assert.Single(page.Operations).Id); + Assert.Null(page.NextCursor); + } + + [Theory] + [InlineData("PostgreSql")] + [InlineData("SqlServer")] + public void Exact_status_lookup_correlates_ordinary_visibility_in_one_query( + string provider) + { + using ReservationsDbContext dbContext = CreateRelationalContext(provider); + Guid reservationId = Guid.Parse( + "92000000-0000-0000-0000-000000000001"); + Guid operationId = Guid.Parse( + "93000000-0000-0000-0000-000000000001"); + + string sql = ReservationStayAmendmentOperationRepository + .BuildVisibleQuery( + dbContext, + PropertyId, + reservationId, + operationId) + .ToQueryString(); + + Assert.Contains( + "stay_amendment_operations", + sql, + StringComparison.OrdinalIgnoreCase); + Assert.Contains("reservations", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains( + "reservation_processing_restriction_state", + sql, + StringComparison.OrdinalIgnoreCase); + Assert.Contains("EXISTS", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("IsAnonymised", sql, StringComparison.Ordinal); + Assert.Contains("IsRestricted", sql, StringComparison.Ordinal); + } + + [Theory] + [InlineData("PostgreSql")] + [InlineData("SqlServer")] + public void Recovery_keyset_and_lookahead_translate_for_relational_providers( + string provider) + { + using ReservationsDbContext dbContext = + CreateRelationalContext(provider); + ReservationStayAmendmentRecoveryCursorRecord cursor = new( + ReservationStayAmendmentOperationOutcome.Pending, + RequestedAtUtc, + Guid.Parse("60000000-0000-0000-0000-000000000001"), + Guid.Parse("70000000-0000-0000-0000-000000000001")); + + string sql = ReservationStayAmendmentOperationRepository + .BuildRecoveryQuery( + dbContext, + PropertyId, + cursor, + pageSize: 100) + .ToQueryString(); + + Assert.Contains( + "stay_amendment_operations", + sql, + StringComparison.OrdinalIgnoreCase); + Assert.Contains("ScopeId", sql, StringComparison.Ordinal); + Assert.Contains("PropertyId", sql, StringComparison.Ordinal); + Assert.Contains("Outcome", sql, StringComparison.Ordinal); + Assert.Contains("UpdatedAtUtc", sql, StringComparison.Ordinal); + Assert.Contains("ReservationId", sql, StringComparison.Ordinal); + Assert.Contains("ORDER BY", sql, StringComparison.OrdinalIgnoreCase); + Assert.True( + sql.Contains("LIMIT", StringComparison.OrdinalIgnoreCase) || + sql.Contains("TOP", StringComparison.OrdinalIgnoreCase) || + sql.Contains("FETCH", StringComparison.OrdinalIgnoreCase)); + Assert.DoesNotContain( + nameof(Reservation.PrimaryGuestName), + sql, + StringComparison.Ordinal); + Assert.DoesNotContain( + nameof(Reservation.Email), + sql, + StringComparison.Ordinal); + Assert.DoesNotContain( + nameof(Reservation.Phone), + sql, + StringComparison.Ordinal); + } + + [Theory] + [InlineData(0)] + [InlineData(101)] + public async Task Recovery_repository_rejects_unbounded_page_sizes( + int pageSize) + { + await using ReservationsDbContext dbContext = CreateInMemoryContext(); + ReservationStayAmendmentOperationRepository repository = new(dbContext); + + await Assert.ThrowsAsync( + () => repository.ListRecoveryAsync( + PropertyId, + cursor: null, + pageSize, + CancellationToken.None)); + } + + [Fact] + public async Task Recovery_repository_rejects_incomplete_cursor() + { + await using ReservationsDbContext dbContext = CreateInMemoryContext(); + ReservationStayAmendmentOperationRepository repository = new(dbContext); + ReservationStayAmendmentRecoveryCursorRecord cursor = new( + ReservationStayAmendmentOperationOutcome.Pending, + default, + Guid.Empty, + Guid.Empty); + + await Assert.ThrowsAsync( + () => repository.ListRecoveryAsync( + PropertyId, + cursor, + pageSize: 10, + CancellationToken.None)); + } + + private static ReservationStayAmendmentOperation CreatePending( + Guid operationId, + Guid reservationId, + DateTimeOffset requestedAtUtc, + Guid? propertyId = null) => + ReservationStayAmendmentOperation.CreatePending( + operationId, + "tenant-a", + propertyId ?? PropertyId, + reservationId, + Guid.NewGuid(), + ReservationStayAmendmentOperation.CurrentRequestSchemaVersion, + new string('a', ReservationStayAmendmentOperation.RequestFingerprintLength), + new DateOnly(2026, 9, 1), + new DateOnly(2026, 9, 3), + new TimeOnly(15, 0), + new TimeOnly(10, 0), + [Guid.Parse("80000000-0000-0000-0000-000000000001")], + expectedDetailsRevision: 1, + actorId: "staff:test", + requestedAtUtc).Value; + + private static ReservationStayAmendmentOperation CreateApplied( + Guid operationId, + Guid reservationId, + DateTimeOffset requestedAtUtc) => + ReservationStayAmendmentOperation.CreateAppliedNoOp( + operationId, + "tenant-a", + PropertyId, + reservationId, + ReservationStayAmendmentOperation.CurrentRequestSchemaVersion, + new string('b', ReservationStayAmendmentOperation.RequestFingerprintLength), + new DateOnly(2026, 9, 1), + new DateOnly(2026, 9, 3), + new TimeOnly(15, 0), + new TimeOnly(10, 0), + [Guid.Parse("80000000-0000-0000-0000-000000000001")], + expectedDetailsRevision: 1, + "staff:test", + resultingDetailsRevision: 1, + resultingReservationVersion: 2, + resultingAllocationVersion: 1, + requestedAtUtc).Value; + + private static ReservationStayAmendmentOperation CreateOutcomeUnknown( + Guid operationId, + Guid reservationId, + DateTimeOffset requestedAtUtc) => + ReservationStayAmendmentOperation.CreateOutcomeUnknown( + operationId, + "tenant-a", + PropertyId, + reservationId, + ReservationStayAmendmentOperation.LegacyRequestSchemaVersion, + new string('c', ReservationStayAmendmentOperation.RequestFingerprintLength), + expectedDetailsRevision: 1, + requestedAtUtc).Value; + + private static ( + Reservation Reservation, + ReservationProcessingRestrictionProjection Restriction) + AddOrdinaryReservation( + ReservationsDbContext dbContext, + Guid propertyId, + Guid reservationId, + DateTimeOffset createdAtUtc) + { + Reservation reservation = Reservation.Create( + reservationId, + "tenant-a", + propertyId, + Guid.NewGuid(), + new DateOnly(2026, 9, 1), + new DateOnly(2026, 9, 3), + [Guid.NewGuid()], + "Visible guest", + email: null, + phone: null, + guestCount: 1, + ReservationSource.Direct, + sourceSystem: null, + sourceReference: null, + notes: null, + Guid.NewGuid(), + Guid.NewGuid(), + ReservationDetailsChangeOrigin.Staff, + "staff:test", + initialAdapterConnectionId: null, + initialExternalOperationId: null, + Guid.NewGuid(), + createdAtUtc).Value; + ReservationProcessingRestrictionProjection restriction = + ReservationProcessingRestrictionProjection.Create( + "tenant-a", + propertyId, + reservationId, + ReservationProcessingRestrictionContract.CurrentVersion, + createdAtUtc).Value; + dbContext.Reservations.Add(reservation); + dbContext.ProcessingRestrictionProjections.Add(restriction); + return (reservation, restriction); + } + + private static ReservationsDbContext CreateInMemoryContext( + string? databaseName = null, + InMemoryDatabaseRoot? databaseRoot = null) + { + DbContextOptionsBuilder builder = new(); + builder.UseInMemoryDatabase( + databaseName ?? $"stay-amendments-{Guid.NewGuid():N}", + databaseRoot ?? new InMemoryDatabaseRoot()); + return new(builder.Options, new TestScopeContext()); + } + + private static ReservationsDbContext CreateRelationalContext(string provider) + { + DbContextOptionsBuilder builder = new(); + switch (provider) + { + case "PostgreSql": + builder.UseNpgsql( + "Host=localhost;Database=bunkfy_query_shape;" + + "Username=query_shape;Password=not-used"); + break; + case "SqlServer": + builder.UseSqlServer( + "Server=localhost;Database=BunkFyQueryShape;" + + "User Id=query-shape;Password=not-used;" + + "TrustServerCertificate=True"); + break; + default: + throw new ArgumentOutOfRangeException( + nameof(provider), + provider, + "Unsupported query-shape test provider."); + } + + return new(builder.Options, new TestScopeContext()); + } + + private sealed class TestScopeContext : IScopeContext + { + public bool IsEnabled => true; + public string ScopeId => "tenant-a"; + } +} diff --git a/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Persistence/ReservationsModelTests.cs b/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Persistence/ReservationsModelTests.cs index 749390d7..8fcaa177 100644 --- a/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Persistence/ReservationsModelTests.cs +++ b/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Persistence/ReservationsModelTests.cs @@ -15,6 +15,25 @@ namespace BunkFy.Modules.Reservations.Tests; [Trait("Category", "Unit")] public sealed class ReservationsModelTests { + [Fact] + public void Tenant_owned_operation_history_and_guest_projection_are_query_scoped() + { + using ReservationsDbContext dbContext = CreateDbContext(); + + Type[] scopedTypes = + [ + typeof(ReservationManagementOperation), + typeof(ReservationExternalOperation), + typeof(ReservationDetailsHistoryEntry), + typeof(ReservationGuestProfileProjection) + ]; + + Assert.All( + scopedTypes, + type => Assert.NotEmpty( + dbContext.Model.FindEntityType(type)!.GetDeclaredQueryFilters())); + } + [Fact] public void Tenant_revision_is_scope_keyed_and_concurrency_guarded() { @@ -256,6 +275,17 @@ public void Management_operation_journal_is_scoped_constrained_and_reservation_o constraint => Assert.Contains( operation.GetCheckConstraints(), candidate => candidate.Name == constraint)); + Assert.Contains( + "1, 2, 3, 4, 5, 6, 7", + operation.GetCheckConstraints().Single(constraint => + constraint.Name == "CK_management_operations_kind").Sql, + StringComparison.Ordinal); + Assert.Contains( + "IN (6, 7)", + operation.GetCheckConstraints().Single(constraint => + constraint.Name == + "CK_management_operations_request_fingerprint").Sql, + StringComparison.Ordinal); } [Fact] diff --git a/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Persistence/ReservationsTenantTerminationContributorTests.cs b/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Persistence/ReservationsTenantTerminationContributorTests.cs index f5a52afa..6b9a9f13 100644 --- a/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Persistence/ReservationsTenantTerminationContributorTests.cs +++ b/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Persistence/ReservationsTenantTerminationContributorTests.cs @@ -9,6 +9,7 @@ namespace BunkFy.Modules.Reservations.Tests.Persistence; using BunkFy.Modules.Reservations.Domain.GuestRecords; using BunkFy.Modules.Reservations.Domain.Models; using BunkFy.Modules.Reservations.Domain.Retention; +using BunkFy.Modules.Reservations.Domain.StayAmendments; using BunkFy.Modules.Reservations.Persistence; using BunkFy.Modules.Reservations.Persistence.Repositories; using BunkFy.Modules.Reservations.Persistence.TenantTermination; @@ -39,6 +40,8 @@ public sealed class ReservationsTenantTerminationContributorTests Guid.Parse("50000000-0000-0000-0000-000000000001"); private static readonly Guid OtherPropertyId = Guid.Parse("50000000-0000-0000-0000-000000000002"); + private static readonly Guid StayInventoryRequestId = + Guid.Parse("90000000-0000-0000-0000-000000000001"); private static readonly DateTimeOffset Now = ReservationsTenantTerminationTestData.Now; private static readonly DateTimeOffset FrozenAtUtc = @@ -95,6 +98,18 @@ record => Field( "reservations.staff-attribution") .GetProperty("lastDetailsActorId") .GetString() == "user:owner"); + DataRightsExportRecord pendingReservation = Assert.Single( + first.Records, + record => record.RecordType == + ReservationsTenantTerminationMetadata.ReservationRecordType && + Field(record, "reservations.booking-state") + .GetProperty("pendingInventoryAmendmentRequestId") + .ValueKind != JsonValueKind.Null); + Assert.Equal( + StayInventoryRequestId, + Field(pendingReservation, "reservations.booking-state") + .GetProperty("pendingInventoryAmendmentRequestId") + .GetGuid()); DataRightsExportRecord managementOperation = Assert.Single( first.Records, record => record.RecordType == @@ -117,6 +132,30 @@ record => record.RecordType == Field(managementOperation, "reservations.management-operation") .GetProperty("requestFingerprint") .GetString()); + DataRightsExportRecord stayAmendmentOperation = Assert.Single( + first.Records, + record => record.RecordType == + ReservationsTenantTerminationMetadata + .StayAmendmentOperationRecordType); + Assert.Equal( + "pending", + Field( + stayAmendmentOperation, + "reservations.stay-amendment-operation") + .GetProperty("outcome") + .GetString()); + Assert.Equal( + StayInventoryRequestId, + Field( + stayAmendmentOperation, + "reservations.stay-amendment-operation") + .GetProperty("inventoryRequestId") + .GetGuid()); + Assert.Equal( + "user:stay-operator", + Field(stayAmendmentOperation, "reservations.staff-attribution") + .GetProperty("requestedBy") + .GetString()); DataRightsExportRecord guestRecordLinkProcess = Assert.Single( first.Records, record => record.RecordType == @@ -134,6 +173,11 @@ record => record.RecordType == Assert.Equal( ReservationsTenantTerminationMetadata.ExportSchemaId, contributor.ExportDescriptor.ExportSchemaId); + Assert.Equal(7, contributor.ExportDescriptor.CatalogVersion); + Assert.Equal(8, contributor.ExportDescriptor.ExportSchemaVersion); + Assert.Equal( + 19, + ReservationsTenantTerminationMetadata.PersonalDataCatalogVersion); Assert.Equal( ReservationsTenantTerminationMetadata.ExportFieldIds .OrderBy(field => field, StringComparer.Ordinal), @@ -152,6 +196,112 @@ await contributor.ExportAsync( replay.Records.Select(Identity).ToArray()); } + [Fact] + public async Task Tenant_export_disambiguates_reservation_local_stay_amendment_ids_and_preserves_unknowns() + { + MutableFenceReader fences = new(); + await using ReservationsDbContext context = CreateContext(fences); + Reservation pendingReservation = + ReservationsTenantTerminationTestData.CreateReservation( + PropertyId, + "Pending Guest"); + Reservation unknownReservation = + ReservationsTenantTerminationTestData.CreateReservation( + OtherPropertyId, + "Legacy Guest"); + Guid sharedOperationId = Guid.NewGuid(); + Guid pendingInventoryRequestId = Guid.NewGuid(); + AddStayAmendmentOperation( + context, + pendingReservation, + sharedOperationId, + pendingInventoryRequestId, + outcomeUnknown: false); + AddStayAmendmentOperation( + context, + unknownReservation, + sharedOperationId, + inventoryRequestId: null, + outcomeUnknown: true); + await context.SaveChangesAsync(); + context.ChangeTracker.Clear(); + fences.Current = FrozenFence(); + ReservationsTenantTerminationContributor contributor = new( + context, + new TestScopeContext(), + new TestClock(), + fences); + CollectingSink sink = new(); + + TenantTerminationContributionResult result = await contributor + .ExportAsync(Request(), sink, CancellationToken.None); + + Assert.Equal(TenantTerminationContributionStatus.Completed, result.Status); + DataRightsExportRecord[] records = sink.Records + .Where(record => record.RecordType == + ReservationsTenantTerminationMetadata + .StayAmendmentOperationRecordType) + .ToArray(); + Assert.Equal(2, records.Length); + Guid[] expectedReservationOrder = + [pendingReservation.Id, unknownReservation.Id]; + Array.Sort(expectedReservationOrder); + Assert.Equal( + expectedReservationOrder, + records + .Select(record => + Field(record, "reservations.reservation-id").GetGuid()) + .ToArray()); + Assert.Equal( + expectedReservationOrder + .Select(reservationId => + DataRightsExportRecordIds.CreateDeterministicChild( + reservationId, + sharedOperationId.ToString("N"))) + .ToArray(), + records.Select(record => record.RecordId).ToArray()); + Assert.Equal(2, records.Select(record => record.RecordId).Distinct().Count()); + Assert.All( + records, + record => Assert.Equal( + sharedOperationId, + Field(record, "reservations.record-id").GetGuid())); + DataRightsExportRecord pending = Assert.Single( + records, + record => Field( + record, + "reservations.stay-amendment-operation") + .GetProperty("outcome") + .GetString() == "pending"); + Assert.Equal( + pendingInventoryRequestId, + Field(pending, "reservations.stay-amendment-operation") + .GetProperty("inventoryRequestId") + .GetGuid()); + DataRightsExportRecord unknown = Assert.Single( + records, + record => Field( + record, + "reservations.stay-amendment-operation") + .GetProperty("outcome") + .GetString() == "outcome-unknown"); + JsonElement unknownState = Field( + unknown, + "reservations.stay-amendment-operation"); + Assert.Equal(JsonValueKind.Null, unknownState.GetProperty("targetArrival").ValueKind); + Assert.Equal( + JsonValueKind.Null, + unknownState.GetProperty("targetInventoryUnitIds").ValueKind); + Assert.Equal( + JsonValueKind.Null, + unknownState.GetProperty("inventoryRequestId").ValueKind); + Assert.Equal( + JsonValueKind.Null, + Field(unknown, "reservations.staff-attribution") + .GetProperty("requestedBy") + .ValueKind); + } + [Fact] public async Task Export_retries_without_records_for_a_different_fence() { @@ -398,6 +548,56 @@ await Assert.ThrowsAsync( messageFailure.Failure); } + [Fact] + public async Task Destroy_counts_stay_amendment_child_separately_from_its_parent() + { + MutableFenceReader fences = new(); + await using ReservationsDbContext context = CreateContext(fences); + Reservation reservation = + ReservationsTenantTerminationTestData.CreateReservation( + PropertyId, + "Destroy Guest"); + reservation.ClearDomainEvents(); + AddStayAmendmentOperation( + context, + reservation, + Guid.NewGuid(), + Guid.NewGuid(), + outcomeUnknown: false); + await context.SaveChangesAsync(); + context.ChangeTracker.Clear(); + fences.Current = FrozenFence(); + ReservationsTenantTerminationContributor contributor = new( + context, + new TestScopeContext(), + new TestClock(Now.AddHours(1)), + fences); + TenantTerminationContributionRequest request = DestroyRequest(); + + TenantTerminationContributionResult result = await contributor + .ExecuteAsync(request, CancellationToken.None); + for (int attempt = 1; + result.Status == TenantTerminationContributionStatus.RetryRequired && + attempt < 100; + attempt++) + { + result = await contributor.ExecuteAsync( + request, + CancellationToken.None); + } + + Assert.Equal(TenantTerminationContributionStatus.Completed, result.Status); + Assert.Equal(4, result.AffectedCount); + Assert.Equal( + result.AffectedCount, + (await context.TenantDestroyReceipts.SingleAsync()) + .RemovedRecordCount); + Assert.Empty(await context.StayAmendmentOperations.ToListAsync()); + Assert.Empty(await context.ManagementOperations.ToListAsync()); + Assert.Empty(await context.RequestedInventoryUnits.ToListAsync()); + Assert.Empty(await context.Reservations.ToListAsync()); + } + [Fact] public void Destroy_progress_rejects_a_batch_above_the_persisted_bound() { @@ -455,6 +655,7 @@ private static void SeedGraph(ReservationsDbContext context) Guid externalOperationId = Guid.NewGuid(); Assert.True(reservation.BeginAllocationAmendment( Guid.NewGuid(), + StayInventoryRequestId, new string('a', Reservation.RequestFingerprintLength), reservation.Arrival, reservation.Departure.AddDays(1), @@ -518,18 +719,38 @@ private static void SeedGraph(ReservationsDbContext context) reservation.Version, ErrorCode: null, Now.AddMinutes(4)))); + Guid stayAmendmentOperationId = Guid.NewGuid(); context.ManagementOperations.Add(new ReservationManagementOperation( new ReservationManagementOperationRecord( - Guid.NewGuid(), + stayAmendmentOperationId, TenantId, PropertyId, reservation.Id, - ReservationManagementOperationKind.InventoryAmendment, + ReservationManagementOperationKind.StayAmendment, ExpectedVersion: null, reservation.DetailsRevision, BusinessDate: null, Now.AddMinutes(5), Digest))); + context.StayAmendmentOperations.Add( + ReservationStayAmendmentOperation.CreatePending( + stayAmendmentOperationId, + TenantId, + PropertyId, + reservation.Id, + StayInventoryRequestId, + ReservationStayAmendmentOperation.CurrentRequestSchemaVersion, + Digest, + reservation.Arrival, + reservation.Departure.AddDays(1), + reservation.ExpectedArrivalTime, + reservation.ExpectedDepartureTime, + reservation.RequestedUnits + .Select(unit => unit.InventoryUnitId) + .ToArray(), + reservation.DetailsRevision, + "user:stay-operator", + Now.AddMinutes(5)).Value); context.ArrivalReminders.Add(ReservationArrivalReminder.Create( Guid.NewGuid(), TenantId, @@ -740,6 +961,57 @@ private static void SeedRetentionProof(ReservationsDbContext context) context.RetentionAnonymisationReceipts.Add(receipt); } + private static void AddStayAmendmentOperation( + ReservationsDbContext context, + Reservation reservation, + Guid operationId, + Guid? inventoryRequestId, + bool outcomeUnknown) + { + context.Reservations.Add(reservation); + context.ManagementOperations.Add(new ReservationManagementOperation( + new ReservationManagementOperationRecord( + operationId, + TenantId, + reservation.PropertyId, + reservation.Id, + ReservationManagementOperationKind.StayAmendment, + ExpectedVersion: null, + reservation.DetailsRevision, + BusinessDate: null, + Now, + Digest))); + ReservationStayAmendmentOperation operation = outcomeUnknown + ? ReservationStayAmendmentOperation.CreateOutcomeUnknown( + operationId, + TenantId, + reservation.PropertyId, + reservation.Id, + ReservationStayAmendmentOperation.LegacyRequestSchemaVersion, + Digest, + reservation.DetailsRevision, + Now).Value + : ReservationStayAmendmentOperation.CreatePending( + operationId, + TenantId, + reservation.PropertyId, + reservation.Id, + inventoryRequestId!.Value, + ReservationStayAmendmentOperation.CurrentRequestSchemaVersion, + Digest, + reservation.Arrival, + reservation.Departure.AddDays(1), + reservation.ExpectedArrivalTime, + reservation.ExpectedDepartureTime, + reservation.RequestedUnits + .Select(unit => unit.InventoryUnitId) + .ToArray(), + reservation.DetailsRevision, + "user:stay-operator", + Now).Value; + context.StayAmendmentOperations.Add(operation); + } + private static WorkspaceTerminationFenceSnapshot FrozenFence() => new( ProcessId, @@ -804,6 +1076,7 @@ await context.RequestedInventoryUnits.AnyAsync() || await context.ReservationDetailsHistory.AnyAsync() || await context.ArrivalReminders.AnyAsync() || await context.ExternalOperations.AnyAsync() || + await context.StayAmendmentOperations.AnyAsync() || await context.ManagementOperations.AnyAsync(); private static JsonElement Field( diff --git a/tests/Integration.Tests/Reservations/ReservationDataRightsIntegrationTests.cs b/tests/Integration.Tests/Reservations/ReservationDataRightsIntegrationTests.cs index 80ea2854..61288e7a 100644 --- a/tests/Integration.Tests/Reservations/ReservationDataRightsIntegrationTests.cs +++ b/tests/Integration.Tests/Reservations/ReservationDataRightsIntegrationTests.cs @@ -1,5 +1,8 @@ namespace Integration.Tests; +using System.Globalization; +using System.Security.Cryptography; +using System.Text; using BunkFy.DataGovernance; using BunkFy.Modules.DataRights.Application.Commands; using BunkFy.Modules.DataRights.Application.Models; @@ -17,6 +20,7 @@ namespace Integration.Tests; using BunkFy.Modules.Reservations.Domain.Aggregates; using BunkFy.Modules.Reservations.Domain.DataRights; using BunkFy.Modules.Reservations.Domain.Models; +using BunkFy.Modules.Reservations.Domain.StayAmendments; using BunkFy.Modules.Reservations.Persistence; using BunkFy.Modules.Workspaces.Contracts; using BunkFy.Modules.Workspaces.Persistence; @@ -104,6 +108,263 @@ INSERT INTO reservations.property_projection field => field.FieldId == "reservation.guest.email").Value.GetString()); } + [DockerFact] + [Trait("Category", "Docker")] + [Trait("Category", "Integration")] + public async Task Export_uses_one_snapshot_when_reservation_changes_after_the_first_record() + { + await using PostgreSqlContainer postgreSql = new PostgreSqlBuilder("postgres:16-alpine") + .WithDatabase("bunkfy_reservation_data_rights_barrier_tests") + .Build(); + await postgreSql.StartAsync(); + + Guid propertyId = Guid.NewGuid(); + Reservation reservation = CreateReservation(propertyId); + using ServiceProvider provider = CreatePersistenceProvider(postgreSql.GetConnectionString()); + using IServiceScope exportScope = provider.CreateScope(); + ReservationsDbContext exportDbContext = + exportScope.ServiceProvider.GetRequiredService(); + await exportDbContext.Database.MigrateAsync(); + await exportDbContext.Database.ExecuteSqlInterpolatedAsync( + $""" + INSERT INTO reservations.property_projection + ("Id", "ScopeId", "TimeZoneId", "IsActive", "IsKnown", + "ProcessingStatus", "TopologySourceVersion", "PolicySourceVersion") + VALUES + ({propertyId}, {"tenant-a"}, {"UTC"}, {true}, {true}, {1}, {1L}, {0L}); + """); + exportDbContext.Reservations.Add(reservation); + await exportDbContext.SaveChangesAsync(); + + IDataRightsSubjectExportContributor exporter = exportScope.ServiceProvider + .GetServices() + .Single(contributor => contributor.OwnerKey == "reservations"); + BlockingFirstWriteSink sink = new(); + Task exportTask = exporter.ExportAsync( + new( + "tenant-a", + DataRightsCaseType.GuestRights, + propertyId, + new DataRightsSubjectCoordinate( + "reservations", + "reservation", + reservation.Id, + reservation.Version)), + sink, + CancellationToken.None); + await sink.FirstWriteReached; + + try + { + using IServiceScope mutationScope = provider.CreateScope(); + ReservationsDbContext mutationDbContext = mutationScope + .ServiceProvider.GetRequiredService(); + int changed = await mutationDbContext.Database + .ExecuteSqlInterpolatedAsync( + $""" + UPDATE reservations.reservations + SET "Version" = "Version" + 1, + "UpdatedAtUtc" = {Now.AddMinutes(1)} + WHERE "ScopeId" = {"tenant-a"} + AND "PropertyId" = {propertyId} + AND "Id" = {reservation.Id}; + """); + Assert.Equal(1, changed); + } + finally + { + sink.Release(); + } + + DataRightsSubjectExportResult result = await exportTask; + + Assert.Equal(DataRightsSubjectExportStatus.Succeeded, result.Status); + DataRightsExportRecord reservationRecord = Assert.Single( + sink.Records, + record => record.RecordType == "reservation"); + Assert.Equal(reservation.Version, reservationRecord.RecordVersion); + } + + [DockerFact] + [Trait("Category", "Docker")] + [Trait("Category", "Integration")] + public async Task Export_excludes_child_only_writes_committed_after_snapshot_selection() + { + await using PostgreSqlContainer postgreSql = new PostgreSqlBuilder("postgres:16-alpine") + .WithDatabase("bunkfy_reservation_data_rights_child_snapshot_tests") + .Build(); + await postgreSql.StartAsync(); + + Guid propertyId = Guid.NewGuid(); + Reservation reservation = CreateReservation(propertyId); + Assert.True(reservation.ConfirmAllocation( + reservation.AllocationRequestId, + Guid.NewGuid(), + allocationVersion: 1, + Guid.NewGuid(), + Now.AddMinutes(1)).IsSuccess); + reservation.ClearDomainEvents(); + + using ServiceProvider provider = CreatePersistenceProvider(postgreSql.GetConnectionString()); + using IServiceScope exportScope = provider.CreateScope(); + ReservationsDbContext exportDbContext = + exportScope.ServiceProvider.GetRequiredService(); + await exportDbContext.Database.MigrateAsync(); + await exportDbContext.Database.ExecuteSqlInterpolatedAsync( + $""" + INSERT INTO reservations.property_projection + ("Id", "ScopeId", "TimeZoneId", "IsActive", "IsKnown", + "ProcessingStatus", "TopologySourceVersion", "PolicySourceVersion") + VALUES + ({propertyId}, {"tenant-a"}, {"UTC"}, {true}, {true}, {1}, {1L}, {0L}); + """); + exportDbContext.Reservations.Add(reservation); + await exportDbContext.SaveChangesAsync(); + + IDataRightsSubjectExportContributor exporter = exportScope.ServiceProvider + .GetServices() + .Single(contributor => contributor.OwnerKey == "reservations"); + BlockingFirstWriteSink sink = new(); + Task exportTask = exporter.ExportAsync( + new( + "tenant-a", + DataRightsCaseType.GuestRights, + propertyId, + new DataRightsSubjectCoordinate( + "reservations", + "reservation", + reservation.Id, + reservation.Version)), + sink, + CancellationToken.None); + await sink.FirstWriteReached; + + Guid operationId = Guid.NewGuid(); + Guid[] inventoryUnitIds = reservation.RequestedUnits + .Select(unit => unit.InventoryUnitId) + .ToArray(); + string fingerprint = StayAmendmentFingerprint( + reservation, + operationId, + inventoryUnitIds); + DateTimeOffset requestedAtUtc = Now.AddMinutes(2); + try + { + using IServiceScope mutationScope = provider.CreateScope(); + ReservationsDbContext mutationDbContext = + mutationScope.ServiceProvider.GetRequiredService(); + Reservation mutableReservation = await mutationDbContext.Reservations + .Include(candidate => candidate.RequestedUnits) + .SingleAsync(candidate => candidate.Id == reservation.Id); + Assert.Equal( + ReservationDetailsChangeOutcome.Unchanged, + mutableReservation.BeginAllocationAmendment( + operationId, + Guid.NewGuid(), + fingerprint, + mutableReservation.Arrival, + mutableReservation.Departure, + inventoryUnitIds, + mutableReservation.PrimaryGuestName, + mutableReservation.Email, + mutableReservation.Phone, + mutableReservation.GuestCount, + mutableReservation.Notes, + mutableReservation.DetailsRevision, + ReservationDetailsChangeOrigin.Staff, + "user:stay-operator", + adapterConnectionId: null, + externalOperationId: null, + operationId, + Guid.NewGuid(), + requestedAtUtc, + mutableReservation.ExpectedArrivalTime, + mutableReservation.ExpectedDepartureTime).Value); + Assert.True( + mutableReservation.AdvanceStayAmendmentEvidenceCoordinate( + requestedAtUtc).IsSuccess); + Assert.Equal( + EntityState.Modified, + mutationDbContext.Entry(mutableReservation).State); + Assert.True( + mutationDbContext.Entry(mutableReservation) + .Property(candidate => candidate.Version) + .IsModified); + IReservationManagementOperationRepository managementOperations = + mutationScope.ServiceProvider + .GetRequiredService(); + await managementOperations.AddAsync( + new ReservationManagementOperationRecord( + operationId, + "tenant-a", + propertyId, + reservation.Id, + ReservationManagementOperationKind.StayAmendment, + ExpectedVersion: null, + mutableReservation.DetailsRevision, + BusinessDate: null, + requestedAtUtc, + fingerprint), + CancellationToken.None); + mutationDbContext.Set().Add( + ReservationStayAmendmentOperation.CreateAppliedNoOp( + operationId, + "tenant-a", + propertyId, + reservation.Id, + ReservationStayAmendmentOperation.CurrentRequestSchemaVersion, + fingerprint, + mutableReservation.Arrival, + mutableReservation.Departure, + mutableReservation.ExpectedArrivalTime, + mutableReservation.ExpectedDepartureTime, + inventoryUnitIds, + mutableReservation.DetailsRevision, + "user:stay-operator", + mutableReservation.DetailsRevision, + mutableReservation.Version, + mutableReservation.AllocationVersion!.Value, + requestedAtUtc).Value); + await mutationDbContext.SaveChangesAsync(); + } + finally + { + sink.Release(); + } + + DataRightsSubjectExportResult result = await exportTask; + Assert.Equal(DataRightsSubjectExportStatus.Succeeded, result.Status); + Assert.DoesNotContain( + sink.Records, + record => record.RecordType == "reservation-stay-amendment-operation"); + + using IServiceScope verificationScope = provider.CreateScope(); + ReservationsDbContext verificationDbContext = + verificationScope.ServiceProvider.GetRequiredService(); + Assert.True(await verificationDbContext + .Set() + .AnyAsync(operation => operation.Id == operationId)); + + IDataRightsSubjectExportContributor freshExporter = verificationScope + .ServiceProvider.GetServices() + .Single(contributor => contributor.OwnerKey == "reservations"); + CollectingSink staleSink = new(); + DataRightsSubjectExportResult stale = await freshExporter.ExportAsync( + new( + "tenant-a", + DataRightsCaseType.GuestRights, + propertyId, + new DataRightsSubjectCoordinate( + "reservations", + "reservation", + reservation.Id, + reservation.Version)), + staleSink, + CancellationToken.None); + Assert.Equal(DataRightsSubjectExportStatus.Stale, stale.Status); + Assert.Empty(staleSink.Records); + } + [DockerFact] [Trait("Category", "Docker")] [Trait("Category", "Integration")] @@ -1284,6 +1545,26 @@ private static Reservation CreateReservation(Guid propertyId, string tenantId) = Guid.NewGuid(), Now).Value; + private static string StayAmendmentFingerprint( + Reservation reservation, + Guid operationId, + IReadOnlyCollection inventoryUnitIds) + { + string canonical = string.Join( + '|', + "v2", + $"reservation={reservation.Id:N}", + $"operation={operationId:N}", + $"expected-details-revision={reservation.DetailsRevision.ToString(CultureInfo.InvariantCulture)}", + $"arrival={reservation.Arrival.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)}", + $"departure={reservation.Departure.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)}", + $"expected-arrival-time={reservation.ExpectedArrivalTime?.ToString("HH:mm", CultureInfo.InvariantCulture) ?? "-"}", + $"expected-departure-time={reservation.ExpectedDepartureTime?.ToString("HH:mm", CultureInfo.InvariantCulture) ?? "-"}", + $"units={string.Join(',', inventoryUnitIds.Order().Select(id => id.ToString("N")))}"); + return Convert.ToHexStringLower( + SHA256.HashData(Encoding.UTF8.GetBytes(canonical))); + } + private static ServiceProvider CreatePersistenceProvider(string connectionString) { HostApplicationBuilder builder = Host.CreateApplicationBuilder(); @@ -1310,6 +1591,32 @@ public ValueTask WriteAsync( } } + private sealed class BlockingFirstWriteSink : IDataRightsExportSink + { + private readonly TaskCompletionSource firstWrite = new( + TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource release = new( + TaskCreationOptions.RunContinuationsAsynchronously); + + public List Records { get; } = []; + public Task FirstWriteReached => this.firstWrite.Task; + + public async ValueTask WriteAsync( + DataRightsExportRecord record, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + this.Records.Add(record); + if (this.Records.Count == 1) + { + this.firstWrite.TrySetResult(true); + await this.release.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + } + } + + public void Release() => this.release.TrySetResult(true); + } + private sealed class TestScopeContext(string scopeId) : IScopeContext { public bool IsEnabled => true; diff --git a/tests/Integration.Tests/Reservations/ReservationStayAmendmentDispatcherIntegrationTests.cs b/tests/Integration.Tests/Reservations/ReservationStayAmendmentDispatcherIntegrationTests.cs new file mode 100644 index 00000000..30fe9b60 --- /dev/null +++ b/tests/Integration.Tests/Reservations/ReservationStayAmendmentDispatcherIntegrationTests.cs @@ -0,0 +1,561 @@ +namespace Integration.Tests; + +using System.Text.Json; +using BunkFy.Modules.Inventory.Contracts; +using BunkFy.Modules.Reservations.Application; +using BunkFy.Modules.Reservations.Application.Commands; +using BunkFy.Modules.Reservations.Application.Ports; +using BunkFy.Modules.Reservations.Contracts; +using BunkFy.Modules.Reservations.Domain.Aggregates; +using BunkFy.Modules.Reservations.Domain.DataRights; +using BunkFy.Modules.Reservations.Persistence; +using BunkFy.Modules.Workspaces.Contracts; +using Gma.Framework.Application.Events.Infrastructure; +using Gma.Framework.Cqrs; +using Gma.Framework.Cqrs.Infrastructure; +using Gma.Framework.Messaging.Infrastructure; +using Gma.Framework.Results; +using Gma.Framework.Runtime.Identity; +using Gma.Framework.Runtime.Time; +using Gma.Framework.Scoping; +using Integration.Tests.Support; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Npgsql; +using Testcontainers.PostgreSql; +using Xunit; + +public sealed class ReservationStayAmendmentDispatcherIntegrationTests +{ + private const string TenantId = + "aa000000-0000-0000-0000-000000000001"; + private static readonly DateTimeOffset Now = new( + 2026, + 8, + 12, + 12, + 0, + 0, + TimeSpan.Zero); + + [DockerFact] + [Trait("Category", "Docker")] + [Trait("Category", "Integration")] + public async Task Desired_stay_dispatch_commits_one_correlated_graph_and_exact_replay_is_silent() + { + await using PostgreSqlContainer postgreSql = + new PostgreSqlBuilder("postgres:16-alpine") + .WithDatabase("bunkfy_reservation_stay_dispatch_tests") + .Build(); + await postgreSql.StartAsync().ConfigureAwait(false); + string connectionString = postgreSql.GetConnectionString(); + + await using ServiceProvider services = CreateProvider(connectionString); + Guid propertyId = Guid.NewGuid(); + Guid sharedOperationId = + Guid.Parse("bb000000-0000-0000-0000-000000000001"); + SeededReservation first = CreateConfirmedReservation( + propertyId, + "First Guest"); + SeededReservation second = CreateConfirmedReservation( + propertyId, + "Second Guest"); + await MigrateAndSeedAsync( + services, + propertyId, + first, + second).ConfigureAwait(false); + + AmendReservationStayCommand firstCommand = CreateCommand( + sharedOperationId, + propertyId, + first); + AmendReservationStayCommand secondCommand = CreateCommand( + sharedOperationId, + propertyId, + second); + + Result firstResult = + await SendAsync(services, firstCommand).ConfigureAwait(false); + Result secondResult = + await SendAsync(services, secondCommand).ConfigureAwait(false); + + Assert.True(firstResult.IsSuccess, firstResult.Error.Code); + Assert.True(secondResult.IsSuccess, secondResult.Error.Code); + Assert.Equal(sharedOperationId, firstResult.Value.OperationId); + Assert.Equal(sharedOperationId, secondResult.Value.OperationId); + Assert.Equal( + ReservationStayAmendmentOutcome.Pending, + firstResult.Value.Outcome); + Assert.Equal( + ReservationStayAmendmentOutcome.Pending, + secondResult.Value.Outcome); + + CommittedGraph firstGraph = await ReadCommittedGraphAsync( + connectionString, + first.ReservationId, + sharedOperationId).ConfigureAwait(false); + CommittedGraph secondGraph = await ReadCommittedGraphAsync( + connectionString, + second.ReservationId, + sharedOperationId).ConfigureAwait(false); + AssertCommittedGraph(firstCommand, firstGraph); + AssertCommittedGraph(secondCommand, secondGraph); + Assert.NotEqual(sharedOperationId, firstGraph.InventoryRequestId); + Assert.NotEqual(sharedOperationId, secondGraph.InventoryRequestId); + Assert.NotEqual( + firstGraph.InventoryRequestId, + secondGraph.InventoryRequestId); + + OutboxRequest[] requests = await ReadOutboxRequestsAsync( + connectionString).ConfigureAwait(false); + Assert.Equal(2, requests.Length); + AssertOutboxRequest(firstCommand, firstGraph, requests); + AssertOutboxRequest(secondCommand, secondGraph, requests); + Assert.Equal( + 2, + await CountAsync( + connectionString, + "reservations.management_operations", + sharedOperationId).ConfigureAwait(false)); + Assert.Equal( + 2, + await CountAsync( + connectionString, + "reservations.stay_amendment_operations", + sharedOperationId).ConfigureAwait(false)); + + Result firstReplay = + await SendAsync(services, firstCommand).ConfigureAwait(false); + Result secondReplay = + await SendAsync(services, secondCommand).ConfigureAwait(false); + + Assert.True(firstReplay.IsSuccess, firstReplay.Error.Code); + Assert.True(secondReplay.IsSuccess, secondReplay.Error.Code); + Assert.Equal( + JsonSerializer.Serialize(firstResult.Value), + JsonSerializer.Serialize(firstReplay.Value)); + Assert.Equal( + JsonSerializer.Serialize(secondResult.Value), + JsonSerializer.Serialize(secondReplay.Value)); + Assert.Equal( + requests.Select(request => request.MessageId).Order().ToArray(), + (await ReadOutboxRequestsAsync(connectionString) + .ConfigureAwait(false)) + .Select(request => request.MessageId) + .Order() + .ToArray()); + Assert.Equal( + firstGraph, + await ReadCommittedGraphAsync( + connectionString, + first.ReservationId, + sharedOperationId).ConfigureAwait(false)); + Assert.Equal( + secondGraph, + await ReadCommittedGraphAsync( + connectionString, + second.ReservationId, + sharedOperationId).ConfigureAwait(false)); + } + + private static void AssertCommittedGraph( + AmendReservationStayCommand command, + CommittedGraph graph) + { + Assert.Equal(command.OperationId, graph.PendingOperationId); + Assert.Equal( + graph.InventoryRequestId, + graph.PendingInventoryRequestId); + Assert.Equal(command.Departure, graph.PendingDeparture); + Assert.Equal(7, graph.ManagementKind); + Assert.Equal( + command.ExpectedDetailsRevision, + graph.ManagementExpectedDetailsRevision); + Assert.Equal(64, graph.ManagementFingerprint.Length); + Assert.Equal( + graph.ManagementFingerprint, + graph.OperationFingerprint); + Assert.Equal(2, graph.RequestSchemaVersion); + Assert.Equal(command.Departure, graph.TargetDeparture); + Assert.Equal(1, graph.Outcome); + Assert.Equal(1, graph.OperationVersion); + Assert.Equal(command.ActorId, graph.RequestedBy); + Assert.Equal(graph.ReservationXmin, graph.ManagementXmin); + Assert.Equal(graph.ReservationXmin, graph.OperationXmin); + } + + private static void AssertOutboxRequest( + AmendReservationStayCommand command, + CommittedGraph graph, + IReadOnlyCollection requests) + { + OutboxRequest request = Assert.Single( + requests, + candidate => candidate.InventoryRequestId == + graph.InventoryRequestId); + Assert.Equal( + typeof(InventoryAllocationAmendmentRequestedIntegrationEvent) + .FullName, + request.EventType); + Assert.Equal(command.ReservationId, request.ReservationId); + Assert.Equal(command.PropertyId, request.PropertyId); + Assert.Equal(command.Arrival, request.Arrival); + Assert.Equal(command.Departure, request.Departure); + Assert.Equal( + command.InventoryUnitIds.ToArray(), + request.InventoryUnitIds.ToArray()); + Assert.Equal(graph.ReservationXmin, request.Xmin); + } + + private static AmendReservationStayCommand CreateCommand( + Guid operationId, + Guid propertyId, + SeededReservation seeded) => new( + operationId, + propertyId, + seeded.ReservationId, + seeded.Arrival, + seeded.Departure.AddDays(1), + seeded.ExpectedArrivalTime, + seeded.ExpectedDepartureTime, + [seeded.InventoryUnitId], + seeded.DetailsRevision, + "user:stay-dispatcher"); + + private static SeededReservation CreateConfirmedReservation( + Guid propertyId, + string guestName) + { + Guid reservationId = Guid.NewGuid(); + Guid inventoryUnitId = Guid.NewGuid(); + DateOnly arrival = new(2026, 9, 1); + DateOnly departure = new(2026, 9, 3); + TimeOnly expectedArrivalTime = new(15, 0); + TimeOnly expectedDepartureTime = new(11, 0); + Reservation reservation = Reservation.Create( + reservationId, + TenantId, + propertyId, + Guid.NewGuid(), + arrival, + departure, + [inventoryUnitId], + guestName, + "guest@example.test", + null, + guestCount: 1, + ReservationSource.Direct, + sourceSystem: null, + sourceReference: null, + notes: null, + Guid.NewGuid(), + Guid.NewGuid(), + ReservationDetailsChangeOrigin.Staff, + initialDetailsActorId: "user:seed", + initialAdapterConnectionId: null, + initialExternalOperationId: null, + Guid.NewGuid(), + Now.AddHours(-1), + expectedArrivalTime, + expectedDepartureTime).Value; + Assert.True(reservation.ConfirmAllocation( + reservation.AllocationRequestId, + Guid.NewGuid(), + allocationVersion: 1, + Guid.NewGuid(), + Now.AddMinutes(-59)).IsSuccess); + reservation.ClearDomainEvents(); + return new( + reservation, + reservationId, + inventoryUnitId, + arrival, + departure, + expectedArrivalTime, + expectedDepartureTime, + reservation.DetailsRevision); + } + + private static async Task MigrateAndSeedAsync( + ServiceProvider services, + Guid propertyId, + params SeededReservation[] reservations) + { + using IServiceScope scope = services.CreateScope(); + ReservationsDbContext dbContext = scope.ServiceProvider + .GetRequiredService(); + await dbContext.Database.MigrateAsync().ConfigureAwait(false); + + foreach (SeededReservation seeded in reservations) + { + dbContext.Reservations.Add(seeded.Reservation); + dbContext.ProcessingRestrictionProjections.Add( + ReservationProcessingRestrictionProjection.Create( + TenantId, + propertyId, + seeded.ReservationId, + ReservationProcessingRestrictionContract.CurrentVersion, + Now.AddHours(-1)).Value); + dbContext.InventoryUnitProjections.Add( + ReservationInventoryUnitProjection.Create( + new ReservationInventoryUnitWriteModel( + TenantId, + seeded.InventoryUnitId, + propertyId, + Guid.NewGuid(), + BedId: null, + InventoryUnitKind.Room, + $"Room {seeded.ReservationId:N}", + IsTopologyActive: true, + IsSellable: true, + ConfigurationVersion: 1, + UnitVersion: 1))); + } + + await dbContext.SaveChangesAsync().ConfigureAwait(false); + foreach (SeededReservation seeded in reservations) + { + await dbContext.Database.ExecuteSqlInterpolatedAsync($""" + INSERT INTO reservations.reservation_operation_locks + ("Id", "ScopeId", "ReservationId", "Revision") + VALUES + ({Guid.NewGuid()}, {TenantId}, + {seeded.ReservationId}, 1) + """).ConfigureAwait(false); + } + } + + private static async Task> + SendAsync( + ServiceProvider services, + AmendReservationStayCommand command) + { + using IServiceScope scope = services.CreateScope(); + return await scope.ServiceProvider + .GetRequiredService() + .SendAsync(command, CancellationToken.None) + .ConfigureAwait(false); + } + + private static async Task ReadCommittedGraphAsync( + string connectionString, + Guid reservationId, + Guid operationId) + { + await using NpgsqlConnection connection = new(connectionString); + await connection.OpenAsync().ConfigureAwait(false); + await using NpgsqlCommand command = new(""" + SELECT + reservation."PendingAllocationAmendmentId", + reservation."PendingInventoryAmendmentRequestId", + reservation."PendingDeparture", + reservation.xmin::text::bigint, + management."Kind", + management."ExpectedDetailsRevision", + management."RequestFingerprint", + management.xmin::text::bigint, + operation."InventoryRequestId", + operation."RequestSchemaVersion", + operation."RequestFingerprint", + operation."TargetDeparture", + operation."Outcome", + operation."OperationVersion", + operation."RequestedBy", + operation.xmin::text::bigint + FROM reservations.reservations reservation + JOIN reservations.management_operations management + ON management."ScopeId" = reservation."ScopeId" + AND management."ReservationId" = reservation."Id" + AND management."Id" = @operationId + JOIN reservations.stay_amendment_operations operation + ON operation."ScopeId" = management."ScopeId" + AND operation."ReservationId" = management."ReservationId" + AND operation."Id" = management."Id" + WHERE reservation."ScopeId" = @tenantId + AND reservation."Id" = @reservationId + """, connection); + command.Parameters.AddWithValue("tenantId", TenantId); + command.Parameters.AddWithValue("reservationId", reservationId); + command.Parameters.AddWithValue("operationId", operationId); + await using NpgsqlDataReader reader = await command + .ExecuteReaderAsync() + .ConfigureAwait(false); + Assert.True(await reader.ReadAsync().ConfigureAwait(false)); + CommittedGraph graph = new( + reader.GetGuid(0), + reader.GetGuid(1), + reader.GetFieldValue(2), + reader.GetInt64(3), + reader.GetInt32(4), + reader.GetInt64(5), + reader.GetString(6), + reader.GetInt64(7), + reader.GetGuid(8), + reader.GetInt32(9), + reader.GetString(10), + reader.GetFieldValue(11), + reader.GetInt32(12), + reader.GetInt64(13), + reader.GetString(14), + reader.GetInt64(15)); + Assert.False(await reader.ReadAsync().ConfigureAwait(false)); + return graph; + } + + private static async Task ReadOutboxRequestsAsync( + string connectionString) + { + await using NpgsqlConnection connection = new(connectionString); + await connection.OpenAsync().ConfigureAwait(false); + await using NpgsqlCommand command = new(""" + SELECT "Id", "EventType", "Payload", xmin::text::bigint + FROM reservations.outbox_messages + WHERE "ScopeId" = @tenantId + AND "EventType" = @eventType + ORDER BY "Id" + """, connection); + command.Parameters.AddWithValue("tenantId", TenantId); + command.Parameters.AddWithValue( + "eventType", + typeof(InventoryAllocationAmendmentRequestedIntegrationEvent) + .FullName!); + await using NpgsqlDataReader reader = await command + .ExecuteReaderAsync() + .ConfigureAwait(false); + List requests = []; + while (await reader.ReadAsync().ConfigureAwait(false)) + { + Guid messageId = reader.GetGuid(0); + string eventType = reader.GetString(1); + string payload = reader.GetString(2); + long xmin = reader.GetInt64(3); + using JsonDocument document = JsonDocument.Parse(payload); + JsonElement root = document.RootElement; + requests.Add(new( + messageId, + eventType, + root.GetProperty("amendmentRequestId").GetGuid(), + root.GetProperty("reservationId").GetGuid(), + root.GetProperty("propertyId").GetGuid(), + DateOnly.Parse( + root.GetProperty("arrival").GetString()!, + System.Globalization.CultureInfo.InvariantCulture), + DateOnly.Parse( + root.GetProperty("departure").GetString()!, + System.Globalization.CultureInfo.InvariantCulture), + root.GetProperty("inventoryUnitIds") + .EnumerateArray() + .Select(item => item.GetGuid()) + .ToArray(), + xmin)); + } + + return requests.ToArray(); + } + + private static async Task CountAsync( + string connectionString, + string qualifiedTable, + Guid operationId) + { + await using NpgsqlConnection connection = new(connectionString); + await connection.OpenAsync().ConfigureAwait(false); + await using NpgsqlCommand command = new( + $"SELECT COUNT(*) FROM {qualifiedTable} " + + "WHERE \"ScopeId\" = @tenantId AND \"Id\" = @operationId", + connection); + command.Parameters.AddWithValue("tenantId", TenantId); + command.Parameters.AddWithValue("operationId", operationId); + object? value = await command.ExecuteScalarAsync() + .ConfigureAwait(false); + return Convert.ToInt64( + value, + System.Globalization.CultureInfo.InvariantCulture); + } + + private static ServiceProvider CreateProvider(string connectionString) + { + HostApplicationBuilder builder = Host.CreateApplicationBuilder(); + builder.Configuration["Persistence:Provider"] = "PostgreSql"; + builder.Configuration["ConnectionStrings:PostgreSql"] = + connectionString; + builder.Services.AddSingleton(new TestScopeContext()); + builder.Services.AddSingleton(new TestClock()); + builder.Services.AddSingleton(); + builder.Services.AddSingleton( + OpenWorkspaceTerminationFenceReader.Instance); + builder.AddApplicationEventsInfrastructure(); + builder.AddCqrsInfrastructure(); + builder.AddMessagingInfrastructure(); + builder.Services.AddReservationsApplication(); + builder.AddReservationsPersistence(); + return builder.Services.BuildServiceProvider( + new ServiceProviderOptions { ValidateScopes = true }); + } + + private sealed record SeededReservation( + Reservation Reservation, + Guid ReservationId, + Guid InventoryUnitId, + DateOnly Arrival, + DateOnly Departure, + TimeOnly ExpectedArrivalTime, + TimeOnly ExpectedDepartureTime, + long DetailsRevision); + + private sealed record CommittedGraph( + Guid PendingOperationId, + Guid PendingInventoryRequestId, + DateOnly PendingDeparture, + long ReservationXmin, + int ManagementKind, + long ManagementExpectedDetailsRevision, + string ManagementFingerprint, + long ManagementXmin, + Guid InventoryRequestId, + int RequestSchemaVersion, + string OperationFingerprint, + DateOnly TargetDeparture, + int Outcome, + long OperationVersion, + string RequestedBy, + long OperationXmin); + + private sealed record OutboxRequest( + Guid MessageId, + string EventType, + Guid InventoryRequestId, + Guid ReservationId, + Guid PropertyId, + DateOnly Arrival, + DateOnly Departure, + IReadOnlyCollection InventoryUnitIds, + long Xmin); + + private sealed class TestScopeContext : IScopeContext + { + public bool IsEnabled => true; + public string ScopeId => TenantId; + } + + private sealed class TestClock : ISystemClock + { + public DateTimeOffset UtcNow => Now; + } + + private sealed class SequentialIdGenerator : IIdGenerator + { + private int next; + + public Guid NewId() + { + byte[] bytes = new byte[16]; + BitConverter.GetBytes(Interlocked.Increment(ref this.next)) + .CopyTo(bytes, 0); + bytes[^1] = 1; + return new Guid(bytes); + } + } +} diff --git a/tests/Integration.Tests/Reservations/ReservationStayAmendmentMigrationIntegrationTests.cs b/tests/Integration.Tests/Reservations/ReservationStayAmendmentMigrationIntegrationTests.cs new file mode 100644 index 00000000..833f129d --- /dev/null +++ b/tests/Integration.Tests/Reservations/ReservationStayAmendmentMigrationIntegrationTests.cs @@ -0,0 +1,2288 @@ +namespace Integration.Tests; + +using BunkFy.Modules.Reservations.Persistence; +using Gma.Framework.Scoping; +using Integration.Tests.Support; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql; +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using Testcontainers.PostgreSql; +using Xunit; + +public sealed class ReservationStayAmendmentMigrationIntegrationTests +{ + private const string PreviousMigration = + "20260807092823_AddReservationGuestRecordLinkProcess"; + private const string CurrentMigration = + "20260812011925_AddReservationStayAmendmentConvergence"; + private const string TenantA = "tenant-a"; + private const string TenantB = "tenant-b"; + private const string DowngradeEvidenceMessage = + "stay-amendment convergence evidence prevents downgrade"; + + private static readonly DateTimeOffset BaseNowUtc = + new(2026, 8, 12, 1, 30, 0, TimeSpan.Zero); + + [DockerFact] + [Trait("Category", "Docker")] + [Trait("Category", "Integration")] + public async Task Upgrade_nowait_failure_rolls_back_then_backfills_exact_and_unknown_rows() + { + await using PostgreSqlContainer postgreSql = + new PostgreSqlBuilder("postgres:16-alpine") + .WithDatabase("bunkfy_reservations_stay_amendment_upgrade_tests") + .Build(); + await postgreSql.StartAsync(); + string connectionString = postgreSql.GetConnectionString(); + + OperationSeed pendingA = CreateSeed( + TenantA, + 'a', + BaseNowUtc); + OperationSeed historicalA = CreateSeed( + TenantA, + 'b', + BaseNowUtc.AddMinutes(1)); + OperationSeed pendingB = CreateSeed( + TenantB, + 'c', + BaseNowUtc.AddMinutes(2)); + OperationSeed adapterA = CreateSeed( + TenantA, + 'd', + BaseNowUtc.AddMinutes(3)); + + await using (ReservationsDbContext previous = + CreateDbContext(connectionString)) + { + await previous.Database.GetService() + .MigrateAsync(PreviousMigration); + await SeedLegacyOperationAsync(previous, pendingA, isPending: true); + await SeedLegacyOperationAsync(previous, historicalA, isPending: false); + await SeedLegacyOperationAsync(previous, pendingB, isPending: true); + await SeedReservationAsync(previous, adapterA, isPending: true); + await MarkPendingAsAdapterAsync(previous, adapterA); + } + + await using NpgsqlConnection blocker = new(connectionString); + await blocker.OpenAsync(); + await using NpgsqlTransaction blockerTransaction = + await blocker.BeginTransactionAsync(); + await ExecuteAsync( + blocker, + blockerTransaction, + "LOCK TABLE reservations.management_operations IN ROW EXCLUSIVE MODE;"); + + await using (ReservationsDbContext blockedUpgrade = + CreateDbContext(connectionString)) + { + PostgresException unavailable = await AssertPostgresFailureAsync( + PostgresErrorCodes.LockNotAvailable, + () => blockedUpgrade.Database.GetService() + .MigrateAsync(CurrentMigration)); + Assert.Contains("lock", unavailable.MessageText, StringComparison.OrdinalIgnoreCase); + } + + Assert.True(await ScalarBoolAsync( + connectionString, + "SELECT to_regclass('reservations.stay_amendment_operations') IS NULL;")); + Assert.Equal( + 0, + await MigrationHistoryCountAsync(connectionString, CurrentMigration)); + Assert.Equal( + 3, + await ScalarIntAsync( + connectionString, + "SELECT COUNT(*)::int FROM reservations.management_operations WHERE \"Kind\" = 6;")); + + await blockerTransaction.RollbackAsync(); + + await using (ReservationsDbContext upgraded = + CreateDbContext(connectionString)) + { + await upgraded.Database.GetService() + .MigrateAsync(CurrentMigration); + } + + Assert.Equal( + 1, + await MigrationHistoryCountAsync(connectionString, CurrentMigration)); + + StayOperationRow exactA = await ReadStayOperationAsync( + connectionString, + pendingA); + AssertExactLegacyBackfill(exactA, pendingA); + + StayOperationRow unknownA = await ReadStayOperationAsync( + connectionString, + historicalA); + AssertHistoricalUnknownBackfill(unknownA, historicalA); + + StayOperationRow exactB = await ReadStayOperationAsync( + connectionString, + pendingB); + AssertExactLegacyBackfill(exactB, pendingB); + Assert.NotEqual(exactA.RequestFingerprint, exactB.RequestFingerprint); + Assert.NotEqual(pendingA.OperationId, pendingB.OperationId); + + Assert.Equal( + 7, + await ReservationVersionAsync(connectionString, pendingA)); + Assert.Equal( + 6, + await ReservationVersionAsync(connectionString, historicalA)); + Assert.Equal( + 7, + await ReservationVersionAsync(connectionString, pendingB)); + Assert.Equal( + 7, + await ReservationVersionAsync(connectionString, adapterA)); + Assert.Equal( + adapterA.OperationId, + await PendingInventoryRequestIdAsync(connectionString, adapterA)); + Assert.Equal(0, await CountOperationRowsAsync(connectionString, adapterA)); + + Assert.Equal( + 3, + await ScalarIntAsync( + connectionString, + "SELECT COUNT(*)::int FROM reservations.stay_amendment_operations;")); + + string recoveryIndex = await ScalarStringAsync( + connectionString, + """ + SELECT indexdef + FROM pg_indexes + WHERE schemaname = 'reservations' + AND tablename = 'stay_amendment_operations' + AND indexdef LIKE '%\"Outcome\"%' + LIMIT 1; + """); + Assert.Contains( + "(\"ScopeId\", \"PropertyId\", \"Outcome\", \"UpdatedAtUtc\", \"Id\", \"ReservationId\")", + recoveryIndex, + StringComparison.Ordinal); + } + + [DockerFact] + [Trait("Category", "Docker")] + [Trait("Category", "Integration")] + public async Task Upgrade_version_overflow_aborts_atomically() + { + await using PostgreSqlContainer postgreSql = + new PostgreSqlBuilder("postgres:16-alpine") + .WithDatabase("bunkfy_reservations_stay_amendment_overflow_tests") + .Build(); + await postgreSql.StartAsync(); + string connectionString = postgreSql.GetConnectionString(); + OperationSeed adapterPending = CreateSeed( + TenantA, + 'v', + BaseNowUtc); + + await using ReservationsDbContext context = CreateDbContext(connectionString); + await context.Database.GetService().MigrateAsync(PreviousMigration); + await SeedReservationAsync(context, adapterPending, isPending: true); + await MarkPendingAsAdapterAsync(context, adapterPending); + await context.Database.ExecuteSqlInterpolatedAsync($""" + UPDATE reservations.reservations + SET "Version" = {long.MaxValue} + WHERE "ScopeId" = {adapterPending.ScopeId} + AND "Id" = {adapterPending.ReservationId}; + """); + + PostgresException rejected = await AssertPostgresFailureAsync( + PostgresErrorCodes.RaiseException, + () => context.Database.GetService() + .MigrateAsync(CurrentMigration)); + Assert.Contains( + "reservation version overflow prevents stay-amendment backfill", + rejected.MessageText, + StringComparison.Ordinal); + Assert.Equal( + 0, + await MigrationHistoryCountAsync(connectionString, CurrentMigration)); + Assert.True(await ScalarBoolAsync( + connectionString, + "SELECT to_regclass('reservations.stay_amendment_operations') IS NULL;")); + Assert.False(await ScalarBoolAsync( + connectionString, + """ + SELECT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'reservations' + AND table_name = 'reservations' + AND column_name = 'PendingInventoryAmendmentRequestId'); + """)); + Assert.Equal( + long.MaxValue, + await ReservationVersionAsync(connectionString, adapterPending)); + } + + [DockerFact] + [Trait("Category", "Docker")] + [Trait("Category", "Integration")] + public async Task Upgrade_rejects_nonexact_active_legacy_and_rolls_back_history() + { + await using PostgreSqlContainer postgreSql = + new PostgreSqlBuilder("postgres:16-alpine") + .WithDatabase("bunkfy_reservations_stay_amendment_bad_legacy_tests") + .Build(); + await postgreSql.StartAsync(); + string connectionString = postgreSql.GetConnectionString(); + OperationSeed malformedActive = CreateSeed( + TenantA, + '0', + BaseNowUtc); + + await using ReservationsDbContext context = CreateDbContext(connectionString); + await context.Database.GetService().MigrateAsync(PreviousMigration); + await SeedLegacyOperationAsync(context, malformedActive, isPending: true); + await context.Database.ExecuteSqlInterpolatedAsync($""" + UPDATE reservations.reservations + SET "PendingDetailsActorId" = NULL + WHERE "ScopeId" = {malformedActive.ScopeId} + AND "Id" = {malformedActive.ReservationId}; + """); + + PostgresException rejected = await AssertPostgresFailureAsync( + PostgresErrorCodes.RaiseException, + () => context.Database.GetService() + .MigrateAsync(CurrentMigration)); + Assert.Contains( + "nonexact active Staff stay amendment prevents upgrade", + rejected.MessageText, + StringComparison.Ordinal); + Assert.Equal( + 0, + await MigrationHistoryCountAsync(connectionString, CurrentMigration)); + Assert.True(await ScalarBoolAsync( + connectionString, + "SELECT to_regclass('reservations.stay_amendment_operations') IS NULL;")); + Assert.Equal( + 1, + await ScalarIntAsync( + connectionString, + "SELECT COUNT(*)::int FROM reservations.management_operations WHERE \"Kind\" = 6;")); + } + + [DockerFact] + [Trait("Category", "Docker")] + [Trait("Category", "Integration")] + public async Task Post_upgrade_predecessor_writer_orders_fail_and_roll_back() + { + await using PostgreSqlContainer postgreSql = + new PostgreSqlBuilder("postgres:16-alpine") + .WithDatabase("bunkfy_reservations_stay_amendment_old_writer_tests") + .Build(); + await postgreSql.StartAsync(); + string connectionString = postgreSql.GetConnectionString(); + OperationSeed updateFirst = CreateSeed(TenantA, 'u', BaseNowUtc); + OperationSeed parentFirst = CreateSeed( + TenantA, + 'p', + BaseNowUtc.AddMinutes(1)); + + await using (ReservationsDbContext previous = + CreateDbContext(connectionString)) + { + await previous.Database.GetService() + .MigrateAsync(PreviousMigration); + await SeedReservationAsync(previous, updateFirst, isPending: false); + await SeedReservationAsync(previous, parentFirst, isPending: false); + await previous.Database.GetService() + .MigrateAsync(CurrentMigration); + } + + await AssertPredecessorWriterRejectedAsync( + connectionString, + updateFirst, + insertParentFirst: false); + await AssertPredecessorWriterRejectedAsync( + connectionString, + parentFirst, + insertParentFirst: true); + + Assert.Equal(0, await CountOperationRowsAsync(connectionString, updateFirst)); + Assert.Equal(0, await CountOperationRowsAsync(connectionString, parentFirst)); + Assert.True(await HasNoPendingIdentifiersAsync(connectionString, updateFirst)); + Assert.True(await HasNoPendingIdentifiersAsync(connectionString, parentFirst)); + } + + [DockerFact] + [Trait("Category", "Docker")] + [Trait("Category", "Integration")] + public async Task Pending_staff_reservation_requires_exact_child_on_update_and_insert() + { + await using PostgreSqlContainer postgreSql = + new PostgreSqlBuilder("postgres:16-alpine") + .WithDatabase("bunkfy_reservations_stay_amendment_live_evidence_tests") + .Build(); + await postgreSql.StartAsync(); + string connectionString = postgreSql.GetConnectionString(); + OperationSeed missingUpdate = CreateSeed( + TenantA, + 'e', + BaseNowUtc, + inventoryRequestId: Guid.NewGuid(), + schemaVersion: 2); + OperationSeed forgedInsert = CreateSeed( + TenantA, + 'f', + BaseNowUtc.AddMinutes(1), + inventoryRequestId: Guid.NewGuid(), + schemaVersion: 2); + + await using ReservationsDbContext context = CreateDbContext(connectionString); + await context.Database.GetService().MigrateAsync(PreviousMigration); + await SeedReservationAsync(context, missingUpdate, isPending: false); + await context.Database.GetService().MigrateAsync(CurrentMigration); + + PostgresException updateRejected = await AssertPostgresFailureAsync( + PostgresErrorCodes.RaiseException, + () => SetPendingReservationAsync(context, missingUpdate)); + Assert.Contains( + "Staff stay-amendment pending state requires durable evidence", + updateRejected.MessageText, + StringComparison.Ordinal); + Assert.True(await HasNoPendingIdentifiersAsync( + connectionString, + missingUpdate)); + + PostgresException insertRejected = await AssertPostgresFailureAsync( + PostgresErrorCodes.RaiseException, + () => InsertCurrentPendingReservationAsync(context, forgedInsert)); + Assert.Contains( + "Staff stay-amendment pending state requires durable evidence", + insertRejected.MessageText, + StringComparison.Ordinal); + Assert.Equal( + 0, + await CountReservationRowsAsync(connectionString, forgedInsert)); + } + + [DockerFact] + [Trait("Category", "Docker")] + [Trait("Category", "Integration")] + public async Task Dual_identifiers_allow_local_collisions_and_reject_global_inventory_collisions() + { + await using PostgreSqlContainer postgreSql = + new PostgreSqlBuilder("postgres:16-alpine") + .WithDatabase("bunkfy_reservations_stay_amendment_dual_id_tests") + .Build(); + await postgreSql.StartAsync(); + string connectionString = postgreSql.GetConnectionString(); + Guid sharedLocalId = Guid.NewGuid(); + OperationSeed localA = CreateSeed( + TenantA, + 'l', + BaseNowUtc, + operationId: sharedLocalId, + inventoryRequestId: Guid.NewGuid(), + schemaVersion: 2); + OperationSeed localB = CreateSeed( + TenantA, + 'm', + BaseNowUtc.AddMinutes(1), + operationId: sharedLocalId, + inventoryRequestId: Guid.NewGuid(), + schemaVersion: 2); + Guid duplicatedInventoryId = Guid.NewGuid(); + OperationSeed inventoryA = CreateSeed( + TenantA, + 'n', + BaseNowUtc.AddMinutes(2), + inventoryRequestId: duplicatedInventoryId, + schemaVersion: 2); + OperationSeed inventoryB = CreateSeed( + TenantA, + 'o', + BaseNowUtc.AddMinutes(3), + inventoryRequestId: duplicatedInventoryId, + schemaVersion: 2); + OperationSeed[] seeds = [localA, localB, inventoryA, inventoryB]; + + await using ReservationsDbContext context = CreateDbContext(connectionString); + await context.Database.GetService().MigrateAsync(PreviousMigration); + foreach (OperationSeed seed in seeds) + { + await SeedReservationAsync(context, seed, isPending: false); + } + await context.Database.GetService().MigrateAsync(CurrentMigration); + + foreach (OperationSeed seed in new[] { localA, localB }) + { + await SetPendingAndInsertOperationPairAsync( + context, + seed, + kind: 7, + StayInsert.Pending(seed, schemaVersion: 2)); + StayOperationRow row = await ReadStayOperationAsync( + connectionString, + seed); + Assert.Equal(seed.InventoryRequestId, row.InventoryRequestId); + } + Assert.Equal(localA.OperationId, localB.OperationId); + Assert.NotEqual(localA.InventoryRequestId, localB.InventoryRequestId); + + await SetPendingAndInsertOperationPairAsync( + context, + inventoryA, + kind: 7, + StayInsert.Pending(inventoryA, schemaVersion: 2)); + PostgresException reservationDuplicate = await AssertPostgresFailureAsync( + PostgresErrorCodes.UniqueViolation, + () => SetPendingReservationAsync(context, inventoryB)); + Assert.Equal( + "IX_reservations_PendingInventoryAmendmentRequestId", + reservationDuplicate.ConstraintName); + Assert.True(await HasNoPendingIdentifiersAsync( + connectionString, + inventoryB)); + + await CompleteRejectedAsync(context, inventoryA); + PostgresException childDuplicate = await AssertPostgresFailureAsync( + PostgresErrorCodes.UniqueViolation, + () => SetPendingAndInsertOperationPairAsync( + context, + inventoryB, + kind: 7, + StayInsert.Pending(inventoryB, schemaVersion: 2))); + Assert.Equal( + "IX_stay_amendment_operations_InventoryRequestId", + childDuplicate.ConstraintName); + Assert.Equal(0, await CountOperationRowsAsync(connectionString, inventoryB)); + Assert.True(await HasNoPendingIdentifiersAsync(connectionString, inventoryB)); + } + + [DockerFact] + [Trait("Category", "Docker")] + [Trait("Category", "Integration")] + public async Task Protocol_enforces_coordinates_shapes_transitions_immutability_and_deletion() + { + await using PostgreSqlContainer postgreSql = + new PostgreSqlBuilder("postgres:16-alpine") + .WithDatabase("bunkfy_reservations_stay_amendment_protocol_tests") + .Build(); + await postgreSql.StartAsync(); + string connectionString = postgreSql.GetConnectionString(); + + OperationSeed coordinatesV2 = CreateSeed( + TenantA, + '1', + BaseNowUtc, + schemaVersion: 2); + OperationSeed coordinatesV1 = CreateSeed( + TenantA, + '2', + BaseNowUtc.AddMinutes(1)); + OperationSeed malformed = CreateSeed( + TenantA, + '3', + BaseNowUtc.AddMinutes(2)); + OperationSeed apply = CreateSeed( + TenantA, + '4', + BaseNowUtc.AddMinutes(3), + schemaVersion: 2); + OperationSeed reject = CreateSeed( + TenantA, + '5', + BaseNowUtc.AddMinutes(4), + schemaVersion: 2); + OperationSeed deleteParent = CreateSeed( + TenantA, + '6', + BaseNowUtc.AddMinutes(5), + schemaVersion: 2); + OperationSeed deleteReservation = CreateSeed( + TenantA, + '7', + BaseNowUtc.AddMinutes(6), + schemaVersion: 2); + OperationSeed deleteChild = CreateSeed( + TenantA, + '8', + BaseNowUtc.AddMinutes(7), + schemaVersion: 2); + OperationSeed tenantB = CreateSeed( + TenantB, + '9', + BaseNowUtc.AddMinutes(8)); + OperationSeed forged = CreateSeed( + TenantA, + 'a', + BaseNowUtc.AddMinutes(9), + schemaVersion: 2); + OperationSeed deploymentFence = CreateSeed( + TenantA, + 'c', + BaseNowUtc.AddMinutes(10), + schemaVersion: 2); + OperationSeed[] seeds = + [ + coordinatesV2, + coordinatesV1, + malformed, + apply, + reject, + deleteParent, + deleteReservation, + deleteChild, + tenantB, + forged, + deploymentFence + ]; + + await using (ReservationsDbContext previous = + CreateDbContext(connectionString)) + { + await previous.Database.GetService() + .MigrateAsync(PreviousMigration); + foreach (OperationSeed seed in seeds) + { + await SeedReservationAsync(previous, seed, isPending: false); + } + } + + await using ReservationsDbContext current = CreateDbContext(connectionString); + await current.Database.GetService() + .MigrateAsync(CurrentMigration); + + StayInsert validV2 = StayInsert.Pending(coordinatesV2, schemaVersion: 2); + StayInsert validV1 = StayInsert.Pending(coordinatesV1, schemaVersion: 1); + await AssertPostgresFailureAsync( + PostgresErrorCodes.RaiseException, + () => InsertOperationPairAsync( + current, + coordinatesV2, + kind: 7, + validV2 with { RequestSchemaVersion = 1 })); + await AssertPostgresFailureAsync( + PostgresErrorCodes.RaiseException, + () => InsertOperationPairAsync( + current, + coordinatesV1, + kind: 6, + validV1 with { RequestSchemaVersion = 2 })); + OperationSeed forgedLegacyCoordinates = coordinatesV1 with + { + RequestFingerprint = new string('e', 64) + }; + PostgresException forgedLegacyFingerprint = await AssertPostgresFailureAsync( + PostgresErrorCodes.RaiseException, + () => InsertOperationPairAsync( + current, + forgedLegacyCoordinates, + kind: 6, + validV1 with + { + RequestFingerprint = forgedLegacyCoordinates.RequestFingerprint + })); + Assert.Contains( + "request fingerprint does not match", + forgedLegacyFingerprint.MessageText, + StringComparison.Ordinal); + OperationSeed forgedCoordinates = forged with + { + RequestFingerprint = new string('f', 64) + }; + PostgresException forgedFingerprint = await AssertPostgresFailureAsync( + PostgresErrorCodes.RaiseException, + () => InsertOperationPairAsync( + current, + forgedCoordinates, + kind: 7, + StayInsert.Pending(forged, schemaVersion: 2) with + { + RequestFingerprint = forgedCoordinates.RequestFingerprint + })); + Assert.Contains( + "request fingerprint does not match", + forgedFingerprint.MessageText, + StringComparison.Ordinal); + await AssertPostgresFailureAsync( + PostgresErrorCodes.RaiseException, + () => InsertOperationPairAsync( + current, + coordinatesV1, + kind: 6, + validV1 with { PropertyId = Guid.NewGuid() })); + await AssertPostgresFailureAsync( + PostgresErrorCodes.RaiseException, + () => InsertOperationPairAsync( + current, + coordinatesV1, + kind: 6, + validV1 with + { + ExpectedDetailsRevision = + coordinatesV1.ExpectedDetailsRevision + 1 + })); + await SetPendingAndInsertOperationPairAsync( + current, + tenantB, + kind: 6, + StayInsert.Pending(tenantB, schemaVersion: 1)); + await AssertPostgresFailureAsync( + PostgresErrorCodes.RaiseException, + () => current.Database.ExecuteSqlInterpolatedAsync($""" + UPDATE reservations.stay_amendment_operations + SET "ScopeId" = {TenantA} + WHERE "ScopeId" = {tenantB.ScopeId} + AND "ReservationId" = {tenantB.ReservationId} + AND "Id" = {tenantB.OperationId}; + """)); + await SetPendingAndInsertOperationPairAsync( + current, + coordinatesV2, + kind: 7, + validV2); + + await SetPendingAndInsertOperationPairAsync( + current, + deploymentFence, + kind: 7, + StayInsert.Pending(deploymentFence, schemaVersion: 2)); + PostgresException terminalOrphan = await AssertPostgresFailureAsync( + PostgresErrorCodes.CheckViolation, + () => ClearPendingReservationAsOldHandlerAsync( + current, + deploymentFence)); + Assert.Equal( + "CK_reservations_pending_inventory_request", + terminalOrphan.ConstraintName); + + await AssertPostgresFailureAsync( + PostgresErrorCodes.RaiseException, + () => current.Database.ExecuteSqlInterpolatedAsync($""" + UPDATE reservations.management_operations + SET "RequestFingerprint" = {new string('d', 64)} + WHERE "ScopeId" = {coordinatesV2.ScopeId} + AND "ReservationId" = {coordinatesV2.ReservationId} + AND "Id" = {coordinatesV2.OperationId}; + """)); + + StayInsert validMalformed = StayInsert.Pending(malformed, schemaVersion: 1); + await AssertPostgresFailureAsync( + PostgresErrorCodes.CheckViolation, + () => InsertOperationPairAsync( + current, + malformed, + kind: 6, + validMalformed with { RequestedBy = " staff:operator " })); + await AssertPostgresFailureAsync( + PostgresErrorCodes.CheckViolation, + () => InsertOperationPairAsync( + current, + malformed, + kind: 6, + validMalformed with + { + TargetDeparture = validMalformed.TargetArrival + })); + string reverseUnits = string.Join( + ',', + malformed.TargetInventoryUnitIdsValue + .OrderDescending() + .Select(id => id.ToString("N"))); + PostgresException noncanonical = await AssertPostgresFailureAsync( + PostgresErrorCodes.RaiseException, + () => InsertOperationPairAsync( + current, + malformed, + kind: 6, + validMalformed with + { + TargetInventoryUnitIds = reverseUnits + })); + Assert.Contains( + "target units must be canonical", + noncanonical.MessageText, + StringComparison.Ordinal); + await AssertPostgresFailureAsync( + PostgresErrorCodes.CheckViolation, + () => InsertOperationPairAsync( + current, + malformed, + kind: 6, + validMalformed with + { + TargetInventoryUnitIds = + validMalformed.TargetInventoryUnitIds!.ToUpperInvariant() + })); + await AssertPostgresFailureAsync( + PostgresErrorCodes.RaiseException, + () => InsertOperationPairAsync( + current, + malformed, + kind: 6, + validMalformed with + { + CompletedAtUtc = malformed.CreatedAtUtc, + ResultingDetailsRevision = 4, + ResultingReservationVersion = 7, + ResultingAllocationVersion = 3 + })); + + await SetPendingAndInsertOperationPairAsync( + current, + apply, + kind: 7, + StayInsert.Pending(apply, schemaVersion: 2)); + await AssertPostgresFailureAsync( + PostgresErrorCodes.RaiseException, + () => current.Database.ExecuteSqlInterpolatedAsync($""" + UPDATE reservations.stay_amendment_operations + SET "TargetDeparture" = {apply.TargetDeparture.AddDays(1)} + WHERE "ScopeId" = {apply.ScopeId} + AND "ReservationId" = {apply.ReservationId} + AND "Id" = {apply.OperationId}; + """)); + + await AssertPostgresFailureAsync( + PostgresErrorCodes.RaiseException, + () => current.Database.ExecuteSqlInterpolatedAsync($""" + UPDATE reservations.stay_amendment_operations + SET "Outcome" = 4, + "OperationVersion" = 2, + "UpdatedAtUtc" = {apply.CreatedAtUtc.AddMinutes(5)} + WHERE "ScopeId" = {apply.ScopeId} + AND "ReservationId" = {apply.ReservationId} + AND "Id" = {apply.OperationId}; + """)); + + await AssertPostgresFailureAsync( + PostgresErrorCodes.RaiseException, + () => current.Database.ExecuteSqlInterpolatedAsync($""" + UPDATE reservations.stay_amendment_operations + SET "OperationVersion" = 2, + "ReconciliationCount" = 1, + "LastReconciledAtUtc" = {apply.CreatedAtUtc.AddMinutes(4)}, + "LastReconciledBy" = {"staff:recovery"}, + "UpdatedAtUtc" = {apply.CreatedAtUtc.AddMinutes(4)} + WHERE "ScopeId" = {apply.ScopeId} + AND "ReservationId" = {apply.ReservationId} + AND "Id" = {apply.OperationId}; + """)); + await AssertPostgresFailureAsync( + PostgresErrorCodes.RaiseException, + () => current.Database.ExecuteSqlInterpolatedAsync($""" + UPDATE reservations.stay_amendment_operations + SET "OperationVersion" = 2, + "UpdatedAtUtc" = {apply.CreatedAtUtc.AddMinutes(1)} + WHERE "ScopeId" = {apply.ScopeId} + AND "ReservationId" = {apply.ReservationId} + AND "Id" = {apply.OperationId}; + """)); + + PostgresException missingReservationAdvance = + await AssertPostgresFailureAsync( + PostgresErrorCodes.RaiseException, + () => current.Database.ExecuteSqlInterpolatedAsync($""" + UPDATE reservations.stay_amendment_operations + SET "OperationVersion" = 2, + "ReconciliationCount" = 1, + "LastReconciledAtUtc" = {apply.CreatedAtUtc.AddMinutes(5)}, + "LastReconciledBy" = {"staff:recovery"}, + "UpdatedAtUtc" = {apply.CreatedAtUtc.AddMinutes(5)} + WHERE "ScopeId" = {apply.ScopeId} + AND "ReservationId" = {apply.ReservationId} + AND "Id" = {apply.OperationId}; + """)); + Assert.Contains( + "graph change requires a reservation version advance", + missingReservationAdvance.MessageText, + StringComparison.Ordinal); + + PostgresException nonadvancingReservationUpdate = + await AssertPostgresFailureAsync( + PostgresErrorCodes.RaiseException, + () => current.Database.ExecuteSqlInterpolatedAsync($""" + UPDATE reservations.reservations + SET "Version" = "Version" + WHERE "ScopeId" = {apply.ScopeId} + AND "Id" = {apply.ReservationId}; + """)); + Assert.Contains( + "reservation updates require a version advance", + nonadvancingReservationUpdate.MessageText, + StringComparison.Ordinal); + + await using (Microsoft.EntityFrameworkCore.Storage.IDbContextTransaction + reconciliation = await current.Database.BeginTransactionAsync()) + { + int advanced = await current.Database.ExecuteSqlInterpolatedAsync($""" + UPDATE reservations.reservations + SET "Version" = "Version" + 1, + "UpdatedAtUtc" = {apply.CreatedAtUtc.AddMinutes(5)} + WHERE "ScopeId" = {apply.ScopeId} + AND "Id" = {apply.ReservationId}; + """); + int reconciled = await current.Database.ExecuteSqlInterpolatedAsync($""" + UPDATE reservations.stay_amendment_operations + SET "OperationVersion" = 2, + "ReconciliationCount" = 1, + "LastReconciledAtUtc" = {apply.CreatedAtUtc.AddMinutes(5)}, + "LastReconciledBy" = {"staff:recovery"}, + "UpdatedAtUtc" = {apply.CreatedAtUtc.AddMinutes(5)} + WHERE "ScopeId" = {apply.ScopeId} + AND "ReservationId" = {apply.ReservationId} + AND "Id" = {apply.OperationId}; + """); + Assert.Equal(1, advanced); + Assert.Equal(1, reconciled); + await reconciliation.CommitAsync(); + } + + await CompleteAppliedAsync(current, apply); + await AssertPostgresFailureAsync( + PostgresErrorCodes.RaiseException, + () => current.Database.ExecuteSqlInterpolatedAsync($""" + UPDATE reservations.stay_amendment_operations + SET "RequestedBy" = {"staff:other"} + WHERE "ScopeId" = {apply.ScopeId} + AND "ReservationId" = {apply.ReservationId} + AND "Id" = {apply.OperationId}; + """)); + + await SetPendingAndInsertOperationPairAsync( + current, + reject, + kind: 7, + StayInsert.Pending(reject, schemaVersion: 2)); + PostgresException orphanedTerminal = await AssertPostgresFailureAsync( + PostgresErrorCodes.RaiseException, + () => current.Database.ExecuteSqlInterpolatedAsync($""" + UPDATE reservations.stay_amendment_operations + SET "Outcome" = 3, + "OperationVersion" = 2, + "CompletedAtUtc" = {reject.CreatedAtUtc.AddMinutes(1)}, + "ResultingDetailsRevision" = {3L}, + "ResultingReservationVersion" = {6L}, + "RejectionCode" = {9}, + "UpdatedAtUtc" = {reject.CreatedAtUtc.AddMinutes(1)} + WHERE "ScopeId" = {reject.ScopeId} + AND "ReservationId" = {reject.ReservationId} + AND "Id" = {reject.OperationId}; + """)); + Assert.Contains( + "terminal stay-amendment result does not match", + orphanedTerminal.MessageText, + StringComparison.Ordinal); + await CompleteRejectedAsync(current, reject); + await AssertPostgresFailureAsync( + PostgresErrorCodes.RaiseException, + () => current.Database.ExecuteSqlInterpolatedAsync($""" + UPDATE reservations.stay_amendment_operations + SET "Outcome" = 2, + "RejectionCode" = NULL + WHERE "ScopeId" = {reject.ScopeId} + AND "ReservationId" = {reject.ReservationId} + AND "Id" = {reject.OperationId}; + """)); + + foreach (OperationSeed seed in new[] + { + deleteParent, + deleteReservation, + deleteChild + }) + { + await SetPendingAndInsertOperationPairAsync( + current, + seed, + kind: 7, + StayInsert.Pending(seed, schemaVersion: 2)); + } + await AssertPostgresFailureAsync( + PostgresErrorCodes.RaiseException, + () => DeleteChildAsync(current, deleteParent)); + await AssertPostgresFailureAsync( + PostgresErrorCodes.RaiseException, + () => DeleteParentAsync(current, deleteParent)); + await AssertPostgresFailureAsync( + PostgresErrorCodes.RaiseException, + () => DeleteReservationAsync(current, deleteReservation)); + + Guid destroyOperationId = await BeginTenantDestructionAsync( + current, + TenantA, + BaseNowUtc.AddHours(1)); + + await ExecuteDestroyDeleteAsync( + connectionString, + destroyOperationId, + """ + DELETE FROM reservations.stay_amendment_operations + WHERE "ScopeId" = @scopeId + AND "ReservationId" = @reservationId + AND "Id" = @operationId; + """, + deleteChild); + Assert.Equal(1, await CountOperationRowsAsync(connectionString, deleteChild)); + Assert.Equal(1, await CountReservationRowsAsync(connectionString, deleteChild)); + await AssertDestroyDeleteRejectedAsync( + connectionString, + destroyOperationId, + """ + DELETE FROM reservations.management_operations + WHERE "ScopeId" = @scopeId + AND "ReservationId" = @reservationId + AND "Id" = @operationId; + """, + tenantB); + + await ExecuteDestroyDeleteAsync( + connectionString, + destroyOperationId, + """ + DELETE FROM reservations.management_operations + WHERE "ScopeId" = @scopeId + AND "ReservationId" = @reservationId + AND "Id" = @operationId; + """, + deleteParent); + Assert.Equal(0, await CountOperationRowsAsync(connectionString, deleteParent)); + Assert.Equal( + 1, + await CountReservationRowsAsync(connectionString, deleteParent)); + + await ExecuteDestroyDeleteAsync( + connectionString, + destroyOperationId, + """ + DELETE FROM reservations.reservations + WHERE "ScopeId" = @scopeId + AND "Id" = @reservationId; + """, + deleteReservation); + Assert.Equal( + 0, + await CountReservationRowsAsync(connectionString, deleteReservation)); + Assert.Equal( + 0, + await CountOperationRowsAsync(connectionString, deleteReservation)); + Assert.Equal(2, await CountOperationRowsAsync(connectionString, tenantB)); + } + + [DockerFact] + [Trait("Category", "Docker")] + [Trait("Category", "Integration")] + public async Task Downgrade_allows_only_historical_unknown_legacy_evidence() + { + await using PostgreSqlContainer postgreSql = + new PostgreSqlBuilder("postgres:16-alpine") + .WithDatabase("bunkfy_reservations_stay_amendment_safe_down_tests") + .Build(); + await postgreSql.StartAsync(); + string connectionString = postgreSql.GetConnectionString(); + OperationSeed historical = CreateSeed(TenantA, '9', BaseNowUtc); + + await using ReservationsDbContext context = CreateDbContext(connectionString); + await context.Database.GetService().MigrateAsync(PreviousMigration); + await SeedLegacyOperationAsync(context, historical, isPending: false); + await context.Database.GetService().MigrateAsync(CurrentMigration); + + StayOperationRow unknown = await ReadStayOperationAsync( + connectionString, + historical); + AssertHistoricalUnknownBackfill(unknown, historical); + + await context.Database.GetService().MigrateAsync(PreviousMigration); + + Assert.True(await ScalarBoolAsync( + connectionString, + "SELECT to_regclass('reservations.stay_amendment_operations') IS NULL;")); + Assert.Equal( + 0, + await MigrationHistoryCountAsync(connectionString, CurrentMigration)); + Assert.Equal( + 1, + await ScalarIntAsync( + connectionString, + "SELECT COUNT(*)::int FROM reservations.management_operations;")); + } + + [DockerFact] + [Trait("Category", "Docker")] + [Trait("Category", "Integration")] + public async Task Downgrade_rejects_exact_legacy_evidence_and_preserves_history() + { + await using PostgreSqlContainer postgreSql = + new PostgreSqlBuilder("postgres:16-alpine") + .WithDatabase("bunkfy_reservations_stay_amendment_exact_down_tests") + .Build(); + await postgreSql.StartAsync(); + string connectionString = postgreSql.GetConnectionString(); + OperationSeed exact = CreateSeed(TenantA, 'a', BaseNowUtc); + + await using ReservationsDbContext context = CreateDbContext(connectionString); + await context.Database.GetService().MigrateAsync(PreviousMigration); + await SeedLegacyOperationAsync(context, exact, isPending: true); + await context.Database.GetService().MigrateAsync(CurrentMigration); + + PostgresException blocked = await AssertPostgresFailureAsync( + PostgresErrorCodes.RaiseException, + () => context.Database.GetService() + .MigrateAsync(PreviousMigration)); + Assert.Contains( + DowngradeEvidenceMessage, + blocked.MessageText, + StringComparison.Ordinal); + Assert.Equal( + 1, + await MigrationHistoryCountAsync(connectionString, CurrentMigration)); + Assert.False(await ScalarBoolAsync( + connectionString, + "SELECT to_regclass('reservations.stay_amendment_operations') IS NULL;")); + Assert.Equal(2, await CountOperationRowsAsync(connectionString, exact)); + } + + [DockerFact] + [Trait("Category", "Docker")] + [Trait("Category", "Integration")] + public async Task Downgrade_rejects_distinct_adapter_pending_identifier() + { + await using PostgreSqlContainer postgreSql = + new PostgreSqlBuilder("postgres:16-alpine") + .WithDatabase("bunkfy_reservations_stay_amendment_adapter_down_tests") + .Build(); + await postgreSql.StartAsync(); + string connectionString = postgreSql.GetConnectionString(); + OperationSeed adapterPending = CreateSeed( + TenantA, + 'i', + BaseNowUtc, + inventoryRequestId: Guid.NewGuid(), + schemaVersion: 2); + + await using ReservationsDbContext context = CreateDbContext(connectionString); + await context.Database.GetService().MigrateAsync(PreviousMigration); + await SeedReservationAsync(context, adapterPending, isPending: false); + await context.Database.GetService().MigrateAsync(CurrentMigration); + await SetPendingAndInsertOperationPairAsync( + context, + adapterPending, + kind: 7, + StayInsert.Pending(adapterPending, schemaVersion: 2)); + Assert.NotEqual( + adapterPending.OperationId, + adapterPending.InventoryRequestId); + + Guid destroyOperationId = await BeginTenantDestructionAsync( + context, + adapterPending.ScopeId, + BaseNowUtc.AddMinutes(1)); + await ExecuteDestroyDeleteAsync( + connectionString, + destroyOperationId, + """ + DELETE FROM reservations.management_operations + WHERE "ScopeId" = @scopeId + AND "ReservationId" = @reservationId + AND "Id" = @operationId; + """, + adapterPending); + Assert.Equal(0, await CountOperationRowsAsync(connectionString, adapterPending)); + + PostgresException blocked = await AssertPostgresFailureAsync( + PostgresErrorCodes.RaiseException, + () => context.Database.GetService() + .MigrateAsync(PreviousMigration)); + Assert.Contains( + DowngradeEvidenceMessage, + blocked.MessageText, + StringComparison.Ordinal); + Assert.Equal( + 1, + await MigrationHistoryCountAsync(connectionString, CurrentMigration)); + Assert.False(await ScalarBoolAsync( + connectionString, + "SELECT to_regclass('reservations.stay_amendment_operations') IS NULL;")); + Assert.False(await HasNoPendingIdentifiersAsync( + connectionString, + adapterPending)); + } + + [DockerFact] + [Trait("Category", "Docker")] + [Trait("Category", "Integration")] + public async Task Downgrade_rejects_kind_seven_and_reconciled_evidence() + { + await using PostgreSqlContainer postgreSql = + new PostgreSqlBuilder("postgres:16-alpine") + .WithDatabase("bunkfy_reservations_stay_amendment_v2_down_tests") + .Build(); + await postgreSql.StartAsync(); + string connectionString = postgreSql.GetConnectionString(); + OperationSeed operation = CreateSeed( + TenantA, + 'b', + BaseNowUtc, + schemaVersion: 2); + + await using ReservationsDbContext context = CreateDbContext(connectionString); + await context.Database.GetService().MigrateAsync(PreviousMigration); + await SeedReservationAsync(context, operation, isPending: false); + await context.Database.GetService().MigrateAsync(CurrentMigration); + await SetPendingAndInsertOperationPairAsync( + context, + operation, + kind: 7, + StayInsert.Pending(operation, schemaVersion: 2)); + await using (Microsoft.EntityFrameworkCore.Storage.IDbContextTransaction + reconciliation = await context.Database.BeginTransactionAsync()) + { + await context.Database.ExecuteSqlInterpolatedAsync($""" + UPDATE reservations.reservations + SET "Version" = "Version" + 1, + "UpdatedAtUtc" = {operation.CreatedAtUtc.AddMinutes(5)} + WHERE "ScopeId" = {operation.ScopeId} + AND "Id" = {operation.ReservationId}; + + UPDATE reservations.stay_amendment_operations + SET "OperationVersion" = 2, + "ReconciliationCount" = 1, + "LastReconciledAtUtc" = {operation.CreatedAtUtc.AddMinutes(5)}, + "LastReconciledBy" = {"staff:recovery"}, + "UpdatedAtUtc" = {operation.CreatedAtUtc.AddMinutes(5)} + WHERE "ScopeId" = {operation.ScopeId} + AND "ReservationId" = {operation.ReservationId} + AND "Id" = {operation.OperationId}; + """); + await reconciliation.CommitAsync(); + } + + PostgresException blocked = await AssertPostgresFailureAsync( + PostgresErrorCodes.RaiseException, + () => context.Database.GetService() + .MigrateAsync(PreviousMigration)); + Assert.Contains( + DowngradeEvidenceMessage, + blocked.MessageText, + StringComparison.Ordinal); + Assert.Equal( + 1, + await MigrationHistoryCountAsync(connectionString, CurrentMigration)); + Assert.Equal(2, await CountOperationRowsAsync(connectionString, operation)); + } + + [DockerFact] + [Trait("Category", "Docker")] + [Trait("Category", "Integration")] + public async Task Downgrade_nowait_lock_failure_preserves_schema_and_history() + { + await using PostgreSqlContainer postgreSql = + new PostgreSqlBuilder("postgres:16-alpine") + .WithDatabase("bunkfy_reservations_stay_amendment_down_lock_tests") + .Build(); + await postgreSql.StartAsync(); + string connectionString = postgreSql.GetConnectionString(); + OperationSeed historical = CreateSeed(TenantA, 'd', BaseNowUtc); + + await using ReservationsDbContext context = CreateDbContext(connectionString); + await context.Database.GetService().MigrateAsync(PreviousMigration); + await SeedLegacyOperationAsync(context, historical, isPending: false); + await context.Database.GetService().MigrateAsync(CurrentMigration); + + await using NpgsqlConnection blocker = new(connectionString); + await blocker.OpenAsync(); + await using NpgsqlTransaction blockerTransaction = + await blocker.BeginTransactionAsync(); + await ExecuteAsync( + blocker, + blockerTransaction, + $""" + UPDATE reservations.tenant_revisions + SET "Revision" = "Revision" + WHERE "ScopeId" = '{TenantA}'; + """); + + PostgresException unavailable = await AssertPostgresFailureAsync( + PostgresErrorCodes.LockNotAvailable, + () => context.Database.GetService() + .MigrateAsync(PreviousMigration)); + Assert.Contains( + "lock", + unavailable.MessageText, + StringComparison.OrdinalIgnoreCase); + Assert.Equal( + 1, + await MigrationHistoryCountAsync(connectionString, CurrentMigration)); + Assert.False(await ScalarBoolAsync( + connectionString, + "SELECT to_regclass('reservations.stay_amendment_operations') IS NULL;")); + Assert.Equal(2, await CountOperationRowsAsync(connectionString, historical)); + + await blockerTransaction.RollbackAsync(); + } + + private static OperationSeed CreateSeed( + string scopeId, + char fingerprintCharacter, + DateTimeOffset createdAtUtc, + Guid? operationId = null, + Guid? inventoryRequestId = null, + int schemaVersion = 1) + { + Guid localOperationId = operationId ?? Guid.NewGuid(); + OperationSeed seed = new( + scopeId, + Guid.NewGuid(), + Guid.NewGuid(), + localOperationId, + inventoryRequestId ?? localOperationId, + Guid.NewGuid(), + string.Empty, + new DateOnly(2026, 9, 2), + new DateOnly(2026, 9, 6), + new TimeOnly(15, 30), + new TimeOnly(10, 0), + [Guid.NewGuid(), Guid.NewGuid()], + ExpectedDetailsRevision: 3, + RequestedBy: $"staff:operator-{fingerprintCharacter}", + createdAtUtc); + return seed with + { + RequestFingerprint = ComputeFingerprint(seed, schemaVersion) + }; + } + + private static string ComputeFingerprint(OperationSeed seed, int schemaVersion) + { + string reservationId = seed.ReservationId.ToString("N"); + string operationId = seed.OperationId.ToString("N"); + string material = schemaVersion switch + { + 1 => string.Join( + '|', + reservationId, + operationId, + seed.ExpectedDetailsRevision.ToString(CultureInfo.InvariantCulture), + seed.TargetInventoryUnitIds), + 2 => + $"v2|reservation={reservationId}|operation={operationId}" + + $"|expected-details-revision={seed.ExpectedDetailsRevision}" + + $"|arrival={seed.TargetArrival:yyyy-MM-dd}" + + $"|departure={seed.TargetDeparture:yyyy-MM-dd}" + + $"|expected-arrival-time={FormatTime(seed.TargetExpectedArrivalTime)}" + + $"|expected-departure-time={FormatTime(seed.TargetExpectedDepartureTime)}" + + $"|units={seed.TargetInventoryUnitIds}", + _ => throw new ArgumentOutOfRangeException(nameof(schemaVersion)) + }; + return Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(material))); + } + + private static string FormatTime(TimeOnly? value) => + value?.ToString("HH:mm", CultureInfo.InvariantCulture) ?? "-"; + + private static async Task SeedLegacyOperationAsync( + ReservationsDbContext context, + OperationSeed seed, + bool isPending) + { + await SeedReservationAsync(context, seed, isPending); + await InsertManagementOperationAsync(context, seed, kind: 6); + } + + private static async Task SeedReservationAsync( + ReservationsDbContext context, + OperationSeed seed, + bool isPending) + { + Guid? pendingOperationId = isPending ? seed.OperationId : null; + string? pendingFingerprint = isPending ? seed.RequestFingerprint : null; + DateOnly? pendingArrival = isPending ? seed.TargetArrival : null; + DateOnly? pendingDeparture = isPending ? seed.TargetDeparture : null; + TimeOnly? pendingArrivalTime = + isPending ? seed.TargetExpectedArrivalTime : null; + TimeOnly? pendingDepartureTime = + isPending ? seed.TargetExpectedDepartureTime : null; + string? pendingUnits = isPending ? seed.TargetInventoryUnitIds : null; + string? pendingName = isPending ? "Pending Guest" : null; + string? pendingNameSearch = isPending ? "PENDING GUEST" : null; + int? pendingGuestCount = isPending ? 2 : null; + string? pendingActor = isPending ? seed.RequestedBy : null; + Guid? pendingCorrelationId = isPending ? Guid.NewGuid() : null; + DateTimeOffset? updatedAtUtc = isPending ? seed.CreatedAtUtc : null; + + await context.Database.ExecuteSqlInterpolatedAsync($""" + INSERT INTO reservations.tenant_revisions ( + "ScopeId", "Revision", "LifecycleStatus") + VALUES ({seed.ScopeId}, {1L}, {1}) + ON CONFLICT ("ScopeId") DO NOTHING; + + INSERT INTO reservations.reservations ( + "Id", "PropertyId", "AllocationRequestId", "AllocationId", + "AllocationVersion", "Arrival", "Departure", + "ExpectedArrivalTime", "ExpectedDepartureTime", + "PrimaryGuestName", "PrimaryGuestNameSearch", + "Email", "EmailSearch", "Phone", "PhoneSearch", + "GuestCount", "Source", "Status", "Version", + "DetailsRevision", "LastDetailsChangeOrigin", + "LastDetailsActorId", "LastDetailsChangedAtUtc", + "PendingAllocationAmendmentId", + "PendingAllocationAmendmentRequestFingerprint", + "PendingArrival", "PendingDeparture", + "PendingExpectedArrivalTime", "PendingExpectedDepartureTime", + "PendingInventoryUnitIds", "PendingPrimaryGuestName", + "PendingPrimaryGuestNameSearch", "PendingGuestCount", + "PendingDetailsChangeOrigin", "PendingDetailsActorId", + "PendingDetailsCorrelationId", "CreatedAtUtc", "UpdatedAtUtc", + "ScopeId") + VALUES ( + {seed.ReservationId}, {seed.PropertyId}, {Guid.NewGuid()}, + {seed.AllocationId}, {2L}, + {new DateOnly(2026, 9, 1)}, {new DateOnly(2026, 9, 5)}, + {new TimeOnly(14, 0)}, {new TimeOnly(11, 0)}, + {"Existing Guest"}, {"EXISTING GUEST"}, + {"existing@example.test"}, {"EXISTING@EXAMPLE.TEST"}, + {"+44 20 1234 5678"}, {"+44 20 1234 5678"}, + {1}, {1}, {2}, {(isPending ? 6L : 5L)}, + {seed.ExpectedDetailsRevision}, {1}, {"staff:seed"}, + {seed.CreatedAtUtc.AddDays(-1)}, + {pendingOperationId}, {pendingFingerprint}, + {pendingArrival}, {pendingDeparture}, + {pendingArrivalTime}, {pendingDepartureTime}, + {pendingUnits}, {pendingName}, {pendingNameSearch}, + {pendingGuestCount}, {(isPending ? 1 : 0)}, {pendingActor}, + {pendingCorrelationId}, {seed.CreatedAtUtc.AddDays(-2)}, + {updatedAtUtc}, {seed.ScopeId}); + """); + } + + private static Task MarkPendingAsAdapterAsync( + ReservationsDbContext context, + OperationSeed seed) => + context.Database.ExecuteSqlInterpolatedAsync($""" + UPDATE reservations.reservations + SET "PendingDetailsChangeOrigin" = {2}, + "PendingDetailsAdapterConnectionId" = {Guid.NewGuid()}, + "PendingDetailsExternalOperationId" = {seed.OperationId} + WHERE "ScopeId" = {seed.ScopeId} + AND "Id" = {seed.ReservationId}; + """); + + private static async Task AssertPredecessorWriterRejectedAsync( + string connectionString, + OperationSeed seed, + bool insertParentFirst) + { + await using NpgsqlConnection connection = new(connectionString); + await connection.OpenAsync(); + await using NpgsqlTransaction transaction = + await connection.BeginTransactionAsync(); + if (insertParentFirst) + { + await InsertManagementOperationAsync(connection, transaction, seed, kind: 6); + } + + PostgresException rejected = await AssertPostgresFailureAsync( + PostgresErrorCodes.CheckViolation, + () => ExecutePredecessorPendingUpdateAsync( + connection, + transaction, + seed)); + Assert.Equal( + "CK_reservations_pending_inventory_request", + rejected.ConstraintName); + await transaction.RollbackAsync(); + } + + private static async Task ExecutePredecessorPendingUpdateAsync( + NpgsqlConnection connection, + NpgsqlTransaction transaction, + OperationSeed seed) + { + await using NpgsqlCommand command = new( + """ + UPDATE reservations.reservations + SET "Version" = 6, + "PendingAllocationAmendmentId" = @operationId, + "PendingAllocationAmendmentRequestFingerprint" = @fingerprint, + "PendingArrival" = @arrival, + "PendingDeparture" = @departure, + "PendingExpectedArrivalTime" = @arrivalTime, + "PendingExpectedDepartureTime" = @departureTime, + "PendingInventoryUnitIds" = @units, + "PendingPrimaryGuestName" = 'Pending Guest', + "PendingPrimaryGuestNameSearch" = 'PENDING GUEST', + "PendingGuestCount" = 2, + "PendingDetailsChangeOrigin" = 1, + "PendingDetailsActorId" = @actor, + "PendingDetailsCorrelationId" = @correlationId, + "UpdatedAtUtc" = @updatedAtUtc + WHERE "ScopeId" = @scopeId AND "Id" = @reservationId; + """, + connection, + transaction); + command.Parameters.AddWithValue("operationId", seed.OperationId); + command.Parameters.AddWithValue("fingerprint", seed.RequestFingerprint); + command.Parameters.AddWithValue("arrival", seed.TargetArrival); + command.Parameters.AddWithValue("departure", seed.TargetDeparture); + command.Parameters.AddWithValue( + "arrivalTime", + (object?)seed.TargetExpectedArrivalTime ?? DBNull.Value); + command.Parameters.AddWithValue( + "departureTime", + (object?)seed.TargetExpectedDepartureTime ?? DBNull.Value); + command.Parameters.AddWithValue("units", seed.TargetInventoryUnitIds); + command.Parameters.AddWithValue("actor", seed.RequestedBy); + command.Parameters.AddWithValue("correlationId", Guid.NewGuid()); + command.Parameters.AddWithValue("updatedAtUtc", seed.CreatedAtUtc); + command.Parameters.AddWithValue("scopeId", seed.ScopeId); + command.Parameters.AddWithValue("reservationId", seed.ReservationId); + await command.ExecuteNonQueryAsync(); + } + + private static async Task InsertManagementOperationAsync( + NpgsqlConnection connection, + NpgsqlTransaction transaction, + OperationSeed seed, + int kind) + { + await using NpgsqlCommand command = new( + """ + INSERT INTO reservations.management_operations ( + "Id", "ScopeId", "ReservationId", "PropertyId", "Kind", + "ExpectedVersion", "ExpectedDetailsRevision", "BusinessDate", + "CreatedAtUtc", "RequestFingerprint") + VALUES ( + @operationId, @scopeId, @reservationId, @propertyId, @kind, + NULL, @expectedDetailsRevision, NULL, @createdAtUtc, + @fingerprint); + """, + connection, + transaction); + command.Parameters.AddWithValue("operationId", seed.OperationId); + command.Parameters.AddWithValue("scopeId", seed.ScopeId); + command.Parameters.AddWithValue("reservationId", seed.ReservationId); + command.Parameters.AddWithValue("propertyId", seed.PropertyId); + command.Parameters.AddWithValue("kind", kind); + command.Parameters.AddWithValue( + "expectedDetailsRevision", + seed.ExpectedDetailsRevision); + command.Parameters.AddWithValue("createdAtUtc", seed.CreatedAtUtc); + command.Parameters.AddWithValue("fingerprint", seed.RequestFingerprint); + await command.ExecuteNonQueryAsync(); + } + + private static async Task HasNoPendingIdentifiersAsync( + string connectionString, + OperationSeed seed) + { + await using NpgsqlConnection connection = new(connectionString); + await connection.OpenAsync(); + await using NpgsqlCommand command = new( + """ + SELECT "PendingAllocationAmendmentId" IS NULL + AND "PendingInventoryAmendmentRequestId" IS NULL + FROM reservations.reservations + WHERE "ScopeId" = @scopeId AND "Id" = @reservationId; + """, + connection); + command.Parameters.AddWithValue("scopeId", seed.ScopeId); + command.Parameters.AddWithValue("reservationId", seed.ReservationId); + return (bool)(await command.ExecuteScalarAsync())!; + } + + private static async Task ReservationVersionAsync( + string connectionString, + OperationSeed seed) + { + await using NpgsqlConnection connection = new(connectionString); + await connection.OpenAsync(); + await using NpgsqlCommand command = new( + """ + SELECT "Version" + FROM reservations.reservations + WHERE "ScopeId" = @scopeId AND "Id" = @reservationId; + """, + connection); + command.Parameters.AddWithValue("scopeId", seed.ScopeId); + command.Parameters.AddWithValue("reservationId", seed.ReservationId); + return (long)(await command.ExecuteScalarAsync())!; + } + + private static async Task PendingInventoryRequestIdAsync( + string connectionString, + OperationSeed seed) + { + await using NpgsqlConnection connection = new(connectionString); + await connection.OpenAsync(); + await using NpgsqlCommand command = new( + """ + SELECT "PendingInventoryAmendmentRequestId" + FROM reservations.reservations + WHERE "ScopeId" = @scopeId AND "Id" = @reservationId; + """, + connection); + command.Parameters.AddWithValue("scopeId", seed.ScopeId); + command.Parameters.AddWithValue("reservationId", seed.ReservationId); + object? value = await command.ExecuteScalarAsync(); + return value is DBNull or null ? null : (Guid)value; + } + + private static Task SetPendingReservationAsync( + ReservationsDbContext context, + OperationSeed seed) => + context.Database.ExecuteSqlInterpolatedAsync($""" + UPDATE reservations.reservations + SET "Version" = {6L}, + "PendingAllocationAmendmentId" = {seed.OperationId}, + "PendingInventoryAmendmentRequestId" = + {seed.InventoryRequestId}, + "PendingAllocationAmendmentRequestFingerprint" = + {seed.RequestFingerprint}, + "PendingArrival" = {seed.TargetArrival}, + "PendingDeparture" = {seed.TargetDeparture}, + "PendingExpectedArrivalTime" = + {seed.TargetExpectedArrivalTime}, + "PendingExpectedDepartureTime" = + {seed.TargetExpectedDepartureTime}, + "PendingInventoryUnitIds" = {seed.TargetInventoryUnitIds}, + "PendingPrimaryGuestName" = {"Pending Guest"}, + "PendingPrimaryGuestNameSearch" = {"PENDING GUEST"}, + "PendingGuestCount" = {2}, + "PendingDetailsChangeOrigin" = {1}, + "PendingDetailsActorId" = {seed.RequestedBy}, + "PendingDetailsCorrelationId" = {Guid.NewGuid()}, + "UpdatedAtUtc" = {seed.CreatedAtUtc} + WHERE "ScopeId" = {seed.ScopeId} + AND "Id" = {seed.ReservationId}; + """); + + private static Task InsertCurrentPendingReservationAsync( + ReservationsDbContext context, + OperationSeed seed) => + context.Database.ExecuteSqlInterpolatedAsync($""" + INSERT INTO reservations.reservations ( + "Id", "PropertyId", "AllocationRequestId", "AllocationId", + "AllocationVersion", "Arrival", "Departure", + "ExpectedArrivalTime", "ExpectedDepartureTime", + "PrimaryGuestName", "PrimaryGuestNameSearch", + "Email", "EmailSearch", "Phone", "PhoneSearch", + "GuestCount", "Source", "Status", "Version", + "DetailsRevision", "LastDetailsChangeOrigin", + "LastDetailsActorId", "LastDetailsChangedAtUtc", + "PendingAllocationAmendmentId", + "PendingInventoryAmendmentRequestId", + "PendingAllocationAmendmentRequestFingerprint", + "PendingArrival", "PendingDeparture", + "PendingExpectedArrivalTime", "PendingExpectedDepartureTime", + "PendingInventoryUnitIds", "PendingPrimaryGuestName", + "PendingPrimaryGuestNameSearch", "PendingGuestCount", + "PendingDetailsChangeOrigin", "PendingDetailsActorId", + "PendingDetailsCorrelationId", "CreatedAtUtc", "UpdatedAtUtc", + "ScopeId") + VALUES ( + {seed.ReservationId}, {seed.PropertyId}, {Guid.NewGuid()}, + {seed.AllocationId}, {2L}, + {new DateOnly(2026, 9, 1)}, {new DateOnly(2026, 9, 5)}, + {new TimeOnly(14, 0)}, {new TimeOnly(11, 0)}, + {"Existing Guest"}, {"EXISTING GUEST"}, + {"existing@example.test"}, {"EXISTING@EXAMPLE.TEST"}, + {"+44 20 1234 5678"}, {"+44 20 1234 5678"}, + {1}, {1}, {2}, {6L}, {seed.ExpectedDetailsRevision}, {1}, + {"staff:seed"}, {seed.CreatedAtUtc.AddDays(-1)}, + {seed.OperationId}, {seed.InventoryRequestId}, + {seed.RequestFingerprint}, {seed.TargetArrival}, + {seed.TargetDeparture}, {seed.TargetExpectedArrivalTime}, + {seed.TargetExpectedDepartureTime}, + {seed.TargetInventoryUnitIds}, {"Pending Guest"}, + {"PENDING GUEST"}, {2}, {1}, {seed.RequestedBy}, + {Guid.NewGuid()}, {seed.CreatedAtUtc.AddDays(-2)}, + {seed.CreatedAtUtc}, {seed.ScopeId}); + """); + + private static Task InsertManagementOperationAsync( + ReservationsDbContext context, + OperationSeed seed, + int kind) => + context.Database.ExecuteSqlInterpolatedAsync($""" + INSERT INTO reservations.management_operations ( + "Id", "ScopeId", "ReservationId", "PropertyId", "Kind", + "ExpectedVersion", "ExpectedDetailsRevision", "BusinessDate", + "CreatedAtUtc", "RequestFingerprint") + VALUES ( + {seed.OperationId}, {seed.ScopeId}, {seed.ReservationId}, + {seed.PropertyId}, {kind}, NULL, + {seed.ExpectedDetailsRevision}, NULL, {seed.CreatedAtUtc}, + {seed.RequestFingerprint}); + """); + + private static async Task InsertOperationPairAsync( + ReservationsDbContext context, + OperationSeed seed, + int kind, + StayInsert operation) + { + await using Microsoft.EntityFrameworkCore.Storage.IDbContextTransaction + transaction = await context.Database.BeginTransactionAsync(); + await InsertManagementOperationAsync(context, seed, kind); + await InsertStayOperationAsync(context, operation); + await transaction.CommitAsync(); + } + + private static async Task SetPendingAndInsertOperationPairAsync( + ReservationsDbContext context, + OperationSeed seed, + int kind, + StayInsert operation) + { + await using Microsoft.EntityFrameworkCore.Storage.IDbContextTransaction + transaction = await context.Database.BeginTransactionAsync(); + await SetPendingReservationAsync(context, seed); + await InsertManagementOperationAsync(context, seed, kind); + await InsertStayOperationAsync(context, operation); + await transaction.CommitAsync(); + } + + private static async Task CompleteAppliedAsync( + ReservationsDbContext context, + OperationSeed seed) + { + DateTimeOffset completedAtUtc = seed.CreatedAtUtc.AddMinutes(6); + await using Microsoft.EntityFrameworkCore.Storage.IDbContextTransaction + transaction = await context.Database.BeginTransactionAsync(); + await context.Database.ExecuteSqlInterpolatedAsync($""" + UPDATE reservations.reservations + SET "Arrival" = {seed.TargetArrival}, + "Departure" = {seed.TargetDeparture}, + "AllocationVersion" = {3L}, + "ExpectedArrivalTime" = {seed.TargetExpectedArrivalTime}, + "ExpectedDepartureTime" = {seed.TargetExpectedDepartureTime}, + "Version" = {8L}, + "DetailsRevision" = {4L}, + "PendingAllocationAmendmentId" = NULL, + "PendingInventoryAmendmentRequestId" = NULL, + "PendingAllocationAmendmentRequestFingerprint" = NULL, + "PendingArrival" = NULL, + "PendingDeparture" = NULL, + "PendingExpectedArrivalTime" = NULL, + "PendingExpectedDepartureTime" = NULL, + "PendingInventoryUnitIds" = NULL, + "PendingPrimaryGuestName" = NULL, + "PendingPrimaryGuestNameSearch" = NULL, + "PendingGuestCount" = NULL, + "PendingDetailsChangeOrigin" = 0, + "PendingDetailsActorId" = NULL, + "PendingDetailsCorrelationId" = NULL, + "UpdatedAtUtc" = {completedAtUtc} + WHERE "ScopeId" = {seed.ScopeId} + AND "Id" = {seed.ReservationId}; + + INSERT INTO reservations.requested_inventory_units ( + "Id", "ScopeId", "ReservationId") + VALUES + ({seed.TargetInventoryUnitIdsValue.ElementAt(0)}, + {seed.ScopeId}, {seed.ReservationId}), + ({seed.TargetInventoryUnitIdsValue.ElementAt(1)}, + {seed.ScopeId}, {seed.ReservationId}); + + UPDATE reservations.stay_amendment_operations + SET "Outcome" = 2, + "OperationVersion" = 3, + "CompletedAtUtc" = {completedAtUtc}, + "ResultingDetailsRevision" = {4L}, + "ResultingReservationVersion" = {8L}, + "ResultingAllocationVersion" = {3L}, + "UpdatedAtUtc" = {completedAtUtc} + WHERE "ScopeId" = {seed.ScopeId} + AND "ReservationId" = {seed.ReservationId} + AND "Id" = {seed.OperationId}; + """); + await transaction.CommitAsync(); + } + + private static async Task CompleteRejectedAsync( + ReservationsDbContext context, + OperationSeed seed) + { + DateTimeOffset completedAtUtc = seed.CreatedAtUtc.AddMinutes(1); + await using Microsoft.EntityFrameworkCore.Storage.IDbContextTransaction + transaction = await context.Database.BeginTransactionAsync(); + await context.Database.ExecuteSqlInterpolatedAsync($""" + UPDATE reservations.reservations + SET "Version" = {7L}, + "PendingAllocationAmendmentId" = NULL, + "PendingInventoryAmendmentRequestId" = NULL, + "PendingAllocationAmendmentRequestFingerprint" = NULL, + "PendingArrival" = NULL, + "PendingDeparture" = NULL, + "PendingExpectedArrivalTime" = NULL, + "PendingExpectedDepartureTime" = NULL, + "PendingInventoryUnitIds" = NULL, + "PendingPrimaryGuestName" = NULL, + "PendingPrimaryGuestNameSearch" = NULL, + "PendingGuestCount" = NULL, + "PendingDetailsChangeOrigin" = 0, + "PendingDetailsActorId" = NULL, + "PendingDetailsCorrelationId" = NULL, + "LastAllocationAmendmentRejectionCode" = {9}, + "UpdatedAtUtc" = {completedAtUtc} + WHERE "ScopeId" = {seed.ScopeId} + AND "Id" = {seed.ReservationId}; + + UPDATE reservations.stay_amendment_operations + SET "Outcome" = 3, + "OperationVersion" = 2, + "CompletedAtUtc" = {completedAtUtc}, + "ResultingDetailsRevision" = {3L}, + "ResultingReservationVersion" = {7L}, + "RejectionCode" = {9}, + "UpdatedAtUtc" = {completedAtUtc} + WHERE "ScopeId" = {seed.ScopeId} + AND "ReservationId" = {seed.ReservationId} + AND "Id" = {seed.OperationId}; + """); + await transaction.CommitAsync(); + } + + private static Task ClearPendingReservationAsOldHandlerAsync( + ReservationsDbContext context, + OperationSeed seed) => + context.Database.ExecuteSqlInterpolatedAsync($""" + UPDATE reservations.reservations + SET "Version" = {7L}, + "PendingAllocationAmendmentId" = NULL, + "PendingAllocationAmendmentRequestFingerprint" = NULL, + "PendingArrival" = NULL, + "PendingDeparture" = NULL, + "PendingExpectedArrivalTime" = NULL, + "PendingExpectedDepartureTime" = NULL, + "PendingInventoryUnitIds" = NULL, + "PendingPrimaryGuestName" = NULL, + "PendingPrimaryGuestNameSearch" = NULL, + "PendingGuestCount" = NULL, + "PendingDetailsChangeOrigin" = 0, + "PendingDetailsActorId" = NULL, + "PendingDetailsCorrelationId" = NULL, + "UpdatedAtUtc" = {seed.CreatedAtUtc.AddMinutes(1)} + WHERE "ScopeId" = {seed.ScopeId} + AND "Id" = {seed.ReservationId}; + """); + + private static Task InsertStayOperationAsync( + ReservationsDbContext context, + StayInsert operation) => + context.Database.ExecuteSqlInterpolatedAsync($""" + INSERT INTO reservations.stay_amendment_operations ( + "Id", "ScopeId", "ReservationId", "PropertyId", + "InventoryRequestId", + "RequestSchemaVersion", "RequestFingerprint", + "TargetArrival", "TargetDeparture", + "TargetExpectedArrivalTime", "TargetExpectedDepartureTime", + "TargetInventoryUnitIds", "ExpectedDetailsRevision", + "RequestedBy", "Outcome", "OperationVersion", + "RequestedAtUtc", "UpdatedAtUtc", "CompletedAtUtc", + "ResultingDetailsRevision", "ResultingReservationVersion", + "ResultingAllocationVersion", "RejectionCode", + "ReconciliationCount", + "LastReconciledAtUtc", "LastReconciledBy") + VALUES ( + {operation.OperationId}, {operation.ScopeId}, + {operation.ReservationId}, {operation.PropertyId}, + {operation.InventoryRequestId}, + {operation.RequestSchemaVersion}, {operation.RequestFingerprint}, + {operation.TargetArrival}, {operation.TargetDeparture}, + {operation.TargetExpectedArrivalTime}, + {operation.TargetExpectedDepartureTime}, + {operation.TargetInventoryUnitIds}, + {operation.ExpectedDetailsRevision}, {operation.RequestedBy}, + {operation.Outcome}, {operation.OperationVersion}, + {operation.RequestedAtUtc}, {operation.UpdatedAtUtc}, + {operation.CompletedAtUtc}, + {operation.ResultingDetailsRevision}, + {operation.ResultingReservationVersion}, + {operation.ResultingAllocationVersion}, + {operation.RejectionCode}, {operation.ReconciliationCount}, + {operation.LastReconciledAtUtc}, {operation.LastReconciledBy}); + """); + + private static Task DeleteChildAsync( + ReservationsDbContext context, + OperationSeed seed) => + context.Database.ExecuteSqlInterpolatedAsync($""" + DELETE FROM reservations.stay_amendment_operations + WHERE "ScopeId" = {seed.ScopeId} + AND "ReservationId" = {seed.ReservationId} + AND "Id" = {seed.OperationId}; + """); + + private static Task DeleteParentAsync( + ReservationsDbContext context, + OperationSeed seed) => + context.Database.ExecuteSqlInterpolatedAsync($""" + DELETE FROM reservations.management_operations + WHERE "ScopeId" = {seed.ScopeId} + AND "ReservationId" = {seed.ReservationId} + AND "Id" = {seed.OperationId}; + """); + + private static Task DeleteReservationAsync( + ReservationsDbContext context, + OperationSeed seed) => + context.Database.ExecuteSqlInterpolatedAsync($""" + DELETE FROM reservations.reservations + WHERE "ScopeId" = {seed.ScopeId} + AND "Id" = {seed.ReservationId}; + """); + + private static async Task BeginTenantDestructionAsync( + ReservationsDbContext context, + string scopeId, + DateTimeOffset startedAtUtc) + { + Guid operationId = Guid.NewGuid(); + string requestSha256 = new('c', 64); + string proofSha256 = new('d', 64); + await context.Database.ExecuteSqlInterpolatedAsync($""" + UPDATE reservations.tenant_revisions + SET "Revision" = 2, + "LifecycleStatus" = 2, + "DestroyOperationId" = {operationId}, + "DestroyRequestSha256" = {requestSha256}, + "DestroyStartedAtUtc" = {startedAtUtc}, + "DestroyCompletedAtUtc" = NULL + WHERE "ScopeId" = {scopeId}; + + INSERT INTO reservations.tenant_destroy_operations ( + "OperationId", "ScopeId", "RequestSha256", + "SelectedRevision", "ResultingRevision", "BatchSize", "Stage", + "RemovedRecordCount", "CompletedBatchCount", "ProofVersion", + "RemovalProofSha256", "StartedAtUtc", "UpdatedAtUtc", + "ConcurrencyVersion") + VALUES ( + {operationId}, {scopeId}, {requestSha256}, {1L}, {2L}, {100}, + {1}, {0L}, {0}, {1}, {proofSha256}, {startedAtUtc}, + {startedAtUtc}, {1}); + """); + return operationId; + } + + private static async Task AssertDestroyDeleteRejectedAsync( + string connectionString, + Guid destroyOperationId, + string sql, + OperationSeed seed) + { + await using NpgsqlConnection connection = new(connectionString); + await connection.OpenAsync(); + await using NpgsqlTransaction transaction = + await connection.BeginTransactionAsync(); + await SetTenantDestroyOperationAsync( + connection, + transaction, + destroyOperationId); + await AssertPostgresFailureAsync( + PostgresErrorCodes.RaiseException, + () => ExecuteSeedCommandAsync(connection, transaction, sql, seed)); + await transaction.RollbackAsync(); + } + + private static async Task ExecuteDestroyDeleteAsync( + string connectionString, + Guid destroyOperationId, + string sql, + OperationSeed seed) + { + await using NpgsqlConnection connection = new(connectionString); + await connection.OpenAsync(); + await using NpgsqlTransaction transaction = + await connection.BeginTransactionAsync(); + await SetTenantDestroyOperationAsync( + connection, + transaction, + destroyOperationId); + int affected = await ExecuteSeedCommandAsync( + connection, + transaction, + sql, + seed); + Assert.Equal(1, affected); + await transaction.CommitAsync(); + } + + private static Task ExecuteSeedCommandAsync( + NpgsqlConnection connection, + NpgsqlTransaction transaction, + string sql, + OperationSeed seed) + { + NpgsqlCommand command = new(sql, connection, transaction); + command.Parameters.AddWithValue("scopeId", seed.ScopeId); + command.Parameters.AddWithValue("reservationId", seed.ReservationId); + command.Parameters.AddWithValue("operationId", seed.OperationId); + return ExecuteAndDisposeAsync(command); + } + + private static async Task ExecuteAndDisposeAsync(NpgsqlCommand command) + { + await using (command) + { + return await command.ExecuteNonQueryAsync(); + } + } + + private static async Task SetTenantDestroyOperationAsync( + NpgsqlConnection connection, + NpgsqlTransaction transaction, + Guid operationId) + { + await using NpgsqlCommand command = new( + "SELECT set_config('bunkfy.reservations_tenant_destroy_operation_id', @operationId, true);", + connection, + transaction); + command.Parameters.AddWithValue("operationId", operationId.ToString("D")); + await command.ExecuteScalarAsync(); + } + + private static async Task ReadStayOperationAsync( + string connectionString, + OperationSeed seed) + { + await using NpgsqlConnection connection = new(connectionString); + await connection.OpenAsync(); + await using NpgsqlCommand command = new( + """ + SELECT + "InventoryRequestId", "RequestSchemaVersion", "RequestFingerprint", + "TargetArrival", "TargetDeparture", + "TargetExpectedArrivalTime", "TargetExpectedDepartureTime", + "TargetInventoryUnitIds", "ExpectedDetailsRevision", + "RequestedBy", "Outcome", "OperationVersion", + "ReconciliationCount", + "RequestedAtUtc" = @createdAtUtc AND + "UpdatedAtUtc" = @createdAtUtc AS "TimesMatch" + FROM reservations.stay_amendment_operations + WHERE "ScopeId" = @scopeId + AND "ReservationId" = @reservationId + AND "Id" = @operationId; + """, + connection); + command.Parameters.AddWithValue("createdAtUtc", seed.CreatedAtUtc); + command.Parameters.AddWithValue("scopeId", seed.ScopeId); + command.Parameters.AddWithValue("reservationId", seed.ReservationId); + command.Parameters.AddWithValue("operationId", seed.OperationId); + await using NpgsqlDataReader reader = await command.ExecuteReaderAsync(); + Assert.True(await reader.ReadAsync()); + return new( + GetNullableValue(reader, 0), + reader.GetInt32(1), + reader.GetString(2), + GetNullableValue(reader, 3), + GetNullableValue(reader, 4), + GetNullableValue(reader, 5), + GetNullableValue(reader, 6), + GetNullableString(reader, 7), + reader.GetInt64(8), + GetNullableString(reader, 9), + reader.GetInt32(10), + reader.GetInt64(11), + reader.GetInt32(12), + reader.GetBoolean(13)); + } + + private static T? GetNullableValue( + NpgsqlDataReader reader, + int ordinal) + where T : struct => + reader.IsDBNull(ordinal) ? null : reader.GetFieldValue(ordinal); + + private static string? GetNullableString( + NpgsqlDataReader reader, + int ordinal) => + reader.IsDBNull(ordinal) ? null : reader.GetString(ordinal); + + private static void AssertExactLegacyBackfill( + StayOperationRow row, + OperationSeed seed) + { + Assert.Equal(seed.OperationId, row.InventoryRequestId); + Assert.Equal(1, row.RequestSchemaVersion); + Assert.Equal(seed.RequestFingerprint, row.RequestFingerprint); + Assert.Equal(seed.TargetArrival, row.TargetArrival); + Assert.Equal(seed.TargetDeparture, row.TargetDeparture); + Assert.Equal( + seed.TargetExpectedArrivalTime, + row.TargetExpectedArrivalTime); + Assert.Equal( + seed.TargetExpectedDepartureTime, + row.TargetExpectedDepartureTime); + Assert.Equal(seed.TargetInventoryUnitIds, row.TargetInventoryUnitIds); + Assert.Equal(seed.ExpectedDetailsRevision, row.ExpectedDetailsRevision); + Assert.Equal(seed.RequestedBy, row.RequestedBy); + Assert.Equal(1, row.Outcome); + Assert.Equal(1, row.OperationVersion); + Assert.Equal(0, row.ReconciliationCount); + Assert.True(row.TimesMatch); + } + + private static void AssertHistoricalUnknownBackfill( + StayOperationRow row, + OperationSeed seed) + { + Assert.Null(row.InventoryRequestId); + Assert.Equal(1, row.RequestSchemaVersion); + Assert.Equal(seed.RequestFingerprint, row.RequestFingerprint); + Assert.Null(row.TargetArrival); + Assert.Null(row.TargetDeparture); + Assert.Null(row.TargetExpectedArrivalTime); + Assert.Null(row.TargetExpectedDepartureTime); + Assert.Null(row.TargetInventoryUnitIds); + Assert.Equal(seed.ExpectedDetailsRevision, row.ExpectedDetailsRevision); + Assert.Null(row.RequestedBy); + Assert.Equal(4, row.Outcome); + Assert.Equal(1, row.OperationVersion); + Assert.Equal(0, row.ReconciliationCount); + Assert.True(row.TimesMatch); + } + + private static async Task MigrationHistoryCountAsync( + string connectionString, + string migrationId) + { + await using NpgsqlConnection connection = new(connectionString); + await connection.OpenAsync(); + await using NpgsqlCommand command = new( + """ + SELECT COUNT(*)::int + FROM reservations.__ef_migrations_history + WHERE "MigrationId" = @migrationId; + """, + connection); + command.Parameters.AddWithValue("migrationId", migrationId); + return (int)(await command.ExecuteScalarAsync())!; + } + + private static async Task CountOperationRowsAsync( + string connectionString, + OperationSeed seed) + { + await using NpgsqlConnection connection = new(connectionString); + await connection.OpenAsync(); + await using NpgsqlCommand command = new( + """ + SELECT ( + (SELECT COUNT(*) + FROM reservations.management_operations + WHERE "ScopeId" = @scopeId + AND "ReservationId" = @reservationId + AND "Id" = @operationId) + + (SELECT COUNT(*) + FROM reservations.stay_amendment_operations + WHERE "ScopeId" = @scopeId + AND "ReservationId" = @reservationId + AND "Id" = @operationId))::int; + """, + connection); + command.Parameters.AddWithValue("scopeId", seed.ScopeId); + command.Parameters.AddWithValue("reservationId", seed.ReservationId); + command.Parameters.AddWithValue("operationId", seed.OperationId); + return (int)(await command.ExecuteScalarAsync())!; + } + + private static async Task CountReservationRowsAsync( + string connectionString, + OperationSeed seed) + { + await using NpgsqlConnection connection = new(connectionString); + await connection.OpenAsync(); + await using NpgsqlCommand command = new( + """ + SELECT COUNT(*)::int + FROM reservations.reservations + WHERE "ScopeId" = @scopeId AND "Id" = @reservationId; + """, + connection); + command.Parameters.AddWithValue("scopeId", seed.ScopeId); + command.Parameters.AddWithValue("reservationId", seed.ReservationId); + return (int)(await command.ExecuteScalarAsync())!; + } + + private static async Task ScalarIntAsync( + string connectionString, + string sql) + { + await using NpgsqlConnection connection = new(connectionString); + await connection.OpenAsync(); + await using NpgsqlCommand command = new(sql, connection); + return (int)(await command.ExecuteScalarAsync())!; + } + + private static async Task ScalarBoolAsync( + string connectionString, + string sql) + { + await using NpgsqlConnection connection = new(connectionString); + await connection.OpenAsync(); + await using NpgsqlCommand command = new(sql, connection); + return (bool)(await command.ExecuteScalarAsync())!; + } + + private static async Task ScalarStringAsync( + string connectionString, + string sql) + { + await using NpgsqlConnection connection = new(connectionString); + await connection.OpenAsync(); + await using NpgsqlCommand command = new(sql, connection); + return (string)(await command.ExecuteScalarAsync())!; + } + + private static async Task ExecuteAsync( + NpgsqlConnection connection, + NpgsqlTransaction transaction, + string sql) + { + await using NpgsqlCommand command = new(sql, connection, transaction); + await command.ExecuteNonQueryAsync(); + } + + private static async Task AssertPostgresFailureAsync( + string sqlState, + Func action) + { + Exception failure = await Assert.ThrowsAnyAsync(action); + PostgresException? providerFailure = failure as PostgresException; + for (Exception? current = failure.InnerException; + providerFailure is null && current is not null; + current = current.InnerException) + { + providerFailure = current as PostgresException; + } + + Assert.NotNull(providerFailure); + Assert.Equal(sqlState, providerFailure.SqlState); + return providerFailure; + } + + private static ReservationsDbContext CreateDbContext(string connectionString) + { + DbContextOptions options = + new DbContextOptionsBuilder() + .UseNpgsql(connectionString, provider => provider + .MigrationsAssembly(ReservationsMigrations.PostgreSqlAssembly) + .MigrationsHistoryTable( + ReservationsMigrations.HistoryTable, + ReservationsMigrations.Schema)) + .Options; + return new( + options, + new TestScopeContext(), + OpenWorkspaceTerminationFenceReader.Instance); + } + + private sealed record OperationSeed( + string ScopeId, + Guid ReservationId, + Guid PropertyId, + Guid OperationId, + Guid InventoryRequestId, + Guid AllocationId, + string RequestFingerprint, + DateOnly TargetArrival, + DateOnly TargetDeparture, + TimeOnly? TargetExpectedArrivalTime, + TimeOnly? TargetExpectedDepartureTime, + IReadOnlyCollection TargetInventoryUnitIdsValue, + long ExpectedDetailsRevision, + string RequestedBy, + DateTimeOffset CreatedAtUtc) + { + public string TargetInventoryUnitIds => string.Join( + ',', + this.TargetInventoryUnitIdsValue + .Order() + .Select(id => id.ToString("N"))); + } + + private sealed record StayInsert( + Guid OperationId, + string ScopeId, + Guid ReservationId, + Guid PropertyId, + Guid? InventoryRequestId, + int RequestSchemaVersion, + string RequestFingerprint, + DateOnly? TargetArrival, + DateOnly? TargetDeparture, + TimeOnly? TargetExpectedArrivalTime, + TimeOnly? TargetExpectedDepartureTime, + string? TargetInventoryUnitIds, + long ExpectedDetailsRevision, + string? RequestedBy, + int Outcome, + long OperationVersion, + DateTimeOffset RequestedAtUtc, + DateTimeOffset UpdatedAtUtc, + DateTimeOffset? CompletedAtUtc, + long? ResultingDetailsRevision, + long? ResultingReservationVersion, + long? ResultingAllocationVersion, + int? RejectionCode, + int ReconciliationCount, + DateTimeOffset? LastReconciledAtUtc, + string? LastReconciledBy) + { + public static StayInsert Pending( + OperationSeed seed, + int schemaVersion) => + new( + seed.OperationId, + seed.ScopeId, + seed.ReservationId, + seed.PropertyId, + seed.InventoryRequestId, + schemaVersion, + seed.RequestFingerprint, + seed.TargetArrival, + seed.TargetDeparture, + seed.TargetExpectedArrivalTime, + seed.TargetExpectedDepartureTime, + seed.TargetInventoryUnitIds, + seed.ExpectedDetailsRevision, + seed.RequestedBy, + Outcome: 1, + OperationVersion: 1, + seed.CreatedAtUtc, + seed.CreatedAtUtc, + CompletedAtUtc: null, + ResultingDetailsRevision: null, + ResultingReservationVersion: null, + ResultingAllocationVersion: null, + RejectionCode: null, + ReconciliationCount: 0, + LastReconciledAtUtc: null, + LastReconciledBy: null); + } + + private sealed record StayOperationRow( + Guid? InventoryRequestId, + int RequestSchemaVersion, + string RequestFingerprint, + DateOnly? TargetArrival, + DateOnly? TargetDeparture, + TimeOnly? TargetExpectedArrivalTime, + TimeOnly? TargetExpectedDepartureTime, + string? TargetInventoryUnitIds, + long ExpectedDetailsRevision, + string? RequestedBy, + int Outcome, + long OperationVersion, + int ReconciliationCount, + bool TimesMatch); + + private sealed class TestScopeContext : IScopeContext + { + public bool IsEnabled => true; + public string ScopeId => TenantA; + } +} diff --git a/tests/Integration.Tests/Reservations/ReservationsSagaIntegrationTests.LostOutcomeRecovery.cs b/tests/Integration.Tests/Reservations/ReservationsSagaIntegrationTests.LostOutcomeRecovery.cs new file mode 100644 index 00000000..f8bb2fbc --- /dev/null +++ b/tests/Integration.Tests/Reservations/ReservationsSagaIntegrationTests.LostOutcomeRecovery.cs @@ -0,0 +1,697 @@ +namespace Integration.Tests; + +using System.Net; +using System.Text.Json; +using BunkFy.Host.Worker; +using BunkFy.Modules.Reservations.Application; +using BunkFy.Modules.Reservations.Application.Commands; +using BunkFy.Modules.Inventory.Contracts; +using BunkFy.Modules.Inventory.Persistence; +using BunkFy.Modules.Reservations.Contracts; +using BunkFy.Modules.Reservations.Persistence; +using BunkFy.Modules.Workspaces.Contracts; +using DotNet.Testcontainers.Containers; +using Gma.Framework.Application.Events.Infrastructure; +using Gma.Framework.Cqrs; +using Gma.Framework.Cqrs.Infrastructure; +using Gma.Framework.Messaging.Infrastructure; +using Gma.Framework.Messaging.Nats; +using Gma.Framework.ModuleComposition; +using Gma.Framework.Naming; +using Gma.Framework.Results; +using Gma.Framework.Runtime.Identity; +using Gma.Framework.Runtime.Time; +using Gma.Framework.Scoping; +using Gma.Framework.Tenancy; +using Gma.Modules.Auth.Contracts; +using Integration.Tests.Support; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using NATS.Client.Core; +using NATS.Client.JetStream; +using NATS.Client.JetStream.Models; +using Npgsql; +using Testcontainers.PostgreSql; +using Xunit; + +public sealed partial class ReservationsSagaIntegrationTests +{ + [DockerFact] + [Trait("Category", "Docker")] + [Trait("Category", "Integration")] + public async Task Restart_and_reconcile_recover_a_lost_stay_amendment_outcome_once() + { + await using IContainer nats = AuthTestContainers.CreateNatsContainer(); + await nats.StartAsync().ConfigureAwait(false); + await using PostgreSqlContainer postgreSql = + new PostgreSqlBuilder("postgres:16-alpine") + .WithDatabase("bunkfy_reservations_lost_outcome_tests") + .Build(); + await postgreSql.StartAsync().ConfigureAwait(false); + + string connectionString = postgreSql.GetConnectionString(); + string natsConnectionString = + AuthTestContainers.GetNatsConnectionString(nats); + await using AuthTestApplication api = new( + "PostgreSql", + connectionString, + natsConnectionString, + disableOutboxPublisher: false); + await api.MigrateGuestRecordsAuthorizationDatabaseAsync() + .ConfigureAwait(false); + await using AdminCliTestApplication admin = new( + "PostgreSql", + connectionString); + await admin.MigrateAsync().ConfigureAwait(false); + using HttpClient client = api.CreateClient(); + + using IHost initialWorker = CreateWorker( + connectionString, + natsConnectionString); + IHost? inventoryOnlyWorker = null; + IHost? recoveryWorker = null; + bool initialWorkerStopped = false; + bool inventoryOnlyWorkerStopped = false; + bool recoveryWorkerStopped = false; + await initialWorker.StartAsync().ConfigureAwait(false); + try + { + await SeedInventoryAsync(api).ConfigureAwait(false); + await SeedGovernedPropertyProjectionsAsync(api) + .ConfigureAwait(false); + await WaitForSellableProjectionAsync( + api, + TimeSpan.FromSeconds(20)).ConfigureAwait(false); + + AuthTokensResponse tokens = await AuthApiClient.RegisterAsync( + client, + TenantId, + "recovery-operator@reservations.test") + .ConfigureAwait(false); + Guid operatorId = GetSubjectId(tokens.AccessToken); + await api.SeedOrganizationMembershipAsync(TenantId, operatorId) + .ConfigureAwait(false); + await GrantReservationsAccessAsync(admin, operatorId) + .ConfigureAwait(false); + + ReservationMutationReceiptDto created = + await CreateReservationAsync( + client, + tokens.AccessToken, + new DateOnly(2026, 11, 1), + new DateOnly(2026, 11, 3), + "Recovery Guest").ConfigureAwait(false); + ReservationDto confirmed = await WaitForStatusAsync( + client, + tokens.AccessToken, + created.ReservationId, + ReservationStatus.Confirmed, + TimeSpan.FromSeconds(20)).ConfigureAwait(false); + Assert.NotNull(confirmed.AllocationId); + + await initialWorker.StopAsync().ConfigureAwait(false); + initialWorkerStopped = true; + + Guid operationId = Guid.NewGuid(); + ReservationStayAmendmentReceiptDto pending; + using (HttpResponseMessage response = await SendAsync( + client, + HttpMethod.Put, + $"/api/reservations/properties/{PropertyId:D}/{created.ReservationId:D}/stay-amendments/{operationId:D}", + tokens.AccessToken, + new + { + arrival = confirmed.Arrival, + departure = confirmed.Departure.AddDays(1), + expectedArrivalTime = new TimeOnly(16, 0), + expectedDepartureTime = new TimeOnly(9, 0), + inventoryUnitIds = new[] { ReplacementRoomId }, + expectedDetailsRevision = + confirmed.DetailsRevision + }).ConfigureAwait(false)) + { + pending = + await ReadSuccessAsync( + response) + .ConfigureAwait(false); + } + + Assert.Equal( + ReservationStayAmendmentOutcome.Pending, + pending.Outcome); + Guid inventoryRequestId = await GetStayInventoryRequestIdAsync( + api, + created.ReservationId, + operationId).ConfigureAwait(false); + Assert.NotEqual(operationId, inventoryRequestId); + ReservationDto oldTruth = await WaitForStatusAsync( + client, + tokens.AccessToken, + created.ReservationId, + ReservationStatus.Confirmed, + TimeSpan.FromSeconds(20)).ConfigureAwait(false); + Assert.Equal(confirmed.Arrival, oldTruth.Arrival); + Assert.Equal(confirmed.Departure, oldTruth.Departure); + Assert.Equal(confirmed.ExpectedArrivalTime, oldTruth.ExpectedArrivalTime); + Assert.Equal(confirmed.ExpectedDepartureTime, oldTruth.ExpectedDepartureTime); + Assert.Equal(confirmed.AllocationId, oldTruth.AllocationId); + Assert.Equal(confirmed.AllocationVersion, oldTruth.AllocationVersion); + Assert.Equal([RoomId], oldTruth.InventoryUnitIds); + + inventoryOnlyWorker = CreateInventoryOnlyWorker( + connectionString, + natsConnectionString); + await inventoryOnlyWorker.StartAsync().ConfigureAwait(false); + InventoryMutationSnapshot firstMutation = + await WaitForInventoryMutationAsync( + connectionString, + confirmed.AllocationId.Value, + inventoryRequestId, + TimeSpan.FromSeconds(20)).ConfigureAwait(false); + Assert.Equal(confirmed.Departure.AddDays(1), firstMutation.Departure); + Assert.Equal(confirmed.AllocationVersion + 1, firstMutation.Version); + Assert.Equal(firstMutation.Version, firstMutation.DecisionAllocationVersion); + Assert.Equal(1, await CountInventoryAmendmentDecisionsAsync( + api, + inventoryRequestId).ConfigureAwait(false)); + Guid[] initialOutcomeIds = await WaitForInventoryOutcomeOutboxAsync( + api, + expectedCount: 1, + TimeSpan.FromSeconds(20)).ConfigureAwait(false); + Assert.Equal([inventoryRequestId], initialOutcomeIds); + + await inventoryOnlyWorker.StopAsync().ConfigureAwait(false); + inventoryOnlyWorkerStopped = true; + + // The Reservations durable consumer was offline for this outcome. + // Purging its exact subject models an outcome lost at the broker boundary. + long purged = await PurgeConfirmedAmendmentOutcomesAsync( + natsConnectionString).ConfigureAwait(false); + Assert.Equal(1, purged); + Assert.Equal( + 0, + await CountReservationsAmendmentOutcomeInboxAsync(api) + .ConfigureAwait(false)); + ReservationStayAmendmentReceiptDto stillPending = + await GetStayAmendmentAsync( + client, + tokens.AccessToken, + created.ReservationId, + operationId).ConfigureAwait(false); + Assert.Equal( + ReservationStayAmendmentOutcome.Pending, + stillPending.Outcome); + + Assert.NotNull(stillPending.NextRecoveryEligibleAtUtc); + FixedRecoveryClock recoveryClock = new( + stillPending.NextRecoveryEligibleAtUtc.Value.AddSeconds(1)); + ReservationStayAmendmentReceiptDto reconciled; + await using (ServiceProvider reconciliationServices = + CreateReconciliationServices( + connectionString, + recoveryClock)) + { + using IServiceScope scope = reconciliationServices.CreateScope(); + Result result = + await scope.ServiceProvider + .GetRequiredService() + .SendAsync( + new ReconcileReservationStayAmendmentCommand( + PropertyId, + created.ReservationId, + operationId, + stillPending.OperationVersion, + "user:recovery-test"), + CancellationToken.None) + .ConfigureAwait(false); + Assert.True(result.IsSuccess, result.Error.Code); + reconciled = result.Value; + } + + Assert.Equal( + ReservationStayAmendmentOutcome.Pending, + reconciled.Outcome); + Assert.Equal(1, reconciled.ReconciliationCount); + Assert.Equal( + stillPending.OperationVersion + 1, + reconciled.OperationVersion); + Guid[] requestIds = await WaitForReservationAmendmentRequestsAsync( + api, + expectedCount: 2, + TimeSpan.FromSeconds(20)).ConfigureAwait(false); + Assert.Equal( + [inventoryRequestId, inventoryRequestId], + requestIds); + + recoveryWorker = CreateRecoveryWorker( + connectionString, + natsConnectionString, + recoveryClock); + await recoveryWorker.StartAsync().ConfigureAwait(false); + ReservationStayAmendmentReceiptDto applied = + await WaitForStayAmendmentAsync( + client, + tokens.AccessToken, + created.ReservationId, + operationId, + ReservationStayAmendmentOutcome.Applied, + TimeSpan.FromSeconds(20)).ConfigureAwait(false); + Assert.Equal(confirmed.Departure.AddDays(1), applied.Target!.Departure); + + InventoryMutationSnapshot afterRecovery = + await ReadInventoryMutationAsync( + connectionString, + confirmed.AllocationId.Value, + inventoryRequestId).ConfigureAwait(false); + Assert.Equal(firstMutation, afterRecovery); + Assert.Equal(1, await CountInventoryAmendmentDecisionsAsync( + api, + inventoryRequestId).ConfigureAwait(false)); + Assert.Equal( + [inventoryRequestId, inventoryRequestId], + await WaitForInventoryOutcomeOutboxAsync( + api, + expectedCount: 2, + TimeSpan.FromSeconds(20)).ConfigureAwait(false)); + Assert.Equal( + 1, + await WaitForReservationsAmendmentOutcomeInboxAsync( + api, + TimeSpan.FromSeconds(20)).ConfigureAwait(false)); + + await recoveryWorker.StopAsync().ConfigureAwait(false); + recoveryWorkerStopped = true; + } + finally + { + if (recoveryWorker is not null) + { + if (!recoveryWorkerStopped) + { + await recoveryWorker.StopAsync().ConfigureAwait(false); + } + + recoveryWorker.Dispose(); + } + + if (inventoryOnlyWorker is not null) + { + if (!inventoryOnlyWorkerStopped) + { + await inventoryOnlyWorker.StopAsync().ConfigureAwait(false); + } + + inventoryOnlyWorker.Dispose(); + } + + if (!initialWorkerStopped) + { + await initialWorker.StopAsync().ConfigureAwait(false); + } + } + } + + private static IHost CreateInventoryOnlyWorker( + string connectionString, + string natsConnectionString) => CreateSelectiveWorker( + connectionString, + natsConnectionString, + includeReservations: false, + clock: null); + + private static IHost CreateRecoveryWorker( + string connectionString, + string natsConnectionString, + ISystemClock clock) => CreateSelectiveWorker( + connectionString, + natsConnectionString, + includeReservations: true, + clock); + + private static IHost CreateSelectiveWorker( + string connectionString, + string natsConnectionString, + bool includeReservations, + ISystemClock? clock) + { + HostApplicationBuilder builder = Host.CreateApplicationBuilder([]); + builder.Environment.EnvironmentName = "Integration"; + builder.Configuration.AddInMemoryCollection( + new Dictionary + { + ["ApplicationIdentity:DisplayName"] = + "BunkFy Reservation Recovery Inventory Worker", + ["ApplicationIdentity:Namespace"] = "bunkfy", + ["Persistence:Provider"] = "PostgreSql", + ["ConnectionStrings:PostgreSql"] = connectionString, + ["ConnectionStrings:nats"] = natsConnectionString, + ["Tenancy:Enabled"] = "true", + ["Caching:Enabled"] = "false", + ["NatsJetStream:Enabled"] = "true", + ["NatsConsumers:Enabled"] = "true", + ["NatsConsumers:FetchBatchSize"] = "10", + ["NatsConsumers:PollInterval"] = "00:00:00.100", + ["NatsConsumers:AckWait"] = "00:00:05", + ["NatsConsumers:AckProgressInterval"] = "00:00:01", + ["NatsConsumers:HandlerTimeout"] = "00:00:10", + ["NatsConsumers:NakDelay"] = "00:00:00.100", + ["Outbox:PollIntervalMilliseconds"] = "100", + ["Outbox:LockDurationMilliseconds"] = "5000", + ["Worker:Modules:Properties"] = "true", + ["Worker:Modules:Inventory"] = "true", + ["Worker:Modules:Reservations"] = + includeReservations.ToString(), + ["Worker:Modules:Guests"] = + includeReservations.ToString(), + ["Tasks:Worker:Enabled"] = "false" + }); + if (clock is not null) + { + builder.Services.AddSingleton(clock); + } + + builder.AddWorkerHost(); + CountryPolicyIntegrationTestData.InstallRegistry(builder.Services); + builder.ValidateModuleComposition(); + return builder.Build(); + } + + private static ServiceProvider CreateReconciliationServices( + string connectionString, + ISystemClock clock) + { + HostApplicationBuilder builder = Host.CreateApplicationBuilder([]); + builder.Configuration["ApplicationIdentity:DisplayName"] = + "BunkFy Reservation Recovery Dispatcher"; + builder.Configuration["ApplicationIdentity:Namespace"] = "bunkfy"; + builder.Configuration["Persistence:Provider"] = "PostgreSql"; + builder.Configuration["ConnectionStrings:PostgreSql"] = + connectionString; + builder.Services.AddSingleton( + RecoveryScopeContext.Instance); + builder.Services.AddSingleton(clock); + builder.Services.AddSingleton(); + builder.Services.AddSingleton( + OpenWorkspaceTerminationFenceReader.Instance); + builder.AddApplicationEventsInfrastructure(); + builder.AddCqrsInfrastructure(); + builder.AddMessagingInfrastructure(); + builder.Services.AddReservationsApplication(); + builder.AddReservationsPersistence(); + return builder.Services.BuildServiceProvider( + new ServiceProviderOptions { ValidateScopes = true }); + } + + private static async Task + WaitForInventoryMutationAsync( + string connectionString, + Guid allocationId, + Guid inventoryRequestId, + TimeSpan timeout) + { + DateTimeOffset deadline = DateTimeOffset.UtcNow.Add(timeout); + while (DateTimeOffset.UtcNow < deadline) + { + InventoryMutationSnapshot? snapshot = + await TryReadInventoryMutationAsync( + connectionString, + allocationId, + inventoryRequestId).ConfigureAwait(false); + if (snapshot is not null) + { + return snapshot; + } + + await Task.Delay(100).ConfigureAwait(false); + } + + throw new TimeoutException( + $"Inventory did not commit amendment decision '{inventoryRequestId:D}'."); + } + + private static async Task + ReadInventoryMutationAsync( + string connectionString, + Guid allocationId, + Guid inventoryRequestId) => + await TryReadInventoryMutationAsync( + connectionString, + allocationId, + inventoryRequestId) + .ConfigureAwait(false) ?? + throw new InvalidOperationException( + $"Inventory amendment decision '{inventoryRequestId:D}' was not found."); + + private static async Task + TryReadInventoryMutationAsync( + string connectionString, + Guid allocationId, + Guid inventoryRequestId) + { + await using NpgsqlConnection connection = new(connectionString); + await connection.OpenAsync().ConfigureAwait(false); + await using NpgsqlCommand command = new(""" + SELECT + allocation."Version", + allocation."Arrival", + allocation."Departure", + allocation.xmin::text::bigint, + decision."AllocationVersion", + decision.xmin::text::bigint + FROM inventory.allocations allocation + JOIN inventory.allocation_amendment_decisions decision + ON decision."ScopeId" = allocation."ScopeId" + AND decision."AllocationId" = allocation."Id" + AND decision."Id" = @inventoryRequestId + WHERE allocation."ScopeId" = @tenantId + AND allocation."Id" = @allocationId + """, connection); + command.Parameters.AddWithValue("tenantId", TenantId); + command.Parameters.AddWithValue("allocationId", allocationId); + command.Parameters.AddWithValue( + "inventoryRequestId", + inventoryRequestId); + await using NpgsqlDataReader reader = await command + .ExecuteReaderAsync() + .ConfigureAwait(false); + if (!await reader.ReadAsync().ConfigureAwait(false)) + { + return null; + } + + InventoryMutationSnapshot snapshot = new( + reader.GetInt64(0), + reader.GetFieldValue(1), + reader.GetFieldValue(2), + reader.GetInt64(3), + reader.GetInt64(4), + reader.GetInt64(5)); + Assert.False(await reader.ReadAsync().ConfigureAwait(false)); + return snapshot; + } + + private static async Task WaitForInventoryOutcomeOutboxAsync( + AuthTestApplication api, + int expectedCount, + TimeSpan timeout) + { + DateTimeOffset deadline = DateTimeOffset.UtcNow.Add(timeout); + Guid[] last = []; + while (DateTimeOffset.UtcNow < deadline) + { + using IServiceScope scope = api.Services.CreateScope(); + scope.ServiceProvider.GetRequiredService() + .SetTenant(TenantId); + InventoryDbContext inventory = scope.ServiceProvider + .GetRequiredService(); + var messages = await inventory.OutboxMessages + .AsNoTracking() + .Where(message => + message.ScopeId == TenantId && + message.EventType == typeof( + InventoryAllocationAmendmentConfirmedIntegrationEvent) + .FullName) + .OrderBy(message => message.CreatedAtUtc) + .Select(message => new + { + message.Payload, + message.ProcessedAtUtc + }) + .ToArrayAsync() + .ConfigureAwait(false); + last = messages + .Select(message => ReadInventoryRequestId(message.Payload)) + .ToArray(); + if (messages.Length == expectedCount && + messages.All(message => message.ProcessedAtUtc.HasValue)) + { + return last; + } + + await Task.Delay(100).ConfigureAwait(false); + } + + throw new TimeoutException( + $"Inventory outcome outbox did not reach {expectedCount} published messages. Last count: {last.Length}."); + } + + private static async Task WaitForReservationAmendmentRequestsAsync( + AuthTestApplication api, + int expectedCount, + TimeSpan timeout) + { + DateTimeOffset deadline = DateTimeOffset.UtcNow.Add(timeout); + Guid[] last = []; + while (DateTimeOffset.UtcNow < deadline) + { + using IServiceScope scope = api.Services.CreateScope(); + scope.ServiceProvider.GetRequiredService() + .SetTenant(TenantId); + ReservationsDbContext reservations = scope.ServiceProvider + .GetRequiredService(); + var messages = await reservations.OutboxMessages + .AsNoTracking() + .Where(message => + message.ScopeId == TenantId && + message.EventType == typeof( + InventoryAllocationAmendmentRequestedIntegrationEvent) + .FullName) + .OrderBy(message => message.CreatedAtUtc) + .Select(message => new + { + message.Payload, + message.ProcessedAtUtc + }) + .ToArrayAsync() + .ConfigureAwait(false); + last = messages + .Select(message => ReadInventoryRequestId(message.Payload)) + .ToArray(); + if (messages.Length == expectedCount && + messages.All(message => message.ProcessedAtUtc.HasValue)) + { + return last; + } + + await Task.Delay(100).ConfigureAwait(false); + } + + throw new TimeoutException( + $"Reservations request outbox did not reach {expectedCount} published messages. Last count: {last.Length}."); + } + + private static Guid ReadInventoryRequestId(string payload) + { + using JsonDocument document = JsonDocument.Parse(payload); + return document.RootElement + .GetProperty("amendmentRequestId") + .GetGuid(); + } + + private static async Task PurgeConfirmedAmendmentOutcomesAsync( + string natsConnectionString) + { + await using NatsConnection connection = new(new NatsOpts + { + Url = natsConnectionString + }); + NatsJSContext jetStream = new(connection); + StreamPurgeResponse response = await jetStream.PurgeStreamAsync( + ApplicationNamespaces.CreateStreamName("bunkfy"), + new StreamPurgeRequest + { + Filter = InventoryIntegrationSubjects + .CreateAllocationAmendmentConfirmed("bunkfy") + }, + CancellationToken.None).ConfigureAwait(false); + return response.Purged; + } + + private static async Task + CountReservationsAmendmentOutcomeInboxAsync( + AuthTestApplication api) + { + using IServiceScope scope = api.Services.CreateScope(); + scope.ServiceProvider.GetRequiredService() + .SetTenant(TenantId); + ReservationsDbContext reservations = scope.ServiceProvider + .GetRequiredService(); + return await reservations.InboxMessages + .AsNoTracking() + .CountAsync(message => + message.ScopeId == TenantId && + message.Handler == ReservationsModuleMetadata + .AllocationAmendmentConfirmedHandlerName) + .ConfigureAwait(false); + } + + private static async Task + WaitForReservationsAmendmentOutcomeInboxAsync( + AuthTestApplication api, + TimeSpan timeout) + { + DateTimeOffset deadline = DateTimeOffset.UtcNow.Add(timeout); + int count = 0; + while (DateTimeOffset.UtcNow < deadline) + { + count = await CountReservationsAmendmentOutcomeInboxAsync(api) + .ConfigureAwait(false); + if (count == 1) + { + return count; + } + + await Task.Delay(100).ConfigureAwait(false); + } + + throw new TimeoutException( + $"Reservations did not consume exactly one recovered outcome. Last count: {count}."); + } + + private static async Task + GetStayAmendmentAsync( + HttpClient client, + string accessToken, + Guid reservationId, + Guid operationId) + { + using HttpResponseMessage response = await SendAsync( + client, + HttpMethod.Get, + $"/api/reservations/properties/{PropertyId:D}/{reservationId:D}/stay-amendments/{operationId:D}", + accessToken).ConfigureAwait(false); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + return await ReadSuccessAsync( + response) + .ConfigureAwait(false); + } + + private sealed record InventoryMutationSnapshot( + long Version, + DateOnly Arrival, + DateOnly Departure, + long AllocationXmin, + long DecisionAllocationVersion, + long DecisionXmin); + + private sealed class FixedRecoveryClock(DateTimeOffset utcNow) + : ISystemClock + { + public DateTimeOffset UtcNow { get; } = utcNow; + } + + private sealed class RecoveryScopeContext : IScopeContext + { + public static RecoveryScopeContext Instance { get; } = new(); + + public bool IsEnabled => true; + public string ScopeId => TenantId; + } + + private sealed class RecoveryIdGenerator : IIdGenerator + { + public Guid NewId() => Guid.NewGuid(); + } +} diff --git a/tests/Integration.Tests/Reservations/ReservationsSagaIntegrationTests.cs b/tests/Integration.Tests/Reservations/ReservationsSagaIntegrationTests.cs index 618c612d..90ca4bf1 100644 --- a/tests/Integration.Tests/Reservations/ReservationsSagaIntegrationTests.cs +++ b/tests/Integration.Tests/Reservations/ReservationsSagaIntegrationTests.cs @@ -1,6 +1,7 @@ namespace Integration.Tests; using System.IdentityModel.Tokens.Jwt; +using System.Globalization; using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; @@ -13,6 +14,7 @@ namespace Integration.Tests; using BunkFy.Modules.Inventory.Contracts; using BunkFy.Modules.Inventory.Persistence; using BunkFy.Modules.Properties.Contracts; +using BunkFy.Modules.Reservations.Admin.Contracts; using BunkFy.Modules.Reservations.Contracts; using BunkFy.Modules.Reservations.Persistence; using DotNet.Testcontainers.Containers; @@ -32,13 +34,15 @@ namespace Integration.Tests; using Testcontainers.PostgreSql; using Xunit; -public sealed class ReservationsSagaIntegrationTests +public sealed partial class ReservationsSagaIntegrationTests { private const string TenantId = "a3000000-0000-0000-0000-000000000001"; private const string TenantHeader = "X-Tenant-Id"; private static readonly Guid PropertyId = Guid.Parse("71000000-0000-0000-0000-000000000001"); private static readonly Guid OtherPropertyId = Guid.Parse("71000000-0000-0000-0000-000000000002"); private static readonly Guid RoomId = Guid.Parse("72000000-0000-0000-0000-000000000001"); + private static readonly Guid ReplacementRoomId = + Guid.Parse("72000000-0000-0000-0000-000000000002"); [DockerFact] [Trait("Category", "Docker")] @@ -60,12 +64,16 @@ public async Task Real_worker_coordinates_reservation_and_stay_lifecycles_throug natsConnectionString, disableOutboxPublisher: false); await api.MigrateGuestRecordsAuthorizationDatabaseAsync().ConfigureAwait(false); - await using AdminCliTestApplication admin = new("PostgreSql", connectionString); + await using AdminCliTestApplication admin = new( + "PostgreSql", + connectionString, + includeReservations: true); await admin.MigrateAsync().ConfigureAwait(false); using HttpClient client = api.CreateClient(); - using IHost worker = CreateWorker(connectionString, natsConnectionString); - await worker.StartAsync().ConfigureAwait(false); + using IHost initialWorker = CreateWorker(connectionString, natsConnectionString); + IHost? stayWorker = null; + await initialWorker.StartAsync().ConfigureAwait(false); try { await SeedInventoryAsync(api).ConfigureAwait(false); @@ -79,6 +87,24 @@ public async Task Real_worker_coordinates_reservation_and_stay_lifecycles_throug Guid operatorId = GetSubjectId(tokens.AccessToken); await api.SeedOrganizationMembershipAsync(TenantId, operatorId).ConfigureAwait(false); await GrantReservationsAccessAsync(admin, operatorId).ConfigureAwait(false); + AuthTokensResponse readerTokens = await AuthApiClient.RegisterAsync( + client, + TenantId, + "reader@reservations.test").ConfigureAwait(false); + Guid readerId = GetSubjectId(readerTokens.AccessToken); + await api.SeedOrganizationMembershipAsync(TenantId, readerId).ConfigureAwait(false); + await GrantReservationsReadAccessAsync(admin, readerId).ConfigureAwait(false); + await GrantReservationsAdminAccessAsync(admin, operatorId).ConfigureAwait(false); + + await using AdminApiTestApplication reservationsAdminApi = new( + "PostgreSql", + connectionString, + natsConnectionString); + using HttpClient reservationsAdminClient = reservationsAdminApi.CreateClient(); + reservationsAdminClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( + "Bearer", + AdminApiTestApplication.CreateAccessTokenWithTenantClaim(operatorId, TenantId)); + reservationsAdminClient.DefaultRequestHeaders.Add(TenantHeader, TenantId); GuestMutationReceiptDto canonicalGuest = await CreateGuestAsync( client, @@ -164,6 +190,386 @@ await WaitForStatusAsync( ReservationStatus.Confirmed, TimeSpan.FromSeconds(20)).ConfigureAwait(false); + await initialWorker.StopAsync().ConfigureAwait(false); + + Guid stayOperationId = Guid.NewGuid(); + ReservationStayAmendmentReceiptDto pendingStay; + using (HttpResponseMessage amendStay = await SendAsync( + client, + HttpMethod.Put, + $"/api/reservations/properties/{PropertyId:D}/{replacement.ReservationId:D}/stay-amendments/{stayOperationId:D}", + tokens.AccessToken, + new + { + arrival = replacementConfirmed.Arrival, + departure = replacementConfirmed.Departure.AddDays(1), + expectedArrivalTime = new TimeOnly(16, 0), + expectedDepartureTime = new TimeOnly(9, 0), + inventoryUnitIds = new[] { ReplacementRoomId }, + expectedDetailsRevision = replacementConfirmed.DetailsRevision + }).ConfigureAwait(false)) + { + Assert.True(amendStay.Headers.CacheControl?.NoStore); + pendingStay = await ReadSuccessAsync(amendStay) + .ConfigureAwait(false); + } + Assert.Equal(ReservationStayAmendmentOutcome.Pending, pendingStay.Outcome); + Assert.Equal(replacementConfirmed.Departure.AddDays(1), pendingStay.Target!.Departure); + ReservationDto pendingAuthoritativeStay = await WaitForStatusAsync( + client, + tokens.AccessToken, + replacement.ReservationId, + ReservationStatus.Confirmed, + TimeSpan.FromSeconds(20)).ConfigureAwait(false); + Assert.Equal(replacementConfirmed.Arrival, pendingAuthoritativeStay.Arrival); + Assert.Equal(replacementConfirmed.Departure, pendingAuthoritativeStay.Departure); + Assert.Equal(replacementConfirmed.ExpectedArrivalTime, pendingAuthoritativeStay.ExpectedArrivalTime); + Assert.Equal(replacementConfirmed.ExpectedDepartureTime, pendingAuthoritativeStay.ExpectedDepartureTime); + Assert.Equal(replacementConfirmed.AllocationId, pendingAuthoritativeStay.AllocationId); + Assert.Equal(replacementConfirmed.AllocationVersion, pendingAuthoritativeStay.AllocationVersion); + Assert.Equal(replacementConfirmed.DetailsRevision, pendingAuthoritativeStay.DetailsRevision); + Assert.Equal([RoomId], pendingAuthoritativeStay.InventoryUnitIds); + + stayWorker = CreateWorker(connectionString, natsConnectionString); + await stayWorker.StartAsync().ConfigureAwait(false); + + ReservationStayAmendmentReceiptDto appliedStay = await WaitForStayAmendmentAsync( + client, + tokens.AccessToken, + replacement.ReservationId, + stayOperationId, + ReservationStayAmendmentOutcome.Applied, + TimeSpan.FromSeconds(20)).ConfigureAwait(false); + Guid inventoryRequestId = await GetStayInventoryRequestIdAsync( + api, + replacement.ReservationId, + stayOperationId).ConfigureAwait(false); + Assert.NotEqual(stayOperationId, inventoryRequestId); + Assert.Equal(1, await CountInventoryAmendmentDecisionsAsync( + api, + inventoryRequestId).ConfigureAwait(false)); + + using (HttpResponseMessage exactStayReplay = await SendAsync( + client, + HttpMethod.Put, + $"/api/reservations/properties/{PropertyId:D}/{replacement.ReservationId:D}/stay-amendments/{stayOperationId:D}", + tokens.AccessToken, + new + { + arrival = replacementConfirmed.Arrival, + departure = replacementConfirmed.Departure.AddDays(1), + expectedArrivalTime = new TimeOnly(16, 0), + expectedDepartureTime = new TimeOnly(9, 0), + inventoryUnitIds = new[] { ReplacementRoomId }, + expectedDetailsRevision = replacementConfirmed.DetailsRevision + }).ConfigureAwait(false)) + { + ReservationStayAmendmentReceiptDto replayed = + await ReadSuccessAsync(exactStayReplay) + .ConfigureAwait(false); + Assert.Equal(appliedStay.OperationVersion, replayed.OperationVersion); + Assert.Equal(appliedStay.Outcome, replayed.Outcome); + } + Assert.Equal(1, await CountInventoryAmendmentDecisionsAsync( + api, + inventoryRequestId).ConfigureAwait(false)); + replacementConfirmed = await WaitForStatusAsync( + client, + tokens.AccessToken, + replacement.ReservationId, + ReservationStatus.Confirmed, + TimeSpan.FromSeconds(20)).ConfigureAwait(false); + Assert.Equal(appliedStay.Target!.Departure, replacementConfirmed.Departure); + Assert.Equal(appliedStay.Target.ExpectedArrivalTime, replacementConfirmed.ExpectedArrivalTime); + Assert.Equal(appliedStay.Target.ExpectedDepartureTime, replacementConfirmed.ExpectedDepartureTime); + Assert.Equal([ReplacementRoomId], replacementConfirmed.InventoryUnitIds); + + string stayStatusPath = + $"/api/reservations/properties/{PropertyId:D}/{replacement.ReservationId:D}/stay-amendments/{stayOperationId:D}"; + using (HttpResponseMessage anonymousStayStatus = await SendAsync( + client, + HttpMethod.Get, + stayStatusPath).ConfigureAwait(false)) + { + await AssertStatusAsync(HttpStatusCode.Unauthorized, anonymousStayStatus) + .ConfigureAwait(false); + } + + using (HttpResponseMessage readerStayStatus = await SendAsync( + client, + HttpMethod.Get, + stayStatusPath, + readerTokens.AccessToken).ConfigureAwait(false)) + { + Assert.True(readerStayStatus.Headers.CacheControl?.NoStore); + ReservationStayAmendmentReceiptDto readReceipt = + await ReadSuccessAsync(readerStayStatus) + .ConfigureAwait(false); + Assert.Equal(stayOperationId, readReceipt.OperationId); + } + + object exactStayRequest = new + { + arrival = appliedStay.Target!.Arrival, + departure = appliedStay.Target.Departure, + expectedArrivalTime = appliedStay.Target.ExpectedArrivalTime, + expectedDepartureTime = appliedStay.Target.ExpectedDepartureTime, + inventoryUnitIds = appliedStay.Target.InventoryUnitIds, + expectedDetailsRevision = appliedStay.ExpectedDetailsRevision + }; + using (HttpResponseMessage readerAmendDenied = await SendAsync( + client, + HttpMethod.Put, + stayStatusPath, + readerTokens.AccessToken, + exactStayRequest).ConfigureAwait(false)) + { + await AssertStatusAsync(HttpStatusCode.Forbidden, readerAmendDenied) + .ConfigureAwait(false); + } + + using (HttpResponseMessage crossPropertyStayStatus = await SendAsync( + client, + HttpMethod.Get, + $"/api/reservations/properties/{OtherPropertyId:D}/{replacement.ReservationId:D}/stay-amendments/{stayOperationId:D}", + tokens.AccessToken).ConfigureAwait(false)) + { + await AssertStatusAsync(HttpStatusCode.Forbidden, crossPropertyStayStatus) + .ConfigureAwait(false); + } + + int inventoryDecisionCountBeforeAdministrativeNoOps = + await CountAllInventoryAmendmentDecisionsAsync(api).ConfigureAwait(false); + Guid cliOperationId = Guid.NewGuid(); + int cliConfirmationAuditBefore = await admin + .CountAuditEntriesContainingAsync(AdminErrors.ConfirmationRequired.Code) + .ConfigureAwait(false); + AdminCliResult unconfirmedCliAmend = await admin.ExecuteAsync( + "reservations", "stay-amendments", "amend", + "--actor", operatorId.ToString("D"), + "--tenant", TenantId, + "--property-id", PropertyId.ToString("D"), + "--reservation-id", replacement.ReservationId.ToString("D"), + "--operation-id", cliOperationId.ToString("D"), + "--arrival", replacementConfirmed.Arrival.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), + "--departure", replacementConfirmed.Departure.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), + "--expected-arrival-time", replacementConfirmed.ExpectedArrivalTime!.Value.ToString("HH:mm", CultureInfo.InvariantCulture), + "--expected-departure-time", replacementConfirmed.ExpectedDepartureTime!.Value.ToString("HH:mm", CultureInfo.InvariantCulture), + "--unit-ids", ReplacementRoomId.ToString("D"), + "--expected-details-revision", replacementConfirmed.DetailsRevision.ToString(CultureInfo.InvariantCulture)) + .ConfigureAwait(false); + Assert.Equal(AdminExitCodes.Failed, unconfirmedCliAmend.ExitCode); + Assert.Contains( + AdminErrors.ConfirmationRequired.Message, + unconfirmedCliAmend.Error, + StringComparison.Ordinal); + Assert.Equal( + cliConfirmationAuditBefore + 1, + await admin.CountAuditEntriesContainingAsync(AdminErrors.ConfirmationRequired.Code) + .ConfigureAwait(false)); + + AdminCliResult confirmedCliAmend = await admin.ExecuteAsync( + "reservations", "stay-amendments", "amend", + "--actor", operatorId.ToString("D"), + "--tenant", TenantId, + "--property-id", PropertyId.ToString("D"), + "--reservation-id", replacement.ReservationId.ToString("D"), + "--operation-id", cliOperationId.ToString("D"), + "--arrival", replacementConfirmed.Arrival.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), + "--departure", replacementConfirmed.Departure.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), + "--expected-arrival-time", replacementConfirmed.ExpectedArrivalTime.Value.ToString("HH:mm", CultureInfo.InvariantCulture), + "--expected-departure-time", replacementConfirmed.ExpectedDepartureTime.Value.ToString("HH:mm", CultureInfo.InvariantCulture), + "--unit-ids", ReplacementRoomId.ToString("D"), + "--expected-details-revision", replacementConfirmed.DetailsRevision.ToString(CultureInfo.InvariantCulture), + "--yes").ConfigureAwait(false); + Assert.Equal(AdminExitCodes.Success, confirmedCliAmend.ExitCode); + Assert.Contains(cliOperationId.ToString("D"), confirmedCliAmend.Output, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Applied", confirmedCliAmend.Output, StringComparison.Ordinal); + Assert.Equal( + operatorId.ToString("D"), + await GetStayRequestedByAsync(api, replacement.ReservationId, cliOperationId) + .ConfigureAwait(false)); + + Guid adminOperationId = Guid.NewGuid(); + string adminStayPath = + $"/api/admin/reservations/properties/{PropertyId:D}/{replacement.ReservationId:D}/stay-amendments/{adminOperationId:D}"; + int adminAmendAuditBefore = await reservationsAdminApi + .CountAuditEntriesAsync(ReservationsAdminOperationNames.AmendStay) + .ConfigureAwait(false); + int adminConfirmationAuditBefore = await reservationsAdminApi + .CountAuditEntriesAsync( + ReservationsAdminOperationNames.AmendStay, + AdminErrors.ConfirmationRequired.Code) + .ConfigureAwait(false); + using (HttpResponseMessage unconfirmedAdminReplay = await reservationsAdminClient.PutAsJsonAsync( + adminStayPath, + new + { + replacementConfirmed.Arrival, + replacementConfirmed.Departure, + replacementConfirmed.ExpectedArrivalTime, + replacementConfirmed.ExpectedDepartureTime, + replacementConfirmed.InventoryUnitIds, + expectedDetailsRevision = replacementConfirmed.DetailsRevision, + confirmed = false + }).ConfigureAwait(false)) + { + Assert.True(unconfirmedAdminReplay.Headers.CacheControl?.NoStore); + await AssertStatusAsync(HttpStatusCode.BadRequest, unconfirmedAdminReplay) + .ConfigureAwait(false); + } + Assert.Equal( + adminConfirmationAuditBefore + 1, + await reservationsAdminApi.CountAuditEntriesAsync( + ReservationsAdminOperationNames.AmendStay, + AdminErrors.ConfirmationRequired.Code).ConfigureAwait(false)); + + ReservationStayAmendmentReceiptDto adminApplied; + using (HttpResponseMessage confirmedAdminAmend = await reservationsAdminClient.PutAsJsonAsync( + adminStayPath, + new + { + replacementConfirmed.Arrival, + replacementConfirmed.Departure, + replacementConfirmed.ExpectedArrivalTime, + replacementConfirmed.ExpectedDepartureTime, + replacementConfirmed.InventoryUnitIds, + expectedDetailsRevision = replacementConfirmed.DetailsRevision, + confirmed = true + }).ConfigureAwait(false)) + { + Assert.True(confirmedAdminAmend.Headers.CacheControl?.NoStore); + adminApplied = + await ReadSuccessAsync(confirmedAdminAmend) + .ConfigureAwait(false); + Assert.Equal(adminOperationId, adminApplied.OperationId); + Assert.Equal(ReservationStayAmendmentOutcome.Applied, adminApplied.Outcome); + } + Assert.Equal( + adminAmendAuditBefore + 2, + await reservationsAdminApi.CountAuditEntriesAsync( + ReservationsAdminOperationNames.AmendStay).ConfigureAwait(false)); + Assert.Equal( + $"admin-api:{operatorId:D}", + await GetStayRequestedByAsync(api, replacement.ReservationId, adminOperationId) + .ConfigureAwait(false)); + Assert.Equal( + inventoryDecisionCountBeforeAdministrativeNoOps, + await CountAllInventoryAmendmentDecisionsAsync(api).ConfigureAwait(false)); + + using (HttpResponseMessage adminStatus = await reservationsAdminClient + .GetAsync(adminStayPath) + .ConfigureAwait(false)) + { + Assert.True(adminStatus.Headers.CacheControl?.NoStore); + ReservationStayAmendmentReceiptDto adminReceipt = + await ReadSuccessAsync(adminStatus) + .ConfigureAwait(false); + Assert.Equal(adminOperationId, adminReceipt.OperationId); + } + + int reconcileConfirmationAuditBefore = await reservationsAdminApi + .CountAuditEntriesAsync( + ReservationsAdminOperationNames.ReconcileStayAmendment, + AdminErrors.ConfirmationRequired.Code) + .ConfigureAwait(false); + using (HttpResponseMessage unconfirmedReconcile = await reservationsAdminClient.PostAsJsonAsync( + adminStayPath + "/reconcile", + new + { + expectedOperationVersion = adminApplied.OperationVersion, + confirmed = false + }).ConfigureAwait(false)) + { + Assert.True(unconfirmedReconcile.Headers.CacheControl?.NoStore); + await AssertStatusAsync(HttpStatusCode.BadRequest, unconfirmedReconcile) + .ConfigureAwait(false); + } + Assert.Equal( + reconcileConfirmationAuditBefore + 1, + await reservationsAdminApi.CountAuditEntriesAsync( + ReservationsAdminOperationNames.ReconcileStayAmendment, + AdminErrors.ConfirmationRequired.Code).ConfigureAwait(false)); + + ReservationMutationReceiptDto conflictHolder = await CreateReservationAsync( + client, + tokens.AccessToken, + replacementConfirmed.Arrival, + replacementConfirmed.Departure, + "Stay Amendment Conflict Holder").ConfigureAwait(false); + ReservationDto conflictConfirmed = await WaitForStatusAsync( + client, + tokens.AccessToken, + conflictHolder.ReservationId, + ReservationStatus.Confirmed, + TimeSpan.FromSeconds(20)).ConfigureAwait(false); + Guid rejectedOperationId = Guid.NewGuid(); + using (HttpResponseMessage rejectedRequest = await SendAsync( + client, + HttpMethod.Put, + $"/api/reservations/properties/{PropertyId:D}/{replacement.ReservationId:D}/stay-amendments/{rejectedOperationId:D}", + tokens.AccessToken, + new + { + arrival = replacementConfirmed.Arrival, + departure = replacementConfirmed.Departure, + expectedArrivalTime = replacementConfirmed.ExpectedArrivalTime, + expectedDepartureTime = replacementConfirmed.ExpectedDepartureTime, + inventoryUnitIds = new[] { RoomId }, + expectedDetailsRevision = replacementConfirmed.DetailsRevision + }).ConfigureAwait(false)) + { + ReservationStayAmendmentReceiptDto pendingRejection = + await ReadSuccessAsync(rejectedRequest) + .ConfigureAwait(false); + Assert.Equal(ReservationStayAmendmentOutcome.Pending, pendingRejection.Outcome); + } + + ReservationStayAmendmentReceiptDto rejectedStay = await WaitForStayAmendmentAsync( + client, + tokens.AccessToken, + replacement.ReservationId, + rejectedOperationId, + ReservationStayAmendmentOutcome.Rejected, + TimeSpan.FromSeconds(20)).ConfigureAwait(false); + Assert.NotNull(rejectedStay.RejectionReason); + ReservationDto afterRejectedStay = await WaitForStatusAsync( + client, + tokens.AccessToken, + replacement.ReservationId, + ReservationStatus.Confirmed, + TimeSpan.FromSeconds(20)).ConfigureAwait(false); + Assert.Equal(replacementConfirmed.Arrival, afterRejectedStay.Arrival); + Assert.Equal(replacementConfirmed.Departure, afterRejectedStay.Departure); + Assert.Equal(replacementConfirmed.ExpectedArrivalTime, afterRejectedStay.ExpectedArrivalTime); + Assert.Equal(replacementConfirmed.ExpectedDepartureTime, afterRejectedStay.ExpectedDepartureTime); + Assert.Equal(replacementConfirmed.AllocationId, afterRejectedStay.AllocationId); + Assert.Equal(replacementConfirmed.AllocationVersion, afterRejectedStay.AllocationVersion); + Assert.Equal(replacementConfirmed.DetailsRevision, afterRejectedStay.DetailsRevision); + Assert.Equal([ReplacementRoomId], afterRejectedStay.InventoryUnitIds); + replacementConfirmed = afterRejectedStay; + + using (HttpResponseMessage cancelConflict = await SendAsync( + client, + HttpMethod.Post, + $"/api/reservations/properties/{PropertyId:D}/{conflictConfirmed.ReservationId:D}/cancel", + tokens.AccessToken, + new + { + operationId = Guid.NewGuid(), + expectedVersion = conflictConfirmed.Version + }).ConfigureAwait(false)) + { + await ReadSuccessAsync(cancelConflict) + .ConfigureAwait(false); + } + await WaitForStatusAsync( + client, + tokens.AccessToken, + conflictConfirmed.ReservationId, + ReservationStatus.Cancelled, + TimeSpan.FromSeconds(20)).ConfigureAwait(false); + await PublishGuestRestrictionTransitionAsync( api, canonicalGuest.GuestId, @@ -317,7 +723,7 @@ await ReadSuccessAsync(checkInReplay) Assert.Equal(checkedIn.DetailsRevision, replayed.DetailsRevision); } Assert.Equal( - 1, + 5, await CountManagementOperationsAsync(api, replacement.ReservationId) .ConfigureAwait(false)); @@ -387,7 +793,7 @@ await ReadSuccessAsync(checkOutReplay) Assert.Equal(checkedOut.DetailsRevision, replayed.DetailsRevision); } Assert.Equal( - 2, + 6, await CountManagementOperationsAsync(api, replacement.ReservationId) .ConfigureAwait(false)); @@ -477,7 +883,13 @@ await WaitForStatusAsync( } finally { - await worker.StopAsync().ConfigureAwait(false); + if (stayWorker is not null) + { + await stayWorker.StopAsync().ConfigureAwait(false); + stayWorker.Dispose(); + } + + await initialWorker.StopAsync().ConfigureAwait(false); } } @@ -535,6 +947,83 @@ FROM reservations.management_operations .ConfigureAwait(false); } + private static async Task GetStayInventoryRequestIdAsync( + AuthTestApplication api, + Guid reservationId, + Guid operationId) + { + using IServiceScope scope = api.Services.CreateScope(); + scope.ServiceProvider.GetRequiredService() + .SetTenant(TenantId); + ReservationsDbContext reservations = scope.ServiceProvider + .GetRequiredService(); + return await reservations.Database.SqlQuery($""" + SELECT "InventoryRequestId" AS "Value" + FROM reservations.stay_amendment_operations + WHERE "ScopeId" = {TenantId} + AND "ReservationId" = {reservationId} + AND "Id" = {operationId} + """) + .SingleAsync() + .ConfigureAwait(false); + } + + private static async Task GetStayRequestedByAsync( + AuthTestApplication api, + Guid reservationId, + Guid operationId) + { + using IServiceScope scope = api.Services.CreateScope(); + scope.ServiceProvider.GetRequiredService() + .SetTenant(TenantId); + ReservationsDbContext reservations = scope.ServiceProvider + .GetRequiredService(); + return await reservations.Database.SqlQuery($""" + SELECT "RequestedBy" AS "Value" + FROM reservations.stay_amendment_operations + WHERE "ScopeId" = {TenantId} + AND "ReservationId" = {reservationId} + AND "Id" = {operationId} + """) + .SingleAsync() + .ConfigureAwait(false); + } + + private static async Task CountInventoryAmendmentDecisionsAsync( + AuthTestApplication api, + Guid inventoryRequestId) + { + using IServiceScope scope = api.Services.CreateScope(); + scope.ServiceProvider.GetRequiredService() + .SetTenant(TenantId); + InventoryDbContext inventory = scope.ServiceProvider + .GetRequiredService(); + return await inventory.Database.SqlQuery($""" + SELECT COUNT(*)::int AS "Value" + FROM inventory.allocation_amendment_decisions + WHERE "Id" = {inventoryRequestId} + """) + .SingleAsync() + .ConfigureAwait(false); + } + + private static async Task CountAllInventoryAmendmentDecisionsAsync( + AuthTestApplication api) + { + using IServiceScope scope = api.Services.CreateScope(); + scope.ServiceProvider.GetRequiredService() + .SetTenant(TenantId); + InventoryDbContext inventory = scope.ServiceProvider + .GetRequiredService(); + return await inventory.Database.SqlQuery($""" + SELECT COUNT(*)::int AS "Value" + FROM inventory.allocation_amendment_decisions + WHERE "ScopeId" = {TenantId} + """) + .SingleAsync() + .ConfigureAwait(false); + } + private static async Task SeedInventoryAsync(AuthTestApplication api) { DateTimeOffset now = DateTimeOffset.UtcNow; @@ -551,6 +1040,9 @@ await propertyHandler.HandleAsync( await roomHandler.HandleAsync( new(Guid.NewGuid(), TenantId, now, PropertyId, RoomId, "101", null, null, RoomStatus.Active, 1), CancellationToken.None).ConfigureAwait(false); + await roomHandler.HandleAsync( + new(Guid.NewGuid(), TenantId, now, PropertyId, ReplacementRoomId, "102", null, null, RoomStatus.Active, 1), + CancellationToken.None).ConfigureAwait(false); await scope.ServiceProvider.GetRequiredService() .SaveChangesAsync() .ConfigureAwait(false); @@ -570,6 +1062,19 @@ await scope.ServiceProvider.GetRequiredService() CancellationToken.None) .ConfigureAwait(false); Assert.True(configured.IsSuccess, configured.Error.Code); + Result replacementConfigured = + await configurationScope.ServiceProvider + .GetRequiredService() + .SendAsync( + new ConfigureRoomSalesModeCommand( + Guid.NewGuid(), + PropertyId, + ReplacementRoomId, + InventorySalesMode.RoomLevel, + 1), + CancellationToken.None) + .ConfigureAwait(false); + Assert.True(replacementConfigured.IsSuccess, replacementConfigured.Error.Code); } private static IIntegrationEventHandler ResolveInventoryHandler(IServiceProvider services) @@ -882,12 +1387,17 @@ private static async Task WaitForSellableProjectionAsync(AuthTestApplication api { using IServiceScope scope = api.Services.CreateScope(); scope.ServiceProvider.GetRequiredService().SetTenant(TenantId); - bool ready = await scope.ServiceProvider.GetRequiredService() + Guid[] sellableIds = await scope.ServiceProvider.GetRequiredService() .InventoryUnitProjections .AsNoTracking() - .AnyAsync(unit => unit.Id == RoomId && unit.PropertyId == PropertyId && unit.IsSellable) + .Where(unit => + (unit.Id == RoomId || unit.Id == ReplacementRoomId) && + unit.PropertyId == PropertyId && + unit.IsSellable) + .Select(unit => unit.Id) + .ToArrayAsync() .ConfigureAwait(false); - if (ready) + if (sellableIds.Length == 2) { return; } @@ -937,6 +1447,58 @@ await AssertAdminSuccessAsync(admin.ExecuteAsync( "--scope", $"tenant:{TenantId}/property:{PropertyId:D}")); } + private static async Task GrantReservationsReadAccessAsync( + AdminCliTestApplication admin, + Guid readerId) + { + await AssertAdminSuccessAsync(admin.ExecuteAsync( + "admin", "roles", "create", + "--actor", "owner", + "--name", "reservations-reader")); + await AssertAdminSuccessAsync(admin.ExecuteAsync( + "admin", "roles", "grant", + "--actor", "owner", + "--role", "reservations-reader", + "--permission", ReservationsAdminPermissionCodes.Read)); + await AssertAdminSuccessAsync(admin.ExecuteAsync( + "admin", "roles", "assign", + "--actor", "owner", + "--target-kind", "user", + "--target-id", readerId.ToString("D"), + "--role", "reservations-reader", + "--scope", $"tenant:{TenantId}/property:{PropertyId:D}")); + } + + private static async Task GrantReservationsAdminAccessAsync( + AdminCliTestApplication admin, + Guid adminActorId) + { + await AssertAdminSuccessAsync(admin.ExecuteAsync( + "admin", "roles", "create", + "--actor", "owner", + "--name", "reservations-stay-admin")); + foreach (string permission in new[] + { + ReservationsAdminPermissionCodes.Read, + ReservationsAdminPermissionCodes.Manage + }) + { + await AssertAdminSuccessAsync(admin.ExecuteAsync( + "admin", "roles", "grant", + "--actor", "owner", + "--role", "reservations-stay-admin", + "--permission", permission)); + } + + await AssertAdminSuccessAsync(admin.ExecuteAsync( + "admin", "roles", "assign", + "--actor", "owner", + "--target-kind", "admin-actor", + "--target-id", adminActorId.ToString("D"), + "--role", "reservations-stay-admin", + "--scope", $"tenant:{TenantId}/property:{PropertyId:D}")); + } + private static async Task CreateReservationAsync( HttpClient client, string accessToken, @@ -997,6 +1559,37 @@ private static async Task WaitForStatusAsync( $"Reservation '{reservationId}' did not reach '{expected}'. Last status: '{last?.Status}'."); } + private static async Task WaitForStayAmendmentAsync( + HttpClient client, + string accessToken, + Guid reservationId, + Guid operationId, + ReservationStayAmendmentOutcome expected, + TimeSpan timeout) + { + DateTimeOffset deadline = DateTimeOffset.UtcNow.Add(timeout); + ReservationStayAmendmentReceiptDto? last = null; + while (DateTimeOffset.UtcNow < deadline) + { + using HttpResponseMessage response = await SendAsync( + client, + HttpMethod.Get, + $"/api/reservations/properties/{PropertyId:D}/{reservationId:D}/stay-amendments/{operationId:D}", + accessToken).ConfigureAwait(false); + last = await ReadSuccessAsync(response) + .ConfigureAwait(false); + if (last.Outcome == expected) + { + return last; + } + + await Task.Delay(100).ConfigureAwait(false); + } + + throw new TimeoutException( + $"Stay amendment '{operationId}' did not reach '{expected}'. Last outcome: '{last?.Outcome}'."); + } + private static async Task SendAsync( HttpClient client, HttpMethod method, diff --git a/tests/Integration.Tests/Reservations/ReservationsTenantTerminationIntegrationTests.cs b/tests/Integration.Tests/Reservations/ReservationsTenantTerminationIntegrationTests.cs index 9e6b8e24..832fdf6a 100644 --- a/tests/Integration.Tests/Reservations/ReservationsTenantTerminationIntegrationTests.cs +++ b/tests/Integration.Tests/Reservations/ReservationsTenantTerminationIntegrationTests.cs @@ -1,5 +1,9 @@ namespace Integration.Tests; +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; using BunkFy.Modules.DataRights.Contracts; using BunkFy.Modules.Reservations.Application.Ports; using BunkFy.Modules.Reservations.Contracts; @@ -9,6 +13,7 @@ namespace Integration.Tests; using BunkFy.Modules.Reservations.Domain.GuestRecords; using BunkFy.Modules.Reservations.Domain.Models; using BunkFy.Modules.Reservations.Domain.Retention; +using BunkFy.Modules.Reservations.Domain.StayAmendments; using BunkFy.Modules.Reservations.Persistence; using BunkFy.Modules.Workspaces.Contracts; using BunkFy.Modules.Workspaces.Domain.Termination; @@ -127,6 +132,25 @@ record => record.RecordId == tenantBReservationId); .Select(record => record.RecordType) .Distinct(StringComparer.Ordinal) .ToArray()); + DataRightsExportRecord stayAmendment = Assert.Single( + first.Records, + record => record.RecordType == + ReservationsTenantTerminationMetadata + .StayAmendmentOperationRecordType && + record.RecordId == + DataRightsExportRecordIds.CreateDeterministicChild( + proofIds.StayAmendmentReservationId, + proofIds.StayAmendmentOperationId.ToString("N"))); + Assert.Equal( + "applied", + Field(stayAmendment, "reservations.stay-amendment-operation") + .GetProperty("outcome") + .GetString()); + Assert.Equal( + 1, + Field(stayAmendment, "reservations.stay-amendment-operation") + .GetProperty("resultingAllocationVersion") + .GetInt64()); CollectingSink replay = new(); TenantTerminationContributionResult replayResult = @@ -432,6 +456,36 @@ UPDATE reservations.tenant_destroy_receipts connectionString, "reservations.reservations", TenantA) > 0); + Assert.Equal( + 1, + await CountForTenantAsync( + connectionString, + "reservations.stay_amendment_operations", + TenantA)); + Assert.Equal( + 2, + await CountForTenantAsync( + connectionString, + "reservations.management_operations", + TenantA)); + Assert.Equal( + 1, + await CountForTenantAsync( + connectionString, + "reservations.external_operations", + TenantA)); + Assert.Equal( + 1, + await CountForTenantAsync( + connectionString, + "reservations.reservation_details_history", + TenantA)); + Assert.Equal( + 1, + await CountForTenantAsync( + connectionString, + "reservations.guest_profile_projection", + TenantA)); Assert.Equal( 1, await ScalarForTenantAsync( @@ -466,6 +520,13 @@ FROM reservations.tenant_revisions tenantId, propertyId, "Maya Chen"); + context.Set().Add( + new ReservationGuestProfileProjection( + tenantId, + Guid.NewGuid(), + propertyId, + BunkFy.Modules.Guests.Contracts.GuestStatus.Active, + version: 1)); Assert.True(reservation.ConfirmAllocation( reservation.AllocationRequestId, Guid.NewGuid(), @@ -483,6 +544,7 @@ FROM reservations.tenant_revisions Guid adapterConnectionId = Guid.NewGuid(); Guid externalOperationId = Guid.NewGuid(); Assert.True(reservation.BeginAllocationAmendment( + Guid.NewGuid(), Guid.NewGuid(), new string('a', Reservation.RequestFingerprintLength), reservation.Arrival, @@ -570,6 +632,69 @@ await managementOperations.AddAsync( BusinessDate: null, CreatedAtUtc: ExportNowUtc.AddMinutes(-7)), CancellationToken.None); + Reservation stayEvidenceReservation = CreateReservation( + tenantId, + propertyId, + "Stay amendment evidence guest"); + Assert.True(stayEvidenceReservation.ConfirmAllocation( + stayEvidenceReservation.AllocationRequestId, + Guid.NewGuid(), + allocationVersion: 1, + Guid.NewGuid(), + ExportNowUtc.AddMinutes(-6)).IsSuccess); + stayEvidenceReservation.ClearDomainEvents(); + Guid stayAmendmentOperationId = Guid.NewGuid(); + Guid[] stayUnits = stayEvidenceReservation.RequestedUnits + .Select(unit => unit.InventoryUnitId) + .ToArray(); + string stayFingerprint = StayAmendmentFingerprint( + stayEvidenceReservation, + stayAmendmentOperationId, + stayUnits); + context.Reservations.Add(stayEvidenceReservation); + await context.SaveChangesAsync(); + Assert.True( + stayEvidenceReservation.AdvanceStayAmendmentEvidenceCoordinate( + ExportNowUtc.AddMinutes(-5)).IsSuccess); + Assert.Equal( + EntityState.Modified, + context.Entry(stayEvidenceReservation).State); + Assert.True( + context.Entry(stayEvidenceReservation) + .Property(candidate => candidate.Version) + .IsModified); + await managementOperations.AddAsync( + new ReservationManagementOperationRecord( + stayAmendmentOperationId, + tenantId, + propertyId, + stayEvidenceReservation.Id, + ReservationManagementOperationKind.StayAmendment, + ExpectedVersion: null, + stayEvidenceReservation.DetailsRevision, + BusinessDate: null, + ExportNowUtc.AddMinutes(-5), + stayFingerprint), + CancellationToken.None); + context.Set().Add( + ReservationStayAmendmentOperation.CreateAppliedNoOp( + stayAmendmentOperationId, + tenantId, + propertyId, + stayEvidenceReservation.Id, + ReservationStayAmendmentOperation.CurrentRequestSchemaVersion, + stayFingerprint, + stayEvidenceReservation.Arrival, + stayEvidenceReservation.Departure, + stayEvidenceReservation.ExpectedArrivalTime, + stayEvidenceReservation.ExpectedDepartureTime, + stayUnits, + stayEvidenceReservation.DetailsRevision, + "user:stay-operator", + stayEvidenceReservation.DetailsRevision, + stayEvidenceReservation.Version, + stayEvidenceReservation.AllocationVersion!.Value, + ExportNowUtc.AddMinutes(-5)).Value); await reminders.RefreshReservationAsync( new( tenantId, @@ -677,7 +802,9 @@ await reminders.RefreshReservationAsync( anonymisationReceipt.Id, restoreReceipt.Id, retentionReceipt.Id, - tombstone.Id)); + tombstone.Id, + stayEvidenceReservation.Id, + stayAmendmentOperationId)); } private static ( @@ -1093,6 +1220,32 @@ private static async Task ScalarForTenantAsync( private static string RecordIdentity(DataRightsExportRecord record) => $"{record.RecordType}|{record.RecordId:N}|{record.RecordVersion}"; + private static JsonElement Field( + DataRightsExportRecord record, + string fieldId) => + Assert.Single( + record.Fields, + field => field.FieldId == fieldId).Value; + + private static string StayAmendmentFingerprint( + Reservation reservation, + Guid operationId, + IReadOnlyCollection unitIds) + { + string canonical = string.Join( + '|', + "v2", + $"reservation={reservation.Id:N}", + $"operation={operationId:N}", + $"expected-details-revision={reservation.DetailsRevision.ToString(CultureInfo.InvariantCulture)}", + $"arrival={reservation.Arrival.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)}", + $"departure={reservation.Departure.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)}", + $"expected-arrival-time={reservation.ExpectedArrivalTime?.ToString("HH:mm", CultureInfo.InvariantCulture) ?? "-"}", + $"expected-departure-time={reservation.ExpectedDepartureTime?.ToString("HH:mm", CultureInfo.InvariantCulture) ?? "-"}", + $"units={string.Join(',', unitIds.Order().Select(id => id.ToString("N")))}"); + return Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(canonical))); + } + private static ServiceProvider CreatePersistenceProvider( string connectionString, string tenantId, @@ -1119,7 +1272,9 @@ private sealed record ProofIds( Guid AnonymisationReceiptId, Guid RestoreReceiptId, Guid RetentionReceiptId, - Guid TombstoneId); + Guid TombstoneId, + Guid StayAmendmentReservationId, + Guid StayAmendmentOperationId); private sealed class TestScopeContext(string scopeId) : IScopeContext { diff --git a/tests/Integration.Tests/Support/AdminCliTestApplication.cs b/tests/Integration.Tests/Support/AdminCliTestApplication.cs index a11f5289..37428a8a 100644 --- a/tests/Integration.Tests/Support/AdminCliTestApplication.cs +++ b/tests/Integration.Tests/Support/AdminCliTestApplication.cs @@ -29,6 +29,8 @@ namespace Integration.Tests.Support; using Gma.Modules.Auth.Persistence; using BunkFy.Modules.Ingestion.AdminCli; using BunkFy.Modules.Ingestion.Persistence; +using BunkFy.Modules.Reservations.AdminCli; +using BunkFy.Modules.Reservations.Persistence; using BunkFy.Modules.Workspaces.Persistence; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; @@ -47,6 +49,7 @@ public AdminCliTestApplication( string connectionString, bool includeIngestion = false, bool includeProperties = false, + bool includeReservations = false, ISystemClock? systemClock = null) { HostApplicationBuilder builder = Host.CreateApplicationBuilder([]); @@ -76,11 +79,21 @@ public AdminCliTestApplication( builder.AddAdminModule(); builder.AddAdminModule(); builder.AddAdminModule(); - if (includeIngestion || includeProperties) + if (includeIngestion || includeProperties || includeReservations) { builder.AddWorkspacesTerminationAdmissionPersistence(); } + if (includeProperties || includeReservations) + { + builder.Services.AddBunkFyAdminCliResourceScopes(); + } + + if (includeReservations) + { + builder.AddAdminModule(); + } + if (includeIngestion) { builder.Services.AddReservationMailParserDescriptor(); @@ -89,7 +102,6 @@ public AdminCliTestApplication( if (includeProperties) { - builder.Services.AddBunkFyAdminCliResourceScopes(); builder.AddAdminModule(); } @@ -116,6 +128,12 @@ public async Task MigrateAsync() await workspaces.Database.MigrateAsync().ConfigureAwait(false); } + ReservationsDbContext? reservations = scope.ServiceProvider.GetService(); + if (reservations is not null) + { + await reservations.Database.MigrateAsync().ConfigureAwait(false); + } + IngestionDbContext? ingestion = scope.ServiceProvider.GetService(); if (ingestion is not null) { From da557ec4b8fd2bc16ca1e433f04b73ccdb472022 Mon Sep 17 00:00:00 2001 From: SadPossum Date: Thu, 13 Aug 2026 19:24:24 +0000 Subject: [PATCH 2/6] Add property operations snapshot for Reservations --- BunkFy.slnx | 1 + docs/README.md | 1 + ...tions-property-operations-snapshot-task.md | 131 +++ .../ReservationsAdminOperationNames.cs | 1 + .../ReservationsAdminApiModule.cs | 39 + .../ReservationsAdminNoStoreStartupFilter.cs | 42 + .../ReservationsAdminCliModule.cs | 133 +++ .../ReservationsModule.cs | 32 + .../ReservationsNoStoreStartupFilter.cs | 42 + ...servationOperationsSnapshotQueryHandler.cs | 51 ++ .../IReservationOperationsSnapshotReader.cs | 40 + .../GetReservationOperationsSnapshotQuery.cs | 10 + .../ReservationsApplicationErrors.cs | 15 + ...rvationOperationsSnapshotQueryValidator.cs | 16 + .../Api/ReservationOperationsSnapshotDto.cs | 39 + .../Metadata/ReservationsContractLimits.cs | 2 + .../ReservationsTenantTerminationMetadata.cs | 2 +- .../DependencyInjection.cs | 3 + .../ReservationOperationsSnapshotReader.cs | 404 +++++++++ src/Modules/Reservations/docs/README.md | 28 + .../docs/personal-data-catalog.v1.json | 652 +++++++++++++- .../docs/personal-data-inventory.v1.md | 87 +- .../Api/ReservationsApiSecurityTests.cs | 81 +- ...tionOperationsSnapshotQueryHandlerTests.cs | 121 +++ ...ervationOperationalSurfaceContractTests.cs | 37 + .../ReservationsPersonalDataCatalogTests.cs | 75 ++ ...rvationDataRightsExportContributorTests.cs | 2 +- ...eservationOperationsSnapshotReaderTests.cs | 35 + ...ationsTenantTerminationContributorTests.cs | 2 +- ...ionsSnapshotPersistenceIntegrationTests.cs | 797 ++++++++++++++++++ .../ReservationsSagaIntegrationTests.cs | 219 ++++- .../Support/AdminCliTestApplication.cs | 16 +- 32 files changed, 3115 insertions(+), 41 deletions(-) create mode 100644 docs/planning/reservations-property-operations-snapshot-task.md create mode 100644 src/Modules/Reservations/BunkFy.Modules.Reservations.AdminApi/ReservationsAdminNoStoreStartupFilter.cs create mode 100644 src/Modules/Reservations/BunkFy.Modules.Reservations.Api/ReservationsNoStoreStartupFilter.cs create mode 100644 src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Handlers/GetReservationOperationsSnapshotQueryHandler.cs create mode 100644 src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Ports/IReservationOperationsSnapshotReader.cs create mode 100644 src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Queries/GetReservationOperationsSnapshotQuery.cs create mode 100644 src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Validation/GetReservationOperationsSnapshotQueryValidator.cs create mode 100644 src/Modules/Reservations/BunkFy.Modules.Reservations.Contracts/Api/ReservationOperationsSnapshotDto.cs create mode 100644 src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/Repositories/ReservationOperationsSnapshotReader.cs create mode 100644 src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Application/GetReservationOperationsSnapshotQueryHandlerTests.cs create mode 100644 src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Persistence/ReservationOperationsSnapshotReaderTests.cs create mode 100644 tests/Integration.Tests/Reservations/ReservationOperationsSnapshotPersistenceIntegrationTests.cs diff --git a/BunkFy.slnx b/BunkFy.slnx index 7d122b37..968b01fa 100644 --- a/BunkFy.slnx +++ b/BunkFy.slnx @@ -185,6 +185,7 @@ + diff --git a/docs/README.md b/docs/README.md index 1182deb0..86a5c92e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -132,6 +132,7 @@ Useful entry points: - [Reservations Operational Surface Hardening Task](planning/reservations-operational-surface-hardening-task.md) - [Reservations Mutation Serialization Task](planning/reservations-mutation-serialization-task.md) - [Reservations Aggregate Query Shape Task](planning/reservations-aggregate-query-shape-task.md) +- [Reservations Property Operations Snapshot Task](planning/reservations-property-operations-snapshot-task.md) - [Reservations Correction Assurance Task](planning/reservations-correction-assurance-task.md) - [Reservations Management Create Idempotency Task](planning/reservations-management-create-idempotency-task.md) - [Reservations Management Lifecycle Idempotency Task](planning/reservations-management-lifecycle-idempotency-task.md) diff --git a/docs/planning/reservations-property-operations-snapshot-task.md b/docs/planning/reservations-property-operations-snapshot-task.md new file mode 100644 index 00000000..6231c759 --- /dev/null +++ b/docs/planning/reservations-property-operations-snapshot-task.md @@ -0,0 +1,131 @@ +# Reservations Property Operations Snapshot Task + +Status: implemented locally; deployment evidence is not part of this task + +## Goal + +Provide one authoritative Reservations read for the current operational state of +one property, coordinated by a property-local calendar date. The response gives +front-desk operators exact Reservation and guest counts, a disjoint attention +breakdown, and a small deterministic upcoming-arrivals list without requiring +clients to download and recompute the reservation directory. + +## Ownership and Boundaries + +- Reservations owns the read model, cohort definitions, ordinary-record + visibility rules, authorization contract, and response minimization. +- Properties remains authoritative for property lifecycle and IANA time-zone + data. Reservations uses its local property projection and fails closed when + that projection is missing, inactive, or has an invalid time zone. +- Inventory remains authoritative for allocation and release. In particular, + `CheckoutPending` remains physically in house until Inventory release + succeeds. +- The snapshot performs no cross-module runtime calls and introduces no + persistence migration. + +## Calendar and Observation Contract + +The handler captures `ObservedAtUtc` once. By default, the reader derives +`LocalDate` from that instant and the valid projected IANA `TimeZoneId`, and +returns `DateSource=PropertyTimeZone`. A caller may select an explicit +`localDate`, which returns `DateSource=Explicit`, but the property must still be +known, active, and have a valid projected IANA time zone. + +An explicit past or future date changes only the calendar predicates evaluated +against current reservation state. It is not a historical as-of reconstruction. +`CurrentlyInHouse` is always a current-state cohort. Property, Guest restriction, +and reservation changes can be briefly delayed by projection lag; `ObservedAtUtc` +identifies when the snapshot was evaluated, not when every upstream fact was +committed. + +## Exact Cohorts + +Every count reports both `ReservationCount` and the exact sum of current +`GuestCount` values. + +- `ConfirmedArrivalsOnLocalDate`: `Confirmed` and arrival equals `LocalDate`. +- `ScheduledDeparturesOnLocalDate`: `CheckedIn` or `CheckoutPending` and + departure equals `LocalDate`. +- `CurrentlyInHouse`: `CheckedIn` or `CheckoutPending`. + +The top-level cohorts are not mutually exclusive. A scheduled departure can be +currently in house, and a currently-in-house reservation can also need +attention. + +Attention is an exact total of seven mutually exclusive categories: + +- `PendingAllocation` +- `AllocationRejected` +- `CancellationPending` +- `NoShowPending` +- `CheckoutPending` +- `ArrivalBeforeLocalDateStillConfirmed` +- `DepartureBeforeLocalDateStillInHouse` + +The date-relative category names are intentionally descriptive. With an +explicit past or future date they do not assert that an item is operationally +overdue. + +## Upcoming Contract + +Upcoming items include only `PendingAllocation` and `Confirmed` reservations +whose arrival is on or after `LocalDate`. They sort by arrival, then non-null +expected arrival time before null, then expected arrival time, then reservation +id. The default limit is 25 and the strict range is 0 through 50. A zero limit +returns counts only while `HasMoreUpcoming` still reports whether any matching +item exists. The reader uses one-item lookahead and never returns more than the +echoed `UpcomingLimit`. + +Upcoming rows reuse the ordinary minimized reservation list DTO, including its +catalogued `PrimaryGuestName`. Anonymised reservations and reservations without +a current supported, unrestricted Guest processing-restriction projection are +excluded by the same fail-closed rule as ordinary directory reads. + +## Surfaces and Failures + +- Public API: + `GET /api/reservations/properties/{propertyId}/operations-snapshot`, tenant + and property scoped with `reservations.read`. +- Admin API: + `GET /api/admin/reservations/properties/{propertyId}/operations-snapshot`, + operation `reservations.operations-snapshot`, `reservations.read`, and the + host's property resource scope. +- Admin CLI: + `reservations operations-snapshot --property-id + [--local-date yyyy-MM-dd] [--upcoming-limit 0..50]` with equivalent + authorization, audit, and property resource scope. + +All responses on the exact public and Admin Reservations path boundaries, +including authentication, authorization, and binding failures before endpoint +execution, receive `Cache-Control: no-store`, `Pragma: no-cache`, and +`Expires: 0`. + +Stable failures are `Reservations.PropertyNotFound` (404), +`Reservations.PropertyInactive` (409), +`Reservations.PropertyTimeZoneUnavailable` (503 when the projected time-zone +identifier is missing, invalid, or not an IANA identifier), and +`Reservations.OperationsSnapshotLimitInvalid` (400). The CLI also returns +`Reservations.OperationsSnapshotLocalDateInvalid` for a non-ISO date. + +## Scale Contract + +This endpoint is an authoritative property-local operational read, not a fleet +aggregate. A regional or fleet dashboard must use a dedicated bounded rollup or +projection. It must not fan out this personal-data response across hundreds of +properties or widen this endpoint into an unbounded multi-property export. + +## Verification + +- Contract and handler tests freeze field names, limits, observation capture, + and stable error mapping. +- Reader tests prove time-zone validation, exact cohorts and disjoint attention + totals, ordinary-record visibility, deterministic lookahead, and limit zero. +- API surface tests freeze public property permission, Admin parity, and + no-store headers for 200, 400, 401, and 403 pipeline outcomes. +- Admin CLI tests cover the real executor, permission, property resource scope, + audit operation, JSON parity, the complete seven-category table breakdown, + and guest counts in upcoming rows. +- Personal-data catalogue v20 independently classifies public and Admin inputs, + public and Admin outputs, the application query, every snapshot wrapper, and + the nested ordinary upcoming DTO under the support boundary; the generated + inventory is checked in and deterministically verified. diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Admin.Contracts/Operations/ReservationsAdminOperationNames.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Admin.Contracts/Operations/ReservationsAdminOperationNames.cs index e87747f3..740aaf78 100644 --- a/src/Modules/Reservations/BunkFy.Modules.Reservations.Admin.Contracts/Operations/ReservationsAdminOperationNames.cs +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Admin.Contracts/Operations/ReservationsAdminOperationNames.cs @@ -3,6 +3,7 @@ namespace BunkFy.Modules.Reservations.Admin.Contracts; public static class ReservationsAdminOperationNames { public const string List = "reservations.list"; + public const string OperationsSnapshot = "reservations.operations-snapshot"; public const string Get = "reservations.get"; public const string History = "reservations.history"; public const string Create = "reservations.create"; diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.AdminApi/ReservationsAdminApiModule.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.AdminApi/ReservationsAdminApiModule.cs index 8347ba65..e68f7ee3 100644 --- a/src/Modules/Reservations/BunkFy.Modules.Reservations.AdminApi/ReservationsAdminApiModule.cs +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.AdminApi/ReservationsAdminApiModule.cs @@ -9,8 +9,11 @@ namespace BunkFy.Modules.Reservations.AdminApi; using Gma.Framework.ModuleComposition; using Gma.Framework.Pagination; using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; using BunkFy.Modules.Reservations.Admin.Contracts; using BunkFy.Modules.Reservations.Application; @@ -26,6 +29,8 @@ public sealed class ReservationsAdminApiModule : IAdminApiModule public void AddServices(IHostApplicationBuilder builder) { builder.SelectModuleProfile(ReservationsProfiles.Default, "BunkFy.Modules.Reservations.AdminApi"); + builder.Services.TryAddEnumerable( + ServiceDescriptor.Singleton()); builder.Services.AddReservationsApplication(); builder.AddReservationsPersistence(); } @@ -37,6 +42,32 @@ public void MapEndpoints(IEndpointRouteBuilder endpoints) .WithTags("Reservations Admin") .RequireAuthorization(); + group.MapGet("/operations-snapshot", async ( + Guid propertyId, + [AsParameters] OperationsSnapshotRequest request, + HttpContext httpContext, + AdminApiExecutor executor, + IRequestDispatcher dispatcher, + CancellationToken cancellationToken) => + { + MarkPersonalDataResponse(httpContext); + return await executor.ExecuteAsync( + httpContext, + AdminOperation.Create( + ReservationsAdminOperationNames.OperationsSnapshot, + ReservationsAdminPermissions.Read), + requireTenant: true, + token => dispatcher.QueryAsync( + new GetReservationOperationsSnapshotQuery( + propertyId, + request.LocalDate, + request.UpcomingLimit ?? ReservationsContractLimits.DefaultOperationsSnapshotUpcomingLimit), + token), + cancellationToken, + errorStatusCodes: ErrorStatusCodes).ConfigureAwait(false); + }) + .Produces(StatusCodes.Status200OK); + group.MapGet("", async ( Guid propertyId, ReservationStatus? status, @@ -462,6 +493,10 @@ public sealed record StayLifecycleRequest( long ExpectedVersion, bool Confirmed); + public sealed record OperationsSnapshotRequest( + DateOnly? LocalDate, + int? UpcomingLimit); + public sealed record LinkReservationGuestRequest( Guid GuestId, ReservationGuestRoleKind Role, @@ -489,6 +524,10 @@ public sealed record ReconcileReservationStayAmendmentRequest( private static readonly ApiErrorStatusCodeMap ErrorStatusCodes = ApiErrorStatusCodeMap.Create( new(ReservationsApplicationErrors.WorkspaceProcessingRestricted.Code, StatusCodes.Status423Locked), new(ReservationsApplicationErrors.WorkspaceProcessingAdmissionUnavailable.Code, StatusCodes.Status503ServiceUnavailable), + new(ReservationsApplicationErrors.PropertyNotFound.Code, StatusCodes.Status404NotFound), + new(ReservationsApplicationErrors.PropertyInactive.Code, StatusCodes.Status409Conflict), + new(ReservationsApplicationErrors.PropertyTimeZoneUnavailable.Code, StatusCodes.Status503ServiceUnavailable), + new(ReservationsApplicationErrors.OperationsSnapshotLimitInvalid.Code, StatusCodes.Status400BadRequest), new(ReservationsApplicationErrors.ReservationNotFound.Code, StatusCodes.Status404NotFound), new(ReservationsApplicationErrors.ExternalSourceAlreadyExists.Code, StatusCodes.Status409Conflict), new(ReservationsApplicationErrors.CreationOperationConflict.Code, StatusCodes.Status409Conflict), diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.AdminApi/ReservationsAdminNoStoreStartupFilter.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.AdminApi/ReservationsAdminNoStoreStartupFilter.cs new file mode 100644 index 00000000..2bf54fcd --- /dev/null +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.AdminApi/ReservationsAdminNoStoreStartupFilter.cs @@ -0,0 +1,42 @@ +namespace BunkFy.Modules.Reservations.AdminApi; + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; + +internal sealed class ReservationsAdminNoStoreStartupFilter : IStartupFilter +{ + private static readonly PathString ReservationsPath = new("/api/admin/reservations"); + + public Action Configure(Action next) + { + ArgumentNullException.ThrowIfNull(next); + + return applicationBuilder => + { + applicationBuilder.Use(nextMiddleware => context => + { + if (context.Request.Path.StartsWithSegments(ReservationsPath)) + { + MarkNoStore(context.Response); + context.Response.OnStarting(static state => + { + MarkNoStore((HttpResponse)state); + return Task.CompletedTask; + }, context.Response); + } + + return nextMiddleware(context); + }); + + next(applicationBuilder); + }; + } + + private static void MarkNoStore(HttpResponse response) + { + response.Headers.CacheControl = "no-store"; + response.Headers.Pragma = "no-cache"; + response.Headers.Expires = "0"; + } +} diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.AdminCli/ReservationsAdminCliModule.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.AdminCli/ReservationsAdminCliModule.cs index 8a81205b..336829f6 100644 --- a/src/Modules/Reservations/BunkFy.Modules.Reservations.AdminCli/ReservationsAdminCliModule.cs +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.AdminCli/ReservationsAdminCliModule.cs @@ -34,6 +34,7 @@ public void MapCommands(IAdminCliCommandRegistry commands) AdminCliGlobalOptions globalOptions = commands.Services.GetRequiredService(); Command module = new(ReservationsModuleMetadata.Name, "Reservation administration operations.") { + CreateOperationsSnapshotCommand(commands.Services, globalOptions), CreateListCommand(commands.Services, globalOptions), CreateGetCommand(commands.Services, globalOptions), CreateCreateCommand(commands.Services, globalOptions), @@ -90,6 +91,70 @@ public void MapCommands(IAdminCliCommandRegistry commands) commands.AddCommand(this.Name, module); } + private static Command CreateOperationsSnapshotCommand( + IServiceProvider services, + AdminCliGlobalOptions globalOptions) + { + Option propertyOption = PropertyOption(); + Option localDateOption = new("--local-date"); + Option upcomingLimitOption = new("--upcoming-limit") + { + DefaultValueFactory = _ => ReservationsContractLimits.DefaultOperationsSnapshotUpcomingLimit + }; + Command command = new( + "operations-snapshot", + "Show exact current Reservations operations cohorts for one property-local date; requires a valid projected IANA time zone.") + { + propertyOption, + localDateOption, + upcomingLimitOption + }; + command.SetAction((parseResult, cancellationToken) => + services.GetRequiredService().ExecuteAsync( + parseResult, + AdminOperation.Create( + ReservationsAdminOperationNames.OperationsSnapshot, + ReservationsAdminPermissions.Read), + parseResult.GetValue(globalOptions.TenantOption), + requireTenant: true, + async (provider, token) => + { + string? localDateText = parseResult.GetValue(localDateOption); + if (!string.IsNullOrWhiteSpace(localDateText) && + !TryParseDate(localDateText, out _)) + { + return Result.Failure( + ReservationsApplicationErrors.OperationsSnapshotLocalDateInvalid); + } + + DateOnly? localDate = string.IsNullOrWhiteSpace(localDateText) + ? null + : DateOnly.ParseExact( + localDateText, + "yyyy-MM-dd", + CultureInfo.InvariantCulture); + Result result = await provider + .GetRequiredService() + .QueryAsync( + new GetReservationOperationsSnapshotQuery( + parseResult.GetRequiredValue(propertyOption), + localDate, + parseResult.GetValue(upcomingLimitOption)), + token) + .ConfigureAwait(false); + if (result.IsSuccess) + { + WriteOperationsSnapshot( + result.Value, + parseResult.GetValue(globalOptions.OutputOption) ?? AdminCliOutput.Table); + } + + return result; + }, + cancellationToken)); + return command; + } + private static Command CreateListCommand(IServiceProvider services, AdminCliGlobalOptions globalOptions) { Option propertyOption = PropertyOption(); @@ -470,10 +535,78 @@ private static void WriteReservationSummaries( ("ExpectedArrival", reservation => reservation.ExpectedArrivalTime?.ToString("HH:mm", CultureInfo.InvariantCulture) ?? string.Empty), ("ExpectedDeparture", reservation => reservation.ExpectedDepartureTime?.ToString("HH:mm", CultureInfo.InvariantCulture) ?? string.Empty), ("Guest", reservation => reservation.PrimaryGuestName), + ("Guests", reservation => reservation.GuestCount.ToString(CultureInfo.InvariantCulture)), ("Status", reservation => reservation.Status.ToString()), ("Units", reservation => reservation.InventoryUnitCount.ToString(CultureInfo.InvariantCulture)) ]); + internal static void WriteOperationsSnapshot( + ReservationOperationsSnapshotDto snapshot, + string output) + { + if (AdminCliOutput.NormalizeFormat(output) == AdminCliOutput.Json) + { + AdminCliOutput.WriteObject(snapshot, output); + return; + } + + AdminCliOutput.WriteRows( + [snapshot], + output, + [ + ("PropertyId", item => item.PropertyId.ToString()), + ("LocalDate", item => item.LocalDate.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)), + ("TimeZone", item => item.TimeZoneId), + ("DateSource", item => item.DateSource.ToString()), + ("ObservedAtUtc", item => item.ObservedAtUtc.ToString("O", CultureInfo.InvariantCulture)), + ("ArrivalsOnLocalDate", item => FormatCount(item.Cohorts.ConfirmedArrivalsOnLocalDate)), + ("DeparturesOnLocalDate", item => FormatCount(item.Cohorts.ScheduledDeparturesOnLocalDate)), + ("CurrentlyInHouse", item => FormatCount(item.Cohorts.CurrentlyInHouse)), + ("Attention", item => FormatCount(item.Attention.Total)), + ("Upcoming", item => item.Upcoming.Count.ToString(CultureInfo.InvariantCulture)), + ("HasMoreUpcoming", item => item.HasMoreUpcoming.ToString()) + ]); + + AdminCliOutput.WriteRows( + AttentionRows(snapshot.Attention), + output, + [ + ("AttentionCategory", item => item.Category), + ("Reservations", item => item.Count.ReservationCount.ToString(CultureInfo.InvariantCulture)), + ("Guests", item => item.Count.GuestCount.ToString(CultureInfo.InvariantCulture)) + ]); + + if (snapshot.Upcoming.Count > 0) + { + WriteReservationSummaries(snapshot.Upcoming, output); + } + } + + private static string FormatCount(ReservationOperationsCountDto count) => + string.Create( + CultureInfo.InvariantCulture, + $"{count.ReservationCount} reservations / {count.GuestCount} guests"); + + private static IReadOnlyCollection AttentionRows( + ReservationOperationsAttentionCountsDto attention) => + [ + new(nameof(attention.PendingAllocation), attention.PendingAllocation), + new(nameof(attention.AllocationRejected), attention.AllocationRejected), + new(nameof(attention.CancellationPending), attention.CancellationPending), + new(nameof(attention.NoShowPending), attention.NoShowPending), + new(nameof(attention.CheckoutPending), attention.CheckoutPending), + new( + nameof(attention.ArrivalBeforeLocalDateStillConfirmed), + attention.ArrivalBeforeLocalDateStillConfirmed), + new( + nameof(attention.DepartureBeforeLocalDateStillInHouse), + attention.DepartureBeforeLocalDateStillInHouse) + ]; + + private sealed record OperationsAttentionRow( + string Category, + ReservationOperationsCountDto Count); + private static void WriteReservationMutationReceipts( IReadOnlyCollection receipts, string output) => diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Api/ReservationsModule.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Api/ReservationsModule.cs index 7efefebc..cd68454e 100644 --- a/src/Modules/Reservations/BunkFy.Modules.Reservations.Api/ReservationsModule.cs +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Api/ReservationsModule.cs @@ -12,6 +12,7 @@ namespace BunkFy.Modules.Reservations.Api; using Gma.Framework.Security.AspNetCore; using Gma.Framework.Tenancy.AccessControl.AspNetCore; using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; @@ -35,6 +36,8 @@ public void AddServices(IHostApplicationBuilder builder) builder.Services.AddOptions(); builder.Services.TryAddEnumerable( ServiceDescriptor.Scoped()); + builder.Services.TryAddEnumerable( + ServiceDescriptor.Singleton()); builder.Services.AddReservationsApplication(); builder.AddReservationsPersistence(); } @@ -49,6 +52,27 @@ public void MapEndpoints(IEndpointRouteBuilder endpoints) .WithTags("Reservations") .RequireAuthorization(); + group.MapGet("/operations-snapshot", async ( + Guid propertyId, + [AsParameters] OperationsSnapshotRequest request, + HttpContext httpContext, + IRequestDispatcher dispatcher, + CancellationToken cancellationToken) => + { + MarkPersonalDataResponse(httpContext); + return (await dispatcher.QueryAsync( + new GetReservationOperationsSnapshotQuery( + propertyId, + request.LocalDate, + request.UpcomingLimit ?? ReservationsContractLimits.DefaultOperationsSnapshotUpcomingLimit), + cancellationToken).ConfigureAwait(false)).ToHttpResult(ErrorStatusCodes); + }) + .Produces(StatusCodes.Status200OK) + .RequireTenant() + .RequireResolvedScopePermission( + ReservationsAdminPermissionCodes.Read, + ReservationsPropertyAccessScopeResolver.ResolverName); + group.MapPost("", async ( Guid propertyId, CreateReservationRequest request, @@ -543,6 +567,10 @@ public sealed record StayLifecycleRequest( DateOnly BusinessDate, long ExpectedVersion); + public sealed record OperationsSnapshotRequest( + DateOnly? LocalDate, + int? UpcomingLimit); + public sealed record LinkReservationGuestRequest( Guid GuestId, ReservationGuestRoleKind Role, @@ -578,6 +606,10 @@ public sealed record ReconcileReservationStayAmendmentRequest(long ExpectedOpera private static readonly ApiErrorStatusCodeMap ErrorStatusCodes = CreateErrorStatusCodes( new(ReservationsApplicationErrors.WorkspaceProcessingRestricted.Code, StatusCodes.Status423Locked), new(ReservationsApplicationErrors.WorkspaceProcessingAdmissionUnavailable.Code, StatusCodes.Status503ServiceUnavailable), + new(ReservationsApplicationErrors.PropertyNotFound.Code, StatusCodes.Status404NotFound), + new(ReservationsApplicationErrors.PropertyInactive.Code, StatusCodes.Status409Conflict), + new(ReservationsApplicationErrors.PropertyTimeZoneUnavailable.Code, StatusCodes.Status503ServiceUnavailable), + new(ReservationsApplicationErrors.OperationsSnapshotLimitInvalid.Code, StatusCodes.Status400BadRequest), new(ReservationsApplicationErrors.ReservationNotFound.Code, StatusCodes.Status404NotFound), new(ReservationsApplicationErrors.ExternalSourceAlreadyExists.Code, StatusCodes.Status409Conflict), new(ReservationsApplicationErrors.CreationOperationConflict.Code, StatusCodes.Status409Conflict), diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Api/ReservationsNoStoreStartupFilter.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Api/ReservationsNoStoreStartupFilter.cs new file mode 100644 index 00000000..d430b66e --- /dev/null +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Api/ReservationsNoStoreStartupFilter.cs @@ -0,0 +1,42 @@ +namespace BunkFy.Modules.Reservations.Api; + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; + +internal sealed class ReservationsNoStoreStartupFilter : IStartupFilter +{ + private static readonly PathString ReservationsPath = new("/api/reservations"); + + public Action Configure(Action next) + { + ArgumentNullException.ThrowIfNull(next); + + return applicationBuilder => + { + applicationBuilder.Use(nextMiddleware => context => + { + if (context.Request.Path.StartsWithSegments(ReservationsPath)) + { + MarkNoStore(context.Response); + context.Response.OnStarting(static state => + { + MarkNoStore((HttpResponse)state); + return Task.CompletedTask; + }, context.Response); + } + + return nextMiddleware(context); + }); + + next(applicationBuilder); + }; + } + + private static void MarkNoStore(HttpResponse response) + { + response.Headers.CacheControl = "no-store"; + response.Headers.Pragma = "no-cache"; + response.Headers.Expires = "0"; + } +} diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Handlers/GetReservationOperationsSnapshotQueryHandler.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Handlers/GetReservationOperationsSnapshotQueryHandler.cs new file mode 100644 index 00000000..9a89beea --- /dev/null +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Handlers/GetReservationOperationsSnapshotQueryHandler.cs @@ -0,0 +1,51 @@ +namespace BunkFy.Modules.Reservations.Application.Handlers; + +using BunkFy.Modules.Reservations.Application.Ports; +using BunkFy.Modules.Reservations.Application.Queries; +using BunkFy.Modules.Reservations.Contracts; +using Gma.Framework.Cqrs; +using Gma.Framework.Results; +using Gma.Framework.Runtime.Time; + +internal sealed class GetReservationOperationsSnapshotQueryHandler( + IReservationOperationsSnapshotReader reader, + ISystemClock clock) + : IQueryHandler< + GetReservationOperationsSnapshotQuery, + ReservationOperationsSnapshotDto> +{ + public async Task> HandleAsync( + GetReservationOperationsSnapshotQuery query, + CancellationToken cancellationToken) + { + if (query.UpcomingLimit is < 0 or > ReservationsContractLimits.MaximumOperationsSnapshotUpcomingLimit) + { + return Result.Failure( + ReservationsApplicationErrors.OperationsSnapshotLimitInvalid); + } + + ReservationOperationsSnapshotReadResult read = await reader.ReadAsync( + query.PropertyId, + query.LocalDate, + clock.UtcNow, + query.UpcomingLimit, + cancellationToken).ConfigureAwait(false); + + return read.Status switch + { + ReservationOperationsSnapshotReadStatus.Found when read.Snapshot is not null => + Result.Success(read.Snapshot), + ReservationOperationsSnapshotReadStatus.PropertyNotFound => + Result.Failure( + ReservationsApplicationErrors.PropertyNotFound), + ReservationOperationsSnapshotReadStatus.PropertyInactive => + Result.Failure( + ReservationsApplicationErrors.PropertyInactive), + ReservationOperationsSnapshotReadStatus.PropertyTimeZoneUnavailable => + Result.Failure( + ReservationsApplicationErrors.PropertyTimeZoneUnavailable), + _ => throw new InvalidOperationException( + "The Reservations operations snapshot reader returned an invalid result.") + }; + } +} diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Ports/IReservationOperationsSnapshotReader.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Ports/IReservationOperationsSnapshotReader.cs new file mode 100644 index 00000000..abee69ed --- /dev/null +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Ports/IReservationOperationsSnapshotReader.cs @@ -0,0 +1,40 @@ +namespace BunkFy.Modules.Reservations.Application.Ports; + +using BunkFy.Modules.Reservations.Contracts; + +public interface IReservationOperationsSnapshotReader +{ + Task ReadAsync( + Guid propertyId, + DateOnly? explicitLocalDate, + DateTimeOffset observedAtUtc, + int upcomingLimit, + CancellationToken cancellationToken); +} + +public enum ReservationOperationsSnapshotReadStatus +{ + Unknown = 0, + Found = 1, + PropertyNotFound = 2, + PropertyInactive = 3, + PropertyTimeZoneUnavailable = 4 +} + +public sealed record ReservationOperationsSnapshotReadResult( + ReservationOperationsSnapshotReadStatus Status, + ReservationOperationsSnapshotDto? Snapshot) +{ + public static ReservationOperationsSnapshotReadResult Found( + ReservationOperationsSnapshotDto snapshot) => + new(ReservationOperationsSnapshotReadStatus.Found, snapshot); + + public static ReservationOperationsSnapshotReadResult PropertyNotFound() => + new(ReservationOperationsSnapshotReadStatus.PropertyNotFound, null); + + public static ReservationOperationsSnapshotReadResult PropertyInactive() => + new(ReservationOperationsSnapshotReadStatus.PropertyInactive, null); + + public static ReservationOperationsSnapshotReadResult PropertyTimeZoneUnavailable() => + new(ReservationOperationsSnapshotReadStatus.PropertyTimeZoneUnavailable, null); +} diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Queries/GetReservationOperationsSnapshotQuery.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Queries/GetReservationOperationsSnapshotQuery.cs new file mode 100644 index 00000000..e138a60b --- /dev/null +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Queries/GetReservationOperationsSnapshotQuery.cs @@ -0,0 +1,10 @@ +namespace BunkFy.Modules.Reservations.Application.Queries; + +using BunkFy.Modules.Reservations.Contracts; +using Gma.Framework.Cqrs; + +public sealed record GetReservationOperationsSnapshotQuery( + Guid PropertyId, + DateOnly? LocalDate, + int UpcomingLimit) + : IQuery; diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/ReservationsApplicationErrors.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/ReservationsApplicationErrors.cs index f0a3045b..1d3ffbf4 100644 --- a/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/ReservationsApplicationErrors.cs +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/ReservationsApplicationErrors.cs @@ -16,6 +16,21 @@ public static class ReservationsApplicationErrors .Select(CountryPolicyDenied) .ToArray(); public static readonly Error ReservationNotFound = new("Reservations.ReservationNotFound", "The reservation was not found."); + public static readonly Error PropertyNotFound = new( + "Reservations.PropertyNotFound", + "The property is not present in the Reservations projection."); + public static readonly Error PropertyInactive = new( + "Reservations.PropertyInactive", + "The property is not active."); + public static readonly Error PropertyTimeZoneUnavailable = new( + "Reservations.PropertyTimeZoneUnavailable", + "The property IANA time zone is unavailable or invalid."); + public static readonly Error OperationsSnapshotLimitInvalid = new( + "Reservations.OperationsSnapshotLimitInvalid", + $"The upcoming limit must be between 0 and {ReservationsContractLimits.MaximumOperationsSnapshotUpcomingLimit}."); + public static readonly Error OperationsSnapshotLocalDateInvalid = new( + "Reservations.OperationsSnapshotLocalDateInvalid", + "The local date must use yyyy-MM-dd format."); public static readonly Error ExternalSourceAlreadyExists = new("Reservations.ExternalSourceAlreadyExists", "A reservation already exists for this external source reference."); public static readonly Error CreationOperationConflict = new( "Reservations.CreationOperationConflict", diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Validation/GetReservationOperationsSnapshotQueryValidator.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Validation/GetReservationOperationsSnapshotQueryValidator.cs new file mode 100644 index 00000000..ec85d01a --- /dev/null +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Validation/GetReservationOperationsSnapshotQueryValidator.cs @@ -0,0 +1,16 @@ +namespace BunkFy.Modules.Reservations.Application.Validation; + +using BunkFy.Modules.Reservations.Application.Queries; +using Gma.Framework.Cqrs; + +internal sealed class GetReservationOperationsSnapshotQueryValidator + : IQueryValidator +{ + public IEnumerable Validate(GetReservationOperationsSnapshotQuery query) + { + if (query.PropertyId == Guid.Empty) + { + yield return "PropertyId is required."; + } + } +} diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Contracts/Api/ReservationOperationsSnapshotDto.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Contracts/Api/ReservationOperationsSnapshotDto.cs new file mode 100644 index 00000000..daaa0701 --- /dev/null +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Contracts/Api/ReservationOperationsSnapshotDto.cs @@ -0,0 +1,39 @@ +namespace BunkFy.Modules.Reservations.Contracts; + +public enum ReservationOperationsDateSource +{ + Unknown = 0, + PropertyTimeZone = 1, + Explicit = 2 +} + +public sealed record ReservationOperationsCountDto( + long ReservationCount, + long GuestCount); + +public sealed record ReservationOperationsCohortCountsDto( + ReservationOperationsCountDto ConfirmedArrivalsOnLocalDate, + ReservationOperationsCountDto ScheduledDeparturesOnLocalDate, + ReservationOperationsCountDto CurrentlyInHouse); + +public sealed record ReservationOperationsAttentionCountsDto( + ReservationOperationsCountDto PendingAllocation, + ReservationOperationsCountDto AllocationRejected, + ReservationOperationsCountDto CancellationPending, + ReservationOperationsCountDto NoShowPending, + ReservationOperationsCountDto CheckoutPending, + ReservationOperationsCountDto ArrivalBeforeLocalDateStillConfirmed, + ReservationOperationsCountDto DepartureBeforeLocalDateStillInHouse, + ReservationOperationsCountDto Total); + +public sealed record ReservationOperationsSnapshotDto( + Guid PropertyId, + DateOnly LocalDate, + string TimeZoneId, + ReservationOperationsDateSource DateSource, + DateTimeOffset ObservedAtUtc, + ReservationOperationsCohortCountsDto Cohorts, + ReservationOperationsAttentionCountsDto Attention, + IReadOnlyCollection Upcoming, + int UpcomingLimit, + bool HasMoreUpcoming); diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Contracts/Metadata/ReservationsContractLimits.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Contracts/Metadata/ReservationsContractLimits.cs index 5e573426..be65c9b1 100644 --- a/src/Modules/Reservations/BunkFy.Modules.Reservations.Contracts/Metadata/ReservationsContractLimits.cs +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Contracts/Metadata/ReservationsContractLimits.cs @@ -12,4 +12,6 @@ public static class ReservationsContractLimits public const int TimeZoneIdMaxLength = 100; public const int SearchMaxLength = 200; public const int MaximumRequestedUnits = 100; + public const int DefaultOperationsSnapshotUpcomingLimit = 25; + public const int MaximumOperationsSnapshotUpcomingLimit = 50; } diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Contracts/Metadata/ReservationsTenantTerminationMetadata.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Contracts/Metadata/ReservationsTenantTerminationMetadata.cs index 8d4e3efc..1ee9684d 100644 --- a/src/Modules/Reservations/BunkFy.Modules.Reservations.Contracts/Metadata/ReservationsTenantTerminationMetadata.cs +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Contracts/Metadata/ReservationsTenantTerminationMetadata.cs @@ -8,7 +8,7 @@ public static class ReservationsTenantTerminationMetadata public const string OwnerKey = ReservationsDataRightsCoordinates.Owner; public const string DependencyOwnerKey = "inventory"; public const int CatalogVersion = 7; - public const int PersonalDataCatalogVersion = 19; + public const int PersonalDataCatalogVersion = 20; public const string ExportCatalogId = "reservations.tenant-portability"; public const int ExportCatalogSchemaVersion = 1; diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/DependencyInjection.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/DependencyInjection.cs index 44a330b4..506711b5 100644 --- a/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/DependencyInjection.cs +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/DependencyInjection.cs @@ -34,6 +34,9 @@ public static IHostApplicationBuilder AddReservationsPersistence(this IHostAppli ReservationsMigrations.HistoryTable)); builder.Services.TryAddScoped(); + builder.Services.TryAddScoped< + IReservationOperationsSnapshotReader, + ReservationOperationsSnapshotReader>(); builder.Services.TryAddScoped< IReservationGuestRecordLinkProcessRepository, ReservationGuestRecordLinkProcessRepository>(); diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/Repositories/ReservationOperationsSnapshotReader.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/Repositories/ReservationOperationsSnapshotReader.cs new file mode 100644 index 00000000..0233d7f1 --- /dev/null +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/Repositories/ReservationOperationsSnapshotReader.cs @@ -0,0 +1,404 @@ +namespace BunkFy.Modules.Reservations.Persistence.Repositories; + +using System.Data; +using BunkFy.Modules.Reservations.Application.Ports; +using BunkFy.Modules.Reservations.Contracts; +using BunkFy.Modules.Reservations.Domain.Aggregates; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage; + +internal sealed class ReservationOperationsSnapshotReader( + ReservationsDbContext dbContext) + : IReservationOperationsSnapshotReader +{ + private static readonly ReservationOperationsCountDto EmptyCount = new(0, 0); + + public async Task ReadAsync( + Guid propertyId, + DateOnly? explicitLocalDate, + DateTimeOffset observedAtUtc, + int upcomingLimit, + CancellationToken cancellationToken) + { + if (propertyId == Guid.Empty || + upcomingLimit is < 0 or > + ReservationsContractLimits.MaximumOperationsSnapshotUpcomingLimit) + { + throw new ArgumentOutOfRangeException( + propertyId == Guid.Empty + ? nameof(propertyId) + : nameof(upcomingLimit)); + } + + DateTimeOffset observation = observedAtUtc.ToUniversalTime(); + if (observation == default) + { + throw new ArgumentOutOfRangeException(nameof(observedAtUtc)); + } + + if (dbContext.Database.IsRelational() && + dbContext.Database.CurrentTransaction is not null) + { + throw new InvalidOperationException( + "The Reservations operations snapshot cannot run inside an existing transaction."); + } + + IDbContextTransaction? transaction = null; + try + { + if (dbContext.Database.IsRelational()) + { + transaction = await dbContext.Database.BeginTransactionAsync( + ResolveRelationalSnapshotIsolation( + dbContext.Database.ProviderName), + cancellationToken).ConfigureAwait(false); + } + + PropertySnapshot? property = await dbContext.PropertyProjections + .AsNoTracking() + .Where(item => item.Id == propertyId) + .Select(item => new PropertySnapshot( + item.IsKnown, + item.IsActive, + item.TimeZoneId, + item.TopologySourceVersion)) + .SingleOrDefaultAsync(cancellationToken) + .ConfigureAwait(false); + if (property is null || + !property.IsKnown || + property.TopologySourceVersion <= 0) + { + return ReservationOperationsSnapshotReadResult.PropertyNotFound(); + } + + if (!property.IsActive) + { + return ReservationOperationsSnapshotReadResult.PropertyInactive(); + } + + if (!TryResolveTimeZone(property.TimeZoneId, out TimeZoneInfo? timeZone)) + { + return ReservationOperationsSnapshotReadResult.PropertyTimeZoneUnavailable(); + } + + DateOnly localDate = explicitLocalDate ?? DateOnly.FromDateTime( + TimeZoneInfo.ConvertTime(observation, timeZone).DateTime); + + IQueryable ordinary = ReservationVisibilityQueries + .Ordinary(dbContext) + .AsNoTracking() + .Where(reservation => reservation.PropertyId == propertyId); + ReservationOperationsAggregate? counts = await ordinary + .Where(reservation => + reservation.Status == ReservationState.PendingAllocation || + reservation.Status == ReservationState.Confirmed || + reservation.Status == ReservationState.AllocationRejected || + reservation.Status == ReservationState.CancellationPending || + reservation.Status == ReservationState.CheckedIn || + reservation.Status == ReservationState.NoShowPending || + reservation.Status == ReservationState.CheckoutPending) + .GroupBy(_ => 1) + .Select(group => new ReservationOperationsAggregate( + group.LongCount(reservation => + reservation.Status == ReservationState.Confirmed && + reservation.Arrival == localDate), + group.Where(reservation => + reservation.Status == ReservationState.Confirmed && + reservation.Arrival == localDate) + .Sum(reservation => (long)reservation.GuestCount), + group.LongCount(reservation => + (reservation.Status == ReservationState.CheckedIn || + reservation.Status == ReservationState.CheckoutPending) && + reservation.Departure == localDate), + group.Where(reservation => + (reservation.Status == ReservationState.CheckedIn || + reservation.Status == ReservationState.CheckoutPending) && + reservation.Departure == localDate) + .Sum(reservation => (long)reservation.GuestCount), + group.LongCount(reservation => + reservation.Status == ReservationState.CheckedIn || + reservation.Status == ReservationState.CheckoutPending), + group.Where(reservation => + reservation.Status == ReservationState.CheckedIn || + reservation.Status == ReservationState.CheckoutPending) + .Sum(reservation => (long)reservation.GuestCount), + group.LongCount(reservation => + reservation.Status == ReservationState.PendingAllocation), + group.Where(reservation => + reservation.Status == ReservationState.PendingAllocation) + .Sum(reservation => (long)reservation.GuestCount), + group.LongCount(reservation => + reservation.Status == ReservationState.AllocationRejected), + group.Where(reservation => + reservation.Status == ReservationState.AllocationRejected) + .Sum(reservation => (long)reservation.GuestCount), + group.LongCount(reservation => + reservation.Status == ReservationState.CancellationPending), + group.Where(reservation => + reservation.Status == ReservationState.CancellationPending) + .Sum(reservation => (long)reservation.GuestCount), + group.LongCount(reservation => + reservation.Status == ReservationState.NoShowPending), + group.Where(reservation => + reservation.Status == ReservationState.NoShowPending) + .Sum(reservation => (long)reservation.GuestCount), + group.LongCount(reservation => + reservation.Status == ReservationState.CheckoutPending), + group.Where(reservation => + reservation.Status == ReservationState.CheckoutPending) + .Sum(reservation => (long)reservation.GuestCount), + group.LongCount(reservation => + reservation.Status == ReservationState.Confirmed && + reservation.Arrival < localDate), + group.Where(reservation => + reservation.Status == ReservationState.Confirmed && + reservation.Arrival < localDate) + .Sum(reservation => (long)reservation.GuestCount), + group.LongCount(reservation => + reservation.Status == ReservationState.CheckedIn && + reservation.Departure < localDate), + group.Where(reservation => + reservation.Status == ReservationState.CheckedIn && + reservation.Departure < localDate) + .Sum(reservation => (long)reservation.GuestCount))) + .SingleOrDefaultAsync(cancellationToken) + .ConfigureAwait(false); + + UpcomingReservationRow[] upcomingRows = await ordinary + .Where(reservation => + (reservation.Status == ReservationState.PendingAllocation || + reservation.Status == ReservationState.Confirmed) && + reservation.Arrival >= localDate) + .OrderBy(reservation => reservation.Arrival) + .ThenBy(reservation => reservation.ExpectedArrivalTime == null) + .ThenBy(reservation => reservation.ExpectedArrivalTime) + .ThenBy(reservation => reservation.Id) + .Take(upcomingLimit + 1) + .Select(reservation => new UpcomingReservationRow( + reservation.Id, + reservation.PropertyId, + reservation.Arrival, + reservation.Departure, + reservation.ExpectedArrivalTime, + reservation.ExpectedDepartureTime, + reservation.PrimaryGuestName, + reservation.GuestCount, + reservation.RequestedUnits.Count, + reservation.Source, + reservation.Status)) + .ToArrayAsync(cancellationToken) + .ConfigureAwait(false); + + bool hasMore = upcomingRows.Length > upcomingLimit; + ReservationOperationsSnapshotDto snapshot = new( + propertyId, + localDate, + timeZone.Id, + explicitLocalDate.HasValue + ? ReservationOperationsDateSource.Explicit + : ReservationOperationsDateSource.PropertyTimeZone, + observation, + MapCohorts(counts), + MapAttention(counts), + upcomingRows + .Take(upcomingLimit) + .Select(MapUpcoming) + .ToArray(), + upcomingLimit, + hasMore); + + if (transaction is not null) + { + await transaction.CommitAsync(cancellationToken) + .ConfigureAwait(false); + } + + return ReservationOperationsSnapshotReadResult.Found(snapshot); + } + finally + { + if (transaction is not null) + { + await transaction.DisposeAsync().ConfigureAwait(false); + } + } + } + + private static bool TryResolveTimeZone( + string? timeZoneId, + out TimeZoneInfo timeZone) + { + timeZone = null!; + if (string.IsNullOrWhiteSpace(timeZoneId)) + { + return false; + } + + try + { + TimeZoneInfo candidate = TimeZoneInfo.FindSystemTimeZoneById( + timeZoneId.Trim()); + if (!candidate.HasIanaId) + { + return false; + } + + timeZone = candidate; + return true; + } + catch (TimeZoneNotFoundException) + { + return false; + } + catch (InvalidTimeZoneException) + { + return false; + } + } + + internal static IsolationLevel ResolveRelationalSnapshotIsolation( + string? providerName) => providerName switch + { + "Npgsql.EntityFrameworkCore.PostgreSQL" => + IsolationLevel.RepeatableRead, + "Microsoft.EntityFrameworkCore.SqlServer" => + IsolationLevel.Serializable, + _ => throw new InvalidOperationException( + $"The Reservations operations snapshot does not support relational provider '{providerName ?? "unknown"}'.") + }; + + private static ReservationOperationsCohortCountsDto MapCohorts( + ReservationOperationsAggregate? counts) => + counts is null + ? new(EmptyCount, EmptyCount, EmptyCount) + : new( + new(counts.ConfirmedArrivalsReservations, + counts.ConfirmedArrivalsGuests), + new(counts.ScheduledDeparturesReservations, + counts.ScheduledDeparturesGuests), + new(counts.InHouseReservations, counts.InHouseGuests)); + + private static ReservationOperationsAttentionCountsDto MapAttention( + ReservationOperationsAggregate? counts) + { + if (counts is null) + { + return new( + EmptyCount, + EmptyCount, + EmptyCount, + EmptyCount, + EmptyCount, + EmptyCount, + EmptyCount, + EmptyCount); + } + + ReservationOperationsCountDto pendingAllocation = new( + counts.PendingAllocationReservations, + counts.PendingAllocationGuests); + ReservationOperationsCountDto allocationRejected = new( + counts.AllocationRejectedReservations, + counts.AllocationRejectedGuests); + ReservationOperationsCountDto cancellationPending = new( + counts.CancellationPendingReservations, + counts.CancellationPendingGuests); + ReservationOperationsCountDto noShowPending = new( + counts.NoShowPendingReservations, + counts.NoShowPendingGuests); + ReservationOperationsCountDto checkoutPending = new( + counts.CheckoutPendingReservations, + counts.CheckoutPendingGuests); + ReservationOperationsCountDto arrivalBeforeLocalDateStillConfirmed = new( + counts.ArrivalBeforeLocalDateStillConfirmedReservations, + counts.ArrivalBeforeLocalDateStillConfirmedGuests); + ReservationOperationsCountDto departureBeforeLocalDateStillInHouse = new( + counts.DepartureBeforeLocalDateStillInHouseReservations, + counts.DepartureBeforeLocalDateStillInHouseGuests); + return new( + pendingAllocation, + allocationRejected, + cancellationPending, + noShowPending, + checkoutPending, + arrivalBeforeLocalDateStillConfirmed, + departureBeforeLocalDateStillInHouse, + new( + pendingAllocation.ReservationCount + + allocationRejected.ReservationCount + + cancellationPending.ReservationCount + + noShowPending.ReservationCount + + checkoutPending.ReservationCount + + arrivalBeforeLocalDateStillConfirmed.ReservationCount + + departureBeforeLocalDateStillInHouse.ReservationCount, + pendingAllocation.GuestCount + + allocationRejected.GuestCount + + cancellationPending.GuestCount + + noShowPending.GuestCount + + checkoutPending.GuestCount + + arrivalBeforeLocalDateStillConfirmed.GuestCount + + departureBeforeLocalDateStillInHouse.GuestCount)); + } + + private static ReservationListItemDto MapUpcoming( + UpcomingReservationRow reservation) => new( + reservation.ReservationId, + reservation.PropertyId, + reservation.Arrival, + reservation.Departure, + reservation.ExpectedArrivalTime, + reservation.ExpectedDepartureTime, + reservation.PrimaryGuestName, + reservation.GuestCount, + reservation.InventoryUnitCount, + reservation.Source == ReservationSource.Direct + ? ReservationSourceKind.Direct + : ReservationSourceKind.External, + reservation.Status switch + { + ReservationState.PendingAllocation => ReservationStatus.PendingAllocation, + ReservationState.Confirmed => ReservationStatus.Confirmed, + _ => ReservationStatus.Unknown + }); + + private sealed record PropertySnapshot( + bool IsKnown, + bool IsActive, + string? TimeZoneId, + long TopologySourceVersion); + + private sealed record ReservationOperationsAggregate( + long ConfirmedArrivalsReservations, + long ConfirmedArrivalsGuests, + long ScheduledDeparturesReservations, + long ScheduledDeparturesGuests, + long InHouseReservations, + long InHouseGuests, + long PendingAllocationReservations, + long PendingAllocationGuests, + long AllocationRejectedReservations, + long AllocationRejectedGuests, + long CancellationPendingReservations, + long CancellationPendingGuests, + long NoShowPendingReservations, + long NoShowPendingGuests, + long CheckoutPendingReservations, + long CheckoutPendingGuests, + long ArrivalBeforeLocalDateStillConfirmedReservations, + long ArrivalBeforeLocalDateStillConfirmedGuests, + long DepartureBeforeLocalDateStillInHouseReservations, + long DepartureBeforeLocalDateStillInHouseGuests); + + private sealed record UpcomingReservationRow( + Guid ReservationId, + Guid PropertyId, + DateOnly Arrival, + DateOnly Departure, + TimeOnly? ExpectedArrivalTime, + TimeOnly? ExpectedDepartureTime, + string PrimaryGuestName, + int GuestCount, + int InventoryUnitCount, + ReservationSource Source, + ReservationState Status); +} diff --git a/src/Modules/Reservations/docs/README.md b/src/Modules/Reservations/docs/README.md index b6ee7112..b89ce9bf 100644 --- a/src/Modules/Reservations/docs/README.md +++ b/src/Modules/Reservations/docs/README.md @@ -20,6 +20,9 @@ retention, and rights policies receive production approval. - distinct scoped permissions for read, create, manage, cancel, check-in, no-show, and check-out; - directly projected, bounded operational directory pages with look-ahead pagination and no aggregate hydration or count query; +- one authoritative property-local operations snapshot with exact Reservation + and guest counts, a disjoint seven-category attention breakdown, and a + deterministic bounded upcoming-arrivals list; - public management API, Admin API, Admin CLI, PostgreSQL migration, and Worker composition; - a versioned local Inventory projection with live unit/block/allocation handlers and a rebuild task sourced from `IInventoryAvailabilityProjectionExportSource`; - an independent editable-details revision and provenance marker that does not move on allocation-only lifecycle changes; @@ -63,6 +66,31 @@ retention, and rights policies receive production approval. The local Inventory projection validates unit/property relationships and supports management reads, but it is advisory for availability. Only an Inventory allocation outcome can confirm a reservation. +## Property Operations Snapshot + +The public API, Admin API, and Admin CLI expose the same current-state read for +one property under `reservations.read`. When no date is supplied, Reservations +derives the property-local date from one captured `ObservedAtUtc` and the valid +projected IANA time zone. An explicit `localDate` changes the date predicates +but still requires a known, active property and valid projected IANA time zone; +an identifier that is merely platform-resolvable but not IANA fails with +`Reservations.PropertyTimeZoneUnavailable`. The read is not a +historical as-of reconstruction. Projection lag can therefore affect current +state briefly. + +Confirmed arrivals and scheduled departures are tied to the response +`LocalDate`; `CurrentlyInHouse` is current state. Those top-level cohorts may +overlap. The seven attention categories are disjoint, and their exact total is +returned with Reservation and guest counts. Upcoming items are limited to 0 +through 50, ordered deterministically, reuse ordinary anonymisation and Guest +restriction rules, and use one-item lookahead for `HasMoreUpcoming`. + +This is a property-local operational read, not a regional or fleet aggregate. +A future fleet dashboard must use a dedicated rollup/projection instead of +fanning this bounded personal-data response out across hundreds of properties. +All exact Reservations API path responses are marked no-store, including +binding and authorization failures before endpoint execution. + ## Runtime Compose Properties, Inventory, Reservations, and Guests in the Worker with NATS consumers, task scheduling, and publishing enabled. The normal Aspire graph enables all four. Reservations rebuild tasks source Inventory availability, Guest eligibility, and property time zones from their owning modules. A minute-level scoped task reads only the indexed due-reminder ledger and publishes reminder events through the Reservations outbox; the Notifications extension owns staff/owner fan-out and delivery preferences. diff --git a/src/Modules/Reservations/docs/personal-data-catalog.v1.json b/src/Modules/Reservations/docs/personal-data-catalog.v1.json index f36ca5e6..b0c7be54 100644 --- a/src/Modules/Reservations/docs/personal-data-catalog.v1.json +++ b/src/Modules/Reservations/docs/personal-data-catalog.v1.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, "catalogId": "reservations.personal-data", - "catalogVersion": 19, + "catalogVersion": 20, "module": "reservations", "approvalState": "engineering-default", "accessPolicies": [ @@ -8662,13 +8662,15 @@ "application-command", "data-rights-export", "domain-event", - "persistence" + "persistence", + "admin-output" ], "allowedBoundaries": [ "adapter", "cross-module", "customer-api", - "intra-module" + "intra-module", + "support" ], "approvalState": "engineering-default", "bindings": [ @@ -8788,6 +8790,27 @@ "member": "GuestCount", "surface": "data-rights-export", "retentionPolicy": "reservation-data-rights-export-fragment" + }, + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationOperationsCountDto", + "member": "GuestCount", + "surface": "api-response", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationListItemDto", + "member": "GuestCount", + "surface": "admin-output", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationOperationsCountDto", + "member": "GuestCount", + "surface": "admin-output", + "retentionPolicy": "transient-response" } ] }, @@ -9316,13 +9339,15 @@ "data-rights-export", "domain-event", "persistence", - "search-index" + "search-index", + "admin-output" ], "allowedBoundaries": [ "adapter", "cross-module", "customer-api", - "intra-module" + "intra-module", + "support" ], "approvalState": "engineering-default", "bindings": [ @@ -9466,6 +9491,13 @@ "member": "PrimaryGuestName", "surface": "data-rights-export", "retentionPolicy": "reservation-data-rights-export-fragment" + }, + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationListItemDto", + "member": "PrimaryGuestName", + "surface": "admin-output", + "retentionPolicy": "transient-response" } ] }, @@ -9488,10 +9520,12 @@ "retentionPolicy": "reservation-record-lifecycle", "rightsPolicy": "guest-reservation-data", "allowedSurfaces": [ - "api-response" + "api-response", + "admin-output" ], "allowedBoundaries": [ - "customer-api" + "customer-api", + "support" ], "approvalState": "engineering-default", "bindings": [ @@ -9501,6 +9535,13 @@ "member": "InventoryUnitCount", "surface": "api-response", "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationListItemDto", + "member": "InventoryUnitCount", + "surface": "admin-output", + "retentionPolicy": "transient-response" } ] }, @@ -11229,13 +11270,15 @@ "data-rights-export", "domain-event", "integration-event", - "persistence" + "persistence", + "admin-output" ], "allowedBoundaries": [ "adapter", "cross-module", "customer-api", - "intra-module" + "intra-module", + "support" ], "approvalState": "engineering-default", "bindings": [ @@ -11706,6 +11749,13 @@ "type": "BunkFy.Modules.Reservations.Domain.GuestRecords.ReservationGuestRecordLinkProcess", "member": "ReservationId", "surface": "persistence" + }, + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationListItemDto", + "member": "ReservationId", + "surface": "admin-output", + "retentionPolicy": "transient-response" } ] }, @@ -11731,12 +11781,14 @@ "allowedSurfaces": [ "api-response", "data-rights-export", - "persistence" + "persistence", + "admin-output" ], "allowedBoundaries": [ "cross-module", "customer-api", - "intra-module" + "intra-module", + "support" ], "approvalState": "engineering-default", "bindings": [ @@ -11773,6 +11825,13 @@ "member": "Status", "surface": "data-rights-export", "retentionPolicy": "reservation-data-rights-export-fragment" + }, + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationListItemDto", + "member": "Status", + "surface": "admin-output", + "retentionPolicy": "transient-response" } ] }, @@ -13356,13 +13415,15 @@ "data-rights-export", "domain-event", "integration-event", - "persistence" + "persistence", + "admin-output" ], "allowedBoundaries": [ "adapter", "cross-module", "customer-api", - "intra-module" + "intra-module", + "support" ], "approvalState": "engineering-default", "bindings": [ @@ -13827,6 +13888,34 @@ "type": "BunkFy.Modules.Reservations.Domain.GuestRecords.ReservationGuestRecordLinkProcess", "member": "PropertyId", "surface": "persistence" + }, + { + "assembly": "BunkFy.Modules.Reservations.Application", + "type": "BunkFy.Modules.Reservations.Application.Queries.GetReservationOperationsSnapshotQuery", + "member": "PropertyId", + "surface": "application-query", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationOperationsSnapshotDto", + "member": "PropertyId", + "surface": "api-response", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationListItemDto", + "member": "PropertyId", + "surface": "admin-output", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationOperationsSnapshotDto", + "member": "PropertyId", + "surface": "admin-output", + "retentionPolicy": "transient-response" } ] }, @@ -14312,12 +14401,14 @@ "api-response", "application-command", "data-rights-export", - "persistence" + "persistence", + "admin-output" ], "allowedBoundaries": [ "cross-module", "customer-api", - "intra-module" + "intra-module", + "support" ], "approvalState": "engineering-default", "bindings": [ @@ -14361,6 +14452,13 @@ "member": "Source", "surface": "data-rights-export", "retentionPolicy": "reservation-data-rights-export-fragment" + }, + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationListItemDto", + "member": "SourceKind", + "surface": "admin-output", + "retentionPolicy": "transient-response" } ] }, @@ -14590,13 +14688,15 @@ "data-rights-export", "domain-event", "integration-event", - "persistence" + "persistence", + "admin-output" ], "allowedBoundaries": [ "adapter", "cross-module", "customer-api", - "intra-module" + "intra-module", + "support" ], "approvalState": "engineering-default", "bindings": [ @@ -14783,6 +14883,13 @@ "type": "BunkFy.Modules.Reservations.Persistence.ReservationInventoryAllocationProjection", "member": "Arrival", "surface": "persistence" + }, + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationListItemDto", + "member": "Arrival", + "surface": "admin-output", + "retentionPolicy": "transient-response" } ] }, @@ -15191,13 +15298,15 @@ "data-rights-export", "domain-event", "integration-event", - "persistence" + "persistence", + "admin-output" ], "allowedBoundaries": [ "adapter", "cross-module", "customer-api", - "intra-module" + "intra-module", + "support" ], "approvalState": "engineering-default", "bindings": [ @@ -15343,6 +15452,13 @@ "type": "BunkFy.Modules.Reservations.Persistence.ReservationInventoryAllocationProjection", "member": "Departure", "surface": "persistence" + }, + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationListItemDto", + "member": "Departure", + "surface": "admin-output", + "retentionPolicy": "transient-response" } ] }, @@ -15374,13 +15490,15 @@ "data-rights-export", "domain-event", "integration-event", - "persistence" + "persistence", + "admin-output" ], "allowedBoundaries": [ "adapter", "cross-module", "customer-api", - "intra-module" + "intra-module", + "support" ], "approvalState": "engineering-default", "bindings": [ @@ -15541,6 +15659,13 @@ "type": "BunkFy.Modules.Reservations.Persistence.ReservationArrivalReminder", "member": "ExpectedArrivalTime", "surface": "persistence" + }, + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationListItemDto", + "member": "ExpectedArrivalTime", + "surface": "admin-output", + "retentionPolicy": "transient-response" } ] }, @@ -15571,13 +15696,15 @@ "application-command", "data-rights-export", "domain-event", - "persistence" + "persistence", + "admin-output" ], "allowedBoundaries": [ "adapter", "cross-module", "customer-api", - "intra-module" + "intra-module", + "support" ], "approvalState": "engineering-default", "bindings": [ @@ -15697,6 +15824,13 @@ "member": "ExpectedDepartureTime", "surface": "data-rights-export", "retentionPolicy": "reservation-data-rights-export-fragment" + }, + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationListItemDto", + "member": "ExpectedDepartureTime", + "surface": "admin-output", + "retentionPolicy": "transient-response" } ] }, @@ -19316,6 +19450,478 @@ "retentionPolicy": "reservations-tenant-termination-export-fragment" } ] + }, + { + "id": "reservation.operations.local-date", + "dataSubject": "guest", + "classification": "lifecycle", + "sensitivity": "standard", + "purposes": [ + "front-desk-operations", + "reservation-management" + ], + "sources": [ + "staff-action", + "system-generated" + ], + "authoritativeOwner": "reservations", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "reservation-records", + "countryPolicyKey": "reservation.record.metadata", + "retentionPolicy": "reservation-record-lifecycle", + "rightsPolicy": "guest-reservation-data", + "allowedSurfaces": [ + "api-input", + "api-response", + "application-query", + "admin-input", + "admin-output" + ], + "allowedBoundaries": [ + "customer-api", + "intra-module", + "support" + ], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Reservations.Api", + "type": "BunkFy.Modules.Reservations.Api.ReservationsModule+OperationsSnapshotRequest", + "member": "LocalDate", + "surface": "api-input", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Reservations.AdminApi", + "type": "BunkFy.Modules.Reservations.AdminApi.ReservationsAdminApiModule+OperationsSnapshotRequest", + "member": "LocalDate", + "surface": "admin-input", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Reservations.Application", + "type": "BunkFy.Modules.Reservations.Application.Queries.GetReservationOperationsSnapshotQuery", + "member": "LocalDate", + "surface": "application-query", + "retentionPolicy": "transient-request" + }, + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationOperationsSnapshotDto", + "member": "LocalDate", + "surface": "api-response", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationOperationsSnapshotDto", + "member": "LocalDate", + "surface": "admin-output", + "retentionPolicy": "transient-response" + } + ] + }, + { + "id": "reservation.operations.time-zone-id", + "dataSubject": "guest", + "classification": "linked-operational", + "sensitivity": "standard", + "purposes": [ + "front-desk-operations", + "reservation-management" + ], + "sources": [ + "system-generated" + ], + "authoritativeOwner": "reservations", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "reservation-records", + "countryPolicyKey": "reservation.record.metadata", + "retentionPolicy": "reservation-record-lifecycle", + "rightsPolicy": "guest-reservation-data", + "allowedSurfaces": [ + "api-response", + "admin-output" + ], + "allowedBoundaries": [ + "customer-api", + "support" + ], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationOperationsSnapshotDto", + "member": "TimeZoneId", + "surface": "api-response", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationOperationsSnapshotDto", + "member": "TimeZoneId", + "surface": "admin-output", + "retentionPolicy": "transient-response" + } + ] + }, + { + "id": "reservation.operations.date-source", + "dataSubject": "guest", + "classification": "lifecycle", + "sensitivity": "standard", + "purposes": [ + "front-desk-operations", + "reservation-management" + ], + "sources": [ + "system-generated" + ], + "authoritativeOwner": "reservations", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "reservation-records", + "countryPolicyKey": "reservation.record.metadata", + "retentionPolicy": "reservation-record-lifecycle", + "rightsPolicy": "guest-reservation-data", + "allowedSurfaces": [ + "api-response", + "admin-output" + ], + "allowedBoundaries": [ + "customer-api", + "support" + ], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationOperationsSnapshotDto", + "member": "DateSource", + "surface": "api-response", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationOperationsSnapshotDto", + "member": "DateSource", + "surface": "admin-output", + "retentionPolicy": "transient-response" + } + ] + }, + { + "id": "reservation.operations.observed-at-utc", + "dataSubject": "guest", + "classification": "linked-operational", + "sensitivity": "standard", + "purposes": [ + "front-desk-operations", + "reservation-management" + ], + "sources": [ + "system-generated" + ], + "authoritativeOwner": "reservations", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "reservation-records", + "countryPolicyKey": "reservation.record.metadata", + "retentionPolicy": "reservation-record-lifecycle", + "rightsPolicy": "guest-reservation-data", + "allowedSurfaces": [ + "api-response", + "admin-output" + ], + "allowedBoundaries": [ + "customer-api", + "support" + ], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationOperationsSnapshotDto", + "member": "ObservedAtUtc", + "surface": "api-response", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationOperationsSnapshotDto", + "member": "ObservedAtUtc", + "surface": "admin-output", + "retentionPolicy": "transient-response" + } + ] + }, + { + "id": "reservation.operations.reservation-count", + "dataSubject": "guest", + "classification": "linked-operational", + "sensitivity": "standard", + "purposes": [ + "front-desk-operations", + "reservation-management" + ], + "sources": [ + "system-generated" + ], + "authoritativeOwner": "reservations", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "reservation-records", + "countryPolicyKey": "reservation.record.metadata", + "retentionPolicy": "reservation-record-lifecycle", + "rightsPolicy": "guest-reservation-data", + "allowedSurfaces": [ + "api-response", + "admin-output" + ], + "allowedBoundaries": [ + "customer-api", + "support" + ], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationOperationsCountDto", + "member": "ReservationCount", + "surface": "api-response", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationOperationsCountDto", + "member": "ReservationCount", + "surface": "admin-output", + "retentionPolicy": "transient-response" + } + ] + }, + { + "id": "reservation.operations.snapshot-payload", + "dataSubject": "guest", + "classification": "structured-payload", + "sensitivity": "unstructured", + "purposes": [ + "front-desk-operations", + "reservation-management" + ], + "sources": [ + "system-generated" + ], + "authoritativeOwner": "reservations", + "controllerProcessorContext": "customer-controller-bunk-fy-processor", + "accessPolicy": "reservation-records", + "countryPolicyKey": "reservation.record.metadata", + "retentionPolicy": "reservation-record-lifecycle", + "rightsPolicy": "guest-reservation-data", + "allowedSurfaces": [ + "api-response", + "admin-output" + ], + "allowedBoundaries": [ + "customer-api", + "support" + ], + "approvalState": "engineering-default", + "bindings": [ + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationOperationsSnapshotDto", + "member": "Cohorts", + "surface": "api-response", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationOperationsSnapshotDto", + "member": "Attention", + "surface": "api-response", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationOperationsSnapshotDto", + "member": "Upcoming", + "surface": "api-response", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationOperationsCohortCountsDto", + "member": "ConfirmedArrivalsOnLocalDate", + "surface": "api-response", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationOperationsCohortCountsDto", + "member": "ScheduledDeparturesOnLocalDate", + "surface": "api-response", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationOperationsCohortCountsDto", + "member": "CurrentlyInHouse", + "surface": "api-response", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationOperationsAttentionCountsDto", + "member": "PendingAllocation", + "surface": "api-response", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationOperationsAttentionCountsDto", + "member": "AllocationRejected", + "surface": "api-response", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationOperationsAttentionCountsDto", + "member": "CancellationPending", + "surface": "api-response", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationOperationsAttentionCountsDto", + "member": "NoShowPending", + "surface": "api-response", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationOperationsAttentionCountsDto", + "member": "CheckoutPending", + "surface": "api-response", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationOperationsAttentionCountsDto", + "member": "ArrivalBeforeLocalDateStillConfirmed", + "surface": "api-response", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationOperationsAttentionCountsDto", + "member": "DepartureBeforeLocalDateStillInHouse", + "surface": "api-response", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationOperationsAttentionCountsDto", + "member": "Total", + "surface": "api-response", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationOperationsSnapshotDto", + "member": "Cohorts", + "surface": "admin-output", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationOperationsSnapshotDto", + "member": "Attention", + "surface": "admin-output", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationOperationsSnapshotDto", + "member": "Upcoming", + "surface": "admin-output", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationOperationsCohortCountsDto", + "member": "ConfirmedArrivalsOnLocalDate", + "surface": "admin-output", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationOperationsCohortCountsDto", + "member": "ScheduledDeparturesOnLocalDate", + "surface": "admin-output", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationOperationsCohortCountsDto", + "member": "CurrentlyInHouse", + "surface": "admin-output", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationOperationsAttentionCountsDto", + "member": "PendingAllocation", + "surface": "admin-output", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationOperationsAttentionCountsDto", + "member": "AllocationRejected", + "surface": "admin-output", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationOperationsAttentionCountsDto", + "member": "CancellationPending", + "surface": "admin-output", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationOperationsAttentionCountsDto", + "member": "NoShowPending", + "surface": "admin-output", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationOperationsAttentionCountsDto", + "member": "CheckoutPending", + "surface": "admin-output", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationOperationsAttentionCountsDto", + "member": "ArrivalBeforeLocalDateStillConfirmed", + "surface": "admin-output", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationOperationsAttentionCountsDto", + "member": "DepartureBeforeLocalDateStillInHouse", + "surface": "admin-output", + "retentionPolicy": "transient-response" + }, + { + "assembly": "BunkFy.Modules.Reservations.Contracts", + "type": "BunkFy.Modules.Reservations.Contracts.ReservationOperationsAttentionCountsDto", + "member": "Total", + "surface": "admin-output", + "retentionPolicy": "transient-response" + } + ] } ] } diff --git a/src/Modules/Reservations/docs/personal-data-inventory.v1.md b/src/Modules/Reservations/docs/personal-data-inventory.v1.md index 67fb42b7..346891b4 100644 --- a/src/Modules/Reservations/docs/personal-data-inventory.v1.md +++ b/src/Modules/Reservations/docs/personal-data-inventory.v1.md @@ -1,4 +1,4 @@ -# reservations Personal-Data Inventory v19 +# reservations Personal-Data Inventory v20 Generated from `reservations.personal-data` schema v1. Catalogue approval: `engineering-default`. @@ -197,12 +197,12 @@ Engineering metadata is not legal or country-launch approval. | reservation.guest-restriction.projection-revision | guest | linked-operational | standard | monotonic-projection-ordering
reservation-guest-link-admission | guests-restriction-state | guests | customer-controller-bunk-fy-processor | reservation-guest-restriction-eligibility | reservation.guest-link.restriction-eligibility | reservation-guest-restriction-projection-lifecycle | guest-reservation-link | application-command
persistence | intra-module | engineering-default | | reservation.guest-restriction.property-id | guest | linked-operational | standard | property-authorization
reservation-guest-link-admission | guest-property-visibility | guests | customer-controller-bunk-fy-processor | reservation-guest-restriction-eligibility | reservation.guest-link.restriction-eligibility | reservation-guest-restriction-projection-lifecycle | guest-reservation-link | application-command
persistence | intra-module | engineering-default | | reservation.guest-restriction.tenant-scope-id | guest | linked-operational | standard | scope-enforcement
tenant-isolation | authenticated-tenant-context | guests | customer-controller-bunk-fy-processor | reservation-guest-restriction-eligibility | reservation.guest-link.restriction-eligibility | reservation-guest-restriction-projection-lifecycle | guest-reservation-link | application-command
persistence | intra-module | engineering-default | -| reservation.guest.count | guest | linked-operational | standard | guest-stay-fulfillment
reservation-management | adapter-observation
staff-entry
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.record.metadata | reservation-record-lifecycle | guest-reservation-data | adapter-ingress
api-input
api-response
application-command
data-rights-export
domain-event
persistence | adapter
cross-module
customer-api
intra-module | engineering-default | +| reservation.guest.count | guest | linked-operational | standard | guest-stay-fulfillment
reservation-management | adapter-observation
staff-entry
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.record.metadata | reservation-record-lifecycle | guest-reservation-data | adapter-ingress
admin-output
api-input
api-response
application-command
data-rights-export
domain-event
persistence | adapter
cross-module
customer-api
intra-module
support | engineering-default | | reservation.guest.email | guest | contact | elevated | guest-stay-fulfillment
reservation-management | adapter-observation
staff-entry
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.guest.contact | reservation-record-lifecycle | guest-reservation-data | adapter-ingress
api-input
api-response
application-command
data-rights-export
domain-event
persistence
search-index | adapter
cross-module
customer-api
intra-module | engineering-default | | reservation.guest.notes | guest | free-text | unstructured | guest-stay-fulfillment
reservation-management | adapter-observation
staff-entry
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.guest.free-text | reservation-record-lifecycle | guest-reservation-data | adapter-ingress
api-input
api-response
application-command
data-rights-export
domain-event
persistence | adapter
cross-module
customer-api
intra-module | engineering-default | | reservation.guest.phone | guest | contact | elevated | guest-stay-fulfillment
reservation-management | adapter-observation
staff-entry
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.guest.contact | reservation-record-lifecycle | guest-reservation-data | adapter-ingress
api-input
api-response
application-command
data-rights-export
domain-event
persistence
search-index | adapter
cross-module
customer-api
intra-module | engineering-default | -| reservation.guest.primary-name | guest | direct-identifier | elevated | guest-stay-fulfillment
reservation-management | adapter-observation
staff-entry
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.guest.identity | reservation-record-lifecycle | guest-reservation-data | adapter-ingress
api-input
api-response
application-command
data-rights-export
domain-event
persistence
search-index | adapter
cross-module
customer-api
intra-module | engineering-default | -| reservation.inventory-unit.count | guest | linked-operational | standard | guest-stay-fulfillment
reservation-management | system-generated | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.record.metadata | reservation-record-lifecycle | guest-reservation-data | api-response | customer-api | engineering-default | +| reservation.guest.primary-name | guest | direct-identifier | elevated | guest-stay-fulfillment
reservation-management | adapter-observation
staff-entry
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.guest.identity | reservation-record-lifecycle | guest-reservation-data | adapter-ingress
admin-output
api-input
api-response
application-command
data-rights-export
domain-event
persistence
search-index | adapter
cross-module
customer-api
intra-module
support | engineering-default | +| reservation.inventory-unit.count | guest | linked-operational | standard | guest-stay-fulfillment
reservation-management | system-generated | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.record.metadata | reservation-record-lifecycle | guest-reservation-data | admin-output
api-response | customer-api
support | engineering-default | | reservation.inventory-unit.id | guest | linked-operational | standard | inventory-allocation-consistency
no-overbooking | staff-action
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.allocation | reservation-record-lifecycle | guest-reservation-data | adapter-ingress
api-input
api-response
application-command
data-rights-export
domain-event
persistence | adapter
cross-module
customer-api
intra-module | engineering-default | | reservation.management-operation.business-date | guest | lifecycle | standard | idempotent-lifecycle-processing
stay-lifecycle-management | staff-entry
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.stay | reservation-management-operation-journal | guest-reservation-data | application-command
data-rights-export
persistence | cross-module
intra-module | engineering-default | | reservation.management-operation.created-at-utc | guest | lifecycle | standard | idempotent-lifecycle-processing
record-consistency | system-clock | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.record.metadata | reservation-management-operation-journal | guest-reservation-data | application-command
data-rights-export
persistence | cross-module
intra-module | engineering-default | @@ -214,6 +214,12 @@ Engineering metadata is not legal or country-launch approval. | reservation.management-operation.request-fingerprint | guest | pseudonymous-identifier | elevated | idempotent-lifecycle-processing
inventory-allocation-consistency | staff-action
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.record.metadata | reservation-management-operation-journal | guest-reservation-data | application-command
data-rights-export
persistence | cross-module
intra-module | engineering-default | | reservation.management-operation.reservation-id | guest | linked-operational | standard | idempotent-lifecycle-processing
record-consistency | staff-action
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.record.metadata | reservation-management-operation-journal | guest-reservation-data | application-command
data-rights-export
persistence | cross-module
intra-module | engineering-default | | reservation.management-operation.scope-id | guest | linked-operational | standard | scope-enforcement
tenant-isolation | system-generated | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.record.metadata | reservation-management-operation-journal | guest-reservation-data | application-command
persistence | intra-module | engineering-default | +| reservation.operations.date-source | guest | lifecycle | standard | front-desk-operations
reservation-management | system-generated | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.record.metadata | reservation-record-lifecycle | guest-reservation-data | admin-output
api-response | customer-api
support | engineering-default | +| reservation.operations.local-date | guest | lifecycle | standard | front-desk-operations
reservation-management | staff-action
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.record.metadata | reservation-record-lifecycle | guest-reservation-data | admin-input
admin-output
api-input
api-response
application-query | customer-api
intra-module
support | engineering-default | +| reservation.operations.observed-at-utc | guest | linked-operational | standard | front-desk-operations
reservation-management | system-generated | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.record.metadata | reservation-record-lifecycle | guest-reservation-data | admin-output
api-response | customer-api
support | engineering-default | +| reservation.operations.reservation-count | guest | linked-operational | standard | front-desk-operations
reservation-management | system-generated | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.record.metadata | reservation-record-lifecycle | guest-reservation-data | admin-output
api-response | customer-api
support | engineering-default | +| reservation.operations.snapshot-payload | guest | structured-payload | unstructured | front-desk-operations
reservation-management | system-generated | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.record.metadata | reservation-record-lifecycle | guest-reservation-data | admin-output
api-response | customer-api
support | engineering-default | +| reservation.operations.time-zone-id | guest | linked-operational | standard | front-desk-operations
reservation-management | system-generated | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.record.metadata | reservation-record-lifecycle | guest-reservation-data | admin-output
api-response | customer-api
support | engineering-default | | reservation.processing-restriction.action | guest | linked-operational | standard | accountability
data-rights-restriction | authorized-data-rights-case
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-processing-restrictions | reservation.processing-restriction-accountability | reservation-processing-restriction-lifecycle | reservation-processing-restriction-accountability | api-response
data-rights-export
persistence | cross-module
customer-api
intra-module | engineering-default | | reservation.processing-restriction.active-count | guest | linked-operational | standard | accountability
data-rights-restriction | authorized-data-rights-case
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-processing-restrictions | reservation.processing-restriction-accountability | reservation-processing-restriction-lifecycle | reservation-processing-restriction-accountability | data-rights-export
persistence | cross-module
customer-api
intra-module | engineering-default | | reservation.processing-restriction.approval-revision | guest | linked-operational | standard | accountability
data-rights-restriction | authorized-data-rights-case
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-processing-restrictions | reservation.processing-restriction-accountability | reservation-processing-restriction-lifecycle | reservation-processing-restriction-accountability | api-response
application-command
data-rights-export
persistence | cross-module
customer-api
intra-module | engineering-default | @@ -235,8 +241,8 @@ Engineering metadata is not legal or country-launch approval. | reservation.processing-restriction.tenant-scope-id | guest | linked-operational | standard | accountability
data-rights-restriction | authorized-data-rights-case
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-processing-restrictions | reservation.processing-restriction-accountability | reservation-processing-restriction-lifecycle | reservation-processing-restriction-accountability | domain-event
integration-event
persistence | cross-module
customer-api
intra-module | engineering-default | | reservation.processing-restriction.transition-at-utc | guest | linked-operational | standard | accountability
data-rights-restriction | authorized-data-rights-case
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-processing-restrictions | reservation.processing-restriction-accountability | reservation-processing-restriction-lifecycle | reservation-processing-restriction-accountability | data-rights-export
domain-event
integration-event
persistence | cross-module
customer-api
intra-module | engineering-default | | reservation.record.created-at-utc | guest | lifecycle | standard | record-consistency
reservation-management | staff-action
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.record.metadata | reservation-record-lifecycle | guest-reservation-data | api-response
data-rights-export
persistence | cross-module
customer-api
intra-module | engineering-default | -| reservation.record.id | guest | linked-operational | standard | record-consistency
reservation-management | staff-action
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.record.metadata | reservation-record-lifecycle | guest-reservation-data | adapter-ingress
api-input
api-response
application-command
application-query
data-rights-export
domain-event
integration-event
persistence | adapter
cross-module
customer-api
intra-module | engineering-default | -| reservation.record.status | guest | lifecycle | standard | record-consistency
reservation-management | staff-action
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.record.metadata | reservation-record-lifecycle | guest-reservation-data | api-response
data-rights-export
persistence | cross-module
customer-api
intra-module | engineering-default | +| reservation.record.id | guest | linked-operational | standard | record-consistency
reservation-management | staff-action
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.record.metadata | reservation-record-lifecycle | guest-reservation-data | adapter-ingress
admin-output
api-input
api-response
application-command
application-query
data-rights-export
domain-event
integration-event
persistence | adapter
cross-module
customer-api
intra-module
support | engineering-default | +| reservation.record.status | guest | lifecycle | standard | record-consistency
reservation-management | staff-action
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.record.metadata | reservation-record-lifecycle | guest-reservation-data | admin-output
api-response
data-rights-export
persistence | cross-module
customer-api
intra-module
support | engineering-default | | reservation.record.updated-at-utc | guest | lifecycle | standard | record-consistency
reservation-management | staff-action
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.record.metadata | reservation-record-lifecycle | guest-reservation-data | api-response
data-rights-export
persistence | cross-module
customer-api
intra-module | engineering-default | | reservation.record.version | guest | lifecycle | standard | record-consistency
reservation-management | staff-action
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.record.metadata | reservation-record-lifecycle | guest-reservation-data | api-input
api-response
application-command
data-rights-export
domain-event
integration-event
persistence | cross-module
customer-api
intra-module | engineering-default | | reservation.reminder.dispatched-at-utc | guest | lifecycle | standard | arrival-reminder-delivery
front-desk-operations | staff-action
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.reminder | reservation-reminder-lifecycle | guest-reservation-data | data-rights-export
persistence | cross-module
intra-module | engineering-default | @@ -258,9 +264,9 @@ Engineering metadata is not legal or country-launch approval. | reservation.retention.execution-lifecycle | guest | lifecycle | standard | fair-retention-scanning
legal-hold-enforcement
operational-audit
retention-execution | reservations-persistence
retention-control-plane
system-clock | reservations | customer-controller-bunk-fy-processor | reservation-retention-execution | reservation.retention.reservation-operational | reservation-retention-execution-evidence | reservation-retention-control | application-command
persistence | intra-module | engineering-default | | reservation.retention.execution-outcome | guest | linked-operational | standard | operational-audit
retention-execution
retention-health | reservations-persistence
reservations-retention-evaluation | reservations | customer-controller-bunk-fy-processor | reservation-retention-execution | reservation.retention.reservation-operational | reservation-retention-execution-evidence | reservation-retention-control | application-command
persistence | intra-module | engineering-default | | reservation.retention.reservation-coordinate | guest | pseudonymous-identifier | elevated | immutable-scope-proof
retention-candidate-selection
retention-idempotency | reservations-current-projections
reservations-persistence | reservations | customer-controller-bunk-fy-processor | reservation-retention-proof | reservation.retention.reservation-operational | reservation-retention-anonymisation-proof | reservation-retention-proof-control | application-command
persistence | intra-module | engineering-default | -| reservation.scope.property-id | guest | linked-operational | standard | scope-enforcement
tenant-isolation | staff-action
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.record.metadata | reservation-record-lifecycle | guest-reservation-data | adapter-ingress
api-response
application-command
application-query
data-rights-export
domain-event
integration-event
persistence | adapter
cross-module
customer-api
intra-module | engineering-default | +| reservation.scope.property-id | guest | linked-operational | standard | scope-enforcement
tenant-isolation | staff-action
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.record.metadata | reservation-record-lifecycle | guest-reservation-data | adapter-ingress
admin-output
api-response
application-command
application-query
data-rights-export
domain-event
integration-event
persistence | adapter
cross-module
customer-api
intra-module
support | engineering-default | | reservation.scope.tenant-id | guest | linked-operational | standard | scope-enforcement
tenant-isolation | staff-action
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.record.metadata | reservation-record-lifecycle | guest-reservation-data | adapter-ingress
application-command
domain-event
integration-event
persistence | adapter
cross-module
intra-module | engineering-default | -| reservation.source.kind | guest | linked-operational | standard | booking-source-reconciliation
reservation-provenance | adapter-observation
staff-entry | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.adapter.provenance | reservation-record-lifecycle | adapter-provenance | api-input
api-response
application-command
data-rights-export
persistence | cross-module
customer-api
intra-module | engineering-default | +| reservation.source.kind | guest | linked-operational | standard | booking-source-reconciliation
reservation-provenance | adapter-observation
staff-entry | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.adapter.provenance | reservation-record-lifecycle | adapter-provenance | admin-output
api-input
api-response
application-command
data-rights-export
persistence | cross-module
customer-api
intra-module
support | engineering-default | | reservation.source.reference | guest | pseudonymous-identifier | elevated | booking-source-reconciliation
reservation-provenance | adapter-observation
staff-entry | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.adapter.provenance | reservation-record-lifecycle | adapter-provenance | adapter-ingress
api-input
api-response
application-command
data-rights-export
persistence | adapter
cross-module
customer-api
intra-module | engineering-default | | reservation.source.system | guest | linked-operational | standard | booking-source-reconciliation
reservation-provenance | adapter-observation
staff-entry | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.adapter.provenance | reservation-record-lifecycle | adapter-provenance | adapter-ingress
api-input
api-response
application-command
data-rights-export
persistence | adapter
cross-module
customer-api
intra-module | engineering-default | | reservation.stay-amendment.completed-at-utc | guest | lifecycle | standard | reservation-stay-amendment
workflow-recovery | staff-action
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.details.history | reservation-management-operation-journal | guest-reservation-data | api-response
data-rights-export
persistence | cross-module
customer-api
intra-module | engineering-default | @@ -294,13 +300,13 @@ Engineering metadata is not legal or country-launch approval. | reservation.stay-amendment.target-expected-departure-time | guest | lifecycle | standard | reservation-stay-amendment
workflow-recovery | staff-action
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.details.history | reservation-management-operation-journal | guest-reservation-data | api-input
api-response
application-command
data-rights-export
persistence | cross-module
customer-api
intra-module | engineering-default | | reservation.stay-amendment.target-inventory-unit-ids | guest | linked-operational | standard | reservation-stay-amendment
workflow-recovery | staff-action
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.details.history | reservation-management-operation-journal | guest-reservation-data | api-input
api-response
application-command
data-rights-export
persistence | cross-module
customer-api
intra-module | engineering-default | | reservation.stay-amendment.updated-at-utc | guest | lifecycle | standard | reservation-stay-amendment
workflow-recovery | staff-action
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.details.history | reservation-management-operation-journal | guest-reservation-data | api-response
application-query
data-rights-export
persistence | cross-module
customer-api
intra-module | engineering-default | -| reservation.stay.arrival-date | guest | lifecycle | standard | front-desk-operations
stay-lifecycle-management | adapter-observation
staff-entry
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.stay | reservation-record-lifecycle | guest-reservation-data | adapter-ingress
api-input
api-response
application-command
data-rights-export
domain-event
integration-event
persistence | adapter
cross-module
customer-api
intra-module | engineering-default | +| reservation.stay.arrival-date | guest | lifecycle | standard | front-desk-operations
stay-lifecycle-management | adapter-observation
staff-entry
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.stay | reservation-record-lifecycle | guest-reservation-data | adapter-ingress
admin-output
api-input
api-response
application-command
data-rights-export
domain-event
integration-event
persistence | adapter
cross-module
customer-api
intra-module
support | engineering-default | | reservation.stay.business-date | guest | lifecycle | standard | front-desk-operations
stay-lifecycle-management | adapter-observation
staff-entry
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.stay | reservation-record-lifecycle | guest-reservation-data | api-input
api-response
application-command
data-rights-export
domain-event
integration-event
persistence | cross-module
customer-api
intra-module | engineering-default | | reservation.stay.checked-in-at-utc | guest | lifecycle | standard | front-desk-operations
stay-lifecycle-management | adapter-observation
staff-entry
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.stay | reservation-record-lifecycle | guest-reservation-data | api-response
data-rights-export
persistence | cross-module
customer-api
intra-module | engineering-default | | reservation.stay.checked-out-at-utc | guest | lifecycle | standard | front-desk-operations
stay-lifecycle-management | adapter-observation
staff-entry
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.stay | reservation-record-lifecycle | guest-reservation-data | api-response
data-rights-export
persistence | cross-module
customer-api
intra-module | engineering-default | -| reservation.stay.departure-date | guest | lifecycle | standard | front-desk-operations
stay-lifecycle-management | adapter-observation
staff-entry
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.stay | reservation-record-lifecycle | guest-reservation-data | adapter-ingress
api-input
api-response
application-command
data-rights-export
domain-event
integration-event
persistence | adapter
cross-module
customer-api
intra-module | engineering-default | -| reservation.stay.expected-arrival-time | guest | lifecycle | standard | front-desk-operations
stay-lifecycle-management | adapter-observation
staff-entry
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.stay | reservation-record-lifecycle | guest-reservation-data | adapter-ingress
api-input
api-response
application-command
data-rights-export
domain-event
integration-event
persistence | adapter
cross-module
customer-api
intra-module | engineering-default | -| reservation.stay.expected-departure-time | guest | lifecycle | standard | front-desk-operations
stay-lifecycle-management | adapter-observation
staff-entry
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.stay | reservation-record-lifecycle | guest-reservation-data | adapter-ingress
api-input
api-response
application-command
data-rights-export
domain-event
persistence | adapter
cross-module
customer-api
intra-module | engineering-default | +| reservation.stay.departure-date | guest | lifecycle | standard | front-desk-operations
stay-lifecycle-management | adapter-observation
staff-entry
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.stay | reservation-record-lifecycle | guest-reservation-data | adapter-ingress
admin-output
api-input
api-response
application-command
data-rights-export
domain-event
integration-event
persistence | adapter
cross-module
customer-api
intra-module
support | engineering-default | +| reservation.stay.expected-arrival-time | guest | lifecycle | standard | front-desk-operations
stay-lifecycle-management | adapter-observation
staff-entry
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.stay | reservation-record-lifecycle | guest-reservation-data | adapter-ingress
admin-output
api-input
api-response
application-command
data-rights-export
domain-event
integration-event
persistence | adapter
cross-module
customer-api
intra-module
support | engineering-default | +| reservation.stay.expected-departure-time | guest | lifecycle | standard | front-desk-operations
stay-lifecycle-management | adapter-observation
staff-entry
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.stay | reservation-record-lifecycle | guest-reservation-data | adapter-ingress
admin-output
api-input
api-response
application-command
data-rights-export
domain-event
persistence | adapter
cross-module
customer-api
intra-module
support | engineering-default | | reservation.stay.no-show-at-utc | guest | lifecycle | standard | front-desk-operations
stay-lifecycle-management | adapter-observation
staff-entry
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.stay | reservation-record-lifecycle | guest-reservation-data | api-response
data-rights-export
persistence | cross-module
customer-api
intra-module | engineering-default | | reservations.anonymisation-proof | guest | linked-operational | elevated | owner-proof
reservation-anonymisation | reservations-anonymisation-workflow
reservations-id-generator | reservations | customer-controller-bunk-fy-processor | reservation-anonymisation-owner-proof | reservation.data-rights-anonymisation | reservation-anonymisation-owner-proof | reservation-anonymisation-accountability | data-rights-export | cross-module | engineering-default | | reservations.booking-state | guest | linked-operational | elevated | guest-stay-fulfillment
reservation-management | adapter-observation
staff-entry
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-records | reservation.guest-booking | reservation-record-lifecycle | guest-reservation-data | data-rights-export | cross-module | engineering-default | @@ -1008,6 +1014,9 @@ Engineering metadata is not legal or country-launch approval. | reservation.guest.count | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationDetailsSnapshotDto | GuestCount | api-response | transient-response | | reservation.guest.count | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationDto | GuestCount | api-response | transient-response | | reservation.guest.count | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationListItemDto | GuestCount | api-response | transient-response | +| reservation.guest.count | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationListItemDto | GuestCount | admin-output | transient-response | +| reservation.guest.count | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationOperationsCountDto | GuestCount | api-response | transient-response | +| reservation.guest.count | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationOperationsCountDto | GuestCount | admin-output | transient-response | | reservation.guest.count | BunkFy.Modules.Reservations.Domain | BunkFy.Modules.Reservations.Domain.Aggregates.Reservation | GuestCount | persistence | reservation-record-lifecycle | | reservation.guest.count | BunkFy.Modules.Reservations.Domain | BunkFy.Modules.Reservations.Domain.Aggregates.Reservation | PendingGuestCount | persistence | reservation-record-lifecycle | | reservation.guest.count | BunkFy.Modules.Reservations.Domain | BunkFy.Modules.Reservations.Domain.Aggregates.ReservationDetailsSnapshot | GuestCount | domain-event | transient-domain-event | @@ -1081,6 +1090,7 @@ Engineering metadata is not legal or country-launch approval. | reservation.guest.primary-name | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationDetailsSnapshotDto | PrimaryGuestName | api-response | transient-response | | reservation.guest.primary-name | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationDto | PrimaryGuestName | api-response | transient-response | | reservation.guest.primary-name | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationListItemDto | PrimaryGuestName | api-response | transient-response | +| reservation.guest.primary-name | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationListItemDto | PrimaryGuestName | admin-output | transient-response | | reservation.guest.primary-name | BunkFy.Modules.Reservations.Domain | BunkFy.Modules.Reservations.Domain.Aggregates.Reservation | PendingPrimaryGuestName | persistence | reservation-record-lifecycle | | reservation.guest.primary-name | BunkFy.Modules.Reservations.Domain | BunkFy.Modules.Reservations.Domain.Aggregates.Reservation | PendingPrimaryGuestNameSearch | persistence | reservation-record-lifecycle | | reservation.guest.primary-name | BunkFy.Modules.Reservations.Domain | BunkFy.Modules.Reservations.Domain.Aggregates.Reservation | PendingPrimaryGuestNameSearch | search-index | reservation-record-lifecycle | @@ -1091,6 +1101,7 @@ Engineering metadata is not legal or country-launch approval. | reservation.guest.primary-name | BunkFy.Modules.Reservations.Persistence | BunkFy.Modules.Reservations.Persistence.Repositories.ReservationDataRightsExport | PrimaryGuestName | data-rights-export | reservation-data-rights-export-fragment | | reservation.guest.primary-name | BunkFy.Modules.Reservations.Persistence | BunkFy.Modules.Reservations.Persistence.Repositories.ReservationPendingAmendmentDataRightsExport | PrimaryGuestName | data-rights-export | reservation-data-rights-export-fragment | | reservation.inventory-unit.count | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationListItemDto | InventoryUnitCount | api-response | transient-response | +| reservation.inventory-unit.count | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationListItemDto | InventoryUnitCount | admin-output | transient-response | | reservation.inventory-unit.id | BunkFy.Modules.Reservations.Api | BunkFy.Modules.Reservations.Api.ReservationsModule+CreateReservationRequest | InventoryUnitIds | api-input | transient-request | | reservation.inventory-unit.id | BunkFy.Modules.Reservations.Api | BunkFy.Modules.Reservations.Api.ReservationsModule+ReassignReservationInventoryRequest | InventoryUnitIds | api-input | transient-request | | reservation.inventory-unit.id | BunkFy.Modules.Reservations.Application | BunkFy.Modules.Reservations.Application.Commands.CreateReservationCommand | InventoryUnitIds | application-command | transient-request | @@ -1145,6 +1156,47 @@ Engineering metadata is not legal or country-launch approval. | reservation.management-operation.reservation-id | BunkFy.Modules.Reservations.Persistence | BunkFy.Modules.Reservations.Persistence.ReservationManagementOperation | ReservationId | persistence | reservation-management-operation-journal | | reservation.management-operation.scope-id | BunkFy.Modules.Reservations.Application | BunkFy.Modules.Reservations.Application.Ports.ReservationManagementOperationRecord | ScopeId | application-command | transient-request | | reservation.management-operation.scope-id | BunkFy.Modules.Reservations.Persistence | BunkFy.Modules.Reservations.Persistence.ReservationManagementOperation | ScopeId | persistence | reservation-management-operation-journal | +| reservation.operations.date-source | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationOperationsSnapshotDto | DateSource | api-response | transient-response | +| reservation.operations.date-source | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationOperationsSnapshotDto | DateSource | admin-output | transient-response | +| reservation.operations.local-date | BunkFy.Modules.Reservations.AdminApi | BunkFy.Modules.Reservations.AdminApi.ReservationsAdminApiModule+OperationsSnapshotRequest | LocalDate | admin-input | transient-request | +| reservation.operations.local-date | BunkFy.Modules.Reservations.Api | BunkFy.Modules.Reservations.Api.ReservationsModule+OperationsSnapshotRequest | LocalDate | api-input | transient-request | +| reservation.operations.local-date | BunkFy.Modules.Reservations.Application | BunkFy.Modules.Reservations.Application.Queries.GetReservationOperationsSnapshotQuery | LocalDate | application-query | transient-request | +| reservation.operations.local-date | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationOperationsSnapshotDto | LocalDate | api-response | transient-response | +| reservation.operations.local-date | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationOperationsSnapshotDto | LocalDate | admin-output | transient-response | +| reservation.operations.observed-at-utc | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationOperationsSnapshotDto | ObservedAtUtc | api-response | transient-response | +| reservation.operations.observed-at-utc | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationOperationsSnapshotDto | ObservedAtUtc | admin-output | transient-response | +| reservation.operations.reservation-count | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationOperationsCountDto | ReservationCount | api-response | transient-response | +| reservation.operations.reservation-count | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationOperationsCountDto | ReservationCount | admin-output | transient-response | +| reservation.operations.snapshot-payload | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationOperationsAttentionCountsDto | AllocationRejected | api-response | transient-response | +| reservation.operations.snapshot-payload | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationOperationsAttentionCountsDto | AllocationRejected | admin-output | transient-response | +| reservation.operations.snapshot-payload | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationOperationsAttentionCountsDto | ArrivalBeforeLocalDateStillConfirmed | api-response | transient-response | +| reservation.operations.snapshot-payload | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationOperationsAttentionCountsDto | ArrivalBeforeLocalDateStillConfirmed | admin-output | transient-response | +| reservation.operations.snapshot-payload | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationOperationsAttentionCountsDto | CancellationPending | api-response | transient-response | +| reservation.operations.snapshot-payload | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationOperationsAttentionCountsDto | CancellationPending | admin-output | transient-response | +| reservation.operations.snapshot-payload | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationOperationsAttentionCountsDto | CheckoutPending | api-response | transient-response | +| reservation.operations.snapshot-payload | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationOperationsAttentionCountsDto | CheckoutPending | admin-output | transient-response | +| reservation.operations.snapshot-payload | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationOperationsAttentionCountsDto | DepartureBeforeLocalDateStillInHouse | api-response | transient-response | +| reservation.operations.snapshot-payload | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationOperationsAttentionCountsDto | DepartureBeforeLocalDateStillInHouse | admin-output | transient-response | +| reservation.operations.snapshot-payload | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationOperationsAttentionCountsDto | NoShowPending | api-response | transient-response | +| reservation.operations.snapshot-payload | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationOperationsAttentionCountsDto | NoShowPending | admin-output | transient-response | +| reservation.operations.snapshot-payload | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationOperationsAttentionCountsDto | PendingAllocation | api-response | transient-response | +| reservation.operations.snapshot-payload | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationOperationsAttentionCountsDto | PendingAllocation | admin-output | transient-response | +| reservation.operations.snapshot-payload | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationOperationsAttentionCountsDto | Total | api-response | transient-response | +| reservation.operations.snapshot-payload | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationOperationsAttentionCountsDto | Total | admin-output | transient-response | +| reservation.operations.snapshot-payload | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationOperationsCohortCountsDto | ConfirmedArrivalsOnLocalDate | api-response | transient-response | +| reservation.operations.snapshot-payload | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationOperationsCohortCountsDto | ConfirmedArrivalsOnLocalDate | admin-output | transient-response | +| reservation.operations.snapshot-payload | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationOperationsCohortCountsDto | CurrentlyInHouse | api-response | transient-response | +| reservation.operations.snapshot-payload | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationOperationsCohortCountsDto | CurrentlyInHouse | admin-output | transient-response | +| reservation.operations.snapshot-payload | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationOperationsCohortCountsDto | ScheduledDeparturesOnLocalDate | api-response | transient-response | +| reservation.operations.snapshot-payload | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationOperationsCohortCountsDto | ScheduledDeparturesOnLocalDate | admin-output | transient-response | +| reservation.operations.snapshot-payload | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationOperationsSnapshotDto | Attention | api-response | transient-response | +| reservation.operations.snapshot-payload | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationOperationsSnapshotDto | Attention | admin-output | transient-response | +| reservation.operations.snapshot-payload | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationOperationsSnapshotDto | Cohorts | api-response | transient-response | +| reservation.operations.snapshot-payload | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationOperationsSnapshotDto | Cohorts | admin-output | transient-response | +| reservation.operations.snapshot-payload | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationOperationsSnapshotDto | Upcoming | api-response | transient-response | +| reservation.operations.snapshot-payload | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationOperationsSnapshotDto | Upcoming | admin-output | transient-response | +| reservation.operations.time-zone-id | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationOperationsSnapshotDto | TimeZoneId | api-response | transient-response | +| reservation.operations.time-zone-id | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationOperationsSnapshotDto | TimeZoneId | admin-output | transient-response | | reservation.processing-restriction.action | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationProcessingRestrictionReceiptDto | Action | api-response | transient-response | | reservation.processing-restriction.action | BunkFy.Modules.Reservations.Domain | BunkFy.Modules.Reservations.Domain.DataRights.ReservationProcessingRestrictionReceipt | Action | persistence | reservation-processing-restriction-lifecycle | | reservation.processing-restriction.action | BunkFy.Modules.Reservations.Persistence | BunkFy.Modules.Reservations.Persistence.Repositories.ReservationProcessingRestrictionReceiptDataRightsExport | Action | data-rights-export | reservation-data-rights-export-fragment | @@ -1309,6 +1361,7 @@ Engineering metadata is not legal or country-launch approval. | reservation.record.id | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationGuestRecordLinkProcessDto | ReservationId | api-response | transient-response | | reservation.record.id | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationGuestRecordLinkReadyIntegrationEvent | ReservationId | integration-event | integration-message-journal | | reservation.record.id | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationListItemDto | ReservationId | api-response | transient-response | +| reservation.record.id | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationListItemDto | ReservationId | admin-output | transient-response | | reservation.record.id | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationMutationReceiptDto | ReservationId | api-response | transient-response | | reservation.record.id | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationNoShowIntegrationEvent | ReservationId | integration-event | integration-message-journal | | reservation.record.id | BunkFy.Modules.Reservations.Domain | BunkFy.Modules.Reservations.Domain.Aggregates.Reservation | Id | persistence | reservation-record-lifecycle | @@ -1340,6 +1393,7 @@ Engineering metadata is not legal or country-launch approval. | reservation.record.id | BunkFy.Modules.Reservations.Persistence | BunkFy.Modules.Reservations.Persistence.ReservationInventoryAllocationProjection | ReservationId | persistence | reservation-record-lifecycle | | reservation.record.status | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationDto | Status | api-response | transient-response | | reservation.record.status | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationListItemDto | Status | api-response | transient-response | +| reservation.record.status | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationListItemDto | Status | admin-output | transient-response | | reservation.record.status | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationMutationReceiptDto | Status | api-response | transient-response | | reservation.record.status | BunkFy.Modules.Reservations.Domain | BunkFy.Modules.Reservations.Domain.Aggregates.Reservation | Status | persistence | reservation-record-lifecycle | | reservation.record.status | BunkFy.Modules.Reservations.Persistence | BunkFy.Modules.Reservations.Persistence.Repositories.ReservationDataRightsExport | Status | data-rights-export | reservation-data-rights-export-fragment | @@ -1504,6 +1558,7 @@ Engineering metadata is not legal or country-launch approval. | reservation.scope.property-id | BunkFy.Modules.Reservations.Application | BunkFy.Modules.Reservations.Application.Ports.ReservationReminderSource | PropertyId | application-command | transient-request | | reservation.scope.property-id | BunkFy.Modules.Reservations.Application | BunkFy.Modules.Reservations.Application.Queries.GetReservationDetailsHistoryQuery | PropertyId | application-query | transient-request | | reservation.scope.property-id | BunkFy.Modules.Reservations.Application | BunkFy.Modules.Reservations.Application.Queries.GetReservationGuestRecordLinkQuery | PropertyId | application-query | transient-request | +| reservation.scope.property-id | BunkFy.Modules.Reservations.Application | BunkFy.Modules.Reservations.Application.Queries.GetReservationOperationsSnapshotQuery | PropertyId | application-query | transient-request | | reservation.scope.property-id | BunkFy.Modules.Reservations.Application | BunkFy.Modules.Reservations.Application.Queries.GetReservationQuery | PropertyId | application-query | transient-request | | reservation.scope.property-id | BunkFy.Modules.Reservations.Application | BunkFy.Modules.Reservations.Application.Queries.ListReservationsQuery | PropertyId | application-query | transient-request | | reservation.scope.property-id | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ExternalReservationAmendmentRequestedIntegrationEvent | PropertyId | adapter-ingress | transient-request | @@ -1524,8 +1579,11 @@ Engineering metadata is not legal or country-launch approval. | reservation.scope.property-id | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationGuestRecordLinkProcessDto | PropertyId | api-response | transient-response | | reservation.scope.property-id | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationGuestRecordLinkReadyIntegrationEvent | PropertyId | integration-event | integration-message-journal | | reservation.scope.property-id | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationListItemDto | PropertyId | api-response | transient-response | +| reservation.scope.property-id | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationListItemDto | PropertyId | admin-output | transient-response | | reservation.scope.property-id | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationMutationReceiptDto | PropertyId | api-response | transient-response | | reservation.scope.property-id | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationNoShowIntegrationEvent | PropertyId | integration-event | integration-message-journal | +| reservation.scope.property-id | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationOperationsSnapshotDto | PropertyId | api-response | transient-response | +| reservation.scope.property-id | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationOperationsSnapshotDto | PropertyId | admin-output | transient-response | | reservation.scope.property-id | BunkFy.Modules.Reservations.Domain | BunkFy.Modules.Reservations.Domain.Aggregates.Reservation | PropertyId | persistence | reservation-record-lifecycle | | reservation.scope.property-id | BunkFy.Modules.Reservations.Domain | BunkFy.Modules.Reservations.Domain.Events.ReservationAllocationAmendmentRequestedDomainEvent | PropertyId | domain-event | transient-domain-event | | reservation.scope.property-id | BunkFy.Modules.Reservations.Domain | BunkFy.Modules.Reservations.Domain.Events.ReservationCancellationRequestedDomainEvent | PropertyId | domain-event | transient-domain-event | @@ -1617,6 +1675,7 @@ Engineering metadata is not legal or country-launch approval. | reservation.source.kind | BunkFy.Modules.Reservations.Application | BunkFy.Modules.Reservations.Application.Commands.CreateReservationCommand | SourceKind | application-command | transient-request | | reservation.source.kind | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationDto | SourceKind | api-response | transient-response | | reservation.source.kind | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationListItemDto | SourceKind | api-response | transient-response | +| reservation.source.kind | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationListItemDto | SourceKind | admin-output | transient-response | | reservation.source.kind | BunkFy.Modules.Reservations.Domain | BunkFy.Modules.Reservations.Domain.Aggregates.Reservation | Source | persistence | reservation-record-lifecycle | | reservation.source.kind | BunkFy.Modules.Reservations.Persistence | BunkFy.Modules.Reservations.Persistence.Repositories.ReservationDataRightsExport | Source | data-rights-export | reservation-data-rights-export-fragment | | reservation.source.reference | BunkFy.Modules.Reservations.Api | BunkFy.Modules.Reservations.Api.ReservationsModule+CreateReservationRequest | SourceReference | api-input | transient-request | @@ -1774,6 +1833,7 @@ Engineering metadata is not legal or country-launch approval. | reservation.stay.arrival-date | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationDetailsSnapshotDto | Arrival | api-response | transient-response | | reservation.stay.arrival-date | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationDto | Arrival | api-response | transient-response | | reservation.stay.arrival-date | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationListItemDto | Arrival | api-response | transient-response | +| reservation.stay.arrival-date | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationListItemDto | Arrival | admin-output | transient-response | | reservation.stay.arrival-date | BunkFy.Modules.Reservations.Domain | BunkFy.Modules.Reservations.Domain.Aggregates.Reservation | Arrival | persistence | reservation-record-lifecycle | | reservation.stay.arrival-date | BunkFy.Modules.Reservations.Domain | BunkFy.Modules.Reservations.Domain.Aggregates.Reservation | PendingArrival | persistence | reservation-record-lifecycle | | reservation.stay.arrival-date | BunkFy.Modules.Reservations.Domain | BunkFy.Modules.Reservations.Domain.Aggregates.ReservationDetailsSnapshot | Arrival | domain-event | transient-domain-event | @@ -1837,6 +1897,7 @@ Engineering metadata is not legal or country-launch approval. | reservation.stay.departure-date | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationDetailsSnapshotDto | Departure | api-response | transient-response | | reservation.stay.departure-date | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationDto | Departure | api-response | transient-response | | reservation.stay.departure-date | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationListItemDto | Departure | api-response | transient-response | +| reservation.stay.departure-date | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationListItemDto | Departure | admin-output | transient-response | | reservation.stay.departure-date | BunkFy.Modules.Reservations.Domain | BunkFy.Modules.Reservations.Domain.Aggregates.Reservation | Departure | persistence | reservation-record-lifecycle | | reservation.stay.departure-date | BunkFy.Modules.Reservations.Domain | BunkFy.Modules.Reservations.Domain.Aggregates.Reservation | PendingDeparture | persistence | reservation-record-lifecycle | | reservation.stay.departure-date | BunkFy.Modules.Reservations.Domain | BunkFy.Modules.Reservations.Domain.Aggregates.ReservationDetailsSnapshot | Departure | domain-event | transient-domain-event | @@ -1865,6 +1926,7 @@ Engineering metadata is not legal or country-launch approval. | reservation.stay.expected-arrival-time | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationDetailsSnapshotDto | ExpectedArrivalTime | api-response | transient-response | | reservation.stay.expected-arrival-time | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationDto | ExpectedArrivalTime | api-response | transient-response | | reservation.stay.expected-arrival-time | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationListItemDto | ExpectedArrivalTime | api-response | transient-response | +| reservation.stay.expected-arrival-time | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationListItemDto | ExpectedArrivalTime | admin-output | transient-response | | reservation.stay.expected-arrival-time | BunkFy.Modules.Reservations.Domain | BunkFy.Modules.Reservations.Domain.Aggregates.Reservation | ExpectedArrivalTime | persistence | reservation-record-lifecycle | | reservation.stay.expected-arrival-time | BunkFy.Modules.Reservations.Domain | BunkFy.Modules.Reservations.Domain.Aggregates.Reservation | PendingExpectedArrivalTime | persistence | reservation-record-lifecycle | | reservation.stay.expected-arrival-time | BunkFy.Modules.Reservations.Domain | BunkFy.Modules.Reservations.Domain.Aggregates.ReservationDetailsSnapshot | ExpectedArrivalTime | domain-event | transient-domain-event | @@ -1884,6 +1946,7 @@ Engineering metadata is not legal or country-launch approval. | reservation.stay.expected-departure-time | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationDetailsSnapshotDto | ExpectedDepartureTime | api-response | transient-response | | reservation.stay.expected-departure-time | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationDto | ExpectedDepartureTime | api-response | transient-response | | reservation.stay.expected-departure-time | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationListItemDto | ExpectedDepartureTime | api-response | transient-response | +| reservation.stay.expected-departure-time | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationListItemDto | ExpectedDepartureTime | admin-output | transient-response | | reservation.stay.expected-departure-time | BunkFy.Modules.Reservations.Domain | BunkFy.Modules.Reservations.Domain.Aggregates.Reservation | ExpectedDepartureTime | persistence | reservation-record-lifecycle | | reservation.stay.expected-departure-time | BunkFy.Modules.Reservations.Domain | BunkFy.Modules.Reservations.Domain.Aggregates.Reservation | PendingExpectedDepartureTime | persistence | reservation-record-lifecycle | | reservation.stay.expected-departure-time | BunkFy.Modules.Reservations.Domain | BunkFy.Modules.Reservations.Domain.Aggregates.ReservationDetailsSnapshot | ExpectedDepartureTime | domain-event | transient-domain-event | diff --git a/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Api/ReservationsApiSecurityTests.cs b/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Api/ReservationsApiSecurityTests.cs index 7f7dd434..9f65fc2e 100644 --- a/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Api/ReservationsApiSecurityTests.cs +++ b/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Api/ReservationsApiSecurityTests.cs @@ -13,6 +13,7 @@ namespace BunkFy.Modules.Reservations.Tests.Api; using Gma.Framework.Cqrs; using Gma.Framework.Security; using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Metadata; using Microsoft.AspNetCore.Routing; @@ -113,6 +114,71 @@ public void Personal_data_response_policies_disable_storage() AssertNoStore(adminContext); } + [Theory] + [InlineData( + "BunkFy.Modules.Reservations.Api.ReservationsNoStoreStartupFilter", + "/api/reservations/properties/71000000-0000-0000-0000-000000000001/operations-snapshot", + StatusCodes.Status200OK)] + [InlineData( + "BunkFy.Modules.Reservations.Api.ReservationsNoStoreStartupFilter", + "/api/reservations/properties/not-a-guid/operations-snapshot", + StatusCodes.Status400BadRequest)] + [InlineData( + "BunkFy.Modules.Reservations.Api.ReservationsNoStoreStartupFilter", + "/api/reservations/properties/71000000-0000-0000-0000-000000000001/operations-snapshot", + StatusCodes.Status401Unauthorized)] + [InlineData( + "BunkFy.Modules.Reservations.Api.ReservationsNoStoreStartupFilter", + "/api/reservations/properties/71000000-0000-0000-0000-000000000001/operations-snapshot", + StatusCodes.Status403Forbidden)] + [InlineData( + "BunkFy.Modules.Reservations.AdminApi.ReservationsAdminNoStoreStartupFilter", + "/api/admin/reservations/properties/71000000-0000-0000-0000-000000000001/operations-snapshot", + StatusCodes.Status200OK)] + [InlineData( + "BunkFy.Modules.Reservations.AdminApi.ReservationsAdminNoStoreStartupFilter", + "/api/admin/reservations/properties/not-a-guid/operations-snapshot", + StatusCodes.Status400BadRequest)] + [InlineData( + "BunkFy.Modules.Reservations.AdminApi.ReservationsAdminNoStoreStartupFilter", + "/api/admin/reservations/properties/71000000-0000-0000-0000-000000000001/operations-snapshot", + StatusCodes.Status401Unauthorized)] + [InlineData( + "BunkFy.Modules.Reservations.AdminApi.ReservationsAdminNoStoreStartupFilter", + "/api/admin/reservations/properties/71000000-0000-0000-0000-000000000001/operations-snapshot", + StatusCodes.Status403Forbidden)] + public async Task Reservations_path_boundary_disables_storage_for_every_pipeline_outcome( + string filterTypeName, + string path, + int statusCode) + { + Type filterType = Assert.Single( + AppDomain.CurrentDomain.GetAssemblies() + .Select(assembly => assembly.GetType(filterTypeName, throwOnError: false)), + type => type is not null)!; + var filter = Assert.IsType( + Activator.CreateInstance(filterType, nonPublic: true), + exactMatch: false); + using ServiceProvider services = new ServiceCollection().BuildServiceProvider(); + var builder = new ApplicationBuilder(services); + filter.Configure(application => application.Run(async context => + { + context.Response.StatusCode = statusCode; + await context.Response.StartAsync(); + }))(builder); + RequestDelegate pipeline = builder.Build(); + var context = new DefaultHttpContext + { + RequestServices = services + }; + context.Request.Path = path; + + await pipeline(context); + + Assert.Equal(statusCode, context.Response.StatusCode); + AssertNoStore(context); + } + [Fact] public async Task Operational_routes_publish_bounded_response_contracts() { @@ -134,6 +200,14 @@ public async Task Operational_routes_publish_bounded_response_contracts() const string api = "/api/reservations/properties/{propertyId:guid}"; AssertResponse(endpoints, HttpMethods.Get, api); + RouteEndpoint publicOperations = AssertResponse( + endpoints, + HttpMethods.Get, + $"{api}/operations-snapshot"); + AccessPermissionMetadata operationsPermission = + Assert.Single(publicOperations.Metadata.OfType()); + Assert.Equal(ReservationsAdminPermissionCodes.Read, operationsPermission.Permission.Value); + Assert.Equal("reservations-property", operationsPermission.ScopeResolverName); AssertResponse(endpoints, HttpMethods.Post, api); AssertResponse(endpoints, HttpMethods.Get, $"{api}/{{reservationId:guid}}"); AssertResponse( @@ -144,6 +218,10 @@ public async Task Operational_routes_publish_bounded_response_contracts() const string admin = "/api/admin/reservations/properties/{propertyId:guid}"; AssertResponse(endpoints, HttpMethods.Get, admin); + AssertResponse( + endpoints, + HttpMethods.Get, + $"{admin}/operations-snapshot"); AssertResponse(endpoints, HttpMethods.Post, admin); AssertResponse(endpoints, HttpMethods.Get, $"{admin}/{{reservationId:guid}}"); AssertResponse( @@ -274,7 +352,7 @@ private static bool HasAssurance(RouteEndpoint endpoint) => "AuthenticationAssuranceMetadata", StringComparison.Ordinal)); - private static void AssertResponse( + private static RouteEndpoint AssertResponse( IEnumerable endpoints, string method, string route) @@ -292,6 +370,7 @@ private static void AssertResponse( metadata => metadata.StatusCode == StatusCodes.Status200OK); Assert.Equal(typeof(TResponse), response.Type); + return endpoint; } private static void AssertNoStore(HttpContext context) diff --git a/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Application/GetReservationOperationsSnapshotQueryHandlerTests.cs b/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Application/GetReservationOperationsSnapshotQueryHandlerTests.cs new file mode 100644 index 00000000..10da9855 --- /dev/null +++ b/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Application/GetReservationOperationsSnapshotQueryHandlerTests.cs @@ -0,0 +1,121 @@ +namespace BunkFy.Modules.Reservations.Tests.Application; + +using BunkFy.Modules.Reservations.Application; +using BunkFy.Modules.Reservations.Application.Handlers; +using BunkFy.Modules.Reservations.Application.Ports; +using BunkFy.Modules.Reservations.Application.Queries; +using BunkFy.Modules.Reservations.Contracts; +using Gma.Framework.Results; +using Gma.Framework.Runtime.Time; +using Xunit; + +[Trait("Category", "Unit")] +public sealed class GetReservationOperationsSnapshotQueryHandlerTests +{ + private static readonly DateTimeOffset ObservedAtUtc = + new(2026, 8, 13, 10, 15, 30, TimeSpan.Zero); + + [Fact] + public async Task Success_captures_one_observation_and_forwards_the_property_date_and_limit() + { + Guid propertyId = Guid.NewGuid(); + DateOnly localDate = new(2026, 8, 14); + ReservationOperationsSnapshotDto snapshot = Snapshot(propertyId, localDate); + var reader = new RecordingReader(ReservationOperationsSnapshotReadResult.Found(snapshot)); + var handler = new GetReservationOperationsSnapshotQueryHandler(reader, new FixedClock()); + + Result result = await handler.HandleAsync( + new GetReservationOperationsSnapshotQuery(propertyId, localDate, 0), + CancellationToken.None); + + Assert.True(result.IsSuccess, result.Error.Code); + Assert.Same(snapshot, result.Value); + ReadCall call = Assert.Single(reader.Calls); + Assert.Equal(propertyId, call.PropertyId); + Assert.Equal(localDate, call.ExplicitLocalDate); + Assert.Equal(ObservedAtUtc, call.ObservedAtUtc); + Assert.Equal(0, call.UpcomingLimit); + } + + [Theory] + [InlineData(-1)] + [InlineData(51)] + public async Task Invalid_limit_returns_the_stable_application_error_without_reading(int limit) + { + var reader = new RecordingReader(ReservationOperationsSnapshotReadResult.PropertyNotFound()); + var handler = new GetReservationOperationsSnapshotQueryHandler(reader, new FixedClock()); + + Result result = await handler.HandleAsync( + new GetReservationOperationsSnapshotQuery(Guid.NewGuid(), null, limit), + CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Equal(ReservationsApplicationErrors.OperationsSnapshotLimitInvalid, result.Error); + Assert.Empty(reader.Calls); + } + + [Theory] + [InlineData(ReservationOperationsSnapshotReadStatus.PropertyNotFound, "Reservations.PropertyNotFound")] + [InlineData(ReservationOperationsSnapshotReadStatus.PropertyInactive, "Reservations.PropertyInactive")] + [InlineData( + ReservationOperationsSnapshotReadStatus.PropertyTimeZoneUnavailable, + "Reservations.PropertyTimeZoneUnavailable")] + public async Task Reader_failures_map_to_stable_application_errors( + ReservationOperationsSnapshotReadStatus status, + string expectedCode) + { + var reader = new RecordingReader(new ReservationOperationsSnapshotReadResult(status, null)); + var handler = new GetReservationOperationsSnapshotQueryHandler(reader, new FixedClock()); + + Result result = await handler.HandleAsync( + new GetReservationOperationsSnapshotQuery(Guid.NewGuid(), null, 25), + CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Equal(expectedCode, result.Error.Code); + } + + private static ReservationOperationsSnapshotDto Snapshot(Guid propertyId, DateOnly localDate) + { + var zero = new ReservationOperationsCountDto(0, 0); + return new ReservationOperationsSnapshotDto( + propertyId, + localDate, + "Europe/Riga", + ReservationOperationsDateSource.Explicit, + ObservedAtUtc, + new ReservationOperationsCohortCountsDto(zero, zero, zero), + new ReservationOperationsAttentionCountsDto(zero, zero, zero, zero, zero, zero, zero, zero), + [], + 0, + false); + } + + private sealed class RecordingReader(ReservationOperationsSnapshotReadResult result) + : IReservationOperationsSnapshotReader + { + public List Calls { get; } = []; + + public Task ReadAsync( + Guid propertyId, + DateOnly? explicitLocalDate, + DateTimeOffset observedAtUtc, + int upcomingLimit, + CancellationToken cancellationToken) + { + this.Calls.Add(new ReadCall(propertyId, explicitLocalDate, observedAtUtc, upcomingLimit)); + return Task.FromResult(result); + } + } + + private sealed record ReadCall( + Guid PropertyId, + DateOnly? ExplicitLocalDate, + DateTimeOffset ObservedAtUtc, + int UpcomingLimit); + + private sealed class FixedClock : ISystemClock + { + public DateTimeOffset UtcNow => ObservedAtUtc; + } +} diff --git a/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Contracts/ReservationOperationalSurfaceContractTests.cs b/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Contracts/ReservationOperationalSurfaceContractTests.cs index 6acfee4e..aa53528f 100644 --- a/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Contracts/ReservationOperationalSurfaceContractTests.cs +++ b/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Contracts/ReservationOperationalSurfaceContractTests.cs @@ -41,6 +41,43 @@ public void List_and_history_envelopes_publish_lookahead_pagination() AssertProperties("HasMore", "Items", "Page", "PageSize"); } + [Fact] + public void Operations_snapshot_contract_is_explicit_bounded_and_property_local() + { + AssertProperties("GuestCount", "ReservationCount"); + AssertProperties( + "ConfirmedArrivalsOnLocalDate", + "CurrentlyInHouse", + "ScheduledDeparturesOnLocalDate"); + AssertProperties( + "AllocationRejected", + "ArrivalBeforeLocalDateStillConfirmed", + "CancellationPending", + "CheckoutPending", + "DepartureBeforeLocalDateStillInHouse", + "NoShowPending", + "PendingAllocation", + "Total"); + AssertProperties( + "Attention", + "Cohorts", + "DateSource", + "HasMoreUpcoming", + "LocalDate", + "ObservedAtUtc", + "PropertyId", + "TimeZoneId", + "Upcoming", + "UpcomingLimit"); + + Assert.Equal(25, ReservationsContractLimits.DefaultOperationsSnapshotUpcomingLimit); + Assert.Equal(50, ReservationsContractLimits.MaximumOperationsSnapshotUpcomingLimit); + Assert.False( + typeof(ReservationOperationsSnapshotDto) + .GetProperty(nameof(ReservationOperationsSnapshotDto.TimeZoneId))! + .PropertyType.IsGenericType); + } + private static void AssertProperties(params string[] expected) { string[] actual = typeof(T) diff --git a/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Contracts/ReservationsPersonalDataCatalogTests.cs b/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Contracts/ReservationsPersonalDataCatalogTests.cs index ad644fff..1deb0212 100644 --- a/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Contracts/ReservationsPersonalDataCatalogTests.cs +++ b/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Contracts/ReservationsPersonalDataCatalogTests.cs @@ -4,6 +4,7 @@ namespace BunkFy.Modules.Reservations.Tests; using System.Text.Json; using BunkFy.DataGovernance; using BunkFy.Extensions.ReservationGuestRecords; +using BunkFy.Modules.Reservations.AdminApi; using BunkFy.Modules.Reservations.Api; using BunkFy.Modules.Reservations.Application.Commands; using BunkFy.Modules.Reservations.Application.Policies; @@ -40,6 +41,21 @@ public sealed class ReservationsPersonalDataCatalogTests nameof(ListReservationsQuery.PageSize) ], StringComparer.Ordinal), + [typeof(GetReservationOperationsSnapshotQuery)] = new( + [nameof(GetReservationOperationsSnapshotQuery.UpcomingLimit)], + StringComparer.Ordinal), + [typeof(ReservationsModule.OperationsSnapshotRequest)] = new( + [nameof(ReservationsModule.OperationsSnapshotRequest.UpcomingLimit)], + StringComparer.Ordinal), + [typeof(ReservationsAdminApiModule.OperationsSnapshotRequest)] = new( + [nameof(ReservationsAdminApiModule.OperationsSnapshotRequest.UpcomingLimit)], + StringComparer.Ordinal), + [typeof(ReservationOperationsSnapshotDto)] = new( + [ + nameof(ReservationOperationsSnapshotDto.UpcomingLimit), + nameof(ReservationOperationsSnapshotDto.HasMoreUpcoming) + ], + StringComparer.Ordinal), [typeof(GetReservationDetailsHistoryQuery)] = new( [ nameof(GetReservationDetailsHistoryQuery.Page), @@ -129,6 +145,48 @@ public void Every_selected_command_query_api_adapter_event_and_domain_member_is_ } } + [Fact] + public void Operations_snapshot_admin_contract_is_independently_classified_for_support_boundary() + { + Type adminInput = typeof(ReservationsAdminApiModule.OperationsSnapshotRequest); + Type[] adminOutputs = + [ + typeof(ReservationOperationsSnapshotDto), + typeof(ReservationOperationsCountDto), + typeof(ReservationOperationsCohortCountsDto), + typeof(ReservationOperationsAttentionCountsDto), + typeof(ReservationListItemDto) + ]; + + AssertType(adminInput, PersonalDataSurface.AdminInput); + Assert.DoesNotContain( + Bindings(), + binding => string.Equals(binding.Type, adminInput.FullName, StringComparison.Ordinal) && + binding.Surface == PersonalDataSurface.ApiInput); + + foreach (Type type in adminOutputs) + { + AssertType(type, PersonalDataSurface.AdminOutput); + } + + PersonalDataFieldDefinition[] adminFields = Catalogue.Fields + .Where(field => field.Bindings.Any(binding => + binding.Surface is PersonalDataSurface.AdminInput or PersonalDataSurface.AdminOutput && + (string.Equals(binding.Type, adminInput.FullName, StringComparison.Ordinal) || + adminOutputs.Any(type => string.Equals(binding.Type, type.FullName, StringComparison.Ordinal))))) + .ToArray(); + + Assert.NotEmpty(adminFields); + Assert.All( + adminFields, + field => Assert.Contains(PersonalDataBoundary.Support, field.AllowedBoundaries)); + Assert.Contains( + Bindings(), + binding => binding.Surface == PersonalDataSurface.AdminOutput && + string.Equals(binding.Type, typeof(ReservationListItemDto).FullName, StringComparison.Ordinal) && + string.Equals(binding.Member, nameof(ReservationListItemDto.PrimaryGuestName), StringComparison.Ordinal)); + } + [Fact] public void Direct_or_unstructured_guest_data_cannot_enter_operational_outputs() { @@ -344,6 +402,10 @@ private static IEnumerable Bindings() => yield return (PersonalDataSurface.ApiInput, type); } + yield return ( + PersonalDataSurface.AdminInput, + typeof(ReservationsAdminApiModule.OperationsSnapshotRequest)); + Assembly contracts = typeof(ReservationsModuleMetadata).Assembly; foreach (Type type in contracts.GetTypes() .Where(type => type.IsPublic && !type.IsAbstract) @@ -363,6 +425,18 @@ private static IEnumerable Bindings() => yield return (surface, type); } + foreach (Type type in new[] + { + typeof(ReservationOperationsSnapshotDto), + typeof(ReservationOperationsCountDto), + typeof(ReservationOperationsCohortCountsDto), + typeof(ReservationOperationsAttentionCountsDto), + typeof(ReservationListItemDto) + }) + { + yield return (PersonalDataSurface.AdminOutput, type); + } + Assembly domain = typeof(Reservation).Assembly; foreach (Type type in domain.GetTypes() .Where(type => type.IsPublic && !type.IsAbstract) @@ -415,6 +489,7 @@ private static Dictionary CreateAssemblyIndex() => new[] { typeof(ReservationsModule).Assembly, + typeof(ReservationsAdminApiModule).Assembly, typeof(CreateReservationCommand).Assembly, typeof(ReservationsModuleMetadata).Assembly, typeof(Reservation).Assembly, diff --git a/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Persistence/ReservationDataRightsExportContributorTests.cs b/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Persistence/ReservationDataRightsExportContributorTests.cs index 071477c3..75697a42 100644 --- a/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Persistence/ReservationDataRightsExportContributorTests.cs +++ b/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Persistence/ReservationDataRightsExportContributorTests.cs @@ -805,7 +805,7 @@ public void Export_schema_is_catalogue_bound_and_versioned() ReservationDataRightsExportSchema.Descriptor; Assert.Equal(ReservationDataRightsDiscoveryContributor.Owner, descriptor.OwnerKey); Assert.Equal("reservations.personal-data", descriptor.CatalogId); - Assert.Equal(19, descriptor.CatalogVersion); + Assert.Equal(20, descriptor.CatalogVersion); Assert.Equal("reservations.subject-export", descriptor.ExportSchemaId); Assert.Equal(9, descriptor.ExportSchemaVersion); Assert.NotEmpty(descriptor.FieldIds); diff --git a/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Persistence/ReservationOperationsSnapshotReaderTests.cs b/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Persistence/ReservationOperationsSnapshotReaderTests.cs new file mode 100644 index 00000000..d332cc67 --- /dev/null +++ b/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Persistence/ReservationOperationsSnapshotReaderTests.cs @@ -0,0 +1,35 @@ +namespace BunkFy.Modules.Reservations.Tests.Persistence; + +using System.Data; +using BunkFy.Modules.Reservations.Persistence.Repositories; +using Xunit; + +public sealed class ReservationOperationsSnapshotReaderTests +{ + [Theory] + [InlineData( + "Npgsql.EntityFrameworkCore.PostgreSQL", + IsolationLevel.RepeatableRead)] + [InlineData( + "Microsoft.EntityFrameworkCore.SqlServer", + IsolationLevel.Serializable)] + public void Snapshot_isolation_is_provider_correct( + string providerName, + IsolationLevel expected) + { + Assert.Equal( + expected, + ReservationOperationsSnapshotReader + .ResolveRelationalSnapshotIsolation(providerName)); + } + + [Fact] + public void Unknown_relational_provider_fails_closed() + { + InvalidOperationException failure = Assert.Throws( + () => ReservationOperationsSnapshotReader + .ResolveRelationalSnapshotIsolation("Unsupported.Provider")); + + Assert.Contains("does not support", failure.Message, StringComparison.Ordinal); + } +} diff --git a/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Persistence/ReservationsTenantTerminationContributorTests.cs b/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Persistence/ReservationsTenantTerminationContributorTests.cs index 6b9a9f13..db57c45b 100644 --- a/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Persistence/ReservationsTenantTerminationContributorTests.cs +++ b/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Persistence/ReservationsTenantTerminationContributorTests.cs @@ -176,7 +176,7 @@ record => record.RecordType == Assert.Equal(7, contributor.ExportDescriptor.CatalogVersion); Assert.Equal(8, contributor.ExportDescriptor.ExportSchemaVersion); Assert.Equal( - 19, + 20, ReservationsTenantTerminationMetadata.PersonalDataCatalogVersion); Assert.Equal( ReservationsTenantTerminationMetadata.ExportFieldIds diff --git a/tests/Integration.Tests/Reservations/ReservationOperationsSnapshotPersistenceIntegrationTests.cs b/tests/Integration.Tests/Reservations/ReservationOperationsSnapshotPersistenceIntegrationTests.cs new file mode 100644 index 00000000..e3876951 --- /dev/null +++ b/tests/Integration.Tests/Reservations/ReservationOperationsSnapshotPersistenceIntegrationTests.cs @@ -0,0 +1,797 @@ +namespace Integration.Tests; + +using System.Data; +using System.Data.Common; +using BunkFy.Modules.Reservations.Application.Ports; +using BunkFy.Modules.Reservations.Contracts; +using BunkFy.Modules.Reservations.Domain.Aggregates; +using BunkFy.Modules.Reservations.Persistence; +using BunkFy.Modules.Workspaces.Contracts; +using Gma.Framework.Scoping; +using Integration.Tests.Support; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.EntityFrameworkCore.Storage; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Testcontainers.PostgreSql; +using Xunit; + +public sealed class ReservationOperationsSnapshotPersistenceIntegrationTests +{ + private const string TenantId = "tenant-a"; + private static readonly DateTimeOffset Observation = + new(2026, 8, 13, 10, 30, 0, TimeSpan.Zero); + + [DockerFact] + [Trait("Category", "Docker")] + [Trait("Category", "Integration")] + public async Task Snapshot_is_property_local_exact_bounded_private_and_repeatable() + { + await using PostgreSqlContainer postgreSql = new PostgreSqlBuilder( + "postgres:16-alpine") + .WithDatabase("bunkfy_reservation_operations_snapshot_tests") + .Build(); + await postgreSql.StartAsync(); + + string connectionString = postgreSql.GetConnectionString(); + await using (ReservationsDbContext migration = CreateDbContext( + connectionString)) + { + await migration.Database.MigrateAsync(); + } + + Guid honoluluPropertyId = Guid.Parse( + "01000000-0000-0000-0000-000000000001"); + Guid kiritimatiPropertyId = Guid.Parse( + "01000000-0000-0000-0000-000000000002"); + Guid inactivePropertyId = Guid.Parse( + "01000000-0000-0000-0000-000000000003"); + Guid invalidTimeZonePropertyId = Guid.Parse( + "01000000-0000-0000-0000-000000000004"); + Guid unknownPropertyId = Guid.Parse( + "01000000-0000-0000-0000-000000000005"); + Guid utcPropertyId = Guid.Parse( + "01000000-0000-0000-0000-000000000006"); + Guid windowsTimeZonePropertyId = Guid.Parse( + "01000000-0000-0000-0000-000000000007"); + Guid policyOnlyPropertyId = Guid.Parse( + "01000000-0000-0000-0000-000000000008"); + DateOnly honoluluDate = new(2026, 8, 13); + + List reservations = CreateStatusMatrix( + honoluluPropertyId, + honoluluDate); + await using (ReservationsDbContext seed = CreateDbContext( + connectionString)) + { + await using IDbContextTransaction transaction = + await seed.Database.BeginTransactionAsync(); + await SeedPropertyAsync( + seed, + honoluluPropertyId, + "Pacific/Honolulu", + isKnown: true, + isActive: true); + await SeedPropertyAsync( + seed, + kiritimatiPropertyId, + "Pacific/Kiritimati", + isKnown: true, + isActive: true); + await SeedPropertyAsync( + seed, + inactivePropertyId, + "Europe/Paris", + isKnown: true, + isActive: false); + await SeedPropertyAsync( + seed, + invalidTimeZonePropertyId, + "Invalid/Property-Zone", + isKnown: true, + isActive: true); + await SeedPropertyAsync( + seed, + unknownPropertyId, + "Etc/UTC", + isKnown: false, + isActive: true); + await SeedPropertyAsync( + seed, + utcPropertyId, + "UTC", + isKnown: true, + isActive: true); + await SeedPropertyAsync( + seed, + windowsTimeZonePropertyId, + "Pacific Standard Time", + isKnown: true, + isActive: true); + await SeedPropertyAsync( + seed, + policyOnlyPropertyId, + "Etc/UTC", + isKnown: true, + isActive: false, + topologySourceVersion: 0); + + foreach (ReservationSeed reservation in reservations) + { + await SeedReservationAsync(seed, reservation); + } + + await transaction.CommitAsync(); + } + + QueryCaptureInterceptor capture = new(); + await using (ServiceProvider provider = CreateProvider( + connectionString, + capture)) + { + ReservationOperationsSnapshotReadResult read = await ReadAsync( + provider, + honoluluPropertyId, + explicitLocalDate: null, + upcomingLimit: 50); + ReservationOperationsSnapshotDto snapshot = AssertFound(read); + + Assert.Equal(honoluluDate, snapshot.LocalDate); + Assert.Equal("Pacific/Honolulu", snapshot.TimeZoneId); + Assert.Equal( + ReservationOperationsDateSource.PropertyTimeZone, + snapshot.DateSource); + Assert.Equal(Observation, snapshot.ObservedAtUtc); + Assert.Equal( + new(4, 10), + snapshot.Cohorts.ConfirmedArrivalsOnLocalDate); + Assert.Equal( + new(2, 9), + snapshot.Cohorts.ScheduledDeparturesOnLocalDate); + Assert.Equal(new(3, 15), snapshot.Cohorts.CurrentlyInHouse); + Assert.Equal(new(101, 201), snapshot.Attention.PendingAllocation); + Assert.Equal(new(1, 7), snapshot.Attention.AllocationRejected); + Assert.Equal(new(1, 8), snapshot.Attention.CancellationPending); + Assert.Equal(new(1, 9), snapshot.Attention.NoShowPending); + Assert.Equal(new(1, 5), snapshot.Attention.CheckoutPending); + Assert.Equal( + new(1, 3), + snapshot.Attention.ArrivalBeforeLocalDateStillConfirmed); + Assert.Equal( + new(1, 6), + snapshot.Attention.DepartureBeforeLocalDateStillInHouse); + Assert.Equal(new(107, 239), snapshot.Attention.Total); + Assert.Equal(50, snapshot.Upcoming.Count); + Assert.Equal(50, snapshot.UpcomingLimit); + Assert.True(snapshot.HasMoreUpcoming); + Assert.Equal( + Enumerable.Range(1, 4).Select(UpcomingId), + snapshot.Upcoming.Take(4).Select(item => item.ReservationId)); + + Assert.Equal(IsolationLevel.RepeatableRead, capture.IsolationLevel); + Assert.Collection( + capture.SelectCommands, + property => Assert.Contains( + "property_projection", + property, + StringComparison.Ordinal), + aggregate => + { + Assert.Contains( + "GROUP BY", + aggregate, + StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain( + "ORDER BY", + aggregate, + StringComparison.OrdinalIgnoreCase); + }, + upcoming => + { + Assert.Contains( + "ORDER BY", + upcoming, + StringComparison.OrdinalIgnoreCase); + Assert.Contains( + "ExpectedArrivalTime\" IS NULL", + upcoming, + StringComparison.Ordinal); + Assert.Contains( + "LIMIT", + upcoming, + StringComparison.OrdinalIgnoreCase); + }); + } + + await using (ServiceProvider provider = CreateProvider(connectionString)) + { + ReservationOperationsSnapshotDto three = AssertFound(await ReadAsync( + provider, + honoluluPropertyId, + explicitLocalDate: honoluluDate, + upcomingLimit: 3)); + Assert.Equal( + ReservationOperationsDateSource.Explicit, + three.DateSource); + Assert.Equal("Pacific/Honolulu", three.TimeZoneId); + Assert.Equal( + Enumerable.Range(1, 3).Select(UpcomingId), + three.Upcoming.Select(item => item.ReservationId)); + Assert.True(three.HasMoreUpcoming); + + ReservationOperationsSnapshotDto historicalReference = + AssertFound(await ReadAsync( + provider, + honoluluPropertyId, + explicitLocalDate: honoluluDate.AddDays(-10), + upcomingLimit: 0)); + Assert.Equal( + new(3, 15), + historicalReference.Cohorts.CurrentlyInHouse); + Assert.Equal( + new(0, 0), + historicalReference.Attention + .DepartureBeforeLocalDateStillInHouse); + + ReservationOperationsSnapshotDto futureReference = + AssertFound(await ReadAsync( + provider, + honoluluPropertyId, + explicitLocalDate: honoluluDate.AddDays(20), + upcomingLimit: 0)); + Assert.Equal( + new(3, 15), + futureReference.Cohorts.CurrentlyInHouse); + Assert.Equal( + new(5, 13), + futureReference.Attention + .ArrivalBeforeLocalDateStillConfirmed); + Assert.Equal( + new(2, 10), + futureReference.Attention + .DepartureBeforeLocalDateStillInHouse); + + ReservationOperationsSnapshotDto zero = AssertFound(await ReadAsync( + provider, + honoluluPropertyId, + explicitLocalDate: honoluluDate, + upcomingLimit: 0)); + Assert.Empty(zero.Upcoming); + Assert.True(zero.HasMoreUpcoming); + + ReservationOperationsSnapshotDto kiritimati = AssertFound( + await ReadAsync( + provider, + kiritimatiPropertyId, + explicitLocalDate: null, + upcomingLimit: 0)); + Assert.Equal(new DateOnly(2026, 8, 14), kiritimati.LocalDate); + Assert.Equal("Pacific/Kiritimati", kiritimati.TimeZoneId); + + ReservationOperationsSnapshotDto utc = AssertFound(await ReadAsync( + provider, + utcPropertyId, + explicitLocalDate: null, + upcomingLimit: 0)); + Assert.Equal(honoluluDate, utc.LocalDate); + Assert.Equal("UTC", utc.TimeZoneId); + + Assert.Equal( + ReservationOperationsSnapshotReadStatus.PropertyNotFound, + (await ReadAsync( + provider, + Guid.Parse("01000000-0000-0000-0000-000000000099"), + explicitLocalDate: honoluluDate, + upcomingLimit: 0)).Status); + Assert.Equal( + ReservationOperationsSnapshotReadStatus.PropertyNotFound, + (await ReadAsync( + provider, + policyOnlyPropertyId, + explicitLocalDate: honoluluDate, + upcomingLimit: 0)).Status); + Assert.Equal( + ReservationOperationsSnapshotReadStatus.PropertyNotFound, + (await ReadAsync( + provider, + unknownPropertyId, + explicitLocalDate: honoluluDate, + upcomingLimit: 0)).Status); + Assert.Equal( + ReservationOperationsSnapshotReadStatus.PropertyInactive, + (await ReadAsync( + provider, + inactivePropertyId, + explicitLocalDate: honoluluDate, + upcomingLimit: 0)).Status); + Assert.Equal( + ReservationOperationsSnapshotReadStatus + .PropertyTimeZoneUnavailable, + (await ReadAsync( + provider, + invalidTimeZonePropertyId, + explicitLocalDate: honoluluDate, + upcomingLimit: 0)).Status); + Assert.Equal( + ReservationOperationsSnapshotReadStatus + .PropertyTimeZoneUnavailable, + (await ReadAsync( + provider, + windowsTimeZonePropertyId, + explicitLocalDate: honoluluDate, + upcomingLimit: 0)).Status); + } + + await AssertRepeatableReadSnapshotAsync(connectionString); + } + + private static async Task AssertRepeatableReadSnapshotAsync( + string connectionString) + { + Guid propertyId = Guid.Parse( + "02000000-0000-0000-0000-000000000001"); + DateOnly honoluluDate = new(2026, 8, 13); + ReservationSeed original = new( + Guid.Parse("02000000-0000-0000-0000-000000000002"), + propertyId, + ReservationState.PendingAllocation, + honoluluDate, + honoluluDate.AddDays(2), + GuestCount: 2, + ExpectedArrivalTime: new(9, 0), + CreatedAtUtc: new(2020, 1, 1, 0, 0, 0, TimeSpan.Zero)); + + await using (ReservationsDbContext seed = CreateDbContext( + connectionString)) + { + await SeedPropertyAsync( + seed, + propertyId, + "Pacific/Honolulu", + isKnown: true, + isActive: true); + await SeedReservationAsync(seed, original); + } + + PropertyReadBarrierInterceptor barrier = new(); + await using ServiceProvider provider = CreateProvider( + connectionString, + barrier); + Task inFlight = ReadAsync( + provider, + propertyId, + explicitLocalDate: null, + upcomingLimit: 10); + await barrier.PropertyRead.WaitAsync(TimeSpan.FromSeconds(20)); + + ReservationSeed concurrent = new( + Guid.Parse("02000000-0000-0000-0000-000000000003"), + propertyId, + ReservationState.PendingAllocation, + honoluluDate.AddDays(1), + honoluluDate.AddDays(3), + GuestCount: 3, + ExpectedArrivalTime: new(8, 0), + CreatedAtUtc: Observation); + try + { + await using ReservationsDbContext writer = CreateDbContext( + connectionString); + await using IDbContextTransaction transaction = + await writer.Database.BeginTransactionAsync(); + await writer.Database.ExecuteSqlInterpolatedAsync($""" + UPDATE reservations.property_projection + SET "TimeZoneId" = {"Pacific/Kiritimati"}, + "TopologySourceVersion" = "TopologySourceVersion" + 1 + WHERE "Id" = {propertyId}; + """); + await SeedReservationAsync(writer, concurrent); + await transaction.CommitAsync(); + } + finally + { + barrier.Release(); + } + + ReservationOperationsSnapshotDto first = AssertFound(await inFlight); + Assert.Equal(IsolationLevel.RepeatableRead, barrier.IsolationLevel); + Assert.Equal("Pacific/Honolulu", first.TimeZoneId); + Assert.Equal(honoluluDate, first.LocalDate); + Assert.Equal(new(1, 2), first.Attention.PendingAllocation); + Assert.Equal( + [original.Id], + first.Upcoming.Select(item => item.ReservationId)); + + await using ServiceProvider laterProvider = CreateProvider( + connectionString); + ReservationOperationsSnapshotDto later = AssertFound(await ReadAsync( + laterProvider, + propertyId, + explicitLocalDate: null, + upcomingLimit: 10)); + Assert.Equal("Pacific/Kiritimati", later.TimeZoneId); + Assert.Equal(honoluluDate.AddDays(1), later.LocalDate); + Assert.Equal(new(2, 5), later.Attention.PendingAllocation); + Assert.Equal( + [concurrent.Id], + later.Upcoming.Select(item => item.ReservationId)); + } + + private static List CreateStatusMatrix( + Guid propertyId, + DateOnly localDate) + { + List reservations = + [ + new(UpcomingId(1), propertyId, ReservationState.Confirmed, + localDate, localDate.AddDays(2), 1, new(7, 0)), + new(UpcomingId(2), propertyId, ReservationState.Confirmed, + localDate, localDate.AddDays(2), 2, new(8, 0)), + new(UpcomingId(3), propertyId, ReservationState.Confirmed, + localDate, localDate.AddDays(2), 3, new(8, 0)), + new(UpcomingId(4), propertyId, ReservationState.Confirmed, + localDate, localDate.AddDays(2), 4, ExpectedArrivalTime: null), + new(Guid.Parse("03000000-0000-0000-0000-000000000001"), + propertyId, ReservationState.Confirmed, localDate.AddDays(-1), + localDate.AddDays(1), 3), + new(Guid.Parse("03000000-0000-0000-0000-000000000002"), + propertyId, ReservationState.CheckedIn, localDate.AddDays(-2), + localDate, 4), + new(Guid.Parse("03000000-0000-0000-0000-000000000003"), + propertyId, ReservationState.CheckoutPending, + localDate.AddDays(-3), localDate, 5), + new(Guid.Parse("03000000-0000-0000-0000-000000000004"), + propertyId, ReservationState.CheckedIn, localDate.AddDays(-4), + localDate.AddDays(-1), 6), + new(Guid.Parse("03000000-0000-0000-0000-000000000005"), + propertyId, ReservationState.AllocationRejected, + localDate.AddDays(2), localDate.AddDays(3), 7), + new(Guid.Parse("03000000-0000-0000-0000-000000000006"), + propertyId, ReservationState.CancellationPending, + localDate.AddDays(2), localDate.AddDays(3), 8), + new(Guid.Parse("03000000-0000-0000-0000-000000000007"), + propertyId, ReservationState.NoShowPending, + localDate.AddDays(-1), localDate.AddDays(1), 9), + new(Guid.Parse("03000000-0000-0000-0000-000000000008"), + propertyId, ReservationState.Cancelled, + localDate.AddDays(-5), localDate.AddDays(-3), 10), + new(Guid.Parse("03000000-0000-0000-0000-000000000009"), + propertyId, ReservationState.NoShow, + localDate.AddDays(-5), localDate.AddDays(-3), 11), + new(Guid.Parse("03000000-0000-0000-0000-000000000010"), + propertyId, ReservationState.CheckedOut, + localDate.AddDays(-5), localDate.AddDays(-3), 12), + new(Guid.Parse("03000000-0000-0000-0000-000000000011"), + propertyId, ReservationState.PendingAllocation, + localDate, localDate.AddDays(2), 50, + Projection: ProjectionVisibility.Restricted), + new(Guid.Parse("03000000-0000-0000-0000-000000000012"), + propertyId, ReservationState.Confirmed, + localDate, localDate.AddDays(2), 50, + Projection: ProjectionVisibility.Unsupported), + new(Guid.Parse("03000000-0000-0000-0000-000000000013"), + propertyId, ReservationState.Confirmed, + localDate, localDate.AddDays(2), 50, + Projection: ProjectionVisibility.Missing), + new(Guid.Parse("03000000-0000-0000-0000-000000000014"), + propertyId, ReservationState.AllocationRejected, + localDate.AddDays(2), localDate.AddDays(3), 50, + IsAnonymised: true) + ]; + + DateTimeOffset oldest = new(2018, 1, 1, 0, 0, 0, TimeSpan.Zero); + for (int index = 0; index < 101; index++) + { + reservations.Add(new( + Guid.Parse($"10000000-0000-0000-0000-{index + 1:D12}"), + propertyId, + ReservationState.PendingAllocation, + localDate.AddDays(10), + localDate.AddDays(12), + (index % 3) + 1, + ExpectedArrivalTime: null, + CreatedAtUtc: oldest.AddMinutes(index))); + } + + return reservations; + } + + private static Guid UpcomingId(int ordinal) => Guid.Parse( + $"00000000-0000-0000-0000-{ordinal:D12}"); + + private static async Task ReadAsync( + ServiceProvider provider, + Guid propertyId, + DateOnly? explicitLocalDate, + int upcomingLimit) + { + using IServiceScope scope = provider.CreateScope(); + return await scope.ServiceProvider + .GetRequiredService() + .ReadAsync( + propertyId, + explicitLocalDate, + Observation, + upcomingLimit, + CancellationToken.None); + } + + private static ReservationOperationsSnapshotDto AssertFound( + ReservationOperationsSnapshotReadResult read) + { + Assert.Equal(ReservationOperationsSnapshotReadStatus.Found, read.Status); + return Assert.IsType(read.Snapshot); + } + + private static async Task SeedPropertyAsync( + ReservationsDbContext context, + Guid propertyId, + string timeZoneId, + bool isKnown, + bool isActive, + long topologySourceVersion = 1) + { + await context.Database.ExecuteSqlInterpolatedAsync($""" + INSERT INTO reservations.property_projection ( + "Id", "ScopeId", "TimeZoneId", "IsActive", "IsKnown", + "TopologySourceVersion") + VALUES ( + {propertyId}, {TenantId}, {timeZoneId}, {isActive}, {isKnown}, + {topologySourceVersion}); + """); + } + + private static async Task SeedReservationAsync( + ReservationsDbContext context, + ReservationSeed seed) + { + DateTimeOffset createdAtUtc = seed.CreatedAtUtc ?? Observation.AddDays(-1); + bool checkedIn = seed.Status is + ReservationState.CheckedIn or + ReservationState.CheckoutPending or + ReservationState.CheckedOut; + bool pendingStay = seed.Status is + ReservationState.NoShowPending or + ReservationState.CheckoutPending; + bool terminal = seed.Status is + ReservationState.AllocationRejected or + ReservationState.Cancelled or + ReservationState.NoShow or + ReservationState.CheckedOut; + bool allocated = seed.Status is not ( + ReservationState.PendingAllocation or + ReservationState.AllocationRejected); + Guid? allocationId = allocated ? Guid.NewGuid() : null; + long? allocationVersion = allocated ? 1L : null; + int? allocationRejection = seed.Status == + ReservationState.AllocationRejected + ? (int)ReservationAllocationRejection.AllocationConflict + : null; + Guid? releaseRequestId = seed.Status is + ReservationState.CancellationPending or + ReservationState.NoShowPending or + ReservationState.CheckoutPending + ? Guid.NewGuid() + : null; + DateOnly? checkedInBusinessDate = checkedIn ? seed.Arrival : null; + DateTimeOffset? checkedInAtUtc = checkedIn ? createdAtUtc : null; + string? checkedInBy = checkedIn ? "staff:check-in" : null; + DateOnly? pendingStayBusinessDate = pendingStay ? seed.Arrival : null; + string? pendingStayActorId = pendingStay ? "staff:pending-stay" : null; + DateOnly? noShowBusinessDate = seed.Status == ReservationState.NoShow + ? seed.Arrival + : null; + DateTimeOffset? noShowAtUtc = seed.Status == ReservationState.NoShow + ? createdAtUtc + : null; + string? noShowBy = seed.Status == ReservationState.NoShow + ? "staff:no-show" + : null; + DateOnly? checkedOutBusinessDate = seed.Status == + ReservationState.CheckedOut + ? seed.Departure + : null; + DateTimeOffset? checkedOutAtUtc = seed.Status == + ReservationState.CheckedOut + ? createdAtUtc + : null; + string? checkedOutBy = seed.Status == ReservationState.CheckedOut + ? "staff:check-out" + : null; + DateTimeOffset? terminalAtUtc = terminal ? createdAtUtc : null; + DateTimeOffset? anonymisedAtUtc = seed.IsAnonymised + ? createdAtUtc + : null; + string primaryGuestName = seed.IsAnonymised + ? Reservation.AnonymisedGuestName + : $"Guest {seed.Id:N}"; + + await context.Database.ExecuteSqlInterpolatedAsync($""" + INSERT INTO reservations.reservations ( + "Id", "PropertyId", "AllocationRequestId", "AllocationId", + "AllocationVersion", "AllocationRejection", "ReleaseRequestId", + "Arrival", "Departure", "ExpectedArrivalTime", + "PrimaryGuestName", "PrimaryGuestNameSearch", "GuestCount", + "Source", "Status", "Version", "CreatedAtUtc", "ScopeId", + "LastDetailsChangedAtUtc", "CheckedInBusinessDate", + "CheckedInAtUtc", "CheckedInBy", "PendingStayBusinessDate", + "PendingStayActorId", "NoShowBusinessDate", "NoShowAtUtc", + "NoShowBy", "CheckedOutBusinessDate", "CheckedOutAtUtc", + "CheckedOutBy", "TerminalAtUtc", "AnonymisedAtUtc", + "IsAnonymised") + VALUES ( + {seed.Id}, {seed.PropertyId}, {Guid.NewGuid()}, {allocationId}, + {allocationVersion}, {allocationRejection}, {releaseRequestId}, + {seed.Arrival}, {seed.Departure}, {seed.ExpectedArrivalTime}, + {primaryGuestName}, {primaryGuestName.ToUpperInvariant()}, + {seed.GuestCount}, {(int)ReservationSource.Direct}, + {(int)seed.Status}, {1L}, {createdAtUtc}, {TenantId}, + {createdAtUtc}, {checkedInBusinessDate}, {checkedInAtUtc}, + {checkedInBy}, {pendingStayBusinessDate}, {pendingStayActorId}, + {noShowBusinessDate}, {noShowAtUtc}, {noShowBy}, + {checkedOutBusinessDate}, {checkedOutAtUtc}, {checkedOutBy}, + {terminalAtUtc}, {anonymisedAtUtc}, {seed.IsAnonymised}); + """); + + if (seed.Projection == ProjectionVisibility.Missing) + { + return; + } + + bool restricted = seed.Projection == ProjectionVisibility.Restricted; + int contractVersion = seed.Projection == ProjectionVisibility.Unsupported + ? ReservationProcessingRestrictionContract.CurrentVersion + 1 + : ReservationProcessingRestrictionContract.CurrentVersion; + await context.Database.ExecuteSqlInterpolatedAsync($""" + INSERT INTO reservations.reservation_processing_restriction_state ( + "ScopeId", "PropertyId", "ReservationId", "ContractVersion", + "Revision", "ActiveRestrictionCount", "IsRestricted", + "LastTransitionAtUtc") + VALUES ( + {TenantId}, {seed.PropertyId}, {seed.Id}, {contractVersion}, + {(restricted ? 1L : 0L)}, {(restricted ? 1 : 0)}, {restricted}, + {createdAtUtc}); + """); + } + + private static ReservationsDbContext CreateDbContext( + string connectionString) + { + DbContextOptions options = + new DbContextOptionsBuilder() + .UseNpgsql(connectionString, provider => provider + .MigrationsAssembly(ReservationsMigrations.PostgreSqlAssembly) + .MigrationsHistoryTable( + ReservationsMigrations.HistoryTable, + ReservationsMigrations.Schema)) + .Options; + return new( + options, + new TestScopeContext(), + OpenWorkspaceTerminationFenceReader.Instance); + } + + private static ServiceProvider CreateProvider( + string connectionString, + IInterceptor? interceptor = null) + { + HostApplicationBuilder builder = Host.CreateApplicationBuilder(); + builder.Configuration["Persistence:Provider"] = "PostgreSql"; + builder.Configuration["ConnectionStrings:PostgreSql"] = + connectionString; + builder.Services.AddSingleton(new TestScopeContext()); + builder.Services.AddSingleton( + OpenWorkspaceTerminationFenceReader.Instance); + builder.Services.AddDbContext(options => + { + options.UseNpgsql(connectionString, provider => provider + .MigrationsAssembly(ReservationsMigrations.PostgreSqlAssembly) + .MigrationsHistoryTable( + ReservationsMigrations.HistoryTable, + ReservationsMigrations.Schema)); + if (interceptor is not null) + { + options.AddInterceptors(interceptor); + } + }); + builder.AddReservationsPersistence(); + return builder.Services.BuildServiceProvider(); + } + + private sealed class QueryCaptureInterceptor : DbCommandInterceptor + { + private readonly List selectCommands = []; + + public IReadOnlyCollection SelectCommands => + this.selectCommands.AsReadOnly(); + public IsolationLevel? IsolationLevel { get; private set; } + + public override ValueTask> + ReaderExecutingAsync( + DbCommand command, + CommandEventData eventData, + InterceptionResult result, + CancellationToken cancellationToken = default) + { + if (command.CommandText.TrimStart().StartsWith( + "SELECT", + StringComparison.OrdinalIgnoreCase)) + { + this.selectCommands.Add(command.CommandText); + this.IsolationLevel ??= eventData.Context?.Database + .CurrentTransaction?.GetDbTransaction().IsolationLevel; + } + + return base.ReaderExecutingAsync( + command, + eventData, + result, + cancellationToken); + } + } + + private sealed class PropertyReadBarrierInterceptor : DbCommandInterceptor + { + private readonly TaskCompletionSource propertyRead = new( + TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource release = new( + TaskCreationOptions.RunContinuationsAsynchronously); + private int intercepted; + + public Task PropertyRead => this.propertyRead.Task; + public IsolationLevel? IsolationLevel { get; private set; } + + public void Release() => this.release.TrySetResult(); + + public override async ValueTask ReaderExecutedAsync( + DbCommand command, + CommandExecutedEventData eventData, + DbDataReader result, + CancellationToken cancellationToken = default) + { + if (command.CommandText.Contains( + "property_projection", + StringComparison.Ordinal) && + Interlocked.CompareExchange(ref this.intercepted, 1, 0) == 0) + { + this.IsolationLevel = eventData.Context?.Database + .CurrentTransaction?.GetDbTransaction().IsolationLevel; + this.propertyRead.TrySetResult(); + await this.release.Task.WaitAsync(cancellationToken); + } + + return await base.ReaderExecutedAsync( + command, + eventData, + result, + cancellationToken); + } + } + + private sealed class TestScopeContext : IScopeContext + { + public bool IsEnabled => true; + public string ScopeId => TenantId; + } + + private sealed record ReservationSeed( + Guid Id, + Guid PropertyId, + ReservationState Status, + DateOnly Arrival, + DateOnly Departure, + int GuestCount, + TimeOnly? ExpectedArrivalTime = null, + DateTimeOffset? CreatedAtUtc = null, + ProjectionVisibility Projection = ProjectionVisibility.Allowed, + bool IsAnonymised = false); + + private enum ProjectionVisibility + { + Allowed, + Restricted, + Unsupported, + Missing + } +} diff --git a/tests/Integration.Tests/Reservations/ReservationsSagaIntegrationTests.cs b/tests/Integration.Tests/Reservations/ReservationsSagaIntegrationTests.cs index 90ca4bf1..23d8e71e 100644 --- a/tests/Integration.Tests/Reservations/ReservationsSagaIntegrationTests.cs +++ b/tests/Integration.Tests/Reservations/ReservationsSagaIntegrationTests.cs @@ -6,6 +6,7 @@ namespace Integration.Tests; using System.Net.Http.Headers; using System.Net.Http.Json; using System.Security.Claims; +using System.Text.Json; using BunkFy.Host.Worker; using BunkFy.Modules.Guests.Contracts; using BunkFy.Modules.Guests.Domain.DataRights; @@ -15,6 +16,7 @@ namespace Integration.Tests; using BunkFy.Modules.Inventory.Persistence; using BunkFy.Modules.Properties.Contracts; using BunkFy.Modules.Reservations.Admin.Contracts; +using BunkFy.Modules.Reservations.Application; using BunkFy.Modules.Reservations.Contracts; using BunkFy.Modules.Reservations.Persistence; using DotNet.Testcontainers.Containers; @@ -38,6 +40,7 @@ public sealed partial class ReservationsSagaIntegrationTests { private const string TenantId = "a3000000-0000-0000-0000-000000000001"; private const string TenantHeader = "X-Tenant-Id"; + private static readonly JsonSerializerOptions WebJsonOptions = new(JsonSerializerDefaults.Web); private static readonly Guid PropertyId = Guid.Parse("71000000-0000-0000-0000-000000000001"); private static readonly Guid OtherPropertyId = Guid.Parse("71000000-0000-0000-0000-000000000002"); private static readonly Guid RoomId = Guid.Parse("72000000-0000-0000-0000-000000000001"); @@ -69,7 +72,12 @@ public async Task Real_worker_coordinates_reservation_and_stay_lifecycles_throug connectionString, includeReservations: true); await admin.MigrateAsync().ConfigureAwait(false); + await using AdminApiTestApplication adminApi = new( + "PostgreSql", + connectionString, + natsConnectionString); using HttpClient client = api.CreateClient(); + using HttpClient adminClient = adminApi.CreateClient(); using IHost initialWorker = CreateWorker(connectionString, natsConnectionString); IHost? stayWorker = null; @@ -85,6 +93,7 @@ public async Task Real_worker_coordinates_reservation_and_stay_lifecycles_throug TenantId, "operator@reservations.test").ConfigureAwait(false); Guid operatorId = GetSubjectId(tokens.AccessToken); + Guid adminActorId = Guid.NewGuid(); await api.SeedOrganizationMembershipAsync(TenantId, operatorId).ConfigureAwait(false); await GrantReservationsAccessAsync(admin, operatorId).ConfigureAwait(false); AuthTokensResponse readerTokens = await AuthApiClient.RegisterAsync( @@ -143,6 +152,203 @@ await WaitForStatusAsync( ReservationStatus.AllocationRejected, TimeSpan.FromSeconds(20)).ConfigureAwait(false); + const string operationsPathSuffix = + "operations-snapshot?localDate=2026-10-01&upcomingLimit=1"; + using (HttpResponseMessage operations = await SendAsync( + client, + HttpMethod.Get, + $"/api/reservations/properties/{PropertyId:D}/{operationsPathSuffix}", + tokens.AccessToken).ConfigureAwait(false)) + { + ReservationOperationsSnapshotDto snapshot = + await ReadSuccessAsync(operations) + .ConfigureAwait(false); + AssertNoStore(operations); + Assert.Equal(PropertyId, snapshot.PropertyId); + Assert.Equal(new DateOnly(2026, 10, 1), snapshot.LocalDate); + Assert.Equal("UTC", snapshot.TimeZoneId); + Assert.Equal(ReservationOperationsDateSource.Explicit, snapshot.DateSource); + Assert.Equal(1, snapshot.UpcomingLimit); + Assert.Single(snapshot.Upcoming); + Assert.Equal("First Guest", snapshot.Upcoming.Single().PrimaryGuestName); + Assert.Equal(1, snapshot.Upcoming.Single().GuestCount); + Assert.Equal(1, snapshot.Cohorts.ConfirmedArrivalsOnLocalDate.ReservationCount); + Assert.Equal(1, snapshot.Cohorts.ConfirmedArrivalsOnLocalDate.GuestCount); + Assert.Equal(1, snapshot.Attention.AllocationRejected.ReservationCount); + Assert.Equal(1, snapshot.Attention.Total.ReservationCount); + } + + using (HttpResponseMessage crossPropertyOperations = await SendAsync( + client, + HttpMethod.Get, + $"/api/reservations/properties/{OtherPropertyId:D}/{operationsPathSuffix}", + tokens.AccessToken).ConfigureAwait(false)) + { + await AssertStatusAsync(HttpStatusCode.Forbidden, crossPropertyOperations) + .ConfigureAwait(false); + AssertNoStore(crossPropertyOperations); + } + + using (HttpResponseMessage anonymousOperations = await SendAsync( + client, + HttpMethod.Get, + $"/api/reservations/properties/{PropertyId:D}/{operationsPathSuffix}") + .ConfigureAwait(false)) + { + await AssertStatusAsync(HttpStatusCode.Unauthorized, anonymousOperations) + .ConfigureAwait(false); + AssertNoStore(anonymousOperations); + } + + using (HttpResponseMessage malformedDate = await SendAsync( + client, + HttpMethod.Get, + $"/api/reservations/properties/{PropertyId:D}/operations-snapshot?localDate=not-a-date", + tokens.AccessToken).ConfigureAwait(false)) + { + await AssertStatusAsync(HttpStatusCode.BadRequest, malformedDate) + .ConfigureAwait(false); + AssertNoStore(malformedDate); + } + + using (HttpResponseMessage invalidLimit = await SendAsync( + client, + HttpMethod.Get, + $"/api/reservations/properties/{PropertyId:D}/operations-snapshot?upcomingLimit=51", + tokens.AccessToken).ConfigureAwait(false)) + { + await AssertStatusAsync(HttpStatusCode.BadRequest, invalidLimit) + .ConfigureAwait(false); + AssertNoStore(invalidLimit); + } + + string adminAccessToken = AdminApiTestApplication + .CreateAccessTokenWithTenantClaim(adminActorId, TenantId); + using (HttpResponseMessage adminOperations = await SendAsync( + adminClient, + HttpMethod.Get, + $"/api/admin/reservations/properties/{PropertyId:D}/{operationsPathSuffix}", + adminAccessToken).ConfigureAwait(false)) + { + ReservationOperationsSnapshotDto adminSnapshot = + await ReadSuccessAsync(adminOperations) + .ConfigureAwait(false); + AssertNoStore(adminOperations); + Assert.Equal(PropertyId, adminSnapshot.PropertyId); + Assert.Equal(1, adminSnapshot.Cohorts.ConfirmedArrivalsOnLocalDate.ReservationCount); + Assert.Equal(1, adminSnapshot.Attention.AllocationRejected.ReservationCount); + } + + using (HttpResponseMessage adminCrossProperty = await SendAsync( + adminClient, + HttpMethod.Get, + $"/api/admin/reservations/properties/{OtherPropertyId:D}/{operationsPathSuffix}", + adminAccessToken).ConfigureAwait(false)) + { + await AssertStatusAsync(HttpStatusCode.Forbidden, adminCrossProperty) + .ConfigureAwait(false); + AssertNoStore(adminCrossProperty); + } + Assert.Equal( + 2, + await adminApi.CountAuditEntriesAsync( + ReservationsAdminOperationNames.OperationsSnapshot) + .ConfigureAwait(false)); + Assert.Equal( + 1, + await adminApi.CountAuditEntriesAsync( + ReservationsAdminOperationNames.OperationsSnapshot, + AdminErrors.Unauthorized.Code) + .ConfigureAwait(false)); + + AdminCliResult operationsTable = await admin.ExecuteAsync( + "reservations", "operations-snapshot", + "--actor", adminActorId.ToString("D"), + "--tenant", TenantId, + "--property-id", PropertyId.ToString("D"), + "--local-date", "2026-10-01", + "--upcoming-limit", "1") + .ConfigureAwait(false); + Assert.Equal(AdminExitCodes.Success, operationsTable.ExitCode); + Assert.Contains("ArrivalsOnLocalDate", operationsTable.Output, StringComparison.Ordinal); + Assert.Contains("CurrentlyInHouse", operationsTable.Output, StringComparison.Ordinal); + Assert.Contains("PendingAllocation", operationsTable.Output, StringComparison.Ordinal); + Assert.Contains("AllocationRejected", operationsTable.Output, StringComparison.Ordinal); + Assert.Contains("CancellationPending", operationsTable.Output, StringComparison.Ordinal); + Assert.Contains("NoShowPending", operationsTable.Output, StringComparison.Ordinal); + Assert.Contains("CheckoutPending", operationsTable.Output, StringComparison.Ordinal); + Assert.Contains( + "ArrivalBeforeLocalDateStillConfirmed", + operationsTable.Output, + StringComparison.Ordinal); + Assert.Contains( + "DepartureBeforeLocalDateStillInHouse", + operationsTable.Output, + StringComparison.Ordinal); + Assert.Contains("Guests", operationsTable.Output, StringComparison.Ordinal); + Assert.Contains("First Guest", operationsTable.Output, StringComparison.Ordinal); + + AdminCliResult operationsJson = await admin.ExecuteAsync( + "reservations", "operations-snapshot", + "--actor", adminActorId.ToString("D"), + "--tenant", TenantId, + "--property-id", PropertyId.ToString("D"), + "--local-date", "2026-10-01", + "--upcoming-limit", "0", + "--output", "json") + .ConfigureAwait(false); + Assert.Equal(AdminExitCodes.Success, operationsJson.ExitCode); + Assert.True( + operationsJson.Output.TrimStart().StartsWith('{'), + $"Expected CLI JSON object output but received:{Environment.NewLine}{operationsJson.Output}"); + ReservationOperationsSnapshotDto? cliSnapshot = JsonSerializer.Deserialize< + ReservationOperationsSnapshotDto>( + operationsJson.Output, + WebJsonOptions); + Assert.NotNull(cliSnapshot); + Assert.Equal(0, cliSnapshot.UpcomingLimit); + Assert.Empty(cliSnapshot.Upcoming); + Assert.True(cliSnapshot.HasMoreUpcoming); + Assert.Equal(1, cliSnapshot.Attention.AllocationRejected.ReservationCount); + + AdminCliResult wrongProperty = await admin.ExecuteAsync( + "reservations", "operations-snapshot", + "--actor", adminActorId.ToString("D"), + "--tenant", TenantId, + "--property-id", OtherPropertyId.ToString("D")) + .ConfigureAwait(false); + Assert.Equal(AdminExitCodes.Unauthorized, wrongProperty.ExitCode); + + AdminCliResult invalidCliDate = await admin.ExecuteAsync( + "reservations", "operations-snapshot", + "--actor", adminActorId.ToString("D"), + "--tenant", TenantId, + "--property-id", PropertyId.ToString("D"), + "--local-date", "not-a-date") + .ConfigureAwait(false); + Assert.NotEqual(AdminExitCodes.Success, invalidCliDate.ExitCode); + Assert.Contains( + ReservationsApplicationErrors.OperationsSnapshotLocalDateInvalid.Message, + invalidCliDate.Error, + StringComparison.Ordinal); + + AdminCliResult invalidCliLimit = await admin.ExecuteAsync( + "reservations", "operations-snapshot", + "--actor", adminActorId.ToString("D"), + "--tenant", TenantId, + "--property-id", PropertyId.ToString("D"), + "--upcoming-limit", "51") + .ConfigureAwait(false); + Assert.NotEqual(AdminExitCodes.Success, invalidCliLimit.ExitCode); + Assert.Contains( + ReservationsApplicationErrors.OperationsSnapshotLimitInvalid.Message, + invalidCliLimit.Error, + StringComparison.Ordinal); + Assert.True( + await admin.CountAuditEntriesContainingAsync( + ReservationsAdminOperationNames.OperationsSnapshot) + .ConfigureAwait(false) >= 5); + using (HttpResponseMessage crossScope = await SendAsync( client, HttpMethod.Get, @@ -1408,7 +1614,9 @@ private static async Task WaitForSellableProjectionAsync(AuthTestApplication api throw new TimeoutException("Reservations did not receive the sellable Inventory unit projection."); } - private static async Task GrantReservationsAccessAsync(AdminCliTestApplication admin, Guid operatorId) + private static async Task GrantReservationsAccessAsync( + AdminCliTestApplication admin, + Guid operatorId) { await AssertAdminSuccessAsync(admin.ExecuteAsync("admin", "bootstrap", "--actor", "owner", "--yes")); await AssertAdminSuccessAsync(admin.ExecuteAsync( @@ -1627,6 +1835,15 @@ private static async Task AssertStatusAsync(HttpStatusCode expected, HttpRespons Assert.True(response.StatusCode == expected, $"Expected {(int)expected} but received {(int)response.StatusCode}. Body: {body}"); } + private static void AssertNoStore(HttpResponseMessage response) + { + Assert.True(response.Headers.CacheControl?.NoStore is true); + Assert.Contains(response.Headers.Pragma, header => + string.Equals(header.Name, "no-cache", StringComparison.OrdinalIgnoreCase)); + Assert.True(response.Content.Headers.TryGetValues("Expires", out IEnumerable? expires)); + Assert.Equal("0", Assert.Single(expires)); + } + private static async Task AssertAdminSuccessAsync(Task resultTask) { AdminCliResult result = await resultTask.ConfigureAwait(false); diff --git a/tests/Integration.Tests/Support/AdminCliTestApplication.cs b/tests/Integration.Tests/Support/AdminCliTestApplication.cs index 37428a8a..18289297 100644 --- a/tests/Integration.Tests/Support/AdminCliTestApplication.cs +++ b/tests/Integration.Tests/Support/AdminCliTestApplication.cs @@ -3,10 +3,12 @@ namespace Integration.Tests.Support; using System.CommandLine; using System.CommandLine.Parsing; using System.Text.RegularExpressions; -using BunkFy.Parsers.ReservationMail; using BunkFy.Host.AdminCli.Security; +using BunkFy.Parsers.ReservationMail; using BunkFy.Modules.Properties.AdminCli; using BunkFy.Modules.Properties.Persistence; +using BunkFy.Modules.Reservations.AdminCli; +using BunkFy.Modules.Reservations.Persistence; using Gma.Framework.Administration.Cli; using Gma.Framework.Caching.Cqrs; using Gma.Framework.Cqrs; @@ -105,6 +107,12 @@ public AdminCliTestApplication( builder.AddAdminModule(); } + if (includeReservations) + { + builder.AddAdminModule(); + builder.Services.AddBunkFyAdminCliResourceScopes(); + } + if (systemClock is not null) { builder.Services.RemoveAll(); @@ -135,11 +143,17 @@ public async Task MigrateAsync() } IngestionDbContext? ingestion = scope.ServiceProvider.GetService(); + ReservationsDbContext? reservations = scope.ServiceProvider.GetService(); if (ingestion is not null) { await ingestion.Database.MigrateAsync().ConfigureAwait(false); } + if (reservations is not null) + { + await reservations.Database.MigrateAsync().ConfigureAwait(false); + } + PropertiesDbContext? properties = scope.ServiceProvider.GetService(); if (properties is not null) { From a31eeff1feb80d33c77ee275d5816f2e5814083f Mon Sep 17 00:00:00 2001 From: SadPossum Date: Sat, 15 Aug 2026 15:43:09 +0000 Subject: [PATCH 3/6] Harden Reservations retention recovery --- .../reservation-record-retention-task.md | 79 +- .../ApplyReservationRetentionCommand.cs | 1 + ...eteReservationRetentionExecutionCommand.cs | 1 + .../ReservationRetentionContributor.cs | 9 +- ...ApplyReservationRetentionCommandHandler.cs | 14 +- ...rvationRetentionExecutionCommandHandler.cs | 48 +- ...rvationRetentionExecutionCommandHandler.cs | 16 +- .../Handlers/ReservationMutationTime.cs | 13 + .../ReservationRetentionOptions.cs | 1 + .../ReservationRetentionOptionsValidator.cs | 7 +- .../ReservationRetentionExecution.cs | 48 +- .../ReservationRetentionSweepCheckpoint.cs | 50 + ...ReservationRetentionCandidateRepository.cs | 263 ++- src/Modules/Reservations/docs/README.md | 20 +- .../docs/personal-data-catalog.v1.json | 14 + .../docs/personal-data-inventory.v1.md | 2 + ...ReservationRetentionCommandHandlerTests.cs | 27 + .../ReservationRetentionConfigurationTests.cs | 22 + .../ReservationRetentionContributorTests.cs | 66 +- ...onRetentionExecutionCommandHandlerTests.cs | 279 +++ .../Domain/ReservationRetentionDomainTests.cs | 194 ++ ...vationRetentionCandidateRepositoryTests.cs | 364 ++- .../Commands/RetentionExecutionStart.cs | 1 + .../RetentionExecutionCommandHandlers.cs | 13 +- .../ExecuteRetentionScheduleTaskHandler.cs | 21 +- .../Aggregates/RetentionExecution.cs | 6 + src/Modules/Retention/docs/README.md | 28 + ...xecuteRetentionScheduleTaskHandlerTests.cs | 188 +- .../RetentionMutationCoordinatorTests.cs | 76 + .../Domain/RetentionExecutionTests.cs | 19 + .../ReservationDataRightsIntegrationTests.cs | 206 +- ...vationRetentionProviderIntegrationTests.cs | 1968 +++++++++++++++++ ...agaIntegrationTests.LostOutcomeRecovery.cs | 20 +- .../ReservationsSagaIntegrationTests.cs | 80 +- .../RetentionControlPlaneIntegrationTests.cs | 30 +- .../Support/AdminCliTestApplication.cs | 22 +- .../CountryPolicyIntegrationTestData.cs | 31 +- 37 files changed, 3966 insertions(+), 281 deletions(-) create mode 100644 src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Handlers/ReservationMutationTime.cs create mode 100644 src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Application/ReservationRetentionExecutionCommandHandlerTests.cs create mode 100644 tests/Integration.Tests/Reservations/ReservationRetentionProviderIntegrationTests.cs diff --git a/docs/planning/reservation-record-retention-task.md b/docs/planning/reservation-record-retention-task.md index 836adb0b..5911b14b 100644 --- a/docs/planning/reservation-record-retention-task.md +++ b/docs/planning/reservation-record-retention-task.md @@ -1,6 +1,7 @@ # Reservation Record Retention Task -Status: published; implementation and exact-candidate gates complete +Status: published baseline; current retry and scale hardening is local and +unpublished ## Outcome @@ -91,7 +92,7 @@ guest links. It writes: -- an append-only retention execution and fair-scan checkpoint; +- a durable, versioned retention execution and fair-scan checkpoint; - an append-only retention anonymisation receipt containing only owner-local coordinates, selected/resulting versions, terminal trigger, deadline, policy digest, reduction counts, event id, actor, and completion time; @@ -104,13 +105,76 @@ automatic retention decision cannot be restored through a rights-case replay. The retention receipt raises the existing reservation-anonymised event so owner projections and downstream module contracts remain coherent. +### Attempt fencing and failed-attempt recovery + +Task Runtime and Retention use two deliberately different counters. Task +Runtime `Attempt` remains the per-run retry-budget counter and restarts when an +operator invokes `RetryAsync`. The persisted, monotonic `LeaseGeneration` +survives that operation and is the value Retention stores and forwards through +its execution and owner-contract field named `Attempt`. Reservations therefore +fences owner work to a lease generation, not to the resettable Task Runtime +budget counter. + +If lease reclaim reaches a central Retention execution that is already +`Completed` or `Blocked`, Retention replays that terminal aggregate without +redispatching Reservations. If Reservations committed terminal owner evidence +before the worker committed central completion, a newer lease generation opens +a forward-only central recovery window and redispatches only to obtain the +owner's exact replay. The Reservations execution and receipt remain unchanged; +when their completion predates the new central start, only the central result's +completion time is normalized to that new start before central persistence. +This preserves immutable owner proof while satisfying the new central window. + +The Retention request attempt is carried through every owner mutation and the +owner completion command. A mutation is admitted only when the matching +Reservations execution is `Running` for the same tenant, data class, +execution-policy version, and exact attempt. Completion is likewise fenced to +the active attempt. A late worker from an earlier attempt can therefore neither +anonymise another record nor terminalize or advance the cursor of a newer +attempt. + +`Failed` is terminal evidence for the current attempt. Replaying that same +attempt returns the persisted status, counts, outcome code, completion time, +and hold timestamp exactly; correcting the underlying condition does not +silently reopen it. Recovery requires a strictly higher lease generation, +exposed to the owner as a new attempt, and its start cannot precede the prior +failed completion. Starting that attempt retains the cumulative affected count, +clears the current-attempt terminal result, and starts from the failed +execution's original cursor. +Already-applied records are recognized through their retention receipt and +tombstone, so the required rescan does not duplicate mutation or proof. + +A newly failed completion never advances the fair-scan checkpoint. The retry +therefore normally observes the unchanged starting ordinal and leaves the +checkpoint untouched until a non-failed completion succeeds. Compatibility +recovery for a legacy row that advanced on failure is deliberately narrow: the +execution must still be `Failed`; tenant, data class, and policy version must +match; retry time must be at or after the persisted completion; the checkpoint +must name that exact execution; and its update timestamp must equal that exact +completion timestamp. Only then is it rewound to the execution's starting +ordinal and disassociated from the failed execution. A checkpoint belonging to +another execution, carrying a different timestamp, or otherwise newer or +ambiguous fails closed. + +Owner mutation and completion timestamps are converted to UTC and truncated to +PostgreSQL's microsecond precision before persistence and response creation. +This includes non-UTC clocks and sub-microsecond .NET ticks, so the first result, +the durable execution/receipt, and exact replay expose the same completion +instant. + ### Fairness and efficiency - Scan the indexed, non-anonymised terminal set by monotonic `ProjectionOrdinal`. - Persist one cursor per tenant, data class, and execution-policy version. -- Read at most `ScanSize + 1` candidate heads and load bounded related facts - with set-based queries. +- Validate `ScanSize` independently at configuration and repository boundaries + as 1 through 1,000. Read at most `ScanSize + 1` candidate heads for end + detection, but fully materialize at most `ScanSize` candidates and load their + related facts with set-based queries. +- Materialize at most 64 governance acknowledgements per property, using + bounded count and limit-plus-one checks. A pre-existing overflow or a 65th + acknowledgement introduced between those queries withholds that property's + governance policy and produces a fail-closed policy-unavailable result. - Mutate at most `MutationBatchSize` records per occurrence. - Reset the cursor only after reaching the end, so a large tenant cannot starve later records and newly terminal earlier ordinals are picked up on the next @@ -138,6 +202,13 @@ owner projections and downstream module contracts remain coherent. ## Evidence +- The current local hardening pass is not publication or deployment evidence. + Its focused attempt, retry, timestamp, bounds, acknowledgement, eligibility, + and mutation unit suites pass 32/32; the combined Reservations catalogue, + Data Rights export, and tenant-termination suites pass 27/27; and the + documentation index, link, and solution guards pass 3/3. The PostgreSQL + provider scenario, complete repository matrix, hosted checks, and deployed + proof remain separate release gates for this amendment. - Focused Reservations retention tests pass 24/24, including domain, eligibility, contributor, mutation, policy-version cursor, append-only receipt, tenant boundary, and model constraints. diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Commands/ApplyReservationRetentionCommand.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Commands/ApplyReservationRetentionCommand.cs index d43bf959..99068bbd 100644 --- a/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Commands/ApplyReservationRetentionCommand.cs +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Commands/ApplyReservationRetentionCommand.cs @@ -4,6 +4,7 @@ namespace BunkFy.Modules.Reservations.Application.Commands; internal sealed record ApplyReservationRetentionCommand( Guid ExecutionId, + int Attempt, Guid PropertyId, Guid ReservationId, long ExpectedReservationVersion, diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Commands/CompleteReservationRetentionExecutionCommand.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Commands/CompleteReservationRetentionExecutionCommand.cs index d4951a34..7f338453 100644 --- a/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Commands/CompleteReservationRetentionExecutionCommand.cs +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Commands/CompleteReservationRetentionExecutionCommand.cs @@ -6,6 +6,7 @@ namespace BunkFy.Modules.Reservations.Application.Commands; internal sealed record CompleteReservationRetentionExecutionCommand( Guid ExecutionId, + int Attempt, ReservationRetentionExecutionState State, int ScannedCount, int RemainingCount, diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Contributors/ReservationRetentionContributor.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Contributors/ReservationRetentionContributor.cs index fc69f534..bf26fe2d 100644 --- a/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Contributors/ReservationRetentionContributor.cs +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Contributors/ReservationRetentionContributor.cs @@ -1,6 +1,7 @@ namespace BunkFy.Modules.Reservations.Application.Contributors; using BunkFy.Modules.Reservations.Application.Commands; +using BunkFy.Modules.Reservations.Application.Handlers; using BunkFy.Modules.Reservations.Application.Policies; using BunkFy.Modules.Reservations.Application.Ports; using BunkFy.Modules.Reservations.Domain.Retention; @@ -58,7 +59,8 @@ public async Task ExecuteAsync( remainingCount: 1, ReservationRetentionCoordinates .CoordinateInvalidOutcome, - this.clock.UtcNow); + ReservationMutationTime.Normalize( + this.clock.UtcNow)); } Result started = @@ -143,6 +145,7 @@ await this.candidates.ScanAsync( await this.dispatcher.SendAsync( new ApplyReservationRetentionCommand( request.ExecutionId, + request.Attempt, candidate.PropertyId, candidate.ReservationId, candidate.ReservationVersion, @@ -232,11 +235,13 @@ await this.dispatcher.SendAsync( await this.dispatcher.SendAsync( new CompleteReservationRetentionExecutionCommand( request.ExecutionId, + request.Attempt, state, scannedCount, remainingCount, outcomeCode, - this.clock.UtcNow, + ReservationMutationTime.Normalize( + this.clock.UtcNow), holdReviewDueAtUtc, start.StartingProjectionOrdinal, nextAfterProjectionOrdinal), diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Handlers/ApplyReservationRetentionCommandHandler.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Handlers/ApplyReservationRetentionCommandHandler.cs index f474f6b3..4eee8f4a 100644 --- a/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Handlers/ApplyReservationRetentionCommandHandler.cs +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Handlers/ApplyReservationRetentionCommandHandler.cs @@ -36,6 +36,7 @@ public async Task> scopeContext.IsEnabled ? scopeContext.ScopeId : null; if (string.IsNullOrWhiteSpace(tenantId) || command.ExecutionId == Guid.Empty || + command.Attempt < 1 || command.PropertyId == Guid.Empty || command.ReservationId == Guid.Empty || command.ExpectedReservationVersion < 1 || @@ -58,6 +59,7 @@ await executions.GetExecutionAsync( StringComparison.Ordinal) || execution.State != ReservationRetentionExecutionState.Running || + execution.Attempt != command.Attempt || !execution.MatchesCoordinate( ReservationRetentionCoordinates.DataClassKey, ReservationRetentionCoordinates @@ -123,7 +125,7 @@ await candidates.LoadAsync( } DateTimeOffset nowUtc = - ToPersistencePrecision(clock.UtcNow); + ReservationMutationTime.Normalize(clock.UtcNow); ReservationRetentionEligibilityResult decision = eligibility.Evaluate(snapshot, nowUtc); if (decision.Status == @@ -253,14 +255,4 @@ await executions.AddAnonymisationProofAsync( new ReservationRetentionMutationResult( ReservationRetentionMutationStatus.Applied)); } - - private static DateTimeOffset ToPersistencePrecision( - DateTimeOffset value) - { - const long ticksPerMicrosecond = - TimeSpan.TicksPerMillisecond / 1000; - return new( - value.Ticks - (value.Ticks % ticksPerMicrosecond), - value.Offset); - } } diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Handlers/BeginReservationRetentionExecutionCommandHandler.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Handlers/BeginReservationRetentionExecutionCommandHandler.cs index 1e51edcb..01cc98f5 100644 --- a/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Handlers/BeginReservationRetentionExecutionCommandHandler.cs +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Handlers/BeginReservationRetentionExecutionCommandHandler.cs @@ -100,10 +100,49 @@ await executions.AddExecutionAsync( .RetentionExecutionCoordinateInvalid); } else if ( - execution.State == - ReservationRetentionExecutionState.Running && + (execution.State is + ReservationRetentionExecutionState.Running or + ReservationRetentionExecutionState.Failed) && request.Attempt > execution.Attempt) { + Result retryable = execution.ValidateRetry( + request.Attempt, + request.StartedAtUtc, + request.DeadlineUtc); + if (retryable.IsFailure) + { + return Result.Failure< + ReservationRetentionExecutionStart>( + retryable.Error); + } + + if (execution.State == + ReservationRetentionExecutionState.Failed) + { + ReservationRetentionSweepCheckpoint? checkpoint = + await executions.GetCheckpointAsync( + ReservationRetentionCoordinates.DataClassKey, + request.ExecutionPolicyVersion, + cancellationToken).ConfigureAwait(false); + if (checkpoint is null) + { + return Result.Failure< + ReservationRetentionExecutionStart>( + ReservationsApplicationErrors + .RetentionProofConflict); + } + + Result prepared = checkpoint.PrepareRetry( + execution, + request.StartedAtUtc); + if (prepared.IsFailure) + { + return Result.Failure< + ReservationRetentionExecutionStart>( + prepared.Error); + } + } + Result retried = execution.BeginRetry( request.Attempt, request.StartedAtUtc, @@ -116,8 +155,9 @@ await executions.AddExecutionAsync( } } else if ( - execution.State == - ReservationRetentionExecutionState.Running && + (execution.State is + ReservationRetentionExecutionState.Running or + ReservationRetentionExecutionState.Failed) && request.Attempt != execution.Attempt) { return Result.Failure< diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Handlers/CompleteReservationRetentionExecutionCommandHandler.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Handlers/CompleteReservationRetentionExecutionCommandHandler.cs index 7a0e195d..35d8016f 100644 --- a/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Handlers/CompleteReservationRetentionExecutionCommandHandler.cs +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Handlers/CompleteReservationRetentionExecutionCommandHandler.cs @@ -41,12 +41,16 @@ await executions.GetCheckpointAsync( ReservationsApplicationErrors.RetentionProofConflict); } + DateTimeOffset completedAtUtc = + ReservationMutationTime.Normalize( + command.CompletedAtUtc); Result completed = execution.Complete( command.State, + command.Attempt, command.ScannedCount, command.RemainingCount, command.OutcomeCode, - command.CompletedAtUtc, + completedAtUtc, command.HoldReviewDueAtUtc); if (completed.IsFailure) { @@ -54,11 +58,19 @@ await executions.GetCheckpointAsync( completed.Error); } + if (command.State == + ReservationRetentionExecutionState.Failed) + { + return Result.Success( + BeginReservationRetentionExecutionCommandHandler + .ToResult(execution)); + } + Result advanced = checkpoint.Advance( command.ExpectedAfterProjectionOrdinal, command.NextAfterProjectionOrdinal, command.ExecutionId, - command.CompletedAtUtc); + completedAtUtc); return advanced.IsFailure ? Result.Failure( advanced.Error) diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Handlers/ReservationMutationTime.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Handlers/ReservationMutationTime.cs new file mode 100644 index 00000000..f248c138 --- /dev/null +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Handlers/ReservationMutationTime.cs @@ -0,0 +1,13 @@ +namespace BunkFy.Modules.Reservations.Application.Handlers; + +internal static class ReservationMutationTime +{ + public static DateTimeOffset Normalize(DateTimeOffset value) + { + const long ticksPerMicrosecond = TimeSpan.TicksPerMillisecond / 1000; + DateTimeOffset utc = value.ToUniversalTime(); + return new( + utc.Ticks - (utc.Ticks % ticksPerMicrosecond), + TimeSpan.Zero); + } +} diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/ReservationRetentionOptions.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/ReservationRetentionOptions.cs index f0d9d555..3290ece0 100644 --- a/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/ReservationRetentionOptions.cs +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/ReservationRetentionOptions.cs @@ -3,6 +3,7 @@ namespace BunkFy.Modules.Reservations.Application; public sealed class ReservationRetentionOptions { public const string SectionName = "Reservations:Retention"; + internal const int MaximumScanSize = 1000; public int IntervalMinutes { get; set; } = 60; public int ScanSize { get; set; } = 100; diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/ReservationRetentionOptionsValidator.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/ReservationRetentionOptionsValidator.cs index 403f7f33..e74a4bef 100644 --- a/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/ReservationRetentionOptionsValidator.cs +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/ReservationRetentionOptionsValidator.cs @@ -16,10 +16,13 @@ public ValidateOptionsResult Validate( $"{ReservationRetentionOptions.SectionName}:IntervalMinutes must be between 15 and 1440."); } - if (options.ScanSize is < 1 or > 1000) + if (options.ScanSize is < 1 or > + ReservationRetentionOptions.MaximumScanSize) { failures.Add( - $"{ReservationRetentionOptions.SectionName}:ScanSize must be between 1 and 1000."); + $"{ReservationRetentionOptions.SectionName}:ScanSize must " + + $"be between 1 and " + + $"{ReservationRetentionOptions.MaximumScanSize}."); } if (options.MutationBatchSize is < 1 or > 250) diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Domain/Retention/ReservationRetentionExecution.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Domain/Retention/ReservationRetentionExecution.cs index 9e1a8373..e4b5cb09 100644 --- a/src/Modules/Reservations/BunkFy.Modules.Reservations.Domain/Retention/ReservationRetentionExecution.cs +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Domain/Retention/ReservationRetentionExecution.cs @@ -77,24 +77,51 @@ public Result BeginRetry( DateTimeOffset startedAtUtc, DateTimeOffset deadlineUtc) { - if (this.State != ReservationRetentionExecutionState.Running || - attempt <= this.Attempt || - startedAtUtc == default || - startedAtUtc < this.StartedAtUtc || - deadlineUtc <= startedAtUtc) + Result valid = this.ValidateRetry( + attempt, + startedAtUtc, + deadlineUtc); + if (valid.IsFailure) { - return Result.Failure( - ReservationsDomainErrors - .RetentionExecutionTransitionInvalid); + return valid; } this.Attempt = attempt; + this.State = ReservationRetentionExecutionState.Running; this.StartedAtUtc = startedAtUtc; this.DeadlineUtc = deadlineUtc; + this.CompletedAtUtc = null; + this.ScannedCount = null; + this.RemainingCount = null; + this.OutcomeCode = null; + this.HoldReviewDueAtUtc = null; this.Version++; return Result.Success(); } + public Result ValidateRetry( + int attempt, + DateTimeOffset startedAtUtc, + DateTimeOffset deadlineUtc) + { + bool failedWindowInvalid = + this.State == ReservationRetentionExecutionState.Failed && + (!this.CompletedAtUtc.HasValue || + startedAtUtc < this.CompletedAtUtc.Value); + return this.State is not ( + ReservationRetentionExecutionState.Running or + ReservationRetentionExecutionState.Failed) || + attempt <= this.Attempt || + startedAtUtc == default || + startedAtUtc < this.StartedAtUtc || + deadlineUtc <= startedAtUtc || + failedWindowInvalid + ? Result.Failure( + ReservationsDomainErrors + .RetentionExecutionTransitionInvalid) + : Result.Success(); + } + public Result RecordAffected() { if (this.State != ReservationRetentionExecutionState.Running) @@ -111,6 +138,7 @@ public Result RecordAffected() public Result Complete( ReservationRetentionExecutionState state, + int attempt, int scannedCount, int remainingCount, string outcomeCode, @@ -124,6 +152,7 @@ public Result Complete( ReservationRetentionExecutionState.Completed or ReservationRetentionExecutionState.Blocked or ReservationRetentionExecutionState.Failed) || + attempt != this.Attempt || scannedCount < 0 || this.AffectedCount > scannedCount || remainingCount < 0 || @@ -140,6 +169,7 @@ ReservationRetentionExecutionState.Blocked or { return this.MatchesResult( state, + attempt, scannedCount, remainingCount, normalized, @@ -172,12 +202,14 @@ public bool MatchesCoordinate( private bool MatchesResult( ReservationRetentionExecutionState state, + int attempt, int scannedCount, int remainingCount, string outcomeCode, DateTimeOffset completedAtUtc, DateTimeOffset? holdReviewDueAtUtc) => this.State == state && + this.Attempt == attempt && this.ScannedCount == scannedCount && this.RemainingCount == remainingCount && string.Equals( diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Domain/Retention/ReservationRetentionSweepCheckpoint.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Domain/Retention/ReservationRetentionSweepCheckpoint.cs index 43c3650b..20b67066 100644 --- a/src/Modules/Reservations/BunkFy.Modules.Reservations.Domain/Retention/ReservationRetentionSweepCheckpoint.cs +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Domain/Retention/ReservationRetentionSweepCheckpoint.cs @@ -89,4 +89,54 @@ public Result Advance( this.Version++; return Result.Success(); } + + public Result PrepareRetry( + ReservationRetentionExecution failedExecution, + DateTimeOffset retryStartedAtUtc) + { + if (failedExecution.State != + ReservationRetentionExecutionState.Failed || + !string.Equals( + this.ScopeId, + failedExecution.ScopeId, + StringComparison.Ordinal) || + !string.Equals( + this.DataClassKey, + failedExecution.DataClassKey, + StringComparison.Ordinal) || + this.ExecutionPolicyVersion != + failedExecution.ExecutionPolicyVersion || + !failedExecution.CompletedAtUtc.HasValue || + retryStartedAtUtc == default || + retryStartedAtUtc < failedExecution.CompletedAtUtc.Value) + { + return Result.Failure( + ReservationsDomainErrors.RetentionCheckpointConflict); + } + + if (this.LastExecutionId == failedExecution.Id) + { + if (this.UpdatedAtUtc != + failedExecution.CompletedAtUtc.Value) + { + return Result.Failure( + ReservationsDomainErrors + .RetentionCheckpointConflict); + } + + this.AfterProjectionOrdinal = + failedExecution.StartingProjectionOrdinal; + this.LastExecutionId = null; + this.UpdatedAtUtc = retryStartedAtUtc; + this.Version++; + return Result.Success(); + } + + return this.AfterProjectionOrdinal == + failedExecution.StartingProjectionOrdinal && + this.UpdatedAtUtc <= failedExecution.StartedAtUtc + ? Result.Success() + : Result.Failure( + ReservationsDomainErrors.RetentionCheckpointConflict); + } } diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/Repositories/ReservationRetentionCandidateRepository.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/Repositories/ReservationRetentionCandidateRepository.cs index 54640d92..c9e7f6af 100644 --- a/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/Repositories/ReservationRetentionCandidateRepository.cs +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/Repositories/ReservationRetentionCandidateRepository.cs @@ -1,6 +1,7 @@ namespace BunkFy.Modules.Reservations.Persistence.Repositories; using BunkFy.Modules.Properties.Contracts; +using BunkFy.Modules.Reservations.Application; using BunkFy.Modules.Reservations.Application.Ports; using BunkFy.Modules.Reservations.Domain.Aggregates; using BunkFy.Modules.Reservations.Domain.Models; @@ -15,12 +16,14 @@ public async Task ScanAsync( int limit, CancellationToken cancellationToken) { - if (afterProjectionOrdinal < 0 || limit < 1) + if (afterProjectionOrdinal < 0 || + limit is < 1 or > + ReservationRetentionOptions.MaximumScanSize) { throw new ArgumentOutOfRangeException( - limit < 1 - ? nameof(limit) - : nameof(afterProjectionOrdinal)); + afterProjectionOrdinal < 0 + ? nameof(afterProjectionOrdinal) + : nameof(limit)); } IQueryable candidates = this.QueryCandidates() @@ -69,10 +72,13 @@ await this.ProjectHeads(candidates) .Single(); } - private IQueryable QueryCandidates() => - dbContext.Reservations + private IQueryable QueryCandidates() + { + string scopeId = dbContext.CurrentScopeId; + return dbContext.Reservations .AsNoTracking() .Where(reservation => + reservation.ScopeId == scopeId && !reservation.IsAnonymised && reservation.TerminalAtUtc != null && (reservation.Status == @@ -80,10 +86,13 @@ private IQueryable QueryCandidates() => reservation.Status == ReservationState.Cancelled || reservation.Status == ReservationState.NoShow || reservation.Status == ReservationState.CheckedOut)); + } private IQueryable ProjectHeads( - IQueryable candidates) => - candidates.Select(reservation => new ReservationRetentionHead( + IQueryable candidates) + { + string scopeId = dbContext.CurrentScopeId; + return candidates.Select(reservation => new ReservationRetentionHead( reservation.Id, reservation.PropertyId, reservation.Version, @@ -94,11 +103,13 @@ private IQueryable ProjectHeads( reservation.IsAnonymised, reservation.TerminalAtUtc, dbContext.DataHolds.Count(hold => + hold.ScopeId == scopeId && hold.PropertyId == reservation.PropertyId && hold.ReservationId == reservation.Id && hold.State == ReservationDataHoldState.Active), dbContext.DataHolds .Where(hold => + hold.ScopeId == scopeId && hold.PropertyId == reservation.PropertyId && hold.ReservationId == reservation.Id && hold.State == @@ -108,12 +119,14 @@ private IQueryable ProjectHeads( .Min(), dbContext.ProcessingRestrictionProjections .Where(projection => + projection.ScopeId == scopeId && projection.PropertyId == reservation.PropertyId && projection.ReservationId == reservation.Id) .Select(projection => (int?)projection.ContractVersion) .SingleOrDefault())); + } private async Task< IReadOnlyList> @@ -130,19 +143,145 @@ private async Task< .Select(head => head.PropertyId) .Distinct() .ToArray(); - ReservationPropertyProjection[] properties = + string scopeId = dbContext.CurrentScopeId; + var propertySqlRows = await dbContext.PropertyProjections .AsNoTracking() - .Include(property => property.GovernancePolicy) - .ThenInclude(policy => - policy!.Acknowledgements) - .Where(property => propertyIds.Contains(property.Id)) + .Where(property => + property.ScopeId == scopeId && + propertyIds.Contains(property.Id)) + .SelectMany( + property => property.GovernancePolicy! + .Acknowledgements + .OrderBy(acknowledgement => + acknowledgement.AcknowledgementId) + .ThenBy(acknowledgement => + acknowledgement.AcknowledgementVersion) + .Select(acknowledgement => new + { + // Retain the outer correlation so Npgsql emits a + // per-property LATERAL LIMIT instead of a global + // acknowledgement ROW_NUMBER window. + CorrelatedPropertyId = + (Guid?)property.Id, + acknowledgement.AcknowledgementId, + AcknowledgementVersion = + (int?)acknowledgement + .AcknowledgementVersion + }) + .Take(PropertiesContractLimits + .MaximumPolicyAcknowledgements + 1) + .DefaultIfEmpty(), + (property, acknowledgement) => + new + { + PropertyId = property.Id, + property.IsKnown, + property.IsActive, + property.ProcessingStatus, + property.TopologySourceVersion, + property.PolicySourceVersion, + HasGovernancePolicy = + property.GovernancePolicy != null, + OperatingCountryCode = + property.GovernancePolicy == null + ? null + : property.GovernancePolicy + .OperatingCountryCode, + PolicyId = property.GovernancePolicy == null + ? null + : property.GovernancePolicy.PolicyId, + PolicyVersion = property.GovernancePolicy == null + ? null + : (int?)property.GovernancePolicy + .PolicyVersion, + DataRegionId = property.GovernancePolicy == null + ? null + : property.GovernancePolicy.DataRegionId, + TransferProfileId = + property.GovernancePolicy == null + ? null + : property.GovernancePolicy + .TransferProfileId, + RetentionPolicyId = + property.GovernancePolicy == null + ? null + : property.GovernancePolicy + .RetentionPolicyId, + RetentionPolicyVersion = + property.GovernancePolicy == null + ? null + : (int?)property.GovernancePolicy + .RetentionPolicyVersion, + ContentSha256 = + property.GovernancePolicy == null + ? null + : property.GovernancePolicy.ContentSha256, + PolicyEffectiveAtUtc = + property.GovernancePolicy == null + ? null + : (DateTimeOffset?)property.GovernancePolicy + .PolicyEffectiveAtUtc, + PolicyExpiresAtUtc = + property.GovernancePolicy == null + ? null + : (DateTimeOffset?)property.GovernancePolicy + .PolicyExpiresAtUtc, + ActivatedAtUtc = + property.GovernancePolicy == null + ? null + : (DateTimeOffset?)property.GovernancePolicy + .ActivatedAtUtc, + AcknowledgementPropertyId = + acknowledgement == null + ? null + : acknowledgement.CorrelatedPropertyId, + AcknowledgementId = acknowledgement == null + ? null + : acknowledgement.AcknowledgementId, + AcknowledgementVersion = + acknowledgement == null + ? null + : acknowledgement + .AcknowledgementVersion + }) + .OrderBy(row => row.PropertyId) + .ThenBy(row => row.AcknowledgementId) + .ThenBy(row => row.AcknowledgementVersion) .ToArrayAsync(cancellationToken) .ConfigureAwait(false); + ReservationRetentionPropertyPolicyRow[] propertyRows = + propertySqlRows + .Select(row => + new ReservationRetentionPropertyPolicyRow( + row.PropertyId, + row.IsKnown, + row.IsActive, + row.ProcessingStatus, + row.TopologySourceVersion, + row.PolicySourceVersion, + row.HasGovernancePolicy, + row.OperatingCountryCode, + row.PolicyId, + row.PolicyVersion, + row.DataRegionId, + row.TransferProfileId, + row.RetentionPolicyId, + row.RetentionPolicyVersion, + row.ContentSha256, + row.PolicyEffectiveAtUtc, + row.PolicyExpiresAtUtc, + row.ActivatedAtUtc, + row.AcknowledgementPropertyId, + row.AcknowledgementId, + row.AcknowledgementVersion)) + .ToArray(); Dictionary - propertyById = properties.ToDictionary( - property => property.Id, - MapProperty); + propertyById = propertyRows + .GroupBy(row => row.PropertyId) + .ToDictionary( + group => group.Key, + group => CreateProperty(group.ToArray())); return heads .Select(head => @@ -164,37 +303,60 @@ await dbContext.PropertyProjections .ToArray(); } - private static ReservationRetentionPropertySnapshot MapProperty( - ReservationPropertyProjection property) => - new( + private static ReservationRetentionPropertySnapshot CreateProperty( + IReadOnlyList rows) + { + ReservationRetentionPropertyPolicyRow property = rows[0]; + ReservationRetentionPropertyPolicyRow[] acknowledgements = rows + .Where(row => row.AcknowledgementPropertyId.HasValue) + .ToArray(); + return new( property.IsKnown, property.IsActive, property.ProcessingStatus, property.TopologySourceVersion, property.PolicySourceVersion, - MapPolicy(property.GovernancePolicy)); + CreatePolicy(property, acknowledgements)); + } - private static PropertyGovernancePolicyBinding? MapPolicy( - ReservationPropertyPolicyBinding? policy) => - policy is null - ? null - : new( - policy.OperatingCountryCode, - policy.PolicyId, - policy.PolicyVersion, - policy.DataRegionId, - policy.TransferProfileId, - policy.RetentionPolicyId, - policy.RetentionPolicyVersion, - policy.ContentSha256, - policy.PolicyEffectiveAtUtc, - policy.PolicyExpiresAtUtc, - policy.ActivatedAtUtc, - policy.Acknowledgements.Select(acknowledgement => + private static PropertyGovernancePolicyBinding? CreatePolicy( + ReservationRetentionPropertyPolicyRow policy, + IReadOnlyList + acknowledgements) + { + if (!policy.HasGovernancePolicy || + acknowledgements.Count > + PropertiesContractLimits.MaximumPolicyAcknowledgements) + { + return null; + } + + try + { + return new( + policy.OperatingCountryCode!, + policy.PolicyId!, + policy.PolicyVersion!.Value, + policy.DataRegionId!, + policy.TransferProfileId!, + policy.RetentionPolicyId!, + policy.RetentionPolicyVersion!.Value, + policy.ContentSha256!, + policy.PolicyEffectiveAtUtc!.Value, + policy.PolicyExpiresAtUtc!.Value, + policy.ActivatedAtUtc!.Value, + acknowledgements.Select(acknowledgement => new PropertyGovernanceAcknowledgement( - acknowledgement.AcknowledgementId, - acknowledgement.AcknowledgementVersion)) + acknowledgement.AcknowledgementId!, + acknowledgement.AcknowledgementVersion!.Value)) .ToArray()); + } + catch (Exception exception) when ( + exception is ArgumentException or InvalidOperationException) + { + return null; + } + } private sealed record ReservationRetentionHead( Guid ReservationId, @@ -209,4 +371,27 @@ private sealed record ReservationRetentionHead( int ActiveHoldCount, DateTimeOffset? EarliestHoldPlacedAtUtc, int? ProcessingRestrictionContractVersion); + + private sealed record ReservationRetentionPropertyPolicyRow( + Guid PropertyId, + bool IsKnown, + bool IsActive, + PropertyProcessingStatus ProcessingStatus, + long TopologySourceVersion, + long PolicySourceVersion, + bool HasGovernancePolicy, + string? OperatingCountryCode, + string? PolicyId, + int? PolicyVersion, + string? DataRegionId, + string? TransferProfileId, + string? RetentionPolicyId, + int? RetentionPolicyVersion, + string? ContentSha256, + DateTimeOffset? PolicyEffectiveAtUtc, + DateTimeOffset? PolicyExpiresAtUtc, + DateTimeOffset? ActivatedAtUtc, + Guid? AcknowledgementPropertyId, + string? AcknowledgementId, + int? AcknowledgementVersion); } diff --git a/src/Modules/Reservations/docs/README.md b/src/Modules/Reservations/docs/README.md index b89ce9bf..bf0672eb 100644 --- a/src/Modules/Reservations/docs/README.md +++ b/src/Modules/Reservations/docs/README.md @@ -102,7 +102,25 @@ The Reservations retention contributor registers one tenant-scoped schedule for ordinal, mutates only a bounded batch, and persists its cursor and proof in the Reservations schema. `Reservations:Retention` may tune the interval, scan size, and mutation batch size; validated conservative defaults apply when the -section is omitted. +section is omitted. Scan size is independently restricted to 1 through 1,000 +at configuration and repository boundaries. The repository uses one-item +lookahead for end detection while materializing no more than the requested +candidate count. Governance hydration loads at most 64 acknowledgements per +property. A stored or concurrently introduced 65th acknowledgement makes that +property's policy unavailable, so the candidate fails closed without mutation. + +Every retention mutation and completion carries the active attempt. The owner +accepts it only while the matching execution is `Running` on that exact +attempt, so an older worker cannot mutate or complete a newer retry. A `Failed` +result is exact-replay evidence while its attempt remains current; recovery +requires a strictly higher attempt. New failures do not advance the sweep +checkpoint, and a retry rescans from the unchanged cursor while existing +receipts make already-applied mutations idempotent. Compatibility rewind exists +only for a legacy failed execution whose checkpoint names that same execution +and has the exact failure completion timestamp; any newer or ambiguous +checkpoint fails closed. Owner mutation and completion times are normalized to +UTC microsecond precision before persistence so the first PostgreSQL result and +its exact replay remain identical. ## Tenant Termination diff --git a/src/Modules/Reservations/docs/personal-data-catalog.v1.json b/src/Modules/Reservations/docs/personal-data-catalog.v1.json index b0c7be54..1b836088 100644 --- a/src/Modules/Reservations/docs/personal-data-catalog.v1.json +++ b/src/Modules/Reservations/docs/personal-data-catalog.v1.json @@ -13102,6 +13102,20 @@ "member": "HoldReviewDueAtUtc", "surface": "application-command" }, + { + "assembly": "BunkFy.Modules.Reservations.Application", + "type": "BunkFy.Modules.Reservations.Application.Commands.ApplyReservationRetentionCommand", + "member": "Attempt", + "surface": "application-command", + "retentionPolicy": "reservation-retention-execution-evidence" + }, + { + "assembly": "BunkFy.Modules.Reservations.Application", + "type": "BunkFy.Modules.Reservations.Application.Commands.CompleteReservationRetentionExecutionCommand", + "member": "Attempt", + "surface": "application-command", + "retentionPolicy": "reservation-retention-execution-evidence" + }, { "assembly": "BunkFy.Modules.Reservations.Application", "type": "BunkFy.Modules.Reservations.Application.Commands.CompleteReservationRetentionExecutionCommand", diff --git a/src/Modules/Reservations/docs/personal-data-inventory.v1.md b/src/Modules/Reservations/docs/personal-data-inventory.v1.md index 346891b4..29dd7d3b 100644 --- a/src/Modules/Reservations/docs/personal-data-inventory.v1.md +++ b/src/Modules/Reservations/docs/personal-data-inventory.v1.md @@ -1491,6 +1491,8 @@ Engineering metadata is not legal or country-launch approval. | reservation.retention.execution-coordinate | BunkFy.Modules.Reservations.Domain | BunkFy.Modules.Reservations.Domain.Retention.ReservationRetentionSweepCheckpoint | Id | persistence | reservation-retention-execution-evidence | | reservation.retention.execution-coordinate | BunkFy.Modules.Reservations.Domain | BunkFy.Modules.Reservations.Domain.Retention.ReservationRetentionSweepCheckpoint | LastExecutionId | persistence | reservation-retention-execution-evidence | | reservation.retention.execution-coordinate | BunkFy.Modules.Reservations.Domain | BunkFy.Modules.Reservations.Domain.Retention.ReservationRetentionSweepCheckpoint | ScopeId | persistence | reservation-retention-execution-evidence | +| reservation.retention.execution-lifecycle | BunkFy.Modules.Reservations.Application | BunkFy.Modules.Reservations.Application.Commands.ApplyReservationRetentionCommand | Attempt | application-command | reservation-retention-execution-evidence | +| reservation.retention.execution-lifecycle | BunkFy.Modules.Reservations.Application | BunkFy.Modules.Reservations.Application.Commands.CompleteReservationRetentionExecutionCommand | Attempt | application-command | reservation-retention-execution-evidence | | reservation.retention.execution-lifecycle | BunkFy.Modules.Reservations.Application | BunkFy.Modules.Reservations.Application.Commands.CompleteReservationRetentionExecutionCommand | CompletedAtUtc | application-command | reservation-retention-execution-evidence | | reservation.retention.execution-lifecycle | BunkFy.Modules.Reservations.Application | BunkFy.Modules.Reservations.Application.Commands.CompleteReservationRetentionExecutionCommand | ExpectedAfterProjectionOrdinal | application-command | reservation-retention-execution-evidence | | reservation.retention.execution-lifecycle | BunkFy.Modules.Reservations.Application | BunkFy.Modules.Reservations.Application.Commands.CompleteReservationRetentionExecutionCommand | HoldReviewDueAtUtc | application-command | reservation-retention-execution-evidence | diff --git a/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Application/ApplyReservationRetentionCommandHandlerTests.cs b/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Application/ApplyReservationRetentionCommandHandlerTests.cs index 167f3c53..2d31f9c7 100644 --- a/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Application/ApplyReservationRetentionCommandHandlerTests.cs +++ b/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Application/ApplyReservationRetentionCommandHandlerTests.cs @@ -26,6 +26,7 @@ public async Task Due_reservation_commits_record_and_both_proofs_once() Fixture fixture = CreateFixture("tenant-a"); ApplyReservationRetentionCommand command = new( fixture.Execution.Id, + Attempt: 1, fixture.Reservation.PropertyId, fixture.Reservation.Id, fixture.Reservation.Version, @@ -66,6 +67,31 @@ public async Task Cross_tenant_execution_is_rejected_before_discovery() await fixture.Handler.HandleAsync( new( fixture.Execution.Id, + Attempt: 1, + fixture.Reservation.PropertyId, + fixture.Reservation.Id, + fixture.Reservation.Version, + fixture.Reservation.DetailsRevision), + CancellationToken.None); + + Assert.Equal( + ReservationsApplicationErrors.RetentionExecutionNotFound, + result.Error); + Assert.Equal(0, fixture.Candidates.LoadCount); + Assert.Equal(0, fixture.OperationLock.CallCount); + Assert.False(fixture.Reservation.IsAnonymised); + } + + [Fact] + public async Task Stale_attempt_is_rejected_before_discovery() + { + Fixture fixture = CreateFixture("tenant-a"); + + Result result = + await fixture.Handler.HandleAsync( + new( + fixture.Execution.Id, + Attempt: 2, fixture.Reservation.PropertyId, fixture.Reservation.Id, fixture.Reservation.Version, @@ -96,6 +122,7 @@ fixture.Candidates.Snapshot with await fixture.Handler.HandleAsync( new( fixture.Execution.Id, + Attempt: 1, fixture.Reservation.PropertyId, fixture.Reservation.Id, fixture.Reservation.Version, diff --git a/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Application/ReservationRetentionConfigurationTests.cs b/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Application/ReservationRetentionConfigurationTests.cs index 08976edc..ed5f137d 100644 --- a/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Application/ReservationRetentionConfigurationTests.cs +++ b/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Application/ReservationRetentionConfigurationTests.cs @@ -23,6 +23,28 @@ public void Defaults_are_bounded_and_valid() Assert.Equal(60, options.IntervalMinutes); Assert.Equal(100, options.ScanSize); Assert.Equal(25, options.MutationBatchSize); + Assert.Equal(1000, ReservationRetentionOptions.MaximumScanSize); + } + + [Fact] + public void Scan_size_cannot_exceed_repository_bound() + { + ReservationRetentionOptions options = new() + { + ScanSize = ReservationRetentionOptions.MaximumScanSize + 1, + MutationBatchSize = 1 + }; + + ValidateOptionsResult validation = + new ReservationRetentionOptionsValidator() + .Validate(null, options); + + Assert.True(validation.Failed); + Assert.Contains( + validation.Failures, + failure => failure.Contains( + $"between 1 and {ReservationRetentionOptions.MaximumScanSize}", + StringComparison.Ordinal)); } [Fact] diff --git a/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Application/ReservationRetentionContributorTests.cs b/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Application/ReservationRetentionContributorTests.cs index 5b947a6c..c07c9a54 100644 --- a/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Application/ReservationRetentionContributorTests.cs +++ b/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Application/ReservationRetentionContributorTests.cs @@ -166,18 +166,65 @@ await contributor.ExecuteAsync( result.OutcomeCode); } + [Fact] + public async Task Attempt_and_utc_microsecond_completion_are_exact() + { + ReservationRetentionPolicyFixture policy = + ReservationRetentionTestData.CreatePolicy(); + FakeDispatcher dispatcher = new( + initialAffectedCount: 0, + new ReservationRetentionMutationResult( + ReservationRetentionMutationStatus.Applied)); + DateTimeOffset clockValue = + ReservationRetentionTestData.Now + .ToOffset(TimeSpan.FromHours(2)) + .AddTicks(7); + ReservationRetentionContributor contributor = + CreateContributor( + dispatcher, + new(new( + [ReservationRetentionTestData.Snapshot( + policy.Binding)], + ReachedEnd: true)), + new(), + policy, + clockValue); + + RetentionContributionResult result = + await contributor.ExecuteAsync( + Request(attempt: 2), + CancellationToken.None); + + ApplyReservationRetentionCommand applied = + Assert.IsType( + dispatcher.AppliedCommand); + CompleteReservationRetentionExecutionCommand completed = + Assert.IsType< + CompleteReservationRetentionExecutionCommand>( + dispatcher.CompletedCommand); + Assert.Equal(2, applied.Attempt); + Assert.Equal(2, completed.Attempt); + Assert.Equal( + ReservationRetentionTestData.Now, + completed.CompletedAtUtc); + Assert.Equal(TimeSpan.Zero, completed.CompletedAtUtc.Offset); + Assert.Equal(completed.CompletedAtUtc, result.CompletedAtUtc); + } + private static ReservationRetentionContributor CreateContributor( FakeDispatcher dispatcher, FakeCandidateRepository repository, ReservationRetentionOptions options, - ReservationRetentionPolicyFixture policy) => + ReservationRetentionPolicyFixture policy, + DateTimeOffset? nowUtc = null) => new( dispatcher, repository, new ReservationRetentionEligibilityEvaluator( policy.Registry), Options.Create(options), - new FakeClock()); + new FakeClock( + nowUtc ?? ReservationRetentionTestData.Now)); private static RetentionContributionRequest Request( int attempt = 1) => @@ -193,10 +240,10 @@ private static RetentionContributionRequest Request( ReservationRetentionTestData.Now.AddMinutes(-1), ReservationRetentionTestData.Now.AddMinutes(10)); - private sealed class FakeClock : ISystemClock + private sealed class FakeClock(DateTimeOffset nowUtc) + : ISystemClock { - public DateTimeOffset UtcNow => - ReservationRetentionTestData.Now; + public DateTimeOffset UtcNow => nowUtc; } private sealed class FakeCandidateRepository( @@ -225,6 +272,7 @@ private readonly Queue mutations = new(mutationResults); public int ApplyCount { get; private set; } + public object? AppliedCommand { get; private set; } public object? CompletedCommand { get; private set; } private int AppliedCount { get; set; } @@ -240,8 +288,8 @@ public Task> SendAsync( StartingProjectionOrdinal: 0, AffectedCount: initialAffectedCount, CompletedResult: null), - ApplyReservationRetentionCommand => - this.NextMutation(), + ApplyReservationRetentionCommand applied => + this.NextMutation(applied), CompleteReservationRetentionExecutionCommand completed => this.Complete(completed), _ => throw new NotSupportedException( @@ -256,9 +304,11 @@ public Task> QueryAsync( CancellationToken cancellationToken = default) => throw new NotSupportedException(); - private ReservationRetentionMutationResult NextMutation() + private ReservationRetentionMutationResult NextMutation( + ApplyReservationRetentionCommand command) { this.ApplyCount++; + this.AppliedCommand = command; ReservationRetentionMutationResult result = this.mutations.Dequeue(); if (result.Status == diff --git a/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Application/ReservationRetentionExecutionCommandHandlerTests.cs b/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Application/ReservationRetentionExecutionCommandHandlerTests.cs new file mode 100644 index 00000000..ab2f8084 --- /dev/null +++ b/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Application/ReservationRetentionExecutionCommandHandlerTests.cs @@ -0,0 +1,279 @@ +namespace BunkFy.Modules.Reservations.Tests; + +using BunkFy.Modules.Reservations.Application.Commands; +using BunkFy.Modules.Reservations.Application.Contributors; +using BunkFy.Modules.Reservations.Application.Handlers; +using BunkFy.Modules.Reservations.Application.Ports; +using BunkFy.Modules.Reservations.Domain.DataRights; +using BunkFy.Modules.Reservations.Domain.Retention; +using BunkFy.Modules.Retention.Contracts; +using Gma.Framework.Results; +using Gma.Framework.Runtime.Identity; +using Gma.Framework.Scoping; +using Xunit; + +[Trait("Category", "Unit")] +public sealed class ReservationRetentionExecutionCommandHandlerTests +{ + private const string TenantId = "tenant-a"; + private static readonly DateTimeOffset StartedAtUtc = + ReservationRetentionTestData.Now; + + [Fact] + public async Task Failed_completion_retries_and_fences_stale_attempt() + { + ReservationRetentionExecution execution = StartExecution(); + Assert.True(execution.RecordAffected().IsSuccess); + ReservationRetentionSweepCheckpoint checkpoint = + CreateCheckpoint(); + FakeExecutionRepository repository = new( + execution, + checkpoint); + CompleteReservationRetentionExecutionCommandHandler complete = + new(repository); + BeginReservationRetentionExecutionCommandHandler begin = new( + repository, + new TestScopeContext(), + new ThrowingIdGenerator()); + DateTimeOffset failedAtUtc = StartedAtUtc.AddMinutes(1); + + Result failed = + await complete.HandleAsync( + new( + execution.Id, + Attempt: 1, + ReservationRetentionExecutionState.Failed, + ScannedCount: 1, + RemainingCount: 1, + ReservationRetentionCoordinates + .MutationFailedOutcome, + failedAtUtc, + HoldReviewDueAtUtc: null, + ExpectedAfterProjectionOrdinal: 0, + NextAfterProjectionOrdinal: 42), + CancellationToken.None); + + Assert.True(failed.IsSuccess); + Assert.Equal(1, failed.Value.AffectedCount); + Assert.Equal( + ReservationRetentionExecutionState.Failed, + execution.State); + Assert.Equal(0, checkpoint.AfterProjectionOrdinal); + Assert.Null(checkpoint.LastExecutionId); + Assert.Equal(StartedAtUtc, checkpoint.UpdatedAtUtc); + + DateTimeOffset retryStartedAtUtc = + StartedAtUtc.AddMinutes(2); + Result retried = + await begin.HandleAsync( + new(Request( + execution.Id, + attempt: 2, + retryStartedAtUtc, + retryStartedAtUtc.AddMinutes(10))), + CancellationToken.None); + + Assert.True(retried.IsSuccess); + Assert.True(retried.Value.DispatchRequired); + Assert.Equal(0, retried.Value.StartingProjectionOrdinal); + Assert.Equal(1, retried.Value.AffectedCount); + Assert.Equal(2, execution.Attempt); + Assert.Equal( + ReservationRetentionExecutionState.Running, + execution.State); + + Result stale = + await complete.HandleAsync( + Completion( + execution.Id, + attempt: 1, + StartedAtUtc.AddMinutes(3)), + CancellationToken.None); + + Assert.Equal( + "Reservations.RetentionExecutionResultInvalid", + stale.Error.Code); + Assert.Equal( + ReservationRetentionExecutionState.Running, + execution.State); + Assert.Equal(0, checkpoint.AfterProjectionOrdinal); + + Result completed = + await complete.HandleAsync( + Completion( + execution.Id, + attempt: 2, + StartedAtUtc.AddMinutes(3)), + CancellationToken.None); + + Assert.True(completed.IsSuccess); + Assert.Equal( + ReservationRetentionExecutionState.Completed, + execution.State); + Assert.Equal(42, checkpoint.AfterProjectionOrdinal); + Assert.Equal(execution.Id, checkpoint.LastExecutionId); + } + + [Fact] + public async Task Begin_retry_rewinds_a_proven_legacy_checkpoint() + { + ReservationRetentionExecution execution = StartExecution(); + DateTimeOffset failedAtUtc = StartedAtUtc.AddMinutes(1); + Assert.True(execution.Complete( + ReservationRetentionExecutionState.Failed, + attempt: 1, + scannedCount: 0, + remainingCount: 1, + ReservationRetentionCoordinates.MutationFailedOutcome, + failedAtUtc, + holdReviewDueAtUtc: null).IsSuccess); + ReservationRetentionSweepCheckpoint checkpoint = + CreateCheckpoint(); + Assert.True(checkpoint.Advance( + expectedAfterProjectionOrdinal: 0, + nextAfterProjectionOrdinal: 42, + execution.Id, + failedAtUtc).IsSuccess); + FakeExecutionRepository repository = new( + execution, + checkpoint); + BeginReservationRetentionExecutionCommandHandler begin = new( + repository, + new TestScopeContext(), + new ThrowingIdGenerator()); + DateTimeOffset retryStartedAtUtc = + StartedAtUtc.AddMinutes(2); + + Result retried = + await begin.HandleAsync( + new(Request( + execution.Id, + attempt: 2, + retryStartedAtUtc, + retryStartedAtUtc.AddMinutes(10))), + CancellationToken.None); + + Assert.True(retried.IsSuccess); + Assert.True(retried.Value.DispatchRequired); + Assert.Equal(0, checkpoint.AfterProjectionOrdinal); + Assert.Null(checkpoint.LastExecutionId); + Assert.Equal(retryStartedAtUtc, checkpoint.UpdatedAtUtc); + Assert.Equal(2, execution.Attempt); + Assert.Equal( + ReservationRetentionExecutionState.Running, + execution.State); + } + + private static ReservationRetentionExecution StartExecution() => + ReservationRetentionExecution.Start( + Guid.NewGuid(), + TenantId, + ReservationRetentionCoordinates.DataClassKey, + ReservationRetentionCoordinates.ExecutionPolicyVersion, + attempt: 1, + startingProjectionOrdinal: 0, + StartedAtUtc, + StartedAtUtc.AddMinutes(15)).Value; + + private static ReservationRetentionSweepCheckpoint + CreateCheckpoint() => + ReservationRetentionSweepCheckpoint.Create( + Guid.NewGuid(), + TenantId, + ReservationRetentionCoordinates.DataClassKey, + ReservationRetentionCoordinates.ExecutionPolicyVersion, + StartedAtUtc).Value; + + private static RetentionContributionRequest Request( + Guid executionId, + int attempt, + DateTimeOffset startedAtUtc, + DateTimeOffset deadlineUtc) => + new( + RetentionExecutionContract.CurrentVersion, + executionId, + TenantId, + PropertyId: null, + ReservationRetentionCoordinates.OwnerKey, + ReservationRetentionCoordinates.DataClassKey, + ReservationRetentionCoordinates.ExecutionPolicyVersion, + attempt, + startedAtUtc, + deadlineUtc); + + private static CompleteReservationRetentionExecutionCommand + Completion( + Guid executionId, + int attempt, + DateTimeOffset completedAtUtc) => + new( + executionId, + attempt, + ReservationRetentionExecutionState.Completed, + ScannedCount: 1, + RemainingCount: 0, + ReservationRetentionCoordinates.CompletedOutcome, + completedAtUtc, + HoldReviewDueAtUtc: null, + ExpectedAfterProjectionOrdinal: 0, + NextAfterProjectionOrdinal: 42); + + private sealed class FakeExecutionRepository( + ReservationRetentionExecution execution, + ReservationRetentionSweepCheckpoint checkpoint) + : IReservationRetentionExecutionRepository + { + public Task GetExecutionAsync( + Guid executionId, + CancellationToken cancellationToken) => + Task.FromResult( + execution.Id == executionId ? execution : null); + + public Task AddExecutionAsync( + ReservationRetentionExecution added, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public Task + GetCheckpointAsync( + string dataClassKey, + int executionPolicyVersion, + CancellationToken cancellationToken) => + Task.FromResult< + ReservationRetentionSweepCheckpoint?>(checkpoint); + + public Task AddCheckpointAsync( + ReservationRetentionSweepCheckpoint added, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public Task + GetReceiptAsync( + Guid reservationId, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public Task + GetTombstoneAsync( + Guid reservationId, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public Task AddAnonymisationProofAsync( + ReservationRetentionAnonymisationReceipt receipt, + ReservationAnonymisationTombstone tombstone, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + } + + private sealed class TestScopeContext : IScopeContext + { + public bool IsEnabled => true; + public string ScopeId => TenantId; + } + + private sealed class ThrowingIdGenerator : IIdGenerator + { + public Guid NewId() => throw new NotSupportedException(); + } +} diff --git a/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Domain/ReservationRetentionDomainTests.cs b/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Domain/ReservationRetentionDomainTests.cs index abb4d9f8..8923ee52 100644 --- a/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Domain/ReservationRetentionDomainTests.cs +++ b/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Domain/ReservationRetentionDomainTests.cs @@ -23,6 +23,7 @@ public void Terminal_execution_cannot_underreport_scanned_work() "Reservations.RetentionExecutionResultInvalid", execution.Complete( ReservationRetentionExecutionState.Completed, + attempt: 1, scannedCount: 1, remainingCount: 0, "reservations.reservation-operational.completed", @@ -30,6 +31,7 @@ public void Terminal_execution_cannot_underreport_scanned_work() holdReviewDueAtUtc: null).Error.Code); Assert.True(execution.Complete( ReservationRetentionExecutionState.Completed, + attempt: 1, scannedCount: 2, remainingCount: 0, "reservations.reservation-operational.completed", @@ -54,6 +56,71 @@ public void Retry_must_move_execution_time_forward() Now.AddMinutes(11)).IsSuccess); } + [Fact] + public void Failed_execution_retries_without_losing_affected_count() + { + ReservationRetentionExecution execution = Start(); + Assert.True(execution.RecordAffected().IsSuccess); + Assert.True(execution.RecordAffected().IsSuccess); + Assert.True(execution.Complete( + ReservationRetentionExecutionState.Failed, + attempt: 1, + scannedCount: 2, + remainingCount: 1, + "reservations.reservation-operational.mutation-failed", + Now.AddMinutes(1), + holdReviewDueAtUtc: null).IsSuccess); + + Assert.True(execution.BeginRetry( + attempt: 2, + Now.AddMinutes(2), + Now.AddMinutes(12)).IsSuccess); + + Assert.Equal(2, execution.Attempt); + Assert.Equal( + ReservationRetentionExecutionState.Running, + execution.State); + Assert.Equal(0, execution.StartingProjectionOrdinal); + Assert.Equal(2, execution.AffectedCount); + Assert.Null(execution.CompletedAtUtc); + Assert.Null(execution.ScannedCount); + Assert.Null(execution.RemainingCount); + Assert.Null(execution.OutcomeCode); + Assert.Null(execution.HoldReviewDueAtUtc); + } + + [Fact] + public void Completion_is_fenced_to_the_active_attempt() + { + ReservationRetentionExecution execution = Start(); + Assert.True(execution.BeginRetry( + attempt: 2, + Now.AddMinutes(1), + Now.AddMinutes(11)).IsSuccess); + + Assert.Equal( + "Reservations.RetentionExecutionResultInvalid", + execution.Complete( + ReservationRetentionExecutionState.Completed, + attempt: 1, + scannedCount: 0, + remainingCount: 0, + "reservations.reservation-operational.completed", + Now.AddMinutes(2), + holdReviewDueAtUtc: null).Error.Code); + Assert.Equal( + ReservationRetentionExecutionState.Running, + execution.State); + Assert.True(execution.Complete( + ReservationRetentionExecutionState.Completed, + attempt: 2, + scannedCount: 0, + remainingCount: 0, + "reservations.reservation-operational.completed", + Now.AddMinutes(2), + holdReviewDueAtUtc: null).IsSuccess); + } + [Fact] public void Checkpoint_advance_is_optimistic_and_idempotent() { @@ -86,6 +153,124 @@ public void Checkpoint_advance_is_optimistic_and_idempotent() Now.AddMinutes(1)).Error.Code); } + [Fact] + public void Legacy_failed_checkpoint_rewinds_only_its_execution() + { + ReservationRetentionExecution execution = Start(); + DateTimeOffset completedAtUtc = Now.AddMinutes(1); + Assert.True(execution.Complete( + ReservationRetentionExecutionState.Failed, + attempt: 1, + scannedCount: 0, + remainingCount: 1, + "reservations.reservation-operational.mutation-failed", + completedAtUtc, + holdReviewDueAtUtc: null).IsSuccess); + ReservationRetentionSweepCheckpoint checkpoint = + CreateCheckpoint(); + Assert.True(checkpoint.Advance( + expectedAfterProjectionOrdinal: 0, + nextAfterProjectionOrdinal: 42, + execution.Id, + completedAtUtc).IsSuccess); + DateTimeOffset retryStartedAtUtc = Now.AddMinutes(2); + + Assert.True(checkpoint.PrepareRetry( + execution, + retryStartedAtUtc).IsSuccess); + + Assert.Equal( + execution.StartingProjectionOrdinal, + checkpoint.AfterProjectionOrdinal); + Assert.Null(checkpoint.LastExecutionId); + Assert.Equal(retryStartedAtUtc, checkpoint.UpdatedAtUtc); + } + + [Fact] + public void Post_fix_failed_checkpoint_is_already_retry_safe() + { + ReservationRetentionExecution execution = Start(); + Assert.True(execution.Complete( + ReservationRetentionExecutionState.Failed, + attempt: 1, + scannedCount: 0, + remainingCount: 1, + "reservations.reservation-operational.mutation-failed", + Now.AddMinutes(1), + holdReviewDueAtUtc: null).IsSuccess); + ReservationRetentionSweepCheckpoint checkpoint = + CreateCheckpoint(); + long checkpointVersion = checkpoint.Version; + + Assert.True(checkpoint.PrepareRetry( + execution, + Now.AddMinutes(2)).IsSuccess); + + Assert.Equal(0, checkpoint.AfterProjectionOrdinal); + Assert.Null(checkpoint.LastExecutionId); + Assert.Equal(Now, checkpoint.UpdatedAtUtc); + Assert.Equal(checkpointVersion, checkpoint.Version); + } + + [Fact] + public void Retry_rejects_checkpoint_advanced_by_another_execution() + { + ReservationRetentionExecution execution = Start(); + Assert.True(execution.Complete( + ReservationRetentionExecutionState.Failed, + attempt: 1, + scannedCount: 0, + remainingCount: 1, + "reservations.reservation-operational.mutation-failed", + Now.AddMinutes(1), + holdReviewDueAtUtc: null).IsSuccess); + ReservationRetentionSweepCheckpoint checkpoint = + CreateCheckpoint(); + Guid otherExecutionId = Guid.NewGuid(); + Assert.True(checkpoint.Advance( + expectedAfterProjectionOrdinal: 0, + nextAfterProjectionOrdinal: 0, + otherExecutionId, + Now.AddMinutes(2)).IsSuccess); + + Assert.Equal( + "Reservations.RetentionCheckpointConflict", + checkpoint.PrepareRetry( + execution, + Now.AddMinutes(3)).Error.Code); + Assert.Equal(otherExecutionId, checkpoint.LastExecutionId); + } + + [Fact] + public void Legacy_retry_requires_the_atomic_completion_timestamp() + { + ReservationRetentionExecution execution = Start(); + DateTimeOffset completedAtUtc = Now.AddMinutes(1); + Assert.True(execution.Complete( + ReservationRetentionExecutionState.Failed, + attempt: 1, + scannedCount: 0, + remainingCount: 1, + "reservations.reservation-operational.mutation-failed", + completedAtUtc, + holdReviewDueAtUtc: null).IsSuccess); + ReservationRetentionSweepCheckpoint checkpoint = + CreateCheckpoint(); + Assert.True(checkpoint.Advance( + expectedAfterProjectionOrdinal: 0, + nextAfterProjectionOrdinal: 42, + execution.Id, + completedAtUtc.AddTicks(10)).IsSuccess); + + Assert.Equal( + "Reservations.RetentionCheckpointConflict", + checkpoint.PrepareRetry( + execution, + Now.AddMinutes(2)).Error.Code); + Assert.Equal(42, checkpoint.AfterProjectionOrdinal); + Assert.Equal(execution.Id, checkpoint.LastExecutionId); + } + [Fact] public void Retention_tombstone_cannot_enter_data_rights_restore_path() { @@ -178,4 +363,13 @@ private static ReservationRetentionExecution Start() => startingProjectionOrdinal: 0, Now, Now.AddMinutes(15)).Value; + + private static ReservationRetentionSweepCheckpoint + CreateCheckpoint() => + ReservationRetentionSweepCheckpoint.Create( + Guid.NewGuid(), + "tenant-a", + "reservation-operational", + executionPolicyVersion: 1, + Now).Value; } diff --git a/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Persistence/ReservationRetentionCandidateRepositoryTests.cs b/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Persistence/ReservationRetentionCandidateRepositoryTests.cs index 70439033..f6318537 100644 --- a/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Persistence/ReservationRetentionCandidateRepositoryTests.cs +++ b/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Persistence/ReservationRetentionCandidateRepositoryTests.cs @@ -1,6 +1,8 @@ namespace BunkFy.Modules.Reservations.Tests; +using System.Reflection; using BunkFy.Modules.Properties.Contracts; +using BunkFy.Modules.Reservations.Application; using BunkFy.Modules.Reservations.Application.Ports; using BunkFy.Modules.Reservations.Contracts; using BunkFy.Modules.Reservations.Domain.Aggregates; @@ -62,11 +64,222 @@ await repository.ScanAsync( Assert.NotNull(candidate.Property.GovernancePolicy); } + [Fact] + public async Task Scan_rejects_limit_above_configured_repository_bound() + { + DbContextOptions options = + CreateOptions(); + await using ReservationsDbContext dbContext = new( + options, + new ReservationRetentionModelTests.TestScopeContext( + "tenant-a")); + ReservationRetentionCandidateRepository repository = + new(dbContext); + + ArgumentOutOfRangeException failure = + await Assert.ThrowsAsync(() => + repository.ScanAsync( + afterProjectionOrdinal: 0, + ReservationRetentionOptions.MaximumScanSize + 1, + CancellationToken.None)); + + Assert.Equal("limit", failure.ParamName); + } + + [Fact] + public async Task Scan_uses_limit_plus_one_without_materializing_extra_candidate() + { + DbContextOptions options = + CreateOptions(); + ReservationRetentionPolicyFixture policy = + ReservationRetentionTestData.CreatePolicy(); + await SeedAsync( + options, + "tenant-a", + Guid.NewGuid(), + policy.Binding, + projectionOrdinal: 1); + await SeedAsync( + options, + "tenant-a", + Guid.NewGuid(), + policy.Binding, + projectionOrdinal: 2); + + await using ReservationsDbContext dbContext = new( + options, + new ReservationRetentionModelTests.TestScopeContext( + "tenant-a")); + ReservationRetentionScanPage page = + await new ReservationRetentionCandidateRepository(dbContext) + .ScanAsync( + afterProjectionOrdinal: 0, + limit: 1, + CancellationToken.None); + + Assert.Single(page.Candidates); + Assert.Equal(1, page.Candidates[0].ProjectionOrdinal); + Assert.False(page.ReachedEnd); + } + + [Fact] + public async Task Foreign_scope_rows_with_same_coordinates_do_not_enrich_candidate() + { + DbContextOptions options = + CreateOptions(); + ReservationRetentionPolicyFixture policy = + ReservationRetentionTestData.CreatePolicy(); + Guid propertyId = Guid.NewGuid(); + Guid reservationId = Guid.NewGuid(); + await using (ReservationsDbContext local = new( + options, + new ReservationRetentionModelTests.TestScopeContext( + "tenant-a"))) + { + Reservation reservation = + ReservationRetentionModelTests.CreateTerminalReservation( + "tenant-a", + propertyId, + reservationId); + local.Entry(reservation) + .Property(item => item.ProjectionOrdinal) + .CurrentValue = 1; + local.Reservations.Add(reservation); + local.ProcessingRestrictionProjections.Add( + ReservationProcessingRestrictionProjection.Create( + "tenant-a", + propertyId, + reservationId, + ReservationProcessingRestrictionContract.CurrentVersion, + ReservationRetentionTestData.Now.AddDays(-401)) + .Value); + await local.SaveChangesAsync(); + } + + await using (ReservationsDbContext foreign = new( + options, + new ReservationRetentionModelTests.TestScopeContext( + "tenant-b"))) + { + foreign.PropertyProjections.Add(CreateProperty( + propertyId, + "tenant-b", + policy.Binding)); + foreign.DataHolds.Add(ReservationDataHold.Place( + Guid.NewGuid(), + "tenant-b", + propertyId, + reservationId, + "regulatory-request", + "user:owner", + ReservationRetentionTestData.Now.AddDays(-10)).Value); + foreign.ProcessingRestrictionProjections.Add( + ReservationProcessingRestrictionProjection.Create( + "tenant-b", + propertyId, + reservationId, + ReservationProcessingRestrictionContract.CurrentVersion + + 1, + ReservationRetentionTestData.Now.AddDays(-401)) + .Value); + await foreign.SaveChangesAsync(); + } + + await using ReservationsDbContext dbContext = new( + options, + new ReservationRetentionModelTests.TestScopeContext( + "tenant-a")); + ReservationRetentionCandidateSnapshot? candidate = + await new ReservationRetentionCandidateRepository(dbContext) + .LoadAsync( + propertyId, + reservationId, + CancellationToken.None); + + Assert.NotNull(candidate); + Assert.Equal(0, candidate.ActiveHoldCount); + Assert.Null(candidate.EarliestHoldPlacedAtUtc); + Assert.Equal( + ReservationProcessingRestrictionContract.CurrentVersion, + candidate.ProcessingRestrictionContractVersion); + Assert.Null(candidate.Property); + } + + [Fact] + public async Task Maximum_policy_acknowledgements_are_loaded() + { + ReservationRetentionCandidateSnapshot candidate = + await LoadCandidateWithAcknowledgementsAsync( + PropertiesContractLimits.MaximumPolicyAcknowledgements, + overflow: false); + + Assert.NotNull(candidate.Property); + Assert.NotNull(candidate.Property.GovernancePolicy); + Assert.Equal( + PropertiesContractLimits.MaximumPolicyAcknowledgements, + candidate.Property.GovernancePolicy.Acknowledgements.Count); + } + + [Fact] + public async Task Policy_acknowledgement_overflow_fails_closed() + { + ReservationRetentionCandidateSnapshot candidate = + await LoadCandidateWithAcknowledgementsAsync( + PropertiesContractLimits.MaximumPolicyAcknowledgements, + overflow: true); + + Assert.NotNull(candidate.Property); + Assert.True(candidate.Property.IsKnown); + Assert.Null(candidate.Property.GovernancePolicy); + } + + [Fact] + public async Task Unconfigured_property_is_preserved_without_policy() + { + ReservationPropertyProjection property = + ReservationPropertyProjection.Create( + Guid.NewGuid(), + "tenant-a"); + property.ApplyTopology( + "UTC", + isActive: true, + sourceVersion: 1); + property.ApplyPolicy( + PropertyProcessingStatus.Unconfigured, + governancePolicy: null, + sourceVersion: 1); + + ReservationRetentionCandidateSnapshot candidate = + await LoadCandidateWithPropertyAsync(property); + + Assert.NotNull(candidate.Property); + Assert.True(candidate.Property.IsKnown); + Assert.Equal( + PropertyProcessingStatus.Unconfigured, + candidate.Property.ProcessingStatus); + Assert.Null(candidate.Property.GovernancePolicy); + } + + [Fact] + public async Task Configured_zero_acknowledgement_policy_is_preserved() + { + ReservationRetentionCandidateSnapshot candidate = + await LoadCandidateWithAcknowledgementsAsync( + acknowledgementCount: 0, + overflow: false); + + Assert.NotNull(candidate.Property); + Assert.NotNull(candidate.Property.GovernancePolicy); + Assert.Empty( + candidate.Property.GovernancePolicy.Acknowledgements); + } + private static async Task SeedAsync( DbContextOptions options, string tenantId, Guid reservationId, - PropertyGovernancePolicyBinding policy) + PropertyGovernancePolicyBinding policy, + long? projectionOrdinal = null) { await using ReservationsDbContext dbContext = new( options, @@ -80,24 +293,17 @@ private static async Task SeedAsync( reservationId); dbContext.Entry(reservation) .Property(item => item.ProjectionOrdinal) - .CurrentValue = string.Equals( - tenantId, - "tenant-a", - StringComparison.Ordinal) - ? 1 - : 2; - ReservationPropertyProjection property = - ReservationPropertyProjection.Create( - propertyId, - tenantId); - property.ApplyTopology( - "UTC", - isActive: true, - sourceVersion: 1); - property.ApplyPolicy( - PropertyProcessingStatus.Enabled, - policy, - sourceVersion: 1); + .CurrentValue = projectionOrdinal ?? + (string.Equals( + tenantId, + "tenant-a", + StringComparison.Ordinal) + ? 1 + : 2); + ReservationPropertyProjection property = CreateProperty( + propertyId, + tenantId, + policy); ReservationProcessingRestrictionProjection restriction = ReservationProcessingRestrictionProjection.Create( tenantId, @@ -113,4 +319,124 @@ private static async Task SeedAsync( dbContext.ProcessingRestrictionProjections.Add(restriction); await dbContext.SaveChangesAsync(); } + + private static async Task + LoadCandidateWithAcknowledgementsAsync( + int acknowledgementCount, + bool overflow) + { + PropertyGovernancePolicyBinding baseline = + ReservationRetentionTestData.CreatePolicy().Binding; + PropertyGovernancePolicyBinding policy = new( + baseline.OperatingCountryCode, + baseline.PolicyId, + baseline.PolicyVersion, + baseline.DataRegionId, + baseline.TransferProfileId, + baseline.RetentionPolicyId, + baseline.RetentionPolicyVersion, + baseline.ContentSha256, + baseline.PolicyEffectiveAtUtc, + baseline.PolicyExpiresAtUtc, + baseline.ActivatedAtUtc, + Enumerable.Range(0, acknowledgementCount) + .Select(index => new PropertyGovernanceAcknowledgement( + $"acknowledgement-{index:D2}", + acknowledgementVersion: 1)) + .ToArray()); + ReservationPropertyProjection property = CreateProperty( + Guid.NewGuid(), + "tenant-a", + policy); + if (overflow) + { + FieldInfo acknowledgementsField = + typeof(ReservationPropertyPolicyBinding).GetField( + "acknowledgements", + BindingFlags.Instance | BindingFlags.NonPublic)!; + List + acknowledgements = + Assert.IsType>( + acknowledgementsField.GetValue( + property.GovernancePolicy)); + acknowledgements.Add(new( + "acknowledgement-overflow", + acknowledgementVersion: 1)); + } + + return await LoadCandidateWithPropertyAsync(property); + } + + private static async Task + LoadCandidateWithPropertyAsync( + ReservationPropertyProjection property) + { + DbContextOptions options = + CreateOptions(); + Guid reservationId = Guid.NewGuid(); + await using (ReservationsDbContext seed = new( + options, + new ReservationRetentionModelTests.TestScopeContext( + "tenant-a"))) + { + Reservation reservation = + ReservationRetentionModelTests.CreateTerminalReservation( + "tenant-a", + property.Id, + reservationId); + seed.Entry(reservation) + .Property(item => item.ProjectionOrdinal) + .CurrentValue = 1; + seed.Reservations.Add(reservation); + seed.PropertyProjections.Add(property); + seed.ProcessingRestrictionProjections.Add( + ReservationProcessingRestrictionProjection.Create( + "tenant-a", + property.Id, + reservationId, + ReservationProcessingRestrictionContract.CurrentVersion, + ReservationRetentionTestData.Now.AddDays(-401)) + .Value); + await seed.SaveChangesAsync(); + } + + await using ReservationsDbContext dbContext = new( + options, + new ReservationRetentionModelTests.TestScopeContext( + "tenant-a")); + return Assert.IsType( + await new ReservationRetentionCandidateRepository(dbContext) + .LoadAsync( + property.Id, + reservationId, + CancellationToken.None)); + } + + private static ReservationPropertyProjection CreateProperty( + Guid propertyId, + string tenantId, + PropertyGovernancePolicyBinding policy) + { + ReservationPropertyProjection property = + ReservationPropertyProjection.Create( + propertyId, + tenantId); + property.ApplyTopology( + "UTC", + isActive: true, + sourceVersion: 1); + property.ApplyPolicy( + PropertyProcessingStatus.Enabled, + policy, + sourceVersion: 1); + return property; + } + + private static DbContextOptions + CreateOptions() => + new DbContextOptionsBuilder() + .UseInMemoryDatabase( + $"reservation-retention-candidates-{Guid.NewGuid():N}") + .Options; } diff --git a/src/Modules/Retention/BunkFy.Modules.Retention.Application/Commands/RetentionExecutionStart.cs b/src/Modules/Retention/BunkFy.Modules.Retention.Application/Commands/RetentionExecutionStart.cs index 30ec9444..7bc64781 100644 --- a/src/Modules/Retention/BunkFy.Modules.Retention.Application/Commands/RetentionExecutionStart.cs +++ b/src/Modules/Retention/BunkFy.Modules.Retention.Application/Commands/RetentionExecutionStart.cs @@ -5,5 +5,6 @@ namespace BunkFy.Modules.Retention.Application.Commands; public sealed record RetentionExecutionStart( bool DispatchRequired, + bool AttemptAdvanced, RetentionExecutionState State, RetentionContributionRequest Request); diff --git a/src/Modules/Retention/BunkFy.Modules.Retention.Application/Handlers/RetentionExecutionCommandHandlers.cs b/src/Modules/Retention/BunkFy.Modules.Retention.Application/Handlers/RetentionExecutionCommandHandlers.cs index 71d228ba..d68b7bf0 100644 --- a/src/Modules/Retention/BunkFy.Modules.Retention.Application/Handlers/RetentionExecutionCommandHandlers.cs +++ b/src/Modules/Retention/BunkFy.Modules.Retention.Application/Handlers/RetentionExecutionCommandHandlers.cs @@ -43,6 +43,7 @@ public async Task> HandleAsync( } RetentionExecution? current = lease.Execution; + bool attemptAdvanced = false; if (current is null) { Result started = RetentionExecution.Start( @@ -77,7 +78,11 @@ public async Task> HandleAsync( RetentionApplicationErrors.ExecutionConflict); } - if (command.Attempt > current.Attempt) + bool retryableState = current.State is + RetentionExecutionState.Running or + RetentionExecutionState.Failed; + if (retryableState && + command.Attempt > current.Attempt) { Result retried = current.BeginRetry( command.Attempt, @@ -87,8 +92,11 @@ public async Task> HandleAsync( { return Result.Failure(retried.Error); } + + attemptAdvanced = true; } - else if (command.Attempt != current.Attempt) + else if (retryableState && + command.Attempt != current.Attempt) { return Result.Failure( RetentionApplicationErrors.ExecutionConflict); @@ -105,6 +113,7 @@ await scheduleStates.RecordStartedAsync( return Result.Success(new RetentionExecutionStart( current.State == RetentionExecutionState.Running, + attemptAdvanced, current.State, ToRequest(current))); } diff --git a/src/Modules/Retention/BunkFy.Modules.Retention.Application/Tasks/ExecuteRetentionScheduleTaskHandler.cs b/src/Modules/Retention/BunkFy.Modules.Retention.Application/Tasks/ExecuteRetentionScheduleTaskHandler.cs index f594e35a..d7051a6c 100644 --- a/src/Modules/Retention/BunkFy.Modules.Retention.Application/Tasks/ExecuteRetentionScheduleTaskHandler.cs +++ b/src/Modules/Retention/BunkFy.Modules.Retention.Application/Tasks/ExecuteRetentionScheduleTaskHandler.cs @@ -28,9 +28,9 @@ public async Task HandleAsync( string tenantId = context.ScopeId ?? throw new InvalidOperationException("Retention.ScopeRequired"); IRetentionExecutionContributor contributor = this.ResolveContributor(payload); + int executionAttempt = context.LeaseGeneration; DateTimeOffset startedAtUtc = clock.UtcNow; DateTimeOffset deadlineUtc = startedAtUtc + contributor.Schedule.ExecutionTimeout; - int executionGeneration = context.LeaseGeneration; Result started = await commandDispatcher.DispatchAsync< @@ -45,7 +45,7 @@ await commandDispatcher.DispatchAsync< payload.TargetScopeKind, payload.PropertyId, payload.ExecutionPolicyVersion, - executionGeneration, + executionAttempt, startedAtUtc, deadlineUtc, startedAtUtc + contributor.Schedule.Interval), @@ -73,6 +73,9 @@ await commandDispatcher.DispatchAsync< contributor, started.Value.Request, cancellationToken).ConfigureAwait(false); + result = NormalizeTerminalReplay( + started.Value, + result); if (!IsValidResult(started.Value.Request, result)) { throw new InvalidOperationException("Retention.OwnerResultInvalid"); @@ -105,7 +108,7 @@ await commandDispatcher.DispatchAsync< CompleteRetentionExecutionCommand, Unit>( context, - new(context.RunId, executionGeneration, result), + new(context.RunId, executionAttempt, result), cancellationToken).ConfigureAwait(false); if (completed.IsFailure) { @@ -212,6 +215,18 @@ result.Status is RetentionContributionStatus.Unknown || blocked == (result.HoldReviewDueAtUtc is not null); } + private static RetentionContributionResult NormalizeTerminalReplay( + RetentionExecutionStart start, + RetentionContributionResult result) => + start.AttemptAdvanced && + result.CompletedAtUtc != default && + result.CompletedAtUtc < start.Request.StartedAtUtc + ? result with + { + CompletedAtUtc = start.Request.StartedAtUtc + } + : result; + private static InvalidOperationException Failure(Error error) => new($"{error.Code}: {error.Message}"); } diff --git a/src/Modules/Retention/BunkFy.Modules.Retention.Domain/Aggregates/RetentionExecution.cs b/src/Modules/Retention/BunkFy.Modules.Retention.Domain/Aggregates/RetentionExecution.cs index 27d87369..e0946da2 100644 --- a/src/Modules/Retention/BunkFy.Modules.Retention.Domain/Aggregates/RetentionExecution.cs +++ b/src/Modules/Retention/BunkFy.Modules.Retention.Domain/Aggregates/RetentionExecution.cs @@ -77,8 +77,14 @@ public Result BeginRetry( DateTimeOffset startedAtUtc, DateTimeOffset deadlineUtc) { + bool failedWindowInvalid = + this.State == RetentionExecutionState.Failed && + (!this.CompletedAtUtc.HasValue || + startedAtUtc < this.CompletedAtUtc.Value); if (attempt <= this.Attempt || startedAtUtc == default || + startedAtUtc < this.StartedAtUtc || + failedWindowInvalid || deadlineUtc <= startedAtUtc) { return Result.Failure(RetentionDomainErrors.AttemptInvalid); diff --git a/src/Modules/Retention/docs/README.md b/src/Modules/Retention/docs/README.md index 697a9393..6a927b53 100644 --- a/src/Modules/Retention/docs/README.md +++ b/src/Modules/Retention/docs/README.md @@ -40,6 +40,34 @@ reads and schedule discovery and fail closed before either surface proceeds. Generic Task Runtime provider streaming, bounded scheduler memory, and Retention execution-history cleanup remain separate follow-up concerns. +## Task Lease And Owner Recovery + +Task Runtime `Attempt` remains the retry-budget counter within the current task +run. An operator `RetryAsync` starts that budget again, so Retention does not +use the resettable value as an execution or owner idempotency fence. Every task +claim also increments the persisted `LeaseGeneration`; that generation is +monotonic across ordinary retry, lease reclaim, timeout recovery, and operator +`RetryAsync`. Retention persists and forwards `LeaseGeneration` in its +execution and owner-contract field named `Attempt`. A stale lease therefore +cannot complete the Retention execution or owner attempt opened by a newer +claim even when Task Runtime's budget counter has restarted. + +If a reclaimed lease finds the central Retention execution already `Completed` +or `Blocked`, begin returns the persisted terminal state with dispatch disabled. +The task converges without invoking the owner again. If the owner committed its +terminal completion but the worker failed before central Retention committed +that result, the newer lease generation opens a non-regressing central recovery +window and asks the owner to replay. The owner's immutable terminal evidence is +not rewritten. When its persisted completion predates the new central start, +Retention records the same terminal counts, status, and outcome with the new +window's start as the central completion time, keeping the central aggregate's +time invariant without changing owner proof. + +Recovery windows may only move forward. A retry of a still-running central +execution cannot start before its previous start, and a `Failed` execution +cannot restart before its persisted completion. `Completed` and `Blocked` +executions are terminal and do not open another owner window. + ## Tenant Termination Retention is a mandatory `Export` and `Destroy` contributor after Ingestion. diff --git a/src/Modules/Retention/tests/BunkFy.Modules.Retention.Tests/Application/ExecuteRetentionScheduleTaskHandlerTests.cs b/src/Modules/Retention/tests/BunkFy.Modules.Retention.Tests/Application/ExecuteRetentionScheduleTaskHandlerTests.cs index 9b89005c..5d316462 100644 --- a/src/Modules/Retention/tests/BunkFy.Modules.Retention.Tests/Application/ExecuteRetentionScheduleTaskHandlerTests.cs +++ b/src/Modules/Retention/tests/BunkFy.Modules.Retention.Tests/Application/ExecuteRetentionScheduleTaskHandlerTests.cs @@ -53,6 +53,151 @@ await handler.HandleAsync( Assert.Empty(securitySignals.Records); } + [Fact] + public async Task Execution_fence_uses_monotonic_lease_generation() + { + FakeTaskDispatcher dispatcher = new(); + RetentionContributionRequest? ownerRequest = null; + TestContributor contributor = new(request => + { + ownerRequest = request; + return Task.FromResult(new RetentionContributionResult( + RetentionExecutionContract.CurrentVersion, + RetentionContributionStatus.Completed, + ScannedCount: 0, + AffectedCount: 0, + RemainingCount: 0, + "ingestion.raw-payload.completed", + Now)); + }); + ExecuteRetentionScheduleTaskHandler handler = new( + dispatcher, + [contributor], + new TestClock(), + new RecordingSecuritySignalRecorder(), + NullLogger.Instance); + TaskExecutionContext context = Context( + attempt: 1, + leaseGeneration: 4); + + await handler.HandleAsync( + Payload(), + context, + CancellationToken.None); + + Assert.Equal(1, context.Attempt); + Assert.Equal(4, context.LeaseGeneration); + Assert.NotNull(dispatcher.Started); + Assert.Equal(4, dispatcher.Started.Attempt); + Assert.NotNull(ownerRequest); + Assert.Equal(4, ownerRequest.Attempt); + Assert.NotNull(dispatcher.Completed); + Assert.Equal(4, dispatcher.Completed.Attempt); + } + + [Fact] + public async Task Prior_attempt_owner_result_converges_in_recovery_window() + { + DateTimeOffset recoveryStartedAtUtc = Now.AddMinutes(2); + FakeTaskDispatcher dispatcher = new(attemptAdvanced: true); + TestContributor contributor = new(_ => Task.FromResult( + new RetentionContributionResult( + RetentionExecutionContract.CurrentVersion, + RetentionContributionStatus.Completed, + ScannedCount: 1, + AffectedCount: 1, + RemainingCount: 0, + "reservations.reservation-operational.completed", + Now.AddMinutes(1)))); + ExecuteRetentionScheduleTaskHandler handler = new( + dispatcher, + [contributor], + new TestClock(recoveryStartedAtUtc), + new RecordingSecuritySignalRecorder(), + NullLogger.Instance); + + await handler.HandleAsync( + Payload(), + Context(attempt: 1, leaseGeneration: 2), + CancellationToken.None); + + Assert.NotNull(dispatcher.Started); + Assert.Equal( + recoveryStartedAtUtc, + dispatcher.Started.StartedAtUtc); + Assert.NotNull(dispatcher.Completed); + Assert.Equal(2, dispatcher.Completed.Attempt); + Assert.Equal( + recoveryStartedAtUtc, + dispatcher.Completed.Result.CompletedAtUtc); + } + + [Fact] + public async Task Fresh_owner_result_before_start_is_rejected() + { + FakeTaskDispatcher dispatcher = new(); + ExecuteRetentionScheduleTaskHandler handler = new( + dispatcher, + [new TestContributor(_ => Task.FromResult( + new RetentionContributionResult( + RetentionExecutionContract.CurrentVersion, + RetentionContributionStatus.Completed, + ScannedCount: 0, + AffectedCount: 0, + RemainingCount: 0, + "reservations.reservation-operational.completed", + Now.AddTicks(-1))))], + new TestClock(), + new RecordingSecuritySignalRecorder(), + NullLogger.Instance); + + InvalidOperationException failure = + await Assert.ThrowsAsync(() => + handler.HandleAsync( + Payload(), + Context(), + CancellationToken.None)); + + Assert.Equal("Retention.OwnerResultInvalid", failure.Message); + Assert.NotNull(dispatcher.Completed); + Assert.Equal( + RetentionContributionStatus.Failed, + dispatcher.Completed.Result.Status); + } + + [Fact] + public async Task Recovery_does_not_normalize_default_completion() + { + FakeTaskDispatcher dispatcher = new(attemptAdvanced: true); + ExecuteRetentionScheduleTaskHandler handler = new( + dispatcher, + [new TestContributor(_ => Task.FromResult( + new RetentionContributionResult( + RetentionExecutionContract.CurrentVersion, + RetentionContributionStatus.Completed, + ScannedCount: 0, + AffectedCount: 0, + RemainingCount: 0, + "reservations.reservation-operational.completed", + default)))], + new TestClock(), + new RecordingSecuritySignalRecorder(), + NullLogger.Instance); + + InvalidOperationException failure = + await Assert.ThrowsAsync(() => + handler.HandleAsync( + Payload(), + Context(leaseGeneration: 2), + CancellationToken.None)); + + Assert.Equal("Retention.OwnerResultInvalid", failure.Message); + Assert.NotNull(dispatcher.Completed); + Assert.Equal( + RetentionContributionStatus.Failed, + dispatcher.Completed.Result.Status); + } + [Fact] public async Task Owner_exception_is_recorded_before_the_task_is_rethrown() { @@ -168,7 +313,8 @@ private static TaskExecutionContext Context( correlationId: includeCorrelation ? Guid.NewGuid() : null, leaseGeneration: leaseGeneration); - private sealed class FakeTaskDispatcher : ITaskCommandDispatcher + private sealed class FakeTaskDispatcher( + bool attemptAdvanced = false) : ITaskCommandDispatcher { public BeginRetentionExecutionCommand? Started { get; private set; } public CompleteRetentionExecutionCommand? Completed { get; private set; } @@ -181,7 +327,8 @@ public Task> DispatchAsync( { object result = command switch { - BeginRetentionExecutionCommand started => this.Start(started), + BeginRetentionExecutionCommand started => + this.Begin(started), CompleteRetentionExecutionCommand completed => this.Complete(completed), _ => throw new InvalidOperationException( @@ -190,24 +337,26 @@ public Task> DispatchAsync( return Task.FromResult((Result)result); } - private Result Start( + private Result Begin( BeginRetentionExecutionCommand command) { this.Started = command; - return Result.Success(new RetentionExecutionStart( - DispatchRequired: true, - RetentionExecutionState.Running, - new RetentionContributionRequest( - RetentionExecutionContract.CurrentVersion, - command.ExecutionId, - command.TenantId, - command.PropertyId, - command.OwnerKey, - command.DataClassKey, - command.ExecutionPolicyVersion, - command.Attempt, - command.StartedAtUtc, - command.DeadlineUtc))); + return Result.Success( + new RetentionExecutionStart( + DispatchRequired: true, + attemptAdvanced, + RetentionExecutionState.Running, + new RetentionContributionRequest( + RetentionExecutionContract.CurrentVersion, + command.ExecutionId, + command.TenantId, + command.PropertyId, + command.OwnerKey, + command.DataClassKey, + command.ExecutionPolicyVersion, + command.Attempt, + command.StartedAtUtc, + command.DeadlineUtc))); } private Result Complete( @@ -237,9 +386,10 @@ public Task ExecuteAsync( execute(request); } - private sealed class TestClock : ISystemClock + private sealed class TestClock( + DateTimeOffset? utcNow = null) : ISystemClock { - public DateTimeOffset UtcNow => Now; + public DateTimeOffset UtcNow { get; } = utcNow ?? Now; } private sealed class RecordingSecuritySignalRecorder diff --git a/src/Modules/Retention/tests/BunkFy.Modules.Retention.Tests/Application/RetentionMutationCoordinatorTests.cs b/src/Modules/Retention/tests/BunkFy.Modules.Retention.Tests/Application/RetentionMutationCoordinatorTests.cs index b4ce0105..2e966746 100644 --- a/src/Modules/Retention/tests/BunkFy.Modules.Retention.Tests/Application/RetentionMutationCoordinatorTests.cs +++ b/src/Modules/Retention/tests/BunkFy.Modules.Retention.Tests/Application/RetentionMutationCoordinatorTests.cs @@ -117,6 +117,62 @@ public async Task Completion_locks_execution_then_authoritative_schedule() calls); } + [Fact] + public async Task Reclaimed_lease_replays_terminal_execution() + { + Guid propertyId = Guid.Parse( + "10000000-0000-0000-0000-000000000004"); + Guid executionId = Guid.Parse( + "20000000-0000-0000-0000-000000000004"); + List calls = []; + RetentionExecution execution = CreateExecution( + executionId, + propertyId); + Assert.True(execution.Complete( + RetentionExecutionState.Completed, + attempt: 1, + scannedCount: 2, + affectedCount: 1, + remainingCount: 0, + "guests.guest-operational.completed", + Now.AddMinutes(1), + holdReviewDueAtUtc: null).IsSuccess); + RecordingExecutionRepository executions = new( + execution, + calls); + RetentionExecutionMutationCoordinator coordinator = new( + new RecordingMutationLock(calls), + executions, + new RecordingScopeRepository(calls), + new TestScopeContext()); + RecordingScheduleStateRepository schedules = new(); + BeginRetentionExecutionCommandHandler handler = new( + coordinator, + executions, + schedules); + BeginRetentionExecutionCommand reclaimed = + CreateStartCommand(executionId, propertyId) with + { + Attempt = 2, + StartedAtUtc = Now.AddMinutes(2), + DeadlineUtc = Now.AddMinutes(7) + }; + + Result result = + await handler.HandleAsync( + reclaimed, + CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.False(result.Value.DispatchRequired); + Assert.Equal( + RetentionExecutionState.Completed, + result.Value.State); + Assert.Equal(1, result.Value.Request.Attempt); + Assert.Equal(1, execution.Attempt); + Assert.Equal(0, schedules.StartedCount); + } + [Fact] public async Task Property_projection_locks_before_merge() { @@ -434,4 +490,24 @@ private sealed class TestScopeContext : IScopeContext public bool IsEnabled => true; public string ScopeId => TenantId; } + + private sealed class RecordingScheduleStateRepository + : IRetentionScheduleStateRepository + { + public int StartedCount { get; private set; } + + public Task RecordStartedAsync( + RetentionExecution execution, + DateTimeOffset nextDueAtUtc, + CancellationToken cancellationToken) + { + this.StartedCount++; + return Task.CompletedTask; + } + + public Task RecordCompletedAsync( + RetentionExecution execution, + CancellationToken cancellationToken) => + Task.CompletedTask; + } } diff --git a/src/Modules/Retention/tests/BunkFy.Modules.Retention.Tests/Domain/RetentionExecutionTests.cs b/src/Modules/Retention/tests/BunkFy.Modules.Retention.Tests/Domain/RetentionExecutionTests.cs index 73582bd5..05269351 100644 --- a/src/Modules/Retention/tests/BunkFy.Modules.Retention.Tests/Domain/RetentionExecutionTests.cs +++ b/src/Modules/Retention/tests/BunkFy.Modules.Retention.Tests/Domain/RetentionExecutionTests.cs @@ -25,6 +25,11 @@ public void Failed_execution_can_retry_with_a_new_attempt_window() StartedAt.AddMinutes(6), holdReviewDueAtUtc: null).IsSuccess); + Assert.True(execution.BeginRetry( + attempt: 2, + StartedAt.AddMinutes(5), + StartedAt.AddMinutes(10)).IsFailure); + Assert.True(execution.BeginRetry( attempt: 2, StartedAt.AddMinutes(10), @@ -36,6 +41,20 @@ public void Failed_execution_can_retry_with_a_new_attempt_window() Assert.Null(execution.OutcomeCode); } + [Fact] + public void Running_retry_rejects_a_regressed_start_window() + { + RetentionExecution execution = Start(); + + Assert.True(execution.BeginRetry( + attempt: 2, + StartedAt.AddMinutes(-1), + StartedAt.AddMinutes(4)).IsFailure); + + Assert.Equal(1, execution.Attempt); + Assert.Equal(StartedAt, execution.StartedAtUtc); + } + [Fact] public void Completed_execution_is_terminal_and_exact_completion_replays() { diff --git a/tests/Integration.Tests/Reservations/ReservationDataRightsIntegrationTests.cs b/tests/Integration.Tests/Reservations/ReservationDataRightsIntegrationTests.cs index 61288e7a..613ccc7d 100644 --- a/tests/Integration.Tests/Reservations/ReservationDataRightsIntegrationTests.cs +++ b/tests/Integration.Tests/Reservations/ReservationDataRightsIntegrationTests.cs @@ -1086,46 +1086,53 @@ await scope.ServiceProvider.GetRequiredService() scope.ServiceProvider.GetRequiredService().SetTenant(TenantId); ReservationsDbContext reservations = scope.ServiceProvider.GetRequiredService(); - PropertyCreatedIntegrationEvent propertyCreated = new( - Guid.NewGuid(), - TenantId, - Now.AddDays(-2), - propertyId, - "Correction House", - "correction-house", - "UTC", - PropertyStatus.Active, - 1); - await ResolveHandler( - scope.ServiceProvider, - ReservationsModuleMetadata.Name) - .HandleAsync(propertyCreated, CancellationToken.None) - .ConfigureAwait(false); - await CountryPolicyIntegrationTestData.ApplyActivationAsync( - scope.ServiceProvider, - ReservationsModuleMetadata.Name, - TenantId, - propertyId, - 2).ConfigureAwait(false); - await ResolveHandler( + Reservation reservation; + await using (var transaction = await reservations.Database + .BeginTransactionAsync() + .ConfigureAwait(false)) + { + PropertyCreatedIntegrationEvent propertyCreated = new( + Guid.NewGuid(), + TenantId, + Now.AddDays(-2), + propertyId, + "Correction House", + "correction-house", + "UTC", + PropertyStatus.Active, + 1); + await ResolveHandler( + scope.ServiceProvider, + ReservationsModuleMetadata.Name) + .HandleAsync(propertyCreated, CancellationToken.None) + .ConfigureAwait(false); + await CountryPolicyIntegrationTestData.ApplyActivationAsync( scope.ServiceProvider, - ReservationsModuleMetadata.Name) - .HandleAsync( - new( - Guid.NewGuid(), - TenantId, - Now.AddDays(-1), - propertyId, - 3, - "user:property-admin"), - CancellationToken.None) - .ConfigureAwait(false); + ReservationsModuleMetadata.Name, + TenantId, + propertyId, + 2).ConfigureAwait(false); + await ResolveHandler( + scope.ServiceProvider, + ReservationsModuleMetadata.Name) + .HandleAsync( + new( + Guid.NewGuid(), + TenantId, + Now.AddDays(-1), + propertyId, + 3, + "user:property-admin"), + CancellationToken.None) + .ConfigureAwait(false); - Reservation reservation = CreateReservation(propertyId, TenantId); - await scope.ServiceProvider.GetRequiredService() - .AddAsync(reservation, CancellationToken.None) - .ConfigureAwait(false); - await reservations.SaveChangesAsync().ConfigureAwait(false); + reservation = CreateReservation(propertyId, TenantId); + await scope.ServiceProvider.GetRequiredService() + .AddAsync(reservation, CancellationToken.None) + .ConfigureAwait(false); + await reservations.SaveChangesAsync().ConfigureAwait(false); + await transaction.CommitAsync().ConfigureAwait(false); + } DataRightsCaseRequest caseRequest = DataRightsCaseRequest.Create( propertyId, @@ -1183,61 +1190,68 @@ await scope.ServiceProvider.GetRequiredService() .SetTenant(TenantId); ReservationsDbContext reservations = scope.ServiceProvider.GetRequiredService(); - await ResolveHandler( - scope.ServiceProvider, - ReservationsModuleMetadata.Name) - .HandleAsync( - new( - Guid.NewGuid(), - TenantId, - Now.AddDays(-2), - propertyId, - "Restriction House", - "restriction-house", - "UTC", - PropertyStatus.Active, - 1), - CancellationToken.None) - .ConfigureAwait(false); - await CountryPolicyIntegrationTestData.ApplyActivationAsync( - scope.ServiceProvider, - ReservationsModuleMetadata.Name, - TenantId, - propertyId, - 2).ConfigureAwait(false); - await ResolveHandler( + Reservation reservation; + await using (var transaction = await reservations.Database + .BeginTransactionAsync() + .ConfigureAwait(false)) + { + await ResolveHandler( + scope.ServiceProvider, + ReservationsModuleMetadata.Name) + .HandleAsync( + new( + Guid.NewGuid(), + TenantId, + Now.AddDays(-2), + propertyId, + "Restriction House", + "restriction-house", + "UTC", + PropertyStatus.Active, + 1), + CancellationToken.None) + .ConfigureAwait(false); + await CountryPolicyIntegrationTestData.ApplyActivationAsync( scope.ServiceProvider, - ReservationsModuleMetadata.Name) - .HandleAsync( - new( - Guid.NewGuid(), - TenantId, - Now.AddDays(-1), - propertyId, - 3, - "user:property-admin"), - CancellationToken.None) - .ConfigureAwait(false); + ReservationsModuleMetadata.Name, + TenantId, + propertyId, + 2).ConfigureAwait(false); + await ResolveHandler( + scope.ServiceProvider, + ReservationsModuleMetadata.Name) + .HandleAsync( + new( + Guid.NewGuid(), + TenantId, + Now.AddDays(-1), + propertyId, + 3, + "user:property-admin"), + CancellationToken.None) + .ConfigureAwait(false); - Reservation reservation = CreateReservation(propertyId, TenantId); - await scope.ServiceProvider.GetRequiredService() - .AddAsync(reservation, CancellationToken.None) - .ConfigureAwait(false); - await reservations.SaveChangesAsync().ConfigureAwait(false); - await reservations.Database.ExecuteSqlInterpolatedAsync( - $""" - INSERT INTO reservations.arrival_reminders - ("Id", "ScopeId", "ReservationId", "PropertyId", - "DetailsRevision", "TimeZoneId", "Arrival", - "ExpectedArrivalTime", "ExpectedArrivalAtUtc", "DueAtUtc", - "LeadTimeMinutes", "State", "DispatchedAtUtc", "Version") - VALUES - ({Guid.NewGuid()}, {TenantId}, {reservation.Id}, {propertyId}, - {reservation.DetailsRevision}, {"UTC"}, {reservation.Arrival}, - {new TimeOnly(15, 0)}, {Now.AddDays(1)}, {Now.AddHours(22)}, - {120}, {(int)ReservationArrivalReminderState.Pending}, - {(DateTimeOffset?)null}, {1L}); - """).ConfigureAwait(false); + reservation = CreateReservation(propertyId, TenantId); + await scope.ServiceProvider.GetRequiredService() + .AddAsync(reservation, CancellationToken.None) + .ConfigureAwait(false); + await reservations.SaveChangesAsync().ConfigureAwait(false); + await reservations.Database.ExecuteSqlInterpolatedAsync( + $""" + INSERT INTO reservations.arrival_reminders + ("Id", "ScopeId", "ReservationId", "PropertyId", + "DetailsRevision", "TimeZoneId", "Arrival", + "ExpectedArrivalTime", "ExpectedArrivalAtUtc", "DueAtUtc", + "LeadTimeMinutes", "State", "DispatchedAtUtc", "Version") + VALUES + ({Guid.NewGuid()}, {TenantId}, {reservation.Id}, {propertyId}, + {reservation.DetailsRevision}, {"UTC"}, {reservation.Arrival}, + {new TimeOnly(15, 0)}, {Now.AddDays(1)}, {Now.AddHours(22)}, + {120}, {(int)ReservationArrivalReminderState.Pending}, + {(DateTimeOffset?)null}, {1L}); + """).ConfigureAwait(false); + await transaction.CommitAsync().ConfigureAwait(false); + } DataRightsCase apply = CreateApprovedRestrictionCase( reservation, @@ -1307,6 +1321,11 @@ private static async Task SeedEligibleReservationAsync( using IServiceScope scope = api.Services.CreateScope(); scope.ServiceProvider.GetRequiredService() .SetTenant(TenantId); + ReservationsDbContext reservations = + scope.ServiceProvider.GetRequiredService(); + await using var transaction = await reservations.Database + .BeginTransactionAsync() + .ConfigureAwait(false); await ResolveHandler( scope.ServiceProvider, ReservationsModuleMetadata.Name) @@ -1360,9 +1379,8 @@ await ResolveHandler( await scope.ServiceProvider.GetRequiredService() .AddAsync(reservation, CancellationToken.None) .ConfigureAwait(false); - await scope.ServiceProvider.GetRequiredService() - .SaveChangesAsync() - .ConfigureAwait(false); + await reservations.SaveChangesAsync().ConfigureAwait(false); + await transaction.CommitAsync().ConfigureAwait(false); return reservation; } diff --git a/tests/Integration.Tests/Reservations/ReservationRetentionProviderIntegrationTests.cs b/tests/Integration.Tests/Reservations/ReservationRetentionProviderIntegrationTests.cs new file mode 100644 index 00000000..0929d244 --- /dev/null +++ b/tests/Integration.Tests/Reservations/ReservationRetentionProviderIntegrationTests.cs @@ -0,0 +1,1968 @@ +namespace Integration.Tests; + +using System.Data.Common; +using System.Reflection; +using System.Text.Json; +using BunkFy.Modules.Properties.Contracts; +using BunkFy.Modules.Reservations.Contracts; +using BunkFy.Modules.Reservations.Domain.Aggregates; +using BunkFy.Modules.Reservations.Domain.DataRights; +using BunkFy.Modules.Reservations.Domain.Retention; +using BunkFy.Modules.Reservations.Persistence; +using BunkFy.Modules.Retention.Application.Commands; +using BunkFy.Modules.Retention.Contracts; +using BunkFy.Modules.Retention.Domain.Aggregates; +using BunkFy.Modules.Retention.Domain.Models; +using BunkFy.Modules.Retention.Persistence; +using Gma.Framework.Cqrs; +using Gma.Framework.Messaging; +using Gma.Framework.Runtime.Time; +using Gma.Framework.Results; +using Gma.Framework.Scoping; +using Gma.Framework.Tasks; +using Gma.Framework.Tasks.Cqrs; +using Gma.Framework.Tenancy; +using Gma.Modules.Organizations.Contracts; +using Integration.Tests.Support; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Npgsql; +using Testcontainers.PostgreSql; +using Xunit; + +public sealed partial class RetentionControlPlaneIntegrationTests +{ + private const string ReservationProviderPolicyUnavailableOutcome = + "reservations.reservation-operational.policy-unavailable"; + + [DockerFact] + [Trait("Category", "Docker")] + [Trait("Category", "Integration")] + public async Task + Reservations_provider_replay_is_exact_and_tenant_fenced() + { + await using PostgreSqlContainer postgreSql = + new PostgreSqlBuilder("postgres:16-alpine") + .WithDatabase("bunkfy_reservation_retention_provider") + .Build(); + await postgreSql.StartAsync().ConfigureAwait(false); + + var clock = new ReservationProviderMutableClock( + DateTimeOffset.UtcNow); + using IHost worker = CreateWorker( + postgreSql.GetConnectionString(), + clock); + await MigrateAsync(worker).ConfigureAwait(false); + + const string exactTenant = + "6e000000-0000-0000-0000-000000000001"; + Guid exactPropertyId = Guid.Parse( + "6e100000-0000-0000-0000-000000000001"); + Guid exactReservationId = + await SeedReservationProviderCandidateAsync( + worker, + exactTenant, + exactPropertyId).ConfigureAwait(false); + RetentionContributionRequest exactRequest = + CreateReservationProviderRequest( + worker, + exactTenant, + Guid.NewGuid(), + attempt: 1); + DateTimeOffset rawCompletedAtUtc = + WithReservationProviderSubMicrosecondTicks( + exactRequest.StartedAtUtc.AddSeconds(1)); + clock.UtcNow = rawCompletedAtUtc; + + RetentionContributionResult first = + await ExecuteReservationProviderAsync( + worker, + exactTenant, + exactRequest).ConfigureAwait(false); + RetentionContributionResult replay = + await ExecuteReservationProviderAsync( + worker, + exactTenant, + exactRequest).ConfigureAwait(false); + + Assert.Equal(first, replay); + Assert.NotEqual( + 0, + rawCompletedAtUtc.Ticks % TimeSpan.TicksPerMicrosecond); + Assert.Equal( + rawCompletedAtUtc.AddTicks( + -(rawCompletedAtUtc.Ticks % + TimeSpan.TicksPerMicrosecond)), + first.CompletedAtUtc); + Assert.Equal(0, first.CompletedAtUtc.Ticks % + TimeSpan.TicksPerMicrosecond); + Assert.Equal(RetentionContributionStatus.Completed, first.Status); + Assert.Equal(1, first.ScannedCount); + Assert.Equal(1, first.AffectedCount); + Assert.Equal(0, first.RemainingCount); + await AssertReservationProviderProofAsync( + worker, + exactTenant, + exactReservationId, + exactRequest.ExecutionId, + expectedAttempt: 1, + first.CompletedAtUtc).ConfigureAwait(false); + + const string localTenant = + "6e000000-0000-0000-0000-000000000002"; + const string foreignTenant = + "6e000000-0000-0000-0000-000000000003"; + Guid sharedPropertyId = Guid.Parse( + "6e100000-0000-0000-0000-000000000002"); + Guid localReservationId; + using (IServiceScope local = CreateReservationProviderScope( + worker, + localTenant)) + { + localReservationId = await SeedReservationAsync( + local.ServiceProvider, + localTenant, + sharedPropertyId, + DateTimeOffset.UtcNow).ConfigureAwait(false); + } + + await ApplyReservationProviderPropertyAsync( + worker, + foreignTenant, + sharedPropertyId).ConfigureAwait(false); + using (IServiceScope foreign = CreateReservationProviderScope( + worker, + foreignTenant)) + { + ReservationsDbContext reservations = foreign.ServiceProvider + .GetRequiredService(); + reservations.DataHolds.Add(ReservationDataHold.Place( + Guid.NewGuid(), + foreignTenant, + sharedPropertyId, + localReservationId, + "regulatory-request", + "integration:foreign-tenant", + DateTimeOffset.UtcNow.AddDays(-1)).Value); + reservations.ProcessingRestrictionProjections.Add( + ReservationProcessingRestrictionProjection.Create( + foreignTenant, + sharedPropertyId, + localReservationId, + ReservationProcessingRestrictionContract.CurrentVersion + + 1, + DateTimeOffset.UtcNow.AddDays(-1)).Value); + await reservations.SaveChangesAsync().ConfigureAwait(false); + } + + using (IServiceScope local = CreateReservationProviderScope( + worker, + localTenant)) + { + ReservationsDbContext reservations = local.ServiceProvider + .GetRequiredService(); + object repository = CreateReservationCandidateRepository( + reservations); + ReservationCandidateScanProof page = + await InvokeReservationCandidateScanAsync( + repository, + afterProjectionOrdinal: 0, + limit: 10, + CancellationToken.None).ConfigureAwait(false); + object candidate = Assert.Single(page.Candidates); + Assert.Equal( + localReservationId, + GetReservationCandidateReservationId(candidate)); + Assert.Equal(0, GetReservationCandidateActiveHoldCount( + candidate)); + Assert.Equal( + ReservationProcessingRestrictionContract.CurrentVersion, + GetReservationCandidateRestrictionVersion(candidate)); + Assert.Null(GetReservationCandidateProperty(candidate)); + } + + RetentionContributionRequest fencedRequest = + CreateReservationProviderRequest( + worker, + localTenant, + Guid.NewGuid(), + attempt: 1); + clock.UtcNow = WithReservationProviderSubMicrosecondTicks( + fencedRequest.StartedAtUtc.AddSeconds(1)); + RetentionContributionResult fenced = + await ExecuteReservationProviderAsync( + worker, + localTenant, + fencedRequest).ConfigureAwait(false); + Assert.Equal(RetentionContributionStatus.Failed, fenced.Status); + Assert.Equal( + ReservationProviderPolicyUnavailableOutcome, + fenced.OutcomeCode); + await AssertReservationProviderHasNoMutationAsync( + worker, + localTenant, + localReservationId).ConfigureAwait(false); + } + + [DockerFact] + [Trait("Category", "Docker")] + [Trait("Category", "Integration")] + public async Task + Reservations_provider_bounds_growth_and_recovers_partial_attempt() + { + await using PostgreSqlContainer postgreSql = + new PostgreSqlBuilder("postgres:16-alpine") + .WithDatabase("bunkfy_reservation_retention_bounds") + .Build(); + await postgreSql.StartAsync().ConfigureAwait(false); + + using IHost worker = CreateWorker( + postgreSql.GetConnectionString()); + await MigrateAsync(worker).ConfigureAwait(false); + await AssertReservationProviderPageBoundAsync(worker) + .ConfigureAwait(false); + await AssertReservationProviderPolicySnapshotIsAtomicAsync( + worker, + postgreSql.GetConnectionString()).ConfigureAwait(false); + await AssertReservationProviderLegacyCheckpointRetryAsync(worker) + .ConfigureAwait(false); + await AssertReservationProviderSplitCrashRecoveryAsync(worker) + .ConfigureAwait(false); + + const string tenantId = + "6e000000-0000-0000-0000-000000000010"; + Guid validPropertyId = Guid.Parse( + "6e100000-0000-0000-0000-000000000010"); + Guid overflowPropertyId = Guid.Parse( + "6e100000-0000-0000-0000-000000000011"); + Guid validReservationId = + await SeedReservationProviderCandidateAsync( + worker, + tenantId, + validPropertyId).ConfigureAwait(false); + Guid overflowReservationId = + await SeedReservationProviderCandidateAsync( + worker, + tenantId, + overflowPropertyId).ConfigureAwait(false); + await InsertReservationAcknowledgementsAsync( + worker, + tenantId, + overflowPropertyId, + PropertiesContractLimits.MaximumPolicyAcknowledgements - 1) + .ConfigureAwait(false); + + using (IServiceScope scope = CreateReservationProviderScope( + worker, + tenantId)) + { + ReservationsDbContext reservations = scope.ServiceProvider + .GetRequiredService(); + ReservationCandidateScanProof maximum = + await InvokeReservationCandidateScanAsync( + CreateReservationCandidateRepository(reservations), + afterProjectionOrdinal: 0, + limit: 10, + CancellationToken.None).ConfigureAwait(false); + object overflowCandidate = Assert.Single( + maximum.Candidates, + candidate => GetReservationCandidatePropertyId(candidate) == + overflowPropertyId); + Assert.Equal( + PropertiesContractLimits.MaximumPolicyAcknowledgements, + GetReservationCandidateAcknowledgementCount( + overflowCandidate)); + } + + ReservationCandidateScanProof raced; + using (IServiceScope scope = CreateReservationProviderScope( + worker, + tenantId)) + { + string connectionString = scope.ServiceProvider + .GetRequiredService() + .Database.GetConnectionString()!; + var growth = new ReservationAcknowledgementGrowthInterceptor( + connectionString, + overflowPropertyId); + DbContextOptions options = + new DbContextOptionsBuilder() + .UseNpgsql(connectionString) + .AddInterceptors(growth) + .Options; + await using var racedContext = new ReservationsDbContext( + options, + new ReservationProviderScopeContext(tenantId)); + raced = await InvokeReservationCandidateScanAsync( + CreateReservationCandidateRepository(racedContext), + afterProjectionOrdinal: 0, + limit: 10, + CancellationToken.None).ConfigureAwait(false); + Assert.True(growth.Inserted); + Assert.Equal(1, growth.MatchedCommandCount); + AssertReservationPropertyPolicyQueryShape( + growth.MatchedCommandText); + } + + object racedOverflow = Assert.Single( + raced.Candidates, + candidate => GetReservationCandidatePropertyId(candidate) == + overflowPropertyId); + object? racedProperty = GetReservationCandidateProperty( + racedOverflow); + Assert.NotNull(racedProperty); + Assert.Equal( + PropertiesContractLimits.MaximumPolicyAcknowledgements, + GetReservationCandidateAcknowledgementCount( + racedOverflow)); + + await AssertReservationProviderTaskRetryAsync( + worker, + tenantId, + validReservationId, + overflowPropertyId, + overflowReservationId, + GetReservationCandidateVersion(racedOverflow), + GetReservationCandidateDetailsRevision(racedOverflow)) + .ConfigureAwait(false); + } + + private static async Task AssertReservationProviderPageBoundAsync( + IHost worker) + { + const string tenantId = + "6e000000-0000-0000-0000-000000000020"; + Guid propertyId = Guid.Parse( + "6e100000-0000-0000-0000-000000000020"); + await ApplyReservationProviderPropertyAsync( + worker, + tenantId, + propertyId).ConfigureAwait(false); + using (IServiceScope seed = CreateReservationProviderScope( + worker, + tenantId)) + { + await SeedReservationAsync( + seed.ServiceProvider, + tenantId, + propertyId, + DateTimeOffset.UtcNow).ConfigureAwait(false); + await SeedReservationAsync( + seed.ServiceProvider, + tenantId, + propertyId, + DateTimeOffset.UtcNow).ConfigureAwait(false); + } + + using IServiceScope scope = CreateReservationProviderScope( + worker, + tenantId); + ReservationsDbContext reservations = scope.ServiceProvider + .GetRequiredService(); + object repository = CreateReservationCandidateRepository( + reservations); + ReservationCandidateScanProof first = + await InvokeReservationCandidateScanAsync( + repository, + afterProjectionOrdinal: 0, + limit: 1, + CancellationToken.None).ConfigureAwait(false); + object firstCandidate = Assert.Single(first.Candidates); + Assert.False(first.ReachedEnd); + long firstOrdinal = GetReservationCandidateProjectionOrdinal( + firstCandidate); + ReservationCandidateScanProof second = + await InvokeReservationCandidateScanAsync( + repository, + firstOrdinal, + limit: 1, + CancellationToken.None).ConfigureAwait(false); + object secondCandidate = Assert.Single(second.Candidates); + Assert.True(second.ReachedEnd); + Assert.True( + GetReservationCandidateProjectionOrdinal(secondCandidate) > + firstOrdinal); + + ArgumentOutOfRangeException failure = + await Assert.ThrowsAsync(() => + InvokeReservationCandidateScanAsync( + repository, + afterProjectionOrdinal: 0, + limit: 1001, + CancellationToken.None)); + Assert.Equal("limit", failure.ParamName); + } + + private static async Task + AssertReservationProviderPolicySnapshotIsAtomicAsync( + IHost worker, + string connectionString) + { + const string tenantId = + "6e000000-0000-0000-0000-000000000030"; + Guid propertyId = Guid.Parse( + "6e100000-0000-0000-0000-000000000030"); + Guid reservationId = + await SeedReservationProviderCandidateAsync( + worker, + tenantId, + propertyId).ConfigureAwait(false); + var replacement = new ReservationPolicyGenerationReplacementInterceptor( + connectionString, + propertyId); + DbContextOptions options = + new DbContextOptionsBuilder() + .UseNpgsql(connectionString) + .AddInterceptors(replacement) + .Options; + ReservationCandidateScanProof page; + await using (var racedContext = new ReservationsDbContext( + options, + new ReservationProviderScopeContext(tenantId))) + { + page = await InvokeReservationCandidateScanAsync( + CreateReservationCandidateRepository(racedContext), + afterProjectionOrdinal: 0, + limit: 1, + CancellationToken.None).ConfigureAwait(false); + } + + Assert.True(replacement.Replaced); + Assert.Equal(1, replacement.MatchedCommandCount); + AssertReservationPropertyPolicyQueryShape( + replacement.MatchedCommandText); + object candidate = Assert.Single(page.Candidates); + Assert.Equal( + reservationId, + GetReservationCandidateReservationId(candidate)); + Assert.Equal(2, GetReservationCandidatePolicySourceVersion( + candidate)); + PropertyGovernancePolicyBinding policy = + GetReservationCandidatePolicy(candidate); + Assert.Equal("integration-hostel-baseline", policy.PolicyId); + PropertyGovernanceAcknowledgement acknowledgement = + Assert.Single(policy.Acknowledgements); + Assert.Equal( + "integration-operator-notice", + acknowledgement.AcknowledgementId); + Assert.Equal(1, acknowledgement.AcknowledgementVersion); + + await using var connection = new NpgsqlConnection( + connectionString); + await connection.OpenAsync().ConfigureAwait(false); + await using NpgsqlCommand command = connection.CreateCommand(); + command.CommandText = """ + SELECT + property."PolicySourceVersion", + property."JurisdictionPolicyId", + acknowledgement."AcknowledgementId" + FROM reservations.property_projection AS property + INNER JOIN reservations.property_policy_acknowledgements + AS acknowledgement + ON acknowledgement."PropertyId" = property."Id" + WHERE property."Id" = @property_id; + """; + command.Parameters.AddWithValue("property_id", propertyId); + await using NpgsqlDataReader reader = + await command.ExecuteReaderAsync().ConfigureAwait(false); + Assert.True(await reader.ReadAsync().ConfigureAwait(false)); + Assert.Equal(3L, reader.GetInt64(0)); + Assert.Equal( + "reservation-provider-replacement", + reader.GetString(1)); + Assert.Equal( + "reservation-provider-replacement-ack", + reader.GetString(2)); + Assert.False(await reader.ReadAsync().ConfigureAwait(false)); + } + + private static async Task + AssertReservationProviderLegacyCheckpointRetryAsync( + IHost worker) + { + const string tenantId = + "6e000000-0000-0000-0000-000000000035"; + Guid propertyId = Guid.Parse( + "6e100000-0000-0000-0000-000000000035"); + Guid reservationId = + await SeedReservationProviderCandidateAsync( + worker, + tenantId, + propertyId).ConfigureAwait(false); + Guid executionId = Guid.NewGuid(); + DateTimeOffset startedAtUtc = DateTimeOffset.UtcNow + .AddMinutes(-2); + startedAtUtc = startedAtUtc.AddTicks( + -(startedAtUtc.Ticks % + TimeSpan.TicksPerMicrosecond)); + DateTimeOffset completedAtUtc = + startedAtUtc.AddMinutes(1); + long advancedCursor; + using (IServiceScope seed = CreateReservationProviderScope( + worker, + tenantId)) + { + ReservationsDbContext reservations = seed.ServiceProvider + .GetRequiredService(); + advancedCursor = await reservations.Reservations + .AsNoTracking() + .Where(item => item.Id == reservationId) + .Select(item => item.ProjectionOrdinal) + .SingleAsync().ConfigureAwait(false); + ReservationRetentionSweepCheckpoint checkpoint = + ReservationRetentionSweepCheckpoint.Create( + Guid.NewGuid(), + tenantId, + ReservationOperationalDataClass, + executionPolicyVersion: 1, + startedAtUtc).Value; + ReservationRetentionExecution execution = + ReservationRetentionExecution.Start( + executionId, + tenantId, + ReservationOperationalDataClass, + executionPolicyVersion: 1, + attempt: 1, + startingProjectionOrdinal: 0, + startedAtUtc, + startedAtUtc.AddMinutes(10)).Value; + Assert.True(execution.Complete( + ReservationRetentionExecutionState.Failed, + attempt: 1, + scannedCount: 1, + remainingCount: 1, + ReservationProviderPolicyUnavailableOutcome, + completedAtUtc, + holdReviewDueAtUtc: null).IsSuccess); + Assert.True(checkpoint.Advance( + expectedAfterProjectionOrdinal: 0, + advancedCursor, + executionId, + completedAtUtc).IsSuccess); + reservations.RetentionSweepCheckpoints.Add(checkpoint); + reservations.RetentionExecutions.Add(execution); + await reservations.SaveChangesAsync().ConfigureAwait(false); + } + + using (IServiceScope legacy = CreateReservationProviderScope( + worker, + tenantId)) + { + ReservationsDbContext reservations = legacy.ServiceProvider + .GetRequiredService(); + ReservationRetentionExecution failed = await reservations + .RetentionExecutions.AsNoTracking() + .SingleAsync().ConfigureAwait(false); + ReservationRetentionSweepCheckpoint advanced = + await reservations.RetentionSweepCheckpoints + .AsNoTracking() + .SingleAsync().ConfigureAwait(false); + Assert.Equal( + ReservationRetentionExecutionState.Failed, + failed.State); + Assert.Equal(failed.Id, advanced.LastExecutionId); + Assert.Equal(advancedCursor, advanced.AfterProjectionOrdinal); + Assert.Equal(failed.CompletedAtUtc, advanced.UpdatedAtUtc); + } + + RetentionContributionRequest retry = + CreateReservationProviderRequest( + worker, + tenantId, + executionId, + attempt: 2); + RetentionContributionResult result = + await ExecuteReservationProviderAsync( + worker, + tenantId, + retry).ConfigureAwait(false); + Assert.Equal(RetentionContributionStatus.Completed, result.Status); + Assert.Equal(1, result.ScannedCount); + Assert.Equal(1, result.AffectedCount); + Assert.Equal(0, result.RemainingCount); + await AssertReservationProviderProofAsync( + worker, + tenantId, + reservationId, + executionId, + expectedAttempt: 2, + result.CompletedAtUtc, + earliestReceiptAtUtc: retry.StartedAtUtc) + .ConfigureAwait(false); + using IServiceScope proof = CreateReservationProviderScope( + worker, + tenantId); + ReservationRetentionSweepCheckpoint recovered = await proof + .ServiceProvider.GetRequiredService() + .RetentionSweepCheckpoints.AsNoTracking() + .SingleAsync().ConfigureAwait(false); + Assert.Equal(0, recovered.AfterProjectionOrdinal); + Assert.Equal(executionId, recovered.LastExecutionId); + } + + private static async Task + AssertReservationProviderSplitCrashRecoveryAsync( + IHost worker) + { + const string tenantId = + "6e000000-0000-0000-0000-000000000040"; + Guid propertyId = Guid.Parse( + "6e100000-0000-0000-0000-000000000040"); + Guid reservationId = + await SeedReservationProviderCandidateAsync( + worker, + tenantId, + propertyId).ConfigureAwait(false); + await ApplyReservationProviderRetentionTenantAsync( + worker, + tenantId).ConfigureAwait(false); + Guid runId = Guid.NewGuid(); + var payload = new ExecuteRetentionSchedulePayload( + ReservationRetentionOwner, + ReservationOperationalDataClass, + ExecutionPolicyVersion: 1, + RetentionTargetScopeKind.Tenant); + DateTimeOffset createdAtUtc = DateTimeOffset.UtcNow; + using (IServiceScope enqueue = worker.Services.CreateScope()) + { + TaskRunEnqueueResult enqueued = await enqueue.ServiceProvider + .GetRequiredService() + .EnqueueAsync( + new TaskRunRequest( + runId, + RetentionModuleMetadata.Name, + ExecuteRetentionSchedulePayload.TaskName, + JsonSerializer.Serialize(payload), + createdAtUtc, + createdAtUtc, + RetentionModuleMetadata.WorkerGroup, + tenantId, + requestedBy: + "reservation-provider-split-crash", + maxAttempts: 1, + payloadVersion: + ExecuteRetentionSchedulePayload.PayloadVersion, + deduplicationKey: + $"reservation-provider-split:{runId:N}"), + CancellationToken.None).ConfigureAwait(false); + Assert.True(enqueued.Created); + } + + TaskRunLease firstLease = await ClaimReservationProviderRunAsync( + worker, + createdAtUtc.AddSeconds(1), + "reservation-provider-split-worker-1") + .ConfigureAwait(false); + Assert.Equal(1, firstLease.Attempt); + Assert.Equal(1, firstLease.LeaseGeneration); + TaskExecutionContext firstContext = + firstLease.CreateExecutionContext(); + using (IServiceScope started = worker.Services.CreateScope()) + { + Assert.Equal( + TaskRunMutationOutcome.Applied, + await started.ServiceProvider + .GetRequiredService() + .MarkStartedAsync( + firstContext, + DateTimeOffset.UtcNow, + CancellationToken.None).ConfigureAwait(false)); + } + + RetentionContributionResult ownerResult; + using (IServiceScope ownerScope = CreateReservationProviderScope( + worker, + tenantId)) + { + IRetentionExecutionContributor contributor = ownerScope + .ServiceProvider + .GetServices() + .Single(item => + item.Schedule.OwnerKey == ReservationRetentionOwner && + item.Schedule.DataClassKey == + ReservationOperationalDataClass); + DateTimeOffset startedAtUtc = ownerScope.ServiceProvider + .GetRequiredService().UtcNow; + Result central = await ownerScope + .ServiceProvider + .GetRequiredService() + .DispatchAsync< + BeginRetentionExecutionCommand, + RetentionExecutionStart>( + firstContext, + new( + runId, + tenantId, + payload.OwnerKey, + payload.DataClassKey, + payload.TargetScopeKind, + payload.PropertyId, + payload.ExecutionPolicyVersion, + firstLease.LeaseGeneration, + startedAtUtc, + startedAtUtc + + contributor.Schedule.ExecutionTimeout, + startedAtUtc + contributor.Schedule.Interval), + CancellationToken.None).ConfigureAwait(false); + Assert.True(central.IsSuccess, central.Error.Code); + Assert.True(central.Value.DispatchRequired); + Assert.False(central.Value.AttemptAdvanced); + ownerResult = await contributor.ExecuteAsync( + central.Value.Request, + CancellationToken.None).ConfigureAwait(false); + } + + Assert.Equal( + RetentionContributionStatus.Completed, + ownerResult.Status); + ReservationProviderFenceProof split = + await ReadReservationProviderFenceProofAsync( + worker, + tenantId, + runId).ConfigureAwait(false); + Assert.Equal(1, split.OwnerAttempt); + Assert.Equal( + ReservationRetentionExecutionState.Completed, + split.OwnerState); + Assert.Equal(1, split.CentralAttempt); + Assert.Equal(RetentionExecutionState.Running, split.CentralState); + Assert.Equal(runId, split.CheckpointLastExecutionId); + await AssertReservationProviderSingleMutationAsync( + worker, + tenantId, + reservationId, + expectedReceiptCount: 1).ConfigureAwait(false); + + using (IServiceScope failed = worker.Services.CreateScope()) + { + Assert.Equal( + TaskRunMutationOutcome.Applied, + await failed.ServiceProvider + .GetRequiredService() + .MarkFailedAsync( + firstContext, + "simulated split crash after owner commit", + DateTimeOffset.UtcNow, + retryAtUtc: null, + CancellationToken.None).ConfigureAwait(false)); + } + + await Task.Delay(10).ConfigureAwait(false); + DateTimeOffset retryAtUtc = DateTimeOffset.UtcNow; + using (IServiceScope retry = worker.Services.CreateScope()) + { + Assert.Equal( + TaskRunMutationOutcome.Applied, + await retry.ServiceProvider + .GetRequiredService() + .RetryAsync( + runId, + "reservation-provider-split-operator", + retryAtUtc, + CancellationToken.None).ConfigureAwait(false)); + } + + TaskRunLease secondLease = await ClaimReservationProviderRunAsync( + worker, + retryAtUtc.AddSeconds(1), + "reservation-provider-split-worker-2") + .ConfigureAwait(false); + Assert.Equal(1, secondLease.Attempt); + Assert.Equal(2, secondLease.LeaseGeneration); + _ = await ExecuteReservationProviderLeaseAsync( + worker, + tenantId, + payload, + secondLease, + expectFailure: false).ConfigureAwait(false); + TaskRunDetails succeeded = + await GetReservationProviderRunAsync(worker, runId) + .ConfigureAwait(false); + Assert.Equal(TaskRunStatus.Succeeded, succeeded.Summary.Status); + ReservationProviderFenceProof recovered = + await ReadReservationProviderFenceProofAsync( + worker, + tenantId, + runId).ConfigureAwait(false); + Assert.Equal(split.OwnerAttempt, recovered.OwnerAttempt); + Assert.Equal(split.OwnerState, recovered.OwnerState); + Assert.Equal( + split.OwnerScannedCount, + recovered.OwnerScannedCount); + Assert.Equal( + split.OwnerAffectedCount, + recovered.OwnerAffectedCount); + Assert.Equal( + split.OwnerRemainingCount, + recovered.OwnerRemainingCount); + Assert.Equal( + split.OwnerCompletedAtUtc, + recovered.OwnerCompletedAtUtc); + Assert.Equal(split.OwnerVersion, recovered.OwnerVersion); + Assert.Equal( + split.CheckpointLastExecutionId, + recovered.CheckpointLastExecutionId); + Assert.Equal( + split.CheckpointAfterProjectionOrdinal, + recovered.CheckpointAfterProjectionOrdinal); + Assert.Equal( + split.CheckpointVersion, + recovered.CheckpointVersion); + Assert.Equal(2, recovered.CentralAttempt); + Assert.Equal( + RetentionExecutionState.Completed, + recovered.CentralState); + Assert.True( + recovered.OwnerCompletedAtUtc < + recovered.CentralStartedAtUtc); + Assert.Equal( + recovered.CentralStartedAtUtc, + recovered.CentralCompletedAtUtc); + await AssertReservationProviderSingleMutationAsync( + worker, + tenantId, + reservationId, + expectedReceiptCount: 1).ConfigureAwait(false); + } + + private static async Task AssertReservationProviderTaskRetryAsync( + IHost worker, + string tenantId, + Guid validReservationId, + Guid overflowPropertyId, + Guid overflowReservationId, + long overflowReservationVersion, + long overflowDetailsRevision) + { + await ApplyReservationProviderRetentionTenantAsync( + worker, + tenantId).ConfigureAwait(false); + Guid runId = Guid.NewGuid(); + var payload = new ExecuteRetentionSchedulePayload( + ReservationRetentionOwner, + ReservationOperationalDataClass, + ExecutionPolicyVersion: 1, + RetentionTargetScopeKind.Tenant); + DateTimeOffset createdAtUtc = DateTimeOffset.UtcNow; + using (IServiceScope enqueue = worker.Services.CreateScope()) + { + TaskRunEnqueueResult enqueued = await enqueue.ServiceProvider + .GetRequiredService() + .EnqueueAsync( + new TaskRunRequest( + runId, + RetentionModuleMetadata.Name, + ExecuteRetentionSchedulePayload.TaskName, + JsonSerializer.Serialize(payload), + createdAtUtc, + createdAtUtc, + RetentionModuleMetadata.WorkerGroup, + tenantId, + requestedBy: + "reservation-provider-integration-test", + maxAttempts: 1, + payloadVersion: + ExecuteRetentionSchedulePayload.PayloadVersion, + deduplicationKey: + $"reservation-provider:{runId:N}"), + CancellationToken.None).ConfigureAwait(false); + Assert.True(enqueued.Created); + } + + TaskRunLease firstLease = await ClaimReservationProviderRunAsync( + worker, + createdAtUtc.AddSeconds(1), + "reservation-provider-worker-1").ConfigureAwait(false); + Assert.Equal(runId, firstLease.RunId); + Assert.Equal(1, firstLease.Attempt); + Assert.Equal(1, firstLease.LeaseGeneration); + InvalidOperationException ownerFailure = + Assert.IsType( + await ExecuteReservationProviderLeaseAsync( + worker, + tenantId, + payload, + firstLease, + expectFailure: true).ConfigureAwait(false)); + Assert.Equal( + "Retention.OwnerReportedFailure", + ownerFailure.Message); + TaskRunDetails failedRun = + await GetReservationProviderRunAsync(worker, runId) + .ConfigureAwait(false); + Assert.Equal(TaskRunStatus.Failed, failedRun.Summary.Status); + Assert.Equal(1, failedRun.Summary.Attempts); + Assert.Equal(1, failedRun.Summary.MaxAttempts); + await AssertReservationProviderMutationCountsAsync( + worker, + tenantId, + validReservationId, + overflowReservationId, + expectedReceiptCount: 1).ConfigureAwait(false); + await AssertReservationProviderExecutionAsync( + worker, + tenantId, + runId, + expectedAttempt: 1, + ReservationRetentionExecutionState.Failed, + expectedScannedCount: 2, + expectedAffectedCount: 1, + expectedRemainingCount: 1).ConfigureAwait(false); + + await DeleteSyntheticReservationAcknowledgementsAsync( + worker, + tenantId, + overflowPropertyId).ConfigureAwait(false); + DateTimeOffset retryAtUtc = DateTimeOffset.UtcNow; + using (IServiceScope retry = worker.Services.CreateScope()) + { + TaskRunMutationOutcome retried = await retry.ServiceProvider + .GetRequiredService() + .RetryAsync( + runId, + "reservation-provider-operator", + retryAtUtc, + CancellationToken.None).ConfigureAwait(false); + Assert.Equal(TaskRunMutationOutcome.Applied, retried); + } + + TaskRunLease secondLease = await ClaimReservationProviderRunAsync( + worker, + retryAtUtc.AddSeconds(1), + "reservation-provider-worker-2").ConfigureAwait(false); + Assert.Equal(runId, secondLease.RunId); + Assert.Equal(1, secondLease.Attempt); + Assert.Equal(2, secondLease.LeaseGeneration); + _ = await ExecuteReservationProviderLeaseAsync( + worker, + tenantId, + payload, + secondLease, + expectFailure: false).ConfigureAwait(false); + TaskRunDetails succeededRun = + await GetReservationProviderRunAsync(worker, runId) + .ConfigureAwait(false); + Assert.Equal(TaskRunStatus.Succeeded, succeededRun.Summary.Status); + Assert.Equal(1, succeededRun.Summary.Attempts); + Assert.Equal(1, succeededRun.Summary.MaxAttempts); + await AssertReservationProviderMutationCountsAsync( + worker, + tenantId, + validReservationId, + overflowReservationId, + expectedReceiptCount: 2).ConfigureAwait(false); + + ReservationProviderFenceProof before = + await ReadReservationProviderFenceProofAsync( + worker, + tenantId, + runId).ConfigureAwait(false); + Assert.Equal(2, before.OwnerAttempt); + Assert.Equal( + ReservationRetentionExecutionState.Completed, + before.OwnerState); + Assert.Equal(2, before.OwnerScannedCount); + Assert.Equal(2, before.OwnerAffectedCount); + Assert.Equal(0, before.OwnerRemainingCount); + Assert.Equal(2, before.CentralAttempt); + Assert.Equal(RetentionExecutionState.Completed, before.CentralState); + Assert.Equal(runId, before.CheckpointLastExecutionId); + Assert.Equal(0, before.CheckpointAfterProjectionOrdinal); + + string staleApplyCode = await DispatchReservationCommandAsync( + worker, + tenantId, + "BunkFy.Modules.Reservations.Application.Commands." + + "ApplyReservationRetentionCommand", + [ + runId, + 1, + overflowPropertyId, + overflowReservationId, + overflowReservationVersion, + overflowDetailsRevision + ]).ConfigureAwait(false); + Assert.Equal( + "Reservations.RetentionExecutionNotFound", + staleApplyCode); + string staleCompleteCode = await DispatchReservationCommandAsync( + worker, + tenantId, + "BunkFy.Modules.Reservations.Application.Commands." + + "CompleteReservationRetentionExecutionCommand", + [ + runId, + 1, + ReservationRetentionExecutionState.Completed, + before.OwnerScannedCount, + before.OwnerRemainingCount, + ReservationRetentionCompletedOutcome, + before.OwnerCompletedAtUtc, + null, + 0L, + 0L + ]).ConfigureAwait(false); + Assert.Equal( + "Reservations.RetentionExecutionResultInvalid", + staleCompleteCode); + ReservationProviderFenceProof after = + await ReadReservationProviderFenceProofAsync( + worker, + tenantId, + runId).ConfigureAwait(false); + Assert.Equal(before, after); + await AssertReservationProviderMutationCountsAsync( + worker, + tenantId, + validReservationId, + overflowReservationId, + expectedReceiptCount: 2).ConfigureAwait(false); + } + + private static async Task + ApplyReservationProviderRetentionTenantAsync( + IHost worker, + string tenantId) + { + using IServiceScope scope = CreateReservationProviderScope( + worker, + tenantId); + IIntegrationEventSubscriptionRegistry subscriptions = + scope.ServiceProvider.GetRequiredService< + IIntegrationEventSubscriptionRegistry>(); + IntegrationEventSubscription subscription = subscriptions + .Subscriptions.Single(item => + item.ConsumerModule == RetentionModuleMetadata.Name && + item.EventType == + typeof(OrganizationChangedIntegrationEvent)); + var handler = + (IIntegrationEventHandler) + scope.ServiceProvider.GetRequiredService( + subscription.HandlerType); + RetentionDbContext retention = scope.ServiceProvider + .GetRequiredService(); + await using var transaction = await retention.Database + .BeginTransactionAsync().ConfigureAwait(false); + await handler.HandleAsync( + new OrganizationChangedIntegrationEvent( + Guid.NewGuid(), + DateTimeOffset.UtcNow, + tenantId, + Guid.NewGuid(), + OrganizationChange.Created, + OrganizationStatus.Active, + organizationVersion: 1), + CancellationToken.None).ConfigureAwait(false); + await retention.SaveChangesAsync().ConfigureAwait(false); + await transaction.CommitAsync().ConfigureAwait(false); + } + + private static async Task + ClaimReservationProviderRunAsync( + IHost worker, + DateTimeOffset claimedAtUtc, + string workerId) + { + using IServiceScope scope = worker.Services.CreateScope(); + IReadOnlyList leases = await scope.ServiceProvider + .GetRequiredService() + .ClaimReadyAsync( + new TaskWorkerClaim( + RetentionModuleMetadata.WorkerGroup, + workerId, + "reservation-provider-node", + claimedAtUtc, + maxRuns: 1, + leaseDuration: TimeSpan.FromMinutes(1)), + CancellationToken.None).ConfigureAwait(false); + return Assert.Single(leases); + } + + private static async Task + ExecuteReservationProviderLeaseAsync( + IHost worker, + string tenantId, + ExecuteRetentionSchedulePayload payload, + TaskRunLease lease, + bool expectFailure) + { + TaskExecutionContext context = lease.CreateExecutionContext(); + DateTimeOffset startedAtUtc = DateTimeOffset.UtcNow; + using (IServiceScope started = worker.Services.CreateScope()) + { + Assert.Equal( + TaskRunMutationOutcome.Applied, + await started.ServiceProvider + .GetRequiredService() + .MarkStartedAsync( + context, + startedAtUtc, + CancellationToken.None).ConfigureAwait(false)); + } + + InvalidOperationException? failure = null; + using (IServiceScope execution = CreateReservationProviderScope( + worker, + tenantId)) + { + if (expectFailure) + { + failure = await Assert.ThrowsAsync< + InvalidOperationException>(() => + InvokeReservationProviderTaskHandlerAsync( + execution.ServiceProvider, + payload, + context, + CancellationToken.None)); + } + else + { + await InvokeReservationProviderTaskHandlerAsync( + execution.ServiceProvider, + payload, + context, + CancellationToken.None).ConfigureAwait(false); + } + } + + using IServiceScope completed = worker.Services.CreateScope(); + ITaskRunStore store = completed.ServiceProvider + .GetRequiredService(); + DateTimeOffset completedAtUtc = DateTimeOffset.UtcNow; + TaskRunMutationOutcome outcome = expectFailure + ? await store.MarkFailedAsync( + context, + failure!.Message, + completedAtUtc, + retryAtUtc: null, + CancellationToken.None).ConfigureAwait(false) + : await store.MarkSucceededAsync( + context, + completedAtUtc, + CancellationToken.None).ConfigureAwait(false); + Assert.Equal(TaskRunMutationOutcome.Applied, outcome); + return failure; + } + + private static Task InvokeReservationProviderTaskHandlerAsync( + IServiceProvider services, + ExecuteRetentionSchedulePayload payload, + TaskExecutionContext context, + CancellationToken cancellationToken) + { + TaskHandlerRegistration registration = Assert.IsType< + TaskHandlerRegistration>(services + .GetRequiredService() + .Find( + RetentionModuleMetadata.Name, + ExecuteRetentionSchedulePayload.TaskName, + ExecuteRetentionSchedulePayload.PayloadVersion)); + object handler = services.GetRequiredService( + registration.HandlerType); + MethodInfo handle = typeof(ITaskHandler< + ExecuteRetentionSchedulePayload>) + .GetMethod(nameof(ITaskHandler<>.HandleAsync))!; + return Assert.IsType( + handle.Invoke( + handler, + [payload, context, cancellationToken]), + exactMatch: false); + } + + private static async Task + GetReservationProviderRunAsync( + IHost worker, + Guid runId) + { + using IServiceScope scope = worker.Services.CreateScope(); + return Assert.IsType( + await scope.ServiceProvider + .GetRequiredService() + .GetAsync(runId, CancellationToken.None) + .ConfigureAwait(false)); + } + + private static async Task + AssertReservationProviderExecutionAsync( + IHost worker, + string tenantId, + Guid executionId, + int expectedAttempt, + ReservationRetentionExecutionState expectedState, + int expectedScannedCount, + int expectedAffectedCount, + int expectedRemainingCount) + { + using IServiceScope scope = CreateReservationProviderScope( + worker, + tenantId); + ReservationRetentionExecution execution = await scope + .ServiceProvider.GetRequiredService() + .RetentionExecutions.AsNoTracking() + .SingleAsync(item => item.Id == executionId) + .ConfigureAwait(false); + Assert.Equal(expectedAttempt, execution.Attempt); + Assert.Equal(expectedState, execution.State); + Assert.Equal(expectedScannedCount, execution.ScannedCount); + Assert.Equal(expectedAffectedCount, execution.AffectedCount); + Assert.Equal(expectedRemainingCount, execution.RemainingCount); + } + + private static async Task + ReadReservationProviderFenceProofAsync( + IHost worker, + string tenantId, + Guid executionId) + { + using IServiceScope scope = CreateReservationProviderScope( + worker, + tenantId); + ReservationsDbContext reservations = scope.ServiceProvider + .GetRequiredService(); + ReservationRetentionExecution owner = await reservations + .RetentionExecutions.AsNoTracking() + .SingleAsync(item => item.Id == executionId) + .ConfigureAwait(false); + ReservationRetentionSweepCheckpoint checkpoint = + await reservations.RetentionSweepCheckpoints + .AsNoTracking() + .SingleAsync().ConfigureAwait(false); + RetentionExecution central = await scope.ServiceProvider + .GetRequiredService() + .Executions.AsNoTracking() + .SingleAsync(item => item.Id == executionId) + .ConfigureAwait(false); + return new( + owner.Attempt, + owner.State, + owner.ScannedCount!.Value, + owner.AffectedCount, + owner.RemainingCount!.Value, + owner.CompletedAtUtc!.Value, + owner.Version, + central.Attempt, + central.State, + central.StartedAtUtc, + central.CompletedAtUtc, + central.Version, + checkpoint.LastExecutionId, + checkpoint.AfterProjectionOrdinal, + checkpoint.Version); + } + + private static async Task DispatchReservationCommandAsync( + IHost worker, + string tenantId, + string commandTypeName, + object?[] arguments) + { + using IServiceScope scope = CreateReservationProviderScope( + worker, + tenantId); + IRetentionExecutionContributor contributor = scope.ServiceProvider + .GetServices() + .Single(item => + item.Schedule.OwnerKey == ReservationRetentionOwner && + item.Schedule.DataClassKey == + ReservationOperationalDataClass); + Type commandType = Assert.IsType( + contributor.GetType().Assembly.GetType( + commandTypeName, + throwOnError: true), + exactMatch: false); + object command = Activator.CreateInstance( + commandType, + BindingFlags.Instance | + BindingFlags.Public | + BindingFlags.NonPublic, + binder: null, + args: arguments, + culture: null)!; + Type commandInterface = commandType.GetInterfaces().Single( + type => + type.IsGenericType && + type.GetGenericTypeDefinition() == typeof(ICommand<>)); + Type responseType = commandInterface.GenericTypeArguments[0]; + MethodInfo send = typeof(IRequestDispatcher) + .GetMethod(nameof(IRequestDispatcher.SendAsync))! + .MakeGenericMethod(responseType); + Task dispatched = Assert.IsType( + send.Invoke( + scope.ServiceProvider + .GetRequiredService(), + [command, CancellationToken.None]), + exactMatch: false); + await dispatched.ConfigureAwait(false); + object result = dispatched.GetType().GetProperty("Result")! + .GetValue(dispatched)!; + Assert.True(Assert.IsType(result.GetType() + .GetProperty("IsFailure")!.GetValue(result))); + object error = result.GetType().GetProperty("Error")! + .GetValue(result)!; + return Assert.IsType(error.GetType() + .GetProperty("Code")!.GetValue(error)); + } + + private static void AssertReservationPropertyPolicyQueryShape( + string? commandText) + { + Assert.NotNull(commandText); + Assert.Contains( + "property_projection", + commandText, + StringComparison.OrdinalIgnoreCase); + Assert.Contains( + "property_policy_acknowledgements", + commandText, + StringComparison.OrdinalIgnoreCase); + Assert.Contains( + "LATERAL", + commandText, + StringComparison.OrdinalIgnoreCase); + Assert.Contains( + "LIMIT 65", + commandText, + StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain( + "ROW_NUMBER", + commandText, + StringComparison.OrdinalIgnoreCase); + } + + private static async Task + SeedReservationProviderCandidateAsync( + IHost worker, + string tenantId, + Guid propertyId) + { + await ApplyReservationProviderPropertyAsync( + worker, + tenantId, + propertyId).ConfigureAwait(false); + using IServiceScope scope = CreateReservationProviderScope( + worker, + tenantId); + return await SeedReservationAsync( + scope.ServiceProvider, + tenantId, + propertyId, + DateTimeOffset.UtcNow).ConfigureAwait(false); + } + + private static async Task ApplyReservationProviderPropertyAsync( + IHost worker, + string tenantId, + Guid propertyId) + { + using IServiceScope scope = CreateReservationProviderScope( + worker, + tenantId); + IIntegrationEventSubscriptionRegistry subscriptions = + scope.ServiceProvider.GetRequiredService< + IIntegrationEventSubscriptionRegistry>(); + await ApplyReservationPropertyCreatedAsync( + scope.ServiceProvider, + subscriptions, + new PropertyCreatedIntegrationEvent( + Guid.NewGuid(), + tenantId, + DateTimeOffset.UtcNow, + propertyId, + $"Reservation Provider {propertyId:N}", + $"reservation-provider-{propertyId:N}", + "UTC", + PropertyStatus.Active, + propertyVersion: 1)).ConfigureAwait(false); + await CountryPolicyIntegrationTestData.ApplyActivationAsync( + scope.ServiceProvider, + ReservationsModuleMetadata.Name, + tenantId, + propertyId, + propertyVersion: 2).ConfigureAwait(false); + await scope.ServiceProvider + .GetRequiredService() + .SaveChangesAsync().ConfigureAwait(false); + } + + private static RetentionContributionRequest + CreateReservationProviderRequest( + IHost worker, + string tenantId, + Guid executionId, + int attempt) + { + using IServiceScope scope = CreateReservationProviderScope( + worker, + tenantId); + IRetentionExecutionContributor contributor = scope.ServiceProvider + .GetServices() + .Single(item => + item.Schedule.OwnerKey == ReservationRetentionOwner && + item.Schedule.DataClassKey == + ReservationOperationalDataClass); + DateTimeOffset startedAtUtc = DateTimeOffset.UtcNow; + return new( + RetentionExecutionContract.CurrentVersion, + executionId, + tenantId, + PropertyId: null, + contributor.Schedule.OwnerKey, + contributor.Schedule.DataClassKey, + contributor.Schedule.ExecutionPolicyVersion, + attempt, + startedAtUtc, + startedAtUtc.AddMinutes(10)); + } + + private static async Task + ExecuteReservationProviderAsync( + IHost worker, + string tenantId, + RetentionContributionRequest request) + { + using IServiceScope scope = CreateReservationProviderScope( + worker, + tenantId); + IRetentionExecutionContributor contributor = scope.ServiceProvider + .GetServices() + .Single(item => + item.Schedule.OwnerKey == ReservationRetentionOwner && + item.Schedule.DataClassKey == + ReservationOperationalDataClass); + return await contributor.ExecuteAsync( + request, + CancellationToken.None).ConfigureAwait(false); + } + + private static async Task AssertReservationProviderProofAsync( + IHost worker, + string tenantId, + Guid reservationId, + Guid executionId, + int expectedAttempt, + DateTimeOffset expectedCompletedAtUtc, + DateTimeOffset? earliestReceiptAtUtc = null) + { + using IServiceScope scope = CreateReservationProviderScope( + worker, + tenantId); + ReservationsDbContext reservations = scope.ServiceProvider + .GetRequiredService(); + ReservationRetentionExecution execution = await reservations + .RetentionExecutions.AsNoTracking() + .SingleAsync(item => item.Id == executionId) + .ConfigureAwait(false); + Assert.Equal(expectedAttempt, execution.Attempt); + Assert.Equal(expectedCompletedAtUtc, execution.CompletedAtUtc); + ReservationRetentionAnonymisationReceipt receipt = + await reservations.RetentionAnonymisationReceipts + .AsNoTracking() + .SingleAsync(item => + item.ReservationId == reservationId) + .ConfigureAwait(false); + if (earliestReceiptAtUtc.HasValue) + { + Assert.InRange( + receipt.CompletedAtUtc, + earliestReceiptAtUtc.Value, + expectedCompletedAtUtc); + } + else + { + Assert.Equal(expectedCompletedAtUtc, receipt.CompletedAtUtc); + } + + Assert.Equal(0, receipt.CompletedAtUtc.Ticks % + TimeSpan.TicksPerMicrosecond); + ReservationAnonymisationTombstone tombstone = + await reservations.AnonymisationTombstones + .AsNoTracking() + .SingleAsync(item => item.Id == reservationId) + .ConfigureAwait(false); + Assert.True(tombstone.MatchesRetention(receipt)); + } + + private static async Task + AssertReservationProviderHasNoMutationAsync( + IHost worker, + string tenantId, + Guid reservationId) + { + using IServiceScope scope = CreateReservationProviderScope( + worker, + tenantId); + ReservationsDbContext reservations = scope.ServiceProvider + .GetRequiredService(); + Reservation reservation = await reservations.Reservations + .AsNoTracking() + .SingleAsync(item => item.Id == reservationId) + .ConfigureAwait(false); + Assert.False(reservation.IsAnonymised); + Assert.Empty(await reservations.RetentionAnonymisationReceipts + .AsNoTracking() + .Where(item => item.ReservationId == reservationId) + .ToArrayAsync().ConfigureAwait(false)); + Assert.Empty(await reservations.AnonymisationTombstones + .AsNoTracking() + .Where(item => item.Id == reservationId) + .ToArrayAsync().ConfigureAwait(false)); + } + + private static async Task + AssertReservationProviderMutationCountsAsync( + IHost worker, + string tenantId, + Guid validReservationId, + Guid overflowReservationId, + int expectedReceiptCount) + { + using IServiceScope scope = CreateReservationProviderScope( + worker, + tenantId); + ReservationsDbContext reservations = scope.ServiceProvider + .GetRequiredService(); + Reservation[] candidates = await reservations.Reservations + .AsNoTracking() + .Where(item => + item.Id == validReservationId || + item.Id == overflowReservationId) + .OrderBy(item => item.ProjectionOrdinal) + .ToArrayAsync().ConfigureAwait(false); + Assert.Equal(2, candidates.Length); + Assert.True(candidates[0].IsAnonymised); + Assert.Equal( + expectedReceiptCount == 2, + candidates[1].IsAnonymised); + Assert.Equal( + expectedReceiptCount, + await reservations.RetentionAnonymisationReceipts + .AsNoTracking() + .CountAsync(item => + item.ReservationId == validReservationId || + item.ReservationId == overflowReservationId) + .ConfigureAwait(false)); + Assert.Equal( + expectedReceiptCount, + await reservations.AnonymisationTombstones + .AsNoTracking() + .CountAsync(item => + item.Id == validReservationId || + item.Id == overflowReservationId) + .ConfigureAwait(false)); + } + + private static async Task + AssertReservationProviderSingleMutationAsync( + IHost worker, + string tenantId, + Guid reservationId, + int expectedReceiptCount) + { + using IServiceScope scope = CreateReservationProviderScope( + worker, + tenantId); + ReservationsDbContext reservations = scope.ServiceProvider + .GetRequiredService(); + Reservation reservation = await reservations.Reservations + .AsNoTracking() + .SingleAsync(item => item.Id == reservationId) + .ConfigureAwait(false); + Assert.Equal( + expectedReceiptCount == 1, + reservation.IsAnonymised); + Assert.Equal( + expectedReceiptCount, + await reservations.RetentionAnonymisationReceipts + .AsNoTracking() + .CountAsync(item => + item.ReservationId == reservationId) + .ConfigureAwait(false)); + Assert.Equal( + expectedReceiptCount, + await reservations.AnonymisationTombstones + .AsNoTracking() + .CountAsync(item => item.Id == reservationId) + .ConfigureAwait(false)); + } + + private static async Task InsertReservationAcknowledgementsAsync( + IHost worker, + string tenantId, + Guid propertyId, + int count) + { + using IServiceScope scope = CreateReservationProviderScope( + worker, + tenantId); + ReservationsDbContext reservations = scope.ServiceProvider + .GetRequiredService(); + await reservations.Database.ExecuteSqlInterpolatedAsync($""" + INSERT INTO reservations.property_policy_acknowledgements ( + "PropertyId", + "AcknowledgementId", + "AcknowledgementVersion") + SELECT + {propertyId}, + 'reservation-provider-bound-' || + lpad(item::text, 3, '0'), + 1 + FROM generate_series(0, {count - 1}) AS item; + """).ConfigureAwait(false); + } + + private static async Task + DeleteSyntheticReservationAcknowledgementsAsync( + IHost worker, + string tenantId, + Guid propertyId) + { + using IServiceScope scope = CreateReservationProviderScope( + worker, + tenantId); + ReservationsDbContext reservations = scope.ServiceProvider + .GetRequiredService(); + int deleted = + await reservations.Database.ExecuteSqlInterpolatedAsync($""" + DELETE FROM reservations.property_policy_acknowledgements + WHERE "PropertyId" = {propertyId} + AND ( + "AcknowledgementId" = + 'reservation-provider-raced' + OR "AcknowledgementId" LIKE + 'reservation-provider-bound-%'); + """).ConfigureAwait(false); + Assert.Equal( + PropertiesContractLimits.MaximumPolicyAcknowledgements, + deleted); + } + + private static object CreateReservationCandidateRepository( + ReservationsDbContext reservations) => + Activator.CreateInstance( + typeof(ReservationsDbContext).Assembly.GetType( + "BunkFy.Modules.Reservations.Persistence.Repositories." + + "ReservationRetentionCandidateRepository", + throwOnError: true)!, + reservations)!; + + private static async Task + InvokeReservationCandidateScanAsync( + object repository, + long afterProjectionOrdinal, + int limit, + CancellationToken cancellationToken) + { + object invocation = repository.GetType() + .GetMethod("ScanAsync")! + .Invoke( + repository, + [afterProjectionOrdinal, limit, cancellationToken])!; + Task task = Assert.IsType( + invocation, + exactMatch: false); + await task.ConfigureAwait(false); + object page = task.GetType().GetProperty("Result")! + .GetValue(task)!; + object[] candidates = + ((System.Collections.IEnumerable)page.GetType() + .GetProperty("Candidates")! + .GetValue(page)!) + .Cast() + .ToArray(); + bool reachedEnd = Assert.IsType(page.GetType() + .GetProperty("ReachedEnd")! + .GetValue(page)); + return new(candidates, reachedEnd); + } + + private static Guid GetReservationCandidateReservationId( + object candidate) => + Assert.IsType(candidate.GetType() + .GetProperty("ReservationId")! + .GetValue(candidate)); + + private static Guid GetReservationCandidatePropertyId( + object candidate) => + Assert.IsType(candidate.GetType() + .GetProperty("PropertyId")! + .GetValue(candidate)); + + private static long GetReservationCandidateProjectionOrdinal( + object candidate) => + Assert.IsType(candidate.GetType() + .GetProperty("ProjectionOrdinal")! + .GetValue(candidate)); + + private static long GetReservationCandidateVersion( + object candidate) => + Assert.IsType(candidate.GetType() + .GetProperty("ReservationVersion")! + .GetValue(candidate)); + + private static long GetReservationCandidateDetailsRevision( + object candidate) => + Assert.IsType(candidate.GetType() + .GetProperty("DetailsRevision")! + .GetValue(candidate)); + + private static int GetReservationCandidateActiveHoldCount( + object candidate) => + Assert.IsType(candidate.GetType() + .GetProperty("ActiveHoldCount")! + .GetValue(candidate)); + + private static int? GetReservationCandidateRestrictionVersion( + object candidate) => + (int?)candidate.GetType() + .GetProperty("ProcessingRestrictionContractVersion")! + .GetValue(candidate); + + private static object? GetReservationCandidateProperty( + object candidate) => + candidate.GetType().GetProperty("Property")! + .GetValue(candidate); + + private static object? GetReservationCandidateGovernancePolicy( + object property) => + property.GetType().GetProperty("GovernancePolicy")! + .GetValue(property); + + private static long GetReservationCandidatePolicySourceVersion( + object candidate) + { + object property = Assert.IsType( + GetReservationCandidateProperty(candidate), + exactMatch: false); + return Assert.IsType(property.GetType() + .GetProperty("PolicySourceVersion")! + .GetValue(property)); + } + + private static PropertyGovernancePolicyBinding + GetReservationCandidatePolicy( + object candidate) + { + object property = Assert.IsType( + GetReservationCandidateProperty(candidate), + exactMatch: false); + return Assert.IsType( + GetReservationCandidateGovernancePolicy(property)); + } + + private static int GetReservationCandidateAcknowledgementCount( + object candidate) => + GetReservationCandidatePolicy(candidate) + .Acknowledgements.Count; + + private static IServiceScope CreateReservationProviderScope( + IHost worker, + string tenantId) + { + IServiceScope scope = worker.Services.CreateScope(); + scope.ServiceProvider.GetRequiredService() + .SetTenant(tenantId); + return scope; + } + + private static DateTimeOffset + WithReservationProviderSubMicrosecondTicks( + DateTimeOffset value) => + value.AddTicks( + -(value.Ticks % TimeSpan.TicksPerMicrosecond) + 7); + + private sealed record ReservationCandidateScanProof( + object[] Candidates, + bool ReachedEnd); + + private sealed record ReservationProviderFenceProof( + int OwnerAttempt, + ReservationRetentionExecutionState OwnerState, + int OwnerScannedCount, + int OwnerAffectedCount, + int OwnerRemainingCount, + DateTimeOffset OwnerCompletedAtUtc, + long OwnerVersion, + int CentralAttempt, + RetentionExecutionState CentralState, + DateTimeOffset CentralStartedAtUtc, + DateTimeOffset? CentralCompletedAtUtc, + long CentralVersion, + Guid? CheckpointLastExecutionId, + long CheckpointAfterProjectionOrdinal, + long CheckpointVersion); + + private sealed class ReservationProviderScopeContext(string scopeId) + : IScopeContext + { + public bool IsEnabled => true; + public string ScopeId { get; } = scopeId; + } + + private sealed class ReservationProviderMutableClock( + DateTimeOffset utcNow) : ISystemClock + { + public DateTimeOffset UtcNow { get; set; } = utcNow; + } + + private sealed class ReservationAcknowledgementGrowthInterceptor( + string connectionString, + Guid propertyId) : DbCommandInterceptor + { + private int inserted; + private int matchedCommandCount; + private string? matchedCommandText; + + public bool Inserted => Volatile.Read(ref this.inserted) == 1; + public int MatchedCommandCount => + Volatile.Read(ref this.matchedCommandCount); + public string? MatchedCommandText => + Volatile.Read(ref this.matchedCommandText); + + public override async ValueTask + ReaderExecutedAsync( + DbCommand command, + CommandExecutedEventData eventData, + DbDataReader result, + CancellationToken cancellationToken = default) + { + if (!IsPropertyPolicySnapshotQuery(command.CommandText)) + { + return result; + } + + _ = Interlocked.Increment(ref this.matchedCommandCount); + Volatile.Write( + ref this.matchedCommandText, + command.CommandText); + if ( + Interlocked.CompareExchange( + ref this.inserted, + 1, + 0) != 0) + { + return result; + } + + await using var connection = new NpgsqlConnection( + connectionString); + await connection.OpenAsync(cancellationToken) + .ConfigureAwait(false); + await using NpgsqlCommand insert = connection.CreateCommand(); + insert.CommandText = """ + INSERT INTO reservations.property_policy_acknowledgements ( + "PropertyId", + "AcknowledgementId", + "AcknowledgementVersion") + VALUES ( + @property_id, + 'reservation-provider-raced', + 1); + """; + insert.Parameters.AddWithValue("property_id", propertyId); + await insert.ExecuteNonQueryAsync(cancellationToken) + .ConfigureAwait(false); + return result; + } + } + + private sealed class + ReservationPolicyGenerationReplacementInterceptor( + string connectionString, + Guid propertyId) : DbCommandInterceptor + { + private int replaced; + private int matchedCommandCount; + private string? matchedCommandText; + + public bool Replaced => Volatile.Read(ref this.replaced) == 1; + public int MatchedCommandCount => + Volatile.Read(ref this.matchedCommandCount); + public string? MatchedCommandText => + Volatile.Read(ref this.matchedCommandText); + + public override async ValueTask + ReaderExecutedAsync( + DbCommand command, + CommandExecutedEventData eventData, + DbDataReader result, + CancellationToken cancellationToken = default) + { + if (!IsPropertyPolicySnapshotQuery(command.CommandText)) + { + return result; + } + + _ = Interlocked.Increment(ref this.matchedCommandCount); + Volatile.Write( + ref this.matchedCommandText, + command.CommandText); + if (Interlocked.CompareExchange( + ref this.replaced, + 1, + 0) != 0) + { + return result; + } + + await using var connection = new NpgsqlConnection( + connectionString); + await connection.OpenAsync(cancellationToken) + .ConfigureAwait(false); + await using NpgsqlTransaction transaction = + await connection.BeginTransactionAsync(cancellationToken) + .ConfigureAwait(false); + await using NpgsqlCommand replacement = + connection.CreateCommand(); + replacement.Transaction = transaction; + replacement.CommandText = """ + UPDATE reservations.property_projection + SET + "PolicySourceVersion" = + "PolicySourceVersion" + 1, + "JurisdictionPolicyId" = + 'reservation-provider-replacement', + "JurisdictionPolicyVersion" = 2 + WHERE "Id" = @property_id; + + DELETE FROM + reservations.property_policy_acknowledgements + WHERE "PropertyId" = @property_id; + + INSERT INTO + reservations.property_policy_acknowledgements ( + "PropertyId", + "AcknowledgementId", + "AcknowledgementVersion") + VALUES ( + @property_id, + 'reservation-provider-replacement-ack', + 1); + """; + replacement.Parameters.AddWithValue( + "property_id", + propertyId); + int affected = await replacement.ExecuteNonQueryAsync( + cancellationToken).ConfigureAwait(false); + if (affected != 3) + { + throw new InvalidOperationException( + $"Expected three policy generation rows, got {affected}."); + } + + await transaction.CommitAsync(cancellationToken) + .ConfigureAwait(false); + return result; + } + } + + private static bool IsPropertyPolicySnapshotQuery( + string commandText) => + commandText.Contains( + "property_projection", + StringComparison.OrdinalIgnoreCase) && + commandText.Contains( + "property_policy_acknowledgements", + StringComparison.OrdinalIgnoreCase); +} diff --git a/tests/Integration.Tests/Reservations/ReservationsSagaIntegrationTests.LostOutcomeRecovery.cs b/tests/Integration.Tests/Reservations/ReservationsSagaIntegrationTests.LostOutcomeRecovery.cs index f8bb2fbc..3f665cba 100644 --- a/tests/Integration.Tests/Reservations/ReservationsSagaIntegrationTests.LostOutcomeRecovery.cs +++ b/tests/Integration.Tests/Reservations/ReservationsSagaIntegrationTests.LostOutcomeRecovery.cs @@ -54,17 +54,25 @@ public async Task Restart_and_reconcile_recover_a_lost_stay_amendment_outcome_on string connectionString = postgreSql.GetConnectionString(); string natsConnectionString = AuthTestContainers.GetNatsConnectionString(nats); + await using (AuthTestApplication migrationApi = new( + "PostgreSql", + connectionString, + natsConnectionString, + disableOutboxPublisher: true)) + { + await migrationApi.MigrateGuestRecordsAuthorizationDatabaseAsync() + .ConfigureAwait(false); + } + + await using AdminCliTestApplication admin = new( + "PostgreSql", + connectionString); + await admin.MigrateAsync().ConfigureAwait(false); await using AuthTestApplication api = new( "PostgreSql", connectionString, natsConnectionString, disableOutboxPublisher: false); - await api.MigrateGuestRecordsAuthorizationDatabaseAsync() - .ConfigureAwait(false); - await using AdminCliTestApplication admin = new( - "PostgreSql", - connectionString); - await admin.MigrateAsync().ConfigureAwait(false); using HttpClient client = api.CreateClient(); using IHost initialWorker = CreateWorker( diff --git a/tests/Integration.Tests/Reservations/ReservationsSagaIntegrationTests.cs b/tests/Integration.Tests/Reservations/ReservationsSagaIntegrationTests.cs index 23d8e71e..3ac9d22e 100644 --- a/tests/Integration.Tests/Reservations/ReservationsSagaIntegrationTests.cs +++ b/tests/Integration.Tests/Reservations/ReservationsSagaIntegrationTests.cs @@ -61,17 +61,26 @@ public async Task Real_worker_coordinates_reservation_and_stay_lifecycles_throug string connectionString = postgreSql.GetConnectionString(); string natsConnectionString = AuthTestContainers.GetNatsConnectionString(nats); - await using AuthTestApplication api = new( - "PostgreSql", - connectionString, - natsConnectionString, - disableOutboxPublisher: false); - await api.MigrateGuestRecordsAuthorizationDatabaseAsync().ConfigureAwait(false); + await using (AuthTestApplication migrationApi = new( + "PostgreSql", + connectionString, + natsConnectionString, + disableOutboxPublisher: true)) + { + await migrationApi.MigrateGuestRecordsAuthorizationDatabaseAsync() + .ConfigureAwait(false); + } + await using AdminCliTestApplication admin = new( "PostgreSql", connectionString, includeReservations: true); await admin.MigrateAsync().ConfigureAwait(false); + await using AuthTestApplication api = new( + "PostgreSql", + connectionString, + natsConnectionString, + disableOutboxPublisher: false); await using AdminApiTestApplication adminApi = new( "PostgreSql", connectionString, @@ -103,7 +112,7 @@ public async Task Real_worker_coordinates_reservation_and_stay_lifecycles_throug Guid readerId = GetSubjectId(readerTokens.AccessToken); await api.SeedOrganizationMembershipAsync(TenantId, readerId).ConfigureAwait(false); await GrantReservationsReadAccessAsync(admin, readerId).ConfigureAwait(false); - await GrantReservationsAdminAccessAsync(admin, operatorId).ConfigureAwait(false); + await GrantReservationsAdminAccessAsync(admin, operatorId, adminActorId).ConfigureAwait(false); await using AdminApiTestApplication reservationsAdminApi = new( "PostgreSql", @@ -1236,6 +1245,11 @@ private static async Task SeedInventoryAsync(AuthTestApplication api) using (IServiceScope scope = api.Services.CreateScope()) { scope.ServiceProvider.GetRequiredService().SetTenant(TenantId); + InventoryDbContext inventory = scope.ServiceProvider + .GetRequiredService(); + await using var transaction = await inventory.Database + .BeginTransactionAsync() + .ConfigureAwait(false); IIntegrationEventHandler propertyHandler = ResolveInventoryHandler(scope.ServiceProvider); IIntegrationEventHandler roomHandler = @@ -1249,9 +1263,10 @@ await roomHandler.HandleAsync( await roomHandler.HandleAsync( new(Guid.NewGuid(), TenantId, now, PropertyId, ReplacementRoomId, "102", null, null, RoomStatus.Active, 1), CancellationToken.None).ConfigureAwait(false); - await scope.ServiceProvider.GetRequiredService() + await inventory .SaveChangesAsync() .ConfigureAwait(false); + await transaction.CommitAsync().ConfigureAwait(false); } using IServiceScope configurationScope = api.Services.CreateScope(); @@ -1325,19 +1340,25 @@ await CountryPolicyIntegrationTestData.ApplyActivationAsync( await guestsTransaction.CommitAsync().ConfigureAwait(false); } - await ResolveHandler( + ReservationsDbContext reservations = scope.ServiceProvider + .GetRequiredService(); + await using (var reservationsTransaction = await reservations.Database + .BeginTransactionAsync() + .ConfigureAwait(false)) + { + await ResolveHandler( + scope.ServiceProvider, + ReservationsModuleMetadata.Name) + .HandleAsync(propertyCreated, CancellationToken.None).ConfigureAwait(false); + await CountryPolicyIntegrationTestData.ApplyActivationAsync( scope.ServiceProvider, - ReservationsModuleMetadata.Name) - .HandleAsync(propertyCreated, CancellationToken.None).ConfigureAwait(false); - await CountryPolicyIntegrationTestData.ApplyActivationAsync( - scope.ServiceProvider, - ReservationsModuleMetadata.Name, - TenantId, - PropertyId, - 2).ConfigureAwait(false); - await scope.ServiceProvider.GetRequiredService() - .SaveChangesAsync() - .ConfigureAwait(false); + ReservationsModuleMetadata.Name, + TenantId, + PropertyId, + 2).ConfigureAwait(false); + await reservations.SaveChangesAsync().ConfigureAwait(false); + await reservationsTransaction.CommitAsync().ConfigureAwait(false); + } } private static IIntegrationEventHandler ResolveHandler( @@ -1679,7 +1700,7 @@ await AssertAdminSuccessAsync(admin.ExecuteAsync( private static async Task GrantReservationsAdminAccessAsync( AdminCliTestApplication admin, - Guid adminActorId) + params Guid[] adminActorIds) { await AssertAdminSuccessAsync(admin.ExecuteAsync( "admin", "roles", "create", @@ -1698,13 +1719,16 @@ await AssertAdminSuccessAsync(admin.ExecuteAsync( "--permission", permission)); } - await AssertAdminSuccessAsync(admin.ExecuteAsync( - "admin", "roles", "assign", - "--actor", "owner", - "--target-kind", "admin-actor", - "--target-id", adminActorId.ToString("D"), - "--role", "reservations-stay-admin", - "--scope", $"tenant:{TenantId}/property:{PropertyId:D}")); + foreach (Guid adminActorId in adminActorIds) + { + await AssertAdminSuccessAsync(admin.ExecuteAsync( + "admin", "roles", "assign", + "--actor", "owner", + "--target-kind", "admin-actor", + "--target-id", adminActorId.ToString("D"), + "--role", "reservations-stay-admin", + "--scope", $"tenant:{TenantId}/property:{PropertyId:D}")); + } } private static async Task CreateReservationAsync( diff --git a/tests/Integration.Tests/Retention/RetentionControlPlaneIntegrationTests.cs b/tests/Integration.Tests/Retention/RetentionControlPlaneIntegrationTests.cs index fe92509d..60c1618b 100644 --- a/tests/Integration.Tests/Retention/RetentionControlPlaneIntegrationTests.cs +++ b/tests/Integration.Tests/Retention/RetentionControlPlaneIntegrationTests.cs @@ -54,6 +54,7 @@ namespace Integration.Tests; using Gma.Modules.TaskRuntime.Persistence; using Integration.Tests.Support; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; @@ -768,9 +769,32 @@ private static async Task ApplyReservationPropertyCreatedAsync( var handler = (IIntegrationEventHandler) services.GetRequiredService(subscription.HandlerType); - await handler.HandleAsync( - propertyCreated, - CancellationToken.None).ConfigureAwait(false); + ReservationsDbContext reservations = + services.GetRequiredService(); + IDbContextTransaction? ownedTransaction = + reservations.Database.CurrentTransaction is null + ? await reservations.Database.BeginTransactionAsync() + .ConfigureAwait(false) + : null; + + try + { + await handler.HandleAsync( + propertyCreated, + CancellationToken.None).ConfigureAwait(false); + if (ownedTransaction is not null) + { + await reservations.SaveChangesAsync().ConfigureAwait(false); + await ownedTransaction.CommitAsync().ConfigureAwait(false); + } + } + finally + { + if (ownedTransaction is not null) + { + await ownedTransaction.DisposeAsync().ConfigureAwait(false); + } + } } private static async Task SeedReservationAsync( diff --git a/tests/Integration.Tests/Support/AdminCliTestApplication.cs b/tests/Integration.Tests/Support/AdminCliTestApplication.cs index 18289297..735411fd 100644 --- a/tests/Integration.Tests/Support/AdminCliTestApplication.cs +++ b/tests/Integration.Tests/Support/AdminCliTestApplication.cs @@ -4,11 +4,14 @@ namespace Integration.Tests.Support; using System.CommandLine.Parsing; using System.Text.RegularExpressions; using BunkFy.Host.AdminCli.Security; -using BunkFy.Parsers.ReservationMail; +using BunkFy.Modules.Ingestion.AdminCli; +using BunkFy.Modules.Ingestion.Persistence; using BunkFy.Modules.Properties.AdminCli; using BunkFy.Modules.Properties.Persistence; using BunkFy.Modules.Reservations.AdminCli; using BunkFy.Modules.Reservations.Persistence; +using BunkFy.Modules.Workspaces.Persistence; +using BunkFy.Parsers.ReservationMail; using Gma.Framework.Administration.Cli; using Gma.Framework.Caching.Cqrs; using Gma.Framework.Cqrs; @@ -29,11 +32,6 @@ namespace Integration.Tests.Support; using Gma.Modules.Auth.Contracts; using Gma.Modules.Auth.Domain.Errors; using Gma.Modules.Auth.Persistence; -using BunkFy.Modules.Ingestion.AdminCli; -using BunkFy.Modules.Ingestion.Persistence; -using BunkFy.Modules.Reservations.AdminCli; -using BunkFy.Modules.Reservations.Persistence; -using BunkFy.Modules.Workspaces.Persistence; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -107,12 +105,6 @@ public AdminCliTestApplication( builder.AddAdminModule(); } - if (includeReservations) - { - builder.AddAdminModule(); - builder.Services.AddBunkFyAdminCliResourceScopes(); - } - if (systemClock is not null) { builder.Services.RemoveAll(); @@ -143,17 +135,11 @@ public async Task MigrateAsync() } IngestionDbContext? ingestion = scope.ServiceProvider.GetService(); - ReservationsDbContext? reservations = scope.ServiceProvider.GetService(); if (ingestion is not null) { await ingestion.Database.MigrateAsync().ConfigureAwait(false); } - if (reservations is not null) - { - await reservations.Database.MigrateAsync().ConfigureAwait(false); - } - PropertiesDbContext? properties = scope.ServiceProvider.GetService(); if (properties is not null) { diff --git a/tests/Integration.Tests/Support/CountryPolicyIntegrationTestData.cs b/tests/Integration.Tests/Support/CountryPolicyIntegrationTestData.cs index e2f25b55..47f92ab2 100644 --- a/tests/Integration.Tests/Support/CountryPolicyIntegrationTestData.cs +++ b/tests/Integration.Tests/Support/CountryPolicyIntegrationTestData.cs @@ -5,8 +5,11 @@ namespace Integration.Tests.Support; using BunkFy.Modules.Guests.Contracts; using BunkFy.Modules.Guests.Persistence; using BunkFy.Modules.Properties.Contracts; +using BunkFy.Modules.Reservations.Contracts; +using BunkFy.Modules.Reservations.Persistence; using BunkFy.Modules.Staff.Domain.Governance; using Gma.Framework.Messaging; +using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Storage; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -79,12 +82,20 @@ public static async Task ApplyActivationAsync( CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(services); - GuestsDbContext? guests = consumerModule == GuestsModuleMetadata.Name - ? services.GetRequiredService() - : null; - IDbContextTransaction? ownedTransaction = guests is not null && - guests.Database.CurrentTransaction is null - ? await guests.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false) + DbContext? consumerDbContext = consumerModule switch + { + GuestsModuleMetadata.Name => + services.GetRequiredService(), + ReservationsModuleMetadata.Name => + services.GetRequiredService(), + _ => null + }; + IDbContextTransaction? ownedTransaction = + consumerDbContext is not null && + consumerDbContext.Database.CurrentTransaction is null + ? await consumerDbContext.Database + .BeginTransactionAsync(cancellationToken) + .ConfigureAwait(false) : null; try @@ -98,8 +109,12 @@ await ApplyActivationCoreAsync( cancellationToken).ConfigureAwait(false); if (ownedTransaction is not null) { - await guests!.SaveChangesAsync(cancellationToken).ConfigureAwait(false); - await ownedTransaction.CommitAsync(cancellationToken).ConfigureAwait(false); + await consumerDbContext! + .SaveChangesAsync(cancellationToken) + .ConfigureAwait(false); + await ownedTransaction + .CommitAsync(cancellationToken) + .ConfigureAwait(false); } } finally From 4a86f29476e95e0b65117671f9486c73cee43fd2 Mon Sep 17 00:00:00 2001 From: SadPossum Date: Sat, 15 Aug 2026 15:56:21 +0000 Subject: [PATCH 4/6] Align retention recovery evidence --- .../reservation-record-retention-task.md | 25 ++++++++------- .../ExecuteRetentionScheduleTaskHandler.cs | 2 +- ...xecuteRetentionScheduleTaskHandlerTests.cs | 31 +++++++++++++++++-- 3 files changed, 44 insertions(+), 14 deletions(-) diff --git a/docs/planning/reservation-record-retention-task.md b/docs/planning/reservation-record-retention-task.md index 5911b14b..9d4252d9 100644 --- a/docs/planning/reservation-record-retention-task.md +++ b/docs/planning/reservation-record-retention-task.md @@ -171,10 +171,12 @@ instant. as 1 through 1,000. Read at most `ScanSize + 1` candidate heads for end detection, but fully materialize at most `ScanSize` candidates and load their related facts with set-based queries. -- Materialize at most 64 governance acknowledgements per property, using - bounded count and limit-plus-one checks. A pre-existing overflow or a 65th - acknowledgement introduced between those queries withholds that property's - governance policy and produces a fail-closed policy-unavailable result. +- Materialize at most 64 governance acknowledgements per property from one + coherent, bounded policy statement. A 65th row visible to that statement + withholds the property's governance policy and produces a fail-closed + policy-unavailable result. A later policy change is observed by the + mutation-time reload or the next scan rather than mixed into the earlier + snapshot. - Mutate at most `MutationBatchSize` records per occurrence. - Reset the cursor only after reaching the end, so a large tenant cannot starve later records and newly terminal earlier ordinals are picked up on the next @@ -202,13 +204,14 @@ instant. ## Evidence -- The current local hardening pass is not publication or deployment evidence. - Its focused attempt, retry, timestamp, bounds, acknowledgement, eligibility, - and mutation unit suites pass 32/32; the combined Reservations catalogue, - Data Rights export, and tenant-termination suites pass 27/27; and the - documentation index, link, and solution guards pass 3/3. The PostgreSQL - provider scenario, complete repository matrix, hosted checks, and deployed - proof remain separate release gates for this amendment. +- The current local hardening pass is not hosted or deployment evidence. + Focused retention tests pass 42/42, the complete Reservations and Retention + unit suites pass 365/365 and 50/50, and the strict touched-project builds + have zero warnings or errors. The exact PostgreSQL provider scenarios pass + 2/2; the consolidated Reservations Docker admission passes 25/25; and both + saga facts pass twice consecutively. The post-rebase repository matrix, + hosted checks, and deployed proof remain separate release gates for this + amendment. - Focused Reservations retention tests pass 24/24, including domain, eligibility, contributor, mutation, policy-version cursor, append-only receipt, tenant boundary, and model constraints. diff --git a/src/Modules/Retention/BunkFy.Modules.Retention.Application/Tasks/ExecuteRetentionScheduleTaskHandler.cs b/src/Modules/Retention/BunkFy.Modules.Retention.Application/Tasks/ExecuteRetentionScheduleTaskHandler.cs index d7051a6c..965a1943 100644 --- a/src/Modules/Retention/BunkFy.Modules.Retention.Application/Tasks/ExecuteRetentionScheduleTaskHandler.cs +++ b/src/Modules/Retention/BunkFy.Modules.Retention.Application/Tasks/ExecuteRetentionScheduleTaskHandler.cs @@ -89,7 +89,7 @@ exception is not OperationCanceledException || "Retention owner {OwnerKey} failed for data class {DataClassKey} at attempt {Attempt}", payload.OwnerKey, payload.DataClassKey, - context.Attempt); + executionAttempt); ownerFailure = exception; result = new( RetentionExecutionContract.CurrentVersion, diff --git a/src/Modules/Retention/tests/BunkFy.Modules.Retention.Tests/Application/ExecuteRetentionScheduleTaskHandlerTests.cs b/src/Modules/Retention/tests/BunkFy.Modules.Retention.Tests/Application/ExecuteRetentionScheduleTaskHandlerTests.cs index 5d316462..2665f8ae 100644 --- a/src/Modules/Retention/tests/BunkFy.Modules.Retention.Tests/Application/ExecuteRetentionScheduleTaskHandlerTests.cs +++ b/src/Modules/Retention/tests/BunkFy.Modules.Retention.Tests/Application/ExecuteRetentionScheduleTaskHandlerTests.cs @@ -10,6 +10,7 @@ namespace BunkFy.Modules.Retention.Tests.Application; using Gma.Framework.Runtime.Time; using Gma.Framework.Tasks; using Gma.Framework.Tasks.Cqrs; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Xunit; @@ -203,6 +204,7 @@ public async Task Owner_exception_is_recorded_before_the_task_is_rethrown() { FakeTaskDispatcher dispatcher = new(); RecordingSecuritySignalRecorder securitySignals = new(); + CapturingLogger logger = new(); TestContributor contributor = new(_ => throw new InvalidOperationException("owner failed")); ExecuteRetentionScheduleTaskHandler handler = new( @@ -210,8 +212,10 @@ public async Task Owner_exception_is_recorded_before_the_task_is_rethrown() [contributor], new TestClock(), securitySignals, - NullLogger.Instance); - TaskExecutionContext context = Context(); + logger); + TaskExecutionContext context = Context( + attempt: 1, + leaseGeneration: 4); InvalidOperationException exception = await Assert.ThrowsAsync(() => @@ -228,6 +232,11 @@ await Assert.ThrowsAsync(() => securitySignals.Records); Assert.Equal("retention.scheduled-execution-failed", signal.Definition.Code); Assert.Equal(context.CorrelationId, signal.CorrelationId); + Assert.Contains( + logger.Messages, + message => message.EndsWith( + "at attempt 4", + StringComparison.Ordinal)); } [Fact] @@ -411,4 +420,22 @@ public SecuritySignalReceipt Record( private sealed record SecuritySignalRecordCapture( SecuritySignalDefinition Definition, Guid? CorrelationId); + + private sealed class CapturingLogger : ILogger + { + public List Messages { get; } = []; + + public IDisposable? BeginScope(TState state) + where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) => + this.Messages.Add(formatter(state, exception)); + } } From 597867ca7ef04ace29ad6ee92b500022253f0717 Mon Sep 17 00:00:00 2001 From: SadPossum Date: Sat, 15 Aug 2026 16:54:56 +0000 Subject: [PATCH 5/6] fix(reservations): complete restriction owner execution --- .../reservations-data-rights-workflow-task.md | 4 +- ...ReservationProcessingRestrictionCommand.cs | 3 +- ...rvationDataRightsRestrictionContributor.cs | 452 ++++++++++++ .../DependencyInjection.cs | 4 + ...tionProcessingRestrictionCommandHandler.cs | 17 +- ...tionProcessingRestrictionCommandHandler.cs | 25 +- ...ervationProcessingRestrictionRepository.cs | 7 + .../ReservationsApplicationErrors.cs | 6 + ...ervationProcessingRestrictionRepository.cs | 22 + src/Modules/Reservations/docs/README.md | 4 + .../docs/personal-data-catalog.v1.json | 8 + .../docs/personal-data-inventory.v1.md | 3 +- ...onDataRightsRestrictionContributorTests.cs | 663 ++++++++++++++++++ ...rocessingRestrictionCommandHandlerTests.cs | 74 +- .../ReservationDataRightsIntegrationTests.cs | 227 ++++-- ...agaIntegrationTests.LostOutcomeRecovery.cs | 6 +- .../ReservationsSagaIntegrationTests.cs | 6 +- 17 files changed, 1468 insertions(+), 63 deletions(-) create mode 100644 src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Contributors/ReservationDataRightsRestrictionContributor.cs create mode 100644 src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Application/ReservationDataRightsRestrictionContributorTests.cs diff --git a/docs/planning/reservations-data-rights-workflow-task.md b/docs/planning/reservations-data-rights-workflow-task.md index c489f779..b9171561 100644 --- a/docs/planning/reservations-data-rights-workflow-task.md +++ b/docs/planning/reservations-data-rights-workflow-task.md @@ -247,7 +247,9 @@ ungated. 2. [Complete] Add approved transactional correction and immutable owner receipts through existing aggregate/history semantics. 3. [Complete] Add Reservations-owned processing restriction, enforcement - and rebuildable PII-free state. + and rebuildable PII-free state; register the Reservations restriction owner + with bounded exact release-target discovery, actor-bound proof replay, and + exactly-one legacy unbound recovery. 4. [Complete] Add holds and fail-closed destructive eligibility. 5. [Complete] Add irreversible aggregate/history/link/receipt redaction, owner proof and terminal ordinary-surface enforcement. diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Commands/ReleaseReservationProcessingRestrictionCommand.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Commands/ReleaseReservationProcessingRestrictionCommand.cs index 8004f5f3..b46957ee 100644 --- a/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Commands/ReleaseReservationProcessingRestrictionCommand.cs +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Commands/ReleaseReservationProcessingRestrictionCommand.cs @@ -13,5 +13,6 @@ public sealed record ReleaseReservationProcessingRestrictionCommand( long ExpectedReservationVersion, long ExpectedRestrictionVersion, long ExpectedProjectionRevision, - string ActorId) + string ActorId, + bool LegacyUnboundTarget = false) : ITransactionalCommand; diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Contributors/ReservationDataRightsRestrictionContributor.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Contributors/ReservationDataRightsRestrictionContributor.cs new file mode 100644 index 00000000..54300531 --- /dev/null +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Contributors/ReservationDataRightsRestrictionContributor.cs @@ -0,0 +1,452 @@ +namespace BunkFy.Modules.Reservations.Application.Contributors; + +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using BunkFy.Modules.DataRights.Contracts; +using BunkFy.Modules.Reservations.Application.Commands; +using BunkFy.Modules.Reservations.Application.Mapping; +using BunkFy.Modules.Reservations.Application.Ports; +using BunkFy.Modules.Reservations.Contracts; +using BunkFy.Modules.Reservations.Domain.Aggregates; +using BunkFy.Modules.Reservations.Domain.DataRights; +using BunkFy.Modules.Reservations.Domain.Models; +using Gma.Framework.Cqrs; +using Gma.Framework.Pagination; +using Gma.Framework.Results; +using Gma.Framework.Runtime.Time; + +internal sealed class ReservationDataRightsRestrictionContributor( + IRequestDispatcher dispatcher, + IReservationProcessingRestrictionProjectionRepository projections, + IReservationProcessingRestrictionRepository restrictions, + ISystemClock clock) + : IDataRightsRestrictionContributor +{ + private static readonly PageRequest ActiveRestrictionPage = new(1, 2); + private static readonly PageRequest ReleaseTargetPage = new( + 1, + DataRightsRestrictionContract.MaxReleaseTargets + 1); + + public string OwnerKey => ReservationsDataRightsCoordinates.Owner; + + public int ContractVersion => DataRightsRestrictionContract.CurrentVersion; + + public async Task + ResolveReleaseTargetsAsync( + DataRightsRestrictionTargetResolutionRequest request, + CancellationToken cancellationToken) + { + if (!IsValid(request, clock.UtcNow)) + { + return DataRightsRestrictionTargetResolutionResult.Failed( + ReservationsApplicationErrors + .ProcessingRestrictionRequestInvalid.Code); + } + + Guid propertyId = request.PropertyId!.Value; + if (request.TargetOwnerOperationId is Guid targetId) + { + ReservationProcessingRestriction? target = + await restrictions.GetAsync( + propertyId, + targetId, + cancellationToken).ConfigureAwait(false); + if (target is null || + target.ReservationId != request.Coordinate.RecordId) + { + return DataRightsRestrictionTargetResolutionResult.NotFound(); + } + + if (target.Status != ReservationProcessingRestrictionStatus.Active || + target.Version != request.TargetOwnerOperationVersion) + { + return DataRightsRestrictionTargetResolutionResult.Stale(); + } + + return DataRightsRestrictionTargetResolutionResult.Completed( + [ToReleaseTarget(target)]); + } + + IReadOnlyCollection active = + await restrictions.ListActiveAsync( + propertyId, + request.Coordinate.RecordId, + ReleaseTargetPage, + cancellationToken).ConfigureAwait(false); + bool limitReached = + active.Count > DataRightsRestrictionContract.MaxReleaseTargets; + return DataRightsRestrictionTargetResolutionResult.Completed( + active.Take(DataRightsRestrictionContract.MaxReleaseTargets) + .Select(ToReleaseTarget) + .ToArray(), + limitReached); + } + + public async Task ExecuteAsync( + DataRightsRestrictionContributionRequest request, + CancellationToken cancellationToken) + { + if (!IsValid(request, clock.UtcNow)) + { + return DataRightsRestrictionContributionResult.Failed( + ReservationsApplicationErrors + .ProcessingRestrictionRequestInvalid.Code); + } + + Guid propertyId = request.PropertyId!.Value; + string actorId = NormalizeActor(request.ExecutingActorId)!; + ReservationProcessingRestrictionReceipt? replay = + await restrictions.FindReceiptByIdempotencyKeyAsync( + request.IdempotencyKey, + cancellationToken).ConfigureAwait(false); + if (replay is not null) + { + ReservationProcessingRestriction? replayRestriction = + await restrictions.GetAsync( + replay.PropertyId, + replay.RestrictionId, + cancellationToken).ConfigureAwait(false); + string? committedActor = ResolveCommittedActor( + replay, + replayRestriction); + return ToResult(request, replay.ToDto(), committedActor); + } + + ReservationProcessingRestrictionProjection? projection = + await projections.GetAsync( + propertyId, + request.Coordinate.RecordId, + cancellationToken).ConfigureAwait(false); + if (projection is null || + projection.ContractVersion != + ReservationProcessingRestrictionContract.CurrentVersion) + { + return DataRightsRestrictionContributionResult.Blocked( + ReservationsApplicationErrors + .ProcessingRestrictionProjectionUnavailable.Code); + } + + ReservationProcessingRestriction? releaseTarget = null; + if (request.Directive == DataRightsRestrictionDirective.Release) + { + if (request.TargetOwnerOperationId is Guid targetId) + { + releaseTarget = await restrictions.GetAsync( + propertyId, + targetId, + cancellationToken).ConfigureAwait(false); + if (releaseTarget is null || + releaseTarget.ReservationId != request.Coordinate.RecordId || + releaseTarget.Status != + ReservationProcessingRestrictionStatus.Active || + releaseTarget.Version != request.TargetOwnerOperationVersion) + { + return DataRightsRestrictionContributionResult.Blocked( + ReservationsApplicationErrors + .ProcessingRestrictionActiveStateInvalid.Code); + } + } + else + { + IReadOnlyCollection active = + await restrictions.ListActiveAsync( + propertyId, + request.Coordinate.RecordId, + ActiveRestrictionPage, + cancellationToken).ConfigureAwait(false); + if (active.Count != 1) + { + return DataRightsRestrictionContributionResult.Blocked( + ReservationsApplicationErrors + .ProcessingRestrictionActiveStateInvalid.Code); + } + + releaseTarget = active.Single(); + } + } + + Result executed = + request.Directive switch + { + DataRightsRestrictionDirective.Apply => + await dispatcher.SendAsync( + new ApplyReservationProcessingRestrictionCommand( + request.IdempotencyKey, + propertyId, + request.CaseId, + request.ApprovalRevision, + request.Coordinate.RecordId, + request.Coordinate.RecordVersion, + projection.Revision, + actorId), + cancellationToken).ConfigureAwait(false), + DataRightsRestrictionDirective.Release + when releaseTarget is not null => + await dispatcher.SendAsync( + new ReleaseReservationProcessingRestrictionCommand( + request.IdempotencyKey, + propertyId, + releaseTarget.Id, + request.CaseId, + request.ApprovalRevision, + request.Coordinate.RecordId, + request.Coordinate.RecordVersion, + releaseTarget.Version, + projection.Revision, + actorId, + LegacyUnboundTarget: + request.TargetOwnerOperationId is null), + cancellationToken).ConfigureAwait(false), + _ => Result.Failure( + ReservationsApplicationErrors + .ProcessingRestrictionActiveStateInvalid) + }; + + if (executed.IsFailure) + { + return IsBlocked(executed.Error.Code) + ? DataRightsRestrictionContributionResult.Blocked( + executed.Error.Code) + : DataRightsRestrictionContributionResult.Failed( + executed.Error.Code); + } + + return ToResult(request, executed.Value, actorId); + } + + private static DataRightsRestrictionContributionResult ToResult( + DataRightsRestrictionContributionRequest request, + ReservationProcessingRestrictionReceiptDto receipt, + string? committedActor) + { + if (!Matches(request, receipt, committedActor)) + { + return DataRightsRestrictionContributionResult.Failed( + ReservationsApplicationErrors + .ProcessingRestrictionOwnerProofInvalid.Code); + } + + return DataRightsRestrictionContributionResult.Completed( + new DataRightsRestrictionOwnerProof( + ReservationProcessingRestrictionContract.CurrentVersion, + receipt.ReceiptId, + receipt.RestrictionId, + receipt.RestrictionVersion, + receipt.ProjectionRevision, + receipt.EffectiveRestricted, + ComputeDigest(receipt, committedActor!), + receipt.CompletedAtUtc)); + } + + private static bool IsValid( + DataRightsRestrictionContributionRequest? request, + DateTimeOffset nowUtc) => + request is not null && + request.ContractVersion == DataRightsRestrictionContract.CurrentVersion && + request.CaseType == DataRightsCaseType.GuestRights && + !string.IsNullOrWhiteSpace(request.TenantId) && + request.IdempotencyKey != Guid.Empty && + request.PropertyId is Guid propertyId && + propertyId != Guid.Empty && + request.CaseId != Guid.Empty && + request.ApprovalRevision > 0 && + request.Coordinate is not null && + string.Equals( + request.Coordinate.OwnerKey, + ReservationsDataRightsCoordinates.Owner, + StringComparison.Ordinal) && + string.Equals( + request.Coordinate.RecordType, + ReservationsDataRightsCoordinates.ReservationRecordType, + StringComparison.Ordinal) && + request.Coordinate.RecordId != Guid.Empty && + request.Coordinate.RecordVersion > 0 && + HasValidExecutionTarget( + request.Directive, + request.TargetOwnerOperationId, + request.TargetOwnerOperationVersion) && + NormalizeActor(request.ExecutingActorId) is not null && + request.DeadlineUtc > nowUtc; + + private static bool IsValid( + DataRightsRestrictionTargetResolutionRequest? request, + DateTimeOffset nowUtc) => + request is not null && + request.ContractVersion == DataRightsRestrictionContract.CurrentVersion && + request.CaseType == DataRightsCaseType.GuestRights && + !string.IsNullOrWhiteSpace(request.TenantId) && + request.PropertyId is Guid propertyId && + propertyId != Guid.Empty && + request.CaseId != Guid.Empty && + request.Coordinate is not null && + string.Equals( + request.Coordinate.OwnerKey, + ReservationsDataRightsCoordinates.Owner, + StringComparison.Ordinal) && + string.Equals( + request.Coordinate.RecordType, + ReservationsDataRightsCoordinates.ReservationRecordType, + StringComparison.Ordinal) && + request.Coordinate.RecordId != Guid.Empty && + request.Coordinate.RecordVersion > 0 && + HasValidResolutionTarget( + request.TargetOwnerOperationId, + request.TargetOwnerOperationVersion) && + request.DeadlineUtc > nowUtc; + + private static bool Matches( + DataRightsRestrictionContributionRequest request, + ReservationProcessingRestrictionReceiptDto receipt, + string? committedActor) + { + ReservationProcessingRestrictionActionDto expectedAction = + request.Directive == DataRightsRestrictionDirective.Apply + ? ReservationProcessingRestrictionActionDto.Apply + : ReservationProcessingRestrictionActionDto.Release; + bool targetMatches = request.Directive switch + { + DataRightsRestrictionDirective.Apply => + request.TargetOwnerOperationId is null && + request.TargetOwnerOperationVersion is null && + receipt.RestrictionVersion == 1 && + receipt.EffectiveRestricted, + DataRightsRestrictionDirective.Release + when request.TargetOwnerOperationId is null => + request.TargetOwnerOperationVersion is null && + !receipt.EffectiveRestricted, + DataRightsRestrictionDirective.Release => + receipt.RestrictionId == request.TargetOwnerOperationId && + receipt.RestrictionVersion == + request.TargetOwnerOperationVersion + 1, + _ => false + }; + return receipt.ReceiptId != Guid.Empty && + receipt.RestrictionId != Guid.Empty && + receipt.Action == expectedAction && + receipt.PropertyId == request.PropertyId!.Value && + receipt.ReservationId == request.Coordinate.RecordId && + receipt.CaseId == request.CaseId && + receipt.ApprovalRevision == request.ApprovalRevision && + receipt.SelectedReservationVersion == + request.Coordinate.RecordVersion && + receipt.ContractVersion == + ReservationProcessingRestrictionContract.CurrentVersion && + receipt.RestrictionVersion > 0 && + receipt.ProjectionRevision > 0 && + targetMatches && + string.Equals( + committedActor, + NormalizeActor(request.ExecutingActorId), + StringComparison.Ordinal) && + receipt.EventId != Guid.Empty && + receipt.CompletedAtUtc != default && + receipt.CompletedAtUtc <= request.DeadlineUtc; + } + + private static bool HasValidExecutionTarget( + DataRightsRestrictionDirective directive, + Guid? targetId, + long? targetVersion) => + directive switch + { + DataRightsRestrictionDirective.Apply => + targetId is null && targetVersion is null, + DataRightsRestrictionDirective.Release => + (targetId is null && targetVersion is null) || + (targetId is Guid id && + id != Guid.Empty && + targetVersion is long version && + version is > 0 and < long.MaxValue), + _ => false + }; + + private static bool HasValidResolutionTarget( + Guid? targetId, + long? targetVersion) => + (targetId is null && targetVersion is null) || + (targetId is Guid id && + id != Guid.Empty && + targetVersion is long version && + version is > 0 and < long.MaxValue); + + private static DataRightsRestrictionReleaseTarget ToReleaseTarget( + ReservationProcessingRestriction restriction) => + new( + restriction.Id, + restriction.Version, + restriction.ApplyCaseId, + restriction.AppliedAtUtc); + + private static string? ResolveCommittedActor( + ReservationProcessingRestrictionReceipt receipt, + ReservationProcessingRestriction? restriction) + { + if (restriction is null || + restriction.Id != receipt.RestrictionId || + restriction.PropertyId != receipt.PropertyId || + restriction.ReservationId != receipt.ReservationId) + { + return null; + } + + return receipt.Action switch + { + ReservationProcessingRestrictionAction.Apply + when restriction.ApplyCaseId == receipt.CaseId && + restriction.ApplyApprovalRevision == + receipt.ApprovalRevision => + NormalizeActor(restriction.AppliedBy), + ReservationProcessingRestrictionAction.Release + when restriction.ReleaseCaseId == receipt.CaseId && + restriction.ReleaseApprovalRevision == + receipt.ApprovalRevision => + NormalizeActor(restriction.ReleasedBy), + _ => null + }; + } + + private static string? NormalizeActor(string? actorId) + { + string? normalized = actorId?.Trim(); + return normalized is { Length: > 0 } && + normalized.Length <= Reservation.ActorIdMaxLength + ? normalized + : null; + } + + private static bool IsBlocked(string code) => + code is "Reservations.ProcessingRestrictionActiveStateInvalid" + or "Reservations.ProcessingRestrictionProjectionUnavailable" + or "Reservations.ProcessingRestrictionReservationVersionConflict" + or "Reservations.ProcessingRestrictionProjectionVersionConflict"; + + private static string ComputeDigest( + ReservationProcessingRestrictionReceiptDto receipt, + string actorId) + { + string canonical = string.Join( + "|", + receipt.ReceiptId.ToString("D"), + receipt.RestrictionId.ToString("D"), + ((int)receipt.Action).ToString(CultureInfo.InvariantCulture), + receipt.PropertyId.ToString("D"), + receipt.ReservationId.ToString("D"), + receipt.CaseId.ToString("D"), + receipt.ApprovalRevision.ToString(CultureInfo.InvariantCulture), + receipt.SelectedReservationVersion.ToString( + CultureInfo.InvariantCulture), + receipt.ContractVersion.ToString(CultureInfo.InvariantCulture), + receipt.RestrictionVersion.ToString(CultureInfo.InvariantCulture), + receipt.ProjectionRevision.ToString(CultureInfo.InvariantCulture), + receipt.EffectiveRestricted ? "1" : "0", + actorId, + receipt.EventId.ToString("D"), + receipt.CompletedAtUtc.ToUniversalTime().ToString( + "O", + CultureInfo.InvariantCulture)); + return Convert.ToHexString( + SHA256.HashData(Encoding.UTF8.GetBytes(canonical))) + .ToLowerInvariant(); + } +} diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/DependencyInjection.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/DependencyInjection.cs index 3307b8c7..34d312ff 100644 --- a/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/DependencyInjection.cs +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/DependencyInjection.cs @@ -58,6 +58,10 @@ public static IServiceCollection AddReservationsApplication(this IServiceCollect ServiceDescriptor.Scoped< IDataRightsAnonymisationContributor, ReservationDataRightsAnonymisationContributor>()); + services.TryAddEnumerable( + ServiceDescriptor.Scoped< + IDataRightsRestrictionContributor, + ReservationDataRightsRestrictionContributor>()); services.TryAddEnumerable( ServiceDescriptor.Scoped< IDataRightsAnonymisationRestoreContributor, diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Handlers/ApplyReservationProcessingRestrictionCommandHandler.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Handlers/ApplyReservationProcessingRestrictionCommandHandler.cs index fcdb9ab8..ba6f84eb 100644 --- a/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Handlers/ApplyReservationProcessingRestrictionCommandHandler.cs +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Handlers/ApplyReservationProcessingRestrictionCommandHandler.cs @@ -61,7 +61,12 @@ await restrictions.FindReceiptByIdempotencyKeyAsync( cancellationToken).ConfigureAwait(false); if (existing is not null) { - return Replay(existing, command); + ReservationProcessingRestriction? replayRestriction = + await restrictions.GetAsync( + command.PropertyId, + existing.RestrictionId, + cancellationToken).ConfigureAwait(false); + return Replay(existing, replayRestriction, command, actorId); } CountryPolicyDecision policyDecision = await countryPolicy.EvaluateAsync( @@ -210,9 +215,17 @@ await restrictions.AddReceiptAsync( private static Result Replay( ReservationProcessingRestrictionReceipt receipt, - ApplyReservationProcessingRestrictionCommand command) + ReservationProcessingRestriction? restriction, + ApplyReservationProcessingRestrictionCommand command, + string actorId) { if (receipt.Action != ReservationProcessingRestrictionAction.Apply || + restriction is null || + restriction.Id != receipt.RestrictionId || + !string.Equals( + restriction.AppliedBy, + actorId, + StringComparison.Ordinal) || receipt.PropertyId != command.PropertyId || receipt.ReservationId != command.ReservationId || receipt.CaseId != command.CaseId || diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Handlers/ReleaseReservationProcessingRestrictionCommandHandler.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Handlers/ReleaseReservationProcessingRestrictionCommandHandler.cs index 518faabb..f69e69bc 100644 --- a/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Handlers/ReleaseReservationProcessingRestrictionCommandHandler.cs +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Handlers/ReleaseReservationProcessingRestrictionCommandHandler.cs @@ -62,7 +62,12 @@ await restrictions.FindReceiptByIdempotencyKeyAsync( cancellationToken).ConfigureAwait(false); if (existing is not null) { - return Replay(existing, command); + ReservationProcessingRestriction? replayRestriction = + await restrictions.GetAsync( + command.PropertyId, + existing.RestrictionId, + cancellationToken).ConfigureAwait(false); + return Replay(existing, replayRestriction, command, actorId); } CountryPolicyDecision policyDecision = await countryPolicy.EvaluateAsync( @@ -89,7 +94,13 @@ await restrictions.FindReceiptByIdempotencyKeyAsync( command.ReservationId, command.ExpectedReservationVersion, DataRightsRestrictionDirective.Release, - ExecutingActorId: actorId), + ExecutingActorId: actorId, + RestrictionTargetOwnerOperationId: + command.LegacyUnboundTarget ? null : command.RestrictionId, + RestrictionTargetOwnerOperationVersion: + command.LegacyUnboundTarget + ? null + : command.ExpectedRestrictionVersion), cancellationToken).ConfigureAwait(false); if (!approval.IsApproved) { @@ -219,9 +230,17 @@ await restrictions.AddReceiptAsync( private static Result Replay( ReservationProcessingRestrictionReceipt receipt, - ReleaseReservationProcessingRestrictionCommand command) + ReservationProcessingRestriction? restriction, + ReleaseReservationProcessingRestrictionCommand command, + string actorId) { if (receipt.Action != ReservationProcessingRestrictionAction.Release || + restriction is null || + restriction.Id != receipt.RestrictionId || + !string.Equals( + restriction.ReleasedBy, + actorId, + StringComparison.Ordinal) || receipt.RestrictionId != command.RestrictionId || receipt.PropertyId != command.PropertyId || receipt.ReservationId != command.ReservationId || diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Ports/IReservationProcessingRestrictionRepository.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Ports/IReservationProcessingRestrictionRepository.cs index 1142e411..ace1aeac 100644 --- a/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Ports/IReservationProcessingRestrictionRepository.cs +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/Ports/IReservationProcessingRestrictionRepository.cs @@ -1,6 +1,7 @@ namespace BunkFy.Modules.Reservations.Application.Ports; using BunkFy.Modules.Reservations.Domain.DataRights; +using Gma.Framework.Pagination; public interface IReservationProcessingRestrictionRepository { @@ -27,6 +28,12 @@ public interface IReservationProcessingRestrictionRepository Guid restrictionId, CancellationToken cancellationToken); + Task> ListActiveAsync( + Guid propertyId, + Guid reservationId, + PageRequest pageRequest, + CancellationToken cancellationToken); + Task AddAsync( ReservationProcessingRestriction restriction, CancellationToken cancellationToken); diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/ReservationsApplicationErrors.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/ReservationsApplicationErrors.cs index 1d3ffbf4..87ce54c5 100644 --- a/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/ReservationsApplicationErrors.cs +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Application/ReservationsApplicationErrors.cs @@ -128,6 +128,12 @@ public static class ReservationsApplicationErrors public static readonly Error ProcessingRestrictionProjectionUnavailable = new( "Reservations.ProcessingRestrictionProjectionUnavailable", "The reservation processing-restriction state is unavailable or unsupported."); + public static readonly Error ProcessingRestrictionActiveStateInvalid = new( + "Reservations.ProcessingRestrictionActiveStateInvalid", + "The active reservation processing-restriction state is inconsistent."); + public static readonly Error ProcessingRestrictionOwnerProofInvalid = new( + "Reservations.ProcessingRestrictionOwnerProofInvalid", + "The committed reservation processing-restriction proof is unavailable or inconsistent."); public static readonly Error DataHoldRequestInvalid = new( "Reservations.DataHoldRequestInvalid", "The reservation data-hold request is invalid."); diff --git a/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/Repositories/ReservationProcessingRestrictionRepository.cs b/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/Repositories/ReservationProcessingRestrictionRepository.cs index 8c6de901..36a51aa2 100644 --- a/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/Repositories/ReservationProcessingRestrictionRepository.cs +++ b/src/Modules/Reservations/BunkFy.Modules.Reservations.Persistence/Repositories/ReservationProcessingRestrictionRepository.cs @@ -2,6 +2,8 @@ namespace BunkFy.Modules.Reservations.Persistence.Repositories; using BunkFy.Modules.Reservations.Application.Ports; using BunkFy.Modules.Reservations.Domain.DataRights; +using BunkFy.Modules.Reservations.Domain.Models; +using Gma.Framework.Pagination; using Microsoft.EntityFrameworkCore; internal sealed class ReservationProcessingRestrictionRepository( @@ -56,6 +58,26 @@ public Task restriction.PropertyId == propertyId, cancellationToken); + public async Task> + ListActiveAsync( + Guid propertyId, + Guid reservationId, + PageRequest pageRequest, + CancellationToken cancellationToken) => + await dbContext.ProcessingRestrictions + .AsNoTracking() + .Where(restriction => + restriction.PropertyId == propertyId && + restriction.ReservationId == reservationId && + restriction.Status == + ReservationProcessingRestrictionStatus.Active) + .OrderBy(restriction => restriction.AppliedAtUtc) + .ThenBy(restriction => restriction.Id) + .Skip(pageRequest.SkipCount) + .Take(pageRequest.PageSize) + .ToArrayAsync(cancellationToken) + .ConfigureAwait(false); + public Task AddAsync( ReservationProcessingRestriction restriction, CancellationToken cancellationToken) diff --git a/src/Modules/Reservations/docs/README.md b/src/Modules/Reservations/docs/README.md index bf0672eb..13485846 100644 --- a/src/Modules/Reservations/docs/README.md +++ b/src/Modules/Reservations/docs/README.md @@ -53,6 +53,10 @@ retention, and rights policies receive production approval. - owner-local Data Rights correction execution protected by the public host's configured recent-authentication assurance in addition to scoped execution permission and approved-claim validation; +- registered Data Rights processing-restriction ownership with bounded release + target discovery, exact target/version selection, and actor-bound immutable + replay proof; legacy unbound releases fail closed unless exactly one active + Reservations restriction remains; - catalogue-driven, transient DataRights export of Reservations-owned booking, pending amendment, durable stay-amendment operation, Guest-link, details-history, adapter-receipt, and reminder records without staff actor diff --git a/src/Modules/Reservations/docs/personal-data-catalog.v1.json b/src/Modules/Reservations/docs/personal-data-catalog.v1.json index 1b836088..9bc06b02 100644 --- a/src/Modules/Reservations/docs/personal-data-catalog.v1.json +++ b/src/Modules/Reservations/docs/personal-data-catalog.v1.json @@ -10058,6 +10058,7 @@ "retentionPolicy": "reservation-processing-restriction-lifecycle", "rightsPolicy": "reservation-processing-restriction-accountability", "allowedSurfaces": [ + "application-command", "api-response", "data-rights-export", "domain-event", @@ -10071,6 +10072,13 @@ ], "approvalState": "engineering-default", "bindings": [ + { + "assembly": "BunkFy.Modules.Reservations.Application", + "type": "BunkFy.Modules.Reservations.Application.Commands.ReleaseReservationProcessingRestrictionCommand", + "member": "LegacyUnboundTarget", + "surface": "application-command", + "retentionPolicy": "transient-request" + }, { "assembly": "BunkFy.Modules.Reservations.Contracts", "type": "BunkFy.Modules.Reservations.Contracts.ReservationProcessingRestrictionReceiptDto", diff --git a/src/Modules/Reservations/docs/personal-data-inventory.v1.md b/src/Modules/Reservations/docs/personal-data-inventory.v1.md index 29dd7d3b..b5e298c8 100644 --- a/src/Modules/Reservations/docs/personal-data-inventory.v1.md +++ b/src/Modules/Reservations/docs/personal-data-inventory.v1.md @@ -225,7 +225,7 @@ Engineering metadata is not legal or country-launch approval. | reservation.processing-restriction.approval-revision | guest | linked-operational | standard | accountability
data-rights-restriction | authorized-data-rights-case
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-processing-restrictions | reservation.processing-restriction-accountability | reservation-processing-restriction-lifecycle | reservation-processing-restriction-accountability | api-response
application-command
data-rights-export
persistence | cross-module
customer-api
intra-module | engineering-default | | reservation.processing-restriction.case-id | guest | linked-operational | standard | accountability
data-rights-restriction | authorized-data-rights-case
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-processing-restrictions | reservation.processing-restriction-accountability | reservation-processing-restriction-lifecycle | reservation-processing-restriction-accountability | api-response
application-command
data-rights-export
persistence | cross-module
customer-api
intra-module | engineering-default | | reservation.processing-restriction.completed-at-utc | guest | linked-operational | standard | accountability
data-rights-restriction | authorized-data-rights-case
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-processing-restrictions | reservation.processing-restriction-accountability | reservation-processing-restriction-lifecycle | reservation-processing-restriction-accountability | api-response
data-rights-export
persistence | cross-module
customer-api
intra-module | engineering-default | -| reservation.processing-restriction.contract-version | guest | linked-operational | standard | accountability
data-rights-restriction | authorized-data-rights-case
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-processing-restrictions | reservation.processing-restriction-accountability | reservation-processing-restriction-lifecycle | reservation-processing-restriction-accountability | api-response
data-rights-export
domain-event
integration-event
persistence | cross-module
customer-api
intra-module | engineering-default | +| reservation.processing-restriction.contract-version | guest | linked-operational | standard | accountability
data-rights-restriction | authorized-data-rights-case
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-processing-restrictions | reservation.processing-restriction-accountability | reservation-processing-restriction-lifecycle | reservation-processing-restriction-accountability | api-response
application-command
data-rights-export
domain-event
integration-event
persistence | cross-module
customer-api
intra-module | engineering-default | | reservation.processing-restriction.effective-state | guest | linked-operational | standard | accountability
data-rights-restriction | authorized-data-rights-case
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-processing-restrictions | reservation.processing-restriction-accountability | reservation-processing-restriction-lifecycle | reservation-processing-restriction-accountability | api-response
data-rights-export
domain-event
integration-event
persistence | cross-module
customer-api
intra-module | engineering-default | | reservation.processing-restriction.event-id | guest | linked-operational | standard | accountability
data-rights-restriction | authorized-data-rights-case
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-processing-restrictions | reservation.processing-restriction-accountability | reservation-processing-restriction-lifecycle | reservation-processing-restriction-accountability | api-response
data-rights-export
domain-event
integration-event
persistence | cross-module
customer-api
intra-module | engineering-default | | reservation.processing-restriction.id | guest | linked-operational | standard | accountability
data-rights-restriction | authorized-data-rights-case
system-generated | reservations | customer-controller-bunk-fy-processor | reservation-processing-restrictions | reservation.processing-restriction-accountability | reservation-processing-restriction-lifecycle | reservation-processing-restriction-accountability | api-response
application-command
data-rights-export
persistence | cross-module
customer-api
intra-module | engineering-default | @@ -1223,6 +1223,7 @@ Engineering metadata is not legal or country-launch approval. | reservation.processing-restriction.completed-at-utc | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationProcessingRestrictionReceiptDto | CompletedAtUtc | api-response | transient-response | | reservation.processing-restriction.completed-at-utc | BunkFy.Modules.Reservations.Domain | BunkFy.Modules.Reservations.Domain.DataRights.ReservationProcessingRestrictionReceipt | CompletedAtUtc | persistence | reservation-processing-restriction-lifecycle | | reservation.processing-restriction.completed-at-utc | BunkFy.Modules.Reservations.Persistence | BunkFy.Modules.Reservations.Persistence.Repositories.ReservationProcessingRestrictionReceiptDataRightsExport | CompletedAtUtc | data-rights-export | reservation-data-rights-export-fragment | +| reservation.processing-restriction.contract-version | BunkFy.Modules.Reservations.Application | BunkFy.Modules.Reservations.Application.Commands.ReleaseReservationProcessingRestrictionCommand | LegacyUnboundTarget | application-command | transient-request | | reservation.processing-restriction.contract-version | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationProcessingRestrictionChangedIntegrationEvent | ContractVersion | integration-event | integration-message-journal | | reservation.processing-restriction.contract-version | BunkFy.Modules.Reservations.Contracts | BunkFy.Modules.Reservations.Contracts.ReservationProcessingRestrictionReceiptDto | ContractVersion | api-response | transient-response | | reservation.processing-restriction.contract-version | BunkFy.Modules.Reservations.Domain | BunkFy.Modules.Reservations.Domain.DataRights.ReservationProcessingRestrictionProjection | ContractVersion | persistence | reservation-processing-restriction-lifecycle | diff --git a/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Application/ReservationDataRightsRestrictionContributorTests.cs b/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Application/ReservationDataRightsRestrictionContributorTests.cs new file mode 100644 index 00000000..58936470 --- /dev/null +++ b/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Application/ReservationDataRightsRestrictionContributorTests.cs @@ -0,0 +1,663 @@ +namespace BunkFy.Modules.Reservations.Tests.Application; + +using BunkFy.Modules.DataRights.Contracts; +using BunkFy.Modules.Reservations.Application; +using BunkFy.Modules.Reservations.Application.Commands; +using BunkFy.Modules.Reservations.Application.Contributors; +using BunkFy.Modules.Reservations.Application.Ports; +using BunkFy.Modules.Reservations.Contracts; +using BunkFy.Modules.Reservations.Domain.DataRights; +using BunkFy.Modules.Reservations.Domain.Models; +using Gma.Framework.Cqrs; +using Gma.Framework.Pagination; +using Gma.Framework.Results; +using Gma.Framework.Runtime.Time; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +[Trait("Category", "Unit")] +public sealed class ReservationDataRightsRestrictionContributorTests +{ + private static readonly DateTimeOffset Now = + new(2026, 8, 15, 12, 0, 0, TimeSpan.Zero); + + [Fact] + public void Application_registers_the_reservations_restriction_owner() + { + ServiceCollection services = new(); + + services.AddReservationsApplication(); + + ServiceDescriptor descriptor = Assert.Single( + services, + candidate => candidate.ServiceType == + typeof(IDataRightsRestrictionContributor)); + Assert.Equal( + typeof(ReservationDataRightsRestrictionContributor), + descriptor.ImplementationType); + Assert.Equal(ServiceLifetime.Scoped, descriptor.Lifetime); + } + + [Fact] + public async Task Committed_owner_receipt_replays_before_current_state_lookup() + { + DataRightsRestrictionContributionRequest request = CreateApplyRequest(); + ReservationProcessingRestriction restriction = + CreateActiveRestriction(request, applyCaseId: request.CaseId); + ReservationProcessingRestrictionReceipt receipt = CreateReceipt( + request, + ReservationProcessingRestrictionAction.Apply, + effectiveRestricted: true, + restrictionId: restriction.Id); + RecordingRestrictionRepository repository = new( + active: [restriction], + receipt: receipt); + RecordingDispatcher dispatcher = new(Result.Failure< + ReservationProcessingRestrictionReceiptDto>( + ReservationsApplicationErrors + .ProcessingRestrictionProjectionUnavailable)); + ReservationDataRightsRestrictionContributor contributor = new( + dispatcher, + new NullProjectionRepository(), + repository, + new TestClock()); + + DataRightsRestrictionContributionResult result = + await contributor.ExecuteAsync(request, CancellationToken.None); + + Assert.Equal( + DataRightsRestrictionContributionStatus.Completed, + result.Status); + DataRightsRestrictionOwnerProof proof = + Assert.IsType(result.OwnerProof); + Assert.Equal(receipt.Id, proof.ReceiptId); + Assert.Equal( + DataRightsRestrictionContract.Sha256Length, + proof.ReceiptSha256.Length); + Assert.Equal(0, dispatcher.CallCount); + Assert.Equal(0, repository.ActiveListCount); + } + + [Fact] + public async Task Changed_actor_cannot_replay_a_committed_owner_receipt() + { + DataRightsRestrictionContributionRequest original = CreateApplyRequest(); + ReservationProcessingRestriction restriction = + CreateActiveRestriction(original, applyCaseId: original.CaseId); + ReservationProcessingRestrictionReceipt receipt = CreateReceipt( + original, + ReservationProcessingRestrictionAction.Apply, + effectiveRestricted: true, + restrictionId: restriction.Id); + ReservationDataRightsRestrictionContributor contributor = new( + new RecordingDispatcher(Result.Failure< + ReservationProcessingRestrictionReceiptDto>( + ReservationsApplicationErrors + .ProcessingRestrictionProjectionUnavailable)), + new NullProjectionRepository(), + new RecordingRestrictionRepository( + active: [restriction], + receipt: receipt), + new TestClock()); + + DataRightsRestrictionContributionResult result = + await contributor.ExecuteAsync( + original with { ExecutingActorId = "user:other-executor" }, + CancellationToken.None); + + Assert.Equal( + DataRightsRestrictionContributionStatus.Failed, + result.Status); + Assert.Equal( + ReservationsApplicationErrors + .ProcessingRestrictionOwnerProofInvalid.Code, + result.OutcomeCode); + } + + [Fact] + public async Task Apply_dispatches_the_exact_reservation_coordinate() + { + DataRightsRestrictionContributionRequest request = CreateApplyRequest() with + { + ExecutingActorId = " user:executor " + }; + ReservationProcessingRestrictionProjection projection = + CreateProjection(request); + ReservationProcessingRestrictionReceiptDto receipt = CreateReceiptDto( + request, + ReservationProcessingRestrictionActionDto.Apply, + restrictionVersion: 1, + projectionRevision: 1, + effectiveRestricted: true); + RecordingDispatcher dispatcher = new(Result.Success(receipt)); + ReservationDataRightsRestrictionContributor contributor = new( + dispatcher, + new StubProjectionRepository(projection), + new RecordingRestrictionRepository(), + new TestClock()); + + DataRightsRestrictionContributionResult result = + await contributor.ExecuteAsync(request, CancellationToken.None); + + Assert.Equal( + DataRightsRestrictionContributionStatus.Completed, + result.Status); + ApplyReservationProcessingRestrictionCommand command = + Assert.IsType( + dispatcher.Command); + Assert.Equal(request.PropertyId, command.PropertyId); + Assert.Equal(request.CaseId, command.CaseId); + Assert.Equal(request.Coordinate.RecordId, command.ReservationId); + Assert.Equal( + request.Coordinate.RecordVersion, + command.ExpectedReservationVersion); + Assert.Equal(projection.Revision, command.ExpectedProjectionRevision); + Assert.Equal("user:executor", command.ActorId); + } + + [Fact] + public async Task Targeted_release_dispatches_only_the_reviewed_active_restriction() + { + DataRightsRestrictionContributionRequest apply = CreateApplyRequest(); + ReservationProcessingRestriction[] active = + [ + CreateActiveRestriction(apply), + CreateActiveRestriction(apply) + ]; + ReservationProcessingRestriction selected = active[1]; + DataRightsRestrictionContributionRequest request = CreateReleaseRequest( + apply, + selected); + ReservationProcessingRestrictionProjection projection = + CreateProjection(request, activeRestrictionCount: 2); + ReservationProcessingRestrictionReceiptDto receipt = CreateReceiptDto( + request, + ReservationProcessingRestrictionActionDto.Release, + restrictionVersion: selected.Version + 1, + projectionRevision: projection.Revision + 1, + effectiveRestricted: true, + restrictionId: selected.Id); + RecordingDispatcher dispatcher = new(Result.Success(receipt)); + RecordingRestrictionRepository repository = new(active: active); + ReservationDataRightsRestrictionContributor contributor = new( + dispatcher, + new StubProjectionRepository(projection), + repository, + new TestClock()); + + DataRightsRestrictionContributionResult result = + await contributor.ExecuteAsync(request, CancellationToken.None); + + Assert.Equal( + DataRightsRestrictionContributionStatus.Completed, + result.Status); + Assert.True(result.OwnerProof?.EffectiveRestricted); + Assert.Equal(selected.Id, result.OwnerProof?.OwnerOperationId); + Assert.Equal(0, repository.ActiveListCount); + ReleaseReservationProcessingRestrictionCommand command = + Assert.IsType( + dispatcher.Command); + Assert.Equal(selected.Id, command.RestrictionId); + Assert.Equal(selected.Version, command.ExpectedRestrictionVersion); + Assert.Equal(projection.Revision, command.ExpectedProjectionRevision); + Assert.False(command.LegacyUnboundTarget); + } + + [Fact] + public async Task Legacy_unbound_release_requires_exactly_one_active_restriction() + { + DataRightsRestrictionContributionRequest apply = CreateApplyRequest(); + ReservationProcessingRestriction active = CreateActiveRestriction(apply); + DataRightsRestrictionContributionRequest request = + CreateLegacyReleaseRequest(apply); + ReservationProcessingRestrictionProjection projection = + CreateProjection(request, activeRestrictionCount: 1); + ReservationProcessingRestrictionReceiptDto receipt = CreateReceiptDto( + request, + ReservationProcessingRestrictionActionDto.Release, + restrictionVersion: active.Version + 1, + projectionRevision: projection.Revision + 1, + effectiveRestricted: false, + restrictionId: active.Id); + RecordingDispatcher dispatcher = new(Result.Success(receipt)); + RecordingRestrictionRepository repository = new(active: [active]); + ReservationDataRightsRestrictionContributor contributor = new( + dispatcher, + new StubProjectionRepository(projection), + repository, + new TestClock()); + + DataRightsRestrictionContributionResult result = + await contributor.ExecuteAsync(request, CancellationToken.None); + + Assert.Equal( + DataRightsRestrictionContributionStatus.Completed, + result.Status); + Assert.False(result.OwnerProof?.EffectiveRestricted); + Assert.Equal(1, repository.ActiveListCount); + ReleaseReservationProcessingRestrictionCommand command = + Assert.IsType( + dispatcher.Command); + Assert.True(command.LegacyUnboundTarget); + Assert.Equal(active.Id, command.RestrictionId); + } + + [Fact] + public async Task Legacy_unbound_release_with_multiple_active_restrictions_fails_closed() + { + DataRightsRestrictionContributionRequest request = + CreateLegacyReleaseRequest(CreateApplyRequest()); + ReservationProcessingRestriction[] active = + [ + CreateActiveRestriction(request), + CreateActiveRestriction(request) + ]; + RecordingDispatcher dispatcher = new(Result.Failure< + ReservationProcessingRestrictionReceiptDto>( + ReservationsApplicationErrors + .ProcessingRestrictionProjectionUnavailable)); + ReservationDataRightsRestrictionContributor contributor = new( + dispatcher, + new StubProjectionRepository(CreateProjection(request, 2)), + new RecordingRestrictionRepository(active: active), + new TestClock()); + + DataRightsRestrictionContributionResult result = + await contributor.ExecuteAsync(request, CancellationToken.None); + + Assert.Equal( + DataRightsRestrictionContributionStatus.Blocked, + result.Status); + Assert.Equal( + ReservationsApplicationErrors + .ProcessingRestrictionActiveStateInvalid.Code, + result.OutcomeCode); + Assert.Equal(0, dispatcher.CallCount); + } + + [Fact] + public async Task Release_target_discovery_is_bounded_with_one_row_lookahead() + { + DataRightsRestrictionContributionRequest request = CreateApplyRequest(); + ReservationProcessingRestriction[] active = Enumerable.Range( + 0, + DataRightsRestrictionContract.MaxReleaseTargets + 1) + .Select(_ => CreateActiveRestriction(request)) + .ToArray(); + RecordingRestrictionRepository repository = new(active: active); + ReservationDataRightsRestrictionContributor contributor = new( + new RecordingDispatcher(Result.Failure< + ReservationProcessingRestrictionReceiptDto>( + ReservationsApplicationErrors + .ProcessingRestrictionProjectionUnavailable)), + new NullProjectionRepository(), + repository, + new TestClock()); + + DataRightsRestrictionTargetResolutionResult result = + await contributor.ResolveReleaseTargetsAsync( + CreateTargetRequest(request), + CancellationToken.None); + + Assert.Equal( + DataRightsRestrictionTargetResolutionStatus.Completed, + result.Status); + Assert.Equal( + DataRightsRestrictionContract.MaxReleaseTargets, + result.Targets?.Count); + Assert.True(result.LimitReached); + Assert.Equal( + DataRightsRestrictionContract.MaxReleaseTargets + 1, + repository.LastPageRequest?.PageSize); + } + + [Fact] + public async Task Exact_target_resolution_distinguishes_missing_and_stale_state() + { + DataRightsRestrictionContributionRequest request = CreateApplyRequest(); + ReservationProcessingRestriction active = CreateActiveRestriction(request); + ReservationDataRightsRestrictionContributor contributor = new( + new RecordingDispatcher(Result.Failure< + ReservationProcessingRestrictionReceiptDto>( + ReservationsApplicationErrors + .ProcessingRestrictionProjectionUnavailable)), + new NullProjectionRepository(), + new RecordingRestrictionRepository(active: [active]), + new TestClock()); + + DataRightsRestrictionTargetResolutionResult missing = + await contributor.ResolveReleaseTargetsAsync( + CreateTargetRequest(request) with + { + TargetOwnerOperationId = Guid.NewGuid(), + TargetOwnerOperationVersion = 1 + }, + CancellationToken.None); + DataRightsRestrictionTargetResolutionResult stale = + await contributor.ResolveReleaseTargetsAsync( + CreateTargetRequest(request) with + { + TargetOwnerOperationId = active.Id, + TargetOwnerOperationVersion = active.Version + 1 + }, + CancellationToken.None); + + Assert.Equal( + DataRightsRestrictionTargetResolutionStatus.NotFound, + missing.Status); + Assert.Equal( + DataRightsRestrictionTargetResolutionStatus.Stale, + stale.Status); + } + + [Fact] + public async Task Tampered_owner_proof_fails_closed() + { + DataRightsRestrictionContributionRequest apply = CreateApplyRequest(); + ReservationProcessingRestriction active = CreateActiveRestriction(apply); + DataRightsRestrictionContributionRequest targeted = + CreateReleaseRequest(apply, active); + ReservationProcessingRestrictionReceiptDto tampered = CreateReceiptDto( + targeted, + ReservationProcessingRestrictionActionDto.Release, + restrictionVersion: active.Version + 1, + projectionRevision: 2, + effectiveRestricted: false, + restrictionId: Guid.NewGuid()); + ReservationDataRightsRestrictionContributor tamperedContributor = new( + new RecordingDispatcher(Result.Success(tampered)), + new StubProjectionRepository(CreateProjection(targeted, 1)), + new RecordingRestrictionRepository(active: [active]), + new TestClock()); + + DataRightsRestrictionContributionResult invalidProof = + await tamperedContributor.ExecuteAsync( + targeted, + CancellationToken.None); + + Assert.Equal( + DataRightsRestrictionContributionStatus.Failed, + invalidProof.Status); + Assert.Equal( + ReservationsApplicationErrors + .ProcessingRestrictionOwnerProofInvalid.Code, + invalidProof.OutcomeCode); + } + + private static DataRightsRestrictionContributionRequest CreateApplyRequest() => + new( + DataRightsRestrictionContract.CurrentVersion, + "tenant-a", + Guid.NewGuid(), + Guid.NewGuid(), + Guid.NewGuid(), + ApprovalRevision: 6, + new DataRightsSubjectCoordinate( + ReservationsDataRightsCoordinates.Owner, + ReservationsDataRightsCoordinates.ReservationRecordType, + Guid.NewGuid(), + RecordVersion: 3), + DataRightsRestrictionDirective.Apply, + "user:executor", + Now.AddMinutes(1)); + + private static DataRightsRestrictionContributionRequest CreateReleaseRequest( + DataRightsRestrictionContributionRequest request, + ReservationProcessingRestriction target) => + request with + { + IdempotencyKey = Guid.NewGuid(), + CaseId = Guid.NewGuid(), + ApprovalRevision = request.ApprovalRevision + 1, + Directive = DataRightsRestrictionDirective.Release, + TargetOwnerOperationId = target.Id, + TargetOwnerOperationVersion = target.Version + }; + + private static DataRightsRestrictionContributionRequest + CreateLegacyReleaseRequest( + DataRightsRestrictionContributionRequest request) => + request with + { + IdempotencyKey = Guid.NewGuid(), + CaseId = Guid.NewGuid(), + ApprovalRevision = request.ApprovalRevision + 1, + Directive = DataRightsRestrictionDirective.Release, + TargetOwnerOperationId = null, + TargetOwnerOperationVersion = null + }; + + private static DataRightsRestrictionTargetResolutionRequest CreateTargetRequest( + DataRightsRestrictionContributionRequest request) => + new( + DataRightsRestrictionContract.CurrentVersion, + request.TenantId, + request.PropertyId, + request.CaseId, + request.Coordinate, + request.DeadlineUtc); + + private static ReservationProcessingRestrictionProjection CreateProjection( + DataRightsRestrictionContributionRequest request, + int activeRestrictionCount = 0) + { + ReservationProcessingRestrictionProjection projection = + ReservationProcessingRestrictionProjection.Create( + request.TenantId, + request.PropertyId!.Value, + request.Coordinate.RecordId, + ReservationProcessingRestrictionContract.CurrentVersion, + Now.AddHours(-1)).Value; + for (int index = 0; index < activeRestrictionCount; index++) + { + Assert.True(projection.Apply( + projection.Revision, + ReservationProcessingRestrictionContract.CurrentVersion, + Now.AddMinutes(-30 + index)).IsSuccess); + } + + return projection; + } + + private static ReservationProcessingRestriction CreateActiveRestriction( + DataRightsRestrictionContributionRequest request, + Guid? applyCaseId = null) => + ReservationProcessingRestriction.Create( + Guid.NewGuid(), + request.TenantId, + request.PropertyId!.Value, + request.Coordinate.RecordId, + applyCaseId ?? Guid.NewGuid(), + request.ApprovalRevision, + request.Coordinate.RecordVersion, + request.ExecutingActorId, + Now.AddMinutes(-10)).Value; + + private static ReservationProcessingRestrictionReceipt CreateReceipt( + DataRightsRestrictionContributionRequest request, + ReservationProcessingRestrictionAction action, + bool effectiveRestricted, + Guid? restrictionId = null) => + ReservationProcessingRestrictionReceipt.Create( + Guid.NewGuid(), + request.TenantId, + request.IdempotencyKey, + restrictionId ?? Guid.NewGuid(), + action, + request.PropertyId!.Value, + request.Coordinate.RecordId, + request.CaseId, + request.ApprovalRevision, + request.Coordinate.RecordVersion, + ReservationProcessingRestrictionContract.CurrentVersion, + action == ReservationProcessingRestrictionAction.Apply ? 1 : 2, + resultingProjectionRevision: 1, + effectiveRestricted, + Guid.NewGuid(), + Now.AddSeconds(-5)).Value; + + private static ReservationProcessingRestrictionReceiptDto CreateReceiptDto( + DataRightsRestrictionContributionRequest request, + ReservationProcessingRestrictionActionDto action, + long restrictionVersion, + long projectionRevision, + bool effectiveRestricted, + Guid? restrictionId = null) => + new( + Guid.NewGuid(), + restrictionId ?? Guid.NewGuid(), + action, + request.PropertyId!.Value, + request.Coordinate.RecordId, + request.CaseId, + request.ApprovalRevision, + request.Coordinate.RecordVersion, + ReservationProcessingRestrictionContract.CurrentVersion, + restrictionVersion, + projectionRevision, + effectiveRestricted, + Guid.NewGuid(), + Now.AddSeconds(-5)); + + private sealed class RecordingDispatcher( + Result result) + : IRequestDispatcher + { + public int CallCount { get; private set; } + public object? Command { get; private set; } + + public Task> SendAsync( + ICommand command, + CancellationToken cancellationToken = default) + { + this.CallCount++; + this.Command = command; + return Task.FromResult((Result)(object)result); + } + + public Task> QueryAsync( + IQuery query, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + } + + private sealed class StubProjectionRepository( + ReservationProcessingRestrictionProjection projection) + : IReservationProcessingRestrictionProjectionRepository + { + public Task GetAsync( + Guid propertyId, + Guid reservationId, + CancellationToken cancellationToken) => + Task.FromResult( + projection); + + public Task EnsureAsync( + string tenantId, + Guid propertyId, + Guid reservationId, + DateTimeOffset initializedAtUtc, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + } + + private sealed class NullProjectionRepository + : IReservationProcessingRestrictionProjectionRepository + { + public Task GetAsync( + Guid propertyId, + Guid reservationId, + CancellationToken cancellationToken) => + Task.FromResult(null); + + public Task EnsureAsync( + string tenantId, + Guid propertyId, + Guid reservationId, + DateTimeOffset initializedAtUtc, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + } + + private sealed class RecordingRestrictionRepository( + IEnumerable? active = null, + ReservationProcessingRestrictionReceipt? receipt = null) + : IReservationProcessingRestrictionRepository + { + private readonly IReadOnlyCollection active = + active?.ToArray() ?? []; + + public int ActiveListCount { get; private set; } + public PageRequest? LastPageRequest { get; private set; } + + public Task + FindReceiptByIdempotencyKeyAsync( + Guid idempotencyKey, + CancellationToken cancellationToken) => + Task.FromResult( + receipt?.IdempotencyKey == idempotencyKey ? receipt : null); + + public Task FindByApplyApprovalAsync( + Guid propertyId, + Guid reservationId, + Guid caseId, + long approvalRevision, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public Task FindByReleaseApprovalAsync( + Guid propertyId, + Guid reservationId, + Guid caseId, + long approvalRevision, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public Task GetAsync( + Guid propertyId, + Guid restrictionId, + CancellationToken cancellationToken) => + Task.FromResult(this.active.SingleOrDefault(restriction => + restriction.PropertyId == propertyId && + restriction.Id == restrictionId)); + + public Task> + ListActiveAsync( + Guid propertyId, + Guid reservationId, + PageRequest pageRequest, + CancellationToken cancellationToken) + { + this.ActiveListCount++; + this.LastPageRequest = pageRequest; + return Task.FromResult< + IReadOnlyCollection>( + this.active + .Where(restriction => + restriction.PropertyId == propertyId && + restriction.ReservationId == reservationId && + restriction.Status == + ReservationProcessingRestrictionStatus.Active) + .OrderBy(restriction => restriction.AppliedAtUtc) + .ThenBy(restriction => restriction.Id) + .Skip(pageRequest.SkipCount) + .Take(pageRequest.PageSize) + .ToArray()); + } + + public Task AddAsync( + ReservationProcessingRestriction restriction, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public Task AddReceiptAsync( + ReservationProcessingRestrictionReceipt value, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + } + + private sealed class TestClock : ISystemClock + { + public DateTimeOffset UtcNow => Now; + } +} diff --git a/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Application/ReservationProcessingRestrictionCommandHandlerTests.cs b/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Application/ReservationProcessingRestrictionCommandHandlerTests.cs index 433be05f..e68c5836 100644 --- a/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Application/ReservationProcessingRestrictionCommandHandlerTests.cs +++ b/src/Modules/Reservations/tests/BunkFy.Modules.Reservations.Tests/Application/ReservationProcessingRestrictionCommandHandlerTests.cs @@ -99,25 +99,40 @@ public async Task Overlapping_approved_restrictions_fail_closed_until_both_are_r restrictions.Restrictions[0]; ReservationProcessingRestriction secondRestriction = restrictions.Restrictions[1]; + ReleaseReservationProcessingRestrictionCommand firstReleaseCommand = + CreateReleaseCommand( + reservation, + firstRestriction, + expectedProjectionRevision: 2); + ReleaseReservationProcessingRestrictionCommand secondReleaseCommand = + CreateReleaseCommand( + reservation, + secondRestriction, + expectedProjectionRevision: 3) with + { + LegacyUnboundTarget = true + }; Result firstRelease = await release.HandleAsync( - CreateReleaseCommand( - reservation, - firstRestriction, - expectedProjectionRevision: 2), + firstReleaseCommand, CancellationToken.None); Result secondRelease = await release.HandleAsync( - CreateReleaseCommand( - reservation, - secondRestriction, - expectedProjectionRevision: 3), + secondReleaseCommand, + CancellationToken.None); + Result changedActorReplay = + await release.HandleAsync( + firstReleaseCommand with { ActorId = "user:other-operator" }, CancellationToken.None); Assert.True(firstRelease.IsSuccess, firstRelease.Error.Code); Assert.True(firstRelease.Value.EffectiveRestricted); Assert.True(secondRelease.IsSuccess, secondRelease.Error.Code); Assert.False(secondRelease.Value.EffectiveRestricted); + Assert.Equal( + ReservationsApplicationErrors + .ProcessingRestrictionIdempotencyConflict, + changedActorReplay.Error); Assert.False(projection.IsRestricted); Assert.Equal(0, projection.ActiveRestrictionCount); Assert.Equal(4, projection.Revision); @@ -130,6 +145,22 @@ await release.HandleAsync( ReservationProcessingRestrictionAction.Release ], restrictions.Receipts.Select(receipt => receipt.Action)); + Assert.Collection( + approval.Requests.Skip(2), + request => + { + Assert.Equal( + firstReleaseCommand.RestrictionId, + request.RestrictionTargetOwnerOperationId); + Assert.Equal( + firstReleaseCommand.ExpectedRestrictionVersion, + request.RestrictionTargetOwnerOperationVersion); + }, + request => + { + Assert.Null(request.RestrictionTargetOwnerOperationId); + Assert.Null(request.RestrictionTargetOwnerOperationVersion); + }); Assert.DoesNotContain( typeof(ReservationProcessingRestrictionReceipt).GetProperties(), property => property.Name is @@ -164,6 +195,10 @@ public async Task Exact_replay_bypasses_approval_and_mismatched_replay_is_reject await handler.HandleAsync(command, CancellationToken.None); Result replay = await handler.HandleAsync(command, CancellationToken.None); + Result changedActorReplay = + await handler.HandleAsync( + command with { ActorId = "user:other-operator" }, + CancellationToken.None); Result conflict = await handler.HandleAsync( command with { CaseId = Guid.NewGuid() }, @@ -180,6 +215,10 @@ command with Assert.True(applied.IsSuccess, applied.Error.Code); Assert.True(replay.IsSuccess, replay.Error.Code); Assert.Equal(applied.Value.ReceiptId, replay.Value.ReceiptId); + Assert.Equal( + ReservationsApplicationErrors + .ProcessingRestrictionIdempotencyConflict, + changedActorReplay.Error); Assert.Equal( ReservationsApplicationErrors.ProcessingRestrictionIdempotencyConflict, conflict.Error); @@ -457,6 +496,25 @@ public Task restriction.PropertyId == propertyId && restriction.Id == restrictionId)); + public Task> + ListActiveAsync( + Guid propertyId, + Guid reservationId, + PageRequest pageRequest, + CancellationToken cancellationToken) => + Task.FromResult>( + this.Restrictions + .Where(restriction => + restriction.PropertyId == propertyId && + restriction.ReservationId == reservationId && + restriction.Status == + ReservationProcessingRestrictionStatus.Active) + .OrderBy(restriction => restriction.AppliedAtUtc) + .ThenBy(restriction => restriction.Id) + .Skip(pageRequest.SkipCount) + .Take(pageRequest.PageSize) + .ToArray()); + public Task AddAsync( ReservationProcessingRestriction restriction, CancellationToken cancellationToken) diff --git a/tests/Integration.Tests/Reservations/ReservationDataRightsIntegrationTests.cs b/tests/Integration.Tests/Reservations/ReservationDataRightsIntegrationTests.cs index 613ccc7d..e48aba1e 100644 --- a/tests/Integration.Tests/Reservations/ReservationDataRightsIntegrationTests.cs +++ b/tests/Integration.Tests/Reservations/ReservationDataRightsIntegrationTests.cs @@ -6,6 +6,7 @@ namespace Integration.Tests; using BunkFy.DataGovernance; using BunkFy.Modules.DataRights.Application.Commands; using BunkFy.Modules.DataRights.Application.Models; +using BunkFy.Modules.DataRights.Application.Queries; using BunkFy.Modules.DataRights.Contracts; using BunkFy.Modules.DataRights.Contracts.Authorization; using BunkFy.Modules.DataRights.Domain.Aggregates; @@ -36,6 +37,8 @@ namespace Integration.Tests; using Microsoft.Extensions.Hosting; using Testcontainers.PostgreSql; using Xunit; +using RestrictionReleaseTarget = + BunkFy.Modules.DataRights.Domain.ValueObjects.DataRightsRestrictionReleaseTarget; public sealed class ReservationDataRightsIntegrationTests { @@ -539,14 +542,13 @@ public async Task Approved_restriction_on_retired_property_gates_processing_and_ DataRightsCase releaseCase) = await SeedApprovedRestrictionCasesAsync(api, propertyId) .ConfigureAwait(false); - ApplyReservationProcessingRestrictionCommand applyCommand = new( - Guid.NewGuid(), - propertyId, + DataRightsCaseScope dataRightsScope = + DataRightsCaseScope.ForProperty(propertyId); + ExecuteDataRightsRestrictionCommand applyCommand = new( + dataRightsScope, applyCase.Id, - applyCase.DecisionRevision!.Value, - reservation.Id, - reservation.Version, - ExpectedProjectionRevision: 0, + Guid.NewGuid(), + applyCase.Version, "user:privacy-operator"); using IServiceScope commandScope = api.Services.CreateScope(); @@ -555,20 +557,28 @@ await SeedApprovedRestrictionCasesAsync(api, propertyId) IRequestDispatcher dispatcher = commandScope.ServiceProvider.GetRequiredService(); - Result applied = + Result applied = await dispatcher.SendAsync(applyCommand, CancellationToken.None) .ConfigureAwait(false); Assert.True(applied.IsSuccess, applied.Error.Code); - Assert.Equal(ReservationProcessingRestrictionActionDto.Apply, applied.Value.Action); - Assert.True(applied.Value.EffectiveRestricted); - Assert.Equal(1, applied.Value.RestrictionVersion); - Assert.Equal(1, applied.Value.ProjectionRevision); + Assert.Equal(DataRightsCaseStatus.Completed, applied.Value.Case.Status); + Assert.Equal( + DataRightsRestrictionDirective.Apply, + applied.Value.Proof.Directive); + Assert.True(applied.Value.Proof.EffectiveRestricted); + Assert.Equal(1, applied.Value.Proof.ResultingOwnerRevision); + Assert.Equal(1, applied.Value.Proof.ResultingProjectionRevision); - Result applyReplay = + Result applyReplay = await dispatcher.SendAsync(applyCommand, CancellationToken.None) .ConfigureAwait(false); Assert.True(applyReplay.IsSuccess, applyReplay.Error.Code); - Assert.Equal(applied.Value.ReceiptId, applyReplay.Value.ReceiptId); + Assert.Equal(applied.Value.Proof, applyReplay.Value.Proof); + + releaseCase = await ApproveRestrictionReleaseCaseAsync( + api, + releaseCase.Id, + applyCase.Id).ConfigureAwait(false); CancelReservationCommand ordinaryMutation = new( Guid.NewGuid(), @@ -605,31 +615,29 @@ await dispatcher.SendAsync( CancellationToken.None).ConfigureAwait(false)); } - ReleaseReservationProcessingRestrictionCommand releaseCommand = new( - Guid.NewGuid(), - propertyId, - applied.Value.RestrictionId, + ExecuteDataRightsRestrictionCommand releaseCommand = new( + dataRightsScope, releaseCase.Id, - releaseCase.DecisionRevision!.Value, - reservation.Id, - reservation.Version, - applied.Value.RestrictionVersion, - applied.Value.ProjectionRevision, + Guid.NewGuid(), + releaseCase.Version, "user:privacy-operator"); - Result released = + Result released = await dispatcher.SendAsync(releaseCommand, CancellationToken.None) .ConfigureAwait(false); Assert.True(released.IsSuccess, released.Error.Code); - Assert.Equal(ReservationProcessingRestrictionActionDto.Release, released.Value.Action); - Assert.False(released.Value.EffectiveRestricted); - Assert.Equal(2, released.Value.RestrictionVersion); - Assert.Equal(2, released.Value.ProjectionRevision); + Assert.Equal(DataRightsCaseStatus.Completed, released.Value.Case.Status); + Assert.Equal( + DataRightsRestrictionDirective.Release, + released.Value.Proof.Directive); + Assert.False(released.Value.Proof.EffectiveRestricted); + Assert.Equal(2, released.Value.Proof.ResultingOwnerRevision); + Assert.Equal(2, released.Value.Proof.ResultingProjectionRevision); - Result releaseReplay = + Result releaseReplay = await dispatcher.SendAsync(releaseCommand, CancellationToken.None) .ConfigureAwait(false); Assert.True(releaseReplay.IsSuccess, releaseReplay.Error.Code); - Assert.Equal(released.Value.ReceiptId, releaseReplay.Value.ReceiptId); + Assert.Equal(released.Value.Proof, releaseReplay.Value.Proof); Result cancelled = await dispatcher.SendAsync( @@ -680,6 +688,16 @@ await reservations.OutboxMessages .OrderBy(message => message.OccurredAtUtc) .ToArrayAsync() .ConfigureAwait(false); + DataRightsDbContext dataRights = + verificationScope.ServiceProvider.GetRequiredService(); + DataRightsCase[] restrictionCases = await dataRights.Cases + .AsNoTracking() + .Where(candidate => + candidate.Id == applyCase.Id || + candidate.Id == releaseCase.Id) + .OrderBy(candidate => candidate.Id) + .ToArrayAsync() + .ConfigureAwait(false); long operationLockRevision = await reservations.Database .SqlQueryRaw(""" SELECT "Revision" AS "Value" @@ -695,6 +713,22 @@ FROM reservations.reservation_operation_locks Assert.Equal(ReservationProcessingRestrictionStatus.Released, restriction.Status); Assert.Equal(releaseCase.Id, restriction.ReleaseCaseId); Assert.Equal(releaseCase.DecisionRevision, restriction.ReleaseApprovalRevision); + Assert.Equal(2, restrictionCases.Length); + Assert.All( + restrictionCases, + dataRightsCase => + { + Assert.Equal(DataRightsCaseState.Completed, dataRightsCase.Status); + Assert.NotNull(dataRightsCase.RestrictionExecutionProof); + }); + DataRightsCase persistedReleaseCase = restrictionCases.Single( + dataRightsCase => dataRightsCase.Id == releaseCase.Id); + Assert.Equal( + restriction.Id, + persistedReleaseCase.RestrictionReleaseTarget?.OwnerOperationId); + Assert.Equal( + 1L, + persistedReleaseCase.RestrictionReleaseTarget?.OwnerOperationVersion); Assert.False(projection.IsRestricted); Assert.Equal(0, projection.ActiveRestrictionCount); Assert.Equal(2, projection.Revision); @@ -1257,7 +1291,7 @@ INSERT INTO reservations.arrival_reminders reservation, DataRightsRestrictionAction.Apply, Now.AddMinutes(1)); - DataRightsCase release = CreateApprovedRestrictionCase( + DataRightsCase release = CreateRestrictionCaseInDiscovery( reservation, DataRightsRestrictionAction.Release, Now.AddMinutes(10)); @@ -1268,10 +1302,127 @@ INSERT INTO reservations.arrival_reminders return (reservation, apply, release); } + private static async Task ApproveRestrictionReleaseCaseAsync( + AuthTestApplication api, + Guid caseId, + Guid expectedSourceCaseId) + { + using IServiceScope scope = api.Services.CreateScope(); + scope.ServiceProvider.GetRequiredService() + .SetTenant(TenantId); + IRequestDispatcher dispatcher = + scope.ServiceProvider.GetRequiredService(); + DataRightsDbContext dataRights = + scope.ServiceProvider.GetRequiredService(); + var releaseCoordinate = await dataRights.Cases + .AsNoTracking() + .Where(candidate => candidate.Id == caseId) + .Select(candidate => new { candidate.PropertyId, candidate.Version }) + .SingleAsync() + .ConfigureAwait(false); + DataRightsCaseScope caseScope = DataRightsCaseScope.ForProperty( + releaseCoordinate.PropertyId!.Value); + Result targets = + await dispatcher.QueryAsync( + new GetDataRightsRestrictionReleaseTargetsQuery( + caseScope, + caseId), + CancellationToken.None).ConfigureAwait(false); + Assert.True(targets.IsSuccess, targets.Error.Code); + Assert.False(targets.Value.LimitReached); + DataRightsRestrictionReleaseTargetCandidateDto target = + Assert.Single(targets.Value.Targets); + Assert.Equal(expectedSourceCaseId, target.SourceCaseId); + Result targeted = await dispatcher.SendAsync( + new SelectDataRightsRestrictionReleaseTargetCommand( + caseScope, + caseId, + target.OwnerOperationId, + target.OwnerOperationVersion, + ExpectedVersion: releaseCoordinate.Version, + "user:privacy-reviewer"), + CancellationToken.None).ConfigureAwait(false); + Assert.True(targeted.IsSuccess, targeted.Error.Code); + Result reviewed = await dispatcher.SendAsync( + new RequireDataRightsReviewCommand( + caseScope, + caseId, + targeted.Value.Version, + "user:privacy-reviewer"), + CancellationToken.None).ConfigureAwait(false); + Assert.True(reviewed.IsSuccess, reviewed.Error.Code); + Result deciding = await dispatcher.SendAsync( + new BeginDataRightsDecisionCommand( + caseScope, + caseId, + reviewed.Value.Version, + "user:decision-maker"), + CancellationToken.None).ConfigureAwait(false); + Assert.True(deciding.IsSuccess, deciding.Error.Code); + Result approved = await dispatcher.SendAsync( + new RecordDataRightsDecisionCommand( + caseScope, + caseId, + DataRightsDecisionOutcome.Approved, + DataRightsDecisionReason.RequestValidated, + deciding.Value.Version, + "user:decision-maker"), + CancellationToken.None).ConfigureAwait(false); + Assert.True(approved.IsSuccess, approved.Error.Code); + + DataRightsCase dataRightsCase = await dataRights.Cases + .AsNoTracking() + .SingleAsync(candidate => candidate.Id == caseId) + .ConfigureAwait(false); + Assert.Equal( + RestrictionReleaseTarget.CurrentBindingVersion, + dataRightsCase.RestrictionTargetingContractVersion); + Assert.Equal( + ReservationsDataRightsCoordinates.Owner, + dataRightsCase.RestrictionReleaseTarget?.OwnerKey); + Assert.Equal( + target.OwnerOperationId, + dataRightsCase.RestrictionReleaseTarget?.OwnerOperationId); + Assert.Equal( + target.OwnerOperationVersion, + dataRightsCase.RestrictionReleaseTarget?.OwnerOperationVersion); + Assert.Equal(approved.Value.Version, dataRightsCase.Version); + Assert.Equal( + DataRightsCaseState.Approved, + dataRightsCase.Status); + return dataRightsCase; + } + private static DataRightsCase CreateApprovedRestrictionCase( Reservation reservation, DataRightsRestrictionAction action, DateTimeOffset startedAtUtc) + { + DataRightsCase dataRightsCase = CreateRestrictionCaseInDiscovery( + reservation, + action, + startedAtUtc); + Assert.True(dataRightsCase.RequireReview( + dataRightsCase.Version, + "user:privacy-reviewer", + startedAtUtc.AddMinutes(3)).IsSuccess); + Assert.True(dataRightsCase.BeginDecision( + dataRightsCase.Version, + "user:decision-maker", + startedAtUtc.AddMinutes(4)).IsSuccess); + Assert.True(dataRightsCase.RecordDecision( + DataRightsCaseDecision.Approved, + DataRightsCaseDecisionReason.RequestValidated, + dataRightsCase.Version, + "user:decision-maker", + startedAtUtc.AddMinutes(5)).IsSuccess); + return dataRightsCase; + } + + private static DataRightsCase CreateRestrictionCaseInDiscovery( + Reservation reservation, + DataRightsRestrictionAction action, + DateTimeOffset startedAtUtc) { DataRightsCaseRequest request = DataRightsCaseRequest.Create( reservation.PropertyId, @@ -1297,20 +1448,6 @@ private static DataRightsCase CreateApprovedRestrictionCase( dataRightsCase.Version, "user:privacy-reviewer", startedAtUtc.AddMinutes(2)).IsSuccess); - Assert.True(dataRightsCase.RequireReview( - dataRightsCase.Version, - "user:privacy-reviewer", - startedAtUtc.AddMinutes(3)).IsSuccess); - Assert.True(dataRightsCase.BeginDecision( - dataRightsCase.Version, - "user:decision-maker", - startedAtUtc.AddMinutes(4)).IsSuccess); - Assert.True(dataRightsCase.RecordDecision( - DataRightsCaseDecision.Approved, - DataRightsCaseDecisionReason.RequestValidated, - dataRightsCase.Version, - "user:decision-maker", - startedAtUtc.AddMinutes(5)).IsSuccess); return dataRightsCase; } diff --git a/tests/Integration.Tests/Reservations/ReservationsSagaIntegrationTests.LostOutcomeRecovery.cs b/tests/Integration.Tests/Reservations/ReservationsSagaIntegrationTests.LostOutcomeRecovery.cs index 3f665cba..6fc561c6 100644 --- a/tests/Integration.Tests/Reservations/ReservationsSagaIntegrationTests.LostOutcomeRecovery.cs +++ b/tests/Integration.Tests/Reservations/ReservationsSagaIntegrationTests.LostOutcomeRecovery.cs @@ -60,7 +60,11 @@ public async Task Restart_and_reconcile_recover_a_lost_stay_amendment_outcome_on natsConnectionString, disableOutboxPublisher: true)) { - await migrationApi.MigrateGuestRecordsAuthorizationDatabaseAsync() + await migrationApi.MigrateGuestDataRightsAuthorizationDatabaseAsync() + .ConfigureAwait(false); + await migrationApi.MigrateStaffAuthorizationDatabaseAsync() + .ConfigureAwait(false); + await migrationApi.MigrateIngestionDatabaseAsync() .ConfigureAwait(false); } diff --git a/tests/Integration.Tests/Reservations/ReservationsSagaIntegrationTests.cs b/tests/Integration.Tests/Reservations/ReservationsSagaIntegrationTests.cs index 3ac9d22e..6e59f412 100644 --- a/tests/Integration.Tests/Reservations/ReservationsSagaIntegrationTests.cs +++ b/tests/Integration.Tests/Reservations/ReservationsSagaIntegrationTests.cs @@ -67,7 +67,11 @@ public async Task Real_worker_coordinates_reservation_and_stay_lifecycles_throug natsConnectionString, disableOutboxPublisher: true)) { - await migrationApi.MigrateGuestRecordsAuthorizationDatabaseAsync() + await migrationApi.MigrateGuestDataRightsAuthorizationDatabaseAsync() + .ConfigureAwait(false); + await migrationApi.MigrateStaffAuthorizationDatabaseAsync() + .ConfigureAwait(false); + await migrationApi.MigrateIngestionDatabaseAsync() .ConfigureAwait(false); } From 69dacedbb3e5ea6b429c27131c4d709c2a24dc7b Mon Sep 17 00:00:00 2001 From: SadPossum Date: Sat, 15 Aug 2026 17:03:34 +0000 Subject: [PATCH 6/6] test(retention): align recovery fixture with scope coordinator --- .../Application/RetentionMutationCoordinatorTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Modules/Retention/tests/BunkFy.Modules.Retention.Tests/Application/RetentionMutationCoordinatorTests.cs b/src/Modules/Retention/tests/BunkFy.Modules.Retention.Tests/Application/RetentionMutationCoordinatorTests.cs index 2e966746..5d36e753 100644 --- a/src/Modules/Retention/tests/BunkFy.Modules.Retention.Tests/Application/RetentionMutationCoordinatorTests.cs +++ b/src/Modules/Retention/tests/BunkFy.Modules.Retention.Tests/Application/RetentionMutationCoordinatorTests.cs @@ -143,6 +143,7 @@ public async Task Reclaimed_lease_replays_terminal_execution() RetentionExecutionMutationCoordinator coordinator = new( new RecordingMutationLock(calls), executions, + new RecordingScheduleHealthReader(snapshot: null, calls), new RecordingScopeRepository(calls), new TestScopeContext()); RecordingScheduleStateRepository schedules = new();