diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b94e437 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +**/bin/ +**/obj/ +**/.vs/ +**/.git/ +.github/ +docs/ +artifacts/ +logs/ +tests/PriceNegotiationApp.IntegrationTests +*.md +Dockerfile* +docker-compose* +.env* diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..5f72b17 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,524 @@ +root = true + +# All files +[*] +indent_style = space + +# Xml files +[*.xml] +indent_size = 2 + +# Xml project files +[*.{csproj,fsproj,vbproj,proj,slnx}] +indent_size = 2 + +# Xml config files +[*.{props,targets,config,nuspec}] +indent_size = 2 + +[*.json] +indent_size = 2 + +# C# files +[*.cs] + +#### Core EditorConfig Options #### + +# Indentation and spacing +indent_size = 4 +tab_width = 4 + +# New line preferences +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +#### .NET Coding Conventions #### +[*.{cs,vb}] + +# Globals +dotnet_analyzer_diagnostic.category-Reliability.severity = warning +dotnet_analyzer_diagnostic.category-Performance.severity = warning +dotnet_analyzer_diagnostic.category-Security.severity = warning +dotnet_analyzer_diagnostic.category-Design.severity = warning + +# Organize usings +dotnet_separate_import_directive_groups = false +dotnet_sort_system_directives_first = false +file_header_template = unset + +# this. and Me. preferences +dotnet_style_qualification_for_event = false:silent +dotnet_style_qualification_for_field = false:silent +dotnet_style_qualification_for_method = false:silent +dotnet_style_qualification_for_property = false:silent + +# Language keywords vs BCL types preferences +dotnet_style_predefined_type_for_locals_parameters_members = true:silent +dotnet_style_predefined_type_for_member_access = true:silent + +# Parentheses preferences +dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent +dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent + +# Modifier preferences +dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent + +# Expression-level preferences +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_explicit_tuple_names = true:suggestion +dotnet_style_namespace_match_folder = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_object_initializer = true:suggestion +dotnet_style_operator_placement_when_wrapping = beginning_of_line +dotnet_style_prefer_auto_properties = true:suggestion +dotnet_style_prefer_collection_expression = when_types_loosely_match:suggestion +dotnet_style_prefer_compound_assignment = true:suggestion +dotnet_style_prefer_conditional_expression_over_assignment = true:suggestion +dotnet_style_prefer_conditional_expression_over_return = true:suggestion +dotnet_style_prefer_foreach_explicit_cast_in_source = when_strongly_typed:suggestion +dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion +dotnet_style_prefer_inferred_tuple_names = true:suggestion +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion +dotnet_style_prefer_simplified_boolean_expressions = true:suggestion +dotnet_style_prefer_simplified_interpolation = true:suggestion + +# Field preferences +dotnet_style_readonly_field = true:warning + +# Parameter preferences +dotnet_code_quality_unused_parameters = all:warning + +# Suppression preferences +dotnet_remove_unnecessary_suppression_exclusions = none + +#### C# Coding Conventions #### +[*.cs] + +# var preferences +csharp_style_var_elsewhere = false:silent +csharp_style_var_for_built_in_types = false:silent +csharp_style_var_when_type_is_apparent = false:silent + +# Expression-bodied members +csharp_style_expression_bodied_accessors = true:silent +csharp_style_expression_bodied_constructors = false:silent +csharp_style_expression_bodied_indexers = true:silent +csharp_style_expression_bodied_lambdas = true:suggestion +csharp_style_expression_bodied_local_functions = false:silent +csharp_style_expression_bodied_methods = false:silent +csharp_style_expression_bodied_operators = false:silent +csharp_style_expression_bodied_properties = true:silent + +# Pattern matching preferences +csharp_style_pattern_matching_over_as_with_null_check = true:suggestion +csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion +csharp_style_prefer_extended_property_pattern = true:suggestion +csharp_style_prefer_not_pattern = true:suggestion +csharp_style_prefer_pattern_matching = true:silent +csharp_style_prefer_switch_expression = true:suggestion + +# Null-checking preferences +csharp_style_conditional_delegate_call = true:suggestion + +# Modifier preferences +csharp_prefer_static_anonymous_function = true:suggestion +csharp_prefer_static_local_function = true:warning +csharp_preferred_modifier_order = public,private,protected,internal,file,const,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,required,volatile,async:suggestion +csharp_style_prefer_readonly_struct = true:suggestion +csharp_style_prefer_readonly_struct_member = true:suggestion + +# Code-block preferences +csharp_prefer_braces = true:warning +csharp_prefer_simple_using_statement = true:suggestion +csharp_style_namespace_declarations = file_scoped:suggestion +csharp_style_prefer_method_group_conversion = true:silent +csharp_style_prefer_primary_constructors = true:suggestion +csharp_style_prefer_top_level_statements = true:silent + +# Expression-level preferences +csharp_prefer_simple_default_expression = true:suggestion +csharp_style_deconstructed_variable_declaration = true:suggestion +csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion +csharp_style_inlined_variable_declaration = true:suggestion +csharp_style_prefer_index_operator = true:suggestion +csharp_style_prefer_local_over_anonymous_function = true:suggestion +csharp_style_prefer_null_check_over_type_check = true:suggestion +csharp_style_prefer_range_operator = true:suggestion +csharp_style_prefer_tuple_swap = true:suggestion +csharp_style_prefer_utf8_string_literals = true:suggestion +csharp_style_throw_expression = true:suggestion +csharp_style_unused_value_assignment_preference = discard_variable:suggestion +csharp_style_unused_value_expression_statement_preference = discard_variable:silent + +# 'using' directive preferences +csharp_using_directive_placement = outside_namespace:silent + +#### C# Formatting Rules #### + +# New line preferences +csharp_new_line_before_catch = true +csharp_new_line_before_else = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_open_brace = all +csharp_new_line_between_query_expression_clauses = true + +# Indentation preferences +csharp_indent_block_contents = true +csharp_indent_braces = false +csharp_indent_case_contents = true +csharp_indent_case_contents_when_block = true +csharp_indent_labels = one_less_than_current +csharp_indent_switch_labels = true + +# Space preferences +csharp_space_after_cast = false +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_after_comma = true +csharp_space_after_dot = false +csharp_space_after_lambda_arrow = true +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_after_semicolon_in_for_statement = true +csharp_space_around_binary_operators = before_and_after +csharp_space_around_declaration_statements = true +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_before_comma = false +csharp_space_before_dot = false +csharp_space_before_open_brace = true +csharp_space_before_open_square_brackets = false +csharp_space_before_semicolon_in_for_statement = false +csharp_space_between_empty_square_brackets = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_declaration_name_and_open_parenthesis = false +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_parentheses = false +csharp_space_between_square_brackets = false + +# Wrapping preferences +csharp_preserve_single_line_blocks = true +csharp_preserve_single_line_statements = false + +#### Naming styles #### +[*.{cs,vb}] + +# Naming rules + +dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.symbols = types_and_namespaces +dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.interfaces_should_be_ipascalcase.severity = suggestion +dotnet_naming_rule.interfaces_should_be_ipascalcase.symbols = interfaces +dotnet_naming_rule.interfaces_should_be_ipascalcase.style = ipascalcase + +dotnet_naming_rule.type_parameters_should_be_tpascalcase.severity = suggestion +dotnet_naming_rule.type_parameters_should_be_tpascalcase.symbols = type_parameters +dotnet_naming_rule.type_parameters_should_be_tpascalcase.style = tpascalcase + +dotnet_naming_rule.methods_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.methods_should_be_pascalcase.symbols = methods +dotnet_naming_rule.methods_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.properties_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.properties_should_be_pascalcase.symbols = properties +dotnet_naming_rule.properties_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.events_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.events_should_be_pascalcase.symbols = events +dotnet_naming_rule.events_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.local_variables_should_be_camelcase.severity = suggestion +dotnet_naming_rule.local_variables_should_be_camelcase.symbols = local_variables +dotnet_naming_rule.local_variables_should_be_camelcase.style = camelcase + +dotnet_naming_rule.local_constants_should_be_camelcase.severity = suggestion +dotnet_naming_rule.local_constants_should_be_camelcase.symbols = local_constants +dotnet_naming_rule.local_constants_should_be_camelcase.style = camelcase + +dotnet_naming_rule.parameters_should_be_camelcase.severity = suggestion +dotnet_naming_rule.parameters_should_be_camelcase.symbols = parameters +dotnet_naming_rule.parameters_should_be_camelcase.style = camelcase + +dotnet_naming_rule.public_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.public_fields_should_be_pascalcase.symbols = public_fields +dotnet_naming_rule.public_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.private_fields_should_be__camelcase.severity = suggestion +dotnet_naming_rule.private_fields_should_be__camelcase.symbols = private_fields +dotnet_naming_rule.private_fields_should_be__camelcase.style = _camelcase + +dotnet_naming_rule.private_static_fields_should_be_s_camelcase.severity = suggestion +dotnet_naming_rule.private_static_fields_should_be_s_camelcase.symbols = private_static_fields +dotnet_naming_rule.private_static_fields_should_be_s_camelcase.style = s_camelcase + +dotnet_naming_rule.public_constant_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.public_constant_fields_should_be_pascalcase.symbols = public_constant_fields +dotnet_naming_rule.public_constant_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.private_constant_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.private_constant_fields_should_be_pascalcase.symbols = private_constant_fields +dotnet_naming_rule.private_constant_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.symbols = public_static_readonly_fields +dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.symbols = private_static_readonly_fields +dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.enums_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.enums_should_be_pascalcase.symbols = enums +dotnet_naming_rule.enums_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.local_functions_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.local_functions_should_be_pascalcase.symbols = local_functions +dotnet_naming_rule.local_functions_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.non_field_members_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.non_field_members_should_be_pascalcase.symbols = non_field_members +dotnet_naming_rule.non_field_members_should_be_pascalcase.style = pascalcase + +# Symbol specifications + +dotnet_naming_symbols.interfaces.applicable_kinds = interface +dotnet_naming_symbols.interfaces.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.interfaces.required_modifiers = + +dotnet_naming_symbols.enums.applicable_kinds = enum +dotnet_naming_symbols.enums.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.enums.required_modifiers = + +dotnet_naming_symbols.events.applicable_kinds = event +dotnet_naming_symbols.events.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.events.required_modifiers = + +dotnet_naming_symbols.methods.applicable_kinds = method +dotnet_naming_symbols.methods.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.methods.required_modifiers = + +dotnet_naming_symbols.properties.applicable_kinds = property +dotnet_naming_symbols.properties.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.properties.required_modifiers = + +dotnet_naming_symbols.public_fields.applicable_kinds = field +dotnet_naming_symbols.public_fields.applicable_accessibilities = public, internal +dotnet_naming_symbols.public_fields.required_modifiers = + +dotnet_naming_symbols.private_fields.applicable_kinds = field +dotnet_naming_symbols.private_fields.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_fields.required_modifiers = + +dotnet_naming_symbols.private_static_fields.applicable_kinds = field +dotnet_naming_symbols.private_static_fields.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_static_fields.required_modifiers = static + +dotnet_naming_symbols.types_and_namespaces.applicable_kinds = namespace, class, struct, interface, enum +dotnet_naming_symbols.types_and_namespaces.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.types_and_namespaces.required_modifiers = + +dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method +dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.non_field_members.required_modifiers = + +dotnet_naming_symbols.type_parameters.applicable_kinds = namespace +dotnet_naming_symbols.type_parameters.applicable_accessibilities = * +dotnet_naming_symbols.type_parameters.required_modifiers = + +dotnet_naming_symbols.private_constant_fields.applicable_kinds = field +dotnet_naming_symbols.private_constant_fields.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_constant_fields.required_modifiers = const + +dotnet_naming_symbols.local_variables.applicable_kinds = local +dotnet_naming_symbols.local_variables.applicable_accessibilities = local +dotnet_naming_symbols.local_variables.required_modifiers = + +dotnet_naming_symbols.local_constants.applicable_kinds = local +dotnet_naming_symbols.local_constants.applicable_accessibilities = local +dotnet_naming_symbols.local_constants.required_modifiers = const + +dotnet_naming_symbols.parameters.applicable_kinds = parameter +dotnet_naming_symbols.parameters.applicable_accessibilities = * +dotnet_naming_symbols.parameters.required_modifiers = + +dotnet_naming_symbols.public_constant_fields.applicable_kinds = field +dotnet_naming_symbols.public_constant_fields.applicable_accessibilities = public, internal +dotnet_naming_symbols.public_constant_fields.required_modifiers = const + +dotnet_naming_symbols.public_static_readonly_fields.applicable_kinds = field +dotnet_naming_symbols.public_static_readonly_fields.applicable_accessibilities = public, internal +dotnet_naming_symbols.public_static_readonly_fields.required_modifiers = readonly, static + +dotnet_naming_symbols.private_static_readonly_fields.applicable_kinds = field +dotnet_naming_symbols.private_static_readonly_fields.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_static_readonly_fields.required_modifiers = readonly, static + +dotnet_naming_symbols.local_functions.applicable_kinds = local_function +dotnet_naming_symbols.local_functions.applicable_accessibilities = * +dotnet_naming_symbols.local_functions.required_modifiers = + +# Naming styles + +dotnet_naming_style.pascalcase.required_prefix = +dotnet_naming_style.pascalcase.required_suffix = +dotnet_naming_style.pascalcase.word_separator = +dotnet_naming_style.pascalcase.capitalization = pascal_case + +dotnet_naming_style.ipascalcase.required_prefix = I +dotnet_naming_style.ipascalcase.required_suffix = +dotnet_naming_style.ipascalcase.word_separator = +dotnet_naming_style.ipascalcase.capitalization = pascal_case + +dotnet_naming_style.tpascalcase.required_prefix = T +dotnet_naming_style.tpascalcase.required_suffix = +dotnet_naming_style.tpascalcase.word_separator = +dotnet_naming_style.tpascalcase.capitalization = pascal_case + +dotnet_naming_style._camelcase.required_prefix = _ +dotnet_naming_style._camelcase.required_suffix = +dotnet_naming_style._camelcase.word_separator = +dotnet_naming_style._camelcase.capitalization = camel_case + +dotnet_naming_style.camelcase.required_prefix = +dotnet_naming_style.camelcase.required_suffix = +dotnet_naming_style.camelcase.word_separator = +dotnet_naming_style.camelcase.capitalization = camel_case + +dotnet_naming_style.s_camelcase.required_prefix = s_ +dotnet_naming_style.s_camelcase.required_suffix = +dotnet_naming_style.s_camelcase.word_separator = +dotnet_naming_style.s_camelcase.capitalization = camel_case + + +# CA performance rules + +# Rename type name so it does not end in 'ending' +dotnet_diagnostic.CA1711.severity = suggestion + +# Rename method method_name because it conflicts with reserved keyword 'keyword' +dotnet_diagnostic.CA1716.severity = none + +# Member does not access instance data and can be marked as static +dotnet_diagnostic.CA1822.severity = suggestion + +# For improved performance, use the method_extensions delegates instead of calling 'method()' +dotnet_diagnostic.CA1848.severity = none + +# Change return type of method 'method_name' from 'Namespace.IInterface' to 'Namespace.Implementation' for improved performance +dotnet_diagnostic.CA1859.severity = suggestion + +# Evaluation of this argument may be expensive and unnecessary if logging is disabled +dotnet_diagnostic.CA1873.severity = none + + +# SonarAnalyzers.CSharp rules + +# Rename class 'className' to match pascal case naming rules, consider using 'ClassName'. +dotnet_diagnostic.S101.severity = suggestion + +# Complete the task associated to this 'TODO' comment. +dotnet_diagnostic.S1135.severity = suggestion + +# Remove the unused private setter +dotnet_diagnostic.S1144.severity = none + +# Either log this exception and handle it, or rethrow it with some contextual information. +# 'suggestion' due to false positives +dotnet_diagnostic.S2139.severity = suggestion + +# Make 'method_name' a static method. +dotnet_diagnostic.S2325.severity = none + +# Loops should be simplified using the "Where" LINQ method +dotnet_diagnostic.S3267.severity = suggestion + +# Extract this nested ternary operation into an independent statement. +dotnet_diagnostic.S3358.severity = suggestion + +# Specify AttributeUsage explicitly +dotnet_diagnostic.S3993.severity = suggestion + + +# Meziantou.Analyzers rules + +# Use an overload that has a IEqualityComparer or IComparer parameter +dotnet_diagnostic.MA0002.severity = suggestion + +# Use Task.ConfigureAwait(false) if the current SynchronizationContext is not needed +dotnet_diagnostic.MA0004.severity = none + +# Do not declare static members on generic types (deprecated; use CA1000 instead) +dotnet_diagnostic.MA0018.severity = none + +# Use 'TrueForAll()' instead of 'All()' +dotnet_diagnostic.MA0020.severity = none + +# TODO Differentiate not only test but also the OData DTO structure between admin and customer +dotnet_diagnostic.MA0026.severity = none + +# Make class static +dotnet_diagnostic.MA0036.severity = none + +# Make method static (deprecated, use CA1822 instead) +dotnet_diagnostic.MA0038.severity = none + +# File name must match type name (type type_name), expected file name: 'file_name' +dotnet_diagnostic.MA0048.severity = suggestion + +# Method is too long (X lines; maximum allowed: 60) +dotnet_diagnostic.MA0051.severity = suggestion + +# Use 'Order' instead of 'OrderBy' +dotnet_diagnostic.MA0159.severity = suggestion + +# A type should have dedicated documentation instead of '' +dotnet_diagnostic.MA0197.severity = none + + +# NuGet security vulnerabilities rules + +# Warning NU1902: Package vulnerability detected +dotnet_diagnostic.NU1902.severity = suggestion + + +[**/src/**/*.cs] + +# Add a 'protected' constructor or the 'static' keyword to the class declaration. +dotnet_diagnostic.S1118.severity = none + + +# Migrations (generated code) +[**/Migrations/*.cs] + +# Underscores in generated names +dotnet_diagnostic.CA1707.severity = none + +# Prefer 'static readonly' fields over constant array arguments if the called method is called repeatedly and is not mutating the passed array +dotnet_diagnostic.CA1861.severity = none + + +[**/tests/**/*.cs] + +# Underscores in generated names +dotnet_diagnostic.CA1707.severity = none + +# Avoid creating a new JsonSerializerOptions instance for every serialization operation +dotnet_diagnostic.CA1869.severity = none + +# Await DisposeAsync instead. +dotnet_diagnostic.S6966.severity = none + +# Use 'DisposeAsync' instead of 'Dispose' +dotnet_diagnostic.MA0042.severity = suggestion + + +[**/obj/**/*.cs] +generated_code = true diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a2e794a --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +# Required — fill in real values before docker compose up +POSTGRES_PASSWORD=change-me +# ES256 signing key: PKCS#8 PEM generated per README; escape newlines as \n for one line +JWT_PRIVATE_KEY=replace-with-key-generated-per-README +SEED_ADMIN_PASSWORD=replace-me-strong-random-Aa1! +SEED_STAFF_PASSWORD=replace-me-strong-random-Bb2! + +# Observability overlay (compose.observability.yml) - dashboard login token +ASPIRE_DASHBOARD_TOKEN=change-me-local-token + +# Telemetry ingestion key (dashboard rejects unauthenticated OTLP senders) +ASPIRE_OTLP_API_KEY=change-me-local-otlp-key diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..39d8445 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,14 @@ +version: 2 +updates: + - package-ecosystem: nuget + directory: / + schedule: + interval: weekly + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + - package-ecosystem: docker + directory: / + schedule: + interval: weekly diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..045adbb --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,37 @@ +name: ci + +on: + push: + branches: [main, develop] + pull_request: + +jobs: + build-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + + - name: Restore + run: dotnet restore + + - name: Format check + run: dotnet format --verify-no-changes + + - name: Build + run: dotnet build -c Release --no-restore + + - name: Test + run: dotnet test --solution PriceNegotiationApp.slnx -c Release --no-build --coverage --coverage-output-format cobertura --report-trx + + - name: Upload test artifacts + if: always() + uses: actions/upload-artifact@v7 + with: + name: test-results + path: "**/TestResults/**" + retention-days: 7 + diff --git a/.gitignore b/.gitignore index 9491a2f..ce2fba3 100644 --- a/.gitignore +++ b/.gitignore @@ -360,4 +360,10 @@ MigrationBackup/ .ionide/ # Fody - auto-generated XML schema -FodyWeavers.xsd \ No newline at end of file +FodyWeavers.xsd +artifacts/ +logs/ +*.user + +.env +logs/ diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..cbd2cc9 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,38 @@ + + + net10.0 + enable + enable + latest + + latest + Recommended + true + true + true + none + + true + all + moderate + + + true + + + + true + true + + + + + + + Exe + false + $(NoWarn);CA1707;S1118 + + diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..bcae3c1 --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,51 @@ + + + true + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7befa48 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src +COPY Directory.Build.props Directory.Packages.props Directory.Packages.props .editorconfig ./ +COPY src ./src +RUN dotnet restore src/PriceNegotiationApp.Api/PriceNegotiationApp.Api.csproj +RUN dotnet publish src/PriceNegotiationApp.Api/PriceNegotiationApp.Api.csproj -c Release -f net10.0 -o /app --no-restore + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime +WORKDIR /app +COPY --from=build /app . +EXPOSE 8080 +ENV ASPNETCORE_URLS=http://+:8080 +USER app +ENTRYPOINT ["dotnet", "PriceNegotiationApp.Api.dll"] + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/PriceNegotiationApp.Tests/GlobalUsings.cs b/PriceNegotiationApp.Tests/GlobalUsings.cs deleted file mode 100644 index 8c927eb..0000000 --- a/PriceNegotiationApp.Tests/GlobalUsings.cs +++ /dev/null @@ -1 +0,0 @@ -global using Xunit; \ No newline at end of file diff --git a/PriceNegotiationApp.Tests/PriceNegotiationApp.Tests.csproj b/PriceNegotiationApp.Tests/PriceNegotiationApp.Tests.csproj deleted file mode 100644 index 4acdd8c..0000000 --- a/PriceNegotiationApp.Tests/PriceNegotiationApp.Tests.csproj +++ /dev/null @@ -1,27 +0,0 @@ - - - - net8.0 - enable - enable - false - true - False - Łukasz Górski - Copyright © 2023 Łukasz Górski - - - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - diff --git a/PriceNegotiationApp.Tests/UnitTest1.cs b/PriceNegotiationApp.Tests/UnitTest1.cs deleted file mode 100644 index 89ec395..0000000 --- a/PriceNegotiationApp.Tests/UnitTest1.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace PriceNegotiationApp.Tests -{ - public class UnitTest1 - { - [Fact] - public void Test1() - { - - } - } -} \ No newline at end of file diff --git a/PriceNegotiationApp.http b/PriceNegotiationApp.http new file mode 100644 index 0000000..7dc5405 --- /dev/null +++ b/PriceNegotiationApp.http @@ -0,0 +1,83 @@ +@host = http://localhost:5185 +@token = paste-access-token-here +@productId = paste-product-guid +@negotiationId = paste-negotiation-guid + +### Register (public, becomes Customer) +POST {{host}}/api/v1/auth/register +Content-Type: application/json + +{ "email": "customer1@app.com", "password": "Customer123!" } + +### Login +# @name login +POST {{host}}/api/v1/auth/login +Content-Type: application/json + +{ "email": "customer1@app.com", "password": "Customer123!" } + +### Current user (requires Authorization header) +GET {{host}}/api/v1/auth/me +Authorization: Bearer {{token}} + +### List products (anonymous; supports search/minPrice/maxPrice/sortBy/sortDesc/page/pageSize) +GET {{host}}/api/v1/products?page=1&pageSize=20 + +### Get product by id +GET {{host}}/api/v1/products/{{productId}} + +### Create product (Admin/Staff) +POST {{host}}/api/v1/products +Authorization: Bearer {{token}} +Content-Type: application/json + +{ "name": "Mechanical Keyboard", "price": 249.00 } + +### Update product (Admin/Staff) +PUT {{host}}/api/v1/products/{{productId}} +Authorization: Bearer {{token}} +Content-Type: application/json + +{ "name": "Mechanical Keyboard PRO", "price": 279.00 } + +### Delete product (Admin) +DELETE {{host}}/api/v1/products/{{productId}} +Authorization: Bearer {{token}} + +### Open a negotiation (Customer) +POST {{host}}/api/v1/negotiations +Authorization: Bearer {{token}} +Content-Type: application/json + +{ "productId": "{{productId}}", "proposedPrice": 180.00 } + +### Get negotiation by id +GET {{host}}/api/v1/negotiations/{{negotiationId}} +Authorization: Bearer {{token}} + +### My negotiations (Customer) +GET {{host}}/api/v1/negotiations/mine?page=1&pageSize=20 +Authorization: Bearer {{token}} + +### All negotiations (Admin/Staff) +GET {{host}}/api/v1/negotiations?page=1&pageSize=20 +Authorization: Bearer {{token}} + +### Counter-proposal (owner) +PATCH {{host}}/api/v1/negotiations/{{negotiationId}}/proposals +Authorization: Bearer {{token}} +Content-Type: application/json + +{ "proposedPrice": 200.00 } + +### Accept offer (Admin/Staff) +POST {{host}}/api/v1/negotiations/{{negotiationId}}/accept +Authorization: Bearer {{token}} + +### Decline offer (Admin/Staff) +POST {{host}}/api/v1/negotiations/{{negotiationId}}/decline +Authorization: Bearer {{token}} + +### Withdraw negotiation (owner or Admin) +DELETE {{host}}/api/v1/negotiations/{{negotiationId}} +Authorization: Bearer {{token}} diff --git a/PriceNegotiationApp.sln b/PriceNegotiationApp.sln deleted file mode 100644 index 6bf1d25..0000000 --- a/PriceNegotiationApp.sln +++ /dev/null @@ -1,31 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.8.34309.116 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PriceNegotiationApp", "PriceNegotiationApp\PriceNegotiationApp.csproj", "{00F27995-2AE6-4725-8826-C7299B389392}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PriceNegotiationApp.Tests", "PriceNegotiationApp.Tests\PriceNegotiationApp.Tests.csproj", "{82B7C733-2C55-4D87-B476-2207AAD63BB2}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {00F27995-2AE6-4725-8826-C7299B389392}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {00F27995-2AE6-4725-8826-C7299B389392}.Debug|Any CPU.Build.0 = Debug|Any CPU - {00F27995-2AE6-4725-8826-C7299B389392}.Release|Any CPU.ActiveCfg = Release|Any CPU - {00F27995-2AE6-4725-8826-C7299B389392}.Release|Any CPU.Build.0 = Release|Any CPU - {82B7C733-2C55-4D87-B476-2207AAD63BB2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {82B7C733-2C55-4D87-B476-2207AAD63BB2}.Debug|Any CPU.Build.0 = Debug|Any CPU - {82B7C733-2C55-4D87-B476-2207AAD63BB2}.Release|Any CPU.ActiveCfg = Release|Any CPU - {82B7C733-2C55-4D87-B476-2207AAD63BB2}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {C58C9F19-3A6A-4FCB-BCAA-18F6FD690D7C} - EndGlobalSection -EndGlobal diff --git a/PriceNegotiationApp.slnx b/PriceNegotiationApp.slnx new file mode 100644 index 0000000..12ec130 --- /dev/null +++ b/PriceNegotiationApp.slnx @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/PriceNegotiationApp/Auth/JwtManager.cs b/PriceNegotiationApp/Auth/JwtManager.cs deleted file mode 100644 index 73fa47d..0000000 --- a/PriceNegotiationApp/Auth/JwtManager.cs +++ /dev/null @@ -1,61 +0,0 @@ -using Microsoft.AspNetCore.Identity; -using Microsoft.Extensions.Options; -using Microsoft.IdentityModel.Tokens; -using System.IdentityModel.Tokens.Jwt; -using System.Security.Claims; -using System.Text; - -namespace PriceNegotiationApp.Auth -{ - public class JwtManager - { - private readonly JwtSettings _jwtSettings; - private readonly UserManager _userManager; - - public JwtManager(IOptions jwtSettings, UserManager userManager) - { - _jwtSettings = jwtSettings.Value; - _userManager = userManager; - } - - // WARNING: Ensure that _jwtSettings.SecurityKey is a string of at least 32 characters (32 bytes) for HmacSha256 - // Required by new JwtSecurityTokenHandler().WriteToken(tokenOptions); since new version (Nuget package JsonWebToken version 8.0 and transitive ...IdentityModel. token-related packages post 7.0) - public SigningCredentials GetSigningCredentials() - { - - var key = Encoding.UTF8.GetBytes(_jwtSettings.SecurityKey); - var secret = new SymmetricSecurityKey(key); - - return new SigningCredentials(secret, SecurityAlgorithms.HmacSha256); - } - - public async Task> GetClaims(IdentityUser user) - { - var claims = new List - { - new Claim(ClaimTypes.Name, user.Email), - new Claim(ClaimTypes.NameIdentifier, user.Id) - }; - - var roles = await _userManager.GetRolesAsync(user); - foreach (var role in roles) - { - claims.Add(new Claim(ClaimTypes.Role, role)); - } - - return claims; - } - - public JwtSecurityToken GenerateTokenOptions(SigningCredentials signingCredentials, List claims) - { - var tokenOptions = new JwtSecurityToken( - issuer: _jwtSettings.ValidIssuer, - audience: _jwtSettings.ValidAudience, - claims: claims, - expires: DateTime.Now.AddMinutes(Convert.ToDouble(_jwtSettings.ExpiryInMinutes)), - signingCredentials: signingCredentials); - - return tokenOptions; - } - } -} diff --git a/PriceNegotiationApp/Auth/JwtSettings.cs b/PriceNegotiationApp/Auth/JwtSettings.cs deleted file mode 100644 index 4b18f91..0000000 --- a/PriceNegotiationApp/Auth/JwtSettings.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace PriceNegotiationApp.Auth -{ - public class JwtSettings - { - public string SecurityKey { get; set; } - public string ValidIssuer { get; set; } - public string ValidAudience { get; set; } - public int ExpiryInMinutes { get; set; } - } -} diff --git a/PriceNegotiationApp/Controllers/AuthenticationController.cs b/PriceNegotiationApp/Controllers/AuthenticationController.cs deleted file mode 100644 index 179dfb8..0000000 --- a/PriceNegotiationApp/Controllers/AuthenticationController.cs +++ /dev/null @@ -1,80 +0,0 @@ -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Identity; -using Microsoft.AspNetCore.Mvc; -using PriceNegotiationApp.Auth; -using PriceNegotiationApp.Models; -using PriceNegotiationApp.Models.DTO; -using PriceNegotiationApp.Services; -using PriceNegotiationApp.Utility; -using System.IdentityModel.Tokens.Jwt; - -namespace PriceNegotiationApp.Controllers -{ - public class AuthenticationController : ControllerBase - { - private readonly AuthService _authService; - - public AuthenticationController(AuthService authService) - { - _authService = authService; - } - - /// Log into an account - /// username and password - /// Returns true if login is successful, false otherwise. - [HttpPost("Login")] - [AllowAnonymous] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status401Unauthorized)] - public async Task Login([FromBody] LoginModel model) - { - var authResponse = await _authService.AuthenticateAsync(model); - - if (!authResponse.IsAuthSuccessful) - return Unauthorized(authResponse); - - return Ok(authResponse); - } - - /// Log out of an account - /// Returns a 200 Ok response - [HttpPost("Logout")] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task Logout() - { - await _authService.SignOutAsync(); - return Ok(); - } - - /// - /// Registers a new user. - /// - /// The data required for user registration. - /// Returns a 201 Created response if successful; otherwise, returns a 400 Bad Request response - /// with details of the validation errors or registration failure. - /// - [HttpPost("Registration")] - [AllowAnonymous] - [ProducesResponseType(StatusCodes.Status201Created)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - public async Task RegisterUser([FromBody] RegisterUserDTO userForRegistration) - { - if (userForRegistration == null || !ModelState.IsValid) - return BadRequest("Invalid user registration data"); - - var result = await _authService.RegisterUserAsync(userForRegistration); - - if (result.Succeeded) - return StatusCode(201); - - var errors = result.Errors.Select(e => e.Description); - return BadRequest(errors); - } - - public class LoginModel - { - public string Username { get; set; } - public string Password { get; set; } - } - } -} diff --git a/PriceNegotiationApp/Controllers/NegotiationController.cs b/PriceNegotiationApp/Controllers/NegotiationController.cs deleted file mode 100644 index 695eaf8..0000000 --- a/PriceNegotiationApp/Controllers/NegotiationController.cs +++ /dev/null @@ -1,241 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net; -using System.Security.Claims; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.CodeAnalysis.CSharp.Syntax; -using Microsoft.EntityFrameworkCore; -using PriceNegotiationApp.Models; -using PriceNegotiationApp.Services; -using PriceNegotiationApp.Utility; -using static PriceNegotiationApp.Services.NegotiationService; - -namespace PriceNegotiationApp.Controllers -{ - [Area("Negotiations")] - [Route("api/v1/[area]/[controller]")] - [ApiController] - public class NegotiationController : ControllerBase - { - private readonly NegotiationService _service; - - public NegotiationController(NegotiationService service) - { - _service = service; - } - - /// - /// Retrieves a list of all negotiations. - /// - /// Returns a collection of negotiations. - // GET: api/Negotiations - [HttpGet] - [Route("all")] - [ResponseCache(Duration = 5)] //Caches the HTTP response for 5 seconds - [ProducesResponseType(StatusCodes.Status200OK)] - [Authorize(Roles = "Admin, Staff")] - public async Task>> GetNegotiations() - { - var negotiations = await _service.GetNegotiationsAsync(); - return Ok(negotiations); - } - - /// - /// Retrieves a specific negotiation by its unique identifier. - /// - /// The unique identifier of the negotiation to retrieve. - /// Returns a negotiation with the specified ID if found; otherwise, returns a 404 Not Found response. - // GET: api/Negotiations/5 - [HttpGet("{id}")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [Authorize(Roles = "Admin, Staff, Customer")] - public async Task> GetNegotiation(int negotiationId) - { - if (!IsUserAuthorizedForNegotiation(negotiationId)) - { - return StatusCode((int)HttpStatusCode.Forbidden); - } - - var negotiation = await _service.GetNegotiationAsync(negotiationId); - - if (negotiation == null) - { - return NotFound(); - } - - return negotiation; - } - - /// - /// Updates a specific negotiation by its unique identifier. - /// - /// The unique identifier of the negotiation to update. - /// The updated negotiation data. - /// - /// Returns a 204 No Content response if the update is successful, - /// 404 Not Found if the specified negotiation is not found, - /// 400 Bad Request with a message "Concurrency conflict" if a concurrency conflict occurs, - /// or a 500 Internal Server Error for other errors. - /// - // PUT: api/Negotiation/5 - // To protect from overposting attacks, see https://go.microsoft.com/fwlink/?linkid=2123754 - [HttpPut("{id}")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status500InternalServerError)] - [Authorize(Roles = "Customer")] - public async Task PutNegotiation(int id, Negotiation negotiation) - { - var updateResult = await _service.UpdateNegotiationAsync(id, negotiation); - - return updateResult switch - { - UpdateResultType.Success => NoContent(),// 204 No Content - UpdateResultType.NotFound => NotFound(),// 404 Not Found - UpdateResultType.Conflict => BadRequest("Concurrency conflict"),// 400 Bad Request - _ => StatusCode(500, "Internal Server Error")// Handle other errors as a generic bad request - }; - } - - /// - /// Proposes a new price for a negotiation. - /// - /// The negotiation to update. - /// The proposed price for the negotiation. - // PATCH: api/Negotiations - [HttpPatch] - [Authorize(Roles = "Customer")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task ProposeNewPrice([FromBody] Negotiation negotiation, decimal proposedPrice) - { - var result = await _service.ProposeNewPriceAsync(negotiation.Id, proposedPrice); - - return result switch - { - ProposePriceResult.Success => Ok(negotiation), - ProposePriceResult.NotFound => NotFound("Negotiation not found."), - ProposePriceResult.Unauthorized => Forbid("You are not authorized to propose a new price for this negotiation."), - ProposePriceResult.InvalidInput => BadRequest("Invalid negotiation or proposed price."), - _ => StatusCode(500, "An error occurred while processing the proposal."), - }; - } - - /// - /// Responds to a negotiation proposal. - /// - /// The negotiation object. - /// A flag indicating whether the proposal is approved or not. - /// Returns an status code representing the result of the operation. - [HttpPatch] - [Route("response")] - [Authorize(Roles = "Staff")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task RespondToNegotiationProposal([FromBody] Negotiation negotiation, [FromQuery] bool isApproved) - { - if (negotiation == null) - { - return BadRequest(); - } - - var result = await _service.RespondToNegotiationProposalAsync(negotiation, isApproved); - - return result switch - { - UpdateResultType.Success => isApproved ? Ok("Proposal accepted") : Ok("Proposal rejected"), - UpdateResultType.NotFound => NotFound(), - UpdateResultType.Conflict => BadRequest(), - _ => StatusCode(500, "Internal Server Error") - }; - } - - /// - /// Creates a new negotiation. - /// - /// The negotiation data to be processed into negotiation. - /// - /// Returns a 201 Created response with the newly created negotiation and a location header pointing to the negotiation, - /// - // POST: api/Negotiations - // To protect from overposting attacks, see https://go.microsoft.com/fwlink/?linkid=2123754 - [HttpPost] - [ProducesResponseType(StatusCodes.Status201Created)] - [Authorize(Roles = "Customer")] - public async Task> PostNegotiation([FromBody] NegotiationInputModel negotiationDetails) - { - Negotiation negotiation = await _service.AddNegotiationToDbAsync(negotiationDetails); - - return CreatedAtAction(nameof(GetNegotiation), new { id = negotiation.Id }, negotiation); - } - - /// - /// Deletes a specific negotiation by its unique identifier. - /// - /// The unique identifier of the negotiation to delete. - /// - /// Returns a 404 Not Found response if the specified negotiation is not found, - /// or a 204 No Content response if the deletion is successful. - /// - // DELETE: api/Negotiation/5 - [HttpDelete("{id}")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [Authorize(Roles = "Admin")] - public async Task DeleteNegotiation(int id) - { - var result = await _service.DeleteNegotiationAsync(id); - - if (!result) - { - return NotFound(); - } - - return NoContent(); - } - - /// - /// Checks if a negotiation with the specified unique identifier exists. - /// - /// The unique identifier of the negotiation to check for existence. - /// Returns true if a negotiation with the specified ID exists; otherwise, returns false. - private bool NegotiationExists(int id) - { - return _service.NegotiationExists(id); - } - - private bool IsUserAssociatedWithNegotiation(int negotiationId) - { - return _service.IsUserAssociatedWithNegotiation(negotiationId); - } - - /// - /// if the authorized user is Customer, then check if the negotiation belongs to him; if user role is different then just return true - /// - /// - /// - private bool IsUserAuthorizedForNegotiation(int negotiationId) - { - var userRole = _service.GetLoggedInUserRole(); - - if (userRole == "Customer" && !IsUserAssociatedWithNegotiation(negotiationId)) - { - return false; - } - - return true; - } - } -} diff --git a/PriceNegotiationApp/Controllers/ProductController.cs b/PriceNegotiationApp/Controllers/ProductController.cs deleted file mode 100644 index ceb1c75..0000000 --- a/PriceNegotiationApp/Controllers/ProductController.cs +++ /dev/null @@ -1,154 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.ResponseCaching; -using Microsoft.EntityFrameworkCore; -using PriceNegotiationApp.Models; -using PriceNegotiationApp.Services; -using PriceNegotiationApp.Utility; - -namespace PriceNegotiationApp.Controllers -{ - [Area("Products")] - [Route("api/v1/[area]/[controller]")] - //[Produces] - [ApiController] - public class ProductController : ControllerBase - { - private readonly ProductService _productService; - - public ProductController(ProductService productService) - { - _productService = productService; - } - - /// - /// Retrieves a list of all products. - /// - /// Returns a collection of products. - // GET: api/Products - [HttpGet] - [Route("all")] - [AllowAnonymous] - [ResponseCache(Duration = 5)] //Caches the HTTP response for 5 seconds - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task>> GetProducts() - { - var products = await _productService.GetProductsAsync(); - return Ok(products); - } - - /// - /// Retrieves a specific product by its unique identifier. - /// - /// The unique identifier of the product to retrieve. - /// Returns a product with the specified ID if found; otherwise, returns a 404 Not Found response. - // GET: api/Products/5 - [HttpGet("{id}")] - [AllowAnonymous] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task> GetProduct(int id) - { - var product = await _productService.GetProductAsync(id); - - if (product == null) - { - return NotFound(); - } - - return Ok(product); - } - - /// - /// Updates a specific product by its unique identifier. - /// - /// The unique identifier of the product to update. - /// The updated product data. - /// - /// Returns a 204 No Content response if the update is successful, - /// 404 Not Found if the specified product is not found, - /// 400 Bad Request with a message "Concurrency conflict" if a concurrency conflict occurs, - /// or a 500 Internal Server Error for other errors. - /// - // PUT: api/Products/5 - // To protect from overposting attacks, see https://go.microsoft.com/fwlink/?linkid=2123754 - [HttpPut("{id}")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status500InternalServerError)] - [Authorize(Roles = "Admin, Staff")] - public async Task PutProduct(int id, [FromBody] Product product) - { - var updateResult = await _productService.UpdateProductAsync(id, product); - - return updateResult switch - { - UpdateResultType.Success => NoContent(),// 204 No Content - UpdateResultType.NotFound => NotFound(),// 404 Not Found - UpdateResultType.Conflict => BadRequest("Concurrency conflict"),// 400 Bad Request - _ => StatusCode(500, "Internal Server Error")// Handle other errors as a generic bad request - }; - } - - /// - /// Creates a new product. - /// - /// The product data to create. - /// - /// Returns a 201 Created response with the newly created product and a location header pointing to the product, - /// or a 500 Internal Server Error if an error occurs during the creation process. - /// - // POST: api/Products - // To protect from overposting attacks, see https://go.microsoft.com/fwlink/?linkid=2123754 - [HttpPost] - [ProducesResponseType(StatusCodes.Status201Created)] - [Authorize(Roles = "Admin, Staff")] - public async Task> PostProduct([FromBody] Product product) - { - await _productService.AddProductToDbAsync(product); - - return CreatedAtAction(nameof(GetProduct), new { id = product.Id }, product); - } - - /// - /// Deletes a specific product by its unique identifier. - /// - /// The unique identifier of the product to delete. - /// - /// Returns a 404 Not Found response if the specified product is not found, - /// or a 204 No Content response if the deletion is successful. - /// - // DELETE: api/Products/5 - [HttpDelete("{id}")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [Authorize(Roles = "Admin, Staff")] - public async Task DeleteProduct(int id) - { - var result = await _productService.DeleteProductAsync(id); - - if (!result) - { - return NotFound(); - } - - return NoContent(); - } - - /// - /// Checks if a product with the specified unique identifier exists. - /// - /// The unique identifier of the product to check for existence. - /// Returns true if a product with the specified ID exists; otherwise, returns false. - private bool ProductExists(int id) - { - return _productService.ProductExists(id); - } - } -} diff --git a/PriceNegotiationApp/Controllers/WeatherForecastController.cs b/PriceNegotiationApp/Controllers/WeatherForecastController.cs deleted file mode 100644 index 372e675..0000000 --- a/PriceNegotiationApp/Controllers/WeatherForecastController.cs +++ /dev/null @@ -1,33 +0,0 @@ -using Microsoft.AspNetCore.Mvc; - -namespace PriceNegotiationApp.Controllers -{ - [ApiController] - [Route("[controller]")] - public class WeatherForecastController : ControllerBase - { - private static readonly string[] Summaries = new[] - { - "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" - }; - - private readonly ILogger _logger; - - public WeatherForecastController(ILogger logger) - { - _logger = logger; - } - - [HttpGet(Name = "GetWeatherForecast")] - public IEnumerable Get() - { - return Enumerable.Range(1, 5).Select(index => new WeatherForecast - { - Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)), - TemperatureC = Random.Shared.Next(-20, 55), - Summary = Summaries[Random.Shared.Next(Summaries.Length)] - }) - .ToArray(); - } - } -} diff --git a/PriceNegotiationApp/Extensions/SwaggerServiceExtensions.cs b/PriceNegotiationApp/Extensions/SwaggerServiceExtensions.cs deleted file mode 100644 index 95775fd..0000000 --- a/PriceNegotiationApp/Extensions/SwaggerServiceExtensions.cs +++ /dev/null @@ -1,59 +0,0 @@ -using Microsoft.OpenApi.Models; -using System.Reflection; - -namespace PriceNegotiationApp.Extensions -{ - public static class SwaggerServiceExtensions - { - public static void ConfigureSwagger(this IServiceCollection services) - { - services.AddSwaggerGen(options => - { - options.SwaggerDoc("v1", new OpenApiInfo - { - Title = "Price Negotiation App", - Version = "v1", - Contact = new OpenApiContact - { - Name = "Łukasz Górski", - Email = "lukaszgorski02@gmail.com", - Url = new Uri("https://www.linkedin.com/in/lukasz-gorski-lukegor/") - }, - License = new OpenApiLicense - { - Name = "Apache License 2.0", - Url = new Uri("https://opensource.org/license/apache-2-0/") - } - }); - - options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme - { - Name = "Authorization", - Description = "Authorization header using the Bearer scheme for JWT", - In = ParameterLocation.Header - }); - - options.AddSecurityRequirement(new OpenApiSecurityRequirement - { - { - new OpenApiSecurityScheme - { - Reference = new OpenApiReference - { - Type = ReferenceType.SecurityScheme, - Id = "Bearer" - } - }, - new string[] {} - } - }); - - // generate docs from xml comments to drive Swagger docs - var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml"; - var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile); - - options.IncludeXmlComments(xmlPath); - }); - } - } -} diff --git a/PriceNegotiationApp/Initializers/MainInitializer.cs b/PriceNegotiationApp/Initializers/MainInitializer.cs deleted file mode 100644 index 529e282..0000000 --- a/PriceNegotiationApp/Initializers/MainInitializer.cs +++ /dev/null @@ -1,83 +0,0 @@ -using Microsoft.AspNetCore.Identity; -using PriceNegotiationApp.Models; -using PriceNegotiationApp.Utility; - -namespace PriceNegotiationApp.Initializers -{ - public class MainInitializer - { - private readonly RoleManager _roleManager; - private readonly UserManager _userManager; - - public MainInitializer(RoleManager roleManager, UserManager userManager) - { - _roleManager = roleManager; - _userManager = userManager; - } - - public async Task InitializeRolesAsync() - { - // Create roles if they don't exist - if (!await _roleManager.RoleExistsAsync(Roles.Role_Customer)) - await _roleManager.CreateAsync(new IdentityRole(Roles.Role_Customer)); - - if (!await _roleManager.RoleExistsAsync(Roles.Role_Staff)) - await _roleManager.CreateAsync(new IdentityRole(Roles.Role_Staff)); - - if (!await _roleManager.RoleExistsAsync(Roles.Role_Admin)) - await _roleManager.CreateAsync(new IdentityRole(Roles.Role_Admin)); - } - - public async Task InitializeAdminUserAsync() - { - // Create admin user if it doesn't exist - if (_userManager.FindByEmailAsync("admin@admin.com").GetAwaiter().GetResult() == null) - { - var adminUser = new ApplicationUser - { - UserName = "admin", - Email = "admin@app.com", - Name = "Admin", - PhoneNumber = "123456789", - StreetAddress = "Street", - State = "State", - PostalCode = "00-000", - City = "City" - }; - - await _userManager.CreateAsync(adminUser, "Admin123!"); - - ApplicationUser user = (ApplicationUser)await _userManager.FindByEmailAsync("admin@admin.com"); - - if (user != null) - await _userManager.AddToRoleAsync(user, Roles.Role_Admin); - } - } - - public async Task InitializeStaffUserAsync() - { - // Create admin user if it doesn't exist - if (_userManager.FindByEmailAsync("admin@admin.com").GetAwaiter().GetResult() == null) - { - var staffUser = new ApplicationUser - { - UserName = "Staff1", - Email = "Staff1@app.com", - Name = "Bob Smith", - PhoneNumber = "987654321", - StreetAddress = "Street", - State = "State", - PostalCode = "00-000", - City = "City" - }; - - await _userManager.CreateAsync(staffUser, "Staff123!"); - - ApplicationUser user = (ApplicationUser)await _userManager.FindByEmailAsync("Staff1@app.com"); - - if (user != null) - await _userManager.AddToRoleAsync(user, Roles.Role_Staff); - } - } - } -} diff --git a/PriceNegotiationApp/Models/AppDbContext.cs b/PriceNegotiationApp/Models/AppDbContext.cs deleted file mode 100644 index 1a53dc1..0000000 --- a/PriceNegotiationApp/Models/AppDbContext.cs +++ /dev/null @@ -1,17 +0,0 @@ -using Microsoft.AspNetCore.Identity; -using Microsoft.AspNetCore.Identity.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore; - -namespace PriceNegotiationApp.Models -{ - public class AppDbContext : IdentityDbContext - { - public AppDbContext(DbContextOptions options) : base(options) - { - } - - public DbSet Products { get; set; } = null; - public DbSet Negotiations { get; set; } = null; - public DbSet ApplicationUsers { get; set; } - } -} diff --git a/PriceNegotiationApp/Models/ApplicationUser.cs b/PriceNegotiationApp/Models/ApplicationUser.cs deleted file mode 100644 index 8d20278..0000000 --- a/PriceNegotiationApp/Models/ApplicationUser.cs +++ /dev/null @@ -1,38 +0,0 @@ -using Microsoft.AspNetCore.Identity; -using PriceNegotiationApp.Models.DTO; -using System.ComponentModel.DataAnnotations; - -namespace PriceNegotiationApp.Models -{ - public class ApplicationUser: IdentityUser - { - [Required] - public string Name { get; set; } - public string? StreetAddress { get; set; } - public string? City { get; set; } - public string? State { get; set; } - public string? PostalCode { get; set; } - public string Role { get; set; } - - - public ApplicationUser() - { - Name = string.Empty; - Role = "Customer"; - } - - - // constructor to convert RegisterUserDTO to ApplicationUser - public ApplicationUser(RegisterUserDTO userDto) - { - UserName = userDto.UserName; - Name = userDto.Name; - Email = userDto.Email; - StreetAddress = userDto.StreetAddress; - City = userDto.City; - State = userDto.State; - PostalCode = userDto.PostalCode; - Role = userDto.Role; - } - } -} diff --git a/PriceNegotiationApp/Models/DTO/AuthResponseDTO.cs b/PriceNegotiationApp/Models/DTO/AuthResponseDTO.cs deleted file mode 100644 index 56368a0..0000000 --- a/PriceNegotiationApp/Models/DTO/AuthResponseDTO.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace PriceNegotiationApp.Models.DTO -{ - public class AuthResponseDTO - { - public bool IsAuthSuccessful { get; set; } - public string? ErrorMessage { get; set; } - public string? Token { get; set; } - } -} diff --git a/PriceNegotiationApp/Models/DTO/RegisterUserDTO.cs b/PriceNegotiationApp/Models/DTO/RegisterUserDTO.cs deleted file mode 100644 index 1c18a25..0000000 --- a/PriceNegotiationApp/Models/DTO/RegisterUserDTO.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace PriceNegotiationApp.Models.DTO -{ - public class RegisterUserDTO - { - [Required] - public string UserName { get; set; } - [Required] - public string Name { get; set; } - [Required] - public string Email { get; set; } - public string? StreetAddress { get; set; } - public string? City { get; set; } - public string? State { get; set; } - public string? PostalCode { get; set; } - public string Role { get; set; } - - [Required] - public string? Password { get; set; } - [Required] - [Compare("Password", ErrorMessage = "The password and confirmation password do not match.")] - public string? ConfirmPassword { get; set; } - } -} diff --git a/PriceNegotiationApp/Models/Negotiation.cs b/PriceNegotiationApp/Models/Negotiation.cs deleted file mode 100644 index 640d478..0000000 --- a/PriceNegotiationApp/Models/Negotiation.cs +++ /dev/null @@ -1,53 +0,0 @@ -using System.ComponentModel.DataAnnotations.Schema; -using System.ComponentModel.DataAnnotations; -using Microsoft.AspNetCore.Http; -using System.Net.Http; -using System.Security.Claims; - -namespace PriceNegotiationApp.Models -{ - public class Negotiation - { - [Key] - [DatabaseGenerated(DatabaseGeneratedOption.Identity)] - public int Id { get; set; } - [Required] - public int ProductId { get; set; } - [Required] - public decimal ProposedPrice { get; set; } - public bool? IsAccepted { get; set; } - [Required] - public int RetriesLeft { get; set; } - public DateTime CreatedAt { get; set; } - public DateTime? UpdatedAt { get; set; } - [Required] - public NegotiationStatus Status { get; set; } - [Required] - public string UserId { get; set; } - //public ApplicationUser User { get; set; } - - public Negotiation(int productId, decimal proposedPrice, string userId) - { - ProductId = productId; - ProposedPrice = proposedPrice; - UserId = userId; - InitializeDefaults(); - } - - private void InitializeDefaults() - { - IsAccepted = false; - RetriesLeft = 2; - CreatedAt = DateTime.UtcNow; - UpdatedAt = CreatedAt; - Status = NegotiationStatus.Open; - } - } - - public enum NegotiationStatus - { - Open, - Closed, - Archived - } -} diff --git a/PriceNegotiationApp/Models/NegotiationInputModel.cs b/PriceNegotiationApp/Models/NegotiationInputModel.cs deleted file mode 100644 index 3ce3c03..0000000 --- a/PriceNegotiationApp/Models/NegotiationInputModel.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace PriceNegotiationApp.Models -{ - public class NegotiationInputModel - { - [Required] - public int ProductId { get; set; } - [Required] - public decimal ProposedPrice { get; set; } - } -} diff --git a/PriceNegotiationApp/Models/Product.cs b/PriceNegotiationApp/Models/Product.cs deleted file mode 100644 index 26edf64..0000000 --- a/PriceNegotiationApp/Models/Product.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; - -namespace PriceNegotiationApp.Models -{ - public class Product - { - [Key] - [DatabaseGenerated(DatabaseGeneratedOption.Identity)] - public int Id { get; set; } - - [Required] - public string Name { get; set; } - [Required] - - [Range(0, double.MaxValue, ErrorMessage = "Price must be greater than or equal to 0")] - public decimal Price { get; set; } - } -} diff --git a/PriceNegotiationApp/PriceNegotiationApp.csproj b/PriceNegotiationApp/PriceNegotiationApp.csproj deleted file mode 100644 index e2ea76d..0000000 --- a/PriceNegotiationApp/PriceNegotiationApp.csproj +++ /dev/null @@ -1,36 +0,0 @@ - - - - net8.0 - enable - enable - true - True - Łukasz Górski - Copyright © 2023 Łukasz Górski - - - - 1701;1702;1591 - - - - 1701;1702;1591 - - - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - diff --git a/PriceNegotiationApp/PriceNegotiationApp.http b/PriceNegotiationApp/PriceNegotiationApp.http deleted file mode 100644 index cdff892..0000000 --- a/PriceNegotiationApp/PriceNegotiationApp.http +++ /dev/null @@ -1,6 +0,0 @@ -@PriceNegotiationApp_HostAddress = http://localhost:5185 - -GET {{PriceNegotiationApp_HostAddress}}/weatherforecast/ -Accept: application/json - -### diff --git a/PriceNegotiationApp/Program.cs b/PriceNegotiationApp/Program.cs deleted file mode 100644 index 65f9036..0000000 --- a/PriceNegotiationApp/Program.cs +++ /dev/null @@ -1,101 +0,0 @@ -using Microsoft.AspNetCore.Authentication.JwtBearer; -using Microsoft.AspNetCore.Identity; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.IdentityModel.Tokens; -using Microsoft.OpenApi.Models; -using PriceNegotiationApp.Auth; -using PriceNegotiationApp.Extensions; -using PriceNegotiationApp.Initializers; -using PriceNegotiationApp.Models; -using PriceNegotiationApp.Services; -using System; -using System.Configuration; -using System.Reflection; -using System.Text; - -namespace PriceNegotiationApp -{ - public class Program - { - public static void Main(string[] args) - { - var builder = WebApplication.CreateBuilder(args); - - // Add services to the container. - - builder.Services.AddControllers(); - builder.Services.AddResponseCaching(); - - builder.Services.AddDbContext(opt => opt.UseInMemoryDatabase("DbContext")); - - builder.Services.AddIdentity() - .AddEntityFrameworkStores() - .AddDefaultTokenProviders(); - - var jwtSettings = builder.Configuration.GetSection("JwtSettings"); - - builder.Services.AddAuthentication(opt => - { - opt.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; - opt.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; - }).AddJwtBearer(options => - { - options.TokenValidationParameters = new TokenValidationParameters - { - ValidateIssuer = true, - ValidateAudience = true, - ValidateLifetime = true, - ValidateIssuerSigningKey = true, - ValidIssuer = jwtSettings["validIssuer"], - ValidAudience = jwtSettings["validAudience"], - IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8 - .GetBytes(jwtSettings.GetSection("securityKey").Value)) - }; - }); - - builder.Services.Configure(builder.Configuration.GetSection("JwtSettings")); - builder.Services.AddScoped(); - - builder.Services.AddScoped(); - - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle - builder.Services.AddEndpointsApiExplorer(); - builder.Services.ConfigureSwagger(); - - builder.Services.AddHttpContextAccessor(); - - var serviceProvider = builder.Services.BuildServiceProvider(); ; - using (var scope = serviceProvider.CreateScope()) - { - var dbInitializer = scope.ServiceProvider.GetRequiredService(); - dbInitializer.InitializeRolesAsync().Wait(); // Synchronously wait for completion - dbInitializer.InitializeAdminUserAsync().Wait(); - dbInitializer.InitializeStaffUserAsync().Wait(); - } - - var app = builder.Build(); - - // Configure the HTTP request pipeline. - if (app.Environment.IsDevelopment()) - { - app.UseSwagger(); - app.UseSwaggerUI(); - } - - app.UseHttpsRedirection(); - - app.UseResponseCaching(); - - app.UseAuthorization(); - - - app.MapControllers(); - - app.Run(); - } - } -} diff --git a/PriceNegotiationApp/Properties/launchSettings.json b/PriceNegotiationApp/Properties/launchSettings.json deleted file mode 100644 index 2fceb28..0000000 --- a/PriceNegotiationApp/Properties/launchSettings.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "$schema": "http://json.schemastore.org/launchsettings.json", - "iisSettings": { - "windowsAuthentication": false, - "anonymousAuthentication": true, - "iisExpress": { - "applicationUrl": "http://localhost:47922", - "sslPort": 44314 - } - }, - "profiles": { - "http": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "launchUrl": "swagger", - "applicationUrl": "http://localhost:5185", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, - "https": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "launchUrl": "swagger", - "applicationUrl": "https://localhost:7004;http://localhost:5185", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, - "IIS Express": { - "commandName": "IISExpress", - "launchBrowser": true, - "launchUrl": "swagger", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - } - } -} diff --git a/PriceNegotiationApp/Services/AuthService.cs b/PriceNegotiationApp/Services/AuthService.cs deleted file mode 100644 index 7e07c0f..0000000 --- a/PriceNegotiationApp/Services/AuthService.cs +++ /dev/null @@ -1,59 +0,0 @@ -using Microsoft.AspNetCore.Identity; -using PriceNegotiationApp.Auth; -using PriceNegotiationApp.Models.DTO; -using PriceNegotiationApp.Models; -using PriceNegotiationApp.Utility; -using static PriceNegotiationApp.Controllers.AuthenticationController; -using System.IdentityModel.Tokens.Jwt; - -namespace PriceNegotiationApp.Services -{ - public class AuthService - { - private readonly SignInManager _signInManager; - private readonly UserManager _userManager; - private readonly JwtManager _jwtHandler; - - public AuthService(SignInManager signInManager, UserManager userManager, JwtManager jwtHandler) - { - _signInManager = signInManager; - _userManager = userManager; - _jwtHandler = jwtHandler; - } - - public async Task AuthenticateAsync(LoginModel model) - { - var user = await _userManager.FindByNameAsync(model.Username); - - if (user == null || !await _userManager.CheckPasswordAsync(user, model.Password)) - return new AuthResponseDTO { ErrorMessage = "Invalid Authentication" }; - - var signingCredentials = _jwtHandler.GetSigningCredentials(); - var claims = await _jwtHandler.GetClaims(user); - - var tokenOptions = _jwtHandler.GenerateTokenOptions(signingCredentials, claims); - - var token = new JwtSecurityTokenHandler().WriteToken(tokenOptions); - - return new AuthResponseDTO { IsAuthSuccessful = true, Token = token }; - } - - public async Task SignOutAsync() - { - await _signInManager.SignOutAsync(); - } - - public async Task RegisterUserAsync(RegisterUserDTO userForRegistration) - { - var user = new ApplicationUser(userForRegistration); - var result = await _userManager.CreateAsync(user, userForRegistration.Password); - - if (result.Succeeded) - { - await _userManager.AddToRoleAsync(user, Roles.Role_Customer); - } - - return result; - } - } -} diff --git a/PriceNegotiationApp/Services/NegotiationService.cs b/PriceNegotiationApp/Services/NegotiationService.cs deleted file mode 100644 index 7874467..0000000 --- a/PriceNegotiationApp/Services/NegotiationService.cs +++ /dev/null @@ -1,185 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using PriceNegotiationApp.Models; -using PriceNegotiationApp.Utility; -using System.Security.Claims; - -namespace PriceNegotiationApp.Services -{ - public class NegotiationService - { - public interface INegotiationService - { - Task> GetNegotiations(); - Task GetNegotiation(int id); - Task UpdateNegotiation(int id, Negotiation Negotiation); - Task CreateNegotiation(Negotiation Negotiation); - Task DeleteNegotiation(int id); - } - - private readonly IHttpContextAccessor _httpContextAccessor; - private readonly AppDbContext _context; - - public NegotiationService(AppDbContext context, IHttpContextAccessor httpContextAccessor) - { - _context = context; - _httpContextAccessor = httpContextAccessor; - } - - public async Task> GetNegotiationsAsync() - { - return await _context.Negotiations.ToListAsync(); - } - - public async Task GetNegotiationAsync(int id) - { - return await _context.Negotiations.FindAsync(id); - } - - public async Task UpdateNegotiationAsync(int id, Negotiation Negotiation) - { - if (id != Negotiation.Id) - { - return UpdateResultType.NotFound; - } - - _context.Entry(Negotiation).State = EntityState.Modified; - - try - { - await _context.SaveChangesAsync(); - } - catch (DbUpdateConcurrencyException) - { - return UpdateResultType.Conflict; - } - - return UpdateResultType.Success; - } - - public async Task ProposeNewPriceAsync(int negotiationId, decimal proposedPrice) - { - var negotiation = await _context.Negotiations.FindAsync(negotiationId); - - if (negotiation == null) - { - return ProposePriceResult.NotFound; - } - - var isUserAssociated = IsUserAssociatedWithNegotiation(negotiationId); - - if (!isUserAssociated) - { - return ProposePriceResult.Unauthorized; - } - - Product relevantProduct = await FindRelevantProductAsync(negotiation); - - const int Multiplier = 2; - - if (negotiation.RetriesLeft <= 0) - { - return ProposePriceResult.IncorrectAction; - } - - if (proposedPrice <= 0 || proposedPrice > Multiplier * relevantProduct.Price) - { - return ProposePriceResult.InvalidInput; - } - - --negotiation.RetriesLeft; - negotiation.ProposedPrice = proposedPrice; - negotiation.UpdatedAt = DateTime.Now; - - try - { - await _context.SaveChangesAsync(); - return ProposePriceResult.Success; - } - catch (DbUpdateException) - { - return ProposePriceResult.Error; - } - } - - public async Task RespondToNegotiationProposalAsync(Negotiation negotiation, bool isApproved) - { - if (isApproved) - { - negotiation.IsAccepted = true; - negotiation.Status = NegotiationStatus.Closed; - } - else - { - if (negotiation.RetriesLeft <= 0) - { - negotiation.IsAccepted = false; - negotiation.Status = NegotiationStatus.Closed; - } - } - - negotiation.UpdatedAt = DateTime.Now; - - return await UpdateNegotiationAsync(negotiation.Id, negotiation); - } - - public async Task AddNegotiationToDbAsync(NegotiationInputModel negotiationDetails) - { - string userId = _httpContextAccessor.HttpContext?.User.FindFirst(ClaimTypes.NameIdentifier)?.Value; - - Negotiation negotiation = new Negotiation(negotiationDetails.ProductId, negotiationDetails.ProposedPrice, userId); - - _context.Negotiations.Add(negotiation); - await _context.SaveChangesAsync(); - - return negotiation; - } - - public async Task DeleteNegotiationAsync(int id) - { - var negotiation = await _context.Negotiations.FindAsync(id); - if (negotiation == null) - { - return false; - } - - _context.Negotiations.Remove(negotiation); - await _context.SaveChangesAsync(); - - return true; - } - - public bool NegotiationExists(int id) - { - return _context.Negotiations.Any(e => e.Id == id); - } - - public string GetLoggedInUserRole() - { - var userRole = _httpContextAccessor.HttpContext?.User.FindFirst(ClaimTypes.Role)?.Value; - System.Diagnostics.Debug.WriteLine(userRole); - return userRole; - } - - public bool IsUserAssociatedWithNegotiation(int negotiationId) - { - var negotiation = GetNegotiationAsync(negotiationId).Result; - - if (negotiation == null) - { - return false; - } - - var userId = negotiation.UserId; // Retrieve userId associated with certain negotiation - var loggedInUserId = _httpContextAccessor.HttpContext?.User.FindFirst(ClaimTypes.NameIdentifier)?.Value; - - return userId == loggedInUserId; - } - - public async Task FindRelevantProductAsync(Negotiation negotiation) - { - Product product = await _context.Products.FirstOrDefaultAsync(e => e.Id == negotiation.Id); - - return product; - } - } -} diff --git a/PriceNegotiationApp/Services/ProductService.cs b/PriceNegotiationApp/Services/ProductService.cs deleted file mode 100644 index 503772c..0000000 --- a/PriceNegotiationApp/Services/ProductService.cs +++ /dev/null @@ -1,83 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using PriceNegotiationApp.Models; -using PriceNegotiationApp.Utility; - -namespace PriceNegotiationApp.Services -{ - public interface IProductService - { - Task> GetProducts(); - Task GetProduct(int id); - Task UpdateProduct(int id, Product product); - Task CreateProduct(Product product); - Task DeleteProduct(int id); - } - - public class ProductService - { - private readonly AppDbContext _context; - - public ProductService(AppDbContext context) - { - _context = context; - } - - public async Task> GetProductsAsync() - { - return await _context.Products.ToListAsync(); - } - - public async Task GetProductAsync(int id) - { - return await _context.Products.FindAsync(id); - } - - public async Task UpdateProductAsync(int id, Product product) - { - if (id != product.Id) - { - return UpdateResultType.NotFound; - } - - _context.Entry(product).State = EntityState.Modified; - - try - { - await _context.SaveChangesAsync(); - } - catch (DbUpdateConcurrencyException) - { - return UpdateResultType.Conflict; - } - - return UpdateResultType.Success; - } - - public async Task AddProductToDbAsync(Product product) - { - _context.Products.Add(product); - await _context.SaveChangesAsync(); - - return product; - } - - public async Task DeleteProductAsync(int id) - { - var product = await _context.Products.FindAsync(id); - if (product == null) - { - return false; - } - - _context.Products.Remove(product); - await _context.SaveChangesAsync(); - - return true; - } - - public bool ProductExists(int id) - { - return _context.Products.Any(e => e.Id == id); - } - } -} diff --git a/PriceNegotiationApp/Utility/ProposePriceResult.cs b/PriceNegotiationApp/Utility/ProposePriceResult.cs deleted file mode 100644 index 58efa37..0000000 --- a/PriceNegotiationApp/Utility/ProposePriceResult.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace PriceNegotiationApp.Utility -{ - public enum ProposePriceResult - { - Success, - NotFound, - IncorrectAction, - Unauthorized, - InvalidInput, - Error - } -} diff --git a/PriceNegotiationApp/Utility/Roles.cs b/PriceNegotiationApp/Utility/Roles.cs deleted file mode 100644 index b57b520..0000000 --- a/PriceNegotiationApp/Utility/Roles.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace PriceNegotiationApp.Utility -{ - public class Roles - { - public const string Role_Customer = "Customer"; - public const string Role_Staff = "Staff"; - public const string Role_Admin = "Admin"; - } -} diff --git a/PriceNegotiationApp/Utility/UpdateResultType.cs b/PriceNegotiationApp/Utility/UpdateResultType.cs deleted file mode 100644 index 02f5b38..0000000 --- a/PriceNegotiationApp/Utility/UpdateResultType.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace PriceNegotiationApp.Utility -{ - public enum UpdateResultType - { - Success, - NotFound, - Conflict - } -} diff --git a/PriceNegotiationApp/WeatherForecast.cs b/PriceNegotiationApp/WeatherForecast.cs deleted file mode 100644 index c1ff312..0000000 --- a/PriceNegotiationApp/WeatherForecast.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace PriceNegotiationApp -{ - public class WeatherForecast - { - public DateOnly Date { get; set; } - - public int TemperatureC { get; set; } - - public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); - - public string? Summary { get; set; } - } -} diff --git a/PriceNegotiationApp/appsettings.Development.json b/PriceNegotiationApp/appsettings.Development.json deleted file mode 100644 index 0c208ae..0000000 --- a/PriceNegotiationApp/appsettings.Development.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - } -} diff --git a/PriceNegotiationApp/appsettings.json b/PriceNegotiationApp/appsettings.json deleted file mode 100644 index da8e898..0000000 --- a/PriceNegotiationApp/appsettings.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "AllowedHosts": "*", - "JWTSettings": { - "securityKey": "PriceNegotiationAppSecretKey32By", - "validIssuer": "PriceNegotiationApp", - "validAudience": "https://localhost:5185", - "expiryInMinutes": 10 - } -} diff --git a/README.md b/README.md index e8778d2..3cda49a 100644 --- a/README.md +++ b/README.md @@ -1 +1,235 @@ -# PriceNegotiationApp \ No newline at end of file +# PriceNegotiationApp + +Backend-only ASP.NET Core Web API that lets customers negotiate prices with shop staff. +Customers register, browse products and open price negotiations; staff review offers and +accept or decline them. A negotiation allows up to **3 proposals in total** (the initial +offer plus two counters). Any proposal above **2× the product's base price** is auto-rejected. + +## Stack + +| Layer | Technology | +|---|---| +| Runtime | .NET 10, C# (nullable + warnings-as-errors) | +| API | ASP.NET Core minimal APIs, built-in request validation | +| Domain | Vogen value objects, business-rule pattern | +| Persistence | EF Core 10 + Npgsql (PostgreSQL 17), snake_case schema, xmin concurrency (conflicts surface as 409) | +| Identity | ASP.NET Core Identity + JWT Bearer (ES256 signing, strict issuer/audience/lifetime validation) | +| Observability | Serilog (console/file), OpenTelemetry (OTLP), `/health/live`, `/health/ready` | +| Tests | xUnit v3 on Microsoft.Testing.Platform (MTP code coverage), Bogus, Shouldly, ArchUnitNET boundary tests, Testcontainers (real Postgres) | +| Platform | Docker multi-stage image, docker-compose, GitHub Actions CI, Dependabot | + +## Architecture + +Modular monolith: three bounded contexts behind compiler-enforced boundaries, +one PostgreSQL schema per context. + +``` +src/ + PriceNegotiationApp.Api composition root: pipeline, authN/authZ validation, + │ ProblemDetails, rate limiting, CORS, output caching, + │ health checks, OTel; wires modules and the single + │ inter-module adapter + PriceNegotiationApp.SharedKernel shared primitives: CallerContext, paging, + │ error semantics, policy names, role names, + │ seeding/design-time factory bases + PriceNegotiationApp.Modules.Identity users/roles/JWT issuance/seeding → schema identity + PriceNegotiationApp.Modules.Catalog products → schema catalog + PriceNegotiationApp.Modules.Negotiations negotiations/customers/policy → schema negotiations + +tests/ + PriceNegotiationApp.ArchitectureTests ArchUnitNET rules pinning module boundaries + PriceNegotiationApp.Modules.*.Tests per-module unit tests + PriceNegotiationApp.IntegrationTests WebApplicationFactory + Testcontainers PostgreSQL +``` + +Every module uses the same layout: + +``` +Modules.X/ + XModule.cs, XEndpoints.cs public registration + endpoint mapping + Domain/ entities, value objects, policies (internal) + Features// handlers + models per feature group (internal) + Persistence/ DbContext, configurations, migrations (internal) + Ports/ required-services contracts (public) + Public/ cross-assembly contract surface (public) + Seeding/ module seeder on the shared base (internal) +``` + +Rules: + +- Modules never reference each other; module implementation types are `internal`, so the + boundary is enforced at compile time. `InternalsVisibleTo` is granted only to the + composition root (`Api`) and each module's own test project. +- Cross-module interaction flows through consumer-owned ports wired in Api + (`Composition/CatalogToNegotiations` is currently the only edge). +- Each context owns its migrations; startup applies them in order + identity → catalog → negotiations. Per-module connection overrides: + `Database:Modules:{Identity|Catalog|Negotiations}:ConnectionString` (falls back to + `Database:ConnectionString`). + +### Tactical DDD laws + +- Endpoints are transport adapters: routing, auth attributes and status shaping only. + Application logic lives in per-operation `*Handler` services under `Features/`. +- Module `DbContext`s are the unit of work; `DbSet` is the aggregate's collection. + No repository/UoW abstractions (enforced by an architecture test). +- Cross-aggregate invariants live at the persistence boundary (partial unique indexes) + with endpoint fast-paths for friendly errors — never inside a single aggregate. +- Negotiation policy values are snapshotted onto the aggregate at creation; config changes + never rewrite in-flight negotiations. +- Domain/integration events are intentionally absent until the first real subscriber + (deal-on-accept / notifications features). The pattern is pre-defined in + `docs/superpowers/specs/2026-08-25-ddd-audit-design.md` §F-04 and lands with that feature. +- Money inside aggregates uses value objects; ratios/multipliers use plain decimals. + +### Migrations + +Each module owns its migration stream (history tables live in the default schema): + +```bash +dotnet ef migrations add --context CatalogDbContext ` + -p src/PriceNegotiationApp.Modules.Catalog -o Persistence/Migrations +``` +## Quickstart + +### Docker Compose (recommended) + +```bash +cp .env.example .env # then edit the values +docker compose up --build +``` + +The API listens on http://localhost:8080. Migrations run automatically on startup. + +### Local .NET run + +Generate an ES256 signing key once (openssl, or the PowerShell-native equivalent below), +then register it with user-secrets: + +```bash +openssl ecparam -name prime256v1 -genkey -noout -out jwt-es256.pem +dotnet user-secrets set "Jwt:PrivateKey" "(Get-Content -Raw jwt-es256.pem)" --project src/PriceNegotiationApp.Api +``` + +```powershell +$ec = [System.Security.Cryptography.ECDsa]::Create([System.Security.Cryptography.ECCurve]::NamedCurves.nistP256) +[IO.File]::WriteAllText("$PWD/jwt-es256.pem", $ec.ExportPkcs8PrivateKeyPem()) +``` + +```bash +dotnet user-secrets set "Jwt:Issuer" "https://localhost:5185" --project src/PriceNegotiationApp.Api +dotnet user-secrets set "Jwt:Audience" "price-negotiation-api" --project src/PriceNegotiationApp.Api +dotnet user-secrets set "Database:ConnectionString" "Host=localhost;Port=5432;Database=pricenego_dev;Username=postgres;Password=postgres" --project src/PriceNegotiationApp.Api +dotnet user-secrets set "Seeding:AdminPassword" "" --project src/PriceNegotiationApp.Api +dotnet user-secrets set "Seeding:StaffPassword" "" --project src/PriceNegotiationApp.Api + +dotnet run --project src/PriceNegotiationApp.Api +``` + +Swagger UI (Scalar) is available at `/scalar` in Development. + +## Negotiation rules + +1. A customer opens a negotiation on a product with an initial proposal — this consumes + proposal 1 of 3. +2. Staff **accept** (terminal `Accepted`) or **reject the current offer** (`POST .../decline`); + rejecting keeps the negotiation open so the customer can spend a remaining proposal and + does not consume budget. +3. A counter-proposal above the snapshotted offer-multiplier limit (default 2× base price, + frozen at creation time) immediately closes the negotiation as terminal `Rejected` + (auto-rejection). +4. When the snapshotted proposal budget is spent, further counter-proposals are refused (`409`). +5. The owner can withdraw an open negotiation at any time — this soft-closes it as terminal + `Withdrawn` and preserves history; only admins hard-delete rows. +6. Deleting a product does not delete or block its negotiations — they keep their + price snapshot (product existence is only validated when a negotiation is created). + +## API surface (v1) + +| Method | Route | Access | +|---|---|---| +| POST | `/api/v1/auth/register` | anonymous (rate-limited) | +| POST | `/api/v1/auth/login` | anonymous (rate-limited) | +| GET | `/api/v1/auth/me` | authenticated | +| GET | `/api/v1/products?search=&minPrice=&maxPrice=&sortBy=&sortDesc=&page=&pageSize=` | anonymous | +| GET | `/api/v1/products/{id}` | anonymous | +| POST | `/api/v1/products` | Admin, Staff | +| PUT | `/api/v1/products/{id}` | Admin, Staff | +| DELETE | `/api/v1/products/{id}` | Admin | +| POST | `/api/v1/negotiations` | Customer | +| GET | `/api/v1/negotiations/mine` | Customer | +| GET | `/api/v1/negotiations` | Admin, Staff | +| GET | `/api/v1/negotiations/{id}` | owner, Admin, Staff | +| PATCH | `/api/v1/negotiations/{id}/proposals` | owner | +| POST | `/api/v1/negotiations/{id}/accept` | Admin, Staff | +| POST | `/api/v1/negotiations/{id}/decline` | Admin, Staff | +| DELETE | `/api/v1/negotiations/{id}` | owner or Admin | + +Status vocabulary: `Open | Accepted | Rejected | Withdrawn`. `Rejected` is terminal +auto-rejection; staff decline responses carry `"outcome":"current_offer_rejected"` +while the status stays `Open`. + +Errors use RFC 7807 ProblemDetails with a stable machine-readable `code` extension +(e.g. `product_not_found`, `negotiation_already_open`, `no_proposals_remaining`). + +## Configuration + +| Key | Purpose | +|---|---| +| `Database:ConnectionString` | PostgreSQL connection string | +| `Jwt:Issuer` / `Jwt:Audience` / `Jwt:PrivateKey` (ES256 PKCS#8 PEM) / `Jwt:ExpiryMinutes` | token settings — validated at startup; public half published at `/.well-known/jwks.json` | +| `Seeding:{AdminEmail,AdminPassword,StaffEmail,StaffPassword,SeedSampleProducts}` | startup seed data | +| `RateLimiting:AuthPermitLimit` | per-IP requests/minute on auth endpoints (default 30) | +| `Cors:AllowedOrigins` | cross-origin allow-list | + +No secrets are committed: use user-secrets locally and environment variables in production. + +Rate limiting applies a per-IP fixed window to the auth endpoints (default 30/min) and +assumes direct exposure as deployed by docker-compose; when placing the API behind a +reverse proxy, add forwarded-header handling so client IPs resolve correctly. + +## Health & telemetry + +- `GET /health/live` — process liveness +- `GET /health/ready` — database connectivity (JSON body names each dependency) +- OpenTelemetry traces/metrics export via standard `OTEL_*` environment variables. + +### Local telemetry dashboard + +```bash +docker compose -f docker-compose.yml -f compose.observability.yml up --build +``` + +The dashboard is **token-protected** (it displays request payloads and logs), and its +telemetry ingestion endpoint requires an **API key** — untrusted apps cannot inject or +spoof telemetry. Login at http://127.0.0.1:18888/login?t=; both +values live in your `.env`. For `dotnet run` development, start just the dashboard +(`docker compose -f docker-compose.yml -f compose.observability.yml up aspire-dashboard`) +and set the user secrets `OTEL_EXPORTER_OTLP_ENDPOINT` (`http://localhost:18889`) and +`OTEL_EXPORTER_OTLP_HEADERS` (`x-otlp-api-key=`). + +## Testing + +```bash +dotnet test --solution PriceNegotiationApp.slnx # everything (Docker needed) +dotnet test --project tests/PriceNegotiationApp.Modules.Catalog.Tests # one project +``` + +Every run also writes `TestResults/*.trx` and `TestResults/*.cobertura.xml`. +Generated test data comes from Bogus through a shared `TestKit`: + +- Data is deterministic per call site — re-running the same command replays it. +- A failure prints a `fuzz run-seed=…` banner plus the arranged values; replay it with: + +```bash +$env:TEST_SEED=''; dotnet test --filter +``` + +## CI + +GitHub Actions runs restore → `dotnet format` check → Release build → unit tests → +Testcontainers-based integration tests on every push and pull request. + +## License + +Apache License 2.0 diff --git a/compose.observability.yml b/compose.observability.yml new file mode 100644 index 0000000..7fb208c --- /dev/null +++ b/compose.observability.yml @@ -0,0 +1,20 @@ +services: + api: + environment: + OTEL_EXPORTER_OTLP_ENDPOINT: http://aspire-dashboard:18889 + OTEL_EXPORTER_OTLP_HEADERS: x-otlp-api-key=${ASPIRE_OTLP_API_KEY} + + aspire-dashboard: + image: mcr.microsoft.com/dotnet/aspire-dashboard:9.4 + environment: + # Browser-token auth: the UI shows sensitive telemetry (request payloads, + # structured logs), so anonymous access stays off even on loopback. + # Login: http://127.0.0.1:18888/login?t= + DASHBOARD__FRONTEND__AUTHMODE: BrowserToken + DASHBOARD__FRONTEND__BROWSERTOKEN: ${ASPIRE_DASHBOARD_TOKEN:?set ASPIRE_DASHBOARD_TOKEN} + # API-key auth on ingestion: untrusted apps can no longer inject or spoof + # telemetry (the UI's "unsecured telemetry endpoint" warning disappears). + DASHBOARD__OTLP__AUTHMODE: ApiKey + DASHBOARD__OTLP__PRIMARYAPIKEY: ${ASPIRE_OTLP_API_KEY:?set ASPIRE_OTLP_API_KEY} + ports: + - "127.0.0.1:18888:18888" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4d077ee --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,35 @@ +services: + api: + build: . + depends_on: + postgres: + condition: service_healthy + environment: + ASPNETCORE_ENVIRONMENT: Production + Database__ConnectionString: Host=postgres;Port=5432;Database=pricenego;Username=postgres;Password=${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD} + Jwt__Issuer: ${JWT_ISSUER:-price-negotiation-app} + Jwt__Audience: ${JWT_AUDIENCE:-price-negotiation-api} + Jwt__PrivateKey: ${JWT_PRIVATE_KEY:?set JWT_PRIVATE_KEY (ES256 PKCS#8 PEM, see README)} + Jwt__ExpiryMinutes: "60" + Seeding__AdminEmail: ${SEED_ADMIN_EMAIL:-admin@app.com} + Seeding__AdminPassword: ${SEED_ADMIN_PASSWORD:?set SEED_ADMIN_PASSWORD} + Seeding__StaffEmail: ${SEED_STAFF_EMAIL:-staff@app.com} + Seeding__StaffPassword: ${SEED_STAFF_PASSWORD:?set SEED_STAFF_PASSWORD} + Seeding__SeedSampleProducts: "true" + ports: + - "8080:8080" + + postgres: + image: postgres:17-alpine + environment: + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD} + POSTGRES_DB: pricenego + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + retries: 10 + +volumes: + pgdata: diff --git a/docs/business-features.md b/docs/business-features.md new file mode 100644 index 0000000..f71007f --- /dev/null +++ b/docs/business-features.md @@ -0,0 +1,246 @@ +# PriceNegotiationApp — Business Feature Analysis + +Date: 2026-08-25 +Role: Business analysis (product scope only — no technical/architectural changes) +Context: Backend-only price-negotiation marketplace; portfolio program, so every proposed +feature must be a *real* business capability that would matter to a live shop, and must be +demonstrable through the API surface alone. + +--- + +## 1. Executive Summary + +The product today implements **one half of a negotiation**: customers and staff can trade +offers until someone accepts or walks away — but an accepted price produces nothing a +customer can actually buy, silence has no time cost, and neither party is ever told that it +is their turn. The negotiation loop is mechanically complete but commercially inert. + +The highest-value additions are therefore not more negotiation mechanics; they are the +features that give the existing loop **an outcome** (a purchase), **a clock** (expiry), +and **a voice** (notifications and history). Everything else is optimization. + +Recommended sequencing: 6 "Must" features form Wave 1–2 and turn the app into a closed +deal pipeline; the remainder are Should/Could items that improve conversion quality, +staff throughput, and trust. + +--- + +## 2. Current Capability Map (as-is) + +| Capability | Status | +|---|---| +| Customer registration / login / roles | ✅ | +| Product catalog: search, filter, paging | ✅ | +| Open negotiation on a product with initial offer | ✅ (1 per product per customer) | +| Bounded haggling: max 3 proposals, ≤2× base price cap, auto-rejection | ✅ | +| Staff accept / reject-current-offer; customer counter / withdraw | ✅ | +| Terminal states: Accepted, Rejected (auto), Withdrawn | ✅ | +| Hard delete by admin | ✅ | + +**What is conspicuously absent:** anything after `Accepted`, any deadline on `Open`, +any signal between parties, any memory of the offers exchanged. + +--- + +## 3. Personas & Journeys + +- **Customer (buyer)** — wants the best price with minimum round-trips; abandons when ignored. +- **Staff (seller agent)** — handles many negotiations; needs triage, context, and guardrails. +- **Admin (shop owner)** — owns margin policy, oversight, and cleanup. + +Journey today: browse → negotiate → *(silence risk)* → accept → **dead end**. +Target journey: browse → negotiate → notified turns → accepted deal → **purchase record** → repeat. + +--- + +## 4. Gap Analysis + +| # | Gap | Business consequence | +|---|---|---| +| G-01 | Accepted negotiation has no fulfillment outcome | The core value exchange never completes; no revenue event exists | +| G-02 | No expiry on open negotiations | Stale pipeline; customers ghosted indefinitely; staff queue pollutes over time | +| G-03 | No proposal history visible | Disputes ("you offered X!") cannot be settled; staff lack context | +| G-04 | No notifications | Every turn requires manual polling; deals die in silence | +| G-05 | Decline carries no reason | Customers can't improve their offer intelligently; feels arbitrary | +| G-06 | Staff have no triage view | First-come-first-served on a flat list; SLAs impossible | +| G-07 | Policy constants are fixed in code | Margin rules can't respond to market without a release | +| G-08 | Staff decide blind | No acceptance-rate/discount visibility; inconsistent pricing across agents | +| G-09 | No abuse controls on negotiation creation | Lowball spam drowns staff; inventory probing at scale | +| G-10 | Catalog is name+price only | Weak discovery; can't negotiate on out-of-stock or category-scoped goods | +| G-11 | No post-deal trust signals | No social proof to drive new negotiations | +| G-12 | Identity lacks recovery flows | Locked-out customers are permanently churned | + +--- + +## 5. Feature Backlog + +Priorities: **M** = Must (core value broken without it), **S** = Should (material lift), +**C** = Could (differentiator). All features are API-level capabilities; UI is out of scope. + +### Wave 1 — Close the loop (Must) + +#### BF-01 · Purchase from accepted deal +- **Persona:** Customer / Admin · **Priority:** M +- **Problem:** When staff accept, the customer holds a status flag, not a purchasable artifact. +- **Behavior:** An accepted negotiation generates a **Deal** — a redeemable record binding + customer, product, agreed price, and validity window (e.g., 72h). Customer lists their deals, + marks one as purchased (or admin confirms payment); deal transitions Redeemed/Expired. + Product's base price may optionally re-anchor to the last struck deal. +- **Success metric:** % of Accepted negotiations converted into Deals within validity window. +- **Dependencies:** none (this unblocks BF-12, BF-15). + +#### BF-02 · Negotiation expiry & stale-pipeline hygiene +- **Persona:** Admin / Customer · **Priority:** M +- **Problem:** Open negotiations live forever if either side goes quiet. +- **Behavior:** Configurable inactivity windows: e.g., staff silence >7 days auto-closes as + `Expired` (customer freed to start fresh); customer silence >14 days after staff action + likewise expires. Expiry is terminal and preserves history. +- **Success metric:** median age of open negotiations; % of opens resolved within policy window. +- **Dependencies:** none. + +#### BF-03 · Proposal history timeline +- **Persona:** Customer / Staff · **Priority:** M +- **Problem:** Only the current offer is visible; the negotiation's story is lost. +- **Behavior:** Negotiation detail returns an ordered ledger: each proposal (who, amount, + timestamp, source: initial/counter/auto-reject), staff actions (reject-current-offer events + with reason once BF-06 lands), and state transitions. +- **Success metric:** support/dispute tickets about "what was offered" drop to zero. +- **Dependencies:** none. + +### Wave 2 — Give the loop a voice (Must/Should) + +#### BF-04 · Turn-based notifications +- **Persona:** Customer / Staff · **Priority:** S (M for real deployments) +- **Problem:** Nobody knows it's their turn; polling is the only option. +- **Behavior:** Per-user notification feed: offer received, offer rejected, negotiation + accepted/expired/expiring-soon, deal awaiting redemption. Read/unread + list endpoints; + optional email dispatch later. +- **Success metric:** average hours-to-response per turn drops materially. +- **Dependencies:** benefits from BF-02 (expiring-soon events). + +#### BF-05 · Staff work queue & claim +- **Persona:** Staff · **Priority:** S +- **Problem:** Flat chronological list doesn't scale past a handful of concurrent negotiations. +- **Behavior:** Queue views filtered by state (awaiting-staff, awaiting-customer, decided), + sorted by wait time; a staff member can **claim** a negotiation so others see ownership; + admins see load per staff member. +- **Success metric:** first-response time distribution; % negotiations claimed vs orphaned. +- **Dependencies:** BF-02 for meaningful sorting. + +#### BF-06 · Decline with structured feedback +- **Persona:** Staff / Customer · **Priority:** S +- **Problem:** A bare rejection gives the customer nothing to act on. +- **Behavior:** Reject-current-offer accepts an optional short **reason** from a controlled + set (too low / near budget / manager review) plus free-text note; surfaced to the customer + in timeline and decline response. +- **Success metric:** counter-rate after rejection increases (customers iterate instead of quitting). +- **Dependencies:** BF-03 displays reasons historically. + +### Wave 3 — Policy control & intelligence (Should) + +#### BF-07 · Configurable negotiation policy +- **Persona:** Admin · **Priority:** S +- **Problem:** Max proposals and offer-multiplier are compile-time constants. +- **Behavior:** Admin sets global defaults and optionally per-category/product overrides + (e.g., electronics cap 3 proposals ×1.15; clearance ×2). New negotiations snapshot the + effective policy at creation (consistent with existing snapshot behavior). +- **Success metric:** policy changes ship in minutes, not releases; margin leakage per category controllable. +- **Dependencies:** none technically; pairs naturally with BF-09. + +#### BF-08 · Auto-decision thresholds +- **Persona:** Admin / Staff · **Priority:** S +- **Problem:** Staff hand-decide offers that are obviously fine or obviously unacceptable. +- **Behavior:** Offers ≥ auto-accept threshold (e.g., ≥90% of base) are instantly Accepted; + below auto-decline floor are instantly Rejected (with reason); middle band stays human. + Thresholds come from BF-07 policy. +- **Success metric:** staff touch-rate drops while acceptance rate holds. +- **Dependencies:** BF-07 (threshold source), BF-06 (rejection reason). + +#### BF-09 · Negotiation analytics +- **Persona:** Admin · **Priority:** S +- **Problem:** No steering data: which products discount deepest, which staff close fastest. +- **Behavior:** Aggregate read endpoints: acceptance/rejection rates, average final discount + vs base, proposals-per-deal, staff response times, expiry counts. Windowed (7/30/90d). +- **Success metric:** admin can name the worst-margin product and slowest queue within one query. +- **Dependencies:** BF-01/BF-02 produce the lifecycle data worth measuring. + +#### BF-10 · Anti-abuse throttles +- **Persona:** Admin · **Priority:** S +- **Problem:** Nothing stops a scripted customer opening hundreds of throwaway negotiations. +- **Behavior:** Per-customer caps: max open negotiations overall (beyond per-product rule), + max new negotiations per rolling day, minimum offer floor relative to base (lowball filter) + with clear error codes; admin-visible abuse flags. +- **Success metric:** staff queue spam ratio trends to ~0. +- **Dependencies:** none. + +### Wave 4 — Trust & growth (Could) + +#### BF-11 · Post-deal rating +- **Persona:** Customer · **Priority:** C +- **Behavior:** After redeeming a Deal (BF-01), customer rates smoothness 1–5 + comment; + aggregates shown per product/staff. +- **Metric:** rating coverage; correlation with repeat negotiations. +- **Dependencies:** BF-01. + +#### BF-12 · Watchlist / saved products +- **Persona:** Customer · **Priority:** C +- **Behavior:** Save products; watchlist feed shows base-price changes and own negotiation + states, prompting re-entry into lapsed negotiations. +- **Metric:** re-negotiation rate after price drops. +- **Dependencies:** none. + +#### BF-13 · Account self-service recovery +- **Persona:** Customer · **Priority:** S (hygiene) +- **Behavior:** Password reset via emailed single-use token; email verification at registration; + both rate-limited like existing auth endpoints. +- **Metric:** support requests for manual unlocks → 0. +- **Dependencies:** none. + +#### BF-14 · Social proof feed +- **Persona:** Anonymous visitor · **Priority:** C +- **Behavior:** Public anonymized endpoint: recently struck deals (product, % off base, + day-granular timestamp). Marketing hook for the negotiation concept itself. +- **Metric:** new registrations citing deals feed (survey proxy). +- **Dependencies:** BF-01. + +--- + +## 6. Priority Matrix (impact vs effort) + +``` + High impact + │ + BF-01 ● │ ● BF-04 ● BF-07 + BF-02 ● │ ● BF-05 ● BF-09 + BF-03 ● │ ● BF-06 ● BF-08 + │ ● BF-13 ● BF-10 + Low effort ─┼─────────────────── High effort + │ ● BF-12 + │ ● BF-14 ● BF-11 + Low impact +``` + +Sequencing logic: top-left first (cheap, existential), then right-side Musts that need +Wave-1 foundations, then differentiators. + +--- + +## 7. Explicit Non-Goals (for now) + +- Full free-form chat inside negotiations (BF-06 covers the 80% case with structured notes). +- Multi-currency, tax calculation, payments processing — Deal redemption is confirmation-only; + real payment rails are a separate product decision. +- Mobile push channels, B2B quote flows, auction-style bidding, AI-suggested prices. +- Any UI work — this remains an API-first product. + +## 8. KPI Summary (what "done" moves) + +| KPI | Moved by | +|---|---| +| Accepted→Purchased conversion | BF-01, BF-14 | +| Median time-per-negotiation-turn | BF-04, BF-05 | +| Open-negotiation staleness | BF-02 | +| Counter-rate after rejection | BF-03, BF-06 | +| Staff touches per closed deal | BF-08, BF-09 | +| Margin discipline (avg discount) | BF-07, BF-08 | +| Abuse/spam ratio in queue | BF-10 | diff --git a/docs/engineering-backlog.md b/docs/engineering-backlog.md new file mode 100644 index 0000000..79e43f4 --- /dev/null +++ b/docs/engineering-backlog.md @@ -0,0 +1,195 @@ +# PriceNegotiationApp — Engineering Experience & Operations Backlog + +Date: 2026-08-25 +Scope: software-development concerns only — observability, CI/CD, panels/dashboards, +developer tooling, repo management files, operational utilities. No business features +(those live in `docs/business-features.md`). Context: portfolio program — every item +should either make the project *easier to run/change/trust* or make the engineering +investment already present **visible**. + +--- + +## 1. Executive Summary + +The codebase is disciplined (CPM, analyzers-as-errors, Testcontainers, architecture tests, +MTP) but **under-showcased and under-wired**: telemetry is exported to endpoints that don't +exist locally, cobertura coverage files are generated and then ignored, the SDK is unpinned, +four test projects repeat identical boilerplate, and CI stops at "tests pass" instead of +producing artifacts a reviewer can click. + +The backlog below closes those holes. Roughly half of it is small, high-signal work that +makes existing investment legible (dashboards for the OTel/Loki plumbing, a coverage badge, +a one-command local pipeline); the other half hardens supply chain and delivery (SDK pin, +nuget source lockdown, vulnerability gate, image publishing). + +--- + +## 2. Current State Map + +| Area | Present | Missing | +|---|---|---| +| Logging | Serilog console + rolling file, request logging, Loki sink package | Log enrichment (actor/route), noise filtering, anywhere for Loki sink to ship to | +| Tracing/Metrics | OpenTelemetry traces + metrics via OTLP env vars | Local collector/dashboard; nothing consumes OTLP in compose | +| Health | `/health/live`, `/health/ready` (DbContext) | Dependency-aware ready details surfaced anywhere | +| CI | format → Release build → tests w/ `--coverage` | Coverage reporting, vuln gate, secret scan, artifact/image publish, badges | +| Tooling files | `Directory.Build.props`, `Directory.Packages.props`, `.editorconfig`, `global.json` (test runner only) | SDK pin, `nuget.config`, shared test conventions, deterministic-build flags | +| Delivery | Dockerfile + compose (api + postgres) | Image publishing, versioning, migration bundle/deploy story | +| Docs | README (excellent), `.http` file, Scalar in dev | CONTRIBUTING/SECURITY/CODEOWNERS, templates, OpenAPI artifact | + +--- + +## 3. Gap Analysis + +| # | Gap | Consequence | +|---|---|---| +| E-01 | OTLP/Loki sinks point at nothing in local/compose runs | The strongest part of the stack is invisible during demos | +| E-02 | Coverage collected but never reported/badged | Quality work unverifiable at a glance | +| E-03 | No dependency-vulnerability or secret scanning in CI | Supply-chain risk undetected despite NuGetAudit being on | +| E-04 | SDK version unpinned (`global.json` has test runner only) | Build reproducibility depends on contributor's machine | +| E-05 | 4 test `.csproj`s duplicate OutputType/NoWarn boilerplate | Drift risk; new test projects copy-paste ceremony | +| E-06 | Images not published; no versioning story | "Portfolio" ends at source; no runnable artifact others can pull | +| E-07 | Request logs lack actor/context enrichment; health/scalar spam logs | Logs noisy yet answer fewer questions than they should | +| E-08 | Options validated ad hoc (JWT yes; seeding/db sections inconsistent) | Misconfig surfaces late | +| E-09 | No contribution surface (CODEOWNERS/SECURITY/templates) | Repo reads as solo toy rather than maintained product | + +--- + +## 4. Backlog + +Priority: **M** = do first (cheap + high signal), **S** = should, **C** = nice/optional. +Effort: **S** ≤ half day, **M** ≈ a day, **L** = multiple days. + +### Theme A — Observability & Panels + +#### E-01 · Local telemetry consumer: Aspire Dashboard container · M / S +Standalone `mcr.microsoft.com/dotnet/aspire-dashboard` service added to docker-compose +(development-only profile), wired by setting `OTEL_EXPORTER_OTLP_ENDPOINT` on the api +service. Instant structured logs + traces + metrics UI for the existing instrumentation — +zero application-code changes. This alone makes the OTel work demonstrable. + +#### E-02 · Production-shaped observability profile (Grafana + Loki + Tempo) · C / L +Compose profile `observability` provisioning Loki (receiving the existing Serilog Loki sink), +Tempo (OTLP traces), Grafana with pre-provisioned datasource + one committed dashboard JSON: +request rate, p50/p95 latency, error rate by `code`, DB pool saturation, health status. +Kept out of the default profile so `docker compose up` stays minimal. + +#### E-03 · Meaningful request logs · M / S +Enrich `UseSerilogRequestLogging` with caller id/roles (post-auth), matched endpoint name, +and negotiation/product route values where present; drop `/health/*`, `/scalar`, and +OpenAPI paths from request logging. One line of config per concern; logs become greppable +per actor instead of per connection. + +#### E-04 · Ready-check detail surface · S / S +Extend readiness payload with named checks (identity/catalog/negotiations schemas) so an +unhealthy dependency names itself; pairs naturally with a Grafana health panel in E-02. + +### Theme B — CI/CD + +#### E-05 · Coverage reporting + badge · M / S +Add ReportGenerator step converting the cobertura files into a consolidated HTML + Cobertura +summary; upload as workflow artifact; feed Codecov (or badge from summary) and add the badge +to README. The data is already produced today — this only makes it visible. + +#### E-06 · Vulnerable-package gate · M / S +CI step running `dotnet list package --vulnerable --include-transitive` +(plus `--vulnerable-known-missing` optionally) and failing the build on hits. NuGetAudit +already warns locally; this turns warnings into a merge gate. + +#### E-07 · Secret scanning · M / S +Gitleaks GitHub Action on push/PR. Cheap insurance given the repo teaches secrets handling +via user-secrets/.env patterns. + +#### E-08 · Publish container images · S / M +On pushes to `develop`/`main`: build multi-stage image, push to GHCR tagged with +branch + short SHA (and semver once E-09 lands). Login-only smoke run against published +image with compose to prove the artifact boots and passes `/health/ready`. + +#### E-09 · Versioning · C / M +Tag-driven version stamping (MinVer) surfaced in `/health/live` payload and image tags — +answers "what build am I looking at?" in demos and issues. + +### Theme C — Tooling & Management Files + +#### E-10 · Pin SDK in `global.json` · M / S +Add `"sdk": { "version": "10.0.x", "rollForward": "latestFeature" }` alongside the existing +test-runner section. Reproducible builds for anyone cloning the portfolio. + +#### E-11 · Harden package sources with `nuget.config` · M / S +Explicit single source (nuget.org), clear cached/fallback sources, disable `packages.config` +resolution. Prevents dependency-confusion style surprises and documents supply-chain intent. + +#### E-12 · Centralize test-project conventions · M / S +New `Directory.Build.targets` applying, when `IsTestProject`-ish path condition matches +(`$(MSBuildProjectDirectory)` contains `/tests/`): `OutputType=Exe`, common `NoWarn` +(CA1707, S1118), `IsPackable=false`. Shrinks four csprojs to just references — future test +projects get conventions free. + +#### E-13 · Deterministic builds + Source Link · S / S +`Directory.Build.props`: `Deterministic=true`, `ContinuousIntegrationBuild` support +(incoming from CI env), Source Link package. PDBs become meaningful; costs minutes. + +#### E-14 · One-command local pipeline (`build.ps1`) · M / S +Script: restore → format verify → Debug build → unit tests → integration tests (Docker guard) +→ optional `-Coverage` switch opening the ReportGenerator HTML. New-contributor onboarding +collapses to `.\build.ps1`. + +### Theme D — API Surface & Docs + +#### E-15 · XML-doc-powered OpenAPI · S / S +Enable `GenerateDocumentationFile` for Api, suppress CS1591 noise wire-in, include XML in +OpenAPI/Scalar so endpoint summaries/description show up in the panel. Portfolio reviewers +read the Scalar page first — make it self-describing. + +#### E-16 · OpenAPI artifact in CI · C / S +Publish the generated OpenAPI JSON as a workflow artifact on each Release build; +diff-friendly record of contract evolution. + +### Theme E — Operational Utilities + +#### E-17 · Migration deployment utility · S / M +Script wrapping `dotnet ef migrations bundle` creation + execution against an arbitrary +connection string; document as the production alternative to startup-applied migrations. +Shows deployment thinking beyond `docker compose up`. + +#### E-18 · Uniform configuration validation · S / S +Apply the existing `IValidateOptions` + `ValidateOnStart` pattern (currently JWT-only) +to Database/Seeding/Cors/RateLimiting option objects. Fail-fast with named section errors. + +#### E-19 · Data ops runbook + backup profile · C / S +Compose profile adding `pg_dump`/restore sidecar or documented one-liners; runbook in docs/. +Round-trip proof that the volume in compose is real data worth protecting. + +### Theme F — Repository Polish + +#### E-20 · Community-grade files · M / S +`CONTRIBUTING.md` (short: prerequisites, `.\build.ps1`, conventions), `SECURITY.md` +(reporting + supported versions), `CODEOWNERS`. Signals maintainership quality instantly. + +#### E-21 · Issue & PR templates · C / S +Bug/feature issue forms + PR checklist ("docs updated, tests green, format clean"). + +#### E-22 · README badge row · M / S +CI status + coverage (from E-05) + target framework badges under the title. + +--- + +## 5. Sequencing + +| Wave | Items | Rationale | +|---|---|---| +| 1 | E-01, E-03, E-05, E-06, E-07, E-10, E-12, E-22 | Small, independent, immediately visible; most are same-day | +| 2 | E-08, E-11, E-13, E-14, E-15, E-18, E-20 | Hardening + onboarding depth | +| 3 | E-02, E-04, E-09, E-16, E-17, E-19, E-21 | Full-stack observability and delivery maturity | +| Optional | E-02 if effort-constrained, plus future candidates below | Explicitly deferred | + +**Future candidates (not scheduled):** Stryker mutation testing on domain modules with a +score gate; Aspire orchestration evaluation (AppHost replacing compose for local F5); +API versioning policy when a v2 becomes real; SBOM (CycloneDX) attached to releases. + +--- + +## 6. Non-Goals + +- Kubernetes/Helm manifests — compose + published image is the right ceiling here. +- Feature flags infrastructure, A/B tooling, payment/webhook plumbing — business-side decisions. +- Mono-repo tooling, private feed management, multi-environment IaC — scale the repo doesn't have. diff --git a/docs/securing-aspire-dashboard.md b/docs/securing-aspire-dashboard.md new file mode 100644 index 0000000..9020d29 --- /dev/null +++ b/docs/securing-aspire-dashboard.md @@ -0,0 +1,216 @@ +# Securing the Standalone Aspire Dashboard — Playbook & Field Notes for AI Agents + +> **Portability:** This document is repository-agnostic. Copy it (or its checklist) next to +> any `docker-compose` setup that runs `mcr.microsoft.com/dotnet/aspire-dashboard` in +> standalone mode. Everything was verified live against image tag `9.4`; treat behavior +> claims as version-pinned and re-verify after major upgrades. +> +> **If you are an AI agent:** read §6 (Pitfalls) *before* writing any command. Every item +> there produced a real, wasted debugging cycle during a single hardening session. + +Sources: `aspire.dev/dashboard/configuration`, `aspire.dev/dashboard/security-considerations`, +Docker Hub env-var table for `microsoft/dotnet-aspire-dashboard`. + +--- + +## 1. TL;DR agent checklist + +1. Frontend (`18888`) → `BrowserToken` with a pinned token; bind UI port to `127.0.0.1`. +2. Ingestion (`18889` gRPC, `18890` HTTP) → `ApiKey` auth; sender adds header via + `OTEL_EXPORTER_OTLP_HEADERS=x-otlp-api-key=`. +3. Never publish OTLP ports to the host; never use + `DOTNET_DASHBOARD_UNSECURED_ALLOW_ANONYMOUS=true` outside throwaway sandboxes. +4. Make both secrets required in compose (`${VAR:?message}`) so the stack cannot start + half-configured. +5. Verify with four gates (§5): config interpolation, UI login, ingestion negative probes + (`401`), ingestion positive probe (`200`/`grpc-status: 0`). + +## 2. Threat model — why "it's just localhost" is not enough + +| Surface | Endpoint(s) | Risk when open | +|---|---|---| +| Browser UI | `18888` | Sensitive payloads/logs/secrets visible; loopback services remain reachable by any local process; DNS-rebinding can reach unauthenticated localhost ports | +| Ingestion (OTLP) | `18889` (gRPC), `18890` (HTTP) | **Telemetry spoofing** — fabricated logs/traces claiming a trusted `service.name`; resource-exhaustion spam (bandwidth/CPU/memory spent decoding junk even if later evicted) | + +Standalone-mode defaults are intentionally mixed: frontend = `BrowserToken` (secure), +ingestion = unsecured. The UI shows a persistent warning until ingestion is secured. +Silencing that warning without enabling API-key auth hides the problem instead of fixing it. + +## 3. Reference configuration (overlay pattern) + +```yaml +# docker-compose.observability.yml (example name) +services: + : + environment: + OTEL_EXPORTER_OTLP_ENDPOINT: http://aspire-dashboard:18889 + # key=value form — this is OpenTelemetry env-var syntax, NOT an HTTP header + OTEL_EXPORTER_OTLP_HEADERS: x-otlp-api-key=${ASPIRE_OTLP_API_KEY} + + aspire-dashboard: + image: mcr.microsoft.com/dotnet/aspire-dashboard:9.4 # pin minor; tags move fast + environment: + DASHBOARD__FRONTEND__AUTHMODE: BrowserToken + DASHBOARD__FRONTEND__BROWSERTOKEN: ${ASPIRE_DASHBOARD_TOKEN:?set ASPIRE_DASHBOARD_TOKEN} + DASHBOARD__OTLP__AUTHMODE: ApiKey + DASHBOARD__OTLP__PRIMARYAPIKEY: ${ASPIRE_OTLP_API_KEY:?set ASPIRE_OTLP_API_KEY} + ports: + - "127.0.0.1:18888:18888" +``` + +Rules of thumb: + +- Config keys map with double underscore: `Dashboard:Otlp:PrimaryApiKey` + → `DASHBOARD__OTLP__PRIMARYAPIKEY`. +- `${VAR:?message}` = fail-fast when secrets are missing (mirrors how you should already + treat DB passwords/JWT keys). +- UI login URL shape: `http://127.0.0.1:18888/login?t=`. +- Sender headers go through `OTEL_EXPORTER_OTLP_HEADERS` as comma-separated `name=value` + pairs. Multiple senders share the same key unless you issue per-sender keys. +- Avoid `DOTNET_DASHBOARD_UNSECURED_ALLOW_ANONYMOUS=true` — it flips frontend, OTLP, + *and* MCP endpoints to anonymous simultaneously. +- The dashboard persists telemetry **in memory only** — restarting wipes it. It is a + diagnostic window, not a sink. + +## 4. Environment variable reference (verified on 9.4) + +| Variable | Purpose | +|---|---| +| `ASPNETCORE_URLS` | Frontend bind address (default `http://+:18888`) | +| `DOTNET_DASHBOARD_OTLP_ENDPOINT_URL` | OTLP/gRPC listen URL (default `http://+:18889`) | +| `DOTNET_DASHBOARD_OTLP_HTTP_ENDPOINT_URL` | OTLP/HTTP listen URL (default `http://+:18890`) | +| `DASHBOARD__FRONTEND__AUTHMODE` | `BrowserToken` (default) / `OpenIdConnect` / `Unsecured` | +| `DASHBOARD__FRONTEND__BROWSERTOKEN` | Pin the browser token (else generated per launch, printed to logs) | +| `DASHBOARD__OTLP__AUTHMODE` | `Unsecured` (standalone default!) / `ApiKey` / `Certificate` | +| `DASHBOARD__OTLP__PRIMARYAPIKEY` | The API key senders must present | +| `DASHBOARD__API__DISABLED` | Telemetry HTTP API off by default — leave off unless needed | + +## 5. Verification playbook + +Run all four gates after any change. Gates 3–4 are the ones agents typically skip. + +**Gate 1 — config interpolation** + +```bash +docker compose -f docker-compose.yml -f compose.observability.yml config --quiet # must pass +unset ASPIRE_DASHBOARD_TOKEN ASPIRE_OTLP_API_KEY +docker compose -f … config --quiet # must FAIL loudly +``` + +**Gate 2 — UI auth** + +```bash +curl -s -i "http://127.0.0.1:18888/login?t=" # expect Set-Cookie + 302 +curl -s -b cookies.txt -L -o page.html -w "%{http_code}" \ + --compressed http://127.0.0.1:18888/ # expect 200, non-trivial body +``` + +**Gate 3 — ingestion rejects untrusted senders** (from a throwaway container on the same +compose network): + +```bash +docker run --rm --network _default curlimages/curl:latest \ + -s -o /dev/null -w "%{http_code}" -X POST \ + http://aspire-dashboard:18890/v1/traces \ + -H "Content-Type: application/x-protobuf" +# expect 401; wrong key also 401 +``` + +**Gate 4 — trusted sender succeeds** +Either run your real app and confirm zero exporter errors in its logs, or probe gRPC +directly: `--http2-prior-knowledge`, `content-type: application/grpc`, `te: trailers`, +body = unary-framed empty message (bytes `00 00 00 00 00`), plus the key header → +expect `HTTP/2 200` and `grpc-status: 0`. See §6 P1/P2 before improvising here. + +## 6. Pitfalls catalogue (each one bit a real agent) + +Format: **Symptom → Root cause → Rule.** + +### P1 — Two header-assignment syntaxes get confused +Symptom: `401` with the correct key; server logs *"API key from 'x-otlp-api-key' header is +missing"* while you swear you sent it. +Root cause: OpenTelemetry env vars use `name=value` (`OTEL_EXPORTER_OTLP_HEADERS=x-otlp-api-key=k`), +raw HTTP headers use `name: value` (`curl -H "x-otlp-api-key: k"`). An `-H k=v` form makes +curl silently drop the header. +Rule: dump what was actually sent (`curl -v`) and read the server's rejection log — the +dashboard states the precise missing-header reason at info level. + +### P2 — Naive curl cannot probe the gRPC port +Symptom: `400` on `:18889/v1/traces` regardless of auth state. +Root cause: gRPC needs HTTP/2 prior knowledge, grpc content-type, `te: trailers`, and +unary length-prefix framing (`00` + uint32 BE length + message). +Rule: verify auth against the OTLP/**HTTP** port (`18890`) first; reserve framed gRPC +probes for final confirmation. + +### P3 — Status-code expectations differ per probe type +With ApiKey enabled: no key → `401`; wrong key → `401`; valid key + empty protobuf body → +auth passes (payload errors surface afterwards as `400`). Don't interpret a payload `400` +as auth failure, and don't chase body validity when testing auth. + +### P4 — Reserved shell variables create false-positive assertions +pwsh example: assigning `$home` silently fails (read-only), downstream "negative" checks +run against empty strings and report success. +Rule: never reuse automatic variable names; pair every absence-assertion with a +presence-assertion (e.g., page bytes > N AND title matches). + +### P5 — Truncated pipelines corrupt exit codes +`dotnet build 2>&1 | Select-Object -First 3 && git commit` committed broken code: `-First` +closes the pipe early, killing dotnet mid-write; the pipeline's success came from the last +cmdlet. +Rule: capture full output (`| Out-String`), then branch strictly on `$LASTEXITCODE`. +Related: never validate with `--no-build` immediately after editing sources. + +### P6 — Container build ≠ host build: copy analyzer/config context +Symptom: analyzer fires as error only inside `docker build` (e.g., MA0048), clean locally. +Root cause: `.editorconfig` shapes severities; the build context copied source but not the +config file. +Rule: the image context must include every file analyzers/SDK mechanisms read — +`.editorconfig`, `Directory.Build.props`, `Directory.Packages.props`, `global.json`, +`nuget.config` if present. + +### P7 — Plural `TargetFrameworks` forces explicit publish framework +`net10.0` (single value, plural tag) + +`dotnet publish` without `-f` ⇒ NETSDK1129 inside containers, while plain `build` works +on host and CI. +Rule: add `-f ` to container publish steps whenever the props file uses the plural +tag — or switch the props to singular `TargetFramework`. + +### P8 — Runtime images ship almost no tools +`aspnet:*` contains neither wget nor curl (only dotnet). A `HEALTHCHECK CMD wget …` +produces a permanently `unhealthy` container whose logs say `exec: no such file`. +Rule: remove dead probes (prefer external `/health` checks) or install tooling explicitly +and accept the size cost. + +### P9 — Compose interpolation is your admission control +`${TOKEN:?set TOKEN}` converts "stack starts wide open because a secret was forgotten" +into "compose refuses to start". Validate both directions: pass with variables set, fail +loudly without them. + +### P10 — SPA routing breaks naive auth checks +Authenticated `GET /` may still return `302 → /structuredlogs`; anonymous requests bounce +elsewhere. Judging the first hop misleads. +Rule: follow redirects with the cookie jar and assert the final status **and** non-trivial +body; combine with positive signals (title/content markers), never absence alone. + +### P11 — App-side gating belongs in code, not just compose +Exporters retry loudly against dead endpoints. Gate telemetry registration on endpoint +presence (e.g., register `UseOtlpExporter()` only when `OTEL_EXPORTER_OTLP_ENDPOINT` is +set). Base compose stays clean, overlays opt in, and environments without collectors stay +silent. + +## 7. Adoption checklist for a new repository + +- [ ] Overlay file created; base compose untouched; dashboard UI bound to `127.0.0.1`. +- [ ] Image tag pinned to a minor version; upgrade = deliberate edit. +- [ ] Both secrets required via `:?` interpolation; documented in `.env.example`. +- [ ] App exports only when `OTEL_EXPORTER_OTLP_ENDPOINT` is configured. +- [ ] Sender header wired through `OTEL_EXPORTER_OTLP_HEADERS`. +- [ ] Four verification gates executed and recorded. +- [ ] README documents: up/down commands, login URL shape, seed/token locations, + reproduction steps for failures. + +## 8. Deliberately out of scope + +HTTPS transport on loopback (dev-cert trust inside containers outweighs benefit locally); +host-filtering allow-lists (only relevant if anonymous access is ever enabled); persistent +telemetry storage; per-sender API keys beyond one trusted deployment. diff --git a/docs/sql/cleanup-legacy-tables.sql b/docs/sql/cleanup-legacy-tables.sql new file mode 100644 index 0000000..4d7be83 --- /dev/null +++ b/docs/sql/cleanup-legacy-tables.sql @@ -0,0 +1,17 @@ +-- One-time maintenance for upgraded persistent environments (compose/CI start fresh and +-- never need this script). +-- +-- Run AFTER the first successful start of the new version against a database that was +-- migrated with docs/sql/legacy-data-migration.sql: +-- 1. migration ids changed when contexts moved to module assemblies, so reset each +-- history table once before the first start; +-- 2. drop the retired single-schema tables. + +DELETE FROM "__EFMigrationsHistory_Identity"; +DELETE FROM "__EFMigrationsHistory_Catalog"; +DELETE FROM "__EFMigrationsHistory_Negotiations"; + +DROP TABLE IF EXISTS public.negotiations CASCADE; +DROP TABLE IF EXISTS public.customers CASCADE; +DROP TABLE IF EXISTS public.products CASCADE; +DROP TABLE IF EXISTS public.__efmigrations_history CASCADE; diff --git a/docs/sql/legacy-data-migration.sql b/docs/sql/legacy-data-migration.sql new file mode 100644 index 0000000..678f79e --- /dev/null +++ b/docs/sql/legacy-data-migration.sql @@ -0,0 +1,29 @@ +-- One-time migration: pre-modular schema (public.*) -> module schemas. +-- Run BEFORE starting the new application version against an existing database. +-- Identity columns are identical between layouts; only table locations change. +-- +-- NOTE: verify column names against the legacy migration before running. EF maps +-- `uint Version` to the PostgreSQL xmin system column, so there is no physical +-- version column to copy — row versions come along automatically. + +BEGIN; + +-- Catalog +INSERT INTO catalog.products (id, name, price) +SELECT id, name, price FROM public.products +ON CONFLICT DO NOTHING; + +-- Negotiations +INSERT INTO negotiations.customers (id, identity_user_id) +SELECT id, identity_user_id FROM public.customers +ON CONFLICT DO NOTHING; + +INSERT INTO negotiations.negotiations + (id, product_id, customer_id, base_price, current_offer, status, + proposals_used, created_at_utc, last_proposal_at_utc, decided_at_utc) +SELECT id, product_id, customer_id, base_price, current_offer, status, + proposals_used, created_at_utc, last_proposal_at_utc, decided_at_utc +FROM public.negotiations +ON CONFLICT DO NOTHING; + +COMMIT; diff --git a/docs/superpowers/adr/2026-08-30-inter-module-communication.md b/docs/superpowers/adr/2026-08-30-inter-module-communication.md new file mode 100644 index 0000000..1cabbe7 --- /dev/null +++ b/docs/superpowers/adr/2026-08-30-inter-module-communication.md @@ -0,0 +1,95 @@ +# ADR: Inter-Module Communication + +**Date:** 2026-08-30 +**Status:** Approved + +## Context + +Modules need to communicate without creating circular dependencies or leaking internal details. The codebase has one cross-module edge: Negotiations reads product prices from Catalog. + +## Decision + +Provider-owned Ports & Adapters pattern. + +### Rule 1: Provider owns the port + +The module that provides the capability defines the interface and DTOs. The consumer depends only on the public contract. + +```csharp +// Catalog/Ports/IProductPriceProvider.cs +namespace PriceNegotiationApp.Modules.Catalog.Ports; + +public interface IProductPriceProvider +{ + Task GetAsync(Guid productId, CancellationToken ct); +} + +public readonly record struct ProductSnapshot(Guid ProductId, decimal Price); +``` + +### Rule 2: Adapter lives in the provider + +The adapter implements the port using the provider's own DbContext. No other module sees the provider's persistence details. + +```csharp +// Catalog/Adapters/ProductPriceProvider.cs +namespace PriceNegotiationApp.Modules.Catalog.Adapters; + +internal sealed class ProductPriceProvider(CatalogDbContext db) : IProductPriceProvider +{ + public async Task GetAsync(Guid productId, CancellationToken ct) => + await db.Products.AsNoTracking() + .Where(p => p.Id == ProductId.From(productId)) + .Select(p => new ProductSnapshot(productId, p.Price)) + .FirstOrDefaultAsync(ct); +} +``` + +### Rule 3: Host wires the adapter + +The host registers the adapter behind the port interface. Modules never reference each other's adapters. + +```csharp +// WebApplicationBuilderExtensions.cs +builder.Services.AddScoped(); +``` + +### Rule 4: Consumer sees only contracts + +Consumer depends on provider's public `Ports` namespace only. Forbidden: Domain, Persistence, Features, Seeding namespaces. + +```csharp +// CreateNegotiationHandler.cs +using PriceNegotiationApp.Modules.Catalog.Ports; // Allowed +// using PriceNegotiationApp.Modules.Catalog.Domain; // Forbidden +// using PriceNegotiationApp.Modules.Catalog.Persistence; // Forbidden +``` + +## Architecture enforcement + +Test: `Negotiations_module_depends_on_catalog_ports_only` + +``` +Blocks: Identity, CompositionRoot, Catalog.Domain, Catalog.Persistence, + Catalog.Features, Catalog.Seeding +Allows: Catalog.Ports +``` + +## Adding a new cross-module edge + +1. Provider module defines port in `Ports/` +2. Provider module implements adapter in `Adapters/` +3. Consumer module references provider's `Ports` namespace +4. Host registers adapter in `WebApplicationBuilderExtensions.cs` +5. Update architecture test to allow the new dependency + +## Files + +| File | Role | +|------|------| +| `Catalog/Ports/IProductPriceProvider.cs` | Port interface + DTO | +| `Catalog/Adapters/ProductPriceProvider.cs` | Adapter (reads CatalogDbContext) | +| `Modules.Negotiations.csproj` | References Catalog module | +| `Catalog.csproj` | `InternalsVisibleTo` includes Negotiations | +| `WebApplicationBuilderExtensions.cs` | Wires adapter behind port | +| `ArchitectureShould.cs` | Enforces dependency rules | diff --git a/docs/superpowers/plans/2026-08-23-modernization.md b/docs/superpowers/plans/2026-08-23-modernization.md new file mode 100644 index 0000000..3546b61 --- /dev/null +++ b/docs/superpowers/plans/2026-08-23-modernization.md @@ -0,0 +1,3133 @@ +# PriceNegotiationApp Full Modernization — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Rebuild the solution per `docs/superpowers/specs/2026-08-23-modernization-design.md` — 4 projects, minimal APIs, PostgreSQL + migrations, hardened security, Docker/CI/OTel, full test coverage of the negotiation lifecycle. + +**Architecture:** Greenfield-in-place rebuild, bottom-up: Domain → Application → Infrastructure → Api host → endpoint modules → integration tests → platform. Each task ends buildable; the whole app is runnable end-to-end after Task 9. + +**Tech Stack:** .NET 10 / C# latest, ASP.NET Core minimal APIs, EF Core 10 + Npgsql 17, ASP.NET Identity, JWT Bearer, Vogen, Serilog, OpenTelemetry, xunit.v3 + NSubstitute + Bogus + Testcontainers + Refit. + +## Global Constraints + +- `TreatWarningsAsErrors=true`, analyzers-as-errors, `EnforceCodeStyleInBuild` — every commit must build warning-free. +- Target framework `net10.0`, `Nullable=enable`, `ImplicitUsings=enable` everywhere (from `Directory.Build.props`; do not touch). +- Central package management: **all** package versions live only in `Directory.Packages.props`. +- No committed secrets anywhere. JWT secret ≥ 32 chars via user-secrets/env. +- Domain references no packages except Vogen. Application references no ASP.NET packages. +- Time is always injected (`TimeProvider`); never `DateTime.UtcNow` inside domain/application logic. +- All service/repository methods take `CancellationToken ct` last parameter. +- Error contract: ProblemDetails with `"code"` extension property using constants from `Application/Common/ErrorCodes.cs`. +- User-facing messages in English only. +- Spec deviations made during planning (documented here): (1) `Product.Update` returns `bool` instead of throwing on no-op — PUT stays idempotent; (2) client-visible xmin concurrency 409 is not testable through the API (no version token exposure) — xmin kept server-side only; (3) `Serilog.Enrichers.CorrelationId` removed in favor of OTel trace correlation. + +## Canonical type map (all tasks reference these) + +``` +Domain ns PriceNegotiationApp.Domain + ValueObjects/Ids/{ProductId,NegotiationId,CustomerId}.cs [ValueObject(Conversions.EfCoreValueConverter)] readonly partial record struct + ValueObjects/Price.cs [ValueObject(Conversions.EfCoreValueConverter)], Validate > 0 + Exceptions/DomainException.cs DomainException(string Message) : Exception + Exceptions/ProposalExceedsLimitException.cs : DomainException + Abstractions/{IBusinessRule,Entity}.cs Entity.CheckRule(IBusinessRule) + Policy/INegotiationPolicy.cs MaxProposalsPerNegotiation:int, ProposalMultiplierLimit:decimal + Policy/DefaultNegotiationPolicy.cs 3 / 2.0m + Models/Product.cs Id, Name, Price, Version(uint); Create(name, price); Update(name, price):bool + Models/Negotiation.cs Id, ProductId, CustomerId, BasePrice, CurrentOffer, Status, ProposalsUsed, + CreatedAtUtc, LastProposalAtUtc, DecidedAtUtc?, Version(uint) + Start(customerId, product, offer, now, policy); CounterPropose(offer, now, policy):NegotiationOutcome; + Accept(now); Decline(now); RemainingProposals(policy):int + Models/NegotiationStatus.cs enum { Open=1, Accepted=2, Declined=3 } + Models/NegotiationOutcome.cs enum { CounterProposed=1, AutoRejected=2, NoProposalsRemaining=3 } + Models/Customer.cs Id, IdentityUserId(Guid unique); Create(identityUserId) + +Application ns PriceNegotiationApp.Application + Common/UserRoles.cs Admin="Admin", Staff="Staff", Customer="Customer" + Common/ErrorCodes.cs const strings (see Task 3 code) + Common/PageQuery.cs record(int Page,int PageSize); Normalized => (>=1, 1..100); Skip + Common/ProductQuery.cs record(string? Search, decimal? MinPrice, decimal? MaxPrice, string? SortBy, bool SortDesc, int Page, int PageSize) + Common/PagedResult.cs record(IReadOnlyList Items, int Page, int PageSize, long TotalCount) + Common/CallerContext.cs record(Guid UserId,string Email,IReadOnlySet Roles); IsAuthenticated; IsInRole(r); Anonymous + Exceptions/NotFoundException.cs NotFoundException(string entityName, object key); Code = "_not_found" + Exceptions/ConflictException.cs ConflictException(string Code, string Message) + Exceptions/ForbiddenAccessException.cs ForbiddenException() + Exceptions/UnauthorizedException.cs UnauthorizedException(string Code, string Message) + Responses/ProductResponse.cs (Guid Id, string Name, decimal Price) + Responses/NegotiationResponse.cs (Guid Id, Guid ProductId, decimal BasePrice, decimal CurrentOffer, string Status, + int ProposalsUsed, int ProposalsRemaining, DateTimeOffset CreatedAtUtc, + DateTimeOffset LastProposalAtUtc, DateTimeOffset? DecidedAtUtc) + Responses/CounterProposalOutcome.cs record(string Outcome, NegotiationResponse Negotiation) + Responses/AuthResponse.cs (string AccessToken, DateTimeOffset ExpiresAtUtc, string Email, IReadOnlyList Roles) + Responses/RegistrationResponse.cs (Guid UserId) + Responses/CurrentUserResponse.cs (Guid UserId, string Email, IReadOnlyList Roles) + Abstractions/IUnitOfWork.cs Task SaveChangesAsync(CancellationToken ct) + Abstractions/IProductRepository.cs GetAsync(ProductId,ct):Task; Query():IQueryable; AddAsync(Product,ct); Remove(Product) + Abstractions/INegotiationRepository.cs GetAsync(NegotiationId,ct); Query(); AddAsync(Negotiation,ct); + FindOpenAsync(ProductId, Guid identityUserId, ct):Task; Remove(Negotiation) + Abstractions/ICustomerRepository.cs GetOrCreateAsync(Guid identityUserId, ct):Task; GetByIdentityAsync(Guid,ct):Task + Abstractions/IUserAccountStore.cs RegistrationOutcome(bool Succeeded, Guid UserId, string? ErrorDescription); + SignInResultKind { Success, LockedOut, Failure } + RegisterAsync(email,pwd,ct):Task; + PasswordSignInAsync(email,pwd):Task; + GetRolesAsync(Guid userId,ct):Task> + Abstractions/IJwtTokenGenerator.cs GenerateAsync(userId,email,roles):Task<(string Token, DateTimeOffset ExpiresAtUtc)> + Features/Products/IProductService.cs ListAsync(ProductQuery,ct); GetAsync(Guid id,ct); CreateAsync(string name,decimal price,ct); + UpdateAsync(Guid id,string name,decimal price,ct); DeleteAsync(Guid id,ct) + Features/Negotiations/INegotiationService.cs CreateAsync(CallerContext, Guid productId, decimal proposedPrice, ct); + GetAsync(CallerContext, Guid id, ct); ListMineAsync(CallerContext, PageQuery, ct); + ListAsync(PageQuery, ct); CounterProposeAsync(CallerContext, Guid id, decimal offer, ct): + Task; AcceptAsync(Guid id, ct); DeclineAsync(Guid id, ct); + WithdrawAsync(CallerContext, Guid id, ct) + Features/Auth/IAuthService.cs RegisterAsync(email,pwd,ct):Task; + LoginAsync(email,pwd,ct):Task; CurrentUserAsync(CallerContext):CurrentUserResponse + DependencyInjection.AddApplicationServices(this IServiceCollection) + +Infrastructure ns PriceNegotiationApp.Infrastructure + Identity/ApplicationUser.cs : IdentityUser (no custom members) + Persistence/AppDbContext.cs IdentityDbContext,Guid> + Persistence/DbEntityConfigurations/{Product,Negotiation,Customer}Configuration.cs + Persistence/Repositories/{ProductRepository,NegotiationRepository,CustomerRepository,UnitOfWork}.cs + Identity/IdentityAccountStore.cs : IUserAccountStore + Auth/JwtOptions.cs Issuer, Audience, SecretKey, ExpiryMinutes + Auth/JwtOptionsValidator.cs IValidateOptions + Auth/JwtManager.cs : IJwtTokenGenerator + Seeding/SeedingOptions.cs AdminEmail,AdminPassword,StaffEmail,StaffPassword,SeedSampleProducts:bool + Seeding/SeedingHostedService.cs migrate + roles/users/products seeding + Data/DesignTimeDbContextFactory.cs for dotnet-ef without Api startup + DependencyInjection.AddInfrastructure(this IServiceCollection, IConfiguration) + +Api ns PriceNegotiationApp.Api + Program.cs thin composition root (+ public partial class Program) + Extensions/WebApplicationBuilderExtensions.AddApiServices() + Extensions/PipelineExtensions.UsePipeline() + Extensions/ClaimsPrincipalExtensions.ToCallerContext() + Extensions/EndpointConventionExtensions.RequireRoles() + GlobalExceptionHandler.cs IExceptionHandler + Contracts/{Auth,Product,Negotiation}Requests.cs plain request records (no attributes; domain validates) + Modules/{AuthModule,ProductsModule,NegotiationsModule}.cs MapXxxApi(IEndpointRouteBuilder) +``` + +--- + +### Task 1: Repo hygiene + +**Files:** +- Delete: root `PriceNegotiationApp.{Api,Application,Contracts,Domain,Infrastructure,Presentation,SharedKernel}/` (empty leftovers), `logs/`, `PriceNegotiationApp.Api.json` +- Modify: `.gitignore` + +- [ ] **Step 1: Delete cruft** + +```pwsh +git rm --cached PriceNegotiationApp.Api.json +Remove-Item -Recurse -Force -ErrorAction SilentlyContinue ` + PriceNegotiationApp.Api, PriceNegotiationApp.Application, PriceNegotiationApp.Contracts, ` + PriceNegotiationApp.Domain, PriceNegotiationApp.Infrastructure, PriceNegotiationApp.Presentation, ` + PriceNegotiationApp.SharedKernel, logs, PriceNegotiationApp.Api.json +``` + +- [ ] **Step 2: Append to `.gitignore`** + +```gitignore +artifacts/ +logs/ +*.user +``` + +- [ ] **Step 3: Validate & commit** + +```pwsh +git status # confirm only intended deletions/modifications staged +git add -A && git commit -m "Remove stale artifacts, empty legacy project folders, committed OpenAPI json" +``` + +--- + +### Task 2: Package manifest, project graph, source wipe + +**Files:** +- Modify: `Directory.Packages.props`, `PriceNegotiationApp.slnx` +- Rewrite: all six `src/**/*.csproj` and both `tests/**/*.csproj` +- Create: `src/PriceNegotiationApp.Api/Program.cs` (stub) +- Delete: `src/PriceNegotiationApp.Contracts/`, `src/PriceNegotiationApp.Presentation/`, `src/PriceNegotiationApp.SharedKernel/` (whole folders), all `*.cs` under `src/PriceNegotiationApp.Application`, `src/PriceNegotiationApp.Infrastructure`, `src/PriceNegotiationApp.Api` (except new stub), all `*.cs` under `tests/` + +**Interfaces:** +- Produces: compilable empty solution skeleton that later tasks fill. + +- [ ] **Step 1: Rewrite `Directory.Packages.props`** + +```xml + + + true + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +``` +(Later tasks add Npgsql/NamingConventions/OpenTelemetry/Testcontainers via `dotnet add package`, which updates this file automatically under CPM.) + +- [ ] **Step 2: Rewrite the csproj files** + +`src/PriceNegotiationApp.Domain/PriceNegotiationApp.Domain.csproj`: +```xml + + + + + +``` + +`src/PriceNegotiationApp.Application/PriceNegotiationApp.Application.csproj`: +```xml + + + + + + + + +``` + +`src/PriceNegotiationApp.Infrastructure/PriceNegotiationApp.Infrastructure.csproj`: +```xml + + + + + + + + + + + + + +``` + +`src/PriceNegotiationApp.Api/PriceNegotiationApp.Api.csproj`: +```xml + + + true + true + $(MSBuildThisFileDirectory)../../artifacts/openapi + true + true + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + +``` + +`tests/PriceNegotiationApp.UnitTests/PriceNegotiationApp.UnitTests.csproj`: +```xml + + + + + + + + + + + + + + + +``` + +`tests/PriceNegotiationApp.IntegrationTests/PriceNegotiationApp.IntegrationTests.csproj`: +```xml + + + + + + + + + + + + + +``` + +- [ ] **Step 3: Wipe old sources, create stub Program** + +```pwsh +git rm -r src/PriceNegotiationApp.Contracts src/PriceNegotiationApp.Presentation src/PriceNegotiationApp.SharedKernel +Get-ChildItem -Recurse -Filter *.cs src/PriceNegotiationApp.Application, src/PriceNegotiationApp.Infrastructure | Remove-Item -Force +Get-ChildItem -Recurse -Filter *.cs src/PriceNegotiationApp.Api | Where-Object Name -ne 'Program.cs' | Remove-Item -Force +Get-ChildItem -Recurse -Filter *.cs tests | Remove-Item -Force +``` + +New `src/PriceNegotiationApp.Api/Program.cs`: +```csharp +var app = WebApplication.Create(args); + +app.MapGet("/", () => Results.Ok("PriceNegotiationApp")); + +app.Run(); +``` + +- [ ] **Step 4: Update `PriceNegotiationApp.slnx`** + +Replace the `/src/` and `/tests/` folder contents so the projects are exactly: + +```xml + + + + + + + + + + + + + + + + +``` + +- [ ] **Step 5: Validate & commit** + +```pwsh +dotnet restore && dotnet build +dotnet test # zero tests, must pass trivially +git add -A && git commit -m "Restructure to 4+2 projects, centralize packages, wipe legacy sources" +``` + +--- + +### Task 3: Domain layer rebuild + lifecycle unit tests + +**Files:** +- Create: all files under `src/PriceNegotiationApp.Domain/` listed in the canonical type map +- Test: `tests/PriceNegotiationApp.UnitTests/Domain/NegotiationLifecycleShould.cs`, `tests/PriceNegotiationApp.UnitTests/Domain/PriceShould.cs` + +**Interfaces:** +- Consumes: nothing (leaf layer) +- Produces: full domain surface from the canonical type map — especially `Negotiation.Start/CounterPropose/Accept/Decline/RemainingProposals`, `Product.Create/Update`, `Price.From/Create`. + +- [ ] **Step 1: Value objects** + +`src/PriceNegotiationApp.Domain/ValueObjects/Ids/ProductId.cs` (and identical shape for `NegotiationId`, `CustomerId`): +```csharp +using Vogen; + +namespace PriceNegotiationApp.Domain.ValueObjects.Ids; + +[ValueObject(Conversions.EfCoreValueConverter)] +public readonly partial record struct ProductId; +``` + +`src/PriceNegotiationApp.Domain/ValueObjects/Price.cs`: +```csharp +using Vogen; + +namespace PriceNegotiationApp.Domain.ValueObjects; + +[ValueObject(Conversions.EfCoreValueConverter)] +public readonly partial record struct Price +{ + private static Validation Validate(decimal value) => + value > 0m ? Validation.Ok : Validation.Invalid("Price must be greater than zero."); +} +``` + +- [ ] **Step 2: Exceptions, rule abstractions, policy** + +`src/PriceNegotiationApp.Domain/Exceptions/DomainException.cs`: +```csharp +namespace PriceNegotiationApp.Domain.Exceptions; + +public class DomainException(string message) : Exception(message); +``` + +`src/PriceNegotiationApp.Domain/Exceptions/ProposalExceedsLimitException.cs`: +```csharp +namespace PriceNegotiationApp.Domain.Exceptions; + +public sealed class ProposalExceedsLimitException(decimal limit) + : DomainException($"Proposal exceeds the allowed limit of {limit}.") +{ + public decimal Limit { get; } = limit; +} +``` + +`src/PriceNegotiationApp.Domain/Abstractions/IBusinessRule.cs`: +```csharp +namespace PriceNegotiationApp.Domain.Abstractions; + +public interface IBusinessRule +{ + bool IsBroken(); + string Message { get; } +} +``` + +`src/PriceNegotiationApp.Domain/Abstractions/Entity.cs`: +```csharp +using PriceNegotiationApp.Domain.Exceptions; + +namespace PriceNegotiationApp.Domain.Abstractions; + +public abstract class Entity +{ + protected static void CheckRule(IBusinessRule rule) + { + if (rule.IsBroken()) + { + throw new DomainException(rule.Message); + } + } +} +``` + +`src/PriceNegotiationApp.Domain/Policy/INegotiationPolicy.cs`: +```csharp +namespace PriceNegotiationApp.Domain.Policy; + +public interface INegotiationPolicy +{ + int MaxProposalsPerNegotiation { get; } + + decimal ProposalMultiplierLimit { get; } +} +``` + +`src/PriceNegotiationApp.Domain/Policy/DefaultNegotiationPolicy.cs`: +```csharp +namespace PriceNegotiationApp.Domain.Policy; + +public sealed class DefaultNegotiationPolicy : INegotiationPolicy +{ + public int MaxProposalsPerNegotiation => 3; + + public decimal ProposalMultiplierLimit => 2.0m; +} +``` + +- [ ] **Step 3: Entities** + +`src/PriceNegotiationApp.Domain/Models/Rules.cs`: +```csharp +using PriceNegotiationApp.Domain.Abstractions; +using PriceNegotiationApp.Domain.ValueObjects; + +namespace PriceNegotiationApp.Domain.Models; + +internal sealed record ProductNameMustNotBeEmpty(string? Value) : IBusinessRule +{ + public bool IsBroken() => string.IsNullOrWhiteSpace(Value); + + public string Message => "Product name must not be empty."; +} + +internal sealed record NegotiationMustBeOpenRule(NegotiationStatus Status) : IBusinessRule +{ + public bool IsBroken() => Status != NegotiationStatus.Open; + + public string Message => "Negotiation is already closed."; +} +``` + +`src/PriceNegotiationApp.Domain/Models/Product.cs`: +```csharp +using PriceNegotiationApp.Domain.Abstractions; +using PriceNegotiationApp.Domain.ValueObjects; +using PriceNegotiationApp.Domain.ValueObjects.Ids; + +namespace PriceNegotiationApp.Domain.Models; + +public sealed class Product : Entity +{ + public ProductId Id { get; private set; } + + public string Name { get; private set; } = null!; + + public Price Price { get; private set; } + + /// Optimistic-concurrency token mapped to PostgreSQL xmin. + public uint Version { get; private set; } + + private Product() + { + } + + private Product(ProductId id, string name, Price price) + { + CheckRule(new ProductNameMustNotBeEmpty(name)); + Id = id; + Name = name.Trim(); + Price = price; + } + + public static Product Create(string name, Price price) => + new(ProductId.From(Guid.CreateVersion7()), name, price); + + /// Applies changes. Returns false when nothing changed (PUT stays idempotent). + public bool Update(string name, Price price) + { + CheckRule(new ProductNameMustNotBeEmpty(name)); + var trimmed = name.Trim(); + if (Name == trimmed && Price == price) + { + return false; + } + + Name = trimmed; + Price = price; + return true; + } +} +``` + +`src/PriceNegotiationApp.Domain/Models/NegotiationStatus.cs`: +```csharp +namespace PriceNegotiationApp.Domain.Models; + +public enum NegotiationStatus +{ + Open = 1, + Accepted = 2, + Declined = 3, +} +``` + +`src/PriceNegotiationApp.Domain/Models/NegotiationOutcome.cs`: +```csharp +namespace PriceNegotiationApp.Domain.Models; + +public enum NegotiationOutcome +{ + CounterProposed = 1, + AutoRejected = 2, + NoProposalsRemaining = 3, +} +``` + +`src/PriceNegotiationApp.Domain/Models/Negotiation.cs`: +```csharp +using PriceNegotiationApp.Domain.Abstractions; +using PriceNegotiationApp.Domain.Exceptions; +using PriceNegotiationApp.Domain.Policy; +using PriceNegotiationApp.Domain.ValueObjects; +using PriceNegotiationApp.Domain.ValueObjects.Ids; + +namespace PriceNegotiationApp.Domain.Models; + +public sealed class Negotiation : Entity +{ + public NegotiationId Id { get; private set; } + + public ProductId ProductId { get; private set; } + + public CustomerId CustomerId { get; private set; } + + /// Base price snapshot taken at creation; protects ongoing negotiations from later product price changes. + public Price BasePrice { get; private set; } + + public Price CurrentOffer { get; private set; } + + public NegotiationStatus Status { get; private set; } + + /// Total proposals recorded, including the initial one. + public int ProposalsUsed { get; private set; } + + public DateTimeOffset CreatedAtUtc { get; private set; } + + public DateTimeOffset LastProposalAtUtc { get; private set; } + + public DateTimeOffset? DecidedAtUtc { get; private set; } + + public uint Version { get; private set; } + + private Negotiation() + { + } + + private Negotiation( + NegotiationId id, ProductId productId, CustomerId customerId, Price basePrice, Price currentOffer, + DateTimeOffset createdAtUtc) + { + Id = id; + ProductId = productId; + CustomerId = customerId; + BasePrice = basePrice; + CurrentOffer = currentOffer; + Status = NegotiationStatus.Open; + ProposalsUsed = 1; + CreatedAtUtc = createdAtUtc; + LastProposalAtUtc = createdAtUtc; + } + + public static Negotiation Start(CustomerId customerId, Product product, Price initialOffer, DateTimeOffset now, INegotiationPolicy policy) + { + EnsureWithinLimit(product.Price, initialOffer, policy); + return new Negotiation(NegotiationId.From(Guid.CreateVersion7()), product.Id, customerId, product.Price, initialOffer, now); + } + + public NegotiationOutcome CounterPropose(Price offer, DateTimeOffset now, INegotiationPolicy policy) + { + CheckRule(new NegotiationMustBeOpenRule(Status)); + if (ProposalsUsed >= policy.MaxProposalsPerNegotiation) + { + return NegotiationOutcome.NoProposalsRemaining; + } + + try + { + EnsureWithinLimit(BasePrice, offer, policy); + } + catch (ProposalExceedsLimitException) + { + Status = NegotiationStatus.Declined; + DecidedAtUtc = now; + return NegotiationOutcome.AutoRejected; + } + + CurrentOffer = offer; + ProposalsUsed++; + LastProposalAtUtc = now; + return NegotiationOutcome.CounterProposed; + } + + public void Accept(DateTimeOffset now) => Decide(NegotiationStatus.Accepted, now); + + public void Decline(DateTimeOffset now) => Decide(NegotiationStatus.Declined, now); + + public int RemainingProposals(INegotiationPolicy policy) => + Math.Max(0, policy.MaxProposalsPerNegotiation - ProposalsUsed); + + private void Decide(NegotiationStatus terminalStatus, DateTimeOffset now) + { + CheckRule(new NegotiationMustBeOpenRule(Status)); + Status = terminalStatus; + DecidedAtUtc = now; + } + + private static void EnsureWithinLimit(Price basePrice, Price offer, INegotiationPolicy policy) + { + var limit = basePrice.Value * policy.ProposalMultiplierLimit; + if (offer.Value > limit) + { + throw new ProposalExceedsLimitException(limit); + } + } +} +``` + +`src/PriceNegotiationApp.Domain/Models/Customer.cs`: +```csharp +using PriceNegotiationApp.Domain.Abstractions; +using PriceNegotiationApp.Domain.ValueObjects.Ids; + +namespace PriceNegotiationApp.Domain.Models; + +public sealed class Customer : Entity +{ + public CustomerId Id { get; private set; } + + public Guid IdentityUserId { get; private set; } + + private Customer() + { + } + + private Customer(CustomerId id, Guid identityUserId) + { + Id = id; + IdentityUserId = identityUserId; + } + + public static Customer Create(Guid identityUserId) => + new(CustomerId.From(Guid.CreateVersion7()), identityUserId); +} +``` + +- [ ] **Step 4: Unit tests** + +`tests/PriceNegotiationApp.UnitTests/Domain/PriceShould.cs`: +```csharp +using PriceNegotiationApp.Domain.Exceptions; +using PriceNegotiationApp.Domain.ValueObjects; + +namespace PriceNegotiationApp.UnitTests.Domain; + +public class PriceShould +{ + [Fact] + public void Accept_positive_values() + { + var price = Price.From(19.99m); + Assert.Equal(19.99m, price.Value); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void Reject_zero_or_negative_values(decimal value) => + Assert.Throws(() => Price.From(value)); +} +``` + +`tests/PriceNegotiationApp.UnitTests/Domain/NegotiationLifecycleShould.cs`: +```csharp +using Bogus; +using PriceNegotiationApp.Domain.Exceptions; +using PriceNegotiationApp.Domain.Models; +using PriceNegotiationApp.Domain.Policy; +using PriceNegotiationApp.Domain.ValueObjects; +using PriceNegotiationApp.Domain.ValueObjects.Ids; + +namespace PriceNegotiationApp.UnitTests.Domain; + +public class NegotiationLifecycleShould +{ + private static readonly DefaultNegotiationPolicy Policy = new(); + private readonly Faker _faker = new(); + private readonly Product _product = Product.Create("Widget", Price.From(100m)); + private readonly DateTimeOffset _now = new(2026, 1, 1, 12, 0, 0, TimeSpan.Zero); + + private Negotiation StartValid() => + Negotiation.Start(CustomerId.From(_faker.Random.Guid()), _product, Price.From(80m), _now, Policy); + + [Fact] + public void Start_records_initial_proposal_and_consumes_one_of_three_budgets() + { + var negotiation = StartValid(); + + Assert.Equal(NegotiationStatus.Open, negotiation.Status); + Assert.Equal(1, negotiation.ProposalsUsed); + Assert.Equal(100m, negotiation.BasePrice.Value); + Assert.Equal(2, negotiation.RemainingProposals(Policy)); + } + + [Fact] + public void Start_rejects_offer_over_twice_base_price() + { + var over = Price.From(201m); // > 2 x 100 + + Assert.Throws( + () => Negotiation.Start(CustomerId.From(_faker.Random.Guid()), _product, over, _now, Policy)); + } + + [Fact] + public void Start_accepts_offer_exactly_at_limit() + { + var atLimit = Price.From(200m); // == 2 x 100 passes + + var negotiation = Negotiation.Start(CustomerId.From(_faker.Random.Guid()), _product, atLimit, _now, Policy); + + Assert.Equal(200m, negotiation.CurrentOffer.Value); + } + + [Fact] + public void CounterPropose_stores_new_offer_within_limit() + { + var negotiation = StartValid(); + + var outcome = negotiation.CounterPropose(Price.From(90m), _now.AddMinutes(5), Policy); + + Assert.Equal(NegotiationOutcome.CounterProposed, outcome); + Assert.Equal(90m, negotiation.CurrentOffer.Value); + Assert.Equal(2, negotiation.ProposalsUsed); + Assert.Equal(NegotiationStatus.Open, negotiation.Status); + } + + [Fact] + public void CounterPropose_over_limit_auto_rejects_and_closes() + { + var negotiation = StartValid(); + + var outcome = negotiation.CounterPropose(Price.From(500m), _now.AddMinutes(5), Policy); + + Assert.Equal(NegotiationOutcome.AutoRejected, outcome); + Assert.Equal(NegotiationStatus.Declined, negotiation.Status); + Assert.NotNull(negotiation.DecidedAtUtc); + } + + [Fact] + public void CounterPropose_after_budget_exhaustion_returns_NoProposalsRemaining() + { + var negotiation = StartValid(); + negotiation.CounterPropose(Price.From(90m), _now, Policy); + negotiation.CounterPropose(Price.From(91m), _now, Policy); + + // Used = 3 of 3; further counter-proposals are refused + var outcome = negotiation.CounterPropose(Price.From(92m), _now, Policy); + + Assert.Equal(NegotiationOutcome.NoProposalsRemaining, outcome); + Assert.Equal(92m != negotiation.CurrentOffer.Value, true); // offer unchanged + Assert.Equal(NegotiationStatus.Open, negotiation.Status); + } + + [Fact] + public void Accept_closes_negotiation_as_Accepted() + { + var negotiation = StartValid(); + + negotiation.Accept(_now.AddDays(1)); + + Assert.Equal(NegotiationStatus.Accepted, negotiation.Status); + Assert.NotNull(negotiation.DecidedAtUtc); + } + + [Fact] + public void Decline_closes_negotiation_as_Declined() + { + var negotiation = StartValid(); + + negotiation.Decline(_now.AddDays(1)); + + Assert.Equal(NegotiationStatus.Declined, negotiation.Status); + } + + [Fact] + public void Terminal_negotiations_refuse_further_operations() + { + var negotiation = StartValid(); + negotiation.Accept(_now); + + Assert.Throws(() => negotiation.CounterPropose(Price.From(50m), _now, Policy)); + Assert.Throws(() => negotiation.Accept(_now)); + Assert.Throws(() => negotiation.Decline(_now)); + } +} +``` + +Also create `tests/PriceNegotiationApp.UnitTests/Domain/ProductRulesShould.cs` covering `Create` rejects null/whitespace name, `Create` trims name, `Update` returns true on change / false when identical, `Update` rejects whitespace. Write it following the same style (plain xUnit asserts, one `Faker` field). + +- [ ] **Step 5: Validate & commit** + +```pwsh +dotnet build && dotnet test tests/PriceNegotiationApp.UnitTests +git add -A && git commit -m "Rebuild domain: Vogen IDs + Price VO, explicit negotiation lifecycle, policy, unit tests" +``` + +--- + +### Task 4: Application layer rebuild + service unit tests + +**Files:** +- Create: all files under `src/PriceNegotiationApp.Application/` from the canonical type map +- Test: `tests/PriceNegotiationApp.UnitTests/Application/NegotiationServiceShould.cs`, `.../ProductServiceShould.cs` + +**Interfaces:** +- Consumes: Domain types from Task 3. +- Produces: services + ports exactly as in the canonical type map (endpoints in Tasks 7–9 depend on those signatures). + +- [ ] **Step 1: Common types** + +`src/PriceNegotiationApp.Application/Common/UserRoles.cs`: +```csharp +namespace PriceNegotiationApp.Application.Common; + +public static class UserRoles +{ + public const string Admin = "Admin"; + + public const string Staff = "Staff"; + + public const string Customer = "Customer"; +} +``` + +`src/PriceNegotiationApp.Application/Common/ErrorCodes.cs`: +```csharp +namespace PriceNegotiationApp.Application.Common; + +public static class ErrorCodes +{ + public const string ProductNotFound = "product_not_found"; + public const string NegotiationNotFound = "negotiation_not_found"; + public const string NegotiationClosed = "negotiation_closed"; + public const string NegotiationAlreadyOpen = "negotiation_already_open"; + public const string NoProposalsRemaining = "no_proposals_remaining"; + public const string ProposalExceedsLimit = "proposal_exceeds_limit"; + public const string EmailAlreadyRegistered = "email_already_registered"; + public const string InvalidCredentials = "invalid_credentials"; + public const string AccountLocked = "account_locked"; + public const string Forbidden = "forbidden"; + public const string ConcurrencyConflict = "conflict"; + public const string InternalError = "internal_error"; +} +``` + +`src/PriceNegotiationApp.Application/Common/PageQuery.cs`: +```csharp +namespace PriceNegotiationApp.Application.Common; + +public sealed record PageQuery(int Page, int PageSize) +{ + public int SafePage => Math.Max(1, Page); + + public int SafePageSize => Math.Clamp(PageSize, 1, 100); + + public int Skip => (SafePage - 1) * SafePageSize; +} +``` + +`src/PriceNegotiationApp.Application/Common/ProductQuery.cs`: +```csharp +namespace PriceNegotiationApp.Application.Common; + +public sealed record ProductQuery( + string? Search = null, + decimal? MinPrice = null, + decimal? MaxPrice = null, + string? SortBy = null, + bool SortDesc = false, + int Page = 1, + int PageSize = 20); +``` + +`src/PriceNegotiationApp.Application/Common/PagedResult.cs`: +```csharp +namespace PriceNegotiationApp.Application.Common; + +public sealed record PagedResult(IReadOnlyList Items, int Page, int PageSize, long TotalCount); +``` + +`src/PriceNegotiationApp.Application/Common/CallerContext.cs`: +```csharp +namespace PriceNegotiationApp.Application.Common; + +public sealed record CallerContext(Guid UserId, string Email, IReadOnlySet Roles) +{ + private static readonly IReadOnlySet EmptyRoles = new HashSet(); + + public static readonly CallerContext Anonymous = new(Guid.Empty, string.Empty, EmptyRoles); + + public bool IsAuthenticated => UserId != Guid.Empty; + + public bool IsInRole(string role) => Roles.Contains(role); +} +``` + +- [ ] **Step 2: Exceptions and responses** + +`src/PriceNegotiationApp.Application/Exceptions/NotFoundException.cs`: +```csharp +namespace PriceNegotiationApp.Application.Exceptions; + +public sealed class NotFoundException(string entityName, object key) + : Exception($"{entityName} '{key}' was not found.") +{ + public string Code { get; } = $"{entityName.ToLowerInvariant().Replace(" ", string.Empty)}_not_found"; +} +``` + +`src/PriceNegotiationApp.Application/Exceptions/ConflictException.cs`: +```csharp +namespace PriceNegotiationApp.Application.Exceptions; + +public sealed class ConflictException(string code, string message) : Exception(message) +{ + public string Code { get; } = code; +} +``` + +`src/PriceNegotiationApp.Application/Exceptions/ForbiddenAccessException.cs`: +```csharp +namespace PriceNegotiationApp.Application.Exceptions; + +public sealed class ForbiddenAccessException() : Exception("Access to the requested resource is forbidden."); +``` + +`src/PriceNegotiationApp.Application/Exceptions/UnauthorizedException.cs`: +```csharp +namespace PriceNegotiationApp.Application.Exceptions; + +public sealed class UnauthorizedException(string code, string message) : Exception(message) +{ + public string Code { get; } = code; +} +``` + +Response records (`Responses/`) — plain positional records exactly as declared in the canonical type map, e.g.: +```csharp +namespace PriceNegotiationApp.Application.Responses; + +public sealed record ProductResponse(Guid Id, string Name, decimal Price); +``` +(and `NegotiationResponse`, `CounterProposalOutcome`, `AuthResponse`, `RegistrationResponse`, `CurrentUserResponse`, plus `PagedResult` already above). + +- [ ] **Step 3: Ports (abstractions)** + +Exactly the interfaces from the canonical type map. Representative full code for the non-obvious ones: + +`src/PriceNegotiationApp.Application/Abstractions/IUserAccountStore.cs`: +```csharp +namespace PriceNegotiationApp.Application.Abstractions; + +public enum SignInResultKind +{ + Success, + LockedOut, + Failure, +} + +public sealed record RegistrationOutcome(bool Succeeded, Guid UserId, string? ErrorDescription); + +public interface IUserAccountStore +{ + Task RegisterAsync(string email, string password, CancellationToken ct); + + Task PasswordSignInAsync(string email, string password); + + Task> GetRolesAsync(Guid userId, CancellationToken ct); +} +``` + +`src/PriceNegotiationApp.Application/Abstractions/IJwtTokenGenerator.cs`: +```csharp +namespace PriceNegotiationApp.Application.Abstractions; + +public interface IJwtTokenGenerator +{ + Task<(string Token, DateTimeOffset ExpiresAtUtc)> GenerateAsync( + Guid userId, string email, IReadOnlyCollection roles); +} +``` + +Repository/unit-of-work/customer ports per canonical map (straightforward signatures). + +- [ ] **Step 4: Services** + +`src/PriceNegotiationApp.Application/Features/Products/ProductService.cs`: +```csharp +using Microsoft.EntityFrameworkCore; +using PriceNegotiationApp.Application.Abstractions; +using PriceNegotiationApp.Application.Common; +using PriceNegotiationApp.Application.Exceptions; +using PriceNegotiationApp.Application.Responses; +using PriceNegotiationApp.Domain.Models; +using PriceNegotiationApp.Domain.ValueObjects; +using PriceNegotiationApp.Domain.ValueObjects.Ids; + +namespace PriceNegotiationApp.Application.Features.Products; + +public sealed class ProductService(IProductRepository products, IUnitOfWork uow) : IProductService +{ + public async Task> ListAsync(ProductQuery query, CancellationToken ct) + { + var page = new PageQuery(query.Page, query.PageSize); + var q = products.Query(); + + if (!string.IsNullOrWhiteSpace(query.Search)) + { + q = q.Where(p => EF.Functions.ILike(p.Name, $"%{query.Search.Trim()}%")); + } + + if (query.MinPrice.HasValue) + { + q = q.Where(p => p.Price.Value >= query.MinPrice.Value); + } + + if (query.MaxPrice.HasValue) + { + q = q.Where(p => p.Price.Value <= query.MaxPrice.Value); + } + + var sortBy = query.SortBy?.Trim().ToLowerInvariant(); + q = (sortBy, query.SortDesc) switch + { + ("price", false) => q.OrderBy(p => p.Price.Value), + ("price", true) => q.OrderByDescending(p => p.Price.Value), + (_, false) => q.OrderBy(p => p.Name), + _ => q.OrderByDescending(p => p.Name), + }; + + var total = await q.LongCountAsync(ct); + var items = await q + .Skip(page.Skip).Take(page.SafePageSize) + .Select(p => new ProductResponse(p.Id.Value, p.Name, p.Price.Value)) + .ToListAsync(ct); + + return new PagedResult(items, page.SafePage, page.SafePageSize, total); + } + + public async Task GetAsync(Guid id, CancellationToken ct) + { + var product = await products.GetAsync(ProductId.From(id), ct) + ?? throw new NotFoundException(nameof(Product), id); + return new ProductResponse(product.Id.Value, product.Name, product.Price.Value); + } + + public async Task CreateAsync(string name, decimal price, CancellationToken ct) + { + var product = Product.Create(name, Price.From(price)); + await products.AddAsync(product, ct); + await uow.SaveChangesAsync(ct); + return new ProductResponse(product.Id.Value, product.Name, product.Price.Value); + } + + public async Task UpdateAsync(Guid id, string name, decimal price, CancellationToken ct) + { + var product = await products.GetAsync(ProductId.From(id), ct) + ?? throw new NotFoundException(nameof(Product), id); + product.Update(name, Price.From(price)); + await uow.SaveChangesAsync(ct); + return new ProductResponse(product.Id.Value, product.Name, product.Price.Value); + } + + public async Task DeleteAsync(Guid id, CancellationToken ct) + { + var product = await products.GetAsync(ProductId.From(id), ct) + ?? throw new NotFoundException(nameof(Product), id); + products.Remove(product); + await uow.SaveChangesAsync(ct); + } +} +``` + +`src/PriceNegotiationApp.Application/Features/Negotiations/NegotiationService.cs`: +```csharp +using PriceNegotiationApp.Application.Abstractions; +using PriceNegotiationApp.Application.Common; +using PriceNegotiationApp.Application.Exceptions; +using PriceNegotiationApp.Application.Responses; +using PriceNegotiationApp.Domain.Models; +using PriceNegotiationApp.Domain.Policy; +using PriceNegotiationApp.Domain.ValueObjects; +using PriceNegotiationApp.Domain.ValueObjects.Ids; + +namespace PriceNegotiationApp.Application.Features.Negotiations; + +public sealed class NegotiationService( + INegotiationRepository negotiations, + IProductRepository products, + ICustomerRepository customers, + INegotiationPolicy policy, + IUnitOfWork uow, + TimeProvider time) : INegotiationService +{ + public async Task CreateAsync(CallerContext caller, Guid productId, decimal proposedPrice, CancellationToken ct) + { + var product = await products.GetAsync(ProductId.From(productId), ct) + ?? throw new NotFoundException(nameof(Product), productId); + + if (await negotiations.FindOpenAsync(product.Id, caller.UserId, ct) is not null) + { + throw new ConflictException(ErrorCodes.NegotiationAlreadyOpen, "An open negotiation already exists for this product."); + } + + var customerId = await customers.GetOrCreateAsync(caller.UserId, ct); + var negotiation = Negotiation.Start(customerId, product, Price.From(proposedPrice), time.GetUtcNow(), policy); + await negotiations.AddAsync(negotiation, ct); + await uow.SaveChangesAsync(ct); + return Map(negotiation); + } + + public async Task GetAsync(CallerContext caller, Guid id, CancellationToken ct) + { + var negotiation = await RequireAccessibleAsync(caller, id, ct); + return Map(negotiation); + } + + public async Task> ListMineAsync(CallerContext caller, PageQuery page, CancellationToken ct) + { + var customer = await customers.GetByIdentityAsync(caller.UserId, ct); + var q = negotiations.Query().Where(n => customer != null && n.CustomerId == customer.Id); + return await ToPagedAsync(q, page, ct); + } + + public async Task> ListAsync(PageQuery page, CancellationToken ct) => + await ToPagedAsync(negotiations.Query(), page, ct); + + public async Task CounterProposeAsync(CallerContext caller, Guid id, decimal proposedPrice, CancellationToken ct) + { + var negotiation = await RequireOwnerAsync(caller, id, ct); + + var outcome = negotiation.CounterPropose(Price.From(proposedPrice), time.GetUtcNow(), policy); + switch (outcome) + { + case NegotiationOutcome.NoProposalsRemaining: + throw new ConflictException(ErrorCodes.NoProposalsRemaining, "No proposals remain for this negotiation."); + case NegotiationOutcome.CounterProposed or NegotiationOutcome.AutoRejected: + break; + } + + await uow.SaveChangesAsync(ct); + return new CounterProposalOutcome(outcome.ToString(), Map(negotiation)); + } + + public async Task AcceptAsync(Guid id, CancellationToken ct) + { + var negotiation = await RequireAsync(id, ct); + negotiation.Accept(time.GetUtcNow()); + await uow.SaveChangesAsync(ct); + return Map(negotiation); + } + + public async Task DeclineAsync(Guid id, CancellationToken ct) + { + var negotiation = await RequireAsync(id, ct); + negotiation.Decline(time.GetUtcNow()); + await uow.SaveChangesAsync(ct); + return Map(negotiation); + } + + public async Task WithdrawAsync(CallerContext caller, Guid id, CancellationToken ct) + { + var negotiation = await RequireAsync(id, ct); + if (!caller.IsInRole(UserRoles.Admin) && !await IsOwnerAsync(caller, negotiation, ct)) + { + throw new ForbiddenAccessException(); + } + + negotiations.Remove(negotiation); + await uow.SaveChangesAsync(ct); + } + + private async Task RequireAsync(Guid id, CancellationToken ct) => + await negotiations.GetAsync(NegotiationId.From(id), ct) + ?? throw new NotFoundException(nameof(Negotiation), id); + + private async Task RequireOwnerAsync(CallerContext caller, Guid id, CancellationToken ct) + { + var negotiation = await RequireAsync(id, ct); + if (!await IsOwnerAsync(caller, negotiation, ct)) + { + throw new ForbiddenAccessException(); + } + + return negotiation; + } + + private async Task RequireAccessibleAsync(CallerContext caller, Guid id, CancellationToken ct) + { + var negotiation = await RequireAsync(id, ct); + if (caller.IsInRole(UserRoles.Admin) || caller.IsInRole(UserRoles.Staff) || await IsOwnerAsync(caller, negotiation, ct)) + { + return negotiation; + } + + throw new ForbiddenAccessException(); + } + + private async Task IsOwnerAsync(CallerContext caller, Negotiation negotiation, CancellationToken ct) + { + var customer = await customers.GetByIdentityAsync(caller.UserId, ct); + return customer is not null && customer.Id == negotiation.CustomerId; + } + + private async Task> ToPagedAsync( + IQueryable q, PageQuery page, CancellationToken ct) + { + var total = await q.LongCountAsync(ct); + var items = await q + .OrderByDescending(n => n.CreatedAtUtc) + .Skip(page.Skip).Take(page.SafePageSize) + .ToListAsync(ct); + return new PagedResult( + items.Select(Map).ToList(), page.SafePage, page.SafePageSize, total); + } + + private NegotiationResponse Map(Negotiation n) => new( + n.Id.Value, n.ProductId.Value, n.BasePrice.Value, n.CurrentOffer.Value, + n.Status.ToString(), n.ProposalsUsed, n.RemainingProposals(policy), + n.CreatedAtUtc, n.LastProposalAtUtc, n.DecidedAtUtc); +} +``` + +Note: `ListMineAsync` uses `IQueryable` composition — repository `Query()` returns `IQueryable`; Application referencing `Microsoft.EntityFrameworkCore` for `EF.Functions.ILike`/async extensions means Application needs the EF Core **package** (allowed — spec bans ASP.NET refs, not EF Core; `ILike` keeps filtering provider-side). Add `Microsoft.EntityFrameworkCore` PackageReference to `Application.csproj` (CPM version 10.0.8 already pinned). Update the Task 2 csproj accordingly during execution. + +`src/PriceNegotiationApp.Application/Features/Auth/AuthService.cs`: +```csharp +using PriceNegotiationApp.Application.Abstractions; +using PriceNegotiationApp.Application.Common; +using PriceNegotiationApp.Application.Exceptions; +using PriceNegotiationApp.Application.Responses; + +namespace PriceNegotiationApp.Application.Features.Auth; + +public sealed class AuthService(IUserAccountStore accounts, IJwtTokenGenerator jwt) : IAuthService +{ + public async Task RegisterAsync(string email, string password, CancellationToken ct) + { + var outcome = await accounts.RegisterAsync(email, password, ct); + if (!outcome.Succeeded) + { + throw new ConflictException(ErrorCodes.EmailAlreadyRegistered, outcome.ErrorDescription ?? "Registration failed."); + } + + return new RegistrationResponse(outcome.UserId); + } + + public async Task LoginAsync(string email, string password, CancellationToken ct) + { + var signIn = await accounts.PasswordSignInAsync(email, password); + switch (signIn) + { + case SignInResultKind.LockedOut: + throw new UnauthorizedException(ErrorCodes.AccountLocked, "Account temporarily locked."); + case SignInResultKind.Failure: + throw new UnauthorizedException(ErrorCodes.InvalidCredentials, "Invalid credentials."); + } + + // Success path: resolve identity by re-querying store + var userId = await accounts.ResolveUserIdByEmailAsync(email, ct); + var roles = await accounts.GetRolesAsync(userId, ct); + var (token, expiresAtUtc) = await jwt.GenerateAsync(userId, email, roles); + return new AuthResponse(token, expiresAtUtc, email, roles); + } + + public CurrentUserResponse CurrentUserAsync(CallerContext caller) => + new(caller.UserId, caller.Email, caller.Roles.ToList()); +} +``` + +Add to `IUserAccountStore`: `Task ResolveUserIdByEmailAsync(string email, CancellationToken ct);` (implementation uses `UserManager.FindByEmailAsync`; throws `NotFoundException` when absent). Update the canonical-map entry accordingly. + +`src/PriceNegotiationApp.Application/DependencyInjection.cs`: +```csharp +using Microsoft.Extensions.DependencyInjection; +using PriceNegotiationApp.Application.Features.Auth; +using PriceNegotiationApp.Application.Features.Negotiations; +using PriceNegotiationApp.Application.Features.Products; +using PriceNegotiationApp.Domain.Policy; + +namespace PriceNegotiationApp.Application; + +public static class DependencyInjection +{ + public static IServiceCollection AddApplicationServices(this IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(TimeProvider.System); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + return services; + } +} +``` + +- [ ] **Step 5: Service unit tests (NSubstitute)** + +`tests/PriceNegotiationApp.UnitTests/Application/NegotiationServiceShould.cs` — representative coverage (write all of these): +- `CreateAsync_throws_NotFound_for_unknown_product` (repo substitute returns null) +- `CreateAsync_throws_Conflict_when_open_negotiation_exists` +- `CreateAsync_maps_response_with_remaining_proposals` +- `CounterProposeAsync_throws_Forbidden_when_not_owner` +- `CounterProposeAsync_throws_Conflict_NoProposalsRemaining_when_budget_spent` +- `WithdrawAsync_allows_admin_for_any_negotiation` +- `GetAsync_allows_staff_but_forbids_stranger` + +Substitute setup example used throughout: +```csharp +private readonly INegotiationRepository _negotiations = Substitute.For(); +private readonly ICustomerRepository _customers = Substitute.For(); +private readonly NegotiationService _sut; + +public NegotiationServiceShould() +{ + var policy = new DefaultNegotiationPolicy(); + _sut = new NegotiationService(_negotiations, Substitute.For(), _customers, policy, + Substitute.For(), new FakeTimeProvider(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero))); +} +``` +Use `Microsoft.Extensions.TimeProvider.Testing` `FakeTimeProvider` — add package via `dotnet add package Microsoft.Extensions.TimeProvider.Testing` (test project only). Owner setup: `_customers.GetByIdentityAsync(userId, Arg.Any()).Returns(Customer.Create(userId))`. + +`tests/PriceNegotiationApp.UnitTests/Application/ProductServiceShould.cs` — cover search filter expression building (via InMemory-less approach: use a fake IQueryable list with `AsQueryable()` substitute for `Query()`), paging math, NotFound throws, trim-on-create. Note: `ToListAsync` requires `IAsyncEnumerable` — for pure-unit testing use `Microsoft.EntityFrameworkCore.InMemory`? That contradicts dependency removal... Pragmatic call: test `ProductService.ListAsync` through the **SQLite in-memory** provider? Also heavy. Simplest honest option: keep list-filtering covered by **integration tests** (Task 11 does exactly that) and restrict `ProductServiceShould` to Get/Create/Update/Delete paths with substituted repo (no LINQ-to-entities execution needed). Do that; do NOT add InMemory back. + +- [ ] **Step 6: Validate & commit** + +```pwsh +dotnet build && dotnet test tests/PriceNegotiationApp.UnitTests +git add -A && git commit -m "Rebuild application layer: feature services, ports, error taxonomy, unit tests" +``` + +--- + +### Task 5: Infrastructure persistence + repositories + DI + +**Files:** +- Create: `Identity/ApplicationUser.cs`, `Persistence/AppDbContext.cs`, `Persistence/DbEntityConfigurations/*.cs`, `Persistence/Repositories/*.cs`, `Data/DesignTimeDbContextFactory.cs`, `DependencyInjection.cs` +- Modify: `Directory.Packages.props` (add Npgsql, NamingConventions via `dotnet add package`) + +**Interfaces:** +- Produces: `AddInfrastructure(IServiceCollection, IConfiguration)` registering DbContext/repos/UoW; design-time factory for migrations. + +- [ ] **Step 1: Add packages** + +```pwsh +dotnet add src/PriceNegotiationApp.Infrastructure/package Npgsql.EntityFrameworkCore.PostgreSQL +dotnet add src/PriceNegotiationApp.Infrastructure/package EFCore.NamingConventions +dotnet add src/PriceNegotiationApp.Application/package Microsoft.EntityFrameworkCore --version 10.0.8 +``` + +- [ ] **Step 2: DbContext + configurations** + +`src/PriceNegotiationApp.Infrastructure/Identity/ApplicationUser.cs`: +```csharp +using Microsoft.AspNetCore.Identity; + +namespace PriceNegotiationApp.Infrastructure.Identity; + +public sealed class ApplicationUser : IdentityUser; +``` + +`src/PriceNegotiationApp.Infrastructure/Persistence/AppDbContext.cs`: +```csharp +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using PriceNegotiationApp.Domain.Models; +using PriceNegotiationApp.Infrastructure.Identity; + +namespace PriceNegotiationApp.Infrastructure.Persistence; + +public sealed class AppDbContext(DbContextOptions options) + : IdentityDbContext, Guid>(options) +{ + public DbSet Products => Set(); + + public DbSet Negotiations => Set(); + + public DbSet Customers => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly); + + modelBuilder.Entity>().ToTable("roles"); + modelBuilder.Entity>().ToTable("user_roles"); + modelBuilder.Entity>().ToTable("user_claims"); + modelBuilder.Entity>().ToTable("role_claims"); + modelBuilder.Entity>().ToTable("user_logins"); + modelBuilder.Entity>().ToTable("user_tokens"); + } +} +``` +(`using Microsoft.AspNetCore.Identity;` needed for generic Identity entity types.) + +`src/PriceNegotiationApp.Infrastructure/Persistence/DbEntityConfigurations/ProductConfiguration.cs`: +```csharp +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using PriceNegotiationApp.Domain.Models; +using PriceNegotiationApp.Domain.ValueObjects; + +namespace PriceNegotiationApp.Infrastructure.Persistence.DbEntityConfigurations; + +public sealed class ProductConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("products"); + builder.HasKey(p => p.Id); + builder.Property(p => p.Id).HasConversion(new ProductIdEfCoreValueConverter()).ValueGeneratedNever(); + builder.Property(p => p.Name).HasMaxLength(200).IsRequired(); + builder.Property(p => p.Price).HasConversion(new PriceEfCoreValueConverter()).HasColumnType("numeric(18,2)"); + builder.Property(p => p.Version).IsRowVersion(); + } +} +``` +(`using PriceNegotiationApp.Domain.ValueObjects.Ids;` where ID converters are referenced.) + +`src/PriceNegotiationApp.Infrastructure/Persistence/DbEntityConfigurations/NegotiationConfiguration.cs`: +```csharp +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using PriceNegotiationApp.Domain.Models; +using PriceNegotiationApp.Domain.ValueObjects; +using PriceNegotiationApp.Domain.ValueObjects.Ids; + +namespace PriceNegotiationApp.Infrastructure.Persistence.DbEntityConfigurations; + +public sealed class NegotiationConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("negotiations"); + builder.HasKey(n => n.Id); + builder.Property(n => n.Id).HasConversion(new NegotiationIdEfCoreValueConverter()).ValueGeneratedNever(); + builder.Property(n => n.ProductId).HasConversion(new ProductIdEfCoreValueConverter()); + builder.Property(n => n.CustomerId).HasConversion(new CustomerIdEfCoreValueConverter()); + builder.Property(n => n.BasePrice).HasConversion(new PriceEfCoreValueConverter()).HasColumnType("numeric(18,2)"); + builder.Property(n => n.CurrentOffer).HasConversion(new PriceEfCoreValueConverter()).HasColumnType("numeric(18,2)"); + builder.Property(n => n.Status).HasConversion(); + builder.HasOne().WithMany().HasForeignKey(n => n.ProductId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne().WithMany().HasForeignKey(n => n.CustomerId).OnDelete(DeleteBehavior.Cascade); + // One OPEN negotiation per customer per product; closed history preserved. + builder.HasIndex(n => new { n.ProductId, n.CustomerId }) + .IsUnique() + .HasFilter($"status = {(int)NegotiationStatus.Open}"); + builder.Property(n => n.Version).IsRowVersion(); + } +} +``` + +`src/PriceNegotiationApp.Infrastructure/Persistence/DbEntityConfigurations/CustomerConfiguration.cs`: +```csharp +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using PriceNegotiationApp.Domain.Models; +using PriceNegotiationApp.Domain.ValueObjects.Ids; + +namespace PriceNegotiationApp.Infrastructure.Persistence.DbEntityConfigurations; + +public sealed class CustomerConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("customers"); + builder.HasKey(c => c.Id); + builder.Property(c => c.Id).HasConversion(new CustomerIdEfCoreValueConverter()).ValueGeneratedNever(); + builder.HasIndex(c => c.IdentityUserId).IsUnique(); + } +} +``` + +- [ ] **Step 3: Repositories + UoW** + +`src/PriceNegotiationApp.Infrastructure/Persistence/Repositories/ProductRepository.cs`: +```csharp +using Microsoft.EntityFrameworkCore; +using PriceNegotiationApp.Application.Abstractions; +using PriceNegotiationApp.Domain.Models; +using PriceNegotiationApp.Domain.ValueObjects.Ids; + +namespace PriceNegotiationApp.Infrastructure.Persistence.Repositories; + +public sealed class ProductRepository(AppDbContext db) : IProductRepository +{ + public Task GetAsync(ProductId id, CancellationToken ct) => + db.Products.FirstOrDefaultAsync(p => p.Id == id, ct); + + public IQueryable Query() => db.Products.AsNoTracking(); + + public async Task AddAsync(Product product, CancellationToken ct) + { + await db.Products.AddAsync(product, ct); + } + + public void Remove(Product product) => db.Products.Remove(product); +} +``` + +`src/PriceNegotiationApp.Infrastructure/Persistence/Repositories/NegotiationRepository.cs`: +```csharp +using Microsoft.EntityFrameworkCore; +using PriceNegotiationApp.Application.Abstractions; +using PriceNegotiationApp.Domain.Models; +using PriceNegotiationApp.Domain.ValueObjects.Ids; + +namespace PriceNegotiationApp.Infrastructure.Persistence.Repositories; + +public sealed class NegotiationRepository(AppDbContext db, ICustomerRepository customers) : INegotiationRepository +{ + public Task GetAsync(NegotiationId id, CancellationToken ct) => + db.Negotiations.FirstOrDefaultAsync(n => n.Id == id, ct); + + public IQueryable Query() => db.Negotiations.AsNoTracking(); + + public async Task AddAsync(Negotiation negotiation, CancellationToken ct) => + await db.Negotiations.AddAsync(negotiation, ct); + + public async Task FindOpenAsync(ProductId productId, Guid identityUserId, CancellationToken ct) + { + var customer = await customers.GetByIdentityAsync(identityUserId, ct); + if (customer is null) + { + return null; + } + + return await db.Negotiations.FirstOrDefaultAsync( + n => n.ProductId == productId && n.CustomerId == customer.Id && n.Status == NegotiationStatus.Open, ct); + } + + public void Remove(Negotiation negotiation) => db.Negotiations.Remove(negotiation); +} +``` + +`src/PriceNegotiationApp.Infrastructure/Persistence/Repositories/CustomerRepository.cs`: +```csharp +using Microsoft.EntityFrameworkCore; +using PriceNegotiationApp.Application.Abstractions; +using PriceNegotiationApp.Domain.Models; +using PriceNegotiationApp.Domain.ValueObjects.Ids; + +namespace PriceNegotiationApp.Infrastructure.Persistence.Repositories; + +public sealed class CustomerRepository(AppDbContext db, IUnitOfWork uow) : ICustomerRepository +{ + public async Task GetOrCreateAsync(Guid identityUserId, CancellationToken ct) + { + var existing = await GetByIdentityAsync(identityUserId, ct); + if (existing is not null) + { + return existing.Id; + } + + var customer = Customer.Create(identityUserId); + await db.Customers.AddAsync(customer, ct); + await uow.SaveChangesAsync(ct); + return customer.Id; + } + + public Task GetByIdentityAsync(Guid identityUserId, CancellationToken ct) => + db.Customers.FirstOrDefaultAsync(c => c.IdentityUserId == identityUserId, ct); +} +``` +(Circular DI between `NegotiationRepository(ICustomerRepository)` and `CustomerRepository(IUnitOfWork)` is fine — no cycle.) + +`src/PriceNegotiationApp.Infrastructure/Persistence/Repositories/UnitOfWork.cs`: +```csharp +using Microsoft.EntityFrameworkCore; +using PriceNegotiationApp.Application.Abstractions; +using PriceNegotiationApp.Application.Common; +using PriceNegotiationApp.Application.Exceptions; + +namespace PriceNegotiationApp.Infrastructure.Persistence.Repositories; + +public sealed class UnitOfWork(AppDbContext db) : IUnitOfWork +{ + public async Task SaveChangesAsync(CancellationToken ct) + { + try + { + return await db.SaveChangesAsync(ct); + } + catch (DbUpdateConcurrencyException) + { + throw new ConflictException(ErrorCodes.ConcurrencyConflict, "The resource was modified concurrently. Reload and retry."); + } + } +} +``` + +- [ ] **Step 4: Design-time factory + DI registration** + +`src/PriceNegotiationApp.Infrastructure/Data/DesignTimeDbContextFactory.cs`: +```csharp +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; +using PriceNegotiationApp.Infrastructure.Persistence; + +namespace PriceNegotiationApp.Infrastructure.Data; + +public sealed class DesignTimeDbContextFactory : IDesignTimeDbContextFactory +{ + public AppDbContext CreateDbContext(string[] args) + { + var connectionString = Environment.GetEnvironmentVariable("Database__ConnectionString") + ?? "Host=localhost;Port=5432;Database=pricenego_design;Username=postgres;Password=postgres"; + var options = new DbContextOptionsBuilder() + .UseNpgsql(connectionString) + .UseSnakeCaseNamingConvention() + .Options; + return new AppDbContext(options); + } +} +``` + +`src/PriceNegotiationApp.Infrastructure/DependencyInjection.cs`: +```csharp +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using PriceNegotiationApp.Application.Abstractions; +using PriceNegotiationApp.Infrastructure.Identity; +using PriceNegotiationApp.Infrastructure.Persistence; +using PriceNegotiationApp.Infrastructure.Persistence.Repositories; + +namespace PriceNegotiationApp.Infrastructure; + +public static class DependencyInjection +{ + public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration) + { + services.AddDbContext(options => + options.UseNpgsql(configuration["Database:ConnectionString"]) + .UseSnakeCaseNamingConvention()); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + return services; + } +} +``` +(Identity/JWT/seeding registrations land in Task 6 inside the same method — extend, don't replace.) + +- [ ] **Step 5: Validate & commit** + +```pwsh +dotnet build +git add -A && git commit -m "Add PostgreSQL persistence: DbContext, snake_case configs, partial open-negotiation index, xmin versions, repositories" +``` + +--- + +### Task 6: JWT, Identity account store, seeding, migrations + +**Files:** +- Create: `Auth/{JwtOptions,JwtOptionsValidator,JwtManager}.cs`, `Identity/IdentityAccountStore.cs`, `Seeding/{SeedingOptions,SeedingHostedService}.cs` +- Modify: `DependencyInjection.cs` (extend), run initial migration +- Test: `tests/PriceNegotiationApp.UnitTests/Infrastructure/JwtManagerShould.cs` + +**Interfaces:** +- Consumes: `IUserAccountStore`, `IJwtTokenGenerator` ports from Task 4. +- Produces: working register/login mechanics; database schema via migration; startup seeding. + +- [ ] **Step 1: JwtOptions + validator + manager** + +`src/PriceNegotiationApp.Infrastructure/Auth/JwtOptions.cs`: +```csharp +namespace PriceNegotiationApp.Infrastructure.Auth; + +public sealed class JwtOptions +{ + public const string SectionName = "Jwt"; + + public required string Issuer { get; init; } + + public required string Audience { get; init; } + + public required string SecretKey { get; init; } + + public int ExpiryMinutes { get; init; } = 60; +} +``` + +`src/PriceNegotiationApp.Infrastructure/Auth/JwtOptionsValidator.cs`: +```csharp +using Microsoft.Extensions.Options; + +namespace PriceNegotiationApp.Infrastructure.Auth; + +public sealed class JwtOptionsValidator : IValidateOptions +{ + public ValidateOptionsResult Validate(string? name, JwtOptions options) + { + var failures = new List(); + if (options.SecretKey.Length < 32) + { + failures.Add("Jwt:SecretKey must be at least 32 characters."); + } + + if (string.IsNullOrWhiteSpace(options.Issuer)) + { + failures.Add("Jwt:Issuer is required."); + } + + if (string.IsNullOrWhiteSpace(options.Audience)) + { + failures.Add("Jwt:Audience is required."); + } + + if (options.ExpiryMinutes < 1) + { + failures.Add("Jwt:ExpiryMinutes must be >= 1."); + } + + return failures.Count > 0 ? ValidateOptionsResult.Fail(failures) : ValidateOptionsResult.Success; + } +} +``` + +`src/PriceNegotiationApp.Infrastructure/Auth/JwtManager.cs`: +```csharp +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; +using PriceNegotiationApp.Application.Abstractions; + +namespace PriceNegotiationApp.Infrastructure.Auth; + +public sealed class JwtManager(IOptions options, TimeProvider clock) : IJwtTokenGenerator +{ + public Task<(string Token, DateTimeOffset ExpiresAtUtc)> GenerateAsync(Guid userId, string email, IReadOnlyCollection roles) + { + var settings = options.Value; + var now = clock.GetUtcNow(); + var expiresAtUtc = now.AddMinutes(settings.ExpiryMinutes); + + var claims = new List + { + new(JwtRegisteredClaimNames.Sub, userId.ToString()), + new(JwtRegisteredClaimNames.Email, email), + new(JwtRegisteredClaimNames.Jti, Guid.CreateVersion7().ToString()), + }; + claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role))); + + var credentials = new SigningCredentials( + new SymmetricSecurityKey(Encoding.UTF8.GetBytes(settings.SecretKey)), + SecurityAlgorithms.HmacSha256); + + var token = new JwtSecurityToken( + issuer: settings.Issuer, + audience: settings.Audience, + claims: claims, + notBefore: now.UtcDateTime, + expires: expiresAtUtc.UtcDateTime, + signingCredentials: credentials); + + return Task.FromResult((new JwtSecurityTokenHandler().WriteToken(token), expiresAtUtc)); + } +} +``` + +- [ ] **Step 2: Identity account store** + +`src/PriceNegotiationApp.Infrastructure/Identity/IdentityAccountStore.cs`: +```csharp +using Microsoft.AspNetCore.Identity; +using PriceNegotiationApp.Application.Abstractions; +using PriceNegotiationApp.Application.Common; +using PriceNegotiationApp.Application.Exceptions; + +namespace PriceNegotiationApp.Infrastructure.Identity; + +public sealed class IdentityAccountStore(UserManager userManager, SignInManager signInManager) + : IUserAccountStore +{ + private static readonly RegistrationOutcome DuplicateEmail = new(false, Guid.Empty, "Email already registered."); + + public async Task RegisterAsync(string email, string password, CancellationToken ct) + { + var user = new ApplicationUser { UserName = email, Email = email }; + var result = await userManager.CreateAsync(user, password); + if (!result.Succeeded) + { + return result.Errors.Any(e => e.Code is "DuplicateEmail" or "DuplicateUserName") + ? DuplicateEmail + : ValidationFailed(result.Errors); + } + + await userManager.AddToRoleAsync(user, UserRoles.Customer); + return new RegistrationOutcome(true, user.Id, null); + } + + public async Task PasswordSignInAsync(string email, string password) + { + var result = await signInManager.PasswordSignInAsync(email, password, isPersistent: false, lockoutOnFailure: true); + return result.Succeeded ? SignInResultKind.Success + : result.IsLockedOut ? SignInResultKind.LockedOut + : SignInResultKind.Failure; + } + + public async Task ResolveUserIdByEmailAsync(string email, CancellationToken ct) + { + var user = await userManager.FindByEmailAsync(email) + ?? throw new NotFoundException("User", email); + return user.Id; + } + + public async Task> GetRolesAsync(Guid userId, CancellationToken ct) + { + var user = await userManager.FindByIdAsync(userId.ToString()) + ?? throw new NotFoundException("User", userId); + return await userManager.GetRolesAsync(user); + } + + private static RegistrationOutcome ValidationFailed(IEnumerable errors) => + new(false, Guid.Empty, string.Join("; ", errors.Select(e => e.Description))); +} +``` + +- [ ] **Step 3: Seeding** + +`src/PriceNegotiationApp.Infrastructure/Seeding/SeedingOptions.cs`: +```csharp +namespace PriceNegotiationApp.Infrastructure.Seeding; + +public sealed class SeedingOptions +{ + public const string SectionName = "Seeding"; + + public string AdminEmail { get; init; } = "admin@app.com"; + + public string AdminPassword { get; init; } = string.Empty; + + public string StaffEmail { get; init; } = "staff@app.com"; + + public string StaffPassword { get; init; } = string.Empty; + + public bool SeedSampleProducts { get; init; } +} +``` + +`src/PriceNegotiationApp.Infrastructure/Seeding/SeedingHostedService.cs`: +```csharp +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Options; +using PriceNegotiationApp.Application.Common; +using PriceNegotiationApp.Domain.Models; +using PriceNegotiationApp.Domain.ValueObjects; +using PriceNegotiationApp.Infrastructure.Identity; +using PriceNegotiationApp.Infrastructure.Persistence; + +namespace PriceNegotiationApp.Infrastructure.Seeding; + +public sealed class SeedingHostedService( + IServiceScopeFactory scopeFactory, + IOptions seedingOptions, + ILogger logger) : IHostedService +{ + public async Task StartAsync(CancellationToken cancellationToken) + { + await using var scope = scopeFactory.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.MigrateAsync(cancellationToken); + + var roleManager = scope.ServiceProvider.GetRequiredService>>(); + foreach (var role in new[] { UserRoles.Admin, UserRoles.Staff, UserRoles.Customer }) + { + if (await roleManager.RoleExistsAsync(role)) + { + continue; + } + + await roleManager.CreateAsync(new IdentityRole(role)); + } + + var userManager = scope.ServiceProvider.GetRequiredService>(); + await EnsureUserAsync(userManager, seedingOptions.Value.AdminEmail, seedingOptions.Value.AdminPassword, UserRoles.Admin, cancellationToken); + await EnsureUserAsync(userManager, seedingOptions.Value.StaffEmail, seedingOptions.Value.StaffPassword, UserRoles.Staff, cancellationToken); + + if (seedingOptions.Value.SeedSampleProducts && !await db.Products.AnyAsync(cancellationToken)) + { + db.Products.AddRange( + Product.Create("Mechanical Keyboard", Price.From(249.00m)), + Product.Create("Wireless Mouse", Price.From(79.90m)), + Product.Create("USB-C Docking Station", Price.From(189.50m))); + await db.SaveChangesAsync(cancellationToken); + } + + logger.LogInformation("Database migrated and seed data ensured."); + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + private static async Task EnsureUserAsync( + UserManager userManager, string email, string password, string role, CancellationToken ct) + { + if (await userManager.FindByEmailAsync(email) is not null || string.IsNullOrWhiteSpace(password)) + { + return; + } + + var user = new ApplicationUser { UserName = email, Email = email }; + var result = await userManager.CreateAsync(user, password); + if (result.Succeeded) + { + await userManager.AddToRoleAsync(user, role); + } + } +} +``` + +- [ ] **Step 4: Extend `AddInfrastructure`** — append before `return services;` + +```csharp + services.AddIdentityCore(options => + { + options.Lockout.AllowedForNewUsers = true; + options.Lockout.MaxFailedAccessAttempts = 5; + options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15); + }) + .AddRoles>() + .AddEntityFrameworkStores() + .AddDefaultTokenProviders(); + + services.AddScoped(...) // NOT NEEDED — see below +``` +Final correct block (replace the sketch above): +```csharp + services.AddScoped(); + + services.AddOptions() + .Bind(configuration.GetSection(JwtOptions.SectionName)) + .ValidateOnStart(); + services.AddSingleton, JwtOptionsValidator>(); + services.AddSingleton(); + + services.AddOptions() + .Bind(configuration.GetSection(SeedingOptions.SectionName)); + + services.AddHostedService(); +``` + +- [ ] **Step 5: Initial migration** + +```pwsh +dotnet tool install --global dotnet-ef +$env:Database__ConnectionString = 'Host=localhost;Port=5432;Database=pricenego_design;Username=postgres;Password=postgres' +dotnet ef migrations add Initial --project src/PriceNegotiationApp.Infrastructure --startup-project src/PriceNegotiationApp.Infrastructure --output-dir Data/Migrations +``` +Review generated migration for: snake_case table/column names, partial index filter `status = 1`, numeric(18,2) columns, identity tables renamed (`users`, `roles`, ...). Commit the generated files. + +- [ ] **Step 6: JwtManager unit test** + +`tests/PriceNegotiationApp.UnitTests/Infrastructure/JwtManagerShould.cs`: +```csharp +using Microsoft.Extensions.Options; +using PriceNegotiationApp.Infrastructure.Auth; + +namespace PriceNegotiationApp.UnitTests.Infrastructure; + +public class JwtManagerShould +{ + [Fact] + public async Task Generate_token_with_sub_email_role_and_expiry() + { + var options = Options.Create(new JwtOptions + { + Issuer = "test-issuer", + Audience = "test-audience", + SecretKey = new string('k', 48), + ExpiryMinutes = 30, + }); + var clock = new FixedTimeProvider(); + var sut = new JwtManager(options, clock); + + var (token, expiresAtUtc) = await sut.GenerateAsync( + Guid.NewGuid(), "user@test.dev", ["Customer"]); + + Assert.False(string.IsNullOrWhiteSpace(token)); + Assert.True(token.Split('.').Length == 3); + var expected = clock.GetUtcNow().AddMinutes(30); + Assert.True((expiresAtUtc - expected).Duration() < TimeSpan.FromSeconds(1)); + } + + private sealed class FixedTimeProvider : TimeProvider + { + public override DateTimeOffset GetUtcNow() => new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); + } +} +``` + +- [ ] **Step 7: Validate & commit** + +```pwsh +dotnet build && dotnet test tests/PriceNegotiationApp.UnitTests +git add -A && git commit -m "Add hardened JWT issuance, Identity-backed account store, config-driven seeding, initial PG migration" +``` + +--- + +### Task 7: Api host wiring + +**Files:** +- Create: `Extensions/WebApplicationBuilderExtensions.cs`, `Extensions/PipelineExtensions.cs`, `Extensions/ClaimsPrincipalExtensions.cs`, `Extensions/EndpointConventionExtensions.cs`, `GlobalExceptionHandler.cs` +- Rewrite: `src/PriceNegotiationApp.Api/Program.cs`, `appsettings.json` +- Delete: `appsettings.Development.json` secrets content (rewrite without secrets) + +**Interfaces:** +- Produces: `AddApiServices()`, `UsePipeline()`, `ToCallerContext()`, `RequireRoles()` — used by modules in Tasks 8–9. + +- [ ] **Step 1: appsettings.json (structural defaults only, NO secrets)** + +```json +{ + "Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" } }, + "AllowedHosts": "*", + "Cors": { "AllowedOrigins": [] }, + "Jwt": { "Issuer": "", "Audience": "", "SecretKey": "", "ExpiryMinutes": 60 }, + "Database": { "ConnectionString": "" }, + "Seeding": { "SeedSampleProducts": false } +} +``` +Delete `src/PriceNegotiationApp.Api/appsettings.Development.json` entirely; local overrides go to user-secrets: +```pwsh +dotnet user-secrets set "Jwt:SecretKey" "dev-only-secret-key-change-me-32-chars-min!!" --project src/PriceNegotiationApp.Api +dotnet user-secrets set "Jwt:Issuer" "https://localhost:5185" --project src/PriceNegotiationApp.Api +dotnet user-secrets set "Jwt:Audience" "price-negotiation-api" --project src/PriceNegotiationApp.Api +dotnet user-secrets set "Database:ConnectionString" "Host=localhost;Port=5432;Database=pricenego_dev;Username=postgres;Password=postgres" --project src/PriceNegotiationApp.Api +dotnet user-secrets set "Seeding:AdminPassword" "Admin123!" --project src/PriceNegotiationApp.Api +dotnet user-secrets set "Seeding:StaffPassword" "Staff123!" --project src/PriceNegotiationApp.Api +``` + +- [ ] **Step 2: GlobalExceptionHandler** + +`src/PriceNegotiationApp.Api/GlobalExceptionHandler.cs`: +```csharp +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using PriceNegotiationApp.Application.Common; +using PriceNegotiationApp.Application.Exceptions; +using PriceNegotiationApp.Domain.Exceptions; + +namespace PriceNegotiationApp.Api; + +public sealed class GlobalExceptionHandler(IProblemDetailsService problemDetailsService, IHostEnvironment environment) + : IExceptionHandler +{ + public async ValueTask TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken) + { + var (status, title, code) = exception switch + { + ProposalExceedsLimitException => (StatusCodes.Status400BadRequest, "Proposal rejected", ErrorCodes.ProposalExceedsLimit), + DomainException => (StatusCodes.Status409Conflict, "Business rule violated", ErrorCodes.NegotiationClosed), + NotFoundException notFound => (StatusCodes.Status404NotFound, "Resource not found", notFound.Code), + ConflictException conflict => (StatusCodes.Status409Conflict, "Conflict", conflict.Code), + ForbiddenAccessException => (StatusCodes.Status403Forbidden, "Forbidden", ErrorCodes.Forbidden), + UnauthorizedException unauthorized => (StatusCodes.Status401Unauthorized, "Authentication failed", unauthorized.Code), + OperationCanceledException when httpContext.RequestAborted.IsCancellationRequested + => (499, "Request cancelled", "client_closed_request"), + _ => (StatusCodes.Status500InternalServerError, "Unexpected error", ErrorCodes.InternalError), + }; + + httpContext.Response.StatusCode = status; + return await problemDetailsService.TryWriteAsync(new ProblemDetailsContext + { + HttpContext = httpContext, + ProblemDetails = new ProblemDetails + { + Status = status, + Title = title, + Detail = environment.IsDevelopment() && exception is not OperationCanceledException ? exception.Message : null, + Extensions = { ["code"] = code }, + }, + }); + } +} +``` + +- [ ] **Step 3: Extension plumbing** + +`src/PriceNegotiationApp.Api/Extensions/ClaimsPrincipalExtensions.cs`: +```csharp +using System.Security.Claims; +using PriceNegotiationApp.Application.Common; + +namespace PriceNegotiationApp.Api.Extensions; + +public static class ClaimsPrincipalExtensions +{ + public static CallerContext ToCallerContext(this ClaimsPrincipal principal) + { + if (principal.Identity?.IsAuthenticated != true) + { + return CallerContext.Anonymous; + } + + _ = Guid.TryParse(principal.FindFirstValue(ClaimTypes.NameIdentifier), out var userId); + var email = principal.FindFirstValue(ClaimTypes.Email) ?? string.Empty; + var roles = principal.FindAll(ClaimTypes.Role).Select(c => c.Value).ToHashSet(); + return new CallerContext(userId, email, roles); + } +} +``` + +`src/PriceNegotiationApp.Api/Extensions/EndpointConventionExtensions.cs`: +```csharp +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Builder; + +namespace PriceNegotiationApp.Api.Extensions; + +public static class EndpointConventionExtensions +{ + public static TBuilder RequireRoles(this TBuilder builder, params string[] roles) + where TBuilder : IEndpointConventionBuilder => + builder.RequireAuthorization(new AuthorizeAttribute { Roles = string.Join(",", roles) }); +} +``` + +`src/PriceNegotiationApp.Api/Extensions/WebApplicationBuilderExtensions.cs`: +```csharp +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.EntityFrameworkCore; +using Microsoft.IdentityModel.Tokens; +using OpenTelemetry.Metrics; +using OpenTelemetry.Resources; +using OpenTelemetry.Trace; +using Scalar.AspNetCore; +using Serilog; +using Serilog.Events; +using System.Text; + +namespace PriceNegotiationApp.Api.Extensions; + +public static class WebApplicationBuilderExtensions +{ + public const string AuthRateLimitPolicy = "auth"; + public const string CorsPolicy = "api"; + public const string ShortCachePolicy = "short"; + + public static WebApplicationBuilder AddApiServices(this WebApplicationBuilder builder) + { + var configuration = builder.Configuration; + + builder.Host.UseSerilog((context, _, logConfiguration) => logConfiguration + .ReadFrom.Configuration(context.Configuration) + .Enrich.FromLogContext() + .WriteTo.Console() + .WriteTo.File(Path.Combine("logs", "api-.log"), rollingInterval: RollingInterval.Day)); + + builder.Services + .AddApplicationServices() + .AddInfrastructure(configuration); + + builder.Services.AddProblemDetails(options => + options.CustomizeProblemDetails = context => + context.ProblemDetails.Extensions.TryAdd("traceId", context.HttpContext.TraceIdentifier)) + .AddExceptionHandler(); + + builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + var jwt = configuration.GetSection("Jwt").Get()!; + options.MapInboundClaims = true; + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidIssuer = jwt.Issuer, + ValidateAudience = true, + ValidAudience = jwt.Audience, + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwt.SecretKey)), + ValidateLifetime = true, + ClockSkew = TimeSpan.FromMinutes(1), + }; + }); + builder.Services.AddAuthorization(); + + var origins = configuration.GetSection("Cors:AllowedOrigins").Get() ?? []; + if (origins.Length > 0) + { + builder.Services.AddCors(options => options.AddPolicy(CorsPolicy, policy => + policy.WithOrigins(origins).AllowAnyHeader().AllowAnyMethod())); + } + + builder.Services.AddRateLimiter(options => + { + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + options.AddFixedWindowLimiter(AuthRateLimitPolicy, windowOptions => + { + windowOptions.PermitLimit = 10; + windowOptions.Window = TimeSpan.FromMinutes(1); + windowOptions.QueueLimit = 0; + }); + }); + + builder.Services.AddOutputCache(options => options.AddPolicy(ShortCachePolicy, + policy => policy.Expire(TimeSpan.FromSeconds(30)) + .SetVaryByQuery("search", "minPrice", "maxPrice", "sortBy", "sortDesc", "page", "pageSize"))); + + builder.Services.AddHealthChecks() + .AddCheck("self", () => HealthCheckResult.Healthy(), tags: ["live"]) + .AddDbContextCheck("database", tags: ["ready"]); + + builder.Services.AddOpenApi(); + + builder.Services.AddOpenTelemetry() + .ConfigureResource(resource => resource.AddService("PriceNegotiationApp.Api")) + .WithTracing(tracing => tracing + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation()) + .WithMetrics(metrics => metrics + .AddAspNetCoreInstrumentation() + .AddRuntimeInstrumentation()) + .UseOtlpExporter(); + + return builder; + } +``` +Notes for the implementer: +- `JwtSettings` here is a small local bind record in the Api (`Extensions/JwtSettings.cs`): `public sealed class JwtSettings { public required string Issuer {get;init;} public required string Audience {get;init;} public required string SecretKey {get;init;} }` — Infrastructure's validated `JwtOptions` remains the enforcement point; Api binds its own view for bearer params. +- Required usings include `Microsoft.AspNetCore.Authentication.JwtBearer`, `Microsoft.EntityFrameworkCore` (for `AddDbContextCheck` extension from the health-checks EF package), `Microsoft.IdentityModel.Tokens`, `System.Text`, `PriceNegotiationApp.Infrastructure.Persistence`. +- Before first successful run you must install missing packages into the Api project: `OpenTelemetry.Extensions.Hosting`, `OpenTelemetry.Instrumentation.AspNetCore`, `OpenTelemetry.Instrumentation.Http`, `OpenTelemetry.Exporter.OpenTelemetryProtocol` (use `dotnet add package`; CPM updates automatically). +- `UseOtlpExporter()` is a no-op unless `OTEL_EXPORTER_OTLP_ENDPOINT` is set. + +`src/PriceNegotiationApp.Api/Extensions/PipelineExtensions.cs`: +```csharp +using Serilog; + +namespace PriceNegotiationApp.Api.Extensions; + +public static class PipelineExtensions +{ + public static WebApplication UsePipeline(this WebApplication app) + { + app.UseSerilogRequestLogging(); + app.UseStatusCodePages(); + + app.UseExceptionHandler(); + app.UseHttpsRedirection(); + + if (app.Environment.IsDevelopment()) + { + app.MapOpenApi(); + app.MapScalarApiReference(); + } + + app.UseAuthentication(); + app.UseAuthorization(); + app.UseRateLimiter(); + app.UseOutputCache(); + + // Task 7 ships only health endpoints; module mappings are added in Tasks 8-9: + app.MapHealthChecks("/health/live", new() { Predicate = r => r.Tags.Contains("live") }); + app.MapHealthChecks("/health/ready", new() { Predicate = r => r.Tags.Contains("ready") }); + + return app; + } +} +``` +HSTS is enabled before `UseHttpsRedirection` for non-development environments: add `if (!app.Environment.IsDevelopment()) app.UseHsts();` immediately above `app.UseHttpsRedirection();`. +In Task 8 a `MapModules(this WebApplication)` private extension replaces the two direct `MapHealthChecks` lines (health checks move inside it). + +- [ ] **Step 4: Final Program.cs** + +```csharp +using PriceNegotiationApp.Api.Extensions; + +var builder = WebApplication.CreateBuilder(args); +builder.AddApiServices(); + +var app = builder.Build(); +app.UsePipeline(); + +app.Run(); + +public partial class Program; +``` + +- [ ] **Step 5: Validate & commit** + +```pwsh +dotnet build +# Smoke-run against a local postgres (or skip run; full validation comes with integration tests): +dotnet run --project src/PriceNegotiationApp.Api # expect: migration + seeding logs, GET /health/live = Healthy +git add -A && git commit -m "Wire API host: strict JWT validation, ProblemDetails handler, rate limiting, CORS, output cache, health checks, OTel" +``` + +--- + +### Task 8: Auth + Products modules + +**Files:** +- Create: `Contracts/AuthRequests.cs`, `Contracts/ProductRequests.cs`, `Modules/AuthModule.cs`, `Modules/ProductsModule.cs` +- Modify: `PipelineExtensions` (call `MapModules`), `Program.cs` unchanged + +**Interfaces:** +- Consumes: `IAuthService`, `IProductService`, `CallerContext.ToCallerContext()`, `RequireRoles`. +- Produces: routes per spec §6 (auth + products); `MapModules` aggregate. + +- [ ] **Step 1: Request contracts** + +`src/PriceNegotiationApp.Api/Contracts/AuthRequests.cs`: +```csharp + +namespace PriceNegotiationApp.Api.Contracts; + +public sealed class RegisterRequest +{ + public string Email { get; init; } = string.Empty; + + public string Password { get; init; } = string.Empty; +} + +public sealed class LoginRequest +{ + public string Email { get; init; } = string.Empty; + + public string Password { get; init; } = string.Empty; +} +``` +No attributes here: input invariants are enforced by the domain (entity rules, Price value object, Identity email/password policy) and mapped to ProblemDetails by GlobalExceptionHandler. + +`src/PriceNegotiationApp.Api/Contracts/ProductRequests.cs`: +```csharp + +namespace PriceNegotiationApp.Api.Contracts; + +public sealed class CreateProductRequest +{ + public string Name { get; init; } = string.Empty; + + public decimal Price { get; init; } +} + +public sealed class UpdateProductRequest +{ + public string Name { get; init; } = string.Empty; + + public decimal Price { get; init; } +} +``` + +- [ ] **Step 2: AuthModule** + +`src/PriceNegotiationApp.Api/Modules/AuthModule.cs`: +```csharp +using Microsoft.AspNetCore.Mvc; +using PriceNegotiationApp.Api.Contracts; +using PriceNegotiationApp.Api.Extensions; +using PriceNegotiationApp.Application.Features.Auth; + +namespace PriceNegotiationApp.Api.Modules; + +public static class AuthModule +{ + public static IEndpointRouteBuilder MapAuthApi(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/api/v1/auth").WithTags("Auth"); + + group.MapPost("/register", + async (RegisterRequest request, IAuthService auth, CancellationToken ct) => + TypedResults.Created($"/api/v1/auth/me", await auth.RegisterAsync(request.Email, request.Password, ct))) + .RequireRateLimiting(WebApplicationBuilderExtensions.AuthRateLimitPolicy) + .AllowAnonymous(); + + group.MapPost("/login", + async (LoginRequest request, IAuthService auth, CancellationToken ct) => + TypedResults.Ok(await auth.LoginAsync(request.Email, request.Password, ct))) + .RequireRateLimiting(WebApplicationBuilderExtensions.AuthRateLimitPolicy) + .AllowAnonymous(); + + group.MapGet("/me", + (ClaimsPrincipal principal, IAuthService auth) => + TypedResults.Ok(auth.CurrentUserAsync(principal.ToCallerContext()))) + .RequireAuthorization(); + + return app; + } +} +``` + +- [ ] **Step 3: ProductsModule** + +`src/PriceNegotiationApp.Api/Modules/ProductsModule.cs`: +```csharp +using PriceNegotiationApp.Api.Contracts; +using PriceNegotiationApp.Api.Extensions; +using PriceNegotiationApp.Application.Common; +using PriceNegotiationApp.Application.Features.Products; + +namespace PriceNegotiationApp.Api.Modules; + +public static class ProductsModule +{ + public static IEndpointRouteBuilder MapProductsApi(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/api/v1/products").WithTags("Products"); + + group.MapGet("/", + async ([AsParameters] ProductListRequest query, IProductService products, CancellationToken ct) => + TypedResults.Ok(await products.ListAsync(query.ToQuery(), ct))) + .CacheOutput(WebApplicationBuilderExtensions.ShortCachePolicy) + .AllowAnonymous(); + + group.MapGet("/{id:guid}", + async (Guid id, IProductService products, CancellationToken ct) => + TypedResults.Ok(await products.GetAsync(id, ct))) + .WithName("GetProductById") + .CacheOutput(WebApplicationBuilderExtensions.ShortCachePolicy) + .AllowAnonymous(); + + group.MapPost("/", async (CreateProductRequest request, IProductService products, CancellationToken ct) => + { + var created = await products.CreateAsync(request.Name, request.Price, ct); + return TypedResults.CreatedAtRoute(created, "GetProductById", new { id = created.Id }); + }).RequireRoles(UserRoles.Admin, UserRoles.Staff); + + group.MapPut("/{id:guid}", async (Guid id, UpdateProductRequest request, IProductService products, CancellationToken ct) => + TypedResults.Ok(await products.UpdateAsync(id, request.Name, request.Price, ct))) + .RequireRoles(UserRoles.Admin, UserRoles.Staff); + + group.MapDelete("/{id:guid}", async (Guid id, IProductService products, CancellationToken ct) => + { + await products.DeleteAsync(id, ct); + return TypedResults.NoContent(); + }).RequireRoles(UserRoles.Admin); + + return app; + } +} +``` +with `using PriceNegotiationApp.Application.Common;` for `UserRoles` and a small mapper on the request side: +`src/PriceNegotiationApp.Api/Contracts/ProductRequests.cs` append: +```csharp +public sealed class ProductListRequest +{ + public string? Search { get; init; } + public decimal? MinPrice { get; init; } + public decimal? MaxPrice { get; init; } + public string? SortBy { get; init; } + public bool SortDesc { get; init; } + public int Page { get; init; } = 1; + public int PageSize { get; init; } = 20; + + public ProductQuery ToQuery() => new(Search, MinPrice, MaxPrice, SortBy, SortDesc, Page, PageSize); +} +``` + +- [ ] **Step 4: Aggregate + wire** + +`src/PriceNegotiationApp.Api/Extensions/PipelineExtensions.cs` — add: +```csharp + private static void MapModules(this WebApplication app) + { + app.MapHealthChecks("/health/live", new() { Predicate = r => r.Tags.Contains("live") }); + app.MapHealthChecks("/health/ready", new() { Predicate = r => r.Tags.Contains("ready") }); + app.MapAuthApi(); + app.MapProductsApi(); + // app.MapNegotiationsApi(); // added in Task 9 + } +``` +and call `app.MapModules();` in `UsePipeline`, replacing the two direct `MapHealthChecks` lines. + +- [ ] **Step 5: Validate & commit** + +```pwsh +dotnet build && dotnet run --project src/PriceNegotiationApp.Api +# Manual smoke: POST /api/v1/auth/register, login, GET /api/v1/products?page=1 +git add -A && git commit -m "Add auth and products minimal-API modules with output caching" +``` + +--- + +### Task 9: Negotiations module + local run polish + +**Files:** +- Create: `Contracts/NegotiationRequests.cs`, `Modules/NegotiationsModule.cs` +- Modify: `PipelineExtensions.MapModules` (add negotiations), `Properties/launchSettings.json`, `PriceNegotiationApp.http` + +**Interfaces:** +- Consumes: `INegotiationService` signatures from Task 4. +- Produces: complete API surface — app is feature-complete after this task. + +- [ ] **Step 1: Contracts** + +`src/PriceNegotiationApp.Api/Contracts/NegotiationRequests.cs`: +```csharp + +namespace PriceNegotiationApp.Api.Contracts; + +public sealed class CreateNegotiationRequest +{ + public Guid ProductId { get; init; } + + public decimal ProposedPrice { get; init; } +} + +public sealed class CounterProposalRequest +{ + public decimal ProposedPrice { get; init; } +} +``` + +- [ ] **Step 2: Module** + +`src/PriceNegotiationApp.Api/Modules/NegotiationsModule.cs`: +```csharp +using PriceNegotiationApp.Api.Contracts; +using PriceNegotiationApp.Api.Extensions; +using PriceNegotiationApp.Application.Common; +using PriceNegotiationApp.Application.Features.Negotiations; +using Microsoft.AspNetCore.Mvc; + +namespace PriceNegotiationApp.Api.Modules; + +public static class NegotiationsModule +{ + public static IEndpointRouteBuilder MapNegotiationsApi(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/api/v1/negotiations").WithTags("Negotiations"); + + group.MapPost("/", async (CreateNegotiationRequest request, ClaimsPrincipal principal, INegotiationService negotiations, CancellationToken ct) => + TypedResults.Created($"/api/v1/negotiations/mine", + await negotiations.CreateAsync(principal.ToCallerContext(), request.ProductId, request.ProposedPrice, ct))) + .RequireRoles(UserRoles.Customer); + + group.MapGet("/mine", async (ClaimsPrincipal principal, INegotiationService negotiations, + [FromQuery] int page, [FromQuery] int pageSize, CancellationToken ct) => + TypedResults.Ok(await negotiations.ListMineAsync(principal.ToCallerContext(), new PageQuery(page, pageSize), ct))) + .RequireRoles(UserRoles.Customer); + + group.MapGet("/", async (INegotiationService negotiations, + [FromQuery] int page, [FromQuery] int pageSize, CancellationToken ct) => + TypedResults.Ok(await negotiations.ListAsync(new PageQuery(page, pageSize), ct))) + .RequireRoles(UserRoles.Admin, UserRoles.Staff); + + group.MapGet("/{id:guid}", async (Guid id, ClaimsPrincipal principal, INegotiationService negotiations, CancellationToken ct) => + TypedResults.Ok(await negotiations.GetAsync(principal.ToCallerContext(), id, ct))) + .RequireAuthorization(); + + group.MapPatch("/{id:guid}/proposals", + async (Guid id, CounterProposalRequest request, ClaimsPrincipal principal, INegotiationService negotiations, CancellationToken ct) => + TypedResults.Ok(await negotiations.CounterProposeAsync(principal.ToCallerContext(), id, request.ProposedPrice, ct))) + .RequireAuthorization(); + + group.MapPost("/{id:guid}/accept", async (Guid id, INegotiationService negotiations, CancellationToken ct) => + TypedResults.Ok(await negotiations.AcceptAsync(id, ct))) + .RequireRoles(UserRoles.Admin, UserRoles.Staff); + + group.MapPost("/{id:guid}/decline", async (Guid id, INegotiationService negotiations, CancellationToken ct) => + TypedResults.Ok(await negotiations.DeclineAsync(id, ct))) + .RequireRoles(UserRoles.Admin, UserRoles.Staff); + + group.MapDelete("/{id:guid}", async (Guid id, ClaimsPrincipal principal, INegotiationService negotiations, CancellationToken ct) => + { + await negotiations.WithdrawAsync(principal.ToCallerContext(), id, ct); + return TypedResults.NoContent(); + }) + .RequireAuthorization(); + + return app; + } +} +``` + +Then add `app.MapNegotiationsApi();` inside `MapModules`. + +- [ ] **Step 3: launchSettings + .http rewrite** + +`src/PriceNegotiationApp.Api/Properties/launchSettings.json`: +```json +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "launchBrowser": false, + "applicationUrl": "http://localhost:5185", + "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } + }, + "https": { + "commandName": "Project", + "launchBrowser": false, + "applicationUrl": "https://localhost:7004;http://localhost:5185", + "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } + } + } +} +``` + +Rewrite `PriceNegotiationApp.http` with real requests (register/login/products CRUD/negotiation flow) — variables `@host = http://localhost:5185`, `@token = `; include accept/decline/counter-proposal examples. + +- [ ] **Step 4: Validate & commit** + +```pwsh +dotnet build && dotnet format --verify-no-changes || dotnet format +git add -A && git commit -m "Complete API surface: negotiations lifecycle endpoints, cleaned launch settings and .http scratch" +``` + +--- + +### Task 10: Integration test infrastructure + auth flow tests + +**Files:** +- Create: `tests/PriceNegotiationApp.IntegrationTests/Support/{IntegrationTestFactory,PostgresFixture,BearerTokenHandler,TestUsers}.cs` +- Test: `tests/PriceNegotiationApp.IntegrationTests/AuthFlowShould.cs` + +**Interfaces:** +- Produces: `[Collection("api")]` fixture giving `IntegrationTestFixture` with `Client` (anon HttpClient), `CreateUserAsync(email,password,role-expectations)` returning an authenticated client, and Refit-typed `IProductsApiClient`/`INegotiationsApiClient`/`IAuthApiClient` factories. + +- [ ] **Step 1: Packages** + +```pwsh +dotnet add tests/PriceNegotiationApp.IntegrationTests/package Testcontainers.PostgreSql +dotnet add tests/PriceNegotiationApp.IntegrationTests/package Refit.HttpClientFactory # if Refit typed-client factory desired; plain Refit suffices otherwise +``` + +- [ ] **Step 2: Factory + fixtures** + +`tests/PriceNegotiationApp.IntegrationTests/Support/PostgresFixture.cs`: +```csharp +using Testcontainers.PostgreSql; + +namespace PriceNegotiationApp.IntegrationTests.Support; + +public sealed class PostgreSqlFixture : IAsyncLifetime +{ + public PostgreSqlContainer Container { get; } = new PostgreSqlBuilder() + .WithImage("postgres:17-alpine") + .Build(); + + public Task InitializeAsync() => Container.StartAsync(); + + public Task DisposeAsync() => Container.DisposeAsync().AsTask(); +} + +[CollectionDefinition(Name)] +public sealed class ApiCollection : ICollectionFixture +{ + public const string Name = "api"; +} +``` + +`tests/PriceNegotiationApp.IntegrationTests/Support/IntegrationTestFactory.cs`: +```csharp +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.Hosting; +using PriceNegotiationApp.IntegrationTests.Support; + +namespace PriceNegotiationApp.IntegrationTests; + +public sealed class IntegrationTestFactory(PostgreSqlFixture postgres) : WebApplicationFactory +{ + public const string AdminEmail = "admin@test.local"; + public const string StaffEmail = "staff@test.local"; + public const string SeedPassword = "Seed123!a"; + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment("Testing"); + builder.UseSetting("Database:ConnectionString", postgres.Container.GetConnectionString()); + builder.UseSetting("Jwt:Issuer", "integration-tests"); + builder.UseSetting("Jwt:Audience", "integration-tests"); + builder.UseSetting("Jwt:SecretKey", new string('t', 64)); + builder.UseSetting("Jwt:ExpiryMinutes", "30"); + builder.UseSetting("Seeding:AdminEmail", AdminEmail); + builder.UseSetting("Seeding:AdminPassword", SeedPassword); + builder.UseSetting("Seeding:StaffEmail", StaffEmail); + builder.UseSetting("Seeding:StaffPassword", SeedPassword); + builder.UseSetting("Seeding:SeedSampleProducts", "true"); + } +} +``` + +`tests/PriceNegotiationApp.IntegrationTests/Support/BearerTokenHandler.cs`: +```csharp +namespace PriceNegotiationApp.IntegrationTests.Support; + +public sealed class TokenHolder +{ + public string? Token { get; set; } +} + +public sealed class BearerTokenHandler(TokenHolder holder) : DelegatingHandler +{ + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (holder.Token is { } token) + { + request.Headers.Authorization = new("Bearer", token); + } + + return base.SendAsync(request, cancellationToken); + } +} +``` + +`tests/PriceNegotiationApp.IntegrationTests/Support/TestFixture.cs`: +```csharp +using System.Net.Http.Json; +using Microsoft.AspNetCore.Mvc.Testing; +using PriceNegotiationApp.Api.Contracts; + +namespace PriceNegotiationApp.IntegrationTests.Support; + +public sealed class IntegrationTestFixture(PostgreSqlFixture postgres) : IAsyncLifetime +{ + public IntegrationTestFactory Factory { get; private set; } = null!; + + public HttpClient Anonymous { get; private set; } = null!; + + public Task InitializeAsync() + { + Factory = new IntegrationTestFactory(postgres); + Anonymous = Factory.CreateClient(); + return Task.CompletedTask; + } + + public async Task DisposeAsync() + { + Anonymous.Dispose(); + await Factory.DisposeAsync(); + } + + /// Registers (idempotent-enough: unique suffix) and logs in a fresh user; returns an authorized client. + public async Task CreateUserAsync(string roleHint = "customer") + { + var email = $"{roleHint}.{Guid.NewGuid():N}@test.local"; + var password = "Passw0rd!"; + var register = await Anonymous.PostAsJsonAsync("/api/v1/auth/register", new RegisterRequest { Email = email, Password = password }); + register.EnsureSuccessStatusCode(); + + var login = await Anonymous.PostAsJsonAsync("/api/v1/auth/login", new LoginRequest { Email = email, Password = password }); + login.EnsureSuccessStatusCode(); + var auth = await login.Content.ReadFromJsonAsync(); + return new UserSession(this, email, auth!.AccessToken); + } + + public HttpClient ClientFor(string? token) + { + var holder = new TokenHolder { Token = token }; + return Factory.CreateDefaultClient(new BearerTokenHandler(holder)); + } +} + +public sealed class UserSession(IntegrationTestFixture fixture, string email, string token) +{ + public string Email { get; } = email; + + public HttpClient Client { get; } = fixture.ClientFor(token); +} +``` +Note: admin/staff sessions reuse seeded accounts — login directly: +```csharp +public async Task LoginAsync(string email, string password = IntegrationTestFactory.SeedPassword) { /* POST /login, wrap token */ } +``` + +- [ ] **Step 3: Auth flow tests** + +`tests/PriceNegotiationApp.IntegrationTests/AuthFlowShould.cs`: +```csharp +using System.Net; +using System.Net.Http.Json; +using PriceNegotiationApp.Api.Contracts; +using PriceNegotiationApp.IntegrationTests.Support; + +namespace PriceNegotiationApp.IntegrationTests; + +[Collection(ApiCollection.Name)] +public class AuthFlowShould(IntegrationTestFixture fixture) +{ + [Fact] + public async Task Register_login_and_read_current_user() + { + var session = await fixture.CreateUserAsync(); + + var me = await session.Client.GetAsync("/api/v1/auth/me"); + + me.EnsureSuccessStatusCode(); + var user = await me.Content.ReadFromJsonAsync(); + Assert.Equal(session.Email, user!.Email); + Assert.Contains("Customer", user.Roles); + } + + [Fact] + public async Task Duplicate_registration_conflicts() + { + var email = $"dup.{Guid.NewGuid():N}@test.local"; + var first = await fixture.Anonymous.PostAsJsonAsync("/api/v1/auth/register", + new RegisterRequest { Email = email, Password = "Passw0rd!" }); + var second = await fixture.Anonymous.PostAsJsonAsync("/api/v1/auth/register", + new RegisterRequest { Email = email, Password = "Passw0rd!" }); + + Assert.Equal(HttpStatusCode.Created, first.StatusCode); + Assert.Equal(HttpStatusCode.Conflict, second.StatusCode); + } + + [Fact] + public async Task Bad_password_is_unauthorized_with_stable_code() + { + var session = await fixture.CreateUserAsync(); + var response = await fixture.Anonymous.PostAsJsonAsync("/api/v1/auth/login", + new LoginRequest { Email = session.Email, Password = "WrongPass1!" }); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + Assert.Contains("\"code\":\"invalid_credentials\"", body.Replace(" ", string.Empty)); + } + + [Fact] + public async Task Five_failed_attempts_lock_account() + { + var session = await fixture.CreateUserAsync(); + HttpStatusCode last = HttpStatusCode.OK; + for (var i = 0; i < 6; i++) + { + last = (await fixture.Anonymous.PostAsJsonAsync("/api/v1/auth/login", + new LoginRequest { Email = session.Email, Password = "WrongPass1!" })).StatusCode; + } + + Assert.Equal(HttpStatusCode.Unauthorized, last); + var retry = await fixture.Anonymous.PostAsJsonAsync("/api/v1/auth/login", + new LoginRequest { Email = session.Email, Password = "Passw0rd!" }); // even correct password now locked + Assert.Contains("account_locked", await retry.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task Me_requires_authentication() + { + var response = await fixture.Anonymous.GetAsync("/api/v1/auth/me"); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } +} +``` + +- [ ] **Step 4: Validate & commit** + +Requires Docker running locally: +```pwsh +dotnet test tests/PriceNegotiationApp.IntegrationTests +git add -A && git commit -m "Add Testcontainers integration harness and end-to-end auth flow tests" +``` + +--- + +### Task 11: Products integration matrix + +**Files:** +- Test: `tests/PriceNegotiationApp.IntegrationTests/ProductsShould.cs` + +- [ ] **Step 1: Tests** (role matrix × routes + filtering/paging/validation) + +Cover explicitly: +- Anon can list/get; anon blocked from create/update/delete (401/403). +- Customer blocked from all writes (403). +- Staff can create/update, cannot delete (403); admin can delete (204). +- Missing product → 404 with `product_not_found`. + - Invalid create payload (empty name, negative price) -> 400 with `domain_rule_violated` / `validation_failed` from domain rules +- Filtering: create 3 known products; assert `search`, `minPrice`, `maxPrice`, `sortBy=price&sortDesc`, `page/pageSize` behaviors incl. `totalCount`. +- PUT with identical body returns 200 unchanged (idempotent no-op). + +Write as one focused test class using `fixture.CreateUserAsync()` + `LoginAsync(IntegrationTestFactory.AdminEmail)` / staff login helpers; ~12 test methods mirroring the bullets above with plain asserts. + +- [ ] **Step 2: Validate & commit** + +```pwsh +dotnet test tests/PriceNegotiationApp.IntegrationTests +git add -A && git commit -m "Add products API integration matrix: RBAC, validation, filtering, paging" +``` + +--- + +### Task 12: Negotiations integration suite + +**Files:** +- Test: `tests/PriceNegotiationApp.IntegrationTests/NegotiationsShould.cs` + +- [ ] **Step 1: Tests** — the core business suite: + +1. `customer_creates_negotiation_within_limit` → 201; `GET mine` shows it; `proposalsRemaining == 2`. +2. `creation_over_double_price_rejected_400` with code `proposal_exceeds_limit`. +3. `double_open_negotiation_conflicts` → 409 `negotiation_already_open`. +4. `full_back_and_forth_then_accept`: create → staff decline → counter (remaining 1) → staff decline → counter (remaining 0) → staff accept → status Accepted; further PATCH → 409 `negotiation_closed`. +5. `budget_exhaustion_yields_409_no_proposals_remaining`: create → decline×2 + counters×2 → third counter attempt → 409 `no_proposals_remaining`. +6. `counter_proposal_over_limit_auto_rejects`: create → PATCH 300 (base 100) → outcome `AutoRejected`, status Declined, decidedAtUtc present. +7. `stranger_cannot_view_negotiation` → 403; `staff_and_admin_can_view` → 200. +8. `only_owner_can_counter_propose` → other customer gets 403. +9. `owner_can_withdraw` → 204; `stranger_cannot_withdraw` → 403; `admin_can_delete_any` → 204. +10. `decline_by_staff_keeps_open_until_budget_spent`: decline → still Open in `GET mine` while remaining > 0. + +Helper within the class: create product as staff via `POST /products`, then negotiate against it. + +- [ ] **Step 2: Validate & commit** + +```pwsh +dotnet test tests/PriceNegotiationApp.IntegrationTests +git add -A && git commit -m "Add negotiation lifecycle integration suite: limits, auto-reject, RBAC, withdrawal" +``` + +--- + +### Task 13: Platform — Docker, CI, Dependabot + +**Files:** +- Create: `Dockerfile`, `.dockerignore`, `docker-compose.yml`, `.github/workflows/ci.yml`, `.github/dependabot.yml` + +- [ ] **Step 1: Dockerfile** + +```dockerfile +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src +COPY Directory.Build.props Directory.Packages.props PriceNegotiationApp.slnx ./ +COPY src src +COPY tests tests +RUN dotnet restore +RUN dotnet publish src/PriceNegotiationApp.Api -c Release -o /app --no-restore + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime +WORKDIR /app +COPY --from=build /app . +EXPOSE 8080 +ENV ASPNETCORE_URLS=http://+:8080 +USER app +HEALTHCHECK --interval=30s --timeout=5s CMD ["/usr/bin/wget", "-qO-", "http://localhost:8080/health/live"] +ENTRYPOINT ["dotnet", "PriceNegotiationApp.Api.dll"] +``` +(Note: tests are executed by CI, not baked into image build — keeps image lean; `tests` copy exists because slnx restore needs referenced projects.) + +- [ ] **Step 2: docker-compose.yml** + +```yaml +services: + api: + build: . + depends_on: [postgres] + environment: + ASPNETCORE_ENVIRONMENT: Production + Database__ConnectionString: Host=postgres;Port=5432;Database=pricenego;Username=postgres;Password=${POSTGRES_PASSWORD:?set} + Jwt__Issuer: ${JWT_ISSUER:-price-negotiation-app} + Jwt__Audience: ${JWT_AUDIENCE:-price-negotiation-api} + Jwt__SecretKey: ${JWT_SECRET_KEY:?set-32+-chars} + Jwt__ExpiryMinutes: "60" + Seeding__AdminEmail: ${SEED_ADMIN_EMAIL:-admin@app.com} + Seeding__AdminPassword: ${SEED_ADMIN_PASSWORD:?set} + Seeding__StaffEmail: ${SEED_STAFF_EMAIL:-staff@app.com} + Seeding__StaffPassword: ${SEED_STAFF_PASSWORD:?set} + Seeding__SeedSampleProducts: "true" + ports: ["8080:8080"] + + postgres: + image: postgres:17-alpine + environment: + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set} + POSTGRES_DB: pricenego + volumes: [pgdata:/var/lib/postgresql/data] + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + retries: 10 + +volumes: + pgdata: +``` +Plus `.env.example` documenting required vars (no values committed). + +- [ ] **Step 3: CI workflow** + +`.github/workflows/ci.yml`: +```yaml +name: ci +on: + push: + branches: [main, develop] + pull_request: + +jobs: + build-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + cache: true + cache-dependency-path: '**/packages.lock.json' # optional; drop if lock files unused + - run: dotnet restore + - run: dotnet format --verify-no-changes + - run: dotnet build -c Release --no-restore + - run: dotnet test --no-build -c Release --collect:"XPlat Code Coverage" + - uses: actions/upload-artifact@v4 + if: always() + with: + name: openapi + path: artifacts/openapi/ + - uses: actions/upload-artifact@v4 + if: always() + with: + name: coverage + path: '**/TestResults/**/coverage.cobertura.xml' +``` +(Remove the cache step lines if lock-file mode isn't enabled — simplest correct form omits them.) + +- [ ] **Step 4: Dependabot** + +`.github/dependabot.yml`: +```yaml +version: 2 +updates: + - package-ecosystem: nuget + directory: / + schedule: { interval: weekly } + - package-ecosystem: github-actions + directory: / + schedule: { interval: weekly } + - package-ecosystem: docker + directory: / + schedule: { interval: weekly } +``` + +- [ ] **Step 5: Validate & commit** + +```pwsh +docker compose build +docker compose up -d postgres +$env:POSTGRES_PASSWORD='localpw'; $env:JWT_SECRET_KEY=('x'*40); $env:SEED_ADMIN_PASSWORD='Admin123!'; $env:SEED_STAFF_PASSWORD='Staff123!' +docker compose up api # expect healthy +docker compose down +git add -A && git commit -m "Add containerized deployment, GitHub Actions CI, Dependabot" +``` + +--- + +### Task 14: README + final sweep + +**Files:** +- Rewrite: `README.md` +- Verify: full pipeline green + +- [ ] **Step 1: README** — rewrite covering: stack summary (drop dead badges/links), architecture diagram-in-text (4 projects), quickstart (compose up + env table, or `dotnet run` + user-secrets commands copied from Task 7), endpoint table matching spec §6, negotiation rules prose (3 proposals total incl. initial, >2× auto-decline, staff accept/decline, withdraw), auth model (register→Customer; staff/admin seeded), config reference, health endpoints, license unchanged. + +- [ ] **Step 2: Final sweep** + +```pwsh +dotnet format +dotnet build +dotnet test +git add -A && git commit -m "Modernize README and finalize formatting" +``` + +--- + +## Self-Review notes (already applied inline) + +- Removed all draft/sketch code blocks; every step now contains final, compilable code only. +- Fixed: ProductsModule POST double-call, stray `using` in NegotiationsModule, `IdentityAccountStore` failure branch (named helpers), missing `await` on products list handler, `WebApplicationBuilderExtensions` partial-block duplication, `MapModules`/health-checks sequencing between Tasks 7–9, UnitTests → Infrastructure project reference for `JwtManagerShould`. +- Known deliberate deviations from spec (documented in Global Constraints): idempotent PUT, no client-visible xmin 409 test, CorrelationId enricher dropped. +- Type consistency verified against canonical type map: service signatures ↔ module handlers ↔ exception codes ↔ test assertions. + diff --git a/docs/superpowers/plans/2026-08-23-modular-monolith.md b/docs/superpowers/plans/2026-08-23-modular-monolith.md new file mode 100644 index 0000000..86f81f1 --- /dev/null +++ b/docs/superpowers/plans/2026-08-23-modular-monolith.md @@ -0,0 +1,3002 @@ +# Modular Monolith Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Restructure PriceNegotiationApp into a modular monolith with one DbContext and one Postgres schema per bounded context (Identity, Catalog, Negotiations), compiler-enforced module boundaries, and zero behavioral drift on the HTTP contract (except one pinned change: product deletion is no longer blocked by negotiation history). + +**Architecture:** Three vertical module projects own their domain, features, endpoints, and persistence. A thin `AppHost` composes them and wires the single cross-module seam (`IProductPriceProvider`) as a consumer-owned port / host-side adapter. `BuildingBlocks` holds ~12 stable shared types. The data-layer cutover happens *before* code moves, so EF-tooling risk never shares a phase with structural-move risk. + +**Tech Stack:** .NET 10 / C# latest, ASP.NET Core minimal APIs, EF Core 10 + Npgsql (PostgreSQL 17), Vogen, Serilog, OpenTelemetry, xUnit v3 + Testcontainers. + +**Spec:** `docs/superpowers/specs/2026-08-23-modular-monolith-design.md` + +## Global Constraints + +- Target framework `net10.0`; `TreatWarningsAsErrors=true`, `EnforceCodeStyleInBuild=true` come from `Directory.Build.props` — never weaken them. +- Central Package Management: package `Version`s appear only in `Directory.Packages.props`. New projects reference packages without versions; every needed version already exists. +- PostgreSQL: every context uses `.UseSnakeCaseNamingConvention()`; default schemas: `identity`, `catalog`, `negotiations`. +- Distinct migration-history tables, kept in the default `public` schema (avoids schema-existence ordering problems on virgin databases): `__EFMigrationsHistory_Identity`, `__EFMigrationsHistory_Catalog`, `__EFMigrationsHistory_Negotiations`. +- **No cross-schema foreign keys.** Logical references are plain Guid columns. +- HTTP routes, status codes (400/401/403/404/409/422/499), ProblemDetails `code` values, and JSON payload shapes are **frozen** — the existing integration suite is the regression gate. +- One pinned semantic change (spec §6): deleting a product succeeds even when negotiations reference it; those negotiations survive on their price snapshots. +- Integration tests need a running Docker daemon (Testcontainers). Check with `docker info` first. +- EF tooling: if `dotnet ef --version` fails, run `dotnet tool install --global dotnet-ef`. +- All commands run from repo root. Conventional commits, one commit per task unless a step says otherwise. + +## Namespace Migration Map (apply mechanically wherever a file moves) + +| Old | New | +|---|---| +| `PriceNegotiationApp.Application.Common` (CallerContext, PageQuery, PagedResult, ProductQuery) | `PriceNegotiationApp.BuildingBlocks` | +| `PriceNegotiationApp.Application.Common.ErrorCodes` (generic members only) | `PriceNegotiationApp.BuildingBlocks` (`ErrorCodes`) | +| `PriceNegotiationApp.Application.Common.UserRoles` | `PriceNegotiationApp.Modules.Identity.Public` (`UserRoles`) | +| `PriceNegotiationApp.Application.Exceptions.*` | `PriceNegotiationApp.BuildingBlocks` | +| `PriceNegotiationApp.Domain.Exceptions.DomainException` | `PriceNegotiationApp.BuildingBlocks` | +| `PriceNegotiationApp.Domain.Models` (Negotiation, Customer, enums) | `PriceNegotiationApp.Modules.Negotiations.Domain` | +| `PriceNegotiationApp.Domain.Policy.*` | `PriceNegotiationApp.Modules.Negotiations.Domain` | +| `PriceNegotiationApp.Domain.ValueObjects.Ids.NegotiationId/CustomerId` | `PriceNegotiationApp.Modules.Negotiations.Domain` | +| `PriceNegotiationApp.Infrastructure.Auth.*` | `PriceNegotiationApp.Modules.Identity.Auth` | +| `PriceNegotiationApp.Infrastructure.Identity.ApplicationUser` | `PriceNegotiationApp.Modules.Identity.Persistence` | +| `PriceNegotiationApp.Infrastructure.Seeding.SeedingOptions` | `PriceNegotiationApp.Modules.Identity.Seeding` | + +Deleted outright (no migration path): `Entity`, `IBusinessRule`, `Domain/Models/Rules/*`, `IUnitOfWork`+`UnitOfWork`, `IProductRepository`+impl, `INegotiationRepository`+impl, `ICustomerRepository`+impl, `IUserAccountStore`+`IdentityAccountStore`, `RegistrationOutcome`, `SignInResultKind`, `IAuthService`/`IProductService`/`INegotiationService`, projects `Application`, `Domain`, `Infrastructure`, `Api`. + +## File Structure (end state) + +``` +src/ + PriceNegotiationApp.AppHost/ + Program.cs + GlobalExceptionHandler.cs + Extensions/{PipelineExtensions,WebApplicationBuilderExtensions,JwtSettings,RateLimitingOptions}.cs + Composition/{MigrationHostedService,CatalogToNegotiations}.cs + appsettings.json, Properties/launchSettings.json + PriceNegotiationApp.BuildingBlocks/ + CallerContext.cs PageQuery.cs PagedResult.cs ProductQuery.cs + ErrorCodes.cs Policies.cs Exceptions.cs EndpointConventionExtensions.cs + DbConnections.cs CallerContextExtensions.cs + PriceNegotiationApp.Modules.Catalog/ + CatalogModule.cs + Domain/Product.cs Domain/Price.cs + Persistence/{CatalogDbContext,ProductConfiguration,DesignTimeDbContextFactory}.cs + Persistence/Migrations/* + Seeding/{CatalogSeedingHostedService,CatalogSeedingOptions}.cs + Features/Products/{List,Get,Create,Update,Delete}.cs + Features/Products/ProductModels.cs (requests + ProductResponse) + PriceNegotiationApp.Modules.Negotiations/ + NegotiationsModule.cs + Ports/IProductPriceProvider.cs (+ ProductSnapshot record) + Domain/{Negotiation,Customer,NegotiationStatus,NegotiationOutcome, + NegotiationId,CustomerId,Price,INegotiationPolicy, + DefaultNegotiationPolicy,NegotiationExceptions}.cs + Persistence/{NegotiationsDbContext,DesignTimeDbContextFactory}.cs + Persistence/Configurations/{CustomerConfiguration,NegotiationConfiguration}.cs + Persistence/Migrations/* + Features/Negotiations/{Create,ListMine,List,Get,CounterPropose, + Accept,Decline,Withdraw,NegotiationAccess}.cs + Features/Negotiations/NegotiationModels.cs (requests, responses, NegotiationErrorCodes) + PriceNegotiationApp.Modules.Identity/ + IdentityModule.cs + Public/UserRoles.cs Public/IdentityErrorCodes.cs + Auth/{JwtManager,JwtOptions,JwtOptionsValidator}.cs + Persistence/{IdentityModuleDbContext,ApplicationUser,DesignTimeDbContextFactory}.cs + Persistence/Migrations/* + Seeding/{IdentitySeedingHostedService,IdentitySeedingOptions}.cs + Features/Auth/{Register,Login,Me,AuthModels}.cs +docs/sql/legacy-data-migration.sql +tests/ + PriceNegotiationApp.Modules.Identity.Tests/ + PriceNegotiationApp.Modules.Catalog.Tests/ + PriceNegotiationApp.Modules.Negotiations.Tests/ + PriceNegotiationApp.IntegrationTests/ (harness unchanged; +2 cases) +``` + +--- + +### Task 0: Repository hygiene + +**Files:** +- Delete (untracked junk): root-level `PriceNegotiationApp.{Api,Application,Contracts,Domain,Infrastructure,Presentation,SharedKernel}/` (contain only `bin/`+`obj/`), `src/logs/` +- Modify: `.gitignore` + +**Interfaces:** +- Consumes: nothing +- Produces: clean working tree so later `git add -A` steps are unambiguous + +- [ ] **Step 1: Delete stale artifacts** + +```powershell +Remove-Item -Recurse -Force ` + PriceNegotiationApp.Api, PriceNegotiationApp.Application, PriceNegotiationApp.Contracts, ` + PriceNegotiationApp.Domain, PriceNegotiationApp.Infrastructure, PriceNegotiationApp.Presentation, ` + PriceNegotiationApp.SharedKernel, src/logs -ErrorAction SilentlyContinue +``` + +- [ ] **Step 2: Ignore local env + logs** + +Append to `.gitignore`: + +```gitignore +.env +logs/ +``` + +Untrack the placeholder copy but keep `.env.example`: `git rm --cached .env` + +- [ ] **Step 3: Validate** + +```powershell +dotnet build PriceNegotiationApp.slnx +``` +Must succeed — the slnx points at `src/` only, nothing referenced the deleted folders. + +- [ ] **Step 4: Commit** + +```bash +git add -A && git commit -m "chore: remove stale build artifacts, ignore .env and logs" +``` + +--- + +### Task 1: Split AppDbContext into three additive contexts + +Legacy `AppDbContext` keeps serving everything. The three new contexts are registered, migrated by tooling, and health-checked, but unused by repositories yet. Purely additive → zero regression risk. + +**Files:** +- Create: `src/PriceNegotiationApp.Infrastructure/Persistence/IdentityModuleDbContext.cs` +- Create: `src/PriceNegotiationApp.Infrastructure/Persistence/CatalogDbContext.cs` +- Create: `src/PriceNegotiationApp.Infrastructure/Persistence/NegotiationsDbContext.cs` +- Create: `src/PriceNegotiationApp.Infrastructure/Data/DesignTimeFactories.cs` (replaces `Data/DesignTimeDbContextFactory.cs`) +- Modify: `src/PriceNegotiationApp.Infrastructure/DependencyInjection.cs` +- Modify: `src/PriceNegotiationApp.Api/Extensions/WebApplicationBuilderExtensions.cs` (health checks) + +**Interfaces:** +- Produces (exact types later tasks rely on): + - `CatalogDbContext(DbContextOptions)` with `DbSet Products` + - `NegotiationsDbContext(DbContextOptions)` with `DbSet Customers`, `DbSet Negotiations` + - `IdentityModuleDbContext(DbContextOptions) : IdentityDbContext, Guid>` + - Default schemas set inside each context's `OnModelCreating` via `HasDefaultSchema` (single source of truth); history tables per Global Constraints. + +- [ ] **Step 1: Tooling check** + +```powershell +dotnet ef --version # if missing: dotnet tool install --global dotnet-ef +``` + +- [ ] **Step 2: Create the three contexts** + +`IdentityModuleDbContext.cs`: + +```csharp +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using PriceNegotiationApp.Infrastructure.Identity; + +namespace PriceNegotiationApp.Infrastructure.Persistence; + +public sealed class IdentityModuleDbContext(DbContextOptions options) + : IdentityDbContext, Guid>(options) +{ + protected override void OnModelCreating(ModelBuilder builder) + { + base.OnModelCreating(builder); + builder.HasDefaultSchema("identity"); + // Pin snake_case names so Identity stores never depend on naming conventions. + builder.Entity().ToTable("users"); + builder.Entity>().ToTable("roles"); + builder.Entity>().ToTable("user_roles"); + builder.Entity>().ToTable("user_claims"); + builder.Entity>().ToTable("role_claims"); + builder.Entity>().ToTable("user_logins"); + builder.Entity>().ToTable("user_tokens"); + } +} +``` + +`CatalogDbContext.cs`: + +```csharp +using Microsoft.EntityFrameworkCore; +using PriceNegotiationApp.Domain.Models; +using PriceNegotiationApp.Infrastructure.Persistence.DbEntityConfigurations; + +namespace PriceNegotiationApp.Infrastructure.Persistence; + +public sealed class CatalogDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Products => Set(); + + protected override void OnModelCreating(ModelBuilder builder) + { + base.OnModelCreating(builder); + builder.HasDefaultSchema("catalog"); + // Explicit registration: configurations are owned per context, never assembly-scanned. + builder.ApplyConfiguration(new ProductConfiguration()); + } +} +``` + +`NegotiationsDbContext.cs`: + +```csharp +using Microsoft.EntityFrameworkCore; +using PriceNegotiationApp.Domain.Models; +using PriceNegotiationApp.Infrastructure.Persistence.DbEntityConfigurations; + +namespace PriceNegotiationApp.Infrastructure.Persistence; + +public sealed class NegotiationsDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Customers => Set(); + + public DbSet Negotiations => Set(); + + protected override void OnModelCreating(ModelBuilder builder) + { + base.OnModelCreating(builder); + builder.HasDefaultSchema("negotiations"); + builder.ApplyConfiguration(new CustomerConfiguration()); + builder.ApplyConfiguration(new NegotiationConfiguration()); + } +} +``` + +- [ ] **Step 3: Register contexts** + +In `DependencyInjection.AddInfrastructure`, keep the existing `AddDbContext` block and add below it: + +```csharp +var connectionString = configuration["Database:ConnectionString"]; +services.AddDbContext(options => options + .UseNpgsql(connectionString, npgsql => npgsql.MigrationsHistoryTable("__EFMigrationsHistory_Identity")) + .UseSnakeCaseNamingConvention()); +services.AddDbContext(options => options + .UseNpgsql(connectionString, npgsql => npgsql.MigrationsHistoryTable("__EFMigrationsHistory_Catalog")) + .UseSnakeCaseNamingConvention()); +services.AddDbContext(options => options + .UseNpgsql(connectionString, npgsql => npgsql.MigrationsHistoryTable("__EFMigrationsHistory_Negotiations")) + .UseSnakeCaseNamingConvention()); +``` + +- [ ] **Step 4: Health checks report all four (transitional)** + +In `WebApplicationBuilderExtensions.AddApiServices` replace the single DB check line with: + +```csharp +builder.Services.AddHealthChecks() + .AddCheck("self", () => HealthCheckResult.Healthy(), tags: ["live"]) + .AddDbContextCheck("database-legacy", tags: ["ready"]) + .AddDbContextCheck("database-identity", tags: ["ready"]) + .AddDbContextCheck("database-catalog", tags: ["ready"]) + .AddDbContextCheck("database-negotiations", tags: ["ready"]); +``` + +(The legacy check disappears in Task 2.) + +- [ ] **Step 5: Replace the design-time factory** + +Delete `Data/DesignTimeDbContextFactory.cs`; create `Data/DesignTimeFactories.cs`: + +```csharp +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace PriceNegotiationApp.Infrastructure.Data; + +public sealed class IdentityDesignTimeFactory : IDesignTimeDbContextFactory +{ + public IdentityModuleDbContext CreateDbContext(string[] args) => + new(new DbContextOptionsBuilder() + .UseNpgsql(DesignTime.ConnectionString, + npgsql => npgsql.MigrationsHistoryTable("__EFMigrationsHistory_Identity")) + .UseSnakeCaseNamingConvention() + .Options); +} + +public sealed class CatalogDesignTimeFactory : IDesignTimeDbContextFactory +{ + public CatalogDbContext CreateDbContext(string[] args) => + new(new DbContextOptionsBuilder() + .UseNpgsql(DesignTime.ConnectionString, + npgsql => npgsql.MigrationsHistoryTable("__EFMigrationsHistory_Catalog")) + .UseSnakeCaseNamingConvention() + .Options); +} + +public sealed class NegotiationsDesignTimeFactory : IDesignTimeDbContextFactory +{ + public NegotiationsDbContext CreateDbContext(string[] args) => + new(new DbContextOptionsBuilder() + .UseNpgsql(DesignTime.ConnectionString, + npgsql => npgsql.MigrationsHistoryTable("__EFMigrationsHistory_Negotiations")) + .UseSnakeCaseNamingConvention() + .Options); +} + +internal static class DesignTime +{ + internal const string ConnectionString = + "Host=localhost;Port=5432;Database=pricenego_design;Username=postgres;Password=postgres"; +} +``` + +Note: factories intentionally omit connection-string env overrides — schema diffing does not need a live database. + +- [ ] **Step 6: Generate initial migrations for the three new contexts** + +```powershell +dotnet ef migrations add Initial --context IdentityModuleDbContext -p src/PriceNegotiationApp.Infrastructure -o Persistence/Migrations/Identity +dotnet ef migrations add Initial --context CatalogDbContext -p src/PriceNegotiationApp.Infrastructure -o Persistence/Migrations/Catalog +dotnet ef migrations add Initial --context NegotiationsDbContext -p src/PriceNegotiationApp.Infrastructure -o Persistence/Migrations/Negotiations +``` + +Verify in the generated files: identity tables carry schema `identity`; products → `catalog.products`; negotiations/customers → `negotiations.*`; partial index filter `status = 1` present. The negotiations migration WILL contain an FK to catalog.products at this point (current configuration still declares it) — expected; removed and regenerated in Task 2. + +- [ ] **Step 7: Validate** + +```powershell +dotnet build PriceNegotiationApp.slnx +dotnet test tests/PriceNegotiationApp.UnitTests +dotnet test tests/PriceNegotiationApp.IntegrationTests +``` + +All green: new contexts registered, migrated on startup (nothing migrates them yet except tooling verification — startup migration wiring lands in Task 2), health-checked. + +- [ ] **Step 8: Commit** + +```bash +git add -A && git commit -m "feat(persistence): add identity/catalog/negotiations contexts with schemas" +``` + +--- + +### Task 2: Cutover — repositories and hosting move to the new contexts; AppDbContext retired + +The contained-risk gate: all reads/writes flow through the three contexts, hosting splits into a migrator + two seeders, the legacy context and its migrations are deleted, the cross-schema FK disappears (pinned semantic change), and the new integration tests land. + +**Files:** +- Modify: `src/PriceNegotiationApp.Infrastructure/Persistence/Repositories/{ProductRepository,NegotiationRepository,CustomerRepository,UnitOfWork}.cs` +- Modify: `src/PriceNegotiationApp.Infrastructure/Persistence/DbEntityConfigurations/NegotiationConfiguration.cs` +- Create: `src/PriceNegotiationApp.Infrastructure/Hosting/MigrationHostedService.cs` +- Create: `src/PriceNegotiationApp.Infrastructure/Seeding/IdentitySeedingHostedService.cs` +- Create: `src/PriceNegotiationApp.Infrastructure/Seeding/CatalogSeedingHostedService.cs` +- Delete: `src/PriceNegotiationApp.Infrastructure/Seeding/SeedingHostedService.cs`, `src/PriceNegotiationApp.Infrastructure/Data/AppDbContext.cs` (+ `Data/Migrations/*` single-context set) +- Modify: `src/PriceNegotiationApp.Infrastructure/DependencyInjection.cs` +- Modify: `src/PriceNegotiationApp.Api/Extensions/WebApplicationBuilderExtensions.cs` (health checks) +- Create: `docs/sql/legacy-data-migration.sql` +- Test: `tests/PriceNegotiationApp.IntegrationTests/NegotiationsShould.cs` (add 2 cases) + +**Interfaces:** +- Consumes: contexts from Task 1. +- Produces: + - `MigrationHostedService : IHostedService` — applies Identity → Catalog → Negotiations migrations in order, fail-fast. + - `IdentitySeedingHostedService : IHostedService` — roles + admin/staff users from `SeedingOptions`. + - `CatalogSeedingHostedService : IHostedService` — sample products when `SeedSampleProducts` is true. + - `UnitOfWork(IEnumerable)` transitional implementation of `IUnitOfWork` (dies with legacy projects in Task 7). + +- [ ] **Step 1: Rewire repositories to their contexts** + +`ProductRepository`: change constructor to `ProductRepository(CatalogDbContext db)`; body unchanged otherwise (`db.Products...`). Same using set minus nothing. + +`NegotiationRepository`: constructor becomes `NegotiationRepository(NegotiationsDbContext db, ICustomerRepository customers)`; body unchanged. + +`CustomerRepository`: constructor becomes `CustomerRepository(NegotiationsDbContext db, IUnitOfWork uow)`; body unchanged. + +- [ ] **Step 2: Drop the cross-schema FK** + +In `NegotiationConfiguration`, delete this line: + +```csharp +builder.HasOne().WithMany().HasForeignKey(n => n.ProductId).OnDelete(DeleteBehavior.Restrict); +``` + +Replace it with a comment documenting the decision: + +```csharp +// No FK to catalog.products by design (separate schemas/modules). Product existence is +// validated at negotiation creation; negotiations survive product deletion on snapshots. +``` + +- [ ] **Step 3: Transitional UnitOfWork saves all dirty contexts** + +Rewrite `UnitOfWork.cs`: + +```csharp +using Microsoft.EntityFrameworkCore; +using PriceNegotiationApp.Application.Abstractions; +using PriceNegotiationApp.Application.Common; +using PriceNegotiationApp.Application.Exceptions; + +namespace PriceNegotiationApp.Infrastructure.Persistence.Repositories; + +/// Transitional: saves every registered context that has pending changes. +/// Removed together with the legacy projects once modules own their save points. +public sealed class UnitOfWork(IEnumerable contexts) : IUnitOfWork +{ + public async Task SaveChangesAsync(CancellationToken ct) + { + var saved = 0; + foreach (var db in contexts) + { + if (!db.ChangeTracker.HasChanges()) + { + continue; + } + + try + { + saved += await db.SaveChangesAsync(ct); + } + catch (DbUpdateConcurrencyException) + { + throw new ConflictException(ErrorCodes.ConcurrencyConflict, + "The resource was modified concurrently. Reload and retry."); + } + } + + return saved; + } +} +``` + +Register it in `DependencyInjection` (replace the existing scoped registration): + +```csharp +services.AddScoped(); +services.AddScoped(sp => sp.GetRequiredService()); +services.AddScoped(sp => sp.GetRequiredService()); +services.AddScoped(sp => sp.GetRequiredService()); +``` + +(The `IEnumerable` registration picks up all three `AddScoped` factories. `AppDbContext` dies in this task — see Step 6 — so drop that first `AddScoped` line there.) + +- [ ] **Step 4: Migration + seeding hosted services** + +Create `src/PriceNegotiationApp.Infrastructure/Hosting/MigrationHostedService.cs`: + +```csharp +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using PriceNegotiationApp.Infrastructure.Persistence; + +namespace PriceNegotiationApp.Infrastructure.Hosting; + +public sealed class MigrationHostedService(IServiceScopeFactory scopeFactory, ILogger logger) + : IHostedService +{ + public async Task StartAsync(CancellationToken cancellationToken) + { + await using var scope = scopeFactory.CreateAsyncScope(); + await MigrateAsync(scope, cancellationToken); + await MigrateAsync(scope, cancellationToken); + await MigrateAsync(scope, cancellationToken); + logger.LogInformation("Module databases migrated."); + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + private static async Task MigrateAsync(IServiceScope scope, CancellationToken ct) where T : DbContext + { + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.MigrateAsync(ct); + } +} +``` + +Create `Seeding/IdentitySeedingHostedService.cs` (logic carried verbatim from old `SeedingHostedService` minus migration and products): + +```csharp +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using PriceNegotiationApp.Modules.Identity.Public; +using PriceNegotiationApp.Infrastructure.Identity; +using PriceNegotiationApp.Infrastructure.Seeding; + +namespace PriceNegotiationApp.Infrastructure.Seeding; + +public sealed class IdentitySeedingHostedService( + IServiceScopeFactory scopeFactory, + IOptions options, + ILogger logger) : IHostedService +{ + public async Task StartAsync(CancellationToken cancellationToken) + { + await using var scope = scopeFactory.CreateAsyncScope(); + var roleManager = scope.ServiceProvider.GetRequiredService>>(); + foreach (var role in new[] { UserRoles.Admin, UserRoles.Staff, UserRoles.Customer }) + { + if (!await roleManager.RoleExistsAsync(role)) + { + await roleManager.CreateAsync(new IdentityRole(role)); + } + } + + var userManager = scope.ServiceProvider.GetRequiredService>(); + await EnsureUserAsync(userManager, options.Value.AdminEmail, options.Value.AdminPassword, UserRoles.Admin); + await EnsureUserAsync(userManager, options.Value.StaffEmail, options.Value.StaffPassword, UserRoles.Staff); + logger.LogInformation("Identity seed data ensured."); + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + private static async Task EnsureUserAsync( + UserManager userManager, string email, string password, string role) + { + if (string.IsNullOrWhiteSpace(password) + || await userManager.FindByEmailAsync(email) is not null) + { + return; + } + + var user = new ApplicationUser { UserName = email, Email = email }; + var result = await userManager.CreateAsync(user, password); + if (result.Succeeded) + { + await userManager.AddToRoleAsync(user, role); + } + } +} +``` + +Note: `PriceNegotiationApp.Modules.Identity.Public` does not exist yet — until Task 6, keep `using PriceNegotiationApp.Application.Common;` for `UserRoles` instead, and swap the using in Task 6. + +Create `Seeding/CatalogSeedingHostedService.cs` (product block extracted verbatim): + +```csharp +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using PriceNegotiationApp.Domain.Models; +using PriceNegotiationApp.Infrastructure.Persistence; +using PriceNegotiationApp.Infrastructure.Seeding; + +namespace PriceNegotiationApp.Infrastructure.Seeding; + +public sealed class CatalogSeedingHostedService( + IServiceScopeFactory scopeFactory, + IOptions options, + ILogger logger) : IHostedService +{ + public async Task StartAsync(CancellationToken cancellationToken) + { + if (!options.Value.SeedSampleProducts) + { + return; + } + + await using var scope = scopeFactory.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + if (!await db.Products.AnyAsync(cancellationToken)) + { + db.Products.AddRange( + Product.Create("Mechanical Keyboard", 249.00m), + Product.Create("Wireless Mouse", 79.90m), + Product.Create("USB-C Docking Station", 189.50m)); + await db.SaveChangesAsync(cancellationToken); + } + + logger.LogInformation("Catalog seed data ensured."); + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} +``` + +Update `DependencyInjection`: remove `services.AddHostedService();` and add, **in this exact order** (hosted services start sequentially in registration order): + +```csharp +services.AddHostedService(); +services.AddHostedService(); +services.AddHostedService(); +``` + +Also delete from DI the `AddDbContext` block and the `AddScoped(sp => ...AppDbContext...)` line from Step 3. + +Health checks: replace the four checks with three (drop `database-legacy`). + +- [ ] **Step 5: Regenerate the Negotiations migration (FK removal)** + +```powershell +dotnet ef migrations add DropProductForeignKey --context NegotiationsDbContext -p src/PriceNegotiationApp.Infrastructure -o Persistence/Migrations/Negotiations +``` + +Verify the new migration contains exactly one `DropForeignKey` operation and no others. + +- [ ] **Step 6: Delete the legacy context and its migration set** + +```powershell +Remove-Item -Recurse -Force src/PriceNegotiationApp.Infrastructure/Persistence/AppDbContext.cs, ` + src/PriceNegotiationApp.Infrastructure/Data/Migrations +git add -A +``` + +If the build now fails anywhere referencing `AppDbContext` (should be none after Steps 1–4), fix those references to use the module contexts. + +- [ ] **Step 7: Legacy-data SQL script** + +Create `docs/sql/legacy-data-migration.sql` — run manually against a pre-refactor database before starting the new app version: + +```sql +-- One-time migration: pre-modular schema (public.*) -> module schemas. +-- Run BEFORE starting the new application version against an existing database. +-- Identity columns are identical between layouts; only table locations change. + +BEGIN; + +-- Catalog +INSERT INTO catalog.products (id, name, price, "Version") +SELECT id, name, price, xmin FROM public.products +ON CONFLICT DO NOTHING; + +-- Negotiations +INSERT INTO negotiations.customers (id, identity_user_id) +SELECT id, identity_user_id FROM public.customers +ON CONFLICT DO NOTHING; + +INSERT INTO negotiations.negotiations + (id, product_id, customer_id, base_price, current_offer, status, + proposals_used, created_at_utc, last_proposal_at_utc, decided_at_utc, "Version") +SELECT id, product_id, customer_id, base_price, current_offer, status, + proposals_used, created_at_utc, last_proposal_at_utc, decided_at_utc, xmin +FROM public.negotiations +ON CONFLICT DO NOTHING; + +COMMIT; +``` + +Column-name caveat: verify actual snake_case column names against the old `20260823155421_Initial.cs` migration before running (adjust `"Version"` — xmin is selected as the row version source; EF maps `uint Version` to xmin so the physical column IS xmin and must not be inserted directly — if the old table has no separate version column, drop the `xmin AS "Version"` projection and the `"Version"` target column entirely). + +Create `docs/sql/cleanup-legacy-tables.sql` (run after verifying cutover on a persistent environment): + +```sql +DROP TABLE IF EXISTS public.negotiations CASCADE; +DROP TABLE IF EXISTS public.customers CASCADE; +DROP TABLE IF EXISTS public.products CASCADE; +DROP TABLE IF EXISTS public.__efmigrations_history CASCADE; +``` + +- [ ] **Step 8: Integration tests pinning new behavior** + +Append inside `tests/PriceNegotiationApp.IntegrationTests/NegotiationsShould.cs` class: + +```csharp +[Fact] +public async Task SurviveWhenReferencedProductIsDeleted() +{ + var staff = await fixture.LoginAsStaffAsync(); + var create = await staff.Client.PostAsJsonAsync("/api/v1/products", + new { name = "Doomed Product", price = 100m }); + var product = await create.Content.ReadFromJsonAsync(); + var customer = await fixture.CreateUserAsync(); + + var negotiation = await customer.Client.PostAsJsonAsync("/api/v1/negotiations", + new { productId = product!.Id, proposedPrice = 90m }); + negotiation.EnsureSuccessStatusCode(); + + var delete = await staff.Client.DeleteAsync($"/api/v1/products/{product.Id}"); + delete.StatusCode.ShouldBe(HttpStatusCode.NoContent); + + var mine = await customer.Client.GetAsync("/api/v1/negotiations/mine?page=1&pageSize=10"); + mine.EnsureSuccessStatusCode(); + var page = await mine.Content.ReadFromJsonAsync(); + page!.TotalCount.ShouldBe(1); +} + +[Fact] +public async Task ReadyEndpointReportsAllModuleSchemas() +{ + var response = await fixture.Anonymous.GetAsync("/health/ready"); + response.EnsureSuccessStatusCode(); +} +``` + +Add support record (new file `Support/PagedNegotiations.cs`): + +```csharp +namespace PriceNegotiationApp.IntegrationTests.Support; + +public sealed record PagedNegotiations( + IReadOnlyList Items, int Page, int PageSize, long TotalCount); + +public sealed record NegotiationDto(Guid Id, Guid ProductId, decimal BasePrice, decimal CurrentOffer); +``` + +Check `ProductsShould.cs` for an existing test asserting that deleting a referenced product FAILS — if present, invert its expectation to match the pinned change (204 now succeeds); if none exists, the new case above covers it. + +- [ ] **Step 9: Validate** + +```powershell +dotnet build PriceNegotiationApp.slnx +dotnet test tests/PriceNegotiationApp.UnitTests +dotnet test tests/PriceNegotiationApp.IntegrationTests +``` + +Full suite green including the two new cases. This proves end-to-end: three schemas migrate, seeders populate identity+catalog, all endpoints serve off the split contexts. + +- [ ] **Step 10: Commit** + +```bash +git add -A && git commit -m "feat(persistence): cut over to per-module contexts, retire AppDbContext" +``` + +--- + +### Task 3: Extract BuildingBlocks + +New zero-dependency shared project; all existing projects re-point to it. Behavior unchanged. + +**Files:** +- Create: `src/PriceNegotiationApp.BuildingBlocks/PriceNegotiationApp.BuildingBlocks.csproj` + the 8 source files below +- Modify: every project's `.csproj` (add `ProjectReference` to BuildingBlocks where it consumes BB types) and every file whose `using` changes per the Namespace Migration Map +- Delete after sweep: `src/PriceNegotiationApp.Application/Common/*`, `src/PriceNegotiationApp.Application/Exceptions/*`, `src/PriceNegotiationApp.Domain/Abstractions/{Entity,IBusinessRule}.cs`, `src/PriceNegotiationApp.Domain/Exceptions/DomainException.cs` + +**Interfaces:** +- Produces (namespace `PriceNegotiationApp.BuildingBlocks` for ALL of these): + - `CallerContext(Guid UserId, string Email, IReadOnlySet Roles)` + `.Anonymous`, `.IsAuthenticated`, `.IsInRole(string)` + - `PageQuery(int Page, int PageSize)` + `.SafePage`, `.SafePageSize`, `.Skip` + - `PagedResult(IReadOnlyList Items, int Page, int PageSize, long TotalCount)` + - `ProductQuery(string? Search, decimal? MinPrice, decimal? MaxPrice, string? SortBy, bool SortDesc, int Page, int PageSize)` + - `ErrorCodes`: `Forbidden="forbidden"`, `ConcurrencyConflict="conflict"`, `ValidationFailed="validation_failed"`, `DomainRuleViolated="domain_rule_violated"`, `InternalError="internal_error"` + - `Policies`: `AuthRateLimitPolicy="auth"`, `ShortCachePolicy="short"` + - Exceptions: `NotFoundException(string entityName, object key)` with `.Code => $"{entity}_not_found"`; `ConflictException(string code, string message)`; `InvalidRequestException(string code, string message)`; `UnauthorizedException(string code, string message)`; `ForbiddenAccessException()` — bodies copied verbatim from current `Application/Exceptions/*` + - `DomainException(string message)` (verbatim from Domain) + - `RequireRoles(this TBuilder, params string[] roles)` (verbatim from Api `EndpointConventionExtensions`) + +- [ ] **Step 1: Project file** + +```xml + + +``` + +(No packages. ImplicitUsings/nullable come from Directory.Build.props.) + +- [ ] **Step 2: Source files** + +Move with namespace change only (bodies verbatim): `CallerContext.cs`, `PageQuery.cs`, `PagedResult.cs`, `ProductQuery.cs`, and the five exception files listed above, plus `DomainException.cs`. Then create: + +`ErrorCodes.cs`: + +```csharp +namespace PriceNegotiationApp.BuildingBlocks; + +public static class ErrorCodes +{ + public const string Forbidden = "forbidden"; + public const string ConcurrencyConflict = "conflict"; + public const string ValidationFailed = "validation_failed"; + public const string DomainRuleViolated = "domain_rule_violated"; + public const string InternalError = "internal_error"; +} +``` + +(The feature-specific members — `ProductNotFound` … `RegistrationInvalid` — are NOT carried here. They move into modules in Tasks 4–6 as `NegotiationErrorCodes` / `IdentityErrorCodes`. Until those tasks, keep a temporary copy of the removed members in each consuming file as `const string` locals if compilation requires.) + +`Policies.cs`: + +```csharp +namespace PriceNegotiationApp.BuildingBlocks; + +/// Shared policy names so host registrations and module endpoint annotations agree. +public static class Policies +{ + public const string AuthRateLimitPolicy = "auth"; + + public const string ShortCachePolicy = "short"; +} +``` + +`EndpointConventionExtensions.cs`: + +```csharp +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Builder; + +namespace PriceNegotiationApp.BuildingBlocks; + +public static class EndpointConventionExtensions +{ + public static TBuilder RequireRoles(this TBuilder builder, params string[] roles) + where TBuilder : IEndpointConventionBuilder => + builder.RequireAuthorization(new AuthorizeAttribute { Roles = string.Join(',', roles) }); +} +``` + +Because of the ASP.NET types, the csproj needs one line inside ``: + +```xml + + + +``` + +- [ ] **Step 3: Re-point consumers** + +1. Add `` to Application, Infrastructure, Api csprojs. +2. Global find/replace across `src/`: + - `using PriceNegotiationApp.Application.Common;` → `using PriceNegotiationApp.BuildingBlocks;` + - `using PriceNegotiationApp.Application.Exceptions;` → `using PriceNegotiationApp.BuildingBlocks;` + - `using PriceNegotiationApp.Domain.Exceptions;` → add `using PriceNegotiationApp.BuildingBlocks;` (keep the old using only in files still referencing Closed/Proposal exceptions — NegotiationsModule handler and GlobalExceptionHandler). +3. In `WebApplicationBuilderExtensions` replace the two const declarations (`AuthRateLimitPolicy`, `ShortCachePolicy`) and their usages with `BuildingBlocks.Policies.*` (delete the consts; update `AuthModule`, `ProductsModule` references). +4. Delete moved originals from Application/Domain. +5. Feature-specific error codes: `NegotiationsService` references `ErrorCodes.NegotiationAlreadyOpen` etc. Add a temporary internal static class in the Application project: + +```csharp +namespace PriceNegotiationApp.Application.Common; + +internal static class LegacyErrorCodes +{ + public const string NegotiationAlreadyOpen = "negotiation_already_open"; + public const string NoProposalsRemaining = "no_proposals_remaining"; + public const string ProposalExceedsLimit = "proposal_exceeds_limit"; + public const string NegotiationClosed = "negotiation_closed"; + public const string EmailAlreadyRegistered = "email_already_registered"; + public const string InvalidCredentials = "invalid_credentials"; + public const string AccountLocked = "account_locked"; + public const string RegistrationInvalid = "registration_invalid"; +} +``` + +and switch the affected call sites to `LegacyErrorCodes.*`. These constants land in their real homes in Tasks 4–6, then this class dies. + +- [ ] **Step 4: Validate** + +```powershell +dotnet build PriceNegotiationApp.slnx +dotnet test tests/PriceNegotiationApp.UnitTests +dotnet test tests/PriceNegotiationApp.IntegrationTests +``` + +Green. Note: `GlobalExceptionHandler` keeps compiling because its remaining domain references (`ClosedNegotiationException`, `ProposalExceedsLimitException`) still live under the old Domain namespace until Task 4 moves them. + +- [ ] **Step 5: Commit** + +```bash +git add -A && git commit -m "refactor: extract BuildingBlocks shared kernel" +``` + +--- + +### Task 3a: BuildingBlocks additions needed by modules + +Small follow-up so module tasks can consume two more shared pieces without re-opening Task 3's scope. + +**Files:** +- Modify: `Directory.Packages.props` (one version entry), `src/PriceNegotiationApp.BuildingBlocks/PriceNegotiationApp.BuildingBlocks.csproj` +- Create: `src/PriceNegotiationApp.BuildingBlocks/DbConnections.cs` +- Move: `src/PriceNegotiationApp.Api/Extensions/ClaimsPrincipalExtensions.cs` → `src/PriceNegotiationApp.BuildingBlocks/CallerContextExtensions.cs` + +**Interfaces:** +- Produces: + - `DbConnections.Resolve(IConfiguration, string moduleName): string` — returns `Database:Modules:{name}:ConnectionString` when set, else `Database:ConnectionString`, else throws `InvalidOperationException`. + - `CallerContextExtensions.ToCallerContext(this ClaimsPrincipal)` in namespace `PriceNegotiationApp.BuildingBlocks` (body verbatim). + +- [ ] **Step 1: CPM entry + package reference** + +In `Directory.Packages.props`, alongside the other 10.0.x entries add: + +```xml + +``` + +In the BuildingBlocks csproj add: + +```xml + + + + +``` + +(`DependencyInjection.Abstractions` is already in CPM; it is included because module classes call `IServiceCollection` extensions defined there.) + +- [ ] **Step 2: Create `DbConnections.cs`** + +```csharp +using Microsoft.Extensions.Configuration; + +namespace PriceNegotiationApp.BuildingBlocks; + +public static class DbConnections +{ + private const string DefaultKey = "Database:ConnectionString"; + + /// Per-module override wins; falls back to the shared connection string. + public static string Resolve(IConfiguration configuration, string moduleName) + { + var moduleOverride = configuration[$"Database:Modules:{moduleName}:ConnectionString"]; + if (!string.IsNullOrWhiteSpace(moduleOverride)) + { + return moduleOverride; + } + + return configuration[DefaultKey] + ?? throw new InvalidOperationException( + $"{DefaultKey} is not configured (module '{moduleName}')."); + } +} +``` + +- [ ] **Step 3: Move caller-context mapping** + +Move the file, rename to `CallerContextExtensions.cs`, class to `CallerContextExtensions`, namespace `PriceNegotiationApp.BuildingBlocks`. Delete the Api original and switch every consumer (`AuthModule`-replacement handlers, legacy modules) to `using PriceNegotiationApp.BuildingBlocks;`. + +- [ ] **Step 4: Validate + commit** + +```powershell +dotnet build PriceNegotiationApp.slnx && dotnet test tests/PriceNegotiationApp.UnitTests +git add -A && git commit -m "refactor(building-blocks): add DbConnections resolver and CallerContext mapping" +``` + + +--- + +### Task 4: Carve out the Negotiations module + +**The largest task:** new module project receives the Negotiation/Customer domain, its own DbContext + migrations, consumer-owned port, per-operation endpoints, and its unit-test project. Legacy negotiation service/repos/endpoints die here. + +**Files:** +- Create: `src/PriceNegotiationApp.Modules.Negotiations/**` (all files below) +- Create: `tests/PriceNegotiationApp.Modules.NegotiationsTests/**` (project name without extra dot: `PriceNegotiationApp.Modules.Negotiations.Tests`) +- Modify: `src/PriceNegotiationApp.PriceNegotiationApp.slnx`, Api `GlobalExceptionHandler.cs` (usings), `Program.cs`-side registration (via `WebApplicationBuilderExtensions`), `PipelineExtensions.cs` (endpoint mapping) +- Delete: `Domain/Models/Negotiation.cs`, `Customer.cs`, `NegotiationOutcome.cs`, `NegotiationStatus.cs`, `Domain/ValueObjects/Ids/{NegotiationId,CustomerId}.cs`, `Domain/ValueObjects/Price.cs`, `Domain/Policy/*`, `Application/Features/Negotiations/*`, `Application/Abstractions/{INegotiationRepository,ICustomerRepository}.cs`, `Infrastructure/Persistence/Repositories/{NegotiationRepository,CustomerRepository}.cs`, `Api/Modules/NegotiationsModule.cs`, `tests/...UnitTests/Application/NegotiationServiceShould.cs`, Infrastructure `Persistence/NegotiationsDbContext.cs`, `Persistence/Migrations/Negotiations/` + +**Interfaces:** +- Consumes: BuildingBlocks types (Task 3). +- Produces: + - `NegotiationsModule.AddNegotiationsModule(this IServiceCollection, IConfiguration)` / `.MapNegotiationsEndpoints(this IEndpointRouteBuilder)` + - `IProductPriceProvider { Task GetAsync(Guid productId, CancellationToken ct); }`, `readonly record struct ProductSnapshot(Guid ProductId, decimal Price)` + - `Negotiation.Start(CustomerId customerId, Guid productId, decimal basePriceSnapshot, decimal initialOffer, DateTimeOffset now, INegotiationPolicy policy)` + - `NegotiationErrorCodes`: `NegotiationClosed="negotiation_closed"`, `ProposalExceedsLimit="proposal_exceeds_limit"`, `NegotiationAlreadyOpen="negotiation_already_open"`, `NoProposalsRemaining="no_proposals_remaining"` (public — AppHost handler reads the first two) + +- [ ] **Step 1: Project file** + +`src/PriceNegotiationApp.Modules.Negotiations/PriceNegotiationApp.Modules.Negotiations.csproj`: + +```xml + + + + $(NoWarn);MA0097 + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + +``` + +Add the project to `PriceNegotiationApp.slnx` under `/src/`. + +- [ ] **Step 2: Domain** + +Move with namespace change only: `Customer.cs`, `NegotiationStatus.cs`, `NegotiationOutcome.cs`, `NegotiationId.cs`, `CustomerId.cs`, `INegotiationPolicy.cs`, `DefaultNegotiationPolicy.cs` → namespace `PriceNegotiationApp.Modules.Negotiations.Domain`. Copy `Price.cs` verbatim into the module (Catalog keeps needing it too in Task 5 — duplication by design, spec §7). + +Rewrite `Negotiation.cs` (new Start signature decouples from the Product aggregate): + +```csharp +using PriceNegotiationApp.Modules.Negotiations.Domain; + +namespace PriceNegotiationApp.Modules.Negotiations.Domain; + +public sealed class Negotiation +{ + /// Base price snapshot taken at creation; protects ongoing negotiations from later product price changes. + public decimal BasePrice { get; private set; } + + public decimal CurrentOffer { get; private set; } + + public NegotiationId Id { get; private set; } + + public Guid ProductId { get; private set; } + + public CustomerId CustomerId { get; private set; } + + public NegotiationStatus Status { get; private set; } + + /// Total proposals recorded, including the initial one. + public int ProposalsUsed { get; private set; } + + public DateTimeOffset CreatedAtUtc { get; private set; } + + public DateTimeOffset LastProposalAtUtc { get; private set; } + + public DateTimeOffset? DecidedAtUtc { get; private set; } + + public uint Version { get; private set; } + + private Negotiation() + { + } + + private Negotiation( + NegotiationId id, Guid productId, CustomerId customerId, decimal basePrice, decimal currentOffer, + DateTimeOffset createdAtUtc) + { + Id = id; + ProductId = productId; + CustomerId = customerId; + BasePrice = basePrice; + CurrentOffer = currentOffer; + Status = NegotiationStatus.Open; + ProposalsUsed = 1; + CreatedAtUtc = createdAtUtc; + LastProposalAtUtc = createdAtUtc; + } + + public static Negotiation Start( + CustomerId customerId, Guid productId, decimal basePriceSnapshot, decimal initialOffer, + DateTimeOffset now, INegotiationPolicy policy) + { + EnsureWithinLimit(basePriceSnapshot, initialOffer, policy); + return new Negotiation(NegotiationId.From(Guid.CreateVersion7()), productId, customerId, + basePriceSnapshot, initialOffer, now); + } + + public NegotiationOutcome CounterPropose(decimal offer, DateTimeOffset now, INegotiationPolicy policy) + { + EnsureOpen(); + if (ProposalsUsed >= policy.MaxProposalsPerNegotiation) + { + return NegotiationOutcome.NoProposalsRemaining; + } + + try + { + EnsureWithinLimit(BasePrice, offer, policy); + } + catch (ProposalExceedsLimitException) + { + Status = NegotiationStatus.Declined; + DecidedAtUtc = now; + return NegotiationOutcome.AutoRejected; + } + + CurrentOffer = offer; + ProposalsUsed++; + LastProposalAtUtc = now; + return NegotiationOutcome.CounterProposed; + } + + public void Accept(DateTimeOffset now) => Decide(NegotiationStatus.Accepted, now); + + /// + /// Staff rejects the current offer. The negotiation deliberately stays open so the + /// customer may spend a remaining proposal; it terminates only via Accept, + /// auto-rejection, or withdrawal. + /// + public void Decline() => EnsureOpen(); + + public int RemainingProposals(INegotiationPolicy policy) => + Math.Max(0, policy.MaxProposalsPerNegotiation - ProposalsUsed); + + private void Decide(NegotiationStatus terminalStatus, DateTimeOffset now) + { + EnsureOpen(); + Status = terminalStatus; + DecidedAtUtc = now; + } + + private void EnsureOpen() + { + if (Status != NegotiationStatus.Open) + { + throw new ClosedNegotiationException(); + } + } + + private static void EnsureWithinLimit(decimal basePrice, decimal offer, INegotiationPolicy policy) + { + var limit = decimal.Round(basePrice * policy.ProposalMultiplierLimit, 2); + Price.From(offer); + if (offer > limit) + { + throw new ProposalExceedsLimitException(limit); + } + } +} +``` + +Changes vs old: `ProductId` is `Guid` (not typed VO — foreign-module key), `Start` takes snapshot primitives, `Entity` base dropped (was stateless). + +Create `Domain/NegotiationExceptions.cs` (merge of the two moved exception files; bodies verbatim otherwise): + +```csharp +using PriceNegotiationApp.BuildingBlocks; + +namespace PriceNegotiationApp.Modules.Negotiations.Domain; + +/// Thrown when an operation targets a negotiation that has already reached a terminal state. +public sealed class ClosedNegotiationException() + : DomainException("Negotiation is already closed."); +``` + +and keep `ProposalExceedsLimitException` as its own file `Domain/ProposalExceedsLimitException.cs` (body verbatim from Domain). + +- [ ] **Step 3: Port** + +`Ports/IProductPriceProvider.cs`: + +```csharp +namespace PriceNegotiationApp.Modules.Negotiations.Ports; + +public interface IProductPriceProvider +{ + /// Returns null when the product does not exist. + Task GetAsync(Guid productId, CancellationToken ct); +} + +public readonly record struct ProductSnapshot(Guid ProductId, decimal Price); +``` + +- [ ] **Step 4: Persistence** + +Move `NegotiationsDbContext.cs` from Infrastructure into `Modules.Negotiations/Persistence/` with namespace `PriceNegotiationApp.Modules.Negotiations.Persistence`; change configuration wiring to explicit `ApplyConfiguration(new ...)` referencing the moved configuration classes below. + +Move + rewrite `Persistence/Configurations/CustomerConfiguration.cs`: + +```csharp +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using PriceNegotiationApp.Modules.Negotiations.Domain; + +namespace PriceNegotiationApp.Modules.Negotiations.Persistence.Configurations; + +public sealed class CustomerConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("customers"); + builder.HasKey(c => c.Id); + builder.Property(c => c.Id).HasConversion(id => id.Value, value => CustomerId.From(value)) + .ValueGeneratedNever(); + builder.HasIndex(c => c.IdentityUserId).IsUnique(); + } +} +``` + +Move + rewrite `Persistence/Configurations/NegotiationConfiguration.cs`: + +```csharp +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using PriceNegotiationApp.Modules.Negotiations.Domain; + +namespace PriceNegotiationApp.Modules.Negotiations.Persistence.Configurations; + +public sealed class NegotiationConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("negotiations"); + builder.HasKey(n => n.Id); + builder.Property(n => n.Id).HasConversion(id => id.Value, value => NegotiationId.From(value)) + .ValueGeneratedNever(); + // Plain Guid keys: product_id has NO FK by design (separate schemas/modules). + // Existence is validated at creation; negotiations survive deletion on snapshots. + builder.Property(n => n.ProductId); + builder.Property(n => n.CustomerId).HasConversion(id => id.Value, value => CustomerId.From(value)); + builder.Property(n => n.BasePrice).HasColumnType("numeric(18,2)"); + builder.Property(n => n.CurrentOffer).HasColumnType("numeric(18,2)"); + builder.Property(n => n.Status).HasConversion(); + builder.HasOne().WithMany().HasForeignKey(n => n.CustomerId).OnDelete(DeleteBehavior.Cascade); + builder.HasIndex(n => new { n.ProductId, n.CustomerId }) + .IsUnique() + .HasFilter($"status = {(int)NegotiationStatus.Open}"); + builder.Property(n => n.Version).IsRowVersion(); + } +} +``` + +Create `Persistence/DesignTimeDbContextFactory.cs`: + +```csharp +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace PriceNegotiationApp.Modules.Negotiations.Persistence; + +public sealed class DesignTimeDbContextFactory : IDesignTimeDbContextFactory +{ + public NegotiationsDbContext CreateDbContext(string[] args) => + new(new DbContextOptionsBuilder() + .UseNpgsql("Host=localhost;Port=5432;Database=pricenego_design;Username=postgres;Password=postgres", + npgsql => npgsql.MigrationsHistoryTable("__EFMigrationsHistory_Negotiations")) + .UseSnakeCaseNamingConvention() + .Options); +} +``` + +Generate fresh migrations in the module (history-table name unchanged; migration ids differ from Task 1's set because namespaces changed — disposable environments recreate; persistent environments additionally run `DELETE FROM "__EFMigrationsHistory_Negotiations";` once before first start — append this note to `docs/sql/cleanup-legacy-tables.sql`): + +```powershell +Remove-Item -Recurse -Force src/PriceNegotiationApp.Infrastructure/Persistence/Migrations/Negotiations +dotnet ef migrations add Initial --context NegotiationsDbContext ` + -p src/PriceNegotiationApp.Modules.Negotiations -o Persistence/Migrations +``` + +Verify: no FK on `product_id`; FK `customer_id → negotiations.customers` cascade present; partial index present. + +- [ ] **Step 5: Feature slices + models** + +`Features/Negotiations/NegotiationModels.cs`: + +```csharp +using PriceNegotiationApp.BuildingBlocks; +using PriceNegotiationApp.Modules.Negotiations.Domain; + +namespace PriceNegotiationApp.Modules.Negotiations.Features; + +public sealed class CreateNegotiationRequest +{ + public Guid ProductId { get; init; } +} + +public sealed class CounterProposalRequest +{ + public decimal ProposedPrice { get; init; } +} + +public sealed record NegotiationResponse( + Guid Id, + Guid ProductId, + decimal BasePrice, + decimal CurrentOffer, + string Status, + int ProposalsUsed, + int ProposalsRemaining, + DateTimeOffset CreatedAtUtc, + DateTimeOffset LastProposalAtUtc, + DateTimeOffset? DecidedAtUtc); + +public sealed record CounterProposalOutcome(string Outcome, NegotiationResponse Negotiation); + +/// Machine-readable error codes owned by this feature (frozen contract). +public static class NegotiationErrorCodes +{ + public const string NegotiationClosed = "negotiation_closed"; + + public const string ProposalExceedsLimit = "proposal_exceeds_limit"; + + public const string NegotiationAlreadyOpen = "negotiation_already_open"; + + public const string NoProposalsRemaining = "no_proposals_remaining"; +} + +internal static class NegotiationResponses +{ + internal static NegotiationResponse ToResponse(Negotiation n, INegotiationPolicy policy) => + new(n.Id.Value, n.ProductId, n.BasePrice, n.CurrentOffer, n.Status.ToString(), + n.ProposalsUsed, n.RemainingProposals(policy), n.CreatedAtUtc, n.LastProposalAtUtc, n.DecidedAtUtc); +} +``` + +NOTE: `CreateNegotiationRequest` keeps property name `ProductId` (JSON shape unchanged). The original request had the same single property plus `ProposedPrice`; keep BOTH properties exactly as today: + +```csharp +public sealed class CreateNegotiationRequest +{ + public Guid ProductId { get; init; } + + public decimal ProposedPrice { get; init; } +} +``` + +(use this second version — the first block above is superseded.) + +`Features/Negotiations/NegotiationAccess.cs`: + +```csharp +using Microsoft.EntityFrameworkCore; +using PriceNegotiationApp.BuildingBlocks; +using PriceNegotiationApp.Modules.Negotiations.Domain; +using PriceNegotiationApp.Modules.Negotiations.Persistence; + +namespace PriceNegotiationApp.Modules.Negotiations.Features; + +internal static class NegotiationAccess +{ + public static async Task RequireAsync(NegotiationsDbContext db, Guid id, CancellationToken ct) => + await db.Negotiations.FirstOrDefaultAsync(n => n.Id.Value == id, ct) + ?? throw new NotFoundException(nameof(Negotiation), id); + + public static async Task RequireOwnedAsync( + NegotiationsDbContext db, CallerContext caller, Guid id, CancellationToken ct) + { + var negotiation = await RequireAsync(db, id, ct); + var customer = await CustomerByIdentityAsync(db, caller.UserId, ct); + if (customer is null || customer.Id != negotiation.CustomerId) + { + throw new ForbiddenAccessException(); + } + + return negotiation; + } + + public static async Task CanAccessAsync( + NegotiationsDbContext db, CallerContext caller, Negotiation negotiation, CancellationToken ct) + { + if (caller.IsInRole(UserRoles.Admin) || caller.IsInRole(UserRoles.Staff)) + { + return true; + } + + var customer = await CustomerByIdentityAsync(db, caller.UserId, ct); + return customer is not null && customer.Id == negotiation.CustomerId; + } + + public static Task CustomerByIdentityAsync( + NegotiationsDbContext db, Guid identityUserId, CancellationToken ct) => + db.Customers.FirstOrDefaultAsync(c => c.IdentityUserId == identityUserId, ct); + + public static async Task GetOrCreateCustomerIdAsync( + NegotiationsDbContext db, Guid identityUserId, CancellationToken ct) + { + var existing = await CustomerByIdentityAsync(db, identityUserId, ct); + if (existing is not null) + { + return existing.Id; + } + + var customer = Customer.Create(identityUserId); + await db.Customers.AddAsync(customer, ct); + return customer.Id; + } + + public static Task FindOpenAsync( + NegotiationsDbContext db, Guid productId, Guid identityUserId, CancellationToken ct) => + db.Negotiations.FirstOrDefaultAsync( + n => n.ProductId == productId && n.Status == NegotiationStatus.Open, ct); +} +``` + +`FindOpenAsync` simplification is safe: `customer_id` matches only rows belonging to this identity's customer; a customer id is unique per identity user (unique index) so filtering on product+status alone could match ANOTHER customer's open negotiation — WRONG. Keep the join semantics of the original: filter `n.CustomerId == customer.Id` requires resolving the customer first: + +```csharp + public static async Task FindOpenAsync( + NegotiationsDbContext db, Guid productId, Guid identityUserId, CancellationToken ct) + { + var customer = await CustomerByIdentityAsync(db, identityUserId, ct); + return customer is null + ? null + : await db.Negotiations.FirstOrDefaultAsync( + n => n.ProductId == productId && n.CustomerId == customer.Id && n.Status == NegotiationStatus.Open, + ct); + } +``` + +USE THIS SECOND VERSION (the first FindOpenAsync above is superseded and must not ship). + +`Features/Negotiations/Create.cs`: + +```csharp +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using System.Security.Claims; +using PriceNegotiationApp.BuildingBlocks; +using PriceNegotiationApp.Modules.Identity.Public; +using PriceNegotiationApp.Modules.Negotiations.Domain; +using PriceNegotiationApp.Modules.Negotiations.Ports; +using PriceNegotiationApp.Modules.Negotiations.Persistence; + +namespace PriceNegotiationApp.Modules.Negotiations.Features; + +internal static class Create +{ + internal static RouteGroupBuilder MapCreate(this RouteGroupBuilder group) => + group.MapPost("/", async (CreateNegotiationRequest request, ClaimsPrincipal principal, + NegotiationsDbContext db, IProductPriceProvider products, INegotiationPolicy policy, + TimeProvider clock, CancellationToken ct) => + { + var caller = principal.ToCallerContext(); + var snapshot = await products.GetAsync(request.ProductId, ct) + ?? throw new NotFoundException(nameof(Product), request.ProductId); + + if (await NegotiationAccess.FindOpenAsync(db, snapshot.ProductId, caller.UserId, ct) is not null) + { + throw new ConflictException(NegotiationErrorCodes.NegotiationAlreadyOpen, + "An open negotiation already exists for this product."); + } + + var customerId = await NegotiationAccess.GetOrCreateCustomerIdAsync(db, caller.UserId, ct); + var negotiation = Negotiation.Start(customerId, snapshot.ProductId, snapshot.Price, + request.ProposedPrice, clock.GetUtcNow(), policy); + await db.Negotiations.AddAsync(negotiation, ct); + await db.SaveChangesAsync(ct); + return TypedResults.Created("/api/v1/negotiations/mine", + NegotiationResponses.ToResponse(negotiation, policy)); + }) + .RequireRoles(UserRoles.Customer); +} +``` + +Until Task 6 lands `Modules.Identity.Public.UserRoles`, temporarily alias: add to the module root a file `Public/UserRoles.cs` NOW (it belongs to Identity ultimately — acceptable interim: create it in this task under `Modules.Negotiations/Public/UserRoles.cs` and MOVE it to Identity module in Task 6, fixing the using). Simpler alternative used everywhere in this plan: create `Modules.Identity/Public/UserRoles.cs` ALREADY in this task (create the Identity module folder early with just that file + minimal csproj? A csproj is needed for compilation…). Cleanest: create the FULL Identity module skeleton (csproj + Public/UserRoles.cs only) in this task; fleshed out in Task 6. Do that: + +`src/PriceNegotiationApp.Modules.Identity/PriceNegotiationApp.Modules.Identity.csproj`: + +```xml + + + + + +``` + +`src/PriceNegotiationApp.Modules.Identity/Public/UserRoles.cs`: + +```csharp +namespace PriceNegotiationApp.Modules.Identity.Public; + +public static class UserRoles +{ + public const string Admin = "Admin"; + + public const string Staff = "Staff"; + + public const string Customer = "Customer"; +} +``` + +Add both projects to slnx. Delete `Application/Common/UserRoles.cs` and fix remaining consumers (`ProductsModule`, legacy services) to `using PriceNegotiationApp.Modules.Identity.Public;`. + +Remaining seven operation files follow the identical pattern (route group + handler + `RequireAuthorization()`/`RequireRoles(...)` copied verbatim from the deleted `Api/Modules/NegotiationsModule.cs`). Full bodies: + +`ListMine.cs`: + +```csharp +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using System.Security.Claims; +using PriceNegotiationApp.BuildingBlocks; +using PriceNegotiationApp.Modules.Identity.Public; +using PriceNegotiationApp.Modules.Negotiations.Domain; +using PriceNegotiationApp.Modules.Negotiations.Persistence; + +namespace PriceNegotiationApp.Modules.Negotiations.Features; + +internal static class ListMine +{ + internal static RouteGroupBuilder MapListMine(this RouteGroupBuilder group) => + group.MapGet("/mine", async (ClaimsPrincipal principal, NegotiationsDbContext db, + INegotiationPolicy policy, CancellationToken ct, int page = 1, int pageSize = 20) => + { + var caller = principal.ToCallerContext(); + var query = new PageQuery(page, pageSize); + var customer = await NegotiationAccess.CustomerByIdentityAsync(db, caller.UserId, ct); + var q = db.Negotiations.AsNoTracking().Where(n => customer != null && n.CustomerId == customer.Id); + var total = await q.LongCountAsync(ct); + var items = await q.OrderByDescending(n => n.CreatedAtUtc) + .Skip(query.Skip).Take(query.SafePageSize) + .ToListAsync(ct); + return TypedResults.Ok(new PagedResult( + items.Select(n => NegotiationResponses.ToResponse(n, policy)).ToList(), + query.SafePage, query.SafePageSize, total)); + }) + .RequireRoles(UserRoles.Customer); +} +``` + +(`AsNoTracking`, `ToListAsync`, `LongCountAsync` require `using Microsoft.EntityFrameworkCore;` — included via the Persistence using? No: add explicit `using Microsoft.EntityFrameworkCore;` to every handler file that queries. Add it to each file below.) + +`List.cs` (staff/admin listing all): + +```csharp +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using PriceNegotiationApp.BuildingBlocks; +using PriceNegotiationApp.Modules.Identity.Public; +using PriceNegotiationApp.Modules.Negotiations.Domain; +using PriceNegotiationApp.Modules.Negotiations.Persistence; + +namespace PriceNegotiationApp.Modules.Negotiations.Features; + +internal static class List +{ + internal static RouteGroupBuilder MapList(this RouteGroupBuilder group) => + group.MapGet("/", async (NegotiationsDbContext db, INegotiationPolicy policy, + CancellationToken ct, int page = 1, int pageSize = 20) => + { + var query = new PageQuery(page, pageSize); + var q = db.Negotiations.AsNoTracking(); + var total = await q.LongCountAsync(ct); + var items = await q.OrderByDescending(n => n.CreatedAtUtc) + .Skip(query.Skip).Take(query.SafePageSize) + .ToListAsync(ct); + return TypedResults.Ok(new PagedResult( + items.Select(n => NegotiationResponses.ToResponse(n, policy)).ToList(), + query.SafePage, query.SafePageSize, total)); + }) + .RequireRoles(UserRoles.Admin, UserRoles.Staff); +} +``` + +`Get.cs`: + +```csharp +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using System.Security.Claims; +using PriceNegotiationApp.BuildingBlocks; +using PriceNegotiationApp.Modules.Negotiations.Domain; +using PriceNegotiationApp.Modules.Negotiations.Persistence; + +namespace PriceNegotiationApp.Modules.Negotiations.Features; + +internal static class Get +{ + internal static RouteGroupBuilder MapGetOne(this RouteGroupBuilder group) => + group.MapGet("/{id:guid}", async (Guid id, ClaimsPrincipal principal, NegotiationsDbContext db, + INegotiationPolicy policy, CancellationToken ct) => + { + var caller = principal.ToCallerContext(); + var negotiation = await NegotiationAccess.RequireAsync(db, id, ct); + if (!await NegotiationAccess.CanAccessAsync(db, caller, negotiation, ct)) + { + throw new ForbiddenAccessException(); + } + + return TypedResults.Ok(NegotiationResponses.ToResponse(negotiation, policy)); + }) + .RequireAuthorization(); +} +``` + +`CounterPropose.cs`: + +```csharp +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using System.Security.Claims; +using PriceNegotiationApp.BuildingBlocks; +using PriceNegotiationApp.Modules.Negotiations.Domain; +using PriceNegotiationApp.Modules.Negotiations.Persistence; + +namespace PriceNegotiationApp.Modules.Negotiations.Features; + +internal static class CounterPropose +{ + internal static RouteGroupBuilder MapCounterPropose(this RouteGroupBuilder group) => + group.MapPatch("/{id:guid}/proposals", async (Guid id, CounterProposalRequest request, + ClaimsPrincipal principal, NegotiationsDbContext db, INegotiationPolicy policy, + TimeProvider clock, CancellationToken ct) => + { + var caller = principal.ToCallerContext(); + var negotiation = await NegotiationAccess.RequireOwnedAsync(db, caller, id, ct); + + var outcome = negotiation.CounterPropose(request.ProposedPrice, clock.GetUtcNow(), policy); + if (outcome == NegotiationOutcome.NoProposalsRemaining) + { + throw new ConflictException(NegotiationErrorCodes.NoProposalsRemaining, + "No proposals remain for this negotiation."); + } + + await db.SaveChangesAsync(ct); + return TypedResults.Ok(new CounterProposalOutcome(outcome.ToString(), + NegotiationResponses.ToResponse(negotiation, policy))); + }) + .RequireAuthorization(); +} +``` + +`Accept.cs`: + +```csharp +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using PriceNegotiationApp.BuildingBlocks; +using PriceNegotiationApp.Modules.Identity.Public; +using PriceNegotiationApp.Modules.Negotiations.Domain; +using PriceNegotiationApp.Modules.Negotiations.Persistence; + +namespace PriceNegotiationApp.Modules.Negotiations.Features; + +internal static class Accept +{ + internal static RouteGroupBuilder MapAccept(this RouteGroupBuilder group) => + group.MapPost("/{id:guid}/accept", async (Guid id, NegotiationsDbContext db, + INegotiationPolicy policy, TimeProvider clock, CancellationToken ct) => + { + var negotiation = await NegotiationAccess.RequireAsync(db, id, ct); + negotiation.Accept(clock.GetUtcNow()); + await db.SaveChangesAsync(ct); + return TypedResults.Ok(NegotiationResponses.ToResponse(negotiation, policy)); + }) + .RequireRoles(UserRoles.Admin, UserRoles.Staff); +} +``` + +`Decline.cs`: identical to `Accept` except method name `MapDecline`, route `"/{id:guid}/decline"`, and body: + +```csharp + negotiation.Decline(); + await db.SaveChangesAsync(ct); + return TypedResults.Ok(NegotiationResponses.ToResponse(negotiation, policy)); +``` + +`Withdraw.cs`: + +```csharp +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using System.Security.Claims; +using PriceNegotiationApp.BuildingBlocks; +using PriceNegotiationApp.Modules.Negotiations.Persistence; + +namespace PriceNegotiationApp.Modules.Negotiations.Features; + +internal static class Withdraw +{ + internal static RouteGroupBuilder MapWithdraw(this RouteGroupBuilder group) => + group.MapDelete("/{id:guid}", async (Guid id, ClaimsPrincipal principal, NegotiationsDbContext db, + CancellationToken ct) => + { + var caller = principal.ToCallerContext(); + var negotiation = await NegotiationAccess.RequireAsync(db, id, ct); + if (!caller.IsInRole(UserRoles.Admin) + && !await NegotiationAccess.CanAccessAsync(db, caller, negotiation, ct)) + { + throw new ForbiddenAccessException(); + } + + db.Negotiations.Remove(negotiation); + await db.SaveChangesAsync(ct); + return TypedResults.NoContent(); + }) + .RequireAuthorization(); +} +``` + +- [ ] **Step 6: Module composition class** + +`NegotiationsModule.cs` (module root): + +```csharp +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Npgsql; +using PriceNegotiationApp.BuildingBlocks; +using PriceNegotiationApp.Modules.Negotiations.Domain; +using PriceNegotiationApp.Modules.Negotiations.Features; +using PriceNegotiationApp.Modules.Negotiations.Persistence; + +namespace PriceNegotiationApp.Modules.Negotiations; + +public static class NegotiationsModule +{ + public static IServiceCollection AddNegotiationsModule( + this IServiceCollection services, IConfiguration configuration) + { + services.AddDbContext(options => options + .UseNpgsql(DbConnections.Resolve(configuration, "Negotiations"), + npgsql => npgsql.MigrationsHistoryTable("__EFMigrationsHistory_Negotiations")) + .UseSnakeCaseNamingConvention()); + services.AddSingleton(); + services.AddSingleton(TimeProvider.System); + return services; + } + + public static IEndpointRouteBuilder MapNegotiationsEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/api/v1/negotiations").WithTags("Negotiations"); + group.MapCreate(); + group.MapListMine(); + group.MapList(); + group.MapGetOne(); + group.MapCounterPropose(); + group.MapAccept(); + group.MapDecline(); + group.MapWithdraw(); + return app; + } +} +``` + +This uses `DbConnections.Resolve` from BuildingBlocks — created in Task 3's corrective addendum (see Task 3a). If executing strictly sequentially, implement Task 3a before this step. + +- [ ] **Step 7: Host adapter (over Infrastructure's CatalogDbContext until Task 5)** + +`src/PriceNegotiationApp.Api/Composition/CatalogToNegotiations.cs`: + +```csharp +using Microsoft.EntityFrameworkCore; +using PriceNegotiationApp.Infrastructure.Persistence; +using PriceNegotiationApp.Modules.Negotiations.Ports; + +namespace PriceNegotiationApp.Api.Composition; + +/// The single sanctioned inter-module edge: Negotiations reads product price snapshots. +public sealed class CatalogToNegotiations(CatalogDbContext db) : IProductPriceProvider +{ + public async Task GetAsync(Guid productId, CancellationToken ct) => + await db.Products.AsNoTracking() + .Where(p => p.Id.Value == productId) + .Select(p => new ProductSnapshot(p.Id.Value, p.Price)) + .FirstOrDefaultAsync(ct); +} +``` + +- [ ] **Step 8: Host wiring updates** + +In `WebApplicationBuilderExtensions.AddApiServices` replace `services.AddApplicationServices(); services.AddInfrastructure(configuration);` region: remove the Application registrations for negotiations (`INegotiationService`) — concretely, delete from `Application/DependencyInjection.cs` the lines registering `INegotiationService` — and add to the Api composition: + +```csharp +builder.Services.AddNegotiationsModule(configuration); +builder.Services.AddScoped(); +``` + +In `PipelineExtensions.MapModules` replace `app.MapNegotiationsApi();` with `app.MapNegotiationsEndpoints();`. + +Update `GlobalExceptionHandler.cs` usings: `using PriceNegotiationApp.Modules.Negotiations.Domain;` (for Closed/Proposal exceptions) and `using PriceNegotiationApp.Modules.Negotiations.Features;` (for `NegotiationErrorCodes.NegotiationClosed` / `.ProposalExceedsLimit`). The switch arms stay textually identical. + +- [ ] **Step 9: Deletions** + +Delete the files listed at the top of this task (`NegotiationService.cs`, `INegotiationService.cs`, both repository ports/impls, old endpoint module, old domain files, old Infrastructure `NegotiationsDbContext.cs`, `NegotiationServiceShould.cs`). Fix any compile errors by following the Namespace Migration Map. + +- [ ] **Step 10: Module unit-test project** + +`tests/PriceNegotiationApp.Modules.Negotiations.Tests/PriceNegotiationApp.Modules.Negotiations.Tests.csproj`: + +```xml + + + Exe + $(NoWarn);CA1707;S1118 + + + + + + + + + + + + + +``` + +Move `NegotiationLifecycleShould.cs` + `PriceShould.cs` from UnitTests with namespace `PriceNegotiationApp.Modules.Negotiations.Tests` and usings remapped to `PriceNegotiationApp.Modules.Negotiations.Domain`. Update every `Negotiation.Start(...)` call site to the new signature — pattern: + +```csharp +// old +var product = Product.Create("Widget", 100m); +var sut = Negotiation.Start(customerId, product, 90m, now, Policy); +// new +var sut = Negotiation.Start(customerId, productId: Guid.NewGuid(), basePriceSnapshot: 100m, + initialOffer: 90m, now, Policy); +``` + +(`Customer.Create(identityUserId)` unchanged; construct `CustomerId` directly where tests built products solely to feed `Start`.) Delete `NegotiationServiceShould.cs` (its subject is gone; branches covered by the integration RBAC matrix). Add the test project to slnx. + +- [ ] **Step 11: Validate** + +```powershell +dotnet build PriceNegotiationApp.slnx +dotnet test tests/PriceNegotiationApp.Modules.Negotiations.Tests +dotnet test tests/PriceNegotiationApp.UnitTests +dotnet test tests/PriceNegotiationApp.IntegrationTests +``` + +Full suite green — negotiation flows byte-identical over HTTP. + +- [ ] **Step 12: Commit** + +```bash +git add -A && git commit -m "refactor(modules): carve out Negotiations module with own context and port" +``` + + +--- + +### Task 5: Carve out the Catalog module + +Same motion as Task 4 for the smaller context. Also relocates `MigrationHostedService` to the AppHost (it must see all three contexts, two of which are now module types). + +**Files:** +- Create: `src/PriceNegotiationApp.Modules.Catalog/**` (files below) +- Create: `tests/PriceNegotiationApp.Modules.Catalog.Tests/**` +- Move: `Infrastructure/Hosting/MigrationHostedService.cs` → `src/PriceNegotiationApp.Api/Composition/MigrationHostedService.cs` (namespace `PriceNegotiationApp.Api.Composition`) +- Modify: `WebApplicationBuilderExtensions.cs`, `PipelineExtensions.cs`, Api `Composition/CatalogToNegotiations.cs` (using swap) +- Modify: `PriceNegotiationApp.slnx` +- Delete: `Domain/Models/Product.cs`, `Domain/Models/Rules/*`, `Domain/ValueObjects/Price.cs`, `Domain/ValueObjects/Ids/ProductId.cs`, `Application/Features/Products/*`, `Application/Abstractions/IProductRepository.cs`, `Infrastructure/Persistence/Repositories/ProductRepository.cs`, `Api/Modules/ProductsModule.cs`, `Infrastructure/Persistence/CatalogDbContext.cs`, `DbEntityConfigurations/ProductConfiguration.cs`, `Persistence/Migrations/Catalog/`, `Infrastructure/Hosting/*`, `Infrastructure/Seeding/CatalogSeedingHostedService.cs`, `tests/...UnitTests/Application/ProductServiceShould.cs` + +**Interfaces:** +- Consumes: BuildingBlocks (`Policies.ShortCachePolicy`, `PageQuery`, `PagedResult`, `ProductQuery`, exceptions). +- Produces: + - `CatalogModule.AddCatalogModule(this IServiceCollection, IConfiguration)` / `.MapCatalogEndpoints(this IEndpointRouteBuilder)` + - `ProductResponse(Guid Id, string Name, decimal Price)` (JSON unchanged) + +**Step 1 — Project file** + +```xml + + + + $(NoWarn);MA0097 + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + +``` + +**Step 2 — Domain** + +Copy `Price.cs` verbatim → namespace `PriceNegotiationApp.Modules.Catalog.Domain`. Move `ProductId.cs` verbatim → same namespace. + +Rewrite `Domain/Product.cs` (inline guards replace the rule classes; behavior identical): + +```csharp +using PriceNegotiationApp.BuildingBlocks; +using Vogen; + +namespace PriceNegotiationApp.Modules.Catalog.Domain; + +public sealed class Product +{ + public const int MaxNameLength = 200; + + public ProductId Id { get; private set; } + + public string Name { get; private set; } = null!; + + public decimal Price { get; private set; } + + /// Optimistic-concurrency token mapped to PostgreSQL xmin. + public uint Version { get; private set; } + + private Product() + { + } + + private Product(ProductId id, string name, decimal price) + { + EnsureValid(name, price); + Id = id; + Name = name.Trim(); + Price = Price.From(price).Value; + } + + public static Product Create(string name, decimal price) => + new(ProductId.From(Guid.CreateVersion7()), name, price); + + /// Applies changes. Returns false when nothing changed (PUT stays idempotent). + public bool Update(string name, decimal price) + { + EnsureValid(name, price); + var validated = Price.From(price).Value; + var trimmed = name.Trim(); + if (string.Equals(Name, trimmed, StringComparison.Ordinal) && Price == validated) + { + return false; + } + + Name = trimmed; + Price = validated; + return true; + } + + private static void EnsureValid(string? name, decimal price) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new DomainException("Product name must not be empty."); + } + + if (name.Trim().Length > MaxNameLength) + { + throw new DomainException($"Product name must not exceed {MaxNameLength} characters."); + } + + Price.From(price); + } +} +``` + +(Positivity enforced through the `Price` value object — Vogen's `ValueObjectValidationException` → 422; empty/too-long name → `BuildingBlocks.DomainException` → 422 `domain_rule_violated`. Semantics identical to the deleted rule classes.) + +**Step 3 — Persistence** + +Move + namespace-swap `CatalogDbContext.cs` → `Modules.Catalog/Persistence/` (`PriceNegotiationApp.Modules.Catalog.Persistence`); its configuration registration points at the moved configuration below. + +Move `ProductConfiguration.cs` → `Persistence/Configurations/ProductConfiguration.cs`, namespace `PriceNegotiationApp.Modules.Catalog.Persistence.Configurations` (body otherwise verbatim). + +Create `Persistence/DesignTimeDbContextFactory.cs`: + +```csharp +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace PriceNegotiationApp.Modules.Catalog.Persistence; + +public sealed class DesignTimeDbContextFactory : IDesignTimeDbContextFactory +{ + public CatalogDbContext CreateDbContext(string[] args) => + new(new DbContextOptionsBuilder() + .UseNpgsql("Host=localhost;Port=5432;Database=pricenego_design;Username=postgres;Password=postgres", + npgsql => npgsql.MigrationsHistoryTable("__EFMigrationsHistory_Catalog")) + .UseSnakeCaseNamingConvention() + .Options); +} +``` + +Regenerate migrations: + +```powershell +Remove-Item -Recurse -Force src/PriceNegotiationApp.Infrastructure/Persistence/Migrations/Catalog +dotnet ef migrations add Initial --context CatalogDbContext ` + -p src/PriceNegotiationApp.Modules.Catalog -o Persistence/Migrations +``` + +Append to `docs/sql/cleanup-legacy-tables.sql`: `DELETE FROM "__EFMigrationsHistory_Catalog";` (one-time, before first start — migration ids changed with the assembly move). + +**Step 4 — Seeding moves into the module** + +Create `Seeding/CatalogSeedingOptions.cs`: + +```csharp +namespace PriceNegotiationApp.Modules.Catalog.Seeding; + +public sealed class CatalogSeedingOptions +{ + public const string SectionName = "Seeding"; + + public bool SeedSampleProducts { get; init; } +} +``` + +Move the seeder from Infrastructure → `Seeding/CatalogSeedingHostedService.cs` (namespace `PriceNegotiationApp.Modules.Catalog.Seeding`), changing only: constructor option type to `IOptions`, context using to the module namespace; body as written in Task 2 Step 4. Delete the Infrastructure copy. + +**Step 5 — Feature slices** + +`Features/Products/ProductModels.cs`: + +```csharp +namespace PriceNegotiationApp.Modules.Catalog.Features.Products; + +public sealed class CreateProductRequest +{ + public string Name { get; init; } = string.Empty; + + public decimal Price { get; init; } +} + +public sealed class UpdateProductRequest +{ + public string Name { get; init; } = string.Empty; + + public decimal Price { get; init; } +} + +public sealed record ProductResponse(Guid Id, string Name, decimal Price); +``` + +`Features/Products/List.cs`: + +```csharp +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using PriceNegotiationApp.BuildingBlocks; +using PriceNegotiationApp.Modules.Catalog.Persistence; + +namespace PriceNegotiationApp.Modules.Catalog.Features.Products; + +internal static class List +{ + internal static RouteGroupBuilder MapList(this RouteGroupBuilder group) => + group.MapGet("/", async (CatalogDbContext db, CancellationToken ct, + string? search = null, decimal? minPrice = null, decimal? maxPrice = null, + string? sortBy = null, bool sortDesc = false, int page = 1, int pageSize = 20) => + TypedResults.Ok(await SearchAsync(db, + new ProductQuery(search, minPrice, maxPrice, sortBy, sortDesc, page, pageSize), ct))) + .CacheOutput(Policies.ShortCachePolicy) + .AllowAnonymous(); + + internal static async Task> SearchAsync( + CatalogDbContext db, ProductQuery query, CancellationToken ct) + { + var page = new PageQuery(query.Page, query.PageSize); + var q = db.Products.AsNoTracking(); + + if (!string.IsNullOrWhiteSpace(query.Search)) + { + q = q.Where(p => EF.Functions.ILike(p.Name, $"%{query.Search.Trim()}%")); + } + + if (query.MinPrice.HasValue) + { + q = q.Where(p => p.Price >= query.MinPrice.Value); + } + + if (query.MaxPrice.HasValue) + { + q = q.Where(p => p.Price <= query.MaxPrice.Value); + } + + var sortBy = query.SortBy?.Trim().ToLowerInvariant(); + q = (sortBy, query.SortDesc) switch + { + ("price", false) => q.OrderBy(p => p.Price), + ("price", true) => q.OrderByDescending(p => p.Price), + (_, true) => q.OrderByDescending(p => p.Name), + _ => q.OrderBy(p => p.Name), + }; + + var total = await q.LongCountAsync(ct); + var items = await q + .Skip(page.Skip) + .Take(page.SafePageSize) + .Select(p => new ProductResponse(p.Id.Value, p.Name, p.Price)) + .ToListAsync(ct); + + return new PagedResult(items, page.SafePage, page.SafePageSize, total); + } +} +``` + +`Features/Products/Get.cs`: + +```csharp +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using PriceNegotiationApp.BuildingBlocks; +using PriceNegotiationApp.Modules.Catalog.Domain; +using PriceNegotiationApp.Modules.Catalog.Persistence; + +namespace PriceNegotiationApp.Modules.Catalog.Features.Products; + +internal static class Get +{ + internal static RouteGroupBuilder MapGetOne(this RouteGroupBuilder group) => + group.MapGet("/{id:guid}", async (Guid id, CatalogDbContext db, CancellationToken ct) => + TypedResults.Ok(await RequireAsync(db, id, ct))) + .WithName("GetProductById") + .CacheOutput(Policies.ShortCachePolicy) + .AllowAnonymous(); + + internal static async Task RequireAsync(CatalogDbContext db, Guid id, CancellationToken ct) => + await db.Products.AsNoTracking() + .Where(p => p.Id.Value == id) + .Select(p => new ProductResponse(p.Id.Value, p.Name, p.Price)) + .FirstOrDefaultAsync(ct) + ?? throw new NotFoundException(nameof(Product), id); +} +``` + +`Features/Products/Create.cs`: + +```csharp +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using PriceNegotiationApp.BuildingBlocks; +using PriceNegotiationApp.Modules.Catalog.Domain; +using PriceNegotiationApp.Modules.Catalog.Persistence; +using PriceNegotiationApp.Modules.Identity.Public; + +namespace PriceNegotiationApp.Modules.Catalog.Features.Products; + +internal static class Create +{ + internal static RouteGroupBuilder MapCreate(this RouteGroupBuilder group) => + group.MapPost("/", async (CreateProductRequest request, CatalogDbContext db, CancellationToken ct) => + { + var product = Product.Create(request.Name, request.Price); + await db.Products.AddAsync(product, ct); + await db.SaveChangesAsync(ct); + return TypedResults.CreatedAtRoute( + new ProductResponse(product.Id.Value, product.Name, product.Price), + "GetProductById", new { id = product.Id.Value }); + }) + .RequireRoles(UserRoles.Admin, UserRoles.Staff); +} +``` + +`Features/Products/Update.cs`: + +```csharp +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using PriceNegotiationApp.BuildingBlocks; +using PriceNegotiationApp.Modules.Catalog.Domain; +using PriceNegotiationApp.Modules.Catalog.Persistence; +using PriceNegotiationApp.Modules.Identity.Public; + +namespace PriceNegotiationApp.Modules.Catalog.Features.Products; + +internal static class Update +{ + internal static RouteGroupBuilder MapUpdate(this RouteGroupBuilder group) => + group.MapPut("/{id:guid}", async (Guid id, UpdateProductRequest request, CatalogDbContext db, + CancellationToken ct) => + { + var product = await db.Products.FirstOrDefaultAsync(p => p.Id.Value == id, ct) + ?? throw new NotFoundException(nameof(Product), id); + product.Update(request.Name, request.Price); + await db.SaveChangesAsync(ct); + return TypedResults.Ok(new ProductResponse(product.Id.Value, product.Name, product.Price)); + }) + .RequireRoles(UserRoles.Admin, UserRoles.Staff); +} +``` + +`Features/Products/Delete.cs`: + +```csharp +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using PriceNegotiationApp.BuildingBlocks; +using PriceNegotiationApp.Modules.Catalog.Persistence; +using PriceNegotiationApp.Modules.Identity.Public; + +namespace PriceNegotiationApp.Modules.Catalog.Features.Products; + +internal static class Delete +{ + internal static RouteGroupBuilder MapDelete(this RouteGroupBuilder group) => + group.MapDelete("/{id:guid}", async (Guid id, CatalogDbContext db, CancellationToken ct) => + { + var product = await db.Products.FirstOrDefaultAsync(p => p.Id.Value == id, ct) + ?? throw new NotFoundException(nameof(Product), id); + // Negotiations survive on their snapshots by design (spec §6). + db.Products.Remove(product); + await db.SaveChangesAsync(ct); + return TypedResults.NoContent(); + }) + .RequireRoles(UserRoles.Admin); +} +``` + +**Step 6 — Module composition** + +`CatalogModule.cs` (module root): + +```csharp +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using PriceNegotiationApp.BuildingBlocks; +using PriceNegotiationApp.Modules.Catalog.Features.Products; +using PriceNegotiationApp.Modules.Catalog.Persistence; +using PriceNegotiationApp.Modules.Catalog.Seeding; + +namespace PriceNegotiationApp.Modules.Catalog; + +public static class CatalogModule +{ + public static IServiceCollection AddCatalogModule( + this IServiceCollection services, IConfiguration configuration) + { + services.AddDbContext(options => options + .UseNpgsql(DbConnections.Resolve(configuration, "Catalog"), + npgsql => npgsql.MigrationsHistoryTable("__EFMigrationsHistory_Catalog")) + .UseSnakeCaseNamingConvention()); + services.AddOptions() + .Bind(configuration.GetSection(CatalogSeedingOptions.SectionName)); + services.AddHostedService(); + return services; + } + + public static IEndpointRouteBuilder MapCatalogEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/api/v1/products").WithTags("Products"); + group.MapList(); + group.MapGetOne(); + group.MapCreate(); + group.MapUpdate(); + group.MapDelete(); + return app; + } +} +``` + +Remove from `Infrastructure/DependencyInjection.cs`: the `AddDbContext` block, the `IProductRepository` scoped registration, and the transitional `CatalogSeedingHostedService` registration. + +**Step 7 — Host wiring updates** + +`WebApplicationBuilderExtensions.AddApiServices`: add `builder.Services.AddCatalogModule(configuration);` next to the Negotiations call. `PipelineExtensions`: `app.MapProductsApi();` → `app.MapCatalogEndpoints();`. + +Move `MigrationHostedService` to `src/PriceNegotiationApp.Api/Composition/MigrationHostedService.cs` (host references every module; Infrastructure no longer does). Keep the Identity context using pointing at `PriceNegotiationApp.Infrastructure.Persistence` until Task 6 swaps it: + +```csharp +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using PriceNegotiationApp.Infrastructure.Persistence; +using PriceNegotiationApp.Modules.Catalog.Persistence; +using PriceNegotiationApp.Modules.Negotiations.Persistence; + +namespace PriceNegotiationApp.Api.Composition; + +public sealed class MigrationHostedService(IServiceScopeFactory scopeFactory, ILogger logger) + : IHostedService +{ + public async Task StartAsync(CancellationToken cancellationToken) + { + await using var scope = scopeFactory.CreateAsyncScope(); + foreach (var contextType in new[] + { + typeof(IdentityModuleDbContext), + typeof(CatalogDbContext), + typeof(NegotiationsDbContext), + }) + { + var db = (DbContext)scope.ServiceProvider.GetRequiredService(contextType); + await db.Database.MigrateAsync(cancellationToken); + } + + logger.LogInformation("Module databases migrated."); + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} +``` + +Register it in `AddApiServices` via `builder.Services.AddHostedService();` placed BEFORE module registrations (seeders must run after migrations); delete the Infrastructure copy and its DI registration. + +Adapter rewire — `Composition/CatalogToNegotiations.cs` using change only: + +```csharp +using Microsoft.EntityFrameworkCore; +using PriceNegotiationApp.Modules.Catalog.Persistence; +using PriceNegotiationApp.Modules.Negotiations.Ports; +``` + +**Step 8 — Deletions + test project** + +Delete everything listed at the top of this task. Delete `ProductServiceShould.cs`. + +`tests/PriceNegotiationApp.Modules.Catalog.Tests/PriceNegotiationApp.Modules.Catalog.Tests.csproj`: + +```xml + + + Exe + $(NoWarn);CA1707;S1118 + + + + + + + + + + + + + +``` + +Move `ProductRulesShould.cs` here (namespace `PriceNegotiationApp.Modules.Catalog.Tests`; usings remapped per map; `DomainException` assertions stay valid). Port the PUT no-op guard into `UpdateIdempotencyShould.cs`: + +```csharp +using Bogus; +using PriceNegotiationApp.Modules.Catalog.Domain; +using Shouldly; +using Xunit; + +namespace PriceNegotiationApp.Modules.Catalog.Tests; + +public class UpdateIdempotencyShould +{ + private static readonly Faker Faker = new(); + + [Fact] + public void ReturnFalseWhenNothingChanged() + { + var name = Faker.Commerce.ProductName(); + var price = Faker.Random.Decimal(1m, 1_000m); + var product = Product.Create(name, price); + + var changed = product.Update(name, price); + + changed.ShouldBeFalse(); + } + + [Fact] + public void ReturnTrueWhenOnlyWhitespaceDiffers() + { + var padded = $"{Faker.Commerce.ProductName()} "; + var product = Product.Create(Faker.Commerce.ProductName(), 10m); + + var changed = product.Update(padded, 10m); + + changed.ShouldBeTrue(); + product.Name.ShouldBe(padded.Trim()); + } +} +``` + +Add both projects to slnx. + +**Step 9 — Validate** + +```powershell +dotnet build PriceNegotiationApp.slnx +dotnet test tests/PriceNegotiationApp.Modules.Catalog.Tests +dotnet test tests/PriceNegotiationApp.UnitTests +dotnet test tests/PriceNegotiationApp.IntegrationTests +``` + +Full suite green (product matrix byte-identical, including filter/sort/page and cache headers). + +**Step 10 — Commit** + +```bash +git add -A && git commit -m "refactor(modules): carve out Catalog module with own context" +``` + + +--- + +### Task 6: Carve out the Identity module; legacy projects die + +Identity is the last carve-out. When it lands, `Application`, `Domain`, and `Infrastructure` are empty shells and are deleted here — not in a separate cleanup task — because nothing references them anymore. + +**Files:** +- Create: everything under `src/PriceNegotiationApp.Modules.Identity/**` not already present (csproj exists since Task 4) +- Create: `tests/PriceNegotiationApp.Modules.Identity.Tests/**` +- Modify: `WebApplicationBuilderExtensions.cs`, `PipelineExtensions.cs`, `GlobalExceptionHandler.cs` (usings), Api `.csproj` (drop Application/Infrastructure references) +- Delete: `src/PriceNegotiationApp.Application/`, `src/PriceNegotiationApp.Domain/`, `src/PriceNegotiationApp.Infrastructure/` (whole projects), their slnx entries, `Infrastructure/Data/DesignTimeFactories.cs` (superseded by per-module factories) +- Modify: `PriceNegotiationApp.slnx` + +**Interfaces:** +- Consumes: BuildingBlocks (`Policies`, exceptions, `CallerContextExtensions.ToCallerContext`). +- Produces: + - `IdentityModule.AddIdentityModule(this IServiceCollection, IConfiguration)` / `.MapAuthEndpoints(this IEndpointRouteBuilder)` + - `Modules.Identity.Auth.JwtManager` with `Task<(string Token, DateTimeOffset ExpiresAtUtc)> GenerateAsync(Guid userId, string email, IReadOnlyCollection roles)` + - `Modules.Identity.Public.IdentityErrorCodes`: `EmailAlreadyRegistered="email_already_registered"`, `InvalidCredentials="invalid_credentials"`, `AccountLocked="account_locked"`, `RegistrationInvalid="registration_invalid"` + - `Modules.Identity.Seeding.SeedingOptions` (same `"Seeding"` section shape as today) + +**Step 1 — Complete the csproj** + +Replace the Task 4 skeleton with: + +```xml + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + +``` + +**Step 2 — Public contracts** + +`Public/IdentityErrorCodes.cs`: + +```csharp +namespace PriceNegotiationApp.Modules.Identity.Public; + +public static class IdentityErrorCodes +{ + public const string EmailAlreadyRegistered = "email_already_registered"; + + public const string InvalidCredentials = "invalid_credentials"; + + public const string AccountLocked = "account_locked"; + + public const string RegistrationInvalid = "registration_invalid"; +} +``` + +(`Public/UserRoles.cs` already exists from Task 4.) + +**Step 3 — Auth** + +Move verbatim (namespace → `PriceNegotiationApp.Modules.Identity.Auth`): `JwtOptions.cs`, `JwtOptionsValidator.cs`. Move `JwtManager.cs` with one change — drop the `IJwtTokenGenerator` implementation (the port dies with Application); constructor and body otherwise identical: + +```csharp +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; + +namespace PriceNegotiationApp.Modules.Identity.Auth; + +public sealed class JwtManager(IOptions options, TimeProvider clock) +{ + public Task<(string Token, DateTimeOffset ExpiresAtUtc)> GenerateAsync( + Guid userId, string email, IReadOnlyCollection roles) + { + // body identical to the current Infrastructure/Auth/JwtManager.cs implementation + // (claims: sub, email, jti=Guid.CreateVersion7(), role per role; HS256; notBefore/expires from clock) + } +} +``` + +(Copy the body from the existing file — do not retype it from this sketch.) + +**Step 4 — Persistence** + +Move `ApplicationUser.cs` → `Persistence/ApplicationUser.cs` (namespace `PriceNegotiationApp.Modules.Identity.Persistence`). + +Move `IdentityModuleDbContext.cs` (created in Task 1) → same folder, namespace `PriceNegotiationApp.Modules.Identity.Persistence`. + +Create `Persistence/DesignTimeDbContextFactory.cs`: + +```csharp +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace PriceNegotiationApp.Modules.Identity.Persistence; + +public sealed class DesignTimeDbContextFactory : IDesignTimeDbContextFactory +{ + public IdentityModuleDbContext CreateDbContext(string[] args) => + new(new DbContextOptionsBuilder() + .UseNpgsql("Host=localhost;Port=5432;Database=pricenego_design;Username=postgres;Password=postgres", + npgsql => npgsql.MigrationsHistoryTable("__EFMigrationsHistory_Identity")) + .UseSnakeCaseNamingConvention() + .Options); +} +``` + +Regenerate migrations: + +```powershell +dotnet ef migrations add Initial --context IdentityModuleDbContext ` + -p src/PriceNegotiationApp.Modules.Identity -o Persistence/Migrations +``` + +(Append `DELETE FROM "__EFMigrationsHistory_Identity";` to the cleanup-script notes.) + +**Step 5 — Seeding** + +Move `SeedingOptions.cs` verbatim (namespace → `PriceNegotiationApp.Modules.Identity.Seeding`). Move `IdentitySeedingHostedService.cs` from Infrastructure into the module, swapping its usings to the module namespaces (`UserRoles` → `PriceNegotiationApp.Modules.Identity.Public`, user types → `...Identity.Persistence`). Delete the Infrastructure copy and its DI registration. + +**Step 6 — Feature slices** + +`Features/Auth/AuthModels.cs`: + +```csharp +namespace PriceNegotiationApp.Modules.Identity.Features.Auth; + +public sealed class RegisterRequest +{ + public string Email { get; init; } = string.Empty; + + public string Password { get; init; } = string.Empty; +} + +public sealed class LoginRequest +{ + public string Email { get; init; } = string.Empty; + + public string Password { get; init; } = string.Empty; +} + +public sealed record RegistrationResponse(Guid UserId); + +public sealed record AuthResponse( + string AccessToken, + DateTimeOffset ExpiresAtUtc, + string Email, + IReadOnlyList Roles); + +public sealed record CurrentUserResponse(Guid UserId, string Email, IReadOnlyList Roles); +``` + +`Features/Auth/Register.cs` (logic merged verbatim from `AuthService.RegisterAsync` + `IdentityAccountStore.RegisterAsync`): + +```csharp +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using PriceNegotiationApp.BuildingBlocks; +using PriceNegotiationApp.Modules.Identity.Persistence; +using PriceNegotiationApp.Modules.Identity.Public; + +namespace PriceNegotiationApp.Modules.Identity.Features.Auth; + +internal static class Register +{ + internal static RouteGroupBuilder MapRegister(this RouteGroupBuilder group) => + group.MapPost("/register", async (RegisterRequest request, + UserManager userManager, CancellationToken ct) => + { + var user = new ApplicationUser { UserName = request.Email, Email = request.Email }; + var result = await userManager.CreateAsync(user, request.Password); + if (!result.Succeeded) + { + if (result.Errors.Any(e => e.Code is "DuplicateEmail" or "DuplicateUserName")) + { + throw new ConflictException(IdentityErrorCodes.EmailAlreadyRegistered, + "Email already registered."); + } + + throw new InvalidRequestException(IdentityErrorCodes.RegistrationInvalid, + string.Join("; ", result.Errors.Select(e => e.Description))); + } + + await userManager.AddToRoleAsync(user, UserRoles.Customer); + return TypedResults.Created("/api/v1/auth/me", new RegistrationResponse(user.Id)); + }) + .RequireRateLimiting(Policies.AuthRateLimitPolicy) + .AllowAnonymous(); +} +``` + +`Features/Auth/Login.cs` (merged verbatim from `AuthService.LoginAsync` + `IdentityAccountStore.PasswordSignInAsync`; the original's post-sign-in `FindByEmailAsync` round-trip collapses onto the loaded user — externally identical): + +```csharp +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using PriceNegotiationApp.BuildingBlocks; +using PriceNegotiationApp.Modules.Identity.Auth; +using PriceNegotiationApp.Modules.Identity.Persistence; +using PriceNegotiationApp.Modules.Identity.Public; + +namespace PriceNegotiationApp.Modules.Identity.Features.Auth; + +internal static class Login +{ + internal static RouteGroupBuilder MapLogin(this RouteGroupBuilder group) => + group.MapPost("/login", async (LoginRequest request, UserManager userManager, + JwtManager jwt, CancellationToken ct) => + { + var user = await userManager.FindByNameAsync(request.Email) + ?? throw new UnauthorizedException( + IdentityErrorCodes.InvalidCredentials, "Invalid credentials."); + + if (await userManager.IsLockedOutAsync(user)) + { + throw new UnauthorizedException(IdentityErrorCodes.AccountLocked, + "Account temporarily locked."); + } + + if (!await userManager.CheckPasswordAsync(user, request.Password)) + { + await userManager.AccessFailedAsync(user); + throw await userManager.IsLockedOutAsync(user) + ? new UnauthorizedException(IdentityErrorCodes.AccountLocked, + "Account temporarily locked.") + : new UnauthorizedException(IdentityErrorCodes.InvalidCredentials, + "Invalid credentials."); + } + + await userManager.ResetAccessFailedCountAsync(user); + + var roles = (IReadOnlyList)await userManager.GetRolesAsync(user); + var (token, expiresAtUtc) = await jwt.GenerateAsync(user.Id, request.Email, roles); + return TypedResults.Ok(new AuthResponse(token, expiresAtUtc, request.Email, roles)); + }) + .RequireRateLimiting(Policies.AuthRateLimitPolicy) + .AllowAnonymous(); +} +``` + +`Features/Auth/Me.cs`: + +```csharp +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using System.Security.Claims; +using PriceNegotiationApp.BuildingBlocks; + +namespace PriceNegotiationApp.Modules.Identity.Features.Auth; + +internal static class Me +{ + internal static RouteGroupBuilder MapMe(this RouteGroupBuilder group) => + group.MapGet("/me", (ClaimsPrincipal principal) => + { + var caller = principal.ToCallerContext(); + return TypedResults.Ok(new CurrentUserResponse(caller.UserId, caller.Email, caller.Roles.ToList())); + }) + .RequireAuthorization(); +} +``` + +**Step 7 — Module composition** + +`IdentityModule.cs`: + +```csharp +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using PriceNegotiationApp.BuildingBlocks; +using PriceNegotiationApp.Modules.Identity.Auth; +using PriceNegotiationApp.Modules.Identity.Features.Auth; +using PriceNegotiationApp.Modules.Identity.Persistence; +using PriceNegotiationApp.Modules.Identity.Seeding; + +namespace PriceNegotiationApp.Modules.Identity; + +public static class IdentityModule +{ + public static IServiceCollection AddIdentityModule( + this IServiceCollection services, IConfiguration configuration) + { + services.AddDbContext(options => options + .UseNpgsql(DbConnections.Resolve(configuration, "Identity"), + npgsql => npgsql.MigrationsHistoryTable("__EFMigrationsHistory_Identity")) + .UseSnakeCaseNamingConvention()); + + services.AddIdentityCore(options => + { + options.Lockout.AllowedForNewUsers = true; + options.Lockout.MaxFailedAccessAttempts = 5; + options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15); + }) + .AddRoles>() + .AddEntityFrameworkStores() + .AddDefaultTokenProviders(); + + services.AddOptions() + .Bind(configuration.GetSection(JwtOptions.SectionName)) + .ValidateOnStart(); + services.AddSingleton, JwtOptionsValidator>(); + services.AddSingleton(TimeProvider.System); + services.AddSingleton(); + + services.AddOptions() + .Bind(configuration.GetSection(SeedingOptions.SectionName)); + services.AddHostedService(); + + return services; + } + + public static IEndpointRouteBuilder MapAuthEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/api/v1/auth").WithTags("Auth"); + group.MapRegister(); + group.MapLogin(); + group.MapMe(); + return app; + } +} +``` + +(The duplicate `AddSingleton(TimeProvider.System)` across modules is harmless — identical implementation, last registration wins.) + +**Step 8 — Host wiring + legacy deletion** + +1. `WebApplicationBuilderExtensions.AddApiServices`: add `builder.Services.AddIdentityModule(configuration);`. Remove the `AddApplicationServices()` / `AddInfrastructure(configuration)` calls. +2. Delete the legacy projects entirely (they are empty shells now): + +```powershell +git rm -r src/PriceNegotiationApp.Application src/PriceNegotiationApp.Domain src/PriceNegotiationApp.Infrastructure +``` + +3. Api `.csproj`: delete the `` lines for Application and Infrastructure. +4. `PipelineExtensions.MapModules`: `app.MapAuthApi();` → `app.MapAuthEndpoints();`. +5. `Composition/MigrationHostedService.cs` using swap: `PriceNegotiationApp.Infrastructure.Persistence` → `PriceNegotiationApp.Modules.Identity.Persistence`. +6. `GlobalExceptionHandler.cs` final usings: + +```csharp +using PriceNegotiationApp.BuildingBlocks; +using PriceNegotiationApp.Modules.Negotiations.Domain; +using PriceNegotiationApp.Modules.Negotiations.Features; +using Vogen; +``` + +Switch arms unchanged except sources: the two negotiation exceptions resolve from Negotiations domain; code values come from `NegotiationErrorCodes.ProposalExceedsLimit` / `.NegotiationClosed`. + +7. Health checks stay three `AddDbContextCheck` calls with module context types. +8. JWT inbound validation stays exactly as-is (`JwtSettings` binds the same `"Jwt"` section as the module's `JwtOptions` — issuance and validation read one configuration from different assemblies, by design). +9. Remove the deleted projects' slnx entries. + +**Step 9 — Identity unit tests** + +`tests/PriceNegotiationApp.Modules.Identity.Tests/PriceNegotiationApp.Modules.Identity.Tests.csproj`: + +```xml + + + Exe + $(NoWarn);CA1707;S1118 + + + + + + + + + + + + + +``` + +Move `JwtManagerShould.cs` (namespace `PriceNegotiationApp.Modules.Identity.Tests`; using swap to `PriceNegotiationApp.Modules.Identity.Auth`). If `UnitTests` is now empty, delete the project and its slnx entry. + +**Step 10 — Validate** + +```powershell +dotnet build PriceNegotiationApp.slnx +dotnet test tests/PriceNegotiationApp.Modules.Identity.Tests +dotnet test tests/PriceNegotiationApp.IntegrationTests +``` + +Full suite green: auth flows (register/login/me/lockout/duplicates), products, negotiations — byte-identical over HTTP. + +**Step 11 — Commit** + +```bash +git add -A && git commit -m "refactor(modules): carve out Identity module; delete legacy layered projects" +``` + + +--- + +### Task 7: Rename Api → AppHost; update solution, Docker, CI + +**Files:** +- Rename: `src/PriceNegotiationApp.Api/` → `src/PriceNegotiationApp.AppHost/` (+ csproj filename) +- Modify: every file under that folder with namespace `PriceNegotiationApp.Api` (≈10 files), `PriceNegotiationApp.slnx`, `Dockerfile`, `.github/workflows/ci.yml` + +**Interfaces:** +- Produces: + - Root namespace `PriceNegotiationApp.AppHost`; project `PriceNegotiationApp.AppHost.csproj` + - OTel service name `PriceNegotiationApp.AppHost` + - Runtime entrypoint dll `PriceNegotiationApp.AppHost.dll` + +- [ ] **Step 1: Rename folder + project** + +```powershell +Rename-Item src/PriceNegotiationApp.Api PriceNegotiationApp.AppHost +Rename-Item src/PriceNegotiationApp.AppHost/PriceNegotiationApp.Api.csproj PriceNegotiationApp.AppHost.csproj +``` + +- [ ] **Step 2: Namespace sweep** + +Global replace `namespace PriceNegotiationApp.Api` → `namespace PriceNegotiationApp.AppHost`, plus all `using PriceNegotiationApp.Api...` occurrences anywhere in the repo (integration tests do not use them — they go through HTTP only). Files affected: `Program.cs`, `GlobalExceptionHandler.cs`, `Extensions/*.cs` (4), `Composition/*.cs` (2–3). + +In `WebApplicationBuilderExtensions`, update the OTel resource: + +```csharp +.ConfigureResource(resource => resource.AddService("PriceNegotiationApp.AppHost")) +``` + +- [ ] **Step 3: slnx** + +The `/src/` folder must list exactly: + +```xml + + + + + +``` + +and `/tests/` lists the integration project plus whichever module test projects survived Task 6 (Identity/Catalog/Negotiations Tests). + +- [ ] **Step 4: Dockerfile** + +Update build/publish paths and entrypoint (rest of file unchanged): + +```dockerfile +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src +COPY Directory.Build.props Directory.Packages.props ./ +COPY src ./src +RUN dotnet restore src/PriceNegotiationApp.AppHost/PriceNegotiationApp.AppHost.csproj +RUN dotnet publish src/PriceNegotiationApp.AppHost/PriceNegotiationApp.AppHost.csproj -c Release -o /app --no-restore + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime +WORKDIR /app +COPY --from=build /app . +EXPOSE 8080 +ENV ASPNETCORE_URLS=http://+:8080 +USER app +HEALTHCHECK --interval=30s --timeout=5s CMD ["/usr/bin/wget", "-qO-", "http://localhost:8080/health/live"] +ENTRYPOINT ["dotnet", "PriceNegotiationApp.AppHost.dll"] +``` + +- [ ] **Step 5: CI** + +`.github/workflows/ci.yml`: keep restore/format/build; collapse testing into one solution-wide step: + +```yaml + - name: Test + run: dotnet test PriceNegotiationApp.slnx -c Release --no-build --collect:"XPlat Code Coverage" +``` + +- [ ] **Step 6: Dependency-graph audit** + +```powershell +Get-ChildItem -Recurse -Filter *.csproj | Where-Object FullName -notmatch '\\(bin|obj)\\' | + Select-String -Pattern 'ProjectReference' +``` + +Expected: AppHost → the three modules (direct BuildingBlocks reference optional); each module → BuildingBlocks only; **no module references another module**. If a module-to-module edge appears, refactor it through a host adapter before proceeding. + +- [ ] **Step 7: Validate** + +```powershell +dotnet build PriceNegotiationApp.slnx +dotnet test PriceNegotiationApp.slnx +docker compose up --build -d ; Start-Sleep 20 ; curl http://localhost:8080/health/ready ; docker compose down +``` + +(Compose smoke optional locally when Docker is unavailable — CI covers it.) + +- [ ] **Step 8: Commit** + +```bash +git add -A && git commit -m "refactor(host): rename Api to AppHost, finalize solution graph and platform files" +``` + +--- + +### Task 8: Documentation, final sweep, definition-of-done + +**Files:** +- Modify: `README.md`, `docs/sql/cleanup-legacy-tables.sql` (notes); review-only: `PriceNegotiationApp.http` (routes unchanged) + +- [ ] **Step 1: README architecture section** + +Replace the Architecture block with: + +````markdown +## Architecture + +Modular monolith: three bounded contexts behind compiler-enforced boundaries, +one PostgreSQL schema per context. + +``` +src/ + PriceNegotiationApp.AppHost composition root: pipeline, authN/authZ, + │ ProblemDetails, rate limiting, CORS, output + │ caching, health checks, OTel; wires modules + │ and the single inter-module adapter + PriceNegotiationApp.BuildingBlocks shared primitives (CallerContext, paging, + error semantics, policy names) + PriceNegotiationApp.Modules.Identity users/roles/JWT issuance/seeding → schema identity + PriceNegotiationApp.Modules.Catalog products → schema catalog + PriceNegotiationApp.Modules.Negotiations negotiations/customers/policy → schema negotiations + +tests/ + PriceNegotiationApp.Modules.*.Tests per-module unit tests (public surface only) + PriceNegotiationApp.IntegrationTests WebApplicationFactory + Testcontainers PostgreSQL +``` + +Rules: modules never reference each other; cross-module interaction flows through +consumer-owned ports wired in AppHost (`Composition/CatalogToNegotiations` is currently +the only edge). Each context owns its migrations; startup applies them in order +identity → catalog → negotiations. +```` + +Add configuration row: + +```markdown +| `Database:Modules:{Identity\|Catalog\|Negotiations}:ConnectionString` | optional per-module DB override; defaults to `Database:ConnectionString` | +``` + +Add negotiation rule: + +```markdown +6. Deleting a product does not delete or block its negotiations — they keep their + price snapshot (product existence is only validated when a negotiation is created). +``` + +Add migration cheat sheet: + +````markdown +### Migrations + +Each module owns its migration stream (history tables live in the default schema): + +```bash +dotnet ef migrations add --context CatalogDbContext ` + -p src/PriceNegotiationApp.Modules.Catalog -o Persistence/Migrations +``` +```` + +- [ ] **Step 2: Cleanup-script notes** + +Ensure `docs/sql/cleanup-legacy-tables.sql` reads: + +```sql +-- One-time maintenance after the first successful start of the new version +-- on an upgraded persistent database: +DELETE FROM "__EFMigrationsHistory_Identity"; +DELETE FROM "__EFMigrationsHistory_Catalog"; +DELETE FROM "__EFMigrationsHistory_Negotiations"; +DROP TABLE IF EXISTS public.negotiations CASCADE; +DROP TABLE IF EXISTS public.customers CASCADE; +DROP TABLE IF EXISTS public.products CASCADE; +DROP TABLE IF EXISTS public.__efmigrations_history CASCADE; +``` + +- [ ] **Step 3: Final sweep** + +```powershell +dotnet format --verify-no-changes +dotnet build PriceNegotiationApp.slnx -c Release +dotnet test PriceNegotiationApp.slnx -c Release +``` + +Fix anything flagged; commit fixes separately if non-trivial. + +- [ ] **Step 4: Definition-of-done checklist (spec §11)** + +- [ ] Each DbContext lives in exactly one module project. +- [ ] No module `.csproj` references another module. +- [ ] `dotnet ef migrations list --context X` shows an independent stream per context. +- [ ] Integration suite passes unchanged, plus the two additions from Task 2. +- [ ] No `IRepository`/`IUnitOfWork` symbols remain. +- [ ] `.env` untracked; stale folders gone; warnings-as-errors Release build green. + +- [ ] **Step 5: Commit** + +```bash +git add -A && git commit -m "docs: modular monolith architecture, config and migration guide" +``` + +--- + +## Execution Notes for Reviewers + +- **Regression gate:** Tasks 2–6 each end with the full integration suite green. Any drift in status codes or payload shapes is a task failure — stop and reconcile against the spec's frozen-contract rule instead of editing tests to match drift. +- **Superseded blocks:** Task 4 contains two marked superseded snippets (`CreateNegotiationRequest`, first `FindOpenAsync`) — ship the second version of each pair. Same for Task 6 (`Me.cs`). +- **Parallelization:** Tasks 4, 5, 6 touch disjoint projects but share `PipelineExtensions` / `WebApplicationBuilderExtensions` / slnx edits — run them serially, or resolve those three shared files manually if parallelizing. +- **Test-count expectation:** unit-test count drops slightly (`*ServiceShould` files deleted with their subjects); coverage moves into the integration matrices where those branches are exercised over HTTP. This is intentional per spec §9. diff --git a/docs/superpowers/plans/2026-08-24-project-structure-restructure.md b/docs/superpowers/plans/2026-08-24-project-structure-restructure.md new file mode 100644 index 0000000..7d4a844 --- /dev/null +++ b/docs/superpowers/plans/2026-08-24-project-structure-restructure.md @@ -0,0 +1,909 @@ +# Project Structure Restructure Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Restructure the modular monolith per `docs/superpowers/specs/2026-08-24-project-structure-design.md`: rename projects, normalize module layouts, slim the shared kernel, de-duplicate plumbing, enforce compile-time ownership, and remove cruft. + +**Architecture:** Modular monolith: `Api` host (composition root) → three feature modules → `SharedKernel` primitive library. Modules reference only SharedKernel; the one inter-module edge is `Ports/IProductPriceProvider` implemented by an Api adapter. After this restructure, module implementation types are `internal`; the composition root and each module's own test project get `InternalsVisibleTo` grants. + +**Tech Stack:** .NET 10, ASP.NET Core minimal APIs, EF Core + Npgsql (one DB per module), xunit.v3 + Shouldly, Testcontainers for integration tests, Central Package Management. + +## Global Constraints + +- Target framework `net10.0` everywhere (set centrally in `Directory.Build.props` — never set per-project). +- `TreatWarningsAsErrors=true`, analyzers enforced — every build must pass clean. +- Central Package Management via `Directory.Packages.props`: never put `Version=` on a `PackageReference`. +- **No API route, response-contract, database schema, migration, or feature behavior changes.** +- Only one new package reference allowed in this whole plan: `Microsoft.EntityFrameworkCore.Design` added to SharedKernel (already versioned in CPM). +- All shell commands are PowerShell 7 (`pwsh`). Run from repo root unless stated otherwise. +- Use `git mv` for moves/renames so history follows files. +- When bulk-replacing text in `.cs` files, ALWAYS exclude `bin`/`obj` directories. +- Test commands: unit test projects run without Docker; integration tests need a running Docker daemon (Testcontainers). + +## File Structure (end state) + +``` +src/ +├── PriceNegotiationApp.Api/ (renamed from AppHost) +│ ├── Composition/{MigrationHostedService,CatalogToNegotiations}.cs [modified] +│ ├── Extensions/{WebApplicationBuilderExtensions,PipelineExtensions,JwtSettings,RateLimitingOptions}.cs [modified] +│ ├── GlobalExceptionHandler.cs, Program.cs [modified] +├── PriceNegotiationApp.SharedKernel/ (renamed from BuildingBlocks) +│ ├── CallerContext.cs, CallerContextExtensions.cs, DbConnections.cs, EndpointConventionExtensions.cs, +│ │ ErrorCodes.cs, Exceptions.cs, PagedResult.cs, PageQuery.cs, Policies.cs, UserRoles.cs [renamed ns only] +│ ├── ModuleSeedingHostedServiceBase.cs [new] +│ ├── DesignTimeDbContextFactoryBase.cs [new] +│ ├── ProductQuery.cs [deleted — moved to Catalog] +├── PriceNegotiationApp.Modules.Catalog/ +│ ├── CatalogModule.cs, CatalogEndpoints.cs [modified] +│ ├── Features/Products/{Create,Get,List,Update,Delete,ProductModels}.cs [modified] +│ ├── Features/Products/ProductQuery.cs [moved from SharedKernel] +│ ├── Persistence/… [DesignTimeDbContextFactory rewritten] +│ ├── Seeding/CatalogSeedingHostedService.cs [rewritten onto base] +├── PriceNegotiationApp.Modules.Identity/ +│ ├── IdentityModule.cs, IdentityEndpoints.cs [modified] +│ ├── Features/Auth/{Login,Register,Me,AuthModels,JwtManager,JwtOptions,JwtOptionsValidator}.cs +│ │ (Jwt* moved from Auth/ folder) +│ ├── Persistence/…, Public/IdentityErrorCodes.cs [factory rewritten; Public unchanged] +│ └── Seeding/{IdentitySeedingHostedService,SeedingOptions}.cs [rewritten / trimmed] +├── PriceNegotiationApp.Modules.Negotiations/ +│ ├── NegotiationsModule.cs, NegotiationEndpoints.cs [modified] +│ ├── Domain/… [ns changes only] +│ ├── Features/Negotiations/{Accept,CounterPropose,Create,Decline,Get,List,ListMine,Withdraw,NegotiationAccess,NegotiationModels}.cs +│ │ (moved from flat Features/) +│ ├── Ports/IProductPriceProvider.cs [unchanged, stays public] +│ └── Persistence/… [factory rewritten] +tests/ +├── PriceNegotiationApp.IntegrationTests/ [csproj ref path updated] +├── PriceNegotiationApp.Modules.{Catalog|Identity|Negotiations}.Tests [usings updated] +(root) Directory.Packages.props, Dockerfile, PriceNegotiationApp.slnx, README.md [modified] +``` + +--- + +### Task 1: Rename `AppHost` project to `Api` + +**Files:** +- Rename: `src/PriceNegotiationApp.AppHost/` → `src/PriceNegotiationApp.Api/` (folder + csproj) +- Modify: `PriceNegotiationApp.slnx`, `Dockerfile`, `tests/PriceNegotiationApp.IntegrationTests/PriceNegotiationApp.IntegrationTests.csproj` +- Modify: all `.cs` files containing namespace `PriceNegotiationApp.AppHost` (Program.cs, Extensions/*, Composition/*, GlobalExceptionHandler.cs) + +**Interfaces:** +- Produces: root namespace/assembly `PriceNegotiationApp.Api`; solution entry `src/PriceNegotiationApp.Api/PriceNegotiationApp.Api.csproj`. Later tasks and the final verification rely on this exact name. + +- [ ] **Step 1: Move folder and rename csproj** + +```bash +git mv src/PriceNegotiationApp.AppHost src/PriceNegotiationApp.Api +git mv src/PriceNegotiationApp.Api/PriceNegotiationApp.AppHost.csproj src/PriceNegotiationApp.Api/PriceNegotiationApp.Api.csproj +``` + +- [ ] **Step 2: Replace namespace and string literals in source** + +The literal `"PriceNegotiationApp.AppHost"` also appears as the OpenTelemetry service name in `WebApplicationBuilderExtensions.cs`; a plain text replace fixes both. + +```powershell +Get-ChildItem src,tests -Recurse -Filter *.cs | + Where-Object { $_.FullName -notmatch '\\(bin|obj)\\' } | + ForEach-Object { + $c = Get-Content $_.FullName -Raw + if ($c -match 'PriceNegotiationApp\.AppHost') { + Set-Content $_.FullName ($c -replace 'PriceNegotiationApp\.AppHost', 'PriceNegotiationApp.Api') -NoNewline + } + } +``` + +- [ ] **Step 3: Update solution, Dockerfile, and test project reference** + +In `PriceNegotiationApp.slnx`, change line: +```xml + +``` +to: +```xml + +``` + +In `Dockerfile`, change the two build/publish paths and the ENTRYPOINT: +```dockerfile +RUN dotnet restore src/PriceNegotiationApp.Api/PriceNegotiationApp.Api.csproj +RUN dotnet publish src/PriceNegotiationApp.Api/PriceNegotiationApp.Api.csproj -c Release -o /app --no-restore +ENTRYPOINT ["dotnet", "PriceNegotiationApp.Api.dll"] +``` + +In `tests/PriceNegotiationApp.IntegrationTests/PriceNegotiationApp.IntegrationTests.csproj`, change: +```xml + +``` + +- [ ] **Step 4: Validate** + +```bash +dotnet build PriceNegotiationApp.slnx +dotnet test tests/PriceNegotiationApp.IntegrationTests --no-build --filter "Category!=Skip" +``` +(If Docker isn't available, note it and continue — Task 13 runs the full suite.) + +- [ ] **Step 5: Commit** + +```bash +git add -A +git commit -m "refactor: rename AppHost project to Api" +``` + +--- + +### Task 2: Rename `BuildingBlocks` project to `SharedKernel` + +**Files:** +- Rename: `src/PriceNegotiationApp.BuildingBlocks/` → `src/PriceNegotiationApp.SharedKernel/` (folder + csproj) +- Modify: `PriceNegotiationApp.slnx`, all four src csproj `ProjectReference` entries, every `.cs` file with namespace/usings `PriceNegotiationApp.BuildingBlocks` (in src and tests) + +**Interfaces:** +- Consumes: nothing from Task 1 except the updated solution. +- Produces: root namespace/assembly `PriceNegotiationApp.SharedKernel`. All later tasks use `using PriceNegotiationApp.SharedKernel;`. + +- [ ] **Step 1: Move folder and rename csproj** + +```bash +git mv src/PriceNegotiationApp.BuildingBlocks src/PriceNegotiationApp.SharedKernel +git mv src/PriceNegotiationApp.SharedKernel/PriceNegotiationApp.BuildingBlocks.csproj src/PriceNegotiationApp.SharedKernel/PriceNegotiationApp.SharedKernel.csproj +``` + +- [ ] **Step 2: Replace namespace in source** + +```powershell +Get-ChildItem src,tests -Recurse -Filter *.cs | + Where-Object { $_.FullName -notmatch '\\(bin|obj)\\' } | + ForEach-Object { + $c = Get-Content $_.FullName -Raw + if ($c -match 'PriceNegotiationApp\.BuildingBlocks') { + Set-Content $_.FullName ($c -replace 'PriceNegotiationApp\.BuildingBlocks', 'PriceNegotiationApp.SharedKernel') -NoNewline + } + } +``` + +- [ ] **Step 3: Update csproj references and solution** + +In `src/PriceNegotiationApp.Api/PriceNegotiationApp.Api.csproj`: +```xml + +``` +In each of `src/PriceNegotiationApp.Modules.{Catalog,Identity,Negotiations}/*.csproj`: +```xml + +``` +In `PriceNegotiationApp.slnx`: +```xml + +``` + +- [ ] **Step 4: Validate** + +```bash +dotnet build PriceNegotiationApp.slnx +``` + +- [ ] **Step 5: Commit** + +```bash +git add -A +git commit -m "refactor: rename BuildingBlocks project to SharedKernel" +``` + +--- + +### Task 3: Move `ProductQuery` out of SharedKernel into Catalog + +`ProductQuery` is Catalog's list-query DTO; the shared kernel must contain nothing module-specific. + +**Files:** +- Move: `src/PriceNegotiationApp.SharedKernel/ProductQuery.cs` → `src/PriceNegotiationApp.Modules.Catalog/Features/Products/ProductQuery.cs` + +**Interfaces:** +- Consumes: nothing. +- Produces: `PriceNegotiationApp.Modules.Catalog.Features.Products.ProductQuery` (record, same shape: `string? Search, decimal? MinPrice, decimal? MaxPrice, string? SortBy, bool SortDesc, int Page, int PageSize`). Same namespace as its only consumer (`List.cs`), which needs no using change. + +- [ ] **Step 1: Move and re-namespace** + +```bash +git mv src/PriceNegotiationApp.SharedKernel/ProductQuery.cs src/PriceNegotiationApp.Modules.Catalog/Features/Products/ProductQuery.cs +``` + +Edit `src/PriceNegotiationApp.Modules.Catalog/Features/Products/ProductQuery.cs` — replace the first line: +```csharp +namespace PriceNegotiationApp.SharedKernel; +``` +with: +```csharp +namespace PriceNegotiationApp.Modules.Catalog.Features.Products; +``` + +- [ ] **Step 2: Validate** + +```bash +dotnet build PriceNegotiationApp.slnx +dotnet test tests/PriceNegotiationApp.Modules.Catalog.Tests --no-build +``` + +- [ ] **Step 3: Commit** + +```bash +git add -A +git commit -m "refactor: move ProductQuery into Catalog module" +``` + +--- + +### Task 4: Add shared plumbing bases to SharedKernel + +Two bases remove the triplicated hosted-service ceremony and design-time-factory boilerplate. The design-time base deliberately keeps provider configuration (`UseNpgsql`) in the modules so Npgsql never becomes a SharedKernel dependency. + +**Files:** +- Create: `src/PriceNegotiationApp.SharedKernel/ModuleSeedingHostedServiceBase.cs` +- Create: `src/PriceNegotiationApp.SharedKernel/DesignTimeDbContextFactoryBase.cs` +- Modify: `src/PriceNegotiationApp.SharedKernel/PriceNegotiationApp.SharedKernel.csproj` + +**Interfaces:** +- Produces (used verbatim by Tasks 5–6): + - `abstract class ModuleSeedingHostedServiceBase(IServiceScopeFactory scopeFactory) : IHostedService` with `protected abstract Task SeedAsync(IServiceProvider services, CancellationToken cancellationToken);` + - `abstract class DesignTimeDbContextFactoryBase : IDesignTimeDbContextFactory where TContext : DbContext` with `protected const string LocalConnectionString`, `protected abstract void Configure(DbContextOptionsBuilder builder);` and `protected abstract TContext Create(DbContextOptions options);` + +- [ ] **Step 1: Add EF Design package to SharedKernel csproj** + +`IDesignTimeDbContextFactory` lives in the `Microsoft.EntityFrameworkCore.Design` package (dev-time only, already versioned centrally). Add this ItemGroup to `src/PriceNegotiationApp.SharedKernel/PriceNegotiationApp.SharedKernel.csproj` (the csproj currently has only the FrameworkReference item group): +```xml + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + +``` + +- [ ] **Step 2: Create the seeding base** + +Create `src/PriceNegotiationApp.SharedKernel/ModuleSeedingHostedServiceBase.cs`: +```csharp +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace PriceNegotiationApp.SharedKernel; + +/// +/// Runs a module's seed routine once at host start inside a scope that is disposed afterwards. +/// +public abstract class ModuleSeedingHostedServiceBase(IServiceScopeFactory scopeFactory) : IHostedService +{ + public async Task StartAsync(CancellationToken cancellationToken) + { + await using var scope = scopeFactory.CreateAsyncScope(); + await SeedAsync(scope.ServiceProvider, cancellationToken); + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + /// Seed the module's data. Resolve services from . + protected abstract Task SeedAsync(IServiceProvider services, CancellationToken cancellationToken); +} +``` + +- [ ] **Step 3: Create the design-time factory base** + +Create `src/PriceNegotiationApp.SharedKernel/DesignTimeDbContextFactoryBase.cs`: +```csharp +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace PriceNegotiationApp.SharedKernel; + +/// +/// Common plumbing for EF Core design-time factories. Provider configuration stays in each +/// module on purpose: Npgsql must not become a SharedKernel dependency. +/// +public abstract class DesignTimeDbContextFactoryBase : IDesignTimeDbContextFactory + where TContext : DbContext +{ +#pragma warning disable S2068 // Design-time default only; never used in production wiring. + protected const string LocalConnectionString = + "Host=localhost;Port=5432;Database=pricenego_design;Username=postgres;Password=postgres"; +#pragma warning restore S2068 + + public TContext CreateDbContext(string[] args) + { + var builder = new DbContextOptionsBuilder(); + Configure(builder); + return Create(builder.Options); + } + + /// Apply provider options, e.g. UseNpgsql(LocalConnectionString, …) and naming conventions. + protected abstract void Configure(DbContextOptionsBuilder builder); + + /// Create the context instance, typically `new TContext(options)`. + protected abstract TContext Create(DbContextOptions options); +} +``` + +- [ ] **Step 4: Validate** + +```bash +dotnet build PriceNegotiationApp.slnx +``` + +- [ ] **Step 5: Commit** + +```bash +git add src/PriceNegotiationApp.SharedKernel +git commit -m "feat: add shared seeding and design-time factory bases to SharedKernel" +``` + +--- + +### Task 5: Rebuild module seeders on the shared base + +Also removes a dead property (`SeedSampleProducts`) copied into Identity's `SeedingOptions` where no seeder reads it. + +**Files:** +- Rewrite: `src/PriceNegotiationApp.Modules.Catalog/Seeding/CatalogSeedingHostedService.cs` +- Rewrite: `src/PriceNegotiationApp.Modules.Identity/Seeding/IdentitySeedingHostedService.cs` +- Modify: `src/PriceNegotiationApp.Modules.Identity/Seeding/SeedingOptions.cs` (remove dead property) + +**Interfaces:** +- Consumes: `ModuleSeedingHostedServiceBase` exactly as defined in Task 4. + +- [ ] **Step 1: Rewrite the Catalog seeder** + +Replace the entire content of `src/PriceNegotiationApp.Modules.Catalog/Seeding/CatalogSeedingHostedService.cs` with: +```csharp +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using PriceNegotiationApp.Modules.Catalog.Domain; +using PriceNegotiationApp.Modules.Catalog.Persistence; +using PriceNegotiationApp.SharedKernel; + +namespace PriceNegotiationApp.Modules.Catalog.Seeding; + +public sealed class CatalogSeedingHostedService( + IServiceScopeFactory scopeFactory, + IOptions options, + ILogger logger) : ModuleSeedingHostedServiceBase(scopeFactory) +{ + protected override async Task SeedAsync(IServiceProvider services, CancellationToken cancellationToken) + { + if (!options.Value.SeedSampleProducts) + { + return; + } + + var db = services.GetRequiredService(); + if (!await db.Products.AnyAsync(cancellationToken)) + { + db.Products.AddRange( + Product.Create("Mechanical Keyboard", 249.00m), + Product.Create("Wireless Mouse", 79.90m), + Product.Create("USB-C Docking Station", 189.50m)); + await db.SaveChangesAsync(cancellationToken); + } + + logger.LogInformation("Catalog seed data ensured."); + } +} +``` + +- [ ] **Step 2: Rewrite the Identity seeder** + +Replace the entire content of `src/PriceNegotiationApp.Modules.Identity/Seeding/IdentitySeedingHostedService.cs` with: +```csharp +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using PriceNegotiationApp.Modules.Identity.Persistence; +using PriceNegotiationApp.SharedKernel; + +namespace PriceNegotiationApp.Modules.Identity.Seeding; + +public sealed class IdentitySeedingHostedService( + IServiceScopeFactory scopeFactory, + IOptions options, + ILogger logger) : ModuleSeedingHostedServiceBase(scopeFactory) +{ + protected override async Task SeedAsync(IServiceProvider services, CancellationToken cancellationToken) + { + var roleManager = services.GetRequiredService>>(); + foreach (var role in new[] { UserRoles.Admin, UserRoles.Staff, UserRoles.Customer }) + { + if (!await roleManager.RoleExistsAsync(role)) + { + await roleManager.CreateAsync(new IdentityRole(role)); + } + } + + var userManager = services.GetRequiredService>(); + await EnsureUserAsync(userManager, options.Value.AdminEmail, options.Value.AdminPassword, UserRoles.Admin); + await EnsureUserAsync(userManager, options.Value.StaffEmail, options.Value.StaffPassword, UserRoles.Staff); + logger.LogInformation("Identity seed data ensured."); + } + + private static async Task EnsureUserAsync( + UserManager userManager, string email, string password, string role) + { + if (string.IsNullOrWhiteSpace(password) + || await userManager.FindByEmailAsync(email) is not null) + { + return; + } + + var user = new ApplicationUser { UserName = email, Email = email }; + var result = await userManager.CreateAsync(user, password); + if (result.Succeeded) + { + await userManager.AddToRoleAsync(user, role); + } + } +} +``` +`UserRoles` resolves via the `using PriceNegotiationApp.SharedKernel;` — it stays in the shared kernel per spec. + +- [ ] **Step 3: Remove the dead property from Identity's SeedingOptions** + +In `src/PriceNegotiationApp.Modules.Identity/Seeding/SeedingOptions.cs`, delete the line: +```csharp + public bool SeedSampleProducts { get; init; } +``` +(No Identity code reads it; `Seeding:SeedSampleProducts` in appsettings/config binds harmlessly nowhere for Identity.) + +- [ ] **Step 4: Validate** + +```bash +dotnet build PriceNegotiationApp.slnx +dotnet test tests/PriceNegotiationApp.Modules.Identity.Tests --no-build +``` + +- [ ] **Step 5: Commit** + +```bash +git add src/PriceNegotiationApp.Modules.Catalog/Seeding src/PriceNegotiationApp.Modules.Identity/Seeding +git commit -m "refactor: rebuild module seeders on shared seeding base" +``` + +--- + +### Task 6: Rebuild the three design-time DbContext factories on the shared base + +**Files:** +- Rewrite: `src/PriceNegotiationApp.Modules.Catalog/Persistence/DesignTimeDbContextFactory.cs` +- Rewrite: `src/PriceNegotiationApp.Modules.Identity/Persistence/DesignTimeDbContextFactory.cs` +- Rewrite: `src/PriceNegotiationApp.Modules.Negotiations/Persistence/DesignTimeDbContextFactory.cs` + +**Interfaces:** +- Consumes: `DesignTimeDbContextFactoryBase` exactly as defined in Task 4 (including `protected const string LocalConnectionString`). +- Produces: unchanged public class names `DesignTimeDbContextFactory` per module (EF tooling finds them by convention). + +- [ ] **Step 1: Catalog factory** + +Replace the entire content of `src/PriceNegotiationApp.Modules.Catalog/Persistence/DesignTimeDbContextFactory.cs` with: +```csharp +using Microsoft.EntityFrameworkCore; +using PriceNegotiationApp.SharedKernel; + +namespace PriceNegotiationApp.Modules.Catalog.Persistence; + +public sealed class DesignTimeDbContextFactory : DesignTimeDbContextFactoryBase +{ + protected override void Configure(DbContextOptionsBuilder builder) => + builder.UseNpgsql(LocalConnectionString, + npgsql => npgsql.MigrationsHistoryTable("__EFMigrationsHistory_Catalog")) + .UseSnakeCaseNamingConvention(); + + protected override CatalogDbContext Create(DbContextOptions options) => new(options); +} +``` + +- [ ] **Step 2: Identity factory** + +Replace the entire content of `src/PriceNegotiationApp.Modules.Identity/Persistence/DesignTimeDbContextFactory.cs` with: +```csharp +using Microsoft.EntityFrameworkCore; +using PriceNegotiationApp.SharedKernel; + +namespace PriceNegotiationApp.Modules.Identity.Persistence; + +public sealed class DesignTimeDbContextFactory : DesignTimeDbContextFactoryBase +{ + protected override void Configure(DbContextOptionsBuilder builder) => + builder.UseNpgsql(LocalConnectionString, + npgsql => npgsql.MigrationsHistoryTable("__EFMigrationsHistory_Identity")) + .UseSnakeCaseNamingConvention(); + + protected override IdentityModuleDbContext Create(DbContextOptions options) => new(options); +} +``` + +- [ ] **Step 3: Negotiations factory** + +Replace the entire content of `src/PriceNegotiationApp.Modules.Negotiations/Persistence/DesignTimeDbContextFactory.cs` with: +```csharp +using Microsoft.EntityFrameworkCore; +using PriceNegotiationApp.SharedKernel; + +namespace PriceNegotiationApp.Modules.Negotiations.Persistence; + +public sealed class DesignTimeDbContextFactory : DesignTimeDbContextFactoryBase +{ + protected override void Configure(DbContextOptionsBuilder builder) => + builder.UseNpgsql(LocalConnectionString, + npgsql => npgsql.MigrationsHistoryTable("__EFMigrationsHistory_Negotiations")) + .UseSnakeCaseNamingConvention(); + + protected override NegotiationsDbContext Create(DbContextOptions options) => new(options); +} +``` + +- [ ] **Step 4: Validate migrations still resolve their factories** + +```bash +dotnet build PriceNegotiationApp.slnx +dotnet ef migrations list --project src/PriceNegotiationApp.Modules.Catalog --no-build +dotnet ef migrations list --project src/PriceNegotiationApp.Modules.Identity --no-build +dotnet ef migrations list --project src/PriceNegotiationApp.Modules.Negotiations --no-build +``` +(`dotnet-ef` must be installed; if unavailable, `dotnet build` passing is acceptable evidence — the factories are exercised again by integration tests.) + +- [ ] **Step 5: Commit** + +```bash +git add src/PriceNegotiationApp.Modules.Catalog/Persistence src/PriceNegotiationApp.Modules.Identity/Persistence src/PriceNegotiationApp.Modules.Negotiations/Persistence +git commit -m "refactor: dedupe design-time DbContext factories onto shared base" +``` + +--- + +### Task 7: Nest Negotiations features under `Features/Negotiations/` + +Makes the module layout match Catalog (`Features//`). Pure move + namespace change. + +**Files:** +- Move: all ten files currently at `src/PriceNegotiationApp.Modules.Negotiations/Features/*.cs` → `src/PriceNegotiationApp.Modules.Negotiations/Features/Negotiations/` +- Modify: those files' `namespace`; `using` updates in `NegotiationEndpoints.cs` and `src/PriceNegotiationApp.Api/GlobalExceptionHandler.cs` + +**Interfaces:** +- Produces: namespace `PriceNegotiationApp.Modules.Negotiations.Features.Negotiations` for Accept, CounterPropose, Create, Decline, Get, List, ListMine, Withdraw, NegotiationAccess, NegotiationModels. + +- [ ] **Step 1: Move the files** + +```bash +New-Item src/PriceNegotiationApp.Modules.Negotiations/Features/Negotiations -ItemType Directory | Out-Null +git mv src/PriceNegotiationApp.Modules.Negotiations/Features/Accept.cs ` + src/PriceNegotiationApp.Modules.Negotiations/Features/CounterPropose.cs ` + src/PriceNegotiationApp.Modules.Negotiations/Features/Create.cs ` + src/PriceNegotiationApp.Modules.Negotiations/Features/Decline.cs ` + src/PriceNegotiationApp.Modules.Negotiations/Features/Get.cs ` + src/PriceNegotiationApp.Modules.Negotiations/Features/List.cs ` + src/PriceNegotiationApp.Modules.Negotiations/Features/ListMine.cs ` + src/PriceNegotiationApp.Modules.Negotiations/Features/Withdraw.cs ` + src/PriceNegotiationApp.Modules.Negotiations/Features/NegotiationAccess.cs ` + src/PriceNegotiationApp.Modules.Negotiations/Features/NegotiationModels.cs ` + src/PriceNegotiationApp.Modules.Negotiations/Features/Negotiations/ +``` + +- [ ] **Step 2: Update namespaces and usings repo-wide** + +Exact-string replace `PriceNegotiationApp.Modules.Negotiations.Features` → `PriceNegotiationApp.Modules.Negotiations.Features.Negotiations` in all `.cs` files. This simultaneously fixes the `namespace …;` declarations and every `using …;` (e.g., in `NegotiationEndpoints.cs` and `GlobalExceptionHandler.cs`). Files in `Features/Negotiations/` that previously had no using (same namespace) need none added. + +```powershell +Get-ChildItem src,tests -Recurse -Filter *.cs | + Where-Object { $_.FullName -notmatch '\\(bin|obj)\\' } | + ForEach-Object { + $c = Get-Content $_.FullName -Raw + if ($c -match 'PriceNegotiationApp\.Modules\.Negotiations\.Features') { + Set-Content $_.FullName ($c -replace 'PriceNegotiationApp\.Modules\.Negotiations\.Features', 'PriceNegotiationApp.Modules.Negotiations.Features.Negotiations') -NoNewline + } + } +``` + +- [ ] **Step 3: Validate** + +```bash +dotnet build PriceNegotiationApp.slnx +dotnet test tests/PriceNegotiationApp.Modules.Negotiations.Tests --no-build +``` + +- [ ] **Step 4: Commit** + +```bash +git add -A +git commit -m "refactor: nest Negotiations features under Features/Negotiations" +``` + +--- + +### Task 8: Fold Identity's `Auth/` folder into `Features/Auth/` + +One feature, one folder: the JWT plumbing belongs next to Login/Register/Me. + +**Files:** +- Move: `src/PriceNegotiationApp.Modules.Identity/Auth/{JwtManager,JwtOptions,JwtOptionsValidator}.cs` → `src/PriceNegotiationApp.Modules.Identity/Features/Auth/` +- Modify: those files' `namespace`; `using` updates in `IdentityModule.cs` and `tests/PriceNegotiationApp.Modules.Identity.Tests/*` (JwtManagerShould) + +**Interfaces:** +- Produces: `PriceNegotiationApp.Modules.Identity.Features.Auth.JwtManager`, `.JwtOptions`, `.JwtOptionsValidator` (names/types unchanged). + +- [ ] **Step 1: Move the files and remove the empty folder** + +```bash +git mv src/PriceNegotiationApp.Modules.Identity/Auth/JwtManager.cs ` + src/PriceNegotiationApp.Modules.Identity/Auth/JwtOptions.cs ` + src/PriceNegotiationApp.Modules.Identity/Auth/JwtOptionsValidator.cs ` + src/PriceNegotiationApp.Modules.Identity/Features/Auth/ +``` + +- [ ] **Step 2: Update namespaces and usings repo-wide** + +```powershell +Get-ChildItem src,tests -Recurse -Filter *.cs | + Where-Object { $_.FullName -notmatch '\\(bin|obj)\\' } | + ForEach-Object { + $c = Get-Content $_.FullName -Raw + if ($c -match 'PriceNegotiationApp\.Modules\.Identity\.Auth') { + Set-Content $_.FullName ($c -replace 'PriceNegotiationApp\.Modules\.Identity\.Auth', 'PriceNegotiationApp.Modules.Identity.Features.Auth') -NoNewline + } + } +``` + +- [ ] **Step 3: Validate** + +```bash +dotnet build PriceNegotiationApp.slnx +dotnet test tests/PriceNegotiationApp.Modules.Identity.Tests --no-build +``` + +- [ ] **Step 4: Commit** + +```bash +git add -A +git commit -m "refactor: fold Identity JWT auth plumbing into Features/Auth" +``` + +--- + +### Task 9: Enforce compile-time ownership — flip module internals to `internal` + +Do this per module (three passes), building after each. Types in `Public/`, `Ports/`, and the root `XModule`/`XEndpoints` files stay `public`. Generated `Persistence/Migrations/*.Designer.cs` files are left untouched (generated code). + +**Files (Catalog pass):** +- Modify: every hand-written `.cs` under `src/PriceNegotiationApp.Modules.Catalog/{Domain,Features,Persistence,Seeding}/` (excluding `Persistence/Migrations/*`) +- Modify: `src/PriceNegotiationApp.Modules.Catalog/PriceNegotiationApp.Modules.Catalog.csproj` + +**Interfaces:** +- Consumes: nothing new. +- Produces: `` grants consumed by Api (already compiled against these types) and the Catalog test project. + +- [ ] **Step 1: Flip visibility declarations in Catalog** + +```powershell +$targets = Get-ChildItem src/PriceNegotiationApp.Modules.Catalog -Recurse -Filter *.cs | + Where-Object { $_.FullName -notmatch '\\(bin|obj|Migrations)\\' -and $_.Name -notin @('CatalogModule.cs','CatalogEndpoints.cs') } +foreach ($f in $targets) { + $c = Get-Content $f.FullName -Raw + $n = [regex]::Replace($c, '\bpublic\s+(?=(?:sealed\s+|static\s+|abstract\s+|partial\s+|readonly\s+)*(?:class|record|interface|struct|enum)\b)', 'internal ') + if ($n -ne $c) { Set-Content $f.FullName $n -NoNewline } +} +``` + +- [ ] **Step 2: Grant internals access in Catalog csproj** + +Add to `src/PriceNegotiationApp.Modules.Catalog/PriceNegotiationApp.Modules.Catalog.csproj`: +```xml + + + + +``` + +- [ ] **Step 3: Validate Catalog** + +```bash +dotnet build PriceNegotiationApp.slnx +dotnet test tests/PriceNegotiationApp.Modules.Catalog.Tests --no-build +``` +Expected failure mode to watch for: a public member exposing an internal type. If the build errors point at `XModule`/`XEndpoints` signatures, widen ONLY the smallest type involved (do not blanket-revert). + +- [ ] **Step 4: Commit** + +```bash +git add src/PriceNegotiationApp.Modules.Catalog +git commit -m "refactor: make Catalog module implementation internal" +``` + +--- + +### Task 10: Enforce ownership — Identity pass + +Same procedure for Identity. `Public/IdentityErrorCodes.cs` stays public. + +**Files:** +- Modify: every hand-written `.cs` under `src/PriceNegotiationApp.Modules.Identity/{Features,Persistence,Seeding}/` (excluding `Persistence/Migrations/*`) +- Modify: `src/PriceNegotiationApp.Modules.Identity/PriceNegotiationApp.Modules.Identity.csproj` + +- [ ] **Step 1: Flip visibility declarations in Identity** + +```powershell +$targets = Get-ChildItem src/PriceNegotiationApp.Modules.Identity -Recurse -Filter *.cs | + Where-Object { $_.FullName -notmatch '\\(bin|obj|Migrations|\\Public\\)' -and $_.Name -notin @('IdentityModule.cs','IdentityEndpoints.cs') } +foreach ($f in $targets) { + $c = Get-Content $f.FullName -Raw + $n = [regex]::Replace($c, '\bpublic\s+(?=(?:sealed\s+|static\s+|abstract\s+|partial\s+|readonly\s+)*(?:class|record|interface|struct|enum)\b)', 'internal ') + if ($n -ne $c) { Set-Content $f.FullName $n -NoNewline } +} +``` + +- [ ] **Step 2: Grant internals access in Identity csproj** + +Add to `src/PriceNegotiationApp.Modules.Identity/PriceNegotiationApp.Modules.Identity.csproj`: +```xml + + + + +``` + +- [ ] **Step 3: Validate Identity** + +```bash +dotnet build PriceNegotiationApp.slnx +dotnet test tests/PriceNegotiationApp.Modules.Identity.Tests --no-build +``` + +- [ ] **Step 4: Commit** + +```bash +git add src/PriceNegotiationApp.Modules.Identity +git commit -m "refactor: make Identity module implementation internal" +``` + +--- + +### Task 11: Enforce ownership — Negotiations pass + +`Ports/IProductPriceProvider.cs` stays public (host implements it). Domain exceptions and error codes stay in `Domain/` — they become visible to Api through its `InternalsVisibleTo` grant, keeping cohesion without making them public contracts. + +**Files:** +- Modify: every hand-written `.cs` under `src/PriceNegotiationApp.Modules.Negotiations/{Domain,Features,Persistence}/` (excluding `Persistence/Migrations/*`) +- Modify: `src/PriceNegotiationApp.Modules.Negotiations/PriceNegotiationApp.Modules.Negotiations.csproj` + +- [ ] **Step 1: Flip visibility declarations in Negotiations** + +```powershell +$targets = Get-ChildItem src/PriceNegotiationApp.Modules.Negotiations -Recurse -Filter *.cs | + Where-Object { $_.FullName -notmatch '\\(bin|obj|Migrations|\\Ports\\)' -and $_.Name -notin @('NegotiationsModule.cs','NegotiationEndpoints.cs') } +foreach ($f in $targets) { + $c = Get-Content $f.FullName -Raw + $n = [regex]::Replace($c, '\bpublic\s+(?=(?:sealed\s+|static\s+|abstract\s+|partial\s+|readonly\s+)*(?:class|record|interface|struct|enum)\b)', 'internal ') + if ($n -ne $c) { Set-Content $f.FullName $n -NoNewline } +} +``` + +- [ ] **Step 2: Grant internals access in Negotiations csproj** + +Add to `src/PriceNegotiationApp.Modules.Negotiations/PriceNegotiationApp.Modules.Negotiations.csproj`: +```xml + + + + +``` + +- [ ] **Step 3: Validate Negotiations + full solution** + +```bash +dotnet build PriceNegotiationApp.slnx +dotnet test tests/PriceNegotiationApp.Modules.Negotiations.Tests --no-build +``` + +- [ ] **Step 4: Commit** + +```bash +git add src/PriceNegotiationApp.Modules.Negotiations +git commit -m "refactor: make Negotiations module implementation internal" +``` + +--- + +### Task 12: Cruft removal and hygiene + +**Files:** +- Delete: `tests/PriceNegotiationApp.UnitTests/` (untracked local residue: empty folders + stale bin/obj, no csproj) +- Delete: `src/PriceNegotiationApp.Api/PriceNegotiationApp.Api.csproj.user` (local state; may be named `*.AppHost.csproj.user`) +- Modify: `Directory.Packages.props` (remove NSubstitute line + empty transitive-overrides block) +- Verify: `PriceNegotiationApp.http` +- Modify: `README.md` (architecture section refresh) + +- [ ] **Step 1: Delete ghost artifacts** + +```powershell +Remove-Item -Recurse -Force tests/PriceNegotiationApp.UnitTests +Remove-Item -Force -ErrorAction SilentlyContinue src/PriceNegotiationApp.Api/*.csproj.user +``` + +- [ ] **Step 2: Clean Directory.Packages.props** + +Delete the line: +```xml + +``` +and the entire block: +```xml + + + +``` + +- [ ] **Step 3: Verify the .http file** + +Open `PriceNegotiationApp.http` and confirm `@host` matches launchSettings (`http://localhost:5185`). If it differs, set it to `http://localhost:5185`. + +- [ ] **Step 4: Refresh README architecture section** + +Update the README's architecture/project-structure text to reflect reality: `Api` host, `SharedKernel`, three modules, normalized layout (`Domain/ Features// Persistence/ Ports/ Public/ Seeding/`), and the ownership rule (internals + `InternalsVisibleTo` for the composition root and tests only). Keep it short — a tree diagram plus 3–5 bullet points matching the spec's §1–§4. Do not invent new features. + +- [ ] **Step 5: Validate** + +```bash +dotnet build PriceNegotiationApp.slnx +``` + +- [ ] **Step 6: Commit** + +```bash +git add -A +git commit -m "chore: remove cruft and refresh docs after restructure" +``` + +--- + +### Task 13: Full verification and boundary audit + +**Files:** none expected to change; fix-ups only if something surfaces. + +- [ ] **Step 1: Clean full build (warnings-as-errors gate)** + +```bash +dotnet clean PriceNegotiationApp.slnx && dotnet build PriceNegotiationApp.slnx +``` + +- [ ] **Step 2: Run every test suite** + +```bash +dotnet test tests/PriceNegotiationApp.Modules.Catalog.Tests +dotnet test tests/PriceNegotiationApp.Modules.Identity.Tests +dotnet test tests/PriceNegotiationApp.Modules.Negotiations.Tests +dotnet test tests/PriceNegotiationApp.IntegrationTests +``` +Integration tests require Docker (Testcontainers PostgreSQL). If Docker is unavailable, state that explicitly in the completion report rather than skipping silently. + +- [ ] **Step 3: Boundary greps** + +No module may reference another module's namespace anywhere: +```powershell +rg -l "using PriceNegotiationApp\.Modules\." src/PriceNegotiationApp.Modules.Catalog src/PriceNegotiationApp.Modules.Identity src/PriceNegotiationApp.Modules.Negotiations +``` +Expected result: **no matches** (modules reference only `PriceNegotiationApp.SharedKernel` and their own namespaces). + +Only the composition root reaches into module internals: +```powershell +rg -l "PriceNegotiationApp\.Modules\.[A-Za-z]+\.(Domain|Persistence|Features)" src/PriceNegotiationApp.Api +``` +Expected matches: `Composition/CatalogToNegotiations.cs`, `Composition/MigrationHostedService.cs`, `Extensions/WebApplicationBuilderExtensions.cs` (health checks), `GlobalExceptionHandler.cs` — all sanctioned host privileges. + +- [ ] **Step 4: Final commit if fix-ups were needed** + +```bash +git status +git add -A +git commit -m "fix: boundary audit follow-ups" +``` +(skip if working tree is clean) diff --git a/docs/superpowers/plans/2026-08-25-bogus-test-data.md b/docs/superpowers/plans/2026-08-25-bogus-test-data.md new file mode 100644 index 0000000..6f66d7c --- /dev/null +++ b/docs/superpowers/plans/2026-08-25-bogus-test-data.md @@ -0,0 +1,847 @@ +# Bogus Adoption & Failure-Diagnostic Artifacts Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Convert every non-semantic test value to seeded Bogus generation via a shared `TestKit`, and make every failure self-explanatory (seed banner + generated-payload dump + TRX artifacts). + +**Architecture:** One new zero-dependency `PriceNegotiationApp.TestKit` classlib exposes a deterministic `Fuzz` facade (per-call-site seeds derived from `TEST_SEED`, unique-by-construction emails, complexity-guaranteed passwords) whose output flows into xunit v3's `ITestOutputHelper` through `[ModuleInitializer]` wiring in each consuming assembly. TRX reporting rides the existing Microsoft.Testing.Platform extension model; semantic literals identified in spec §4 stay untouched. + +**Tech Stack:** Bogus 35.6.5, xunit.v3 (`TestContext.Current.TestOutputHelper`, `[ModuleInitializer]`), Microsoft.Testing.Extensions.TrxReport 2.3.3 (matches MTP 2.3.3), GitHub Actions `upload-artifact@v4`. + +## Global Constraints + +- Source spec: `docs/superpowers/specs/2026-08-25-bogus-test-data-design.md`. +- Litmus rule: convert only values where *"a random different value would still verify the same behavior"*; **never remove an existing InlineData edge**. +- Default seed constant: `8675309`; override via env var `TEST_SEED`. +- Reproducibility contract: same `TEST_SEED` + same `--filter` ⇒ identical generated values (counter-based sequences are order-dependent across *different* filters — this is documented behavior). +- TestKit references **Bogus only**; it must not reference xunit packages (sink is an `Action`). +- The two Api-owned validator/guard classes and all Api types remain reachable only as they are today; no production code changes in this plan. +- ArchitectureTests changes limited to adding the TrxReport package reference. +- Every task: `dotnet build` zero warnings; touched suites green before commit. +- Shell is pwsh from repo root; integration tests need Docker. + +--- + +### Task 1: TestKit project + Fuzz facade + sink wiring + +**Files:** +- Create: `tests/PriceNegotiationApp.TestKit/PriceNegotiationApp.TestKit.csproj` +- Create: `tests/PriceNegotiationApp.TestKit/Fuzz.cs` +- Create: `tests/PriceNegotiationApp.Modules.Catalog.Tests/TestBootstrap.cs` +- Create: `tests/PriceNegotiationApp.Modules.Identity.Tests/TestBootstrap.cs` +- Create: `tests/PriceNegotiationApp.Modules.Negotiations.Tests/TestBootstrap.cs` +- Create: `tests/PriceNegotiationApp.IntegrationTests/TestBootstrap.cs` +- Modify: the four corresponding `.csproj` files (add TestKit project reference) +- Modify: `PriceNegotiationApp.slnx` (register TestKit) + +**Interfaces:** +- Consumes: nothing new. +- Produces (used by Tasks 3–7): + - `static int Fuzz.RunSeed { get; }` + - `static Faker Fuzz.NewFaker(int salt = 0, …)` — caller-file+member keyed seed + - `static decimal Fuzz.Price(this Faker, decimal min = 0.01m, decimal max = 1000m)` + - `static string Fuzz.ProductName(this Faker)` — ≤200 chars + - `static string Fuzz.Text(this Faker, int minLen, int maxLen)` + - `static string Fuzz.Email()` — deterministic-per-sequence + - `static string Fuzz.UniqueEmail()` — unique by construction within process + - `static string Fuzz.Password(int length = 14)` — upper+lower+digit+symbol guaranteed + - `static void Fuzz.Dump(string label, object value)` — JSON line to sink + - `static Action? Fuzz.Sink` + +- [ ] **Step 1: Create the project** + +`tests/PriceNegotiationApp.TestKit/PriceNegotiationApp.TestKit.csproj`: + +```xml + + + + + +``` + +(Deliberately NOT matching the `EndsWith("Tests")` convention block in `Directory.Build.props` — this is a classlib.) + +- [ ] **Step 2: Implement Fuzz** + +`tests/PriceNegotiationApp.TestKit/Fuzz.cs`: + +```csharp +using System.Collections.Concurrent; +using System.Runtime.CompilerServices; +using System.Text.Json; +using Bogus; + +namespace PriceNegotiationApp.TestKit; + +/// +/// Deterministic test-data generation. Faker instances are seeded from +/// (TEST_SEED, call-site), so re-running the same command line replays identical data. +/// Dump() reports every arranged value into test output, which lands in TRX artifacts. +/// +public static class Fuzz +{ + public static int RunSeed { get; } = + int.TryParse(Environment.GetEnvironmentVariable("TEST_SEED"), out var seed) + ? seed + : 8675309; + + /// Attached by each test assembly's module initializer to xunit v3 output. + public static Action? Sink; + + private static readonly ConcurrentDictionary SiteCounters = new(); + private static int _uniqueSequence; + + public static Faker NewFaker( + int salt = 0, + [CallerFilePath] string filePath = "", + [CallerMemberName] string member = "") + { + var site = $"{filePath}:{member}"; + var occurrence = SiteCounters.AddOrUpdate(site, 1, static (_, current) => current + 1); + var seed = HashCode.Combine(RunSeed, site, salt, occurrence); + Sink?.Invoke($"fuzz run-seed={RunSeed} scope={member} site-occurrence={occurrence} seed={seed}"); + return new Faker().UseSeed(seed); + } + + public static decimal Price(this Faker faker, decimal min = 0.01m, decimal max = 1000m) => + Math.Round(faker.Random.Decimal(min, max), 2); + + public static string ProductName(this Faker faker) + { + var name = faker.Commerce.ProductName(); + return name.Length <= 200 ? name : name[..200]; + } + + public static string Text(this Faker faker, int minLen, int maxLen) => + faker.Random.String2(faker.Random.Int(minLen, maxLen)); + + public static string Email() => + new Faker().UseSeed(HashCode.Combine(RunSeed, Interlocked.Increment(ref _uniqueSequence))) + .Internet.Email(); + + public static string UniqueEmail() + { + var sequence = Interlocked.Increment(ref _uniqueSequence); + var local = new Faker().UseSeed(HashCode.Combine(RunSeed, sequence)) + .Internet.UserName().ToLowerInvariant().Replace("'", "").Replace(".", ""); + return $"{local}.f{sequence}@test.local"; + } + + public static string Password(int length = 14) + { + ArgumentOutOfRangeException.ThrowIfLessThan(length, 4); + + const string upper = "ABCDEFGHJKLMNPQRSTUVWXYZ"; + const string lower = "abcdefghijkmnpqrstuvwxyz"; + const string digits = "23456789"; + const string symbols = "!@#$%^&*"; + var all = string.Concat(upper, lower, digits, symbols); + + var randomizer = new Randomizer(HashCode.Combine(RunSeed, Interlocked.Increment(ref _uniqueSequence))); + var chars = new char[length]; + chars[0] = upper[randomizer.Int(0, upper.Length - 1)]; + chars[1] = lower[randomizer.Int(0, lower.Length - 1)]; + chars[2] = digits[randomizer.Int(0, digits.Length - 1)]; + chars[3] = symbols[randomizer.Int(0, symbols.Length - 1)]; + for (var i = 4; i < length; i++) + { + chars[i] = all[randomizer.Int(0, all.Length - 1)]; + } + + for (var i = length - 1; i > 0; i--) + { + var swap = randomizer.Int(0, i); + (chars[i], chars[swap]) = (chars[swap], chars[i]); + } + + return new string(chars); + } + + public static string HttpsUrl() => + $"https://{new Faker().UseSeed(HashCode.Combine(RunSeed, Interlocked.Increment(ref _uniqueSequence))).Internet.DomainName()}"; + + public static void Dump(string label, object value) => + Sink?.Invoke($"fuzz {label} = {JsonSerializer.Serialize(value)}"); +} +``` + +- [ ] **Step 3: Add one bootstrap per consuming assembly** + +Identical content modulo namespace, e.g. +`tests/PriceNegotiationApp.Modules.Catalog.Tests/TestBootstrap.cs`: + +```csharp +using System.Runtime.CompilerServices; +using PriceNegotiationApp.TestKit; +using Xunit; + +namespace PriceNegotiationApp.Modules.Catalog.Tests; + +public static class TestBootstrap +{ + [ModuleInitializer] + internal static void WireFuzzSink() => + Fuzz.Sink = line => TestContext.Current?.TestOutputHelper?.WriteLine(line); +} +``` + +Repeat for namespaces: +- `PriceNegotiationApp.Modules.Identity.Tests` +- `PriceNegotiationApp.Modules.Negotiations.Tests` +- `PriceNegotiationApp.IntegrationTests` + +(ArchitectureTests does not consume Fuzz — skip it.) + +- [ ] **Step 4: Reference TestKit from the four csprojs + solution** + +Add to each of the four test csprojs' `` with ProjectReferences: + +```xml + +``` + +(IntegrationTests path is the same depth: `..\PriceNegotiationApp.TestKit\…`.) + +In `PriceNegotiationApp.slnx`, inside ``, add first: + +```xml + +``` + +- [ ] **Step 5: Validate** + +```bash +dotnet build && dotnet test tests/PriceNegotiationApp.Modules.Catalog.Tests --no-build +``` + +Zero warnings; catalog suite still green (nothing consumes Fuzz yet — wiring only). + +- [ ] **Step 6: Commit** + +```bash +git add tests/PriceNegotiationApp.TestKit tests/PriceNegotiationApp.Modules.Catalog.Tests/TestBootstrap.cs tests/PriceNegotiationApp.Modules.Identity.Tests/TestBootstrap.cs tests/PriceNegotiationApp.Modules.Negotiations.Tests/TestBootstrap.cs tests/PriceNegotiationApp.IntegrationTests/TestBootstrap.cs tests/PriceNegotiationApp.Modules.Catalog.Tests/PriceNegotiationApp.Modules.Catalog.Tests.csproj tests/PriceNegotiationApp.Modules.Identity.Tests/PriceNegotiationApp.Modules.Identity.Tests.csproj tests/PriceNegotiationApp.Modules.Negotiations.Tests/PriceNegotiationApp.Modules.Negotiations.Tests.csproj tests/PriceNegotiationApp.IntegrationTests/PriceNegotiationApp.IntegrationTests.csproj PriceNegotiationApp.slnx +git commit -m "test: deterministic fuzz facade with per-assembly output sinks" +``` + +--- + +### Task 2: TRX reporting infrastructure + +**Files:** +- Modify: `Directory.Packages.props` +- Modify: all five test `.csproj` files (add `Microsoft.Testing.Extensions.TrxReport`) +- Modify: `.github/workflows/ci.yml` (Test step + artifact upload) + +**Interfaces:** +- Consumes: Microsoft.Testing.Platform 2.3.3 (already transitively present via xunit.v3 4.0.0). +- Produces: `dotnet test … --report-trx` writes `TestResults/*.trx`; CI uploads `TestResults/**`. + +- [ ] **Step 1: Pin the package** + +In `Directory.Packages.props`, alphabetically after `Microsoft.Testing.Extensions.CodeCoverage`: + +```xml + +``` + +(2.3.3 matches the Microsoft.Testing.Platform 2.3.3 that xunit.v3 4.0.0 pulls in.) + +- [ ] **Step 2: Reference it from all five test projects** + +Add to each of the five csprojs' packages `` (alphabetical position): + +```xml + +``` + +Files: the three `Modules.*.Tests`, `IntegrationTests`, `ArchitectureTests`. + +- [ ] **Step 3: Update CI** + +In `.github/workflows/ci.yml`, replace the Test step and add an upload step after it: + +```yaml + - name: Test + run: dotnet test --solution PriceNegotiationApp.slnx -c Release --no-build --coverage --coverage-output-format cobertura --report-trx + + - name: Upload test artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results + path: "**/TestResults/**" + retention-days: 7 +``` + +- [ ] **Step 4: Validate locally** + +```bash +dotnet build && dotnet test tests/PriceNegotiationApp.Modules.Catalog.Tests --no-build --report-trx +Get-ChildItem tests/PriceNegotiationApp.Modules.Catalog.Tests/TestResults -Filter *.trx | Select-Object -First 1 -ExpandProperty Name +``` + +A non-empty `.trx` file must exist and contain the string `ProductRulesShould`. + +- [ ] **Step 5: Commit** + +```bash +git add Directory.Packages.props .github/workflows/ci.yml tests/PriceNegotiationApp.ArchitectureTests/PriceNegotiationApp.ArchitectureTests.csproj tests/PriceNegotiationApp.IntegrationTests/PriceNegotiationApp.IntegrationTests.csproj tests/PriceNegotiationApp.Modules.Catalog.Tests/PriceNegotiationApp.Modules.Catalog.Tests.csproj tests/PriceNegotiationApp.Modules.Identity.Tests/PriceNegotiationApp.Modules.Identity.Tests.csproj tests/PriceNegotiationApp.Modules.Negotiations.Tests/PriceNegotiationApp.Modules.Negotiations.Tests.csproj +git commit -m "test: persist trx reports locally and as ci artifacts" +``` + +--- + +### Task 3: Catalog module conversions + +**Files:** +- Modify: `tests/PriceNegotiationApp.Modules.Catalog.Tests/ProductRulesShould.cs` (full rewrite) +- Modify: `tests/PriceNegotiationApp.Modules.Catalog.Tests/UpdateIdempotencyShould.cs` (full rewrite) + +**Interfaces:** +- Consumes Task 1: `Fuzz.NewFaker()`, `Fuzz.Price()`, `Fuzz.ProductName()`, `Fuzz.Dump()`. + +- [ ] **Step 1: Rewrite ProductRulesShould** + +```csharp +using PriceNegotiationApp.Modules.Catalog.Domain; +using PriceNegotiationApp.SharedKernel; +using PriceNegotiationApp.TestKit; +using Shouldly; +using Vogen; +using Xunit; + +namespace PriceNegotiationApp.Modules.Catalog.Tests; + +public class ProductRulesShould +{ + // Semantic partitions stay inline: null/empty/whitespace and zero/negative are + // distinct validation branches; 'x' x201 is the length boundary. + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Create_rejects_null_or_whitespace_name(string? name) => + Should.Throw(() => Product.Create(name!, Fuzz.NewFaker().Price())); + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void Create_rejects_non_positive_price(decimal price) => + Should.Throw( + () => Product.Create(Fuzz.NewFaker().ProductName(), price)); + + [Fact] + public void Create_rejects_name_over_200_characters() => + Should.Throw(() => Product.Create(new string('x', 201), Fuzz.NewFaker().Price())); + + [Fact] + public void Create_trims_surrounding_whitespace_and_assigns_id_and_price() + { + var faker = Fuzz.NewFaker(); + var rawName = $" {faker.ProductName()} "; + var price = faker.Price(); + + var product = Product.Create(rawName, price); + + product.Name.ShouldBe(rawName.Trim()); + product.Id.Value.ShouldNotBe(Guid.Empty); + product.Price.ShouldBe(price); + } + + [Fact] + public void Update_returns_true_and_applies_changes_when_changed() + { + var faker = Fuzz.NewFaker(); + var originalName = faker.ProductName(); + var originalPrice = faker.Price(); + var product = Product.Create(originalName, originalPrice); + var newName = faker.ProductName(); + var newPrice = faker.Price(); + Fuzz.Dump("update-pair", new { originalName, originalPrice, newName, newPrice }); + + var expectedChanged = + !string.Equals(originalName, newName, StringComparison.Ordinal) || originalPrice != newPrice; + var changed = product.Update(newName, newPrice); + + changed.ShouldBe(expectedChanged); // collision-immune: Bogus *could* repeat a value + product.Name.ShouldBe(newName); + product.Price.ShouldBe(newPrice); + } + + [Fact] + public void Update_returns_false_when_identical() + { + var faker = Fuzz.NewFaker(); + var name = faker.ProductName(); + var price = faker.Price(); + var product = Product.Create(name, price); + + var changed = product.Update(name, price); + + changed.ShouldBeFalse(); + } +} +``` + +- [ ] **Step 2: Migrate UpdateIdempotencyShould to seeded fakers** + +```csharp +using PriceNegotiationApp.Modules.Catalog.Domain; +using PriceNegotiationApp.TestKit; +using Shouldly; +using Xunit; + +namespace PriceNegotiationApp.Modules.Catalog.Tests; + +public class UpdateIdempotencyShould +{ + [Fact] + public void Return_false_when_nothing_changed() + { + var faker = Fuzz.NewFaker(); + var name = faker.ProductName(); + var price = faker.Price(); + var product = Product.Create(name, price); + + var changed = product.Update(name, price); + + changed.ShouldBeFalse(); + } + + [Fact] + public void Return_true_when_only_whitespace_differs() + { + var faker = Fuzz.NewFaker(); + var padded = $"{faker.ProductName()} "; + var product = Product.Create(faker.ProductName(), faker.Price()); + + var changed = product.Update(padded, product.Price); + + changed.ShouldBeTrue(); + product.Name.ShouldBe(padded.Trim()); + } +} +``` + +(Note the second fact now updates with the product's own fuzzed price — previously `10m`; +the whitespace-only-name proposition is unchanged.) + +- [ ] **Step 3: Validate + dual-seed check** + +```bash +dotnet build && dotnet test tests/PriceNegotiationApp.Modules.Catalog.Tests --no-build +$env:TEST_SEED='424242'; dotnet test tests/PriceNegotiationApp.Modules.Catalog.Tests --no-build; Remove-Item Env:TEST_SEED +``` + +Both runs green. + +- [ ] **Step 4: Commit** + +```bash +git add tests/PriceNegotiationApp.Modules.Catalog.Tests/ProductRulesShould.cs tests/PriceNegotiationApp.Modules.Catalog.Tests/UpdateIdempotencyShould.cs +git commit -m "test(catalog): bogus-driven product data with semantic boundaries preserved" +``` + +--- + +### Task 4: Identity module conversions + +**Files:** +- Modify: `tests/PriceNegotiationApp.Modules.Identity.Tests/JwtManagerShould.cs` +- Modify: `tests/PriceNegotiationApp.Modules.Identity.Tests/SeedingOptionsValidatorShould.cs` + +**Interfaces:** +- Consumes Task 1: `Fuzz.Email()`, `Fuzz.Password()`, `Fuzz.NewFaker()`. + +- [ ] **Step 1: JwtManagerShould — fuzz the subject email** + +Replace the test body (keep the FixedTimeProvider class and options setup): + +```csharp + [Fact] + public void Generate_token_with_sub_email_role_and_expiry() + { + var options = Options.Create(new JwtOptions + { + Issuer = "test-issuer", + Audience = "test-audience", + SecretKey = new string('k', 48), // length is semantic; content irrelevant + ExpiryMinutes = 30, + }); + var clock = new FixedTimeProvider(); + var sut = new JwtManager(options, clock); + var email = Fuzz.Email(); + + var (token, expiresAtUtc) = sut.Generate(Guid.NewGuid(), email, ["Customer"]); + + token.ShouldNotBeNullOrWhiteSpace(); + token.ShouldContain(Base64UrlEncoder.Encode(email)); + token.Split('.').Length.ShouldBe(3); + var expected = clock.GetUtcNow().AddMinutes(30); + (expiresAtUtc - expected).Duration().ShouldBeLessThan(TimeSpan.FromSeconds(1)); + } +``` + +Add usings `PriceNegotiationApp.TestKit;` and `Microsoft.IdentityModel.Tokens;` (for +`Base64UrlEncoder`, which lets the token itself prove which email went in — stronger than +the previous assertion set and still deterministic). + +- [ ] **Step 2: SeedingOptionsValidatorShould — fuzz happy paths, add whitespace branch** + +Full rewrite: + +```csharp +using PriceNegotiationApp.Modules.Identity.Seeding; +using PriceNegotiationApp.TestKit; +using Shouldly; +using Xunit; + +namespace PriceNegotiationApp.Modules.Identity.Tests; + +public class SeedingOptionsValidatorShould +{ + private readonly SeedingOptionsValidator _sut = new(); + + // Unspecified fields fall back to fresh Fuzz values, so every happy-path run + // exercises different-but-valid data. Invalid partitions stay inline. + private static SeedingOptions Options( + string? adminEmail = null, + string? adminPassword = null, + string? staffEmail = null, + string? staffPassword = null) => new() + { + AdminEmail = adminEmail ?? Fuzz.Email(), + AdminPassword = adminPassword ?? Fuzz.Password(), + StaffEmail = staffEmail ?? Fuzz.Email(), + StaffPassword = staffPassword ?? Fuzz.Password(), + }; + + [Fact] + public void Accept_a_complete_configuration_with_generated_values() + { + var options = Options(); + Fuzz.Dump("seeding-options", options); + + _sut.Validate(null, options).Succeeded.ShouldBeTrue(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("not-an-email")] + public void Reject_invalid_admin_email(string? email) => + _sut.Validate(null, Options(adminEmail: email!)).Failed.ShouldBeTrue(); + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("not-an-email")] + public void Reject_invalid_staff_email(string? email) => + _sut.Validate(null, Options(staffEmail: email!)).Failed.ShouldBeTrue(); + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("short")] + public void Reject_admin_password_shorter_than_identity_floor(string? password) => + _sut.Validate(null, Options(adminPassword: password!)).Failed.ShouldBeTrue(); + + [Fact] + public void Aggregate_every_violation_in_one_result() + { + var result = _sut.Validate(null, new SeedingOptions()); + + result.Failed.ShouldBeTrue(); + result.Failures.Count().ShouldBe(2); + result.Failures.ShouldContain(f => f.Contains("AdminPassword")); + result.Failures.ShouldContain(f => f.Contains("StaffPassword")); + } +} +``` + +(`new SeedingOptions()` keeps class defaults for emails — valid — so exactly two password +failures remain; `Failures` is `IEnumerable` → `.Count()`.) + +- [ ] **Step 3: Validate** + +```bash +dotnet build && dotnet test tests/PriceNegotiationApp.Modules.Identity.Tests --no-build +``` + +All facts green including the new whitespace branches. + +- [ ] **Step 4: Commit** + +```bash +git add tests/PriceNegotiationApp.Modules.Identity.Tests/JwtManagerShould.cs tests/PriceNegotiationApp.Modules.Identity.Tests/SeedingOptionsValidatorShould.cs +git commit -m "test(identity): bogus-driven credentials plus whitespace email edges" +``` + +--- + +### Task 5: Negotiations lifecycle conversion + +**Files:** +- Modify: `tests/PriceNegotiationApp.Modules.Negotiations.Tests/NegotiationLifecycleShould.cs` + +**Interfaces:** +- Consumes Task 1: `Fuzz.NewFaker()`, `Fuzz.Dump()`. + +- [ ] **Step 1: Seed the faker, dump arranged data, keep every number** + +Apply three edits to the existing file: + +1. Usings: add `PriceNegotiationApp.TestKit;`. +2. Replace the field `private readonly Faker _faker = new();` with: + +```csharp + private readonly Faker _faker = Fuzz.NewFaker(); +``` + +(keep `using Bogus;` — the field type stays `Faker`.) + +3. Replace `StartValid()` so each run records its arranged data: + +```csharp + private Negotiation StartValid() + { + var customerId = CustomerId.From(_faker.Random.Guid()); + Fuzz.Dump("start-valid", new { customer = customerId.Value, product = _productId }); + return Negotiation.Start(customerId, _productId, BasePrice, 80m, _now, Policy); + } +``` + +Every numeric assertion (`80m/90m/91m/92m/200m/201m/500m`, budget counts) remains exactly +as-is — those are state-machine semantics per spec §1. + +- [ ] **Step 2: Validate + triple-run stability check** + +```bash +dotnet build && dotnet test tests/PriceNegotiationApp.Modules.Negotiations.Tests --no-build +dotnet test tests/PriceNegotiationApp.Modules.Negotiations.Tests --no-build +dotnet test tests/PriceNegotiationApp.Modules.Negotiations.Tests --no-build +``` + +Three consecutive green runs. + +- [ ] **Step 3: Commit** + +```bash +git add tests/PriceNegotiationApp.Modules.Negotiations.Tests/NegotiationLifecycleShould.cs +git commit -m "test(negotiations): seeded fakers with arrange dumps, numbers untouched" +``` + +--- + +### Task 6: Integration tests conversions + +**Files:** +- Modify: `tests/PriceNegotiationApp.IntegrationTests/Support/IntegrationTestFixture.cs` +- Modify: `tests/PriceNegotiationApp.IntegrationTests/Support/UserSession.cs` +- Modify: `tests/PriceNegotiationApp.IntegrationTests/AuthFlowShould.cs` +- Modify: `tests/PriceNegotiationApp.IntegrationTests/ProductsShould.cs` +- Modify: `tests/PriceNegotiationApp.IntegrationTests/NegotiationsShould.cs` + +**Interfaces:** +- Consumes Task 1: `Fuzz.UniqueEmail()`, `Fuzz.Password()`, `Fuzz.NewFaker()`, `Fuzz.ProductName()`, `Fuzz.Text()`. +- Produces: `UserSession.Password` property (needed because the lockout test must retry the *actual* generated password). + +- [ ] **Step 1: Fixture + session carry generated credentials** + +`IntegrationTestFixture.CreateUserAsync` — replace the two literal lines: + +```csharp + var email = Fuzz.UniqueEmail(); + var password = Fuzz.Password(); +``` + +and change the return to preserve it: + +```csharp + return new UserSession(Factory, email, content!.AccessToken, password); +``` + +Add `using PriceNegotiationApp.TestKit;`. + +`UserSession` — full file: + +```csharp +using PriceNegotiationApp.IntegrationTests.Support; + +namespace PriceNegotiationApp.IntegrationTests.Support; + +public sealed class UserSession( + IntegrationTestFactory factory, string email, string token, string password) +{ + public string Email { get; } = email; + + public string Token { get; } = token; + + public string Password { get; } = password; + + public HttpClient Client { get; } = + factory.CreateDefaultClient(new BearerTokenHandler(new TokenHolder { Token = token })); +} +``` + +- [ ] **Step 2: AuthFlowShould** + +Replacements: +- `Duplicate_registration_conflicts`: `var email = Fuzz.UniqueEmail(); var body = new RegisterRequest { Email = email, Password = Fuzz.Password() };` +- `Five_failed_attempts_lock_account`: final retry line uses `Password = session.Password` (was `"Passw0rd!x"`). +- ✅ `"not-an-email"`, `"short"`, `"WrongPass1!"` literals stay. +- Add `using PriceNegotiationApp.TestKit;`. + +- [ ] **Step 3: ProductsShould** + +Class-level helper and usings: + +```csharp +using PriceNegotiationApp.TestKit; + + private static object DenialPayload(decimal price) => new + { + name = Fuzz.NewFaker().ProductName(), + price, + }; +``` + +Replacements: +- `"Anon Probe", 42m` → `CreateProductAsync(staff)` (defaults fuzz). +- Every denial body (`"X", 1m` / `"C", 1m` variants) → `DenialPayload(1m)`. +- `"Staff Updated", created.Price + 1` → name = `Fuzz.NewFaker().ProductName()`; price stays `created.Price + 1`. +- `CreateProductAsync` fallback line becomes: + +```csharp + new { name = name ?? Fuzz.NewFaker().ProductName(), price = price ?? Fuzz.NewFaker().Price() }, +``` + +✅ Stay literal: `string.Empty`, `"Valid Name"`, `-5m` (422 partitions); filter-trio prices +`10m/30m/20m` + range `15/25` (sort/range assertions pin exact values); the Guid search +marker (uniqueness-critical for search isolation). + +- [ ] **Step 4: NegotiationsShould** + +In `CreateProductAsync` convert only the name template (base price stays `100m` — the +suite pins `BasePrice.ShouldBe(100m)` against products created here): + +```csharp + new { name = name ?? Fuzz.NewFaker().ProductName(), price = price ?? 100m }, +``` + +Add `using PriceNegotiationApp.TestKit;`. Nothing else in the file changes. + +- [ ] **Step 5: ConfigurationValidationShould — fuzz well-formed origins** + +Replace the accept fact: + +```csharp + [Fact] + public void Accept_well_formed_cors_origins() + { + var origins = new[] + { + Fuzz.HttpsUrl(), + $"http://{Fuzz.NewFaker().Internet.DomainName()}", + }; + + Should.NotThrow(() => CorsOriginsGuard.EnsureValid(origins)); + } +``` + +(✅ malformed partitions stay inline.) Add `using PriceNegotiationApp.TestKit;`. + +- [ ] **Step 6: Validate (Docker required)** + +```bash +dotnet build && dotnet test tests/PriceNegotiationApp.IntegrationTests --no-build +``` + +All green including lockout flow (proves generated passwords satisfy Identity policy). + +- [ ] **Step 7: Commit** + +```bash +git add tests/PriceNegotiationApp.IntegrationTests +git commit -m "test(integration): bogus identities, product data and cors origins" +``` + +--- + +### Task 7: README testing docs + full verification matrix + +**Files:** +- Modify: `README.md` + +**Interfaces:** none (docs + verification). + +- [ ] **Step 1: Add a Testing section to README** + +Insert before `## CI`: + +```markdown +## Testing + +```bash +dotnet test --solution PriceNegotiationApp.slnx # everything (Docker needed) +dotnet test --project tests/PriceNegotiationApp.Modules.Catalog.Tests # one project +``` + +Every test run also writes `TestResults/*.trx` and `TestResults/*.cobertura.xml`. +Generated test data comes from Bogus through a shared `TestKit`: + +- Data is deterministic per call site — re-running the same command replays it. +- A failure prints a `fuzz run-seed=…` banner plus the arranged values; replay it with: + +```bash +$env:TEST_SEED=''; dotnet test --filter +``` +``` + +(Keep the inner fences as shown — the section nests one level.) + +- [ ] **Step 2: Verification matrix** + +Run in order; every line must be green: + +```bash +# 1. default seed, whole suite +$env:TEST_SEED=$null +dotnet format --verify-no-changes --no-restore +dotnet build -c Release --no-restore +dotnet test --solution PriceNegotiationApp.slnx -c Release --no-build --report-trx + +# 2. alternate seed, whole suite +$env:TEST_SEED='424242' +dotnet test --solution PriceNegotiationApp.slnx -c Release --no-build + +# 3. triple stability run, negotiations unit suite +Remove-Item Env:TEST_SEED +3..1 | ForEach-Object { dotnet test tests/PriceNegotiationApp.Modules.Negotiations.Tests --no-build } +``` + +Acceptance: zero failures across all runs → proves semantic literals were preserved +(generated prices always >0/≤1000 respect domain rules; generated names never collide with +the ≤200-char rule; generated passwords always satisfy Identity complexity). + +- [ ] **Step 3: Commit** + +```bash +git add README.md +git commit -m "docs: testing section covering trx artifacts and seed reproduction" +``` + +--- + +## Self-Review Record + +- Spec §2 TestKit → Tasks 1 (all members of `Fuzz` present: RunSeed/NewFaker/Price/ProductName/Text/Email/UniqueEmail/Password/Dump/Sink; HttpsUrl added beyond spec to serve E-18-era ConfigurationValidationShould conversions — noted here as deliberate superset used by nothing yet? **Correction:** Task 6 does not convert ConfigurationValidationShould origins; HttpsUrl is therefore unused — removed from plan? It IS defined in Task 1 Fuzz code. Keep it (one-liner, documented) or drop? Decision: keep — next validation conversion will use it; harmless. +- Spec §3 TRX/artifacts → Task 2 (+CI upload). Local README usage → Task 7 Step 1. +- Spec §4 migration map rows → Tasks 3 (Catalog), 4 (Identity incl. whitespace edge ➕), 5 (Negotiations), 6 (Integration fixture/AuthFlow/Products/NegotiationsShould); DbWriteGuardShould + ArchitectureTests untouched ✓. +- Spec §5 failure story → Dump/banner in Task 1 + sink wiring; TRX capture Task 2. +- Spec §6 verification → Task 7 Step 2 (dual-seed + triple-run). +- Type consistency: `Fuzz.NewFaker()` salt param unused by callers (fine); extension methods (`Price/ProductName/Text`) invoked on `Faker` instances; `UserSession` constructor arity updated at its single construction site (fixture) — no other constructors exist (checked: AuthFlow/Products/Negotiations use fixture-created sessions only). + diff --git a/docs/superpowers/plans/2026-08-25-ddd-tactical-polish.md b/docs/superpowers/plans/2026-08-25-ddd-tactical-polish.md new file mode 100644 index 0000000..2222780 --- /dev/null +++ b/docs/superpowers/plans/2026-08-25-ddd-tactical-polish.md @@ -0,0 +1,444 @@ +# DDD Tactical Polish Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Resolve audit finding F-01 (money VOs inside the Negotiation aggregate), pin deliberate designs F-02/F-03 with doc-comments, codify repository stance (F-05) and add three architecture drift guards (F-06). + +**Architecture:** Swap raw `decimal` money fields in `Negotiation` to the existing Vogen `Price` VO with explicit EF value conversions — type-preserving, no migration. Everything else is comments, one README law block, and three new ArchUnitNET facts. Zero behavior change at any API boundary. + +**Tech Stack:** Vogen 8.0.7, EF Core 10 value conversions, ArchUnitNET.xUnitV3 0.13.4, MTP. + +## Global Constraints + +- Source spec: `docs/superpowers/specs/2026-08-25-ddd-audit-design.md` §5 table. +- **No database migration may be produced**: columns stay `numeric(18,2)`; the change is conversion-only. Prove with `dotnet ef migrations has-pending-model-changes`. +- External JSON contract unchanged (`BasePrice`/`CurrentOffer` remain JSON numbers). +- Semantic literals in tests stay inline per the Bogus doctrine; only `.Value` accessors get added. +- Existing Negotiation unit suite must pass **unchanged in assertions except `.Value` additions** — that proves behavior preservation. +- Every task ends with zero-warning build + touched suites green; shell pwsh from repo root. + +--- + +### Task 1: Price value object inside the Negotiation aggregate + +**Files:** +- Modify: `src/PriceNegotiationApp.Modules.Negotiations/Domain/Negotiation.cs` +- Modify: `src/PriceNegotiationApp.Modules.Negotiations/Persistence/Configurations/NegotiationConfiguration.cs` +- Modify: `src/PriceNegotiationApp.Modules.Negotiations/Features/Negotiations/NegotiationModels.cs` (`ToResponse` only) + +**Interfaces:** +- Consumes: existing `Price` VO (`Price.From(decimal)` throws `ValueObjectValidationException` on ≤0; `.Value` exposes decimal). +- Produces: `Negotiation.BasePrice` / `Negotiation.CurrentOffer` become `Price`; public method signatures (`Start`, `CounterPropose`, `Accept`, `RejectCurrentOffer`, `Withdraw`, `RemainingProposals`) keep **decimal parameters** — endpoint contracts do not move. + +- [ ] **Step 1: Rewrite Negotiation.cs** + +```csharp +namespace PriceNegotiationApp.Modules.Negotiations.Domain; + +internal sealed class Negotiation +{ + /// + /// Cross-aggregate invariant "at most one Open negotiation per (product, customer)" + /// cannot live here: it spans aggregates. Enforcement stack is intentional — + /// partial unique index uq_negotiations_open_product_customer (authoritative), + /// endpoint pre-check (friendly fast-path 409). Do NOT move it into this class. + /// + /// Base price snapshot taken at creation; protects ongoing negotiations from later product price changes. + public Price BasePrice { get; private set; } + + public Price CurrentOffer { get; private set; } + + public NegotiationId Id { get; private set; } + + public Guid ProductId { get; private set; } + + public CustomerId CustomerId { get; private set; } + + public NegotiationStatus Status { get; private set; } + + /// Total proposals recorded, including the initial one. + public int ProposalsUsed { get; private set; } + + /// Proposal budget snapshotted from the active policy at creation time. + public int MaxProposals { get; private set; } + + /// Offer multiplier limit snapshotted from the active policy at creation time. + public decimal OfferMultiplierLimit { get; private set; } + + public DateTimeOffset CreatedAtUtc { get; private set; } + + public DateTimeOffset LastProposalAtUtc { get; private set; } + + /// Most recent staff reject-current-offer action; does not change status. + public DateTimeOffset? LastStaffActionAtUtc { get; private set; } + + public DateTimeOffset? DecidedAtUtc { get; private set; } + + public uint Version { get; private set; } + + private Negotiation() + { + } + + private Negotiation( + NegotiationId id, Guid productId, CustomerId customerId, Price basePrice, Price initialOffer, + INegotiationPolicy policy, DateTimeOffset createdAtUtc) + { + Id = id; + ProductId = productId; + CustomerId = customerId; + BasePrice = basePrice; + CurrentOffer = initialOffer; + MaxProposals = policy.MaxProposalsPerNegotiation; + OfferMultiplierLimit = policy.ProposalMultiplierLimit; + Status = NegotiationStatus.Open; + ProposalsUsed = 1; + CreatedAtUtc = createdAtUtc; + LastProposalAtUtc = createdAtUtc; + } + + public static Negotiation Start( + CustomerId customerId, Guid productId, decimal basePriceSnapshot, decimal initialOffer, + DateTimeOffset now, INegotiationPolicy policy) + { + var basePrice = Price.From(basePriceSnapshot); + var offer = Price.From(initialOffer); + var limit = decimal.Round(basePrice.Value * policy.ProposalMultiplierLimit, 2); + if (offer.Value > limit) + { + throw new ProposalExceedsLimitException(limit); + } + + return new Negotiation(NegotiationId.From(Guid.CreateVersion7()), productId, customerId, + basePrice, offer, policy, now); + } + + public NegotiationOutcome CounterPropose(decimal offer, DateTimeOffset now) + { + EnsureOpen(); + var candidate = Price.From(offer); + if (ProposalsUsed >= MaxProposals) + { + return NegotiationOutcome.NoProposalsRemaining; + } + + var limit = decimal.Round(BasePrice.Value * OfferMultiplierLimit, 2); + if (candidate.Value > limit) + { + Status = NegotiationStatus.Rejected; + DecidedAtUtc = now; + return NegotiationOutcome.AutoRejected; + } + + CurrentOffer = candidate; + ProposalsUsed++; + LastProposalAtUtc = now; + return NegotiationOutcome.CounterProposed; + } + + public void Accept(DateTimeOffset now) => Decide(NegotiationStatus.Accepted, now); + + /// + /// Staff rejects the current offer. The negotiation deliberately stays open so the + /// customer may spend a remaining proposal; the proposal budget is untouched. + /// It terminates only via Accept, auto-rejection, or withdrawal. + /// + public void RejectCurrentOffer(DateTimeOffset now) + { + EnsureOpen(); + LastStaffActionAtUtc = now; + } + + /// Owner abandons the negotiation; state becomes terminal, history is preserved. + public void Withdraw(DateTimeOffset now) => Decide(NegotiationStatus.Withdrawn, now); + + public int RemainingProposals() => Math.Max(0, MaxProposals - ProposalsUsed); + + private void Decide(NegotiationStatus terminalStatus, DateTimeOffset now) + { + EnsureOpen(); + Status = terminalStatus; + DecidedAtUtc = now; + } + + private void EnsureOpen() + { + if (Status != NegotiationStatus.Open) + { + throw new ClosedNegotiationException(); + } + } +} +``` + +Note the ordering nuance kept deliberately: budget-exhaustion check runs **before** price +validation, matching previous observable behavior (exhausted budget returns +`NoProposalsRemaining` even for an invalid amount). + +- [ ] **Step 2: EF value conversions** + +In `NegotiationConfiguration.cs`, replace the two money lines: + +```csharp + builder.Property(n => n.BasePrice).HasConversion( + price => price.Value, value => Domain.Price.From(value)).HasColumnType("numeric(18,2)"); + builder.Property(n => n.CurrentOffer).HasConversion( + price => price.Value, value => Domain.Price.From(value)).HasColumnType("numeric(18,2)"); +``` + +(`OfferMultiplierLimit` stays a plain decimal mapped to `numeric(5,2)` — it is a ratio, +not money.) + +- [ ] **Step 3: Response mapper** + +In `NegotiationModels.cs`, the mapper body becomes: + +```csharp + internal static NegotiationResponse ToResponse(Negotiation n) => + new(n.Id.Value, n.ProductId, n.BasePrice.Value, n.CurrentOffer.Value, n.Status.ToString(), + n.ProposalsUsed, n.RemainingProposals(), n.CreatedAtUtc, n.LastProposalAtUtc, n.DecidedAtUtc); +``` + +`NegotiationResponse`'s decimal properties are untouched — JSON contract identical. + +- [ ] **Step 4: Prove conversion-only (no migration)** + +```bash +dotnet build && dotnet ef migrations has-pending-model-changes --context NegotiationsDbContext -p src/PriceNegotiationApp.Modules.Negotiations --framework net10.0 +``` + +Expected output contains "No changes have been made to the model since the last +migration". If it reports pending changes, stop and fix the conversions before continuing. + +- [ ] **Step 5: Update unit tests — `.Value` additions + new VO-path facts** + +In `tests/PriceNegotiationApp.Modules.Negotiations.Tests/NegotiationLifecycleShould.cs` +apply these mechanical replacements: + +```text +negotiation.BasePrice.ShouldBe(100m) → negotiation.BasePrice.Value.ShouldBe(100m) +negotiation.CurrentOffer.ShouldBe(90m) → negotiation.CurrentOffer.Value.ShouldBe(90m) +negotiation.CurrentOffer.ShouldNotBe(92m) → negotiation.CurrentOffer.Value.ShouldNotBe(92m) +negotiation.CurrentOffer.ShouldBe(200m) → negotiation.CurrentOffer.Value.ShouldBe(200m) +withdrawn.CurrentOffer.ShouldBe(90m) → withdrawn.CurrentOffer.Value.ShouldBe(90m) +``` + +Append three new facts covering the now-hardened base-price path: + +```csharp + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void Start_rejects_non_positive_base_price(decimal badBase) => + Should.Throw( + () => Negotiation.Start(CustomerId.From(_faker.Random.Guid()), _productId, badBase, 80m, _now, Policy)); + + [Fact] + public void CounterPropose_rejects_non_positive_offer() + { + var negotiation = StartValid(); + + Should.Throw( + () => negotiation.CounterPropose(0m, _now.AddMinutes(5))); + } +``` + +Add `using Microsoft.Extensions.DependencyInjection;`? No — add `using Vogen;` for +`ValueObjectValidationException`. + +- [ ] **Step 6: Validate** + +```bash +dotnet build && dotnet test tests/PriceNegotiationApp.Modules.Negotiations.Tests --no-build +dotnet test tests/PriceNegotiationApp.IntegrationTests --no-build +``` + +All green (integration asserts HTTP decimals — unchanged). + +- [ ] **Step 7: Commit** + +```bash +git add src/PriceNegotiationApp.Modules.Negotiations tests/PriceNegotiationApp.Modules.Negotiations.Tests/NegotiationLifecycleShould.cs +git commit -m "refactor(negotiations): price value object inside aggregate, conversion-only persistence" +``` + +--- + +### Task 2: Pin deliberate designs with doc-comments + +**Files:** +- Modify: `src/PriceNegotiationApp.Modules.Negotiations/Persistence/Configurations/CustomerConfiguration.cs` +- Modify: `src/PriceNegotiationApp.Modules.Negotiations/Domain/Customer.cs` + +**Interfaces:** none (comments only). The Negotiation cross-aggregate comment already +landed inside Task 1 Step 1's rewrite. + +- [ ] **Step 1: Anemia rationale on CustomerConfiguration** + +Above `builder.ToTable("customers");` insert: + +```csharp + // DELIBERATE ANEMIC DESIGN (ddd-audit spec F-03): Customer is a reference row + // binding an ASP.NET Identity user into this context. It is created once and + // never mutated; it has no behavioral invariants beyond a non-empty identity + // link. Do not "enrich" it into a fake aggregate without a real use case. +``` + +Also mirror one summary line onto the entity: + +In `Domain/Customer.cs`, above the class: + +```csharp +/// Reference row binding an Identity user to this context. Intentionally +/// anemic — see CustomerConfiguration for rationale. Do not enrich without cause. +``` + +- [ ] **Step 2: Validate + commit** + +```bash +dotnet build +git add src/PriceNegotiationApp.Modules.Negotiations +git commit -m "docs(domain): pin deliberate designs — cross-aggregate uniqueness, anemic customer" +``` + +--- + +### Task 3: Architecture drift guards (F-06) + +**Files:** +- Modify: `tests/PriceNegotiationApp.ArchitectureTests/ArchitectureShould.cs` + +**Interfaces:** +- Consumes Task 1 state (Domain namespaces unchanged); existing providers `CatalogTypes`, `IdentityTypes`, `NegotiationsTypes`, `CompositionRoot`, `EntityFramework` already defined in the class. + +- [ ] **Step 1: Add three facts** + +Append inside `ArchitectureShould`: + +```csharp + [Fact] + public void Domain_namespaces_never_reach_into_persistence_namespaces() + { + var persistence = Types().That().ResideInNamespace($"{Catalog}.Persistence") + .Or().ResideInNamespace($"{Negotiations}.Persistence") + .As("persistence namespaces"); + + Types().That().Are(catalogDomain).Or().Are(negotiationsDomain) + .Should().NotDependOnAny(persistence) + .Check(Architecture); + } + + [Fact] + public void Port_contracts_stay_persistence_free() + { + var catalogPorts = Types().That().ResideInNamespace($"{Catalog}.Ports").As("catalog ports"); + var negotiationsPorts = Types().That().ResideInNamespace($"{Negotiations}.Ports").As("negotiations ports"); + var persistence = Types().That().ResideInNamespace($"{Catalog}.Persistence") + .Or().ResideInNamespace($"{Negotiations}.Persistence") + .As("persistence namespaces"); + + Types().That().Are(catalogPorts).Or().Are(negotiationsPorts) + .Should().NotDependOnAny(persistence) + .Check(Architecture); + } + + [Fact] + public void Repository_ceremony_stays_out_of_the_codebase() + { + // F-05 doctrine: module DbContext is the unit of work, DbSet the aggregate + // collection. A repository layer re-introduces ceremony without payoff here. + var repositories = Types().That().HaveFullNameContaining("Repository"); + + repositories.GetObjects(Architecture).ShouldBeEmpty( + "repository-style types must not appear; use the module DbContext directly"); + } +``` + +The two domain providers (`catalogDomain`, `negotiationsDomain`) already exist inside the +`Domain_namespaces_stay_free_of_persistence_concerns` fact — hoist them into class-level +readonly providers so both facts share them: + +```csharp + private static readonly IObjectProvider CatalogDomain = + Types().That().ResideInNamespace($"{Catalog}.Domain").As("catalog domain"); + + private static readonly IObjectProvider NegotiationsDomain = + Types().That().ResideInNamespace($"{Negotiations}.Domain").As("negotiations domain"); +``` + +…and refactor the existing fact to consume those fields (deleting its local copies). + +- [ ] **Step 2: Validate** + +```bash +dotnet build && dotnet test tests/PriceNegotiationApp.ArchitectureTests --no-build +``` + +All architecture facts green (8 total). + +- [ ] **Step 3: Commit** + +```bash +git add tests/PriceNegotiationApp.ArchitectureTests/ArchitectureShould.cs +git commit -m "test(architecture): guard domain/persistence leakage and repository ceremony" +``` + +--- + +### Task 4: Tactical DDD laws in README + full CI parity + +**Files:** +- Modify: `README.md` + +**Interfaces:** none. + +- [ ] **Step 1: Append laws to the Architecture section rules list** + +After the last `- Modules never reference…` bullet block, add: + +```markdown +### Tactical DDD laws + +- Module `DbContext`s are the unit of work; `DbSet` is the aggregate's collection. + No repository/UoW abstractions (enforced by an architecture test). +- Cross-aggregate invariants live at the persistence boundary (partial unique indexes) + with endpoint fast-paths for friendly errors — never inside a single aggregate. +- Negotiation policy values are snapshotted onto the aggregate at creation; config changes + never rewrite in-flight negotiations. +- Domain/integration events are intentionally absent until the first real subscriber + (deal-on-accept / notifications features). Pattern will follow + `docs/superpowers/specs/2026-08-25-ddd-audit-design.md` §F-04 when triggered. +- Money inside aggregates uses value objects; ratios/multipliers use plain decimals. +``` + +- [ ] **Step 2: Full CI parity** + +```bash +dotnet format --verify-no-changes --no-restore +dotnet build -c Release --no-restore +dotnet test --solution PriceNegotiationApp.slnx -c Release --no-build --report-trx +``` + +Everything green; fix formatting via `dotnet format` before committing if flagged. + +- [ ] **Step 3: Commit** + +```bash +git add README.md +git commit -m "docs: tactical ddd laws added to architecture section" +``` + +--- + +## Self-Review Record + +- Spec coverage: §5 change 1 → Task 1 (VO swap + conversions + mapper + `.Value` tests + + two new VO-path facts + pending-changes proof); change 2 → Task 2 (+ aggregate comment + folded into Task 1 code); change 3 → Task 3 (three guards incl. ports purity); + change 4 → Task 4 Step 1; change 5 → this plan documents F-04 vocabulary only (spec §3 + F-04 says "implement nothing") ✓. +- Placeholder scan: none. +- Type consistency: `Price.From(decimal)`, `.Value` accessor used uniformly; + `IObjectProvider` providers named consistently (`CatalogDomain`, + `NegotiationsDomain`) after hoist; response mapper keeps decimal JSON contract. + diff --git a/docs/superpowers/plans/2026-08-25-engineering-hardening.md b/docs/superpowers/plans/2026-08-25-engineering-hardening.md new file mode 100644 index 0000000..67e1094 --- /dev/null +++ b/docs/superpowers/plans/2026-08-25-engineering-hardening.md @@ -0,0 +1,851 @@ +# Engineering Hardening Implementation Plan (E-01/03/04/10/12/13/18 + E-11) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Land the eight engineering-hardening items from `docs/superpowers/specs/2026-08-25-engineering-hardening-design.md`: local telemetry dashboard, meaningful request logs, readiness detail JSON, SDK pin, centralized test conventions, CI-only deterministic build support, fail-fast startup validation, and an uncommitted local `nuget.config`. + +**Architecture:** Build-system changes come first (targets/pins/global package reference), then four independent Api-side code items (logging, health writer, OTLP gating + compose overlay, options validation), then the deliberately-uncommitted `nuget.config`. No business behavior changes anywhere. + +**Tech Stack:** .NET 10 SDK / MSBuild (`Directory.Build.props|targets`), Serilog request logging, ASP.NET Core health checks, OpenTelemetry `.UseOtlpExporter()`, Aspire Dashboard container, Microsoft.Testing.Platform. + +## Global Constraints + +- Source spec: `docs/superpowers/specs/2026-08-25-engineering-hardening-design.md`. +- Local SDK is `10.0.303`; pin uses `"version": "10.0.303"`, `"rollForward": "latestFeature"`. +- Base `docker-compose.yml` must remain byte-for-byte untouched; observability goes in a new override file. +- `/health/live` output and semantics unchanged; only `/health/ready` gains a JSON body writer. +- `Deterministic=true` etc. are already SDK defaults — do NOT re-add them (spec §6). +- `CatalogSeedingOptions` gets **no** validator (single bool — spec §7); instead add a one-line comment noting the deliberate omission. +- Validators replicate the existing JWT pattern: `AddOptions().Bind(...)` (+`.ValidateOnStart()` where wired) plus `AddSingleton, TV>()`. +- Identity internals are visible to its own test project; **Api internals are visible to nobody** — the two new Api validator/guard classes must be `public`. +- Every task ends with `dotnet build` zero warnings and touched test projects green. +- All commands run from repo root in pwsh. +- Integration tests need Docker (Testcontainers postgres:17-alpine); if unavailable, say so plainly instead of skipping silently. +- **E-11 (`nuget.config`) is created in Task 8, LAST, and must never be `git add`ed or committed.** + +--- + +### Task 1: Centralize test-project conventions + +**Files:** +- Modify: `Directory.Build.props` +- Modify: `tests/PriceNegotiationApp.Modules.Negotiations.Tests/PriceNegotiationApp.Modules.Negotiations.Tests.csproj` +- Modify: `tests/PriceNegotiationApp.Modules.Identity.Tests/PriceNegotiationApp.Modules.Identity.Tests.csproj` +- Modify: `tests/PriceNegotiationApp.Modules.Catalog.Tests/PriceNegotiationApp.Modules.Catalog.Tests.csproj` +- Modify: `tests/PriceNegotiationApp.IntegrationTests/PriceNegotiationApp.IntegrationTests.csproj` +- Modify: `tests/PriceNegotiationApp.ArchitectureTests/PriceNegotiationApp.ArchitectureTests.csproj` + +**Interfaces:** +- Consumes: nothing. +- Produces: implicit build conventions for any project whose name contains `.Tests` — `OutputType=Exe`, `IsPackable=false`, `NoWarn += CA1707;S1118`. Later tasks rely on csprojs NOT redeclaring these. + +- [ ] **Step 1: Add the conventions to Directory.Build.props** + +Append inside the existing top-level `` element (after the NuGetAudit properties): + +```xml + + + Exe + false + $(NoWarn);CA1707;S1118 + +``` + +(MSBuild property-function string literals use backticks, not quotes. The block lives in +`Directory.Build.props`, NOT a new `Directory.Build.targets` — package-provided build checks +such as xunit.v3's executable-output validation execute before the targets import would run.) + +- [ ] **Step 2: Slim the five test csprojs** + +In each of the five files listed above, delete the entire `` block containing `Exe` and `$(NoWarn);CA1707;S1118` (IntegrationTests/Negotiations/Identity/Catalog have exactly that two-property block; ArchitectureTests has the same). Keep every other line — project references, package references — untouched. Example result for Negotiations.Tests: + +```xml + + + + + + + + + + + +``` + +Apply the same deletion to the other four; their reference items stay as-is. + +- [ ] **Step 3: Validate** + +```bash +dotnet build && dotnet test tests/PriceNegotiationApp.Modules.Negotiations.Tests --no-build +``` + +Build succeeds with zero warnings; all unit tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add Directory.Build.props tests/ +git commit -m "build: centralize test project conventions in Directory.Build.props" +``` + +--- + +### Task 2: Pin the SDK + +**Files:** +- Modify: `global.json` + +**Interfaces:** +- Consumes: nothing. +- Produces: deterministic SDK resolution (10.0.3xx feature band) for all later tasks and CI. + +- [ ] **Step 1: Add the sdk section** + +Full file content: + +```json +{ + "sdk": { + "version": "10.0.303", + "rollForward": "latestFeature" + }, + "test": { + "runner": "Microsoft.Testing.Platform" + } +} +``` + +- [ ] **Step 2: Validate** + +```bash +dotnet --version +dotnet build --no-restore +``` + +`dotnet --version` prints a `10.0.3xx` value (locally expected exactly `10.0.303`); build still succeeds. If it prints a lower band or errors about a missing SDK, run `dotnet sdk check` and install the 10.0.3xx band before proceeding. + +- [ ] **Step 3: Commit** + +```bash +git add global.json +git commit -m "build: pin dotnet sdk to the 10.0.3xx feature band" +``` + +--- + +### Task 3: CI-only deterministic compilation + source link + +**Files:** +- Modify: `Directory.Build.props` + +**Interfaces:** +- Consumes: GitHub Actions' automatic `CI=true` environment variable. +- Produces: reproducible PDBs linked to the GitHub commit when built in CI; no local behavior change. + +- [ ] **Step 1: Extend Directory.Build.props** + +Append inside the existing top-level `` element, after the current ``: + +```xml + + true + + + + true + true + + + + +``` + +(`Deterministic` stays unset on purpose — the SDK defaults it to true; see spec §6.) + +- [ ] **Step 2: Validate both modes** + +```bash +dotnet build --no-restore +$env:CI='true'; dotnet build --no-restore; Remove-Item Env:CI +``` + +Both succeed with zero warnings. The second simulates the CI property path. + +- [ ] **Step 3: Commit** + +```bash +git add Directory.Build.props +git commit -m "build: reproducible ci compilation with source link" +``` + +--- + +### Task 4: Enriched request logs with noise suppression + +**Files:** +- Modify: `src/PriceNegotiationApp.Api/Extensions/PipelineExtensions.cs` + +**Interfaces:** +- Consumes: existing Serilog pipeline (`app.UseSerilogRequestLogging()` currently parameterless at PipelineExtensions.cs:14). +- Produces: diagnostic context keys `UserId`, `Roles`, `Endpoint`, `RemoteIp` on every non-suppressed request record. + +- [ ] **Step 1: Replace the request-logging call** + +In `PipelineExtensions.cs`, replace line `app.UseSerilogRequestLogging();` with: + +```csharp + app.UseSerilogRequestLogging(options => + { + // Enrichment runs at response completion, so HttpContext.User is populated. + options.EnrichDiagnosticContext = (diagnosticContext, httpContext) => + { + diagnosticContext.Set("UserId", + httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier)); + diagnosticContext.Set("Roles", string.Join(',', + httpContext.User.FindAll(ClaimTypes.Role).Select(claim => claim.Value))); + diagnosticContext.Set("Endpoint", httpContext.GetEndpoint()?.DisplayName); + diagnosticContext.Set("RemoteIp", + httpContext.Connection.RemoteIpAddress?.ToString()); + }; + options.GetLevel = (httpContext, elapsed, ex) => ex is not null + ? LogEventLevel.Error + : IsInfrastructurePath(httpContext.Request.Path) + ? LogEventLevel.Verbose + : elapsed > 500 ? LogEventLevel.Warning : LogEventLevel.Information; + }); +``` + +Add the helper at the bottom of the class: + +```csharp + private static bool IsInfrastructurePath(PathString path) => + path.StartsWithSegments("/health", StringComparison.OrdinalIgnoreCase) || + path.StartsWithSegments("/scalar", StringComparison.OrdinalIgnoreCase) || + path.StartsWithSegments("/openapi", StringComparison.OrdinalIgnoreCase) || + path.StartsWithSegments("/favicon", StringComparison.OrdinalIgnoreCase); +``` + +(The `StringComparison` argument satisfies Meziantou.Analyzer rule MA0074.) + +Extend the file's usings with: + +```csharp +using System.Security.Claims; +using Serilog.Events; +``` + +- [ ] **Step 2: Validate** + +```bash +dotnet build +``` + +Zero warnings. (Behavioral smoke-check happens naturally in Task 6's dashboard run, where suppressed paths stop appearing.) + +- [ ] **Step 3: Commit** + +```bash +git add src/PriceNegotiationApp.Api/Extensions/PipelineExtensions.cs +git commit -m "feat(api): enrich request logs and suppress infrastructure noise" +``` + +--- + +### Task 5: Readiness endpoint reports per-dependency detail + +**Files:** +- Create: `src/PriceNegotiationApp.Api/ReadyHealthReport.cs` +- Modify: `src/PriceNegotiationApp.Api/Extensions/PipelineExtensions.cs` (ready `MapHealthChecks` block, ~line 40) +- Test: `tests/PriceNegotiationApp.IntegrationTests/ReadyHealthShould.cs` + +**Interfaces:** +- Consumes: existing health-check registrations (`database-identity`, `database-catalog`, `database-negotiations`, all tagged `ready`; note the `self` check is tagged `live` only and therefore absent from readiness output). +- Produces: `static Task ReadyHealthReport.WriteAsync(HttpContext, HealthReport)` — JSON body `{ status, totalDurationMs, entries: { : { status, durationMs, description? } } }`; `description` present only when a check is not Healthy. + +- [ ] **Step 1: Create the writer** + +`src/PriceNegotiationApp.Api/ReadyHealthReport.cs`: + +```csharp +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace PriceNegotiationApp.Api; + +/// JSON body for /health/ready naming every dependency and its verdict. +public static class ReadyHealthReport +{ + public static async Task WriteAsync(HttpContext context, HealthReport report) + { + var payload = new + { + status = report.Status.ToString(), + totalDurationMs = report.TotalDuration.TotalMilliseconds, + entries = report.Entries.ToDictionary( + entry => entry.Key, + entry => entry.Value.Status == HealthStatus.Healthy + ? (object)new + { + status = entry.Value.Status.ToString(), + durationMs = entry.Value.Duration.TotalMilliseconds, + } + : new + { + status = entry.Value.Status.ToString(), + durationMs = entry.Value.Duration.TotalMilliseconds, + description = entry.Value.Description ?? entry.Value.Exception?.Message, + }), + }; + + await context.Response.WriteAsJsonAsync(payload); + } +} +``` + +- [ ] **Step 2: Wire it into the ready probe** + +In `PipelineExtensions.MapModules`, replace: + +```csharp + app.MapHealthChecks("/health/ready", new HealthCheckOptions { Predicate = r => r.Tags.Contains("ready") }); +``` + +with: + +```csharp + app.MapHealthChecks("/health/ready", new HealthCheckOptions + { + Predicate = r => r.Tags.Contains("ready"), + ResponseWriter = ReadyHealthReport.WriteAsync, + }); +``` + +The `/health/live` line above it stays untouched. Default `HealthCheckOptions.ResultStatusCodes` already maps Unhealthy → 503. + +- [ ] **Step 3: Add the integration test** + +Create `tests/PriceNegotiationApp.IntegrationTests/ReadyHealthShould.cs`: + +```csharp +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using PriceNegotiationApp.IntegrationTests.Support; +using Shouldly; +using Xunit; + +namespace PriceNegotiationApp.IntegrationTests; + +[Collection(ApiCollection.Name)] +public class ReadyHealthShould(IntegrationTestFixture fixture) +{ + [Fact] + public async Task Ready_reports_json_status_per_dependency() + { + var response = await fixture.Anonymous.GetAsync("/health/ready", TestContext.Current.CancellationToken); + + response.StatusCode.ShouldBe(HttpStatusCode.OK); + response.Content.Headers.ContentType!.MediaType.ShouldBe("application/json"); + + var body = await response.Content.ReadFromJsonAsync( + cancellationToken: TestContext.Current.CancellationToken); + + body.GetProperty("status").GetString().ShouldBe("Healthy"); + body.GetProperty("totalDurationMs").GetDouble().ShouldBeGreaterThanOrEqualTo(0); + + var entries = body.GetProperty("entries"); + foreach (var name in new[] { "database-identity", "database-catalog", "database-negotiations" }) + { + entries.TryGetProperty(name, out _).ShouldBeTrue($"missing health entry '{name}'"); + entries.GetProperty(name).GetProperty("status").GetString().ShouldBe("Healthy"); + entries.GetProperty(name).TryGetProperty("description", out _) + .ShouldBeFalse("healthy checks must not carry a description"); + } + } +} +``` + +(The pre-existing `Ready_endpoint_reports_all_module_schemas` fact in `NegotiationsShould.cs` still passes — the new payload contains the substring `Healthy`.) + +- [ ] **Step 4: Validate** + +```bash +dotnet build && dotnet test tests/PriceNegotiationApp.IntegrationTests --no-build +``` + +Requires Docker. Both ready-related facts green. + +- [ ] **Step 5: Commit** + +```bash +git add src/PriceNegotiationApp.Api/ReadyHealthReport.cs src/PriceNegotiationApp.Api/Extensions/PipelineExtensions.cs tests/PriceNegotiationApp.IntegrationTests/ReadyHealthShould.cs +git commit -m "feat(api): readiness endpoint reports per-dependency detail" +``` + +--- + +### Task 6: Opt-in OTLP export + Aspire Dashboard compose overlay + +**Files:** +- Modify: `src/PriceNegotiationApp.Api/Extensions/WebApplicationBuilderExtensions.cs` (OpenTelemetry block near the end of `AddApiServices`) +- Create: `compose.observability.yml` +- Modify: `README.md` (Health & telemetry section) + +**Interfaces:** +- Consumes: existing `.UseOtlpExporter()` registration; standard `OTEL_EXPORTER_OTLP_ENDPOINT` environment variable. +- Produces: telemetry exported only when an endpoint is configured; `compose.observability.yml` runnable as `-f docker-compose.yml -f compose.observability.yml up`. + +- [ ] **Step 1: Gate the exporter on endpoint presence** + +In `WebApplicationBuilderExtensions.AddApiServices`, replace the entire `builder.Services.AddOpenTelemetry()...UseOtlpExporter();` block with: + +```csharp + // Telemetry ships only when a consumer is configured (Aspire dashboard overlay, + // Grafana stack, or any OTLP endpoint). Prevents endless export retries against + // localhost:4317 where nothing listens. + var otlpEndpoint = configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]; + if (!string.IsNullOrEmpty(otlpEndpoint)) + { + builder.Services.AddOpenTelemetry() + .ConfigureResource(resource => resource.AddService("PriceNegotiationApp.Api")) + .WithTracing(tracing => tracing + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation()) + .WithMetrics(metrics => metrics + .AddAspNetCoreInstrumentation() + .AddRuntimeInstrumentation()) + .UseOtlpExporter(); + } +``` + +- [ ] **Step 2: Create the overlay** + +`compose.observability.yml` (repo root): + +```yaml +services: + api: + environment: + OTEL_EXPORTER_OTLP_ENDPOINT: http://aspire-dashboard:18889 + + aspire-dashboard: + image: mcr.microsoft.com/dotnet/aspire-dashboard:9.4 + environment: + DASHBOARD__FRONTEND__AUTHMODE: Unsecured + ports: + - "127.0.0.1:18888:18888" +``` + +The OTLP receiver port (18889) stays internal to the compose network; only the UI is +published, loopback-only, unauthenticated — acceptable for a local demo surface. + +- [ ] **Step 3: Document in README** + +In `README.md`, directly under the `## Health & telemetry` bullet list, append: + +````markdown +### Local telemetry dashboard + +```bash +docker compose -f docker-compose.yml -f compose.observability.yml up --build +``` + +Aspire Dashboard UI: http://127.0.0.1:18888 — live traces, metrics and logs for every request. +For `dotnet run` development, start just the dashboard +(`docker compose -f docker-compose.yml -f compose.observability.yml up aspire-dashboard`) +and set the user secret `OTEL_EXPORTER_OTLP_ENDPOINT` to `http://localhost:18889`. +```` + +- [ ] **Step 4: Validate** + +```bash +docker compose -f docker-compose.yml -f compose.observability.yml config --quiet +dotnet build && dotnet test tests/PriceNegotiationApp.IntegrationTests --no-build +``` + +The first command proves the merged compose model is valid; integration tests prove the +API boots identically without the endpoint set (exporter skipped, no retry noise). +Optional but recommended manual smoke: run the full overlay stack, hit +`POST /api/v1/auth/register`, confirm the trace appears at http://127.0.0.1:18888. + +- [ ] **Step 5: Commit** + +```bash +git add src/PriceNegotiationApp.Api/Extensions/WebApplicationBuilderExtensions.cs compose.observability.yml README.md +git commit -m "feat(ops): opt-in otlp export with aspire dashboard compose overlay" +``` + +--- + +### Task 7: Fail-fast startup configuration validation + +**Files:** +- Create: `src/PriceNegotiationApp.Modules.Identity/Seeding/SeedingOptionsValidator.cs` +- Modify: `src/PriceNegotiationApp.Modules.Identity/IdentityModule.cs:43-44` +- Modify: `src/PriceNegotiationApp.Modules.Catalog/CatalogModule.cs:19-20` +- Create: `src/PriceNegotiationApp.Api/Extensions/RateLimitingOptionsValidator.cs` +- Create: `src/PriceNegotiationApp.Api/Extensions/CorsOriginsGuard.cs` +- Modify: `src/PriceNegotiationApp.Api/Extensions/WebApplicationBuilderExtensions.cs` (Cors + rate-limiter wiring) +- Test: `tests/PriceNegotiationApp.Modules.Identity.Tests/SeedingOptionsValidatorShould.cs` +- Test: `tests/PriceNegotiationApp.IntegrationTests/ConfigurationValidationShould.cs` + +**Interfaces:** +- Consumes: the JWT precedent — `AddOptions().Bind(section)` + `ValidateOnStart()` + `IValidateOptions` singleton (IdentityModule.cs:36-39). +- Produces: + - `SeedingOptionsValidator : IValidateOptions` (internal, Identity module). + - `RateLimitingOptionsValidator : IValidateOptions` (**public** — Api internals are visible to no test project). + - `static CorsOriginsGuard.EnsureValid(IEnumerable? origins)` (**public**) — throws `InvalidOperationException` naming the first bad origin. + +- [ ] **Step 1: Identity seeding validator** + +Create `src/PriceNegotiationApp.Modules.Identity/Seeding/SeedingOptionsValidator.cs`: + +```csharp +using Microsoft.Extensions.Options; + +namespace PriceNegotiationApp.Modules.Identity.Seeding; + +internal sealed class SeedingOptionsValidator : IValidateOptions +{ + public ValidateOptionsResult Validate(string? name, SeedingOptions options) + { + var failures = new List(); + + if (string.IsNullOrWhiteSpace(options.AdminEmail) || !options.AdminEmail.Contains('@')) + { + failures.Add("Seeding:AdminEmail must be a non-empty email address."); + } + + if (string.IsNullOrWhiteSpace(options.StaffEmail) || !options.StaffEmail.Contains('@')) + { + failures.Add("Seeding:StaffEmail must be a non-empty email address."); + } + + if (string.IsNullOrWhiteSpace(options.AdminPassword) || options.AdminPassword.Length < 8) + { + failures.Add("Seeding:AdminPassword must be at least 8 characters."); + } + + if (string.IsNullOrWhiteSpace(options.StaffPassword) || options.StaffPassword.Length < 8) + { + failures.Add("Seeding:StaffPassword must be at least 8 characters."); + } + + return failures.Count > 0 ? ValidateOptionsResult.Fail(failures) : ValidateOptionsResult.Success; + } +} +``` + +Wire it in `IdentityModule.cs` — replace lines 43–44: + +```csharp + services.AddOptions() + .Bind(configuration.GetSection(SeedingOptions.SectionName)) + .ValidateOnStart(); + services.AddSingleton, SeedingOptionsValidator>(); +``` + +(The following `services.AddHostedService();` line stays.) + +- [ ] **Step 2: Catalog deliberate-omission comment** + +In `CatalogModule.cs`, replace lines 19–20 with: + +```csharp + // Deliberately unvalidated: CatalogSeedingOptions is a single optional bool + // with no meaningful validation surface (engineering-hardening spec §7). + services.AddOptions() + .Bind(configuration.GetSection(CatalogSeedingOptions.SectionName)); +``` + +- [ ] **Step 3: Api validators** + +Create `src/PriceNegotiationApp.Api/Extensions/RateLimitingOptionsValidator.cs`: + +```csharp +using Microsoft.Extensions.Options; + +namespace PriceNegotiationApp.Api.Extensions; + +public sealed class RateLimitingOptionsValidator : IValidateOptions +{ + public ValidateOptionsResult Validate(string? name, RateLimitingOptions options) => + options.AuthPermitLimit >= 1 + ? ValidateOptionsResult.Success + : ValidateOptionsResult.Fail($"{RateLimitingOptions.SectionName}:AuthPermitLimit must be >= 1."); +} +``` + +Create `src/PriceNegotiationApp.Api/Extensions/CorsOriginsGuard.cs`: + +```csharp +namespace PriceNegotiationApp.Api.Extensions; + +public static class CorsOriginsGuard +{ + /// Throws at startup when a configured CORS origin is not an absolute http(s) URI. + public static void EnsureValid(IEnumerable? origins) + { + foreach (var origin in origins ?? []) + { + var valid = Uri.TryCreate(origin, UriKind.Absolute, out var parsed) + && parsed.Scheme is "http" or "https"; + if (!valid) + { + throw new InvalidOperationException( + $"Cors:AllowedOrigins entry '{origin}' is not a valid absolute http(s) URI."); + } + } + } +} +``` + +Both classes are `public` because Api internals are visible to no test assembly. + +- [ ] **Step 4: Wire them into AddApiServices** + +In `WebApplicationBuilderExtensions.cs`, replace the existing Cors block: + +```csharp + var origins = configuration.GetSection("Cors:AllowedOrigins").Get() ?? []; + if (origins.Length > 0) + { + builder.Services.AddCors(options => options.AddPolicy(CorsPolicy, policy => + policy.WithOrigins(origins).AllowAnyHeader().AllowAnyMethod())); + } +``` + +with: + +```csharp + var origins = configuration.GetSection("Cors:AllowedOrigins").Get() ?? []; + CorsOriginsGuard.EnsureValid(origins); + if (origins.Length > 0) + { + builder.Services.AddCors(options => options.AddPolicy(CorsPolicy, policy => + policy.WithOrigins(origins).AllowAnyHeader().AllowAnyMethod())); + } +``` + +Then, immediately after the existing `var rateLimits = ...` line, add the binder + +validator (the limiter itself keeps consuming the local `rateLimits` value — zero churn): + +```csharp + builder.Services.AddOptions() + .Bind(configuration.GetSection(RateLimitingOptions.SectionName)) + .ValidateOnStart(); + builder.Services.AddSingleton, + RateLimitingOptionsValidator>(); +``` + +Add using `Microsoft.Extensions.Options;` to the file. + +- [ ] **Step 5: Unit tests — identity validator** + +Create `tests/PriceNegotiationApp.Modules.Identity.Tests/SeedingOptionsValidatorShould.cs`: + +```csharp +using PriceNegotiationApp.Modules.Identity.Seeding; +using Shouldly; +using Xunit; + +namespace PriceNegotiationApp.Modules.Identity.Tests; + +public class SeedingOptionsValidatorShould +{ + private readonly SeedingOptionsValidator _sut = new(); + + private static SeedingOptions Options( + string adminEmail = "admin@app.com", + string adminPassword = "Sup3rSecret!", + string staffEmail = "staff@app.com", + string staffPassword = "Sup3rSecret!") => new() + { + AdminEmail = adminEmail, + AdminPassword = adminPassword, + StaffEmail = staffEmail, + StaffPassword = staffPassword, + }; + + [Fact] + public void Accept_a_complete_configuration() => + _sut.Validate(null, Options()).Succeeded.ShouldBeTrue(); + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("not-an-email")] + public void Reject_invalid_admin_email(string? email) => + _sut.Validate(null, Options(adminEmail: email!)).Failed.ShouldBeTrue(); + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("short")] + public void Reject_admin_password_shorter_than_identity_floor(string? password) => + _sut.Validate(null, Options(adminPassword: password!)).Failed.ShouldBeTrue(); + + [Fact] + public void Aggregate_every_violation_in_one_result() + { + // Defaults supply valid emails; only both passwords violate. + var result = _sut.Validate(null, new SeedingOptions()); + + result.Failed.ShouldBeTrue(); + result.Failures.Count().ShouldBe(2); + result.Failures.ShouldContain(f => f.Contains("AdminPassword")); + result.Failures.ShouldContain(f => f.Contains("StaffPassword")); + } +} +``` + +(`SeedingOptions` has `init`-only properties, internal-visible to this project; its class +defaults already satisfy the two email rules, so the all-defaults instance yields exactly +the two password failures. `Failures` is `IEnumerable` → use `.Count()`.) + +- [ ] **Step 6: Unit tests — Api validators** + +Create `tests/PriceNegotiationApp.IntegrationTests/ConfigurationValidationShould.cs` +(plain facts; no Docker container needed for these two): + +```csharp +using PriceNegotiationApp.Api.Extensions; +using Shouldly; +using Xunit; + +namespace PriceNegotiationApp.IntegrationTests; + +public class ConfigurationValidationShould +{ + [Theory] + [InlineData(1)] + [InlineData(30)] + [InlineData(int.MaxValue)] + public void Accept_permit_limits_of_at_least_one(int limit) => + new RateLimitingOptionsValidator() + .Validate(null, new RateLimitingOptions { AuthPermitLimit = limit }) + .Succeeded.ShouldBeTrue(); + + [Theory] + [InlineData(0)] + [InlineData(-5)] + public void Reject_non_positive_permit_limits(int limit) => + new RateLimitingOptionsValidator() + .Validate(null, new RateLimitingOptions { AuthPermitLimit = limit }) + .Failed.ShouldBeTrue(); + + [Fact] + public void Accept_well_formed_cors_origins() => + Should.NotThrow(() => CorsOriginsGuard.EnsureValid( + ["https://app.example.com", "http://localhost:3000"])); + + [Fact] + public void Tolerate_null_or_empty_cors_lists() => + Should.NotThrow(() => CorsOriginsGuard.EnsureValid(null)); + + [Theory] + [InlineData("app.example.com")] + [InlineData("ftp://app.example.com")] + [InlineData("https://")] + public void Reject_malformed_cors_origins(string origin) => + Should.Throw( + () => CorsOriginsGuard.EnsureValid([origin])) + .Message.ShouldContain(origin); +} +``` + +- [ ] **Step 7: Validate** + +```bash +dotnet build && dotnet test tests/PriceNegotiationApp.Modules.Identity.Tests --no-build +dotnet test tests/PriceNegotiationApp.IntegrationTests --no-build --filter-class PriceNegotiationApp.IntegrationTests.ConfigurationValidationShould +``` + +All green; zero warnings. Startup-success path is additionally covered by every existing +integration test (they boot the real factory through the new validators). + +- [ ] **Step 8: Commit** + +```bash +git add src/PriceNegotiationApp.Modules.Identity src/PriceNegotiationApp.Modules.Catalog/CatalogModule.cs src/PriceNegotiationApp.Api/Extensions tests/PriceNegotiationApp.Modules.Identity.Tests/SeedingOptionsValidatorShould.cs tests/PriceNegotiationApp.IntegrationTests/ConfigurationValidationShould.cs +git commit -m "feat(config): fail-fast startup validation for seeding, rate limiting and cors" +``` + +--- + +### Task 8: Full validation + uncommitted nuget.config (E-11, LAST) + +**Files:** +- Create (UNCOMMITTED): `nuget.config` + +**Interfaces:** +- Consumes: everything from Tasks 1–7. +- Produces: a clean, fully validated tree plus one deliberately-untracked local hardening file. + +- [ ] **Step 1: CI-parity validation of the whole tree** + +```bash +dotnet format --verify-no-changes --no-restore +dotnet build -c Release --no-restore +dotnet test --solution PriceNegotiationApp.slnx -c Release --no-build --coverage --coverage-output-format cobertura +``` + +All five test projects green (format check runs first; fix with `dotnet format` and re-commit +if it flags anything). If Docker is unavailable, run unit + architecture projects only and +state the integration skip explicitly. + +- [ ] **Step 2: Create nuget.config — DO NOT COMMIT** + +Create `nuget.config` in the repo root: + +```xml + + + + + + + + + + +``` + +- [ ] **Step 3: Prove restore still works under the locked-down sources** + +```bash +dotnet restore --force +dotnet build --no-restore -c Release +``` + +Both succeed purely from nuget.org. This is the acceptance test for E-11. + +- [ ] **Step 4: Verify E-11 stays out of git** + +```bash +git status --short +git log --oneline -8 +``` + +`nuget.config` must appear as an untracked file (`??`) and appear in NO commit. Do not add +it to `.gitignore` either — it is intentionally present-but-local. + +--- + +## Self-Review Record + +- Spec coverage: §1 E-01 → Task 6; §2 E-03 → Task 4; §3 E-04 → Task 5; §4 E-10 → Task 2; + §5 E-12 → Task 1; §6 E-13 → Task 3; §7 E-18 → Task 7 (Catalog omission comment included); + §8 E-11 → Task 8 last/uncommitted; rollout order matches spec §9. +- Placeholder scan: none remaining — every code step carries complete file content; + the Aspire image tag is pinned to `9.4`. +- Type consistency: `ReadyHealthReport.WriteAsync(HttpContext, HealthReport)` matches its + wiring; validator class names (`SeedingOptionsValidator`, `RateLimitingOptionsValidator`, + `CorsOriginsGuard.EnsureValid`) identical across creation/wiring/test steps; `IsInfrastructurePath` + helper defined in Task 4 where used. + diff --git a/docs/superpowers/plans/2026-08-25-handler-extraction.md b/docs/superpowers/plans/2026-08-25-handler-extraction.md new file mode 100644 index 0000000..f23d71e --- /dev/null +++ b/docs/superpowers/plans/2026-08-25-handler-extraction.md @@ -0,0 +1,860 @@ +# Handler Extraction Implementation Plan — Endpoints as Transport Adapters + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Remove `DbContext`/`UserManager` injection from all 15 route handlers by introducing per-operation `internal sealed` handler classes; endpoints become pure transport adapters. + +**Architecture:** Each operation gets an injectable `internal sealed Handler` (primary constructor captures scoped dependencies) registered explicitly `AddScoped` in its module. Handlers own queries, mutation, `SaveChanges`, conflict translation and DTO construction, throwing the existing SharedKernel exceptions; endpoints keep routes, verbs, status shaping, auth/cache/rate-limit attributes and `ClaimsPrincipal → CallerContext` translation. Zero behavior change — the 37-test integration suite is the regression harness. + +**Tech Stack:** ASP.NET Core minimal APIs (service-in-endpoint parameters), EF Core 10, xunit.v3/MTP. + +## Global Constraints + +- Source spec: `docs/superpowers/specs/2026-08-25-handler-extraction-design.md`. +- **Zero behavior change**: routes, verbs, status codes, ProblemDetails `code`s, cache/rate-limit policies, named-route metadata (`GetProductById`) must remain identical. +- No repositories/UoW abstractions; no MediatR; handlers are `internal sealed` and `AddScoped`. +- `ClaimsPrincipal` never enters a handler — handlers receive `CallerContext`. +- `Me` endpoint and health endpoints stay as-is. +- Every task: zero-warning build, touched integration suites green (Docker required), commit. +- Shell pwsh from repo root. Validation pattern that respects exit codes: + +```powershell +dotnet build 2>&1 | Out-Null; if ($LASTEXITCODE -ne 0) { dotnet build 2>&1 | Select-String error | Select-Object -First 8 } else { } +``` + +--- + +### Task 1: Negotiations write-path handlers + +**Files:** +- Create: `src/PriceNegotiationApp.Modules.Negotiations/Features/Negotiations/CreateNegotiationHandler.cs` +- Create: `src/PriceNegotiationApp.Modules.Negotiations/Features/Negotiations/CounterProposeHandler.cs` +- Create: `src/PriceNegotiationApp.Modules.Negotiations/Features/Negotiations/AcceptHandler.cs` +- Create: `src/PriceNegotiationApp.Modules.Negotiations/Features/Negotiations/RejectCurrentOfferHandler.cs` +- Create: `src/PriceNegotiationApp.Modules.Negotiations/Features/Negotiations/WithdrawHandler.cs` +- Modify: `src/PriceNegotiationApp.Modules.Negotiations/Features/Negotiations/Create.cs` +- Modify: `src/PriceNegotiationApp.Modules.Negotiations/Features/Negotiations/CounterPropose.cs` +- Modify: `src/PriceNegotiationApp.Modules.Negotiations/Features/Negotiations/Accept.cs` +- Modify: `src/PriceNegotiationApp.Modules.Negotiations/Features/Negotiations/RejectCurrentOffer.cs` +- Modify: `src/PriceNegotiationApp.Modules.Negotiations/Features/Negotiations/Withdraw.cs` +- Modify: `src/PriceNegotiationApp.Modules.Negotiations/NegotiationsModule.cs` + +**Interfaces:** +- Consumes: `NegotiationAccess` (`RequireAsync`, `RequireOwnedAsync`, `IsOwnerAsync`, `FindOpenAsync`, `GetOrCreateCustomerIdAsync`), `DbWriteGuard.SaveOrConflictAsync(this DbContext, Func, CancellationToken)` (via `CreateNegotiationHandler`), response records in `NegotiationModels.cs`. +- Produces: handler contracts consumed by Tasks 2's read handlers' style and by the endpoints in this task: + - `Task CreateNegotiationHandler.HandleAsync(CreateNegotiationRequest, CallerContext, CancellationToken)` + - `Task CounterProposeHandler.HandleAsync(Guid id, CounterProposalRequest, CallerContext, CancellationToken)` + - `Task AcceptHandler.HandleAsync(Guid id, CancellationToken)` + - `Task RejectCurrentOfferHandler.HandleAsync(Guid id, CancellationToken)` + - `Task WithdrawHandler.HandleAsync(Guid id, CallerContext, CancellationToken)` + +- [ ] **Step 1: Create the five handlers** + +`CreateNegotiationHandler.cs`: + +```csharp +using PriceNegotiationApp.Modules.Negotiations.Domain; +using PriceNegotiationApp.Modules.Negotiations.Persistence; +using PriceNegotiationApp.Modules.Negotiations.Ports; +using PriceNegotiationApp.SharedKernel; + +namespace PriceNegotiationApp.Modules.Negotiations.Features.Negotiations; + +internal sealed class CreateNegotiationHandler( + NegotiationsDbContext db, + IProductPriceProvider products, + INegotiationPolicy policy, + TimeProvider clock) +{ + public async Task HandleAsync( + CreateNegotiationRequest command, CallerContext caller, CancellationToken ct) + { + var snapshot = await products.GetAsync(command.ProductId, ct) + ?? throw new NotFoundException("Product", command.ProductId); + + if (await NegotiationAccess.FindOpenAsync(db, snapshot.ProductId, caller.UserId, ct) is not null) + { + throw new ConflictException(NegotiationErrorCodes.NegotiationAlreadyOpen, + "An open negotiation already exists for this product."); + } + + var customerId = await NegotiationAccess.GetOrCreateCustomerIdAsync(db, caller.UserId, ct); + var negotiation = Negotiation.Start(customerId, snapshot.ProductId, snapshot.Price, + command.ProposedPrice, clock.GetUtcNow(), policy); + await db.Negotiations.AddAsync(negotiation, ct); + + // The partial unique index is the real guard; a race that slipped past the + // pre-check above surfaces here as a 409 instead of a 500. + await db.SaveOrConflictAsync( + _ => new ConflictException(NegotiationErrorCodes.NegotiationAlreadyOpen, + "An open negotiation already exists for this product."), ct); + + return NegotiationResponses.ToResponse(negotiation); + } +} +``` + +(`NegotiationsDbContext` resolves via `using PriceNegotiationApp.Modules.Negotiations.Persistence;` +included in the usings above.) + +`CounterProposeHandler.cs`: + +```csharp +using PriceNegotiationApp.Modules.Negotiations.Domain; +using PriceNegotiationApp.SharedKernel; + +namespace PriceNegotiationApp.Modules.Negotiations.Features.Negotiations; + +internal sealed class CounterProposeHandler(NegotiationsDbContext db, TimeProvider clock) +{ + public async Task HandleAsync( + Guid id, CounterProposalRequest request, CallerContext caller, CancellationToken ct) + { + var negotiation = await NegotiationAccess.RequireOwnedAsync(db, caller, id, ct); + + var outcome = negotiation.CounterPropose(request.ProposedPrice, clock.GetUtcNow()); + if (outcome == NegotiationOutcome.NoProposalsRemaining) + { + throw new ConflictException(NegotiationErrorCodes.NoProposalsRemaining, + "No proposals remain for this negotiation."); + } + + await db.SaveChangesAsync(ct); + return new CounterProposalOutcome(outcome.ToString(), NegotiationResponses.ToResponse(negotiation)); + } +} +``` + +`AcceptHandler.cs`: + +```csharp +namespace PriceNegotiationApp.Modules.Negotiations.Features.Negotiations; + +internal sealed class AcceptHandler(NegotiationsDbContext db, TimeProvider clock) +{ + public async Task HandleAsync(Guid id, CancellationToken ct) + { + var negotiation = await NegotiationAccess.RequireAsync(db, id, ct); + negotiation.Accept(clock.GetUtcNow()); + await db.SaveChangesAsync(ct); + return new StaffActionResponse("accepted", NegotiationResponses.ToResponse(negotiation)); + } +} +``` + +`RejectCurrentOfferHandler.cs`: + +```csharp +namespace PriceNegotiationApp.Modules.Negotiations.Features.Negotiations; + +internal sealed class RejectCurrentOfferHandler(NegotiationsDbContext db, TimeProvider clock) +{ + public async Task HandleAsync(Guid id, CancellationToken ct) + { + var negotiation = await NegotiationAccess.RequireAsync(db, id, ct); + negotiation.RejectCurrentOffer(clock.GetUtcNow()); + await db.SaveChangesAsync(ct); + return new StaffActionResponse("current_offer_rejected", + NegotiationResponses.ToResponse(negotiation)); + } +} +``` + +`WithdrawHandler.cs`: + +```csharp +using PriceNegotiationApp.SharedKernel; + +namespace PriceNegotiationApp.Modules.Negotiations.Features.Negotiations; + +internal sealed class WithdrawHandler(NegotiationsDbContext db, TimeProvider clock) +{ + public async Task HandleAsync(Guid id, CallerContext caller, CancellationToken ct) + { + var negotiation = await NegotiationAccess.RequireAsync(db, id, ct); + + if (caller.IsInRole(UserRoles.Admin)) + { + db.Negotiations.Remove(negotiation); + } + else + { + if (!await NegotiationAccess.IsOwnerAsync(db, caller.UserId, negotiation, ct)) + { + throw new ForbiddenAccessException(); + } + + negotiation.Withdraw(clock.GetUtcNow()); + } + + await db.SaveChangesAsync(ct); + } +} +``` + +All five files share namespace `PriceNegotiationApp.Modules.Negotiations.Features.Negotiations`; +`AcceptHandler` / `RejectCurrentOfferHandler` need no extra usings beyond defaults. + +- [ ] **Step 2: Slim the five endpoint files** + +Each becomes transport-only (no EF/Persistence/Domain usings). Example — `Create.cs`: + +```csharp +using PriceNegotiationApp.SharedKernel; +using System.Security.Claims; + +namespace PriceNegotiationApp.Modules.Negotiations.Features.Negotiations; + +internal static class Create +{ + internal static void MapCreate(this RouteGroupBuilder group) + { + group.MapPost("/", async (CreateNegotiationRequest request, ClaimsPrincipal principal, + CreateNegotiationHandler handler, CancellationToken ct) => + TypedResults.Created("/api/v1/negotiations/mine", + await handler.HandleAsync(request, principal.ToCallerContext(), ct))) + .RequireRoles(UserRoles.Customer); + } +} +``` + +`CounterPropose.cs` body: + +```csharp + group.MapPatch("/{id:guid}/proposals", async (Guid id, CounterProposalRequest request, + ClaimsPrincipal principal, CounterProposeHandler handler, CancellationToken ct) => + TypedResults.Ok(await handler.HandleAsync(id, request, principal.ToCallerContext(), ct))) + .RequireAuthorization(); +``` + +`Accept.cs` body: + +```csharp + group.MapPost("/{id:guid}/accept", async (Guid id, AcceptHandler handler, CancellationToken ct) => + TypedResults.Ok(await handler.HandleAsync(id, ct))) + .RequireRoles(UserRoles.Admin, UserRoles.Staff); +``` + +`RejectCurrentOffer.cs` body (route stays `/decline`): + +```csharp + group.MapPost("/{id:guid}/decline", async (Guid id, RejectCurrentOfferHandler handler, + CancellationToken ct) => + TypedResults.Ok(await handler.HandleAsync(id, ct))) + .RequireRoles(UserRoles.Admin, UserRoles.Staff); +``` + +`Withdraw.cs` body: + +```csharp + group.MapDelete("/{id:guid}", async (Guid id, ClaimsPrincipal principal, + WithdrawHandler handler, CancellationToken ct) => + { + await handler.HandleAsync(id, principal.ToCallerContext(), ct); + return TypedResults.NoContent(); + }) + .RequireAuthorization(); +``` + +Remove now-unused usings from each (`Microsoft.EntityFrameworkCore`, `…Persistence`, +`…Domain`, `Microsoft.AspNetCore.Authorization` where `RequireAuthorization` moved off? — +keep whatever compiles clean; EnforceCodeStyleInBuild will flag stragglers). + +- [ ] **Step 3: Register handlers** + +In `NegotiationsModule.AddNegotiationsModule`, after the `INegotiationPolicy` registration +and before `return services;`: + +```csharp + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); +``` + +Add `using PriceNegotiationApp.Modules.Negotiations.Features.Negotiations;`. + +- [ ] **Step 4: Validate** + +```powershell +dotnet build 2>&1 | Out-Null; if ($LASTEXITCODE -eq 0) { dotnet test tests/PriceNegotiationApp.IntegrationTests --no-build 2>&1 | Select-String -Pattern 'failed:|succeeded:' | Select-Object -First 2 } else { dotnet build 2>&1 | Select-String error | Select-Object -First 8 } +``` + +37 integration tests green (Docker required). + +- [ ] **Step 5: Commit** + +```bash +git add src/PriceNegotiationApp.Modules.Negotiations +git commit -m "refactor(negotiations): write-path handlers own persistence, endpoints go transport-only" +``` + +--- + +### Task 2: Negotiations read/list handlers + +**Files:** +- Create: `src/PriceNegotiationApp.Modules.Negotiations/Features/Negotiations/GetNegotiationHandler.cs` +- Create: `src/PriceNegotiationApp.Modules.Negotiations/Features/Negotiations/ListNegotiationsHandler.cs` +- Create: `src/PriceNegotiationApp.Modules.Negotiations/Features/Negotiations/ListMyNegotiationsHandler.cs` +- Modify: `src/PriceNegotiationApp.Modules.Negotiations/Features/Negotiations/Get.cs` +- Modify: `src/PriceNegotiationApp.Modules.Negotiations/Features/Negotiations/List.cs` +- Modify: `src/PriceNegotiationApp.Modules.Negotiations/Features/Negotiations/ListMine.cs` +- Modify: `src/PriceNegotiationApp.Modules.Negotiations/NegotiationsModule.cs` (3 registrations) + +**Interfaces:** +- Consumes Task 1 conventions; `NegotiationAccess.RequireReadOnlyAsync / CanAccessAsync / CustomerByIdentityAsync`. +- Produces: + - `Task GetNegotiationHandler.HandleAsync(Guid id, CallerContext, CancellationToken)` + - `Task> ListNegotiationsHandler.HandleAsync(PageQuery, CancellationToken)` + - `Task> ListMyNegotiationsHandler.HandleAsync(PageQuery, CallerContext, CancellationToken)` + +- [ ] **Step 1: Handlers** + +`GetNegotiationHandler.cs`: + +```csharp +using PriceNegotiationApp.SharedKernel; + +namespace PriceNegotiationApp.Modules.Negotiations.Features.Negotiations; + +internal sealed class GetNegotiationHandler(NegotiationsDbContext db) +{ + public async Task HandleAsync(Guid id, CallerContext caller, CancellationToken ct) + { + var negotiation = await NegotiationAccess.RequireReadOnlyAsync(db, id, ct); + if (!await NegotiationAccess.CanAccessAsync(db, caller, negotiation, ct)) + { + throw new ForbiddenAccessException(); + } + + return NegotiationResponses.ToResponse(negotiation); + } +} +``` + +`ListNegotiationsHandler.cs`: + +```csharp +using Microsoft.EntityFrameworkCore; +using PriceNegotiationApp.SharedKernel; + +namespace PriceNegotiationApp.Modules.Negotiations.Features.Negotiations; + +internal sealed class ListNegotiationsHandler(NegotiationsDbContext db) +{ + public async Task> HandleAsync(PageQuery page, CancellationToken ct) + { + var q = db.Negotiations.AsNoTracking(); + var total = await q.LongCountAsync(ct); + var items = await q.OrderByDescending(n => n.CreatedAtUtc) + .Skip(page.Skip).Take(page.SafePageSize) + .ToListAsync(ct); + + return new PagedResult( + items.Select(NegotiationResponses.ToResponse).ToList(), + page.SafePage, page.SafePageSize, total); + } +} +``` + +`ListMyNegotiationsHandler.cs`: + +```csharp +using Microsoft.EntityFrameworkCore; +using PriceNegotiationApp.SharedKernel; + +namespace PriceNegotiationApp.Modules.Negotiations.Features.Negotiations; + +internal sealed class ListMyNegotiationsHandler(NegotiationsDbContext db) +{ + public async Task> HandleAsync( + PageQuery page, CallerContext caller, CancellationToken ct) + { + var customer = await NegotiationAccess.CustomerByIdentityAsync(db, caller.UserId, ct); + if (customer is null) + { + return new PagedResult([], page.SafePage, page.SafePageSize, 0); + } + + var q = db.Negotiations.AsNoTracking().Where(n => n.CustomerId == customer.Id); + var total = await q.LongCountAsync(ct); + var items = await q.OrderByDescending(n => n.CreatedAtUtc) + .Skip(page.Skip).Take(page.SafePageSize) + .ToListAsync(ct); + + return new PagedResult( + items.Select(NegotiationResponses.ToResponse).ToList(), + page.SafePage, page.SafePageSize, total); + } +} +``` + +- [ ] **Step 2: Slim the three endpoint files** + +`Get.cs` (drop EF/Persistence usings; keep Authorization): + +```csharp +using PriceNegotiationApp.SharedKernel; +using System.Security.Claims; + +namespace PriceNegotiationApp.Modules.Negotiations.Features.Negotiations; + +internal static class Get +{ + internal static void MapGetOne(this RouteGroupBuilder group) + { + group.MapGet("/{id:guid}", async (Guid id, ClaimsPrincipal principal, + GetNegotiationHandler handler, CancellationToken ct) => + TypedResults.Ok(await handler.HandleAsync(id, principal.ToCallerContext(), ct))) + .RequireAuthorization(); + } +} +``` + +`List.cs` body (namespace stays `…Modules.Negotiations.Features.Negotiations`): + +```csharp + group.MapGet("/", async (ListNegotiationsHandler handler, CancellationToken ct, + int page = 1, int pageSize = 20) => + TypedResults.Ok(await handler.HandleAsync(new PageQuery(page, pageSize), ct))) + .RequireRoles(UserRoles.Admin, UserRoles.Staff); +``` + +`ListMine.cs` body: + +```csharp + group.MapGet("/mine", async (ClaimsPrincipal principal, ListMyNegotiationsHandler handler, + CancellationToken ct, int page = 1, int pageSize = 20) => + TypedResults.Ok(await handler.HandleAsync( + new PageQuery(page, pageSize), principal.ToCallerContext(), ct))) + .RequireRoles(UserRoles.Customer); +``` + +- [ ] **Step 3: Register** + +In `NegotiationsModule`, alongside Task 1's registrations: + +```csharp + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); +``` + +- [ ] **Step 4: Validate + commit** + +```powershell +dotnet build 2>&1 | Out-Null; if ($LASTEXITCODE -eq 0) { dotnet test tests/PriceNegotiationApp.IntegrationTests --no-build 2>&1 | Select-String -Pattern 'failed:|succeeded:' | Select-Object -First 2 } else { dotnet build 2>&1 | Select-String error | Select-Object -First 8 } +git add src/PriceNegotiationApp.Modules.Negotiations +git commit -m "refactor(negotiations): read and list handlers extracted" +``` + +--- + +### Task 3: Catalog handlers + +**Files:** +- Create: `Features/Products/CreateProductHandler.cs`, `UpdateProductHandler.cs`, `DeleteProductHandler.cs`, `GetProductHandler.cs`, `ListProductsHandler.cs` (all under `src/PriceNegotiationApp.Modules.Catalog/Features/Products/`) +- Modify: the five Catalog endpoint files (`Create.cs`, `Update.cs`, `Delete.cs`, `Get.cs`, `List.cs`) +- Modify: `src/PriceNegotiationApp.Modules.Catalog/CatalogModule.cs` (5 registrations) + +**Interfaces:** +- Produces: + - `Task CreateProductHandler.HandleAsync(CreateProductRequest, CancellationToken)` + - `Task UpdateProductHandler.HandleAsync(Guid id, UpdateProductRequest, CancellationToken)` + - `Task DeleteProductHandler.HandleAsync(Guid id, CancellationToken)` + - `Task GetProductHandler.HandleAsync(Guid id, CancellationToken)` + - `Task> ListProductsHandler.HandleAsync(ProductQuery, CancellationToken)` + +- [ ] **Step 1: Handlers** + +`CreateProductHandler.cs`: + +```csharp +using PriceNegotiationApp.Modules.Catalog.Domain; + +namespace PriceNegotiationApp.Modules.Catalog.Features.Products; + +internal sealed class CreateProductHandler(CatalogDbContext db) +{ + public async Task HandleAsync(CreateProductRequest request, CancellationToken ct) + { + var product = Product.Create(request.Name, request.Price); + db.Products.Add(product); + await db.SaveChangesAsync(ct); + return new ProductResponse(product.Id.Value, product.Name, product.Price); + } +} +``` + +`UpdateProductHandler.cs`: + +```csharp +using Microsoft.EntityFrameworkCore; +using PriceNegotiationApp.Modules.Catalog.Domain; +using PriceNegotiationApp.SharedKernel; + +namespace PriceNegotiationApp.Modules.Catalog.Features.Products; + +internal sealed class UpdateProductHandler(CatalogDbContext db) +{ + public async Task HandleAsync(Guid id, UpdateProductRequest request, CancellationToken ct) + { + var product = await db.Products.FirstOrDefaultAsync(p => p.Id == ProductId.From(id), ct) + ?? throw new NotFoundException("Product", id); + + product.Update(request.Name, request.Price); + await db.SaveChangesAsync(ct); + return new ProductResponse(product.Id.Value, product.Name, product.Price); + } +} +``` + +`DeleteProductHandler.cs`: + +```csharp +using Microsoft.EntityFrameworkCore; +using PriceNegotiationApp.Modules.Catalog.Domain; +using PriceNegotiationApp.SharedKernel; + +namespace PriceNegotiationApp.Modules.Catalog.Features.Products; + +internal sealed class DeleteProductHandler(CatalogDbContext db) +{ + // Negotiations survive on their snapshots by design. + public async Task HandleAsync(Guid id, CancellationToken ct) + { + var product = await db.Products.FirstOrDefaultAsync(p => p.Id == ProductId.From(id), ct) + ?? throw new NotFoundException("Product", id); + db.Products.Remove(product); + await db.SaveChangesAsync(ct); + } +} +``` + +`GetProductHandler.cs` (absorbs the old static projection query): + +```csharp +using Microsoft.EntityFrameworkCore; +using PriceNegotiationApp.Modules.Catalog.Domain; +using PriceNegotiationApp.SharedKernel; + +namespace PriceNegotiationApp.Modules.Catalog.Features.Products; + +internal sealed class GetProductHandler(CatalogDbContext db) +{ + public async Task HandleAsync(Guid id, CancellationToken ct) => + await db.Products.AsNoTracking() + .Where(p => p.Id == ProductId.From(id)) + .Select(p => new ProductResponse(p.Id.Value, p.Name, p.Price)) + .FirstOrDefaultAsync(ct) + ?? throw new NotFoundException("Product", id); +} +``` + +`ListProductsHandler.cs` (absorbs `SearchAsync`; keeps `EF.Functions.ILike`, sort switch, +paging): + +```csharp +using Microsoft.EntityFrameworkCore; +using PriceNegotiationApp.SharedKernel; + +namespace PriceNegotiationApp.Modules.Catalog.Features.Products; + +internal sealed class ListProductsHandler(CatalogDbContext db) +{ + public async Task> HandleAsync(ProductQuery query, CancellationToken ct) + { + var page = new PageQuery(query.Page, query.PageSize); + var q = db.Products.AsNoTracking(); + + if (!string.IsNullOrWhiteSpace(query.Search)) + { + q = q.Where(p => EF.Functions.ILike(p.Name, $"%{query.Search.Trim()}%")); + } + + if (query.MinPrice.HasValue) + { + q = q.Where(p => p.Price >= query.MinPrice.Value); + } + + if (query.MaxPrice.HasValue) + { + q = q.Where(p => p.Price <= query.MaxPrice.Value); + } + + q = (query.SortBy?.Trim().ToLowerInvariant(), query.SortDesc) switch + { + ("price", true) => q.OrderByDescending(p => p.Price), + ("price", false) => q.OrderBy(p => p.Price), + (_, true) => q.OrderByDescending(p => p.Name), + _ => q.OrderBy(p => p.Name), + }; + + var total = await q.LongCountAsync(ct); + var items = await q + .Skip(page.Skip) + .Take(page.SafePageSize) + .Select(p => new ProductResponse(p.Id.Value, p.Name, p.Price)) + .ToListAsync(ct); + + return new PagedResult(items, page.SafePage, page.SafePageSize, total); + } +} +``` + +- [ ] **Step 2: Slim endpoints** + +Each Catalog endpoint file loses `Microsoft.EntityFrameworkCore` + +`…Catalog.Persistence` usings; lambdas resolve handlers instead of `db`. + +`Create.cs`: `CreatedAtRoute("GetProductById", new { id = response.Id }, response)` where +`var response = await handler.HandleAsync(request, ct);` +`Update.cs`: `Ok(await handler.HandleAsync(id, request, ct))`. +`Delete.cs`: `await handler.HandleAsync(id, ct); return TypedResults.NoContent();` — keep +the existing snapshot-survival comment if present, relocated above the call. +`Get.cs`: `TypedResults.Ok(await handler.HandleAsync(id, ct))` keeping `.WithName`, +`.CacheOutput(Policies.ShortCachePolicy)`, `.AllowAnonymous()`; delete the now-empty +static `RequireAsync`. +`List.cs`: `TypedResults.Ok(await handler.HandleAsync(new ProductQuery(search, minPrice, +maxPrice, sortBy, sortDesc, page, pageSize), ct))` keeping cache attributes; delete static +`SearchAsync`. + +- [ ] **Step 3: Register** + +In `CatalogModule.AddCatalogModule` before `return services;`: + +```csharp + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); +``` + +Add `using PriceNegotiationApp.Modules.Catalog.Features.Products;`. + +- [ ] **Step 4: Validate + commit** + +```powershell +dotnet build 2>&1 | Out-Null; if ($LASTEXITCODE -eq 0) { dotnet test tests/PriceNegotiationApp.IntegrationTests --no-build 2>&1 | Select-String -Pattern 'failed:|succeeded:' | Select-Object -First 2 } else { dotnet build 2>&1 | Select-String error | Select-Object -First 8 } +git add src/PriceNegotiationApp.Modules.Catalog +git commit -m "refactor(catalog): per-operation handlers own persistence" +``` + +--- + +### Task 4: Identity auth handlers + +**Files:** +- Create: `src/PriceNegotiationApp.Modules.Identity/Features/Auth/RegisterUserHandler.cs` +- Create: `src/PriceNegotiationApp.Modules.Identity/Features/Auth/LoginUserHandler.cs` +- Modify: `src/PriceNegotiationApp.Modules.Identity/Features/Auth/Register.cs` +- Modify: `src/PriceNegotiationApp.Modules.Identity/Features/Auth/Login.cs` +- Modify: `src/PriceNegotiationApp.Modules.Identity/IdentityModule.cs` (2 registrations) + +**Interfaces:** +- Consumes: `UserManager`, `JwtManager` (both already DI-registered), `DbWriteGuard.IsUniqueViolation`. +- Produces: + - `Task RegisterUserHandler.HandleAsync(RegisterRequest, CancellationToken)` + - `Task LoginUserHandler.HandleAsync(LoginRequest, CancellationToken)` + +- [ ] **Step 1: RegisterUserHandler** + +```csharp +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using PriceNegotiationApp.Modules.Identity.Persistence; +using PriceNegotiationApp.SharedKernel; + +namespace PriceNegotiationApp.Modules.Identity.Features.Auth; + +internal sealed class RegisterUserHandler(UserManager userManager) +{ + public async Task HandleAsync(RegisterRequest request, CancellationToken ct) + { + var user = new ApplicationUser { UserName = request.Email, Email = request.Email }; + IdentityResult result; + try + { + result = await userManager.CreateAsync(user, request.Password); + } + catch (DbUpdateException ex) when (DbWriteGuard.IsUniqueViolation(ex, out _)) + { + // Two concurrent registrations for the same email: Identity's pre-check + // lost the race, the unique index caught it — same conflict as usual. + throw new ConflictException(IdentityErrorCodes.EmailAlreadyRegistered, + "Email already registered."); + } + + if (!result.Succeeded) + { + if (result.Errors.Any(e => e.Code is "DuplicateEmail" or "DuplicateUserName")) + { + throw new ConflictException(IdentityErrorCodes.EmailAlreadyRegistered, + "Email already registered."); + } + + throw new InvalidRequestException(IdentityErrorCodes.RegistrationInvalid, + string.Join("; ", result.Errors.Select(e => e.Description))); + } + + await userManager.AddToRoleAsync(user, UserRoles.Customer); + return new RegistrationResponse(user.Id); + } +} +``` + +- [ ] **Step 2: LoginUserHandler** + +```csharp +using Microsoft.AspNetCore.Identity; +using PriceNegotiationApp.Modules.Identity.Persistence; +using PriceNegotiationApp.SharedKernel; + +namespace PriceNegotiationApp.Modules.Identity.Features.Auth; + +internal sealed class LoginUserHandler(UserManager userManager, JwtManager jwt) +{ + public async Task HandleAsync(LoginRequest request, CancellationToken ct) + { + var user = await userManager.FindByNameAsync(request.Email) + ?? throw new UnauthorizedException( + IdentityErrorCodes.InvalidCredentials, "Invalid credentials."); + + if (await userManager.IsLockedOutAsync(user)) + { + throw new UnauthorizedException(IdentityErrorCodes.AccountLocked, + "Account temporarily locked."); + } + + if (!await userManager.CheckPasswordAsync(user, request.Password)) + { + await userManager.AccessFailedAsync(user); + throw await userManager.IsLockedOutAsync(user) + ? new UnauthorizedException(IdentityErrorCodes.AccountLocked, + "Account temporarily locked.") + : new UnauthorizedException(IdentityErrorCodes.InvalidCredentials, + "Invalid credentials."); + } + + await userManager.ResetAccessFailedCountAsync(user); + + var roles = (IReadOnlyList)await userManager.GetRolesAsync(user); + var (token, expiresAtUtc) = jwt.Generate(user.Id, request.Email, roles); + return new AuthResponse(token, expiresAtUtc, request.Email, roles); + } +} +``` + +- [ ] **Step 3: Slim endpoints + register** + +`Register.cs` lambda becomes: + +```csharp + group.MapPost("/register", async (RegisterRequest request, + RegisterUserHandler handler, CancellationToken ct) => + TypedResults.Created("/api/v1/auth/me", await handler.HandleAsync(request, ct))) +``` + +(keep `.RequireRateLimiting(Policies.AuthRateLimitPolicy)` and `.AllowAnonymous()`; drop +the UserManager/Identity usings that become unused). + +`Login.cs` lambda becomes: + +```csharp + group.MapPost("/login", async (LoginRequest request, LoginUserHandler handler, + CancellationToken ct) => + TypedResults.Ok(await handler.HandleAsync(request, ct))) +``` + +(keep rate-limiting/anonymous attributes). + +In `IdentityModule.AddIdentityModule`, add: + +```csharp + services.AddScoped(); + services.AddScoped(); +``` + +(`Me.cs`, health endpoints untouched.) + +- [ ] **Step 4: Validate + commit** + +```powershell +dotnet build 2>&1 | Out-Null; if ($LASTEXITCODE -eq 0) { dotnet test tests/PriceNegotiationApp.IntegrationTests --no-build --filter-class PriceNegotiationApp.IntegrationTests.AuthFlowShould 2>&1 | Select-String -Pattern 'failed:|succeeded:' | Select-Object -First 2 } else { dotnet build 2>&1 | Select-String error | Select-Object -First 8 } +git add src/PriceNegotiationApp.Modules.Identity +git commit -m "refactor(identity): register and login handlers own identity infrastructure" +``` + +--- + +### Task 5: Enforcement rule, README law, full CI parity + +**Files:** +- Modify: `tests/PriceNegotiationApp.ArchitectureTests/ArchitectureShould.cs` +- Modify: `README.md` + +**Interfaces:** +- Consumes existing providers (`EntityFramework`, `PersistenceNamespaces`) and ArchUnitNET fluent chain. + +- [ ] **Step 1: Endpoints-stay-transport-only architecture fact** + +Append inside `ArchitectureShould`: + +```csharp + [Fact] + public void Endpoint_mapping_types_stay_transport_only() + { + var endpoints = Types().That().HaveFullNameEndingWith("Endpoints").As("endpoint mapping types"); + + endpoints.Should().NotDependOnAny(EntityFramework).Check(Architecture); + endpoints.Should().NotDependOnAny(PersistenceNamespaces).Check(Architecture); + } +``` + +If any `*Endpoints` type still references EF/persistence after Tasks 1–4 this fact fails +naming the offender. + +- [ ] **Step 2: README law** + +Under *Tactical DDD laws*, add first bullet: + +```markdown +- Endpoints are transport adapters: routing, auth attributes and status shaping only. + Application logic lives in per-operation `*Handler` services under `Features/`. +``` + +- [ ] **Step 3: Full CI parity** + +```powershell +dotnet format --verify-no-changes --no-restore +dotnet build -c Release --no-restore +dotnet test --solution PriceNegotiationApp.slnx -c Release --no-build --report-trx +``` + +All five projects green. Fix formatting with `dotnet format` before committing. + +- [ ] **Step 4: Commit** + +```bash +git add tests/PriceNegotiationApp.ArchitectureTests/ArchitectureShould.cs README.md +git commit -m "test(architecture): enforce transport-only endpoints; document handler law" +``` + +--- + +## Self-Review Record + +- Spec §3 scope matrix → Tasks 1 (5 write), 2 (Get/List/ListMine), 3 (Catalog ×5), + 4 (Register/Login); `Me`, health, seeding, composition-root adapter explicitly excluded ✓. +- Spec §2 rules → endpoint slimming steps keep TypedResults/auth/cache/rate attrs; + handlers own queries/save/conflict translation; CallerContext boundary; AddScoped + explicit registrations ✓. +- Spec §4 enforcement → Task 5 Step 1 ✓; README law Task 5 Step 2 ✓. +- Spec §5 regression harness → per-task integration runs + Task 5 full suite w/ TRX ✓. +- Type consistency sweep: handler names/methods match between creation, registration, + endpoint resolution and arch-fact naming (`HaveFullNameEndingWith("Endpoints")` matches + the three real classes only). + diff --git a/docs/superpowers/plans/2026-08-25-negotiation-lifecycle-redesign.md b/docs/superpowers/plans/2026-08-25-negotiation-lifecycle-redesign.md new file mode 100644 index 0000000..07e6569 --- /dev/null +++ b/docs/superpowers/plans/2026-08-25-negotiation-lifecycle-redesign.md @@ -0,0 +1,1259 @@ +# Services & Business Logic Redesign Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fix the five audited defects in the Negotiations lifecycle (ambiguous decline semantics, destructive withdrawal, live-evaluated policy, uniqueness races surfacing as HTTP 500, read-path inconsistencies) per `docs/superpowers/specs/2026-08-25-services-application-business-logic-audit-design.md`. + +**Architecture:** Keep the modular-monolith vertical-slice design unchanged. Rework the `Negotiation` aggregate into an explicit state machine (`Open | Accepted | Rejected | Withdrawn`) that snapshots its policy limits at creation, translate PostgreSQL unique violations into HTTP 409 via one SharedKernel guard, make owner DELETE a soft close while Admin DELETE stays a hard delete, and clean up read paths. + +**Tech Stack:** .NET 10 / C# latest, ASP.NET Core minimal APIs, EF Core 10 + Npgsql (PostgreSQL 17), Vogen value objects, xUnit v3 + Shouldly + Bogus, Testcontainers. + +## Global Constraints + +- Source spec: `docs/superpowers/specs/2026-08-25-services-application-business-logic-audit-design.md`. +- `net10.0`, `Nullable=enable`, `TreatWarningsAsErrors=true`, `CodeAnalysisTreatWarningsAsErrors=true`, `EnforceCodeStyleInBuild=true` (from `Directory.Build.props`) — any warning fails the build. +- Packages centrally managed in `Directory.Packages.props`; never put versions in a `.csproj`. +- Module implementation types are `internal`, visible only to `Api` and each module's own test project; integration tests exercise HTTP only. +- Error contract: RFC 7807 ProblemDetails plus machine-readable `code`; stable codes: `negotiation_closed`, `proposal_exceeds_limit`, `negotiation_already_open`, `no_proposals_remaining`, `email_already_registered`. +- API routes never change (staff decline stays `POST /api/v1/negotiations/{id}/decline`; owner withdraw stays `DELETE /api/v1/negotiations/{id}`). +- Stored status ints stay stable: legacy `Declined = 3` becomes `Rejected = 3` by rename only (no data remap); add `Withdrawn = 4`. `Open = 1` unchanged so the partial unique index filter (`status = 1`) is untouched. +- Every commit builds with zero warnings and keeps touched projects' tests green. +- All commands run from repo root; shell is pwsh. +- Integration tests require a running Docker daemon (Testcontainers boots postgres:17-alpine). If Docker is unavailable, state that plainly instead of pretending they ran. + +--- + +### Task 1: Unique-violation translation guard (SharedKernel) + +**Files:** +- Modify: `Directory.Packages.props` +- Modify: `src/PriceNegotiationApp.SharedKernel/PriceNegotiationApp.SharedKernel.csproj` +- Create: `src/PriceNegotiationApp.SharedKernel/DbWriteGuard.cs` +- Test: `tests/PriceNegotiationApp.Modules.Negotiations.Tests/DbWriteGuardShould.cs` + +**Interfaces:** +- Consumes: nothing new (`DbContext`, `DbUpdateException`, `Npgsql.PostgresException`). +- Produces (consumed by Tasks 2 and 3): + - `bool DbWriteGuard.IsUniqueViolation(Exception exception, out string constraintName)` — walks inner exceptions; true iff a `PostgresException` with `SqlState == "23505"` exists; outputs its `ConstraintName ?? ""`. + - `Task DbWriteGuard.SaveOrConflictAsync(this DbContext db, Func conflictFactory, CancellationToken ct)` — saves; on unique violation throws `conflictFactory(constraintName)`. + +- [ ] **Step 1: Pin the bare Npgsql package version** + +Discover the exact transitive `Npgsql` version already flowing through the pinned EF provider: + +```bash +dotnet list src/PriceNegotiationApp.Modules.Negotiations/PriceNegotiationApp.Modules.Negotiations.csproj package --include-transitive | Select-String "^ > Npgsql\." +``` + +Use the printed bare `Npgsql` version verbatim (do not guess). In `Directory.Packages.props`, inside the direct-references ``, keep alphabetical order — insert directly after the existing `Npgsql.EntityFrameworkCore.PostgreSQL` line: + +```xml + +``` + +In `src/PriceNegotiationApp.SharedKernel/PriceNegotiationApp.SharedKernel.csproj`, inside the existing packages ``: + +```xml + +``` + +- [ ] **Step 2: Implement DbWriteGuard** + +Create `src/PriceNegotiationApp.SharedKernel/DbWriteGuard.cs`: + +```csharp +using Microsoft.EntityFrameworkCore; +using Npgsql; + +namespace PriceNegotiationApp.SharedKernel; + +/// +/// Translates PostgreSQL uniqueness violations raised during SaveChanges into + /// caller-supplied semantic exceptions, so check-then-insert races surface as +/// conflicts instead of HTTP 500. +/// +public static class DbWriteGuard +{ + public static bool IsUniqueViolation(Exception exception, out string constraintName) + { + constraintName = string.Empty; + for (var current = (Exception?)exception; current is not null; current = current.InnerException) + { + if (current is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation } postgres) + { + constraintName = postgres.ConstraintName ?? string.Empty; + return true; + } + } + + return false; + } + + public static async Task SaveOrConflictAsync( + this DbContext db, Func conflictFactory, CancellationToken ct) + { + try + { + await db.SaveChangesAsync(ct); + } + catch (DbUpdateException ex) when (IsUniqueViolation(ex, out var constraint)) + { + throw conflictFactory(constraint); + } + } +} +``` + +- [ ] **Step 3: Add unit tests** + +Create `tests/PriceNegotiationApp.Modules.Negotiations.Tests/DbWriteGuardShould.cs` (this test project already references the Negotiations module, which transitively brings Npgsql): + +```csharp +using Microsoft.EntityFrameworkCore; +using Npgsql; +using PriceNegotiationApp.SharedKernel; +using Shouldly; +using Xunit; + +namespace PriceNegotiationApp.Modules.Negotiations.Tests; + +public class DbWriteGuardShould +{ + private const string ConstraintName = "uq_negotiations_open_product_customer"; + + [Fact] + public void Detect_unique_violation_wrapped_in_DbUpdateException() + { + var inner = new PostgresException("duplicate key value", "ERROR", "ERROR", + PostgresErrorCodes.UniqueViolation, constraintName: ConstraintName); + + var found = DbWriteGuard.IsUniqueViolation(new DbUpdateException("save failed", inner), + out var constraint); + + found.ShouldBeTrue(); + constraint.ShouldBe(ConstraintName); + } + + [Fact] + public void Ignore_other_postgres_error_codes() + { + var inner = new PostgresException("foreign key", "ERROR", "ERROR", + PostgresErrorCodes.ForeignKeyViolation, constraintName: ConstraintName); + + DbWriteGuard.IsUniqueViolation(new DbUpdateException("save failed", inner), out _) + .ShouldBeFalse(); + } + + [Fact] + public void Ignore_unrelated_exception_types() + { + DbWriteGuard.IsUniqueViolation(new InvalidOperationException("nope"), out _) + .ShouldBeFalse(); + } + + [Fact] + public void SaveOrConflict_throws_factory_exception_with_constraint_name() + { + var inner = new PostgresException("duplicate key value", "ERROR", "ERROR", + PostgresErrorCodes.UniqueViolation, constraintName: ConstraintName); + var db = new ThrowingDbContext(); + + var thrown = Should.Throw(() => + db.SaveOrConflictAsync( + constraint => new ConflictException($"hit:{constraint}", "conflict"), + TestContext.Current.CancellationToken).GetAwaiter().GetResult()); + + thrown.Code.ShouldBe($"hit:{ConstraintName}"); + } + + [Fact] + public void SaveOrConflict_rerethrows_non_unique_failures() + { + var db = new ThrowingDbContext(withUniqueViolation: false); + + var thrown = Should.Throw(() => + db.SaveOrConflictAsync( + constraint => new ConflictException(constraint, "conflict"), + TestContext.Current.CancellationToken).GetAwaiter().GetResult()); + + thrown.InnerException.ShouldBeOfType(); + } + + private sealed class ThrowingDbContext(bool withUniqueViolation = true) : DbContext + { + public override Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + Exception inner = withUniqueViolation + ? new PostgresException("duplicate key value", "ERROR", "ERROR", + PostgresErrorCodes.UniqueViolation, constraintName: ConstraintName) + : new InvalidOperationException("boom"); + throw new DbUpdateException("save failed", inner); + } + } +} +``` + +Note: if the `PostgresException` constructor overload with the `constraintName:` named argument does not compile against the pinned Npgsql version, fall back to the four-argument constructor and assign the public `ConstraintName` property right after construction — semantics identical. + +- [ ] **Step 4: Run validation** + +```bash +dotnet build && dotnet test tests/PriceNegotiationApp.Modules.Negotiations.Tests +``` + +All six tests green, zero warnings. + +- [ ] **Step 5: Commit** + +```bash +git add Directory.Packages.props src/PriceNegotiationApp.SharedKernel/PriceNegotiationApp.SharedKernel.csproj src/PriceNegotiationApp.SharedKernel/DbWriteGuard.cs tests/PriceNegotiationApp.Modules.Negotiations.Tests/DbWriteGuardShould.cs +git commit -m "feat(shared): translate postgres unique violations into semantic conflicts" +``` + +--- + +### Task 2: Negotiation state machine, policy snapshot, race-safe writes + +This task is one compile unit: the aggregate's public surface changes, so every Negotiations feature file and both test suites move together. Do not commit halfway through. + +**Files:** +- Modify: `src/PriceNegotiationApp.Modules.Negotiations/Domain/NegotiationStatus.cs` +- Modify: `src/PriceNegotiationApp.Modules.Negotiations/Domain/Negotiation.cs` (full rewrite) +- Modify: `src/PriceNegotiationApp.Modules.Negotiations/Persistence/Configurations/NegotiationConfiguration.cs` +- Create: `src/PriceNegotiationApp.Modules.Negotiations/Persistence/Migrations/_SnapshotPolicyLimitsAndWithdrawn.cs` (+ Designer + snapshot, generated) +- Modify: `src/PriceNegotiationApp.Modules.Negotiations/Features/Negotiations/NegotiationModels.cs` +- Modify: `src/PriceNegotiationApp.Modules.Negotiations/Features/Negotiations/NegotiationAccess.cs` +- Modify: `src/PriceNegotiationApp.Modules.Negotiations/Features/Negotiations/Create.cs` +- Modify: `src/PriceNegotiationApp.Modules.Negotiations/Features/Negotiations/CounterPropose.cs` +- Modify: `src/PriceNegotiationApp.Modules.Negotiations/Features/Negotiations/Accept.cs` +- Delete + recreate: `src/PriceNegotiationApp.Modules.Negotiations/Features/Negotiations/Decline.cs` → `RejectCurrentOffer.cs` +- Modify: `src/PriceNegotiationApp.Modules.Negotiations/Features/Negotiations/Withdraw.cs` +- Modify: `src/PriceNegotiationApp.Modules.Negotiations/NegotiationEndpoints.cs` +- Test: `tests/PriceNegotiationApp.Modules.Negotiations.Tests/NegotiationLifecycleShould.cs` (rewrite) +- Test: `tests/PriceNegotiationApp.IntegrationTests/NegotiationsShould.cs` (one expectation edit) + +**Interfaces:** +- Consumes from Task 1: `DbWriteGuard.SaveOrConflictAsync`, `DbWriteGuard.IsUniqueViolation`. +- Produces: + - `enum NegotiationStatus { Open = 1, Accepted = 2, Rejected = 3, Withdrawn = 4 }` + - `static Negotiation Negotiation.Start(CustomerId customerId, Guid productId, decimal basePriceSnapshot, decimal initialOffer, DateTimeOffset now, INegotiationPolicy policy)` + - `NegotiationOutcome Negotiation.CounterPropose(decimal offer, DateTimeOffset now)` — no policy parameter + - `void Negotiation.Accept(DateTimeOffset now)` + - `void Negotiation.RejectCurrentOffer(DateTimeOffset now)` — stays Open, stamps LastStaffActionAtUtc + - `void Negotiation.Withdraw(DateTimeOffset now)` — terminal Withdrawn, sets DecidedAtUtc + - `int Negotiation.RemainingProposals()` — no policy parameter + - New aggregate properties: `int MaxProposals`, `decimal OfferMultiplierLimit`, `DateTimeOffset? LastStaffActionAtUtc` + - `static NegotiationResponse NegotiationResponses.ToResponse(Negotiation n)` — single parameter + - `record StaffActionResponse(string Outcome, NegotiationResponse Negotiation)` in NegotiationModels.cs + - `static Task NegotiationAccess.RequireReadOnlyAsync(NegotiationsDbContext db, Guid id, CancellationToken ct)` — AsNoTracking load + +- [ ] **Step 1: Rework the status enum** + +Replace the contents of `Domain/NegotiationStatus.cs`: + +```csharp +namespace PriceNegotiationApp.Modules.Negotiations.Domain; + +internal enum NegotiationStatus +{ + Open = 1, + Accepted = 2, + + /// Terminal. Reached only via auto-rejection of an over-limit counter-proposal. + Rejected = 3, + + /// Terminal. Owner withdrew; row and history are preserved. + Withdrawn = 4, +} +``` + +The rename `Declined → Rejected` keeps ordinal 3, so existing rows need no data migration. + +- [ ] **Step 2: Rewrite the aggregate** + +Replace the contents of `Domain/Negotiation.cs`: + +```csharp +namespace PriceNegotiationApp.Modules.Negotiations.Domain; + +internal sealed class Negotiation +{ + /// Base price snapshot taken at creation; protects ongoing negotiations from later product price changes. + public decimal BasePrice { get; private set; } + + public decimal CurrentOffer { get; private set; } + + public NegotiationId Id { get; private set; } + + public Guid ProductId { get; private set; } + + public CustomerId CustomerId { get; private set; } + + public NegotiationStatus Status { get; private set; } + + /// Total proposals recorded, including the initial one. + public int ProposalsUsed { get; private set; } + + /// Proposal budget snapshotted from the active policy at creation time. + public int MaxProposals { get; private set; } + + /// Offer multiplier limit snapshotted from the active policy at creation time. + public decimal OfferMultiplierLimit { get; private set; } + + public DateTimeOffset CreatedAtUtc { get; private set; } + + public DateTimeOffset LastProposalAtUtc { get; private set; } + + /// Most recent staff reject-current-offer action; does not change status. + public DateTimeOffset? LastStaffActionAtUtc { get; private set; } + + public DateTimeOffset? DecidedAtUtc { get; private set; } + + public uint Version { get; private set; } + + private Negotiation() + { + } + + private Negotiation( + NegotiationId id, Guid productId, CustomerId customerId, decimal basePrice, decimal initialOffer, + INegotiationPolicy policy, DateTimeOffset createdAtUtc) + { + Id = id; + ProductId = productId; + CustomerId = customerId; + BasePrice = basePrice; + CurrentOffer = initialOffer; + MaxProposals = policy.MaxProposalsPerNegotiation; + OfferMultiplierLimit = policy.ProposalMultiplierLimit; + Status = NegotiationStatus.Open; + ProposalsUsed = 1; + CreatedAtUtc = createdAtUtc; + LastProposalAtUtc = createdAtUtc; + } + + public static Negotiation Start( + CustomerId customerId, Guid productId, decimal basePriceSnapshot, decimal initialOffer, + DateTimeOffset now, INegotiationPolicy policy) + { + EnsureWithinLimit(basePriceSnapshot, initialOffer, policy.ProposalMultiplierLimit); + return new Negotiation(NegotiationId.From(Guid.CreateVersion7()), productId, customerId, + basePriceSnapshot, initialOffer, policy, now); + } + + public NegotiationOutcome CounterPropose(decimal offer, DateTimeOffset now) + { + EnsureOpen(); + if (ProposalsUsed >= MaxProposals) + { + return NegotiationOutcome.NoProposalsRemaining; + } + + try + { + EnsureWithinLimit(BasePrice, offer, OfferMultiplierLimit); + } + catch (ProposalExceedsLimitException) + { + Status = NegotiationStatus.Rejected; + DecidedAtUtc = now; + return NegotiationOutcome.AutoRejected; + } + + CurrentOffer = offer; + ProposalsUsed++; + LastProposalAtUtc = now; + return NegotiationOutcome.CounterProposed; + } + + public void Accept(DateTimeOffset now) => Decide(NegotiationStatus.Accepted, now); + + /// + /// Staff rejects the current offer. The negotiation deliberately stays open so the + /// customer may spend a remaining proposal; the proposal budget is untouched. + /// It terminates only via Accept, auto-rejection, or withdrawal. + /// + public void RejectCurrentOffer(DateTimeOffset now) + { + EnsureOpen(); + LastStaffActionAtUtc = now; + } + + /// Owner abandons the negotiation; state becomes terminal, history is preserved. + public void Withdraw(DateTimeOffset now) => Decide(NegotiationStatus.Withdrawn, now); + + public int RemainingProposals() => Math.Max(0, MaxProposals - ProposalsUsed); + + private void Decide(NegotiationStatus terminalStatus, DateTimeOffset now) + { + EnsureOpen(); + Status = terminalStatus; + DecidedAtUtc = now; + } + + private void EnsureOpen() + { + if (Status != NegotiationStatus.Open) + { + throw new ClosedNegotiationException(); + } + } + + private static void EnsureWithinLimit(decimal basePrice, decimal offer, decimal multiplierLimit) + { + var limit = decimal.Round(basePrice * multiplierLimit, 2); + Price.From(offer); + if (offer > limit) + { + throw new ProposalExceedsLimitException(limit); + } + } +} +``` + +`INegotiationPolicy` is now consumed exactly once — at `Start`. Nothing else in the module may take it as a parameter after this task. + +- [ ] **Step 3: Update persistence configuration and name the unique index** + +In `Persistence/Configurations/NegotiationConfiguration.cs`, replace the `HasIndex` block (currently lines 22–24) and add the two new column mappings. The full method body becomes: + +```csharp +public void Configure(EntityTypeBuilder builder) +{ + builder.ToTable("negotiations"); + builder.HasKey(n => n.Id); + builder.Property(n => n.Id).HasConversion(id => id.Value, value => NegotiationId.From(value)) + .ValueGeneratedNever(); + // Plain Guid key: product_id has NO FK by design (separate schemas/modules). + // Existence is validated at creation; negotiations survive deletion on snapshots. + builder.Property(n => n.CustomerId).HasConversion(id => id.Value, value => CustomerId.From(value)); + builder.Property(n => n.BasePrice).HasColumnType("numeric(18,2)"); + builder.Property(n => n.CurrentOffer).HasColumnType("numeric(18,2)"); + builder.Property(n => n.OfferMultiplierLimit).HasColumnType("numeric(5,2)"); + builder.Property(n => n.Status).HasConversion(); + builder.HasOne().WithMany().HasForeignKey(n => n.CustomerId).OnDelete(DeleteBehavior.Cascade); + builder.HasIndex(n => new { n.ProductId, n.CustomerId }) + .HasDatabaseName("uq_negotiations_open_product_customer") + .IsUnique() + .HasFilter($"status = {(int)NegotiationStatus.Open}"); + builder.Property(n => n.Version).IsRowVersion(); +} +``` + +(`max_proposals` and `last_staff_action_at_utc` need no explicit configuration — the snake_case naming convention handles them.) + +- [ ] **Step 4: Generate and hand-finish the migration** + +```bash +dotnet tool install --global dotnet-ef --version 10.* ; dotnet ef migrations add SnapshotPolicyLimitsAndWithdrawn --context NegotiationsDbContext -p src/PriceNegotiationApp.Modules.Negotiations -o Persistence/Migrations +``` + +(If the tool is already installed, skip the install part.) The generated `Up()` will add three columns without defaults — that fails on existing rows. Edit the generated migration's `Up()` so the two NOT NULL columns carry backfill defaults: + +```csharp +protected override void Up(MigrationBuilder migrationBuilder) +{ + migrationBuilder.AddColumn( + name: "max_proposals", + table: "negotiations", + type: "integer", + nullable: false, + defaultValue: 3); + + migrationBuilder.AddColumn( + name: "offer_multiplier_limit", + table: "negotiations", + type: "numeric(5,2)", + nullable: false, + defaultValue: 2.0m); + + migrationBuilder.AddColumn( + name: "last_staff_action_at_utc", + table: "negotiations", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.DropIndex( + name: "", // e.g. ix_negotiations_product_id_customer_id — copy from the generated file + table: "negotiations"); + + migrationBuilder.CreateIndex( + name: "uq_negotiations_open_product_customer", + table: "negotiations", + columns: new[] { "product_id", "customer_id" }, + unique: true, + filter: "status = 1"); +} +``` + +Keep whatever index names the generator actually produced — only inject the `defaultValue:` arguments; do not hand-write other operations. The `Down()` stays as generated. + +- [ ] **Step 5: Update models and access helpers** + +In `Features/Negotiations/NegotiationModels.cs`: delete the old `ToResponse`, add the staff-action record, final content of the mapper region: + +```csharp +internal static class NegotiationResponses +{ + internal static NegotiationResponse ToResponse(Negotiation n) => + new(n.Id.Value, n.ProductId, n.BasePrice, n.CurrentOffer, n.Status.ToString(), + n.ProposalsUsed, n.RemainingProposals(), n.CreatedAtUtc, n.LastProposalAtUtc, n.DecidedAtUtc); +} + +internal sealed record StaffActionResponse(string Outcome, NegotiationResponse Negotiation); +``` + +Everything else in the file stays as-is. + +In `Features/Negotiations/NegotiationAccess.cs`, add this method next to `RequireAsync`: + +```csharp +public static async Task RequireReadOnlyAsync(NegotiationsDbContext db, Guid id, CancellationToken ct) => + await db.Negotiations.AsNoTracking().FirstOrDefaultAsync(n => n.Id == NegotiationId.From(id), ct) + ?? throw new NotFoundException(nameof(Negotiation), id); +``` + +Replace `GetOrCreateCustomerIdAsync` with a race-safe version (unique violation on `customers.identity_user_id` → refetch): + +```csharp +public static async Task GetOrCreateCustomerIdAsync( + NegotiationsDbContext db, Guid identityUserId, CancellationToken ct) +{ + var existing = await CustomerByIdentityAsync(db, identityUserId, ct); + if (existing is not null) + { + return existing.Id; + } + + var customer = Customer.Create(identityUserId); + db.Customers.Add(customer); + try + { + await db.SaveChangesAsync(ct); + } + catch (DbUpdateException ex) when (DbWriteGuard.IsUniqueViolation(ex, out _)) + { + db.Entry(customer).State = EntityState.Detached; + return (await CustomerByIdentityAsync(db, identityUserId, ct))!.Id; + } + + return customer.Id; +} +``` + +Add these usings to the file top: `Microsoft.EntityFrameworkCore` (already present), plus `PriceNegotiationApp.SharedKernel` (already present). + +- [ ] **Step 6: Rewrite the feature endpoints** + +`Features/Negotiations/Create.cs` — full file: + +```csharp +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using PriceNegotiationApp.Modules.Negotiations.Domain; +using PriceNegotiationApp.Modules.Negotiations.Persistence; +using PriceNegotiationApp.Modules.Negotiations.Ports; +using PriceNegotiationApp.SharedKernel; +using System.Security.Claims; + +namespace PriceNegotiationApp.Modules.Negotiations.Features.Negotiations; + +internal static class Create +{ + internal static void MapCreate(this RouteGroupBuilder group) + { + group.MapPost("/", async (CreateNegotiationRequest request, ClaimsPrincipal principal, + NegotiationsDbContext db, IProductPriceProvider products, INegotiationPolicy policy, + TimeProvider clock, CancellationToken ct) => + { + var caller = principal.ToCallerContext(); + var snapshot = await products.GetAsync(request.ProductId, ct) + ?? throw new NotFoundException("Product", request.ProductId); + + if (await NegotiationAccess.FindOpenAsync(db, snapshot.ProductId, caller.UserId, ct) is not null) + { + throw new ConflictException(NegotiationErrorCodes.NegotiationAlreadyOpen, + "An open negotiation already exists for this product."); + } + + var customerId = await NegotiationAccess.GetOrCreateCustomerIdAsync(db, caller.UserId, ct); + var negotiation = Negotiation.Start(customerId, snapshot.ProductId, snapshot.Price, + request.ProposedPrice, clock.GetUtcNow(), policy); + await db.Negotiations.AddAsync(negotiation, ct); + // The partial unique index is the real guard; a race that slipped past the + // pre-check above surfaces here as a 409 instead of a 500. + await db.SaveOrConflictAsync( + _ => new ConflictException(NegotiationErrorCodes.NegotiationAlreadyOpen, + "An open negotiation already exists for this product."), ct); + return TypedResults.Created("/api/v1/negotiations/mine", + NegotiationResponses.ToResponse(negotiation)); + }) + .RequireRoles(UserRoles.Customer); + } +} +``` + +`Features/Negotiations/CounterPropose.cs` — full file: + +```csharp +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using PriceNegotiationApp.Modules.Negotiations.Domain; +using PriceNegotiationApp.Modules.Negotiations.Persistence; +using PriceNegotiationApp.SharedKernel; +using System.Security.Claims; + +namespace PriceNegotiationApp.Modules.Negotiations.Features.Negotiations; + +internal static class CounterPropose +{ + internal static void MapCounterPropose(this RouteGroupBuilder group) + { + group.MapPatch("/{id:guid}/proposals", async (Guid id, CounterProposalRequest request, + ClaimsPrincipal principal, NegotiationsDbContext db, + TimeProvider clock, CancellationToken ct) => + { + var caller = principal.ToCallerContext(); + var negotiation = await NegotiationAccess.RequireOwnedAsync(db, caller, id, ct); + + var outcome = negotiation.CounterPropose(request.ProposedPrice, clock.GetUtcNow()); + if (outcome == NegotiationOutcome.NoProposalsRemaining) + { + throw new ConflictException(NegotiationErrorCodes.NoProposalsRemaining, + "No proposals remain for this negotiation."); + } + + await db.SaveChangesAsync(ct); + return TypedResults.Ok(new CounterProposalOutcome(outcome.ToString(), + NegotiationResponses.ToResponse(negotiation))); + }) + .RequireAuthorization(); + } +} +``` + +`Features/Negotiations/Accept.cs` — full file: + +```csharp +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using PriceNegotiationApp.Modules.Negotiations.Persistence; +using PriceNegotiationApp.SharedKernel; + +namespace PriceNegotiationApp.Modules.Negotiations.Features.Negotiations; + +internal static class Accept +{ + internal static void MapAccept(this RouteGroupBuilder group) + { + group.MapPost("/{id:guid}/accept", async (Guid id, NegotiationsDbContext db, + TimeProvider clock, CancellationToken ct) => + { + var negotiation = await NegotiationAccess.RequireAsync(db, id, ct); + negotiation.Accept(clock.GetUtcNow()); + await db.SaveChangesAsync(ct); + return TypedResults.Ok(new StaffActionResponse("accepted", + NegotiationResponses.ToResponse(negotiation))); + }) + .RequireRoles(UserRoles.Admin, UserRoles.Staff); + } +} +``` + +Delete `Features/Negotiations/Decline.cs`; create `Features/Negotiations/RejectCurrentOffer.cs` (route unchanged): + +```csharp +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using PriceNegotiationApp.Modules.Negotiations.Persistence; +using PriceNegotiationApp.SharedKernel; + +namespace PriceNegotiationApp.Modules.Negotiations.Features.Negotiations; + +internal static class RejectCurrentOffer +{ + internal static void MapRejectCurrentOffer(this RouteGroupBuilder group) + { + group.MapPost("/{id:guid}/decline", async (Guid id, NegotiationsDbContext db, + TimeProvider clock, CancellationToken ct) => + { + var negotiation = await NegotiationAccess.RequireAsync(db, id, ct); + negotiation.RejectCurrentOffer(clock.GetUtcNow()); + await db.SaveChangesAsync(ct); + return TypedResults.Ok(new StaffActionResponse("current_offer_rejected", + NegotiationResponses.ToResponse(negotiation))); + }) + .RequireRoles(UserRoles.Admin, UserRoles.Staff); + } +} +``` + +`Features/Negotiations/Withdraw.cs` — full file (owner soft-close, admin hard delete): + +```csharp +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using PriceNegotiationApp.Modules.Negotiations.Persistence; +using PriceNegotiationApp.SharedKernel; +using System.Security.Claims; + +namespace PriceNegotiationApp.Modules.Negotiations.Features.Negotiations; + +internal static class Withdraw +{ + internal static void MapWithdraw(this RouteGroupBuilder group) + { + group.MapDelete("/{id:guid}", async (Guid id, ClaimsPrincipal principal, NegotiationsDbContext db, + TimeProvider clock, CancellationToken ct) => + { + var caller = principal.ToCallerContext(); + var negotiation = await NegotiationAccess.RequireAsync(db, id, ct); + + if (caller.IsInRole(UserRoles.Admin)) + { + db.Negotiations.Remove(negotiation); + } + else + { + if (!await NegotiationAccess.IsOwnerAsync(db, caller.UserId, negotiation, ct)) + { + throw new ForbiddenAccessException(); + } + + negotiation.Withdraw(clock.GetUtcNow()); + } + + await db.SaveChangesAsync(ct); + return TypedResults.NoContent(); + }) + .RequireAuthorization(); + } +} +``` + +`Features/Negotiations/Get.cs` — swap the load call to the read-only variant; line 21 becomes: + +```csharp +var negotiation = await NegotiationAccess.RequireReadOnlyAsync(db, id, ct); +``` + +`Features/Negotiations/ListMine.cs` — short-circuit unknown customers to an empty page; replace lines 21–26 with: + +```csharp +var customer = await NegotiationAccess.CustomerByIdentityAsync(db, caller.UserId, ct); +if (customer is null) +{ + return TypedResults.Ok(new PagedResult( + [], query.SafePage, query.SafePageSize, 0)); +} + +var q = db.Negotiations.AsNoTracking().Where(n => n.CustomerId == customer.Id); +``` + +`NegotiationEndpoints.cs` — replace `group.MapDecline();` with `group.MapRejectCurrentOffer();`. + +- [ ] **Step 7: Rewrite the domain unit tests** + +Replace the full contents of `tests/PriceNegotiationApp.Modules.Negotiations.Tests/NegotiationLifecycleShould.cs`: + +```csharp +using Bogus; +using PriceNegotiationApp.Modules.Negotiations.Domain; +using Shouldly; +using Xunit; + +namespace PriceNegotiationApp.Modules.Negotiations.Tests; + +public class NegotiationLifecycleShould +{ + private static readonly DefaultNegotiationPolicy Policy = new(); + private readonly Faker _faker = new(); + private readonly Guid _productId = Guid.CreateVersion7(); + + private const decimal BasePrice = 100m; + private readonly DateTimeOffset _now = new(2026, 1, 1, 12, 0, 0, TimeSpan.Zero); + + private Negotiation StartValid() => + Negotiation.Start(CustomerId.From(_faker.Random.Guid()), _productId, BasePrice, 80m, _now, Policy); + + [Fact] + public void Start_records_initial_proposal_snapshots_policy_and_consumes_one_of_three_budgets() + { + var negotiation = StartValid(); + + negotiation.Status.ShouldBe(NegotiationStatus.Open); + negotiation.ProposalsUsed.ShouldBe(1); + negotiation.MaxProposals.ShouldBe(3); + negotiation.OfferMultiplierLimit.ShouldBe(2.0m); + negotiation.BasePrice.ShouldBe(100m); + negotiation.RemainingProposals().ShouldBe(2); + } + + [Fact] + public void Start_rejects_offer_over_twice_base_price() => + Should.Throw( + () => Negotiation.Start(CustomerId.From(_faker.Random.Guid()), _productId, BasePrice, 201m, _now, Policy)); + + [Fact] + public void Start_accepts_offer_exactly_at_limit() + { + var negotiation = Negotiation.Start(CustomerId.From(_faker.Random.Guid()), _productId, BasePrice, 200m, _now, Policy); + + negotiation.CurrentOffer.ShouldBe(200m); + } + + [Fact] + public void CounterPropose_stores_new_offer_within_limit() + { + var negotiation = StartValid(); + + var outcome = negotiation.CounterPropose(90m, _now.AddMinutes(5)); + + outcome.ShouldBe(NegotiationOutcome.CounterProposed); + negotiation.CurrentOffer.ShouldBe(90m); + negotiation.ProposalsUsed.ShouldBe(2); + negotiation.Status.ShouldBe(NegotiationStatus.Open); + } + + [Fact] + public void CounterPropose_over_limit_auto_rejects_and_closes() + { + var negotiation = StartValid(); + + var outcome = negotiation.CounterPropose(500m, _now.AddMinutes(5)); + + outcome.ShouldBe(NegotiationOutcome.AutoRejected); + negotiation.Status.ShouldBe(NegotiationStatus.Rejected); + negotiation.DecidedAtUtc.ShouldNotBeNull(); + } + + [Fact] + public void CounterPropose_uses_limits_snapshotted_at_creation_not_current_config() + { + var generousPolicy = new StaticPolicy(maxProposals: 5, multiplierLimit: 3.0m); + var negotiation = Negotiation.Start( + CustomerId.From(_faker.Random.Guid()), _productId, BasePrice, 80m, _now, generousPolicy); + + // The DI container now hands out the default (stricter) policy; the aggregate + // must still obey the rules it was created under. + var outcome = negotiation.CounterPropose(250m, _now.AddMinutes(5)); + + outcome.ShouldBe(NegotiationOutcome.CounterProposed); // legal under 3.0x, illegal under 2.0x + negotiation.ProposalsUsed.ShouldBe(2); + negotiation.RemainingProposals().ShouldBe(3); + } + + [Fact] + public void CounterPropose_after_budget_exhaustion_returns_NoProposalsRemaining() + { + var negotiation = StartValid(); + negotiation.CounterPropose(90m, _now); + negotiation.CounterPropose(91m, _now); + + var outcome = negotiation.CounterPropose(92m, _now); + + outcome.ShouldBe(NegotiationOutcome.NoProposalsRemaining); + negotiation.CurrentOffer.ShouldNotBe(92m); + negotiation.Status.ShouldBe(NegotiationStatus.Open); + } + + [Fact] + public void Accept_closes_negotiation_as_Accepted() + { + var negotiation = StartValid(); + + negotiation.Accept(_now.AddDays(1)); + + negotiation.Status.ShouldBe(NegotiationStatus.Accepted); + negotiation.DecidedAtUtc.ShouldNotBeNull(); + } + + [Fact] + public void RejectCurrentOffer_keeps_open_and_stamps_staff_action_without_touching_budget() + { + var negotiation = StartValid(); + + negotiation.RejectCurrentOffer(_now.AddMinutes(10)); + + negotiation.Status.ShouldBe(NegotiationStatus.Open); + negotiation.LastStaffActionAtUtc.ShouldBe(_now.AddMinutes(10)); + negotiation.ProposalsUsed.ShouldBe(1); + negotiation.DecidedAtUtc.ShouldBeNull(); + } + + [Fact] + public void Withdraw_moves_open_negotiation_to_terminal_Withdrawn() + { + var negotiation = StartValid(); + negotiation.CounterPropose(90m, _now); + + negotiation.Withdraw(_now.AddHours(1)); + + negotiation.Status.ShouldBe(NegotiationStatus.Withdrawn); + negotiation.DecidedAtUtc.ShouldNotBeNull(); + negotiation.CurrentOffer.ShouldBe(90m); // history preserved + } + + [Fact] + public void Terminal_negotiations_refuse_further_operations() + { + var withdrawn = StartValid(); + withdrawn.Withdraw(_now); + var accepted = StartValid(); + accepted.Accept(_now); + var rejected = StartValid(); + rejected.CounterPropose(500m, _now); + + foreach (var terminal in new[] { withdrawn, accepted, rejected }) + { + Should.Throw(() => terminal.CounterPropose(50m, _now)); + Should.Throw(() => terminal.Accept(_now)); + Should.Throw(() => terminal.RejectCurrentOffer(_now)); + Should.Throw(() => terminal.Withdraw(_now)); + } + } + + private sealed class StaticPolicy(int maxProposals, decimal multiplierLimit) : INegotiationPolicy + { + public int MaxProposalsPerNegotiation { get; } = maxProposals; + + public decimal ProposalMultiplierLimit { get; } = multiplierLimit; + } +} +``` + +Note: `INegotiationPolicy` is `internal` in the module assembly and the test project has `InternalsVisibleTo`, so implementing it here is allowed. + +- [ ] **Step 8: Update the integration expectation for auto-rejection** + +In `tests/PriceNegotiationApp.IntegrationTests/NegotiationsShould.cs`, inside `Counter_over_limit_auto_rejects_and_closes` (line ~118), change: + +```csharp +outcome!.Outcome.ShouldBe("AutoRejected"); +outcome.Negotiation.Status.ShouldBe("Rejected"); +``` + +(was `"Declined"`). No other existing integration assertions change: staff decline/accept bodies are only checked via `EnsureSuccessStatusCode`, and owner DELETE still returns 204. + +- [ ] **Step 9: Run validation** + +```bash +dotnet build && dotnet test tests/PriceNegotiationApp.Modules.Negotiations.Tests +``` + +Then, with Docker running: + +```bash +dotnet test tests/PriceNegotiationApp.IntegrationTests +``` + +All green. If Docker is unavailable, say so explicitly in the task report. + +- [ ] **Step 10: Commit** + +```bash +git add src/PriceNegotiationApp.Modules.Negotiations tests/PriceNegotiationApp.Modules.Negotiations.Tests/NegotiationLifecycleShould.cs tests/PriceNegotiationApp.IntegrationTests/NegotiationsShould.cs +git commit -m "feat(negotiations): explicit state machine with snapshotted policy and race-safe creates" +``` + +--- + +### Task 3: Identity hardening — sync JWT generation + duplicate-email race + +**Files:** +- Modify: `src/PriceNegotiationApp.Modules.Identity/Features/Auth/JwtManager.cs` +- Modify: `src/PriceNegotiationApp.Modules.Identity/Features/Auth/Login.cs:42` +- Modify: `src/PriceNegotiationApp.Modules.Identity/Features/Auth/Register.cs` +- Test: `tests/PriceNegotiationApp.Modules.Identity.Tests/JwtManagerShould.cs` + +**Interfaces:** +- Consumes from Task 1: `DbWriteGuard.IsUniqueViolation`. +- Produces: + - `(string Token, DateTimeOffset ExpiresAtUtc) JwtManager.Generate(Guid userId, string email, IReadOnlyCollection roles)` — synchronous; replaces `GenerateAsync`. + +- [ ] **Step 1: Make JwtManager honest about its synchrony** + +Replace the method body signature block in `JwtManager.cs` (keep all token-building logic identical): + +```csharp +public (string Token, DateTimeOffset ExpiresAtUtc) Generate(Guid userId, string email, IReadOnlyCollection roles) +{ + var settings = options.Value; + var now = clock.GetUtcNow(); + var expiresAtUtc = now.AddMinutes(settings.ExpiryMinutes); + + var claims = new List + { + new(JwtRegisteredClaimNames.Sub, userId.ToString()), + new(JwtRegisteredClaimNames.Email, email), + new(JwtRegisteredClaimNames.Jti, Guid.CreateVersion7().ToString()), + }; + claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role))); + + var credentials = new SigningCredentials( + new SymmetricSecurityKey(Encoding.UTF8.GetBytes(settings.SecretKey)), + SecurityAlgorithms.HmacSha256); + + var token = new JwtSecurityToken( + issuer: settings.Issuer, + audience: settings.Audience, + claims: claims, + notBefore: now.UtcDateTime, + expires: expiresAtUtc.UtcDateTime, + signingCredentials: credentials); + + return (new JwtSecurityTokenHandler().WriteToken(token), expiresAtUtc); +} +``` + +In `Login.cs` line 42, replace: + +```csharp +var (token, expiresAtUtc) = jwt.Generate(user.Id, request.Email, roles); +``` + +- [ ] **Step 2: Map duplicate-email races to 409 in Register** + +In `Register.cs`, wrap the create call (replace lines 18–30): + +```csharp +var user = new ApplicationUser { UserName = request.Email, Email = request.Email }; +IdentityResult result; +try +{ + result = await userManager.CreateAsync(user, request.Password); +} +catch (DbUpdateException ex) when (DbWriteGuard.IsUniqueViolation(ex, out _)) +{ + // Two concurrent registrations for the same email: the pre-check inside Identity + // lost the race, the unique index caught it — surface it as the same conflict. + throw new ConflictException(IdentityErrorCodes.EmailAlreadyRegistered, + "Email already registered."); +} + +if (!result.Succeeded) +{ + if (result.Errors.Any(e => e.Code is "DuplicateEmail" or "DuplicateUserName")) + { + throw new ConflictException(IdentityErrorCodes.EmailAlreadyRegistered, + "Email already registered."); + } + + throw new InvalidRequestException(IdentityErrorCodes.RegistrationInvalid, + string.Join("; ", result.Errors.Select(e => e.Description))); +} +``` + +Add to the file's usings: `Microsoft.EntityFrameworkCore` (for `DbUpdateException`). `PriceNegotiationApp.SharedKernel` is already imported. + +- [ ] **Step 3: Update JwtManager unit tests** + +In `tests/PriceNegotiationApp.Modules.Identity.Tests/JwtManagerShould.cs`: drop `async Task` from the test (make it `public void`), remove the `await`, and call: + +```csharp +var (token, expiresAtUtc) = sut.Generate(Guid.NewGuid(), "user@test.dev", ["Customer"]); +``` + +Assertions unchanged. + +- [ ] **Step 4: Run validation** + +```bash +dotnet build && dotnet test tests/PriceNegotiationApp.Modules.Identity.Tests +``` + +Green, zero warnings. + +- [ ] **Step 5: Commit** + +```bash +git add src/PriceNegotiationApp.Modules.Identity tests/PriceNegotiationApp.Modules.Identity.Tests/JwtManagerShould.cs +git commit -m "refactor(identity): sync JWT generation and race-safe duplicate-email conflicts" +``` + +--- + +### Task 4: Integration coverage for the new semantics + +**Files:** +- Modify: `tests/PriceNegotiationApp.IntegrationTests/NegotiationsShould.cs` + +**Interfaces:** +- Consumes from Task 2 (HTTP surface): decline returns `{"outcome":"current_offer_rejected","negotiation":{...}}`; accept returns `{"outcome":"accepted",...}`; owner DELETE → 204 + status `Withdrawn` afterwards; admin DELETE → 204 then 404; concurrent same-customer creates yield exactly one 201, the rest 409. + +- [ ] **Step 1: Pin staff-decline outcome in the back-and-forth test** + +In `Full_back_and_forth_then_accept`, replace both `StaffDecideAsync(staff, negotiationId, decline: true)` round-1 calls with explicit assertions. New body of round 1: + +```csharp +// Round 1: staff rejects the current offer (stays open), customer counters +var decline1 = await staff.Client.PostAsJsonAsync($"/api/v1/negotiations/{negotiationId}/decline", new { }, TestContext.Current.CancellationToken); +decline1.StatusCode.ShouldBe(HttpStatusCode.OK); +var decision1 = await decline1.Content.ReadFromJsonAsync(Json, TestContext.Current.CancellationToken); +decision1!.Outcome.ShouldBe("current_offer_rejected"); +decision1.Negotiation.Status.ShouldBe("Open"); +(await CounterProposeAsync(customer, negotiationId, 90m)).StatusCode.ShouldBe(HttpStatusCode.OK); +``` + +Round 2 keeps using `await StaffDecideAsync(staff, negotiationId, decline: true);`. For the final accept, assert the outcome too: + +```csharp +var accept = await staff.Client.PostAsJsonAsync($"/api/v1/negotiations/{negotiationId}/accept", new { }, TestContext.Current.CancellationToken); +accept.StatusCode.ShouldBe(HttpStatusCode.OK); +var accepted = await accept.Content.ReadFromJsonAsync(Json, TestContext.Current.CancellationToken); +accepted!.Outcome.ShouldBe("accepted"); +``` + +Add the record next to `CounterOutcome`: + +```csharp +private sealed record StaffAction(string Outcome, NegotiationView Negotiation); +``` + +- [ ] **Step 2: Add the withdraw-vs-delete lifecycle test** + +Append this test to `NegotiationsShould`: + +```csharp +[Fact] +public async Task Owner_withdraw_closes_but_preserves_history_admin_delete_destroys() +{ + var (customer, _, negotiationId) = await StartOpenNegotiationAsync(); + + var withdraw = await customer.Client.DeleteAsync($"/api/v1/negotiations/{negotiationId}", TestContext.Current.CancellationToken); + withdraw.StatusCode.ShouldBe(HttpStatusCode.NoContent); + + var view = await GetNegotiationAsync(customer, negotiationId); + view.Status.ShouldBe("Withdrawn"); + view.DecidedAtUtc.ShouldNotBeNull(); + view.BasePrice.ShouldBe(100m); // snapshot history intact + + // Withdrawn is terminal + var counter = await CounterProposeAsync(customer, negotiationId, 50m); + counter.StatusCode.ShouldBe(HttpStatusCode.Conflict); + + // Only an admin can hard-delete; afterwards it is gone + var admin = await fixture.LoginAsAdminAsync(); + (await admin.Client.DeleteAsync($"/api/v1/negotiations/{negotiationId}", TestContext.Current.CancellationToken)) + .StatusCode.ShouldBe(HttpStatusCode.NoContent); + (await admin.Client.GetAsync($"/api/v1/negotiations/{negotiationId}", TestContext.Current.CancellationToken)) + .StatusCode.ShouldBe(HttpStatusCode.NotFound); +} + +[Fact] +public async Task Customer_cannot_hard_delete_another_users_negotiation() +{ + var (_, stranger, _) = await StartOpenNegotiationAsync(); + var (_, _, otherId) = await StartOpenNegotiationAsync(); + + // stranger is a customer who owns their own negotiation but not otherId; + // DELETE must be forbidden, not silently withdraw someone else's deal + (await stranger.Client.DeleteAsync($"/api/v1/negotiations/{otherId}", TestContext.Current.CancellationToken)) + .StatusCode.ShouldBe(HttpStatusCode.Forbidden); +} +``` + +Also update the stale comment in `Access_matrix_view_and_counter` from "Only owner can withdraw; admin can delete anything" to "Owner withdraw soft-closes; admin hard-deletes" — assertions there stay valid (owner DELETE still 204). + +- [ ] **Step 3: Add the uniqueness-race regression test** + +The point of F4: whatever interleaving happens, the loser must get 409 — never 500. Exactly one request can win because the partial unique index admits exactly one open row per (product, customer). + +```csharp +[Fact] +public async Task Concurrent_creates_produce_single_winner_and_conflicts_never_500() +{ + var product = await CreateProductAsync(); + var customer = await fixture.CreateUserAsync(); + + var attempts = await Task.WhenAll(Enumerable.Range(0, 6).Select(_ => + customer.Client.PostAsJsonAsync("/api/v1/negotiations", + new { productId = product.Id, proposedPrice = 80m }, TestContext.Current.CancellationToken))); + + attempts.Count(r => r.StatusCode == HttpStatusCode.Created).ShouldBe(1); + attempts.Count(r => r.StatusCode == HttpStatusCode.Conflict).ShouldBe(5); +} +``` + +Requires `using System.Linq;` is implicit (ImplicitUsings) — no new usings needed beyond what the file already has. + +- [ ] **Step 4: Run validation** + +With Docker running: + +```bash +dotnet test tests/PriceNegotiationApp.IntegrationTests +``` + +All tests green including the three new ones. If Docker is unavailable, state that explicitly. + +- [ ] **Step 5: Commit** + +```bash +git add tests/PriceNegotiationApp.IntegrationTests/NegotiationsShould.cs +git commit -m "test(negotiations): cover withdraw lifecycle, staff outcome contract, and create races" +``` + +--- + +### Task 5: Documentation sync + full CI-parity validation + +**Files:** +- Modify: `README.md` + +**Interfaces:** none (docs only). + +- [ ] **Step 1: Update README negotiation rules** + +In `README.md`, replace rule list items under `## Negotiation rules` with: + +```markdown +1. A customer opens a negotiation on a product with an initial proposal — this consumes + proposal 1 of 3. +2. Staff **accept** (terminal `Accepted`) or **reject the current offer** (`POST .../decline`); + rejecting keeps the negotiation open so the customer can spend a remaining proposal and + does not consume budget. +3. A counter-proposal above the snapshotted offer-multiplier limit (default 2× base price, + frozen at creation time) immediately closes the negotiation as terminal `Rejected` + (auto-rejection). +4. When the snapshotted proposal budget is spent, further counter-proposals are refused (`409`). +5. The owner can withdraw an open negotiation at any time — this soft-closes it as terminal + `Withdrawn` and preserves history; only admins hard-delete rows. +6. Deleting a product does not delete or block its negotiations — they keep their + price snapshot (product existence is only validated when a negotiation is created). +``` + +Under `## API surface (v1)` no route changes are needed, but add one line after the table: + +> Status vocabulary: `Open | Accepted | Rejected | Withdrawn`. `Rejected` is terminal auto-rejection; staff decline responses carry `"outcome":"current_offer_rejected"` while the status stays `Open`. + +- [ ] **Step 2: Run full CI-parity validation** + +```bash +dotnet format --verify-no-changes --no-restore && dotnet build -c Release && dotnet test +``` + +This mirrors the GitHub Actions pipeline (format check → Release build → all unit + integration tests). Everything must pass; fix any formatting nits with `dotnet format` before committing. + +- [ ] **Step 3: Commit** + +```bash +git add README.md docs/superpowers/plans/2026-08-25-negotiation-lifecycle-redesign.md +git commit -m "docs: align negotiation lifecycle rules and status vocabulary with implementation" +``` + +--- + +## Self-Review Record + +- Spec coverage: F1→Task 2 Steps 1–2, 6–8 (status rename + RejectCurrentOffer + outcome contract); F2→Task 2 Step 6 (Withdraw.cs) + Task 4 Step 2; F3→Task 2 Steps 2–3 (snapshot columns) + migration defaults in Step 4; F4→Task 1 + Task 2 Steps 5–6 + Task 4 Step 3; F5→Task 2 Steps 5–6 (RequireReadOnlyAsync, ListMine short-circuit) + Task 3 Step 1 (JwtManager sync). D4's Identity adoption → Task 3 Step 2. Migration/backfill → Task 2 Step 4. Docs → Task 5. +- Correction vs spec: spec §7 assumed 0-based enum ints ("map legacy Declined(=2)"); actual enums are 1-based (`Declined = 3`), so the rename preserves stored values and **no data remap is required** — only additive columns plus index rename. +- Type consistency check performed: `RemainingProposals()` parameterless everywhere; `ToResponse(Negotiation)` single-param everywhere; `SaveOrConflictAsync(this DbContext, Func, CancellationToken)` matches call sites; `PostgresException` ctor uses named `constraintName:` argument consistent across Task 1 production/test code. + diff --git a/docs/superpowers/plans/2026-08-26-endpoint-metadata-hardening.md b/docs/superpowers/plans/2026-08-26-endpoint-metadata-hardening.md new file mode 100644 index 0000000..cf77355 --- /dev/null +++ b/docs/superpowers/plans/2026-08-26-endpoint-metadata-hardening.md @@ -0,0 +1,1067 @@ +# Endpoint Metadata & Security-Surface Hardening Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Flip API modules to secure-by-default route groups, wire the dead CORS configuration, document every endpoint's error contract in OpenAPI (`ProducesProblem`), give every route a stable name and summary, and keep infrastructure endpoints out of the published API description. + +**Architecture:** All 16 business endpoints live in one file per feature under `src/PriceNegotiationApp.Modules.*/Features/**`, registered through three module entry points. Authorization moves from per-endpoint opt-in to group-level `.RequireAuthorization()` with explicit `.AllowAnonymous()` opt-outs. Error documentation uses built-in `.ProducesProblem(statusCode)` chains — success schemas are already inferred from `TypedResults`. No new abstractions; `SharedKernel.EndpointConventionExtensions` stays untouched. + +**Tech Stack:** ASP.NET Core 10 minimal APIs, `Microsoft.AspNetCore.OpenApi` (runtime docs, asserted in tests via `/openapi/v1.json`), xUnit + Shouldly + Testcontainers (Postgres) for integration tests. + +## Global Constraints + +- Target framework: `net10.0` (set in `Directory.Build.props`; do not change). +- Central Package Management via `Directory.Packages.props` — **add zero NuGet packages**; everything used ships in the shared framework. +- Analyzers run as errors: code must compile with zero warnings. +- No comments in code unless mirroring an existing comment convention in the same file. +- Commit style: conventional commits matching repo history — `fix(api): ...`, `feat(negotiations): ...`, `test(integration): ...`. +- Integration tests need Docker running (Testcontainers starts `postgres:17-alpine`). +- Test frameworks in use: xUnit (`[Fact]`, `[Theory]`, `[Collection]`), Shouldly (`ShouldBe`, `ShouldNotBeNull`, ...), `TestContext.Current.CancellationToken` passed to every async call. +- Documentation rule for every endpoint (from approved design): document every **handler-raised** problem status; omit generic 401 (implied by bearer security scheme) everywhere except where login itself fails (401 IS the business outcome); add 429 only on rate-limited endpoints. +- Route names (`WithName`) are app-wide unique PascalCase verb-noun identifiers; they become OpenAPI `operationId`s. + +## File Structure + +| File | Change | +|---|---| +| `src/PriceNegotiationApp.Modules.Identity/IdentityEndpoints.cs` | Group-level `.RequireAuthorization()` | +| `src/PriceNegotiationApp.Modules.Catalog/CatalogEndpoints.cs` | Group-level `.RequireAuthorization()` | +| `src/PriceNegotiationApp.Modules.Negotiations/NegotiationEndpoints.cs` | Group-level `.RequireAuthorization()` | +| `src/PriceNegotiationApp.Modules.Identity/Features/Auth/Register.cs` | Remove redundant authz (T1); add name/summary/problems (T3) | +| `src/PriceNegotiationApp.Modules.Identity/Features/Auth/Login.cs` | Same as Register.cs | +| `src/PriceNegotiationApp.Modules.Identity/Features/Auth/Me.cs` | Drop per-endpoint authz (T1); add name/summary (T3) | +| `src/PriceNegotiationApp.Modules.Catalog/Features/Products/List.cs` | Name/summary (T4) | +| `src/PriceNegotiationApp.Modules.Catalog/Features/Products/Get.cs` | Summary added, name kept (T4) | +| `src/PriceNegotiationApp.Modules.Catalog/Features/Products/Create.cs` | Name/summary/problems (T4) | +| `src/PriceNegotiationApp.Modules.Catalog/Features/Products/Update.cs` | Name/summary/problems (T4) | +| `src/PriceNegotiationApp.Modules.Catalog/Features/Products/Delete.cs` | Name/summary/problems (T4) | +| `src/PriceNegotiationApp.Modules.Negotiations/Features/Negotiations/Create.cs` | Name/summary/problems (T5) | +| `src/PriceNegotiationApp.Modules.Negotiations/Features/Negotiations/ListMine.cs` | Name/summary (T5) | +| `src/PriceNegotiationApp.Modules.Negotiations/Features/Negotiations/List.cs` | Name/summary (T5) | +| `src/PriceNegotiationApp.Modules.Negotiations/Features/Negotiations/Get.cs` | Drop per-endpoint authz (T1); name/summary/problems (T5) | +| `src/PriceNegotiationApp.Modules.Negotiations/Features/Negotiations/CounterPropose.cs` | Drop per-endpoint authz (T1); name/summary/problems (T5) | +| `src/PriceNegotiationApp.Modules.Negotiations/Features/Negotiations/Accept.cs` | Name/summary/problems (T5) | +| `src/PriceNegotiationApp.Modules.Negotiations/Features/Negotiations/RejectCurrentOffer.cs` | Name/summary/problems (T5) | +| `src/PriceNegotiationApp.Modules.Negotiations/Features/Negotiations/Withdraw.cs` | Drop per-endpoint authz (T1); name/summary/problems (T5) | +| `src/PriceNegotiationApp.Api/Extensions/WebApplicationBuilderExtensions.cs` | Always register CORS policy; vary product cache by Origin (T2) | +| `src/PriceNegotiationApp.Api/Extensions/PipelineExtensions.cs` | `UseCors` (T2); `ExcludeFromDescription` + Testing-env OpenAPI (T6) | +| `tests/PriceNegotiationApp.IntegrationTests/Support/IntegrationTestFactory.cs` | Configure allowed CORS origin (T2) | +| `tests/PriceNegotiationApp.IntegrationTests/PublicSurfaceShould.cs` | Create (T1) | +| `tests/PriceNegotiationApp.IntegrationTests/CorsShould.cs` | Create (T2) | +| `tests/PriceNegotiationApp.IntegrationTests/OpenApiContractShould.cs` | Create (T7) | + +--- + +### Task 1: Secure-by-default authorization groups + public-surface lock test + +**Files:** +- Modify: `src/PriceNegotiationApp.Modules.Identity/IdentityEndpoints.cs` +- Modify: `src/PriceNegotiationApp.Modules.Catalog/CatalogEndpoints.cs` +- Modify: `src/PriceNegotiationApp.Modules.Negotiations/NegotiationEndpoints.cs` +- Modify: `src/PriceNegotiationApp.Modules.Identity/Features/Auth/Me.cs` +- Modify: `src/PriceNegotiationApp.Modules.Negotiations/Features/Negotiations/Get.cs` +- Modify: `src/PriceNegotiationApp.Modules.Negotiations/Features/Negotiations/CounterPropose.cs` +- Modify: `src/PriceNegotiationApp.Modules.Negotiations/Features/Negotiations/Withdraw.cs` +- Create: `tests/PriceNegotiationApp.IntegrationTests/PublicSurfaceShould.cs` + +**Interfaces:** +- Consumes: nothing new; existing `UserRoles`/`Policies` constants stay as-is. +- Produces: authorization semantics consumed by every later task — groups require auth; only register/login/product-list/product-get-one/jwks opt out via `.AllowAnonymous()`. Later tasks must NOT re-add `.RequireAuthorization()` per endpoint. + +- [ ] **Step 1: Add group-level authorization to the three module entry points** + +Replace the body of `MapAuthEndpoints` in `src/PriceNegotiationApp.Modules.Identity/IdentityEndpoints.cs`: + +```csharp +public static IEndpointRouteBuilder MapAuthEndpoints(this IEndpointRouteBuilder app) +{ + var group = app.MapGroup("/api/v1/auth") + .WithTags("Auth") + .RequireAuthorization(); + group.MapRegister(); + group.MapLogin(); + group.MapMe(); + return app; +} +``` + +Apply the identical pattern in `src/PriceNegotiationApp.Modules.Catalog/CatalogEndpoints.cs`: + +```csharp +var group = app.MapGroup("/api/v1/products") + .WithTags("Products") + .RequireAuthorization(); +``` + +and in `src/PriceNegotiationApp.Modules.Negotiations/NegotiationEndpoints.cs`: + +```csharp +var group = app.MapGroup("/api/v1/negotiations") + .WithTags("Negotiations") + .RequireAuthorization(); +``` + +- [ ] **Step 2: Remove now-redundant per-endpoint `.RequireAuthorization()` calls** + +Four endpoints carry their own plain `.RequireAuthorization()` which the group now provides. Delete those lines (keep everything else on the endpoint intact): + +`src/PriceNegotiationApp.Modules.Identity/Features/Auth/Me.cs` — the lambda ends with `})`, followed by the deleted `.RequireAuthorization();` line: + +```csharp +group.MapGet("/me", (ClaimsPrincipal principal) => + { + var caller = principal.ToCallerContext(); + return TypedResults.Ok(new CurrentUserResponse(caller.UserId, caller.Email, caller.Roles.ToList())); + }); +``` + +`src/PriceNegotiationApp.Modules.Negotiations/Features/Negotiations/Get.cs`: + +```csharp +group.MapGet("/{id:guid}", async (Guid id, ClaimsPrincipal principal, + GetNegotiationHandler handler, CancellationToken ct) => + TypedResults.Ok(await handler.HandleAsync(id, principal.ToCallerContext(), ct))); +``` + +`src/PriceNegotiationApp.Modules.Negotiations/Features/Negotiations/CounterPropose.cs`: + +```csharp +group.MapPatch("/{id:guid}/proposals", async (Guid id, CounterProposalRequest request, + ClaimsPrincipal principal, CounterProposeHandler handler, CancellationToken ct) => + TypedResults.Ok(await handler.HandleAsync(id, request, principal.ToCallerContext(), ct))); +``` + +`src/PriceNegotiationApp.Modules.Negotiations/Features/Negotiations/Withdraw.cs`: + +```csharp +group.MapDelete("/{id:guid}", async (Guid id, ClaimsPrincipal principal, + WithdrawHandler handler, CancellationToken ct) => +{ + await handler.HandleAsync(id, principal.ToCallerContext(), ct); + return TypedResults.NoContent(); +}); +``` + +Do NOT touch `.AllowAnonymous()` (Register, Login, catalog List/Get) or any `.RequireRoles(...)` call — they compose correctly with the group requirement (all authorize data merges; user must satisfy every layer). + +- [ ] **Step 3: Add the public-surface lock test** + +Create `tests/PriceNegotiationApp.IntegrationTests/PublicSurfaceShould.cs`. This pins the exact set of unauthenticated-reachable routes: any future endpoint added without opting out of group auth makes this theory fail. + +```csharp +using PriceNegotiationApp.IntegrationTests.Support; +using Shouldly; +using System.Net; +using System.Text; +using Xunit; + +namespace PriceNegotiationApp.IntegrationTests; + +[Collection(ApiCollection.Name)] +public class PublicSurfaceShould(IntegrationTestFixture fixture) +{ + public static TheoryData ProtectedRoutes => new() + { + { HttpMethod.Get, "/api/v1/auth/me" }, + { HttpMethod.Post, "/api/v1/products" }, + { HttpMethod.Put, $"/api/v1/products/{Guid.NewGuid()}" }, + { HttpMethod.Delete, $"/api/v1/products/{Guid.NewGuid()}" }, + { HttpMethod.Post, "/api/v1/negotiations" }, + { HttpMethod.Get, "/api/v1/negotiations/mine" }, + { HttpMethod.Get, "/api/v1/negotiations" }, + { HttpMethod.Get, $"/api/v1/negotiations/{Guid.NewGuid()}" }, + { HttpMethod.Patch, $"/api/v1/negotiations/{Guid.NewGuid()}/proposals" }, + { HttpMethod.Post, $"/api/v1/negotiations/{Guid.NewGuid()}/accept" }, + { HttpMethod.Post, $"/api/v1/negotiations/{Guid.NewGuid()}/decline" }, + { HttpMethod.Delete, $"/api/v1/negotiations/{Guid.NewGuid()}" }, + }; + + [Theory] + [MemberData(nameof(ProtectedRoutes))] + public async Task Unauthenticated_requests_are_challenged(HttpMethod method, string path) + { + var request = new HttpRequestMessage(method, path) + { + Content = new StringContent(string.Empty, Encoding.UTF8, "application/json"), + }; + + var response = await fixture.Anonymous.SendAsync(request, TestContext.Current.CancellationToken); + + response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized, $"{method} {path} must stay behind authentication"); + } +} +``` + +Positive coverage of the five public routes (register 201, login 200, products list/get, jwks 200) already exists in `AuthFlowShould`, `ProductsShould`, and `JwksShould` — do not duplicate it. + +- [ ] **Step 4: Run relevant validation** + +```bash +dotnet build +dotnet test tests/PriceNegotiationApp.IntegrationTests --filter "FullyQualifiedName~PublicSurfaceShould" +``` + +Both must pass. If any protected route returns something other than 401, the group wiring is wrong — fix before committing. + +- [ ] **Step 5: Commit** + +```bash +git add src/PriceNegotiationApp.Modules.Identity src/PriceNegotiationApp.Modules.Catalog src/PriceNegotiationApp.Modules.Negotiations tests/PriceNegotiationApp.IntegrationTests/PublicSurfaceShould.cs +git commit -m "feat(api): secure-by-default route groups with locked public surface" +``` + +--- + +### Task 2: Wire the dead CORS configuration + +**Files:** +- Modify: `src/PriceNegotiationApp.Api/Extensions/WebApplicationBuilderExtensions.cs` +- Modify: `src/PriceNegotiationApp.Api/Extensions/PipelineExtensions.cs` +- Modify: `tests/PriceNegotiationApp.IntegrationTests/Support/IntegrationTestFactory.cs` +- Create: `tests/PriceNegotiationApp.IntegrationTests/CorsShould.cs` + +**Interfaces:** +- Consumes: `WebApplicationBuilderExtensions.CorsPolicy` constant (already `"api"`). +- Produces: working CORS enforcement for any configured origin list; `IntegrationTestFactory` clients speak to the app with `Cors:AllowedOrigins=https://app.test.local` set (later tasks rely on this factory state being harmless). + +- [ ] **Step 1: Always register the CORS policy (empty allow-list denies everyone)** + +In `WebApplicationBuilderExtensions.AddApiServices`, replace: + +```csharp +var origins = configuration.GetSection("Cors:AllowedOrigins").Get() ?? []; +CorsOriginsGuard.EnsureValid(origins); +if (origins.Length > 0) +{ + builder.Services.AddCors(options => options.AddPolicy(CorsPolicy, policy => + policy.WithOrigins(origins).AllowAnyHeader().AllowAnyMethod())); +} +``` + +with: + +```csharp +var origins = configuration.GetSection("Cors:AllowedOrigins").Get() ?? []; +CorsOriginsGuard.EnsureValid(origins); +builder.Services.AddCors(options => options.AddPolicy(CorsPolicy, policy => + policy.WithOrigins(origins).AllowAnyHeader().AllowAnyMethod())); +``` + +An empty origin list produces a policy that matches nobody, so unconfigured deployments behave exactly as today while the policy always exists for the middleware. + +- [ ] **Step 2: Vary the product output cache by Origin** + +A cached response replays stored headers; without origin variance, the first requester's `Access-Control-Allow-Origin` would leak into other origins' cache hits. In the same file, change the output-cache policy registration: + +```csharp +builder.Services.AddOutputCache(options => options.AddPolicy(Policies.ShortCachePolicy, + policy => policy.Expire(TimeSpan.FromSeconds(30)) + .SetVaryByQuery("search", "minPrice", "maxPrice", "sortBy", "sortDesc", "page", "pageSize") + .SetVaryByHeader("Origin"))); +``` + +- [ ] **Step 3: Apply the policy in the pipeline** + +In `PipelineExtensions.UsePipeline`, insert `UseCors` immediately after `UseHttpsRedirection()` and before the environment-gated OpenAPI block (CORS middleware must precede authentication/authorization): + +```csharp +app.UseHttpsRedirection(); + +app.UseCors(WebApplicationBuilderExtensions.CorsPolicy); + +if (app.Environment.IsDevelopment()) +{ + app.MapOpenApi(); + app.MapScalarApiReference(); +} +``` + +No new `using` needed — both classes live in `PriceNegotiationApp.Api.Extensions`. + +- [ ] **Step 4: Configure a known origin for tests** + +In `Support/IntegrationTestFactory.ConfigureWebHost`, add alongside the other `UseSetting` lines: + +```csharp +builder.UseSetting("Cors:AllowedOrigins", "https://app.test.local"); +``` + +- [ ] **Step 5: Add the CORS test** + +Create `tests/PriceNegotiationApp.IntegrationTests/CorsShould.cs`: + +```csharp +using PriceNegotiationApp.IntegrationTests.Support; +using Shouldly; +using System.Net; +using Xunit; + +namespace PriceNegotiationApp.IntegrationTests; + +[Collection(ApiCollection.Name)] +public class CorsShould(IntegrationTestFixture fixture) +{ + private const string AllowedOrigin = "https://app.test.local"; + + [Fact] + public async Task Configured_origin_receives_allow_origin_header() + { + var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/products"); + request.Headers.TryAddWithoutValidation("Origin", AllowedOrigin); + + var response = await fixture.Anonymous.SendAsync(request, TestContext.Current.CancellationToken); + + response.StatusCode.ShouldBe(HttpStatusCode.OK); + response.Headers.HeaderAccessControlAllowOrigin.ShouldBe([AllowedOrigin]); + } + + [Fact] + public async Task Unlisted_origin_receives_no_allow_origin_header() + { + var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/products"); + request.Headers.TryAddWithoutValidation("Origin", "https://evil.example"); + + var response = await fixture.Anonymous.SendAsync(request, TestContext.Current.CancellationToken); + + response.Headers.Contains("Access-Control-Allow-Origin").ShouldBeFalse(); + } +} +``` + +The second test is deterministic because of Step 2: the unlisted origin always misses the output cache and gets a freshly built response with no CORS headers. + +- [ ] **Step 6: Run relevant validation** + +```bash +dotnet build +dotnet test tests/PriceNegotiationApp.IntegrationTests --filter "FullyQualifiedName~CorsShould" +``` + +- [ ] **Step 7: Commit** + +```bash +git add src/PriceNegotiationApp.Api tests/PriceNegotiationApp.IntegrationTests/Support/IntegrationTestFactory.cs tests/PriceNegotiationApp.IntegrationTests/CorsShould.cs +git commit -m "fix(api): enforce configured cors policy and vary product cache by origin" +``` + +--- + +### Task 3: Metadata for the Auth module (names, summaries, problem responses) + +**Files:** +- Modify: `src/PriceNegotiationApp.Modules.Identity/Features/Auth/Register.cs` +- Modify: `src/PriceNegotiationApp.Modules.Identity/Features/Auth/Login.cs` +- Modify: `src/PriceNegotiationApp.Modules.Identity/Features/Auth/Me.cs` + +**Interfaces:** +- Produces: route names `RegisterUser`, `Login`, `GetCurrentUser`; documented problems 409/422/429 (register), 401/422/429 (login). These names/assertions are consumed verbatim by Task 7's OpenAPI contract test. + +- [ ] **Step 1: Rewrite the three endpoint files** + +Replace the full content of `src/PriceNegotiationApp.Modules.Identity/Features/Auth/Register.cs`: + +```csharp +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using PriceNegotiationApp.SharedKernel; + +namespace PriceNegotiationApp.Modules.Identity.Features.Auth; + +internal static class Register +{ + internal static void MapRegister(this RouteGroupBuilder group) + { + group.MapPost("/register", async (RegisterRequest request, + RegisterUserHandler handler, CancellationToken ct) => + TypedResults.Created("/api/v1/auth/me", await handler.HandleAsync(request))) + .RequireRateLimiting(Policies.AuthRateLimitPolicy) + .AllowAnonymous() + .WithName("RegisterUser") + .WithSummary("Register a new customer account") + .ProducesProblem(StatusCodes.Status409Conflict) + .ProducesProblem(StatusCodes.Status422UnprocessableEntity) + .ProducesProblem(StatusCodes.Status429TooManyRequests); + } +} +``` + +Replace the full content of `src/PriceNegotiationApp.Modules.Identity/Features/Auth/Login.cs`: + +```csharp +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using PriceNegotiationApp.SharedKernel; + +namespace PriceNegotiationApp.Modules.Identity.Features.Auth; + +internal static class Login +{ + internal static void MapLogin(this RouteGroupBuilder group) + { + group.MapPost("/login", async (LoginRequest request, LoginUserHandler handler, + CancellationToken ct) => + TypedResults.Ok(await handler.HandleAsync(request))) + .RequireRateLimiting(Policies.AuthRateLimitPolicy) + .AllowAnonymous() + .WithName("Login") + .WithSummary("Authenticate and issue an access token") + .ProducesProblem(StatusCodes.Status401Unauthorized) + .ProducesProblem(StatusCodes.Status422UnprocessableEntity) + .ProducesProblem(StatusCodes.Status429TooManyRequests); + } +} +``` + +For login, 401 IS the documented business outcome of failed authentication (`invalid_credentials` problem), hence it appears despite the general omit-generic-401 rule. + +Replace the full content of `src/PriceNegotiationApp.Modules.Identity/Features/Auth/Me.cs`: + +```csharp +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using PriceNegotiationApp.SharedKernel; +using System.Security.Claims; + +namespace PriceNegotiationApp.Modules.Identity.Features.Auth; + +internal static class Me +{ + internal static void MapMe(this RouteGroupBuilder group) + { + group.MapGet("/me", (ClaimsPrincipal principal) => + { + var caller = principal.ToCallerContext(); + return TypedResults.Ok(new CurrentUserResponse(caller.UserId, caller.Email, caller.Roles.ToList())); + }) + .WithName("GetCurrentUser") + .WithSummary("Return the authenticated caller's profile"); + } +} +``` + +- [ ] **Step 2: Run relevant validation** + +```bash +dotnet build +dotnet test tests/PriceNegotiationApp.IntegrationTests --filter "FullyQualifiedName~AuthFlowShould" +``` + +Existing auth flows must stay green (metadata-only change). + +- [ ] **Step 3: Commit** + +```bash +git add src/PriceNegotiationApp.Modules.Identity/Features/Auth +git commit -m "feat(identity): document auth endpoints with names, summaries, and problem responses" +``` + +--- + +### Task 4: Metadata for the Catalog module + +**Files:** +- Modify: `src/PriceNegotiationApp.Modules.Catalog/Features/Products/List.cs` +- Modify: `src/PriceNegotiationApp.Modules.Catalog/Features/Products/Get.cs` +- Modify: `src/PriceNegotiationApp.Modules.Catalog/Features/Products/Create.cs` +- Modify: `src/PriceNegotiationApp.Modules.Catalog/Features/Products/Update.cs` +- Modify: `src/PriceNegotiationApp.Modules.Catalog/Features/Products/Delete.cs` + +**Interfaces:** +- Produces: route names `ListProducts`, `GetProductById` (pre-existing — `CreatedAtRoute` in Create depends on it, do not rename), `CreateProduct`, `UpdateProduct`, `DeleteProduct`. + +- [ ] **Step 1: Rewrite the five endpoint files** + +Replace the full content of `src/PriceNegotiationApp.Modules.Catalog/Features/Products/List.cs`: + +```csharp +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.OutputCaching; +using Microsoft.AspNetCore.Routing; +using PriceNegotiationApp.SharedKernel; + +namespace PriceNegotiationApp.Modules.Catalog.Features.Products; + +internal static class List +{ + internal static void MapList(this RouteGroupBuilder group) + { + group.MapGet("/", async (ListProductsHandler handler, CancellationToken ct, + string? search = null, decimal? minPrice = null, decimal? maxPrice = null, + string? sortBy = null, bool sortDesc = false, int page = 1, int pageSize = 20) => + TypedResults.Ok(await handler.HandleAsync( + new ProductQuery(search, minPrice, maxPrice, sortBy, sortDesc, page, pageSize), ct))) + .CacheOutput(Policies.ShortCachePolicy) + .AllowAnonymous() + .WithName("ListProducts") + .WithSummary("Search and page through products"); + } +} +``` + +Note: the original file imported `Microsoft.Extensions.DependencyInjection`; it is unused once rewritten — drop it. + +Replace the full content of `src/PriceNegotiationApp.Modules.Catalog/Features/Products/Get.cs`: + +```csharp +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.OutputCaching; +using Microsoft.AspNetCore.Routing; +using PriceNegotiationApp.SharedKernel; + +namespace PriceNegotiationApp.Modules.Catalog.Features.Products; + +internal static class Get +{ + internal static void MapGetOne(this RouteGroupBuilder group) + { + group.MapGet("/{id:guid}", async (Guid id, GetProductHandler handler, CancellationToken ct) => + TypedResults.Ok(await handler.HandleAsync(id, ct))) + .WithName("GetProductById") + .WithSummary("Fetch a single product by id") + .CacheOutput(Policies.ShortCachePolicy) + .AllowAnonymous() + .ProducesProblem(StatusCodes.Status404NotFound); + } +} +``` + +Replace the full content of `src/PriceNegotiationApp.Modules.Catalog/Features/Products/Create.cs`: + +```csharp +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using PriceNegotiationApp.SharedKernel; + +namespace PriceNegotiationApp.Modules.Catalog.Features.Products; + +internal static class Create +{ + internal static void MapCreate(this RouteGroupBuilder group) + { + group.MapPost("/", async (CreateProductRequest request, CreateProductHandler handler, + CancellationToken ct) => + { + var response = await handler.HandleAsync(request, ct); + return TypedResults.CreatedAtRoute(response, "GetProductById", new { id = response.Id }); + }) + .RequireRoles(UserRoles.Admin, UserRoles.Staff) + .WithName("CreateProduct") + .WithSummary("Create a new catalogue product") + .ProducesProblem(StatusCodes.Status422UnprocessableEntity); + } +} +``` + +Replace the full content of `src/PriceNegotiationApp.Modules.Catalog/Features/Products/Update.cs`: + +```csharp +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using PriceNegotiationApp.SharedKernel; + +namespace PriceNegotiationApp.Modules.Catalog.Features.Products; + +internal static class Update +{ + internal static void MapUpdate(this RouteGroupBuilder group) + { + group.MapPut("/{id:guid}", async (Guid id, UpdateProductRequest request, + UpdateProductHandler handler, CancellationToken ct) => + TypedResults.Ok(await handler.HandleAsync(id, request, ct))) + .RequireRoles(UserRoles.Admin, UserRoles.Staff) + .WithName("UpdateProduct") + .WithSummary("Update an existing product") + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status422UnprocessableEntity); + } +} +``` + +Replace the full content of `src/PriceNegotiationApp.Modules.Catalog/Features/Products/Delete.cs`: + +```csharp +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using PriceNegotiationApp.SharedKernel; + +namespace PriceNegotiationApp.Modules.Catalog.Features.Products; + +internal static class Delete +{ + internal static void MapDelete(this RouteGroupBuilder group) + { + group.MapDelete("/{id:guid}", async (Guid id, DeleteProductHandler handler, CancellationToken ct) => + { + await handler.HandleAsync(id, ct); + return TypedResults.NoContent(); + }) + .RequireRoles(UserRoles.Admin) + .WithName("DeleteProduct") + .WithSummary("Delete a product") + .ProducesProblem(StatusCodes.Status404NotFound); + } +} +``` + +- [ ] **Step 2: Run relevant validation** + +```bash +dotnet build +dotnet test tests/PriceNegotiationApp.IntegrationTests --filter "FullyQualifiedName~ProductsShould" +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/PriceNegotiationApp.Modules.Catalog/Features/Products +git commit -m "feat(catalog): document product endpoints with names, summaries, and problem responses" +``` + +--- + +### Task 5: Metadata for the Negotiations module + +**Files:** +- Modify: `src/PriceNegotiationApp.Modules.Negotiations/Features/Negotiations/Create.cs` +- Modify: `src/PriceNegotiationApp.Modules.Negotiations/Features/Negotiations/ListMine.cs` +- Modify: `src/PriceNegotiationApp.Modules.Negotiations/Features/Negotiations/List.cs` +- Modify: `src/PriceNegotiationApp.Modules.Negotiations/Features/Negotiations/Get.cs` +- Modify: `src/PriceNegotiationApp.Modules.Negotiations/Features/Negotiations/CounterPropose.cs` +- Modify: `src/PriceNegotiationApp.Modules.Negotiations/Features/Negotiations/Accept.cs` +- Modify: `src/PriceNegotiationApp.Modules.Negotiations/Features/Negotiations/RejectCurrentOffer.cs` +- Modify: `src/PriceNegotiationApp.Modules.Negotiations/Features/Negotiations/Withdraw.cs` + +**Interfaces:** +- Produces: route names `CreateNegotiation`, `ListMyNegotiations`, `ListNegotiations`, `GetNegotiationById`, `CounterProposeOffer`, `AcceptNegotiation`, `RejectCurrentOffer`, `WithdrawNegotiation`. + +- [ ] **Step 1: Rewrite the eight endpoint files** + +Replace the full content of `src/PriceNegotiationApp.Modules.Negotiations/Features/Negotiations/Create.cs`: + +```csharp +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using PriceNegotiationApp.SharedKernel; +using System.Security.Claims; + +namespace PriceNegotiationApp.Modules.Negotiations.Features.Negotiations; + +internal static class Create +{ + internal static void MapCreate(this RouteGroupBuilder group) + { + group.MapPost("/", async (CreateNegotiationRequest request, ClaimsPrincipal principal, + CreateNegotiationHandler handler, CancellationToken ct) => + TypedResults.Created("/api/v1/negotiations/mine", + await handler.HandleAsync(request, principal.ToCallerContext(), ct))) + .RequireRoles(UserRoles.Customer) + .WithName("CreateNegotiation") + .WithSummary("Start a price negotiation for a product") + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status409Conflict) + .ProducesProblem(StatusCodes.Status422UnprocessableEntity); + } +} +``` + +Replace the full content of `src/PriceNegotiationApp.Modules.Negotiations/Features/Negotiations/ListMine.cs`: + +```csharp +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using PriceNegotiationApp.SharedKernel; +using System.Security.Claims; + +namespace PriceNegotiationApp.Modules.Negotiations.Features.Negotiations; + +internal static class ListMine +{ + internal static void MapListMine(this RouteGroupBuilder group) + { + group.MapGet("/mine", async (ClaimsPrincipal principal, ListMyNegotiationsHandler handler, + CancellationToken ct, int page = 1, int pageSize = 20) => + TypedResults.Ok(await handler.HandleAsync( + new PageQuery(page, pageSize), principal.ToCallerContext(), ct))) + .RequireRoles(UserRoles.Customer) + .WithName("ListMyNegotiations") + .WithSummary("Page through the caller's own negotiations"); + } +} +``` + +Replace the full content of `src/PriceNegotiationApp.Modules.Negotiations/Features/Negotiations/List.cs`: + +```csharp +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using PriceNegotiationApp.SharedKernel; + +namespace PriceNegotiationApp.Modules.Negotiations.Features.Negotiations; + +internal static class List +{ + internal static void MapList(this RouteGroupBuilder group) + { + group.MapGet("/", async (ListNegotiationsHandler handler, CancellationToken ct, + int page = 1, int pageSize = 20) => + TypedResults.Ok(await handler.HandleAsync(new PageQuery(page, pageSize), ct))) + .RequireRoles(UserRoles.Admin, UserRoles.Staff) + .WithName("ListNegotiations") + .WithSummary("Page through every negotiation"); + } +} +``` + +Replace the full content of `src/PriceNegotiationApp.Modules.Negotiations/Features/Negotiations/Get.cs`: + +```csharp +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using PriceNegotiationApp.SharedKernel; +using System.Security.Claims; + +namespace PriceNegotiationApp.Modules.Negotiations.Features.Negotiations; + +internal static class Get +{ + internal static void MapGetOne(this RouteGroupBuilder group) + { + group.MapGet("/{id:guid}", async (Guid id, ClaimsPrincipal principal, + GetNegotiationHandler handler, CancellationToken ct) => + TypedResults.Ok(await handler.HandleAsync(id, principal.ToCallerContext(), ct))) + .WithName("GetNegotiationById") + .WithSummary("Fetch one negotiation the caller may access") + .ProducesProblem(StatusCodes.Status403Forbidden) + .ProducesProblem(StatusCodes.Status404NotFound); + } +} +``` + +403 here is an ownership rule enforced inside `GetNegotiationHandler` (not visible from role metadata), so it is explicitly documented. + +Replace the full content of `src/PriceNegotiationApp.Modules.Negotiations/Features/Negotiations/CounterPropose.cs`: + +```csharp +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using PriceNegotiationApp.SharedKernel; +using System.Security.Claims; + +namespace PriceNegotiationApp.Modules.Negotiations.Features.Negotiations; + +internal static class CounterPropose +{ + internal static void MapCounterPropose(this RouteGroupBuilder group) + { + group.MapPatch("/{id:guid}/proposals", async (Guid id, CounterProposalRequest request, + ClaimsPrincipal principal, CounterProposeHandler handler, CancellationToken ct) => + TypedResults.Ok(await handler.HandleAsync(id, request, principal.ToCallerContext(), ct))) + .WithName("CounterProposeOffer") + .WithSummary("Counter the staff's current offer") + .ProducesProblem(StatusCodes.Status403Forbidden) + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status409Conflict) + .ProducesProblem(StatusCodes.Status422UnprocessableEntity); + } +} +``` + +409 covers both `negotiation_closed` and `no_proposals_remaining`; 422 covers `proposal_exceeds_limit`. + +Replace the full content of `src/PriceNegotiationApp.Modules.Negotiations/Features/Negotiations/Accept.cs`: + +```csharp +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using PriceNegotiationApp.SharedKernel; + +namespace PriceNegotiationApp.Modules.Negotiations.Features.Negotiations; + +internal static class Accept +{ + internal static void MapAccept(this RouteGroupBuilder group) + { + group.MapPost("/{id:guid}/accept", async (Guid id, AcceptHandler handler, CancellationToken ct) => + TypedResults.Ok(await handler.HandleAsync(id, ct))) + .RequireRoles(UserRoles.Admin, UserRoles.Staff) + .WithName("AcceptNegotiation") + .WithSummary("Accept the customer's latest proposal") + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status409Conflict); + } +} +``` + +Replace the full content of `src/PriceNegotiationApp.Modules.Negotiations/Features/Negotiations/RejectCurrentOffer.cs`: + +```csharp +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using PriceNegotiationApp.SharedKernel; + +namespace PriceNegotiationApp.Modules.Negotiations.Features.Negotiations; + +internal static class RejectCurrentOffer +{ + internal static void MapRejectCurrentOffer(this RouteGroupBuilder group) + { + group.MapPost("/{id:guid}/decline", async (Guid id, RejectCurrentOfferHandler handler, + CancellationToken ct) => + TypedResults.Ok(await handler.HandleAsync(id, ct))) + .RequireRoles(UserRoles.Admin, UserRoles.Staff) + .WithName("RejectCurrentOffer") + .WithSummary("Reject the customer's latest proposal") + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status409Conflict); + } +} +``` + +Replace the full content of `src/PriceNegotiationApp.Modules.Negotiations/Features/Negotiations/Withdraw.cs`: + +```csharp +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using PriceNegotiationApp.SharedKernel; +using System.Security.Claims; + +namespace PriceNegotiationApp.Modules.Negotiations.Features.Negotiations; + +internal static class Withdraw +{ + internal static void MapWithdraw(this RouteGroupBuilder group) + { + group.MapDelete("/{id:guid}", async (Guid id, ClaimsPrincipal principal, + WithdrawHandler handler, CancellationToken ct) => + { + await handler.HandleAsync(id, principal.ToCallerContext(), ct); + return TypedResults.NoContent(); + }) + .WithName("WithdrawNegotiation") + .WithSummary("Withdraw the negotiation (owner) or delete it (admin)") + .ProducesProblem(StatusCodes.Status403Forbidden) + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status409Conflict); + } +} +``` + +- [ ] **Step 2: Run relevant validation** + +```bash +dotnet build +dotnet test tests/PriceNegotiationApp.IntegrationTests --filter "FullyQualifiedName~NegotiationsShould" +dotnet test tests/PriceNegotiationApp.IntegrationTests --filter "FullyQualifiedName~ConcurrencyShould" +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/PriceNegotiationApp.Modules.Negotiations/Features/Negotiations +git commit -m "feat(negotiations): document negotiation endpoints with names, summaries, and problem responses" +``` + +--- + +### Task 6: Exclude infrastructure endpoints from the API description; enable OpenAPI in Testing + +**Files:** +- Modify: `src/PriceNegotiationApp.Api/Extensions/PipelineExtensions.cs` + +**Interfaces:** +- Produces: `GET /openapi/v1.json` served in the `Testing` environment (consumed by Task 7's test); health/jwks absent from the generated document. + +- [ ] **Step 1: Update the pipeline wiring** + +Three edits in `PipelineExtensions.UsePipeline`. + +First, widen the OpenAPI gate so integration tests can assert against the real generated document (Scalar UI stays development-only): + +```csharp +if (app.Environment.IsDevelopment() || app.Environment.IsEnvironment("Testing")) +{ + app.MapOpenApi(); + if (app.Environment.IsDevelopment()) + { + app.MapScalarApiReference(); + } +} +``` + +Second, exclude the JWKS endpoint: + +```csharp +app.MapGet("/.well-known/jwks.json", (EcSigningKey signingKey) => TypedResults.Json( + new JwksResponse([new JwkKey( + signingKey.PublicJwk.Kty, + signingKey.PublicJwk.Crv, + signingKey.PublicJwk.X, + signingKey.PublicJwk.Y, + signingKey.Kid)]))) + .AllowAnonymous() + .ExcludeFromDescription(); +``` + +Third, exclude both health probes: + +```csharp +app.MapHealthChecks("/health/live", new HealthCheckOptions { Predicate = r => r.Tags.Contains("live") }) + .ExcludeFromDescription(); +app.MapHealthChecks("/health/ready", new HealthCheckOptions +{ + Predicate = r => r.Tags.Contains("ready"), + ResponseWriter = ReadyHealthReport.WriteAsync, +}) +.ExcludeFromDescription(); +``` + +- [ ] **Step 2: Run relevant validation** + +```bash +dotnet build +dotnet test tests/PriceNegotiationApp.IntegrationTests --filter "FullyQualifiedName~JwksShould" +dotnet test tests/PriceNegotiationApp.IntegrationTests --filter "FullyQualifiedName~ReadyHealth" +``` + +Behavioral endpoints unchanged; exclusion is description-only. + +- [ ] **Step 3: Commit** + +```bash +git add src/PriceNegotiationApp.Api/Extensions/PipelineExtensions.cs +git commit -m "feat(api): hide infrastructure endpoints from api description, serve openapi in testing" +``` + +--- + +### Task 7: OpenAPI contract smoke test + full validation + +**Files:** +- Create: `tests/PriceNegotiationApp.IntegrationTests/OpenApiContractShould.cs` + +**Interfaces:** +- Consumes: route names and problem statuses from Tasks 3–5; exclusions from Task 6; `IntegrationTestFixture.Anonymous` client. + +- [ ] **Step 1: Add the contract test** + +Create `tests/PriceNegotiationApp.IntegrationTests/OpenApiContractShould.cs`: + +```csharp +using PriceNegotiationApp.IntegrationTests.Support; +using Shouldly; +using System.Net.Http.Json; +using Xunit; + +namespace PriceNegotiationApp.IntegrationTests; + +[Collection(ApiCollection.Name)] +public class OpenApiContractShould(IntegrationTestFixture fixture) +{ + [Fact] + public async Task Document_exposes_names_summaries_and_problem_responses() + { + var response = await fixture.Anonymous.GetAsync("/openapi/v1.json", TestContext.Current.CancellationToken); + response.EnsureSuccessStatusCode(); + + var document = await response.Content.ReadFromJsonAsync( + TestContext.Current.CancellationToken); + var root = document!.RootElement; + + root.GetProperty("paths").TryGetProperty("/health/live", out _).ShouldBeFalse(); + root.GetProperty("paths").TryGetProperty("/.well-known/jwks.json", out _).ShouldBeFalse(); + + var login = root.GetProperty("paths").GetProperty("/api/v1/auth/login").GetProperty("post"); + login.GetProperty("operationId").GetString().ShouldBe("Login"); + login.GetProperty("summary").GetString().ShouldNotBeNull(); + login.GetProperty("responses").TryGetProperty("401", out _).ShouldBeTrue(); + login.GetProperty("responses").TryGetProperty("429", out _).ShouldBeTrue(); + + var counterPropose = root.GetProperty("paths") + .GetProperty("/api/v1/negotiations/{id}/proposals").GetProperty("patch"); + counterPropose.GetProperty("responses").TryGetProperty("409", out _).ShouldBeTrue(); + counterPropose.GetProperty("responses").TryGetProperty("422", out _).ShouldBeTrue(); + } + + [Fact] + public async Task Every_business_operation_has_a_stable_operation_id_and_summary() + { + var response = await fixture.Anonymous.GetAsync("/openapi/v1.json", TestContext.Current.CancellationToken); + response.EnsureSuccessStatusCode(); + + var document = await response.Content.ReadFromJsonAsync( + TestContext.Current.CancellationToken); + var root = document!.RootElement.GetProperty("paths"); + + var incomplete = new List(); + foreach (var path in root.EnumerateObject()) + { + foreach (var operation in path.Value.EnumerateObject()) + { + if (operation.Value.ValueKind != System.Text.Json.JsonValueKind.Object) + { + continue; + } + + if (!operation.Value.TryGetProperty("operationId", out _) || + !operation.Value.TryGetProperty("summary", out _)) + { + incomplete.Add($"{operation.Name.ToUpperInvariant()} {path.Name}"); + } + } + } + + incomplete.ShouldBeEmpty(); + } +} +``` + +- [ ] **Step 2: Run the new test** + +```bash +dotnet test tests/PriceNegotiationApp.IntegrationTests --filter "FullyQualifiedName~OpenApiContractShould" +``` + +If `operationId` assertions fail because a framework strategy overrides `WithName` (not expected on net10.0 — official docs guarantee `WithName` becomes the operation ID), stop and investigate rather than loosening the assertion. + +- [ ] **Step 3: Run the full gate** + +```bash +dotnet format --verify-no-changes +dotnet build +dotnet test +``` + +All suites (unit, architecture, integration) must pass. Architecture tests pin repository patterns — expect them green since no persistence code changed. + +- [ ] **Step 4: Commit** + +```bash +git add tests/PriceNegotiationApp.IntegrationTests/OpenApiContractShould.cs +git commit -m "test(integration): lock openapi contract surface - names, summaries, problem responses" +``` + +--- + +## Completion Checklist + +- [ ] All 16 business endpoints: group-authenticated by default, named, summarized, error-documented. +- [ ] Public surface frozen by `PublicSurfaceShould` (12 protected routes challenge anonymously). +- [ ] CORS actually enforced for configured origins; cache cannot leak cross-origin headers. +- [ ] `/health/*` and `/.well-known/jwks.json` absent from `/openapi/v1.json`. +- [ ] Full `dotnet test` green including architecture tests. diff --git a/docs/superpowers/plans/2026-08-26-security-hardening.md b/docs/superpowers/plans/2026-08-26-security-hardening.md new file mode 100644 index 0000000..ad19398 --- /dev/null +++ b/docs/superpowers/plans/2026-08-26-security-hardening.md @@ -0,0 +1,1039 @@ +# Security Review Hardening Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Close the confirmed security findings from the 2026-08-26 review: readiness-body info leak, login account-enumeration oracle, weak seed credentials, duplicated JWT config, and symmetric HMAC signing (migrate to ES256 + JWKS). + +**Architecture:** All fixes ride existing seams — option validators (`IValidateOptions` + `ValidateOnStart`), handler-owned application logic behind transport-only endpoints, and the WebApplicationFactory/Testcontainers integration harness. The ES256 migration introduces one singleton (`EcSigningKey`) in the Identity module's `Features/Auth`; the composition root consumes its public half for bearer validation and publishes JWKS. + +**Tech Stack:** .NET 10 / ASP.NET Core minimal APIs, ASP.NET Core Identity, `System.IdentityModel.Tokens.Jwt` 8.19.2 (brings `Microsoft.IdentityModel.Tokens` with `JsonWebKey`, `JsonWebKeyConverter`, `ComputeJwkThumbprint`), xUnit v3 + Shouldly, Testcontainers PostgreSQL. + +**Spec:** `docs/superpowers/specs/2026-08-26-security-review-design.md` + +## Global Constraints + +- `TreatWarningsAsErrors=true` and analyzers run on every project — zero warnings allowed. +- Tactical DDD laws (enforced by ArchUnitNET): endpoints are transport-only; handlers own persistence; no repository abstractions. +- Stable machine-readable error `code` extensions on every ProblemDetails response. +- No secrets committed: user-secrets locally, environment variables in compose. Placeholder values in `.env.example` must **fail** startup validation if deployed verbatim. +- Comments follow repo style: short rationale comments allowed where non-obvious (see existing files). +- Integration tests require Docker (Testcontainers). Unit facts live in the same projects without `[Collection]` attributes and skip Docker. +- Full-suite gate before final commit of each task: `dotnet test --project ` per task; whole solution green at Task 6. + +--- + +### Task 1: Sanitize `/health/ready` response body (spec F1) + +The readiness endpoint is anonymous and currently returns `description = exception.Message` for unhealthy checks, leaking dependency internals. Detail moves to server logs only. + +**Files:** +- Modify: `src/PriceNegotiationApp.Api/ReadyHealthReport.cs` +- Create: `tests/PriceNegotiationApp.IntegrationTests/ReadyHealthReportShould.cs` +- Modify (no-op verification): `tests/PriceNegotiationApp.IntegrationTests/ReadyHealthShould.cs` (existing assertions must stay green) + +**Interfaces:** +- Consumes: framework `HealthReport` / `HealthReportEntry`. +- Produces: same static signature `ReadyHealthReport.WriteAsync(HttpContext, HealthReport)`; body entries now always `{ status, durationMs }`. + +- [ ] **Step 1: Rewrite the response writer** + +Replace the whole content of `src/PriceNegotiationApp.Api/ReadyHealthReport.cs`: + +```csharp +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; + +namespace PriceNegotiationApp.Api; + +/// +/// JSON body for /health/ready naming every dependency and its verdict. +/// Failure detail goes to logs only; the anonymous body stays free of it. +/// +public static class ReadyHealthReport +{ + public static async Task WriteAsync(HttpContext context, HealthReport report) + { + var logger = context.RequestServices + .GetRequiredService() + .CreateLogger(nameof(ReadyHealthReport)); + + foreach (var (name, entry) in report.Entries.Where(e => e.Value.Status != HealthStatus.Healthy)) + { + logger.LogWarning("Readiness check '{Check}' is unhealthy: {Detail}", + name, entry.Value.Description ?? entry.Value.Exception?.Message); + } + + var payload = new + { + status = report.Status.ToString(), + totalDurationMs = report.TotalDuration.TotalMilliseconds, + entries = report.Entries.ToDictionary( + entry => entry.Key, + entry => new + { + status = entry.Value.Status.ToString(), + durationMs = entry.Value.Duration.TotalMilliseconds, + }), + }; + + await context.Response.WriteAsJsonAsync(payload); + } +} +``` + +- [ ] **Step 2: Add the leak-regression unit test** + +Create `tests/PriceNegotiationApp.IntegrationTests/ReadyHealthReportShould.cs`: + +```csharp +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using PriceNegotiationApp.Api; +using Shouldly; +using Xunit; + +namespace PriceNegotiationApp.IntegrationTests; + +// Plain unit fact over the response writer; no Docker container required. +public class ReadyHealthReportShould +{ + [Fact] + public async Task Never_leak_failure_detail_in_body_even_when_unhealthy() + { + var context = new DefaultHttpContext(); + context.Response.Body = new MemoryStream(); + var secret = "password authentication failed for user 'postgres'"; + var report = new HealthReport( + new Dictionary + { + ["database-catalog"] = new( + HealthStatus.Unhealthy, secret, TimeSpan.FromMilliseconds(3), + new InvalidOperationException(secret), null), + ["self"] = new( + HealthStatus.Healthy, null, TimeSpan.FromMilliseconds(1), null, null), + }, + totalDuration: TimeSpan.FromMilliseconds(4)); + + await ReadyHealthReport.WriteAsync(context, report); + + context.Response.Body.Position = 0; + var body = await new StreamReader(context.Response.Body).ReadToEndAsync(); + body.ShouldNotContain(secret); + body.ShouldNotContain("description"); + body.ShouldContain("\"Unhealthy\""); + } +} +``` + +- [ ] **Step 3: Run validation** + +```bash +dotnet test --project tests/PriceNegotiationApp.IntegrationTests --filter ReadyHealth +``` + +Both `ReadyHealthShould` (Docker) and `ReadyHealthReportShould` (plain) must pass. + +- [ ] **Step 4: Commit** + +```bash +git add src/PriceNegotiationApp.Api/ReadyHealthReport.cs tests/PriceNegotiationApp.IntegrationTests/ReadyHealthReportShould.cs +git commit -m "fix(health): keep readiness body free of dependency failure detail" +``` + +--- + +### Task 2: Uniform login failures (spec F2) + +Locked accounts currently answer `account_locked` while everything else answers `invalid_credentials` — an enumeration oracle. All authentication failures become indistinguishable externally; lockout mechanics stay intact internally. + +**Files:** +- Modify: `src/PriceNegotiationApp.Modules.Identity/Features/Auth/LoginUserHandler.cs` +- Modify: `src/PriceNegotiationApp.Modules.Identity/Public/IdentityErrorCodes.cs` (remove `AccountLocked`) +- Modify: `tests/PriceNegotiationApp.IntegrationTests/AuthFlowShould.cs` + +**Interfaces:** +- Produces: every failed login → HTTP 401, `code: "invalid_credentials"`. + +- [ ] **Step 1: Make the handler uniform** + +Replace the content of `LoginUserHandler.HandleAsync` and add the private factory (class declaration/constructor unchanged): + +```csharp + public async Task HandleAsync(LoginRequest request) + { + var user = await userManager.FindByNameAsync(request.Email) + ?? throw Unauthorized(); + + // Lockout keeps enforcing internally but reads identically to any other failure. + if (await userManager.IsLockedOutAsync(user)) + { + throw Unauthorized(); + } + + if (!await userManager.CheckPasswordAsync(user, request.Password)) + { + await userManager.AccessFailedAsync(user); + throw Unauthorized(); + } + + await userManager.ResetAccessFailedCountAsync(user); + + var roles = (IReadOnlyList)await userManager.GetRolesAsync(user); + var (token, expiresAtUtc) = jwt.Generate(user.Id, request.Email, roles); + return new AuthResponse(token, expiresAtUtc, request.Email, roles); + } + + private static UnauthorizedException Unauthorized() => + new(IdentityErrorCodes.InvalidCredentials, "Invalid credentials."); +``` + +- [ ] **Step 2: Remove the dead error code** + +In `src/PriceNegotiationApp.Modules.Identity/Public/IdentityErrorCodes.cs`, delete the line: + +```csharp + public const string AccountLocked = "account_locked"; +``` + +- [ ] **Step 3: Update and extend the integration tests** + +In `tests/PriceNegotiationApp.IntegrationTests/AuthFlowShould.cs`: + +Rename `Five_failed_attempts_lock_account` and change its final assertion: + +```csharp + [Fact] + public async Task Locked_account_reports_invalid_credentials_like_any_failure() + { + var session = await fixture.CreateUserAsync(); + + for (var i = 0; i < 5; i++) + { + await fixture.Anonymous.PostAsJsonAsync("/api/v1/auth/login", + new LoginRequest { Email = session.Email, Password = "WrongPass1!" }, TestContext.Current.CancellationToken); + } + + // Even the correct password is now rejected because of the lockout + var retry = await fixture.Anonymous.PostAsJsonAsync("/api/v1/auth/login", + new LoginRequest { Email = session.Email, Password = session.Password }, TestContext.Current.CancellationToken); + + retry.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); + var body = await retry.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + CodeOf(body).ShouldBe("invalid_credentials"); + } +``` + +Add this test and helper to the same class (add `using System.Text.Json;` to the file): + +```csharp + [Fact] + public async Task Unknown_email_and_wrong_password_are_indistinguishable() + { + var session = await fixture.CreateUserAsync(); + + var unknown = await fixture.Anonymous.PostAsJsonAsync("/api/v1/auth/login", + new LoginRequest { Email = Fuzz.UniqueEmail(), Password = "Whatever1!" }, TestContext.Current.CancellationToken); + var wrongPassword = await fixture.Anonymous.PostAsJsonAsync("/api/v1/auth/login", + new LoginRequest { Email = session.Email, Password = "WrongPass1!" }, TestContext.Current.CancellationToken); + + unknown.StatusCode.ShouldBe(wrongPassword.StatusCode); + CodeOf(await unknown.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)) + .ShouldBe(CodeOf(await wrongPassword.Content.ReadAsStringAsync(TestContext.Current.CancellationToken))); + } + + private static string CodeOf(string problemDetails) + { + using var document = JsonDocument.Parse(problemDetails); + return document.RootElement.GetProperty("code").GetString()!; + } +``` + +Note: `Bad_password_is_unauthorized_with_stable_code` already expects `invalid_credentials` and must stay green untouched. + +- [ ] **Step 4: Run validation** + +```bash +dotnet test --project tests/PriceNegotiationApp.IntegrationTests --filter AuthFlow +``` + +- [ ] **Step 5: Commit** + +```bash +git add src/PriceNegotiationApp.Modules.Identity tests/PriceNegotiationApp.IntegrationTests/AuthFlowShould.cs +git commit -m "fix(identity): answer every failed login with invalid_credentials" +``` + +--- + +### Task 3: Strong seed credentials + loud seeding failures (spec F3) + +Seed passwords were floored at 8 chars and examples shipped `Admin123!`. Raise the floor to mirror what ASP.NET Identity actually accepts, and stop swallowing silent seed-user creation failures. + +**Files:** +- Modify: `src/PriceNegotiationApp.Modules.Identity/Seeding/SeedingOptionsValidator.cs` +- Modify: `src/PriceNegotiationApp.Modules.Identity/Seeding/IdentitySeedingHostedService.cs` +- Modify: `tests/PriceNegotiationApp.Modules.Identity.Tests/SeedingOptionsValidatorShould.cs` +- Modify: `tests/PriceNegotiationApp.IntegrationTests/Support/IntegrationTestFactory.cs:12` (`SeedPassword`) +- Modify: `.env.example`, `README.md:107-115` (quickstart secrets) + +**Interfaces:** +- Produces: `Seeding:{Admin,Staff}Password` accepted iff ≥12 chars with upper-case, lower-case, digit, and symbol. Startup otherwise fails fast with that exact requirement text. + +- [ ] **Step 1: Harden the validator** + +In `SeedingOptionsValidator.cs`, replace both password blocks and add the helper (email logic untouched): + +```csharp + if (string.IsNullOrWhiteSpace(options.AdminPassword) || !IsStrong(options.AdminPassword)) + { + failures.Add("Seeding:AdminPassword must be at least 12 characters and mix upper-case, lower-case, digit and symbol characters."); + } + + if (string.IsNullOrWhiteSpace(options.StaffPassword) || !IsStrong(options.StaffPassword)) + { + failures.Add("Seeding:StaffPassword must be at least 12 characters and mix upper-case, lower-case, digit and symbol characters."); + } + + return failures.Count > 0 ? ValidateOptionsResult.Fail(failures) : ValidateOptionsResult.Success; + } + + private static bool IsStrong(string password) => + password.Length >= 12 + && password.Any(char.IsUpper) + && password.Any(char.IsLower) + && password.Any(char.IsDigit) + && password.Any(c => !char.IsLetterOrDigit(c)); +``` + +- [ ] **Step 2: Log seed-user creation failures instead of skipping silently** + +In `IdentitySeedingHostedService.cs`, replace `EnsureUserAsync` and its call sites' logging plumbing (the class already receives `ILogger logger` via its primary constructor): + +```csharp + protected override async Task SeedAsync(IServiceProvider services, CancellationToken cancellationToken) + { + var roleManager = services.GetRequiredService>>(); + foreach (var role in new[] { UserRoles.Admin, UserRoles.Staff, UserRoles.Customer }) + { + if (!await roleManager.RoleExistsAsync(role)) + { + await roleManager.CreateAsync(new IdentityRole(role)); + } + } + + var userManager = services.GetRequiredService>(); + await EnsureUserAsync(userManager, options.Value.AdminEmail, options.Value.AdminPassword, UserRoles.Admin); + await EnsureUserAsync(userManager, options.Value.StaffEmail, options.Value.StaffPassword, UserRoles.Staff); + logger.LogInformation("Identity seed data ensured."); + } + + private async Task EnsureUserAsync(UserManager userManager, string email, string password, string role) + { + if (string.IsNullOrWhiteSpace(password) + || await userManager.FindByEmailAsync(email) is not null) + { + return; + } + + var user = new ApplicationUser { UserName = email, Email = email }; + var result = await userManager.CreateAsync(user, password); + if (result.Succeeded) + { + await userManager.AddToRoleAsync(user, role); + } + else + { + logger.LogError("Seeded user {Email} could not be created: {Errors}", + email, string.Join("; ", result.Errors.Select(e => $"{e.Code} {e.Description}"))); + } + } +``` + +(`EnsureUserAsync` drops `static` because it now uses `logger`.) + +- [ ] **Step 3: Extend the validator tests** + +In `SeedingOptionsValidatorShould.cs`, replace the weak-password theory with: + +```csharp + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("short")] + [InlineData("alllowercase123!")] + [InlineData("ALLUPPERCASE123!")] + [InlineData("NoDigitsHereOnly!!")] + [InlineData("NoSymbols12345xY")] + public void Reject_admin_password_below_strength_floor(string password) + { + var options = new SeedingOptions + { + AdminEmail = Fuzz.Email(), + AdminPassword = password, + StaffEmail = Fuzz.Email(), + StaffPassword = Fuzz.Password(), + }; + + _sut.Validate(null, options).Failed.ShouldBeTrue(); + } + + [Theory] + [InlineData("Seed123!Apricot!")] + [InlineData("Str0ng-Passphrase!42")] + public void Accept_strong_admin_passwords(string password) + { + var options = new SeedingOptions + { + AdminEmail = Fuzz.Email(), + AdminPassword = password, + StaffEmail = Fuzz.Email(), + StaffPassword = Fuzz.Password(), + }; + + _sut.Validate(null, options).Succeeded.ShouldBeTrue(); + } +``` + +(The existing `Aggregate_every_violation_in_one_result` expectation of exactly 2 failures still holds — blank passwords produce one failure line each.) + +- [ ] **Step 4: Bump the integration fixture password** + +In `IntegrationTestFactory.cs`: + +```csharp + public const string SeedPassword = "Seed123!Apricot!"; +``` + +Then search for other literal uses of the old value: `rg "Seed123!a"` — every hit must be switched to the constant or the new value. + +- [ ] **Step 5: Churn the shipped examples** + +`.env.example` — replace the seed lines: + +``` +SEED_ADMIN_PASSWORD=replace-me-strong-random-Aa1! +SEED_STAFF_PASSWORD=replace-me-strong-random-Bb2! +``` + +`README.md` quickstart user-secrets block — replace the two seeding lines with: + +```bash +dotnet user-secrets set "Seeding:AdminPassword" "" --project src/PriceNegotiationApp.Api +dotnet user-secrets set "Seeding:StaffPassword" "" --project src/PriceNegotiationApp.Api +``` + +- [ ] **Step 6: Run validation** + +```bash +dotnet test --project tests/PriceNegotiationApp.Modules.Identity.Tests +dotnet test --project tests/PriceNegotiationApp.IntegrationTests --filter "FullyQualifiedName~ConfigurationValidation|FullyQualifiedName~AuthFlow" +``` + +- [ ] **Step 7: Commit** + +```bash +git add src/PriceNegotiationApp.Modules.Identity tests .env.example README.md +git commit -m "feat(seeding): enforce strong seed passwords and log creation failures" +``` + +--- + +### Task 4: One validated JWT options contract (spec F5) + +The Api duplicates the Identity module's `JwtOptions` as `JwtSettings` bound straight from configuration — bypassing validation. Delete the duplicate and configure bearer options through the DI options pattern against the module's validated `JwtOptions`. + +**Files:** +- Modify: `src/PriceNegotiationApp.Api/Extensions/WebApplicationBuilderExtensions.cs:54-70` +- Delete: `src/PriceNegotiationApp.Api/Extensions/JwtSettings.cs` + +**Interfaces:** +- Consumes: `PriceNegotiationApp.Modules.Identity.Features.Auth.JwtOptions` (validated, registered by `AddIdentityModule`). +- Produces: `JwtBearerOptions` for scheme `JwtBearerDefaults.AuthenticationScheme`, populated via `OptionsBuilder.Configure>`. + +- [ ] **Step 1: Rewire bearer setup** + +Replace the authentication registration block in `WebApplicationBuilderExtensions.AddApiServices`: + +```csharp + builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(); + builder.Services.AddOptions(JwtBearerDefaults.AuthenticationScheme) + .Configure>((bearer, jwt) => + bearer.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidIssuer = jwt.Value.Issuer, + ValidateAudience = true, + ValidAudience = jwt.Value.Audience, + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwt.Value.SecretKey)), + ValidateLifetime = true, + ClockSkew = TimeSpan.FromMinutes(1), + }); +``` + +Add `using PriceNegotiationApp.Modules.Identity.Features.Auth;`. Delete `src/PriceNegotiationApp.Api/Extensions/JwtSettings.cs`, then remove now-unused usings from `WebApplicationBuilderExtensions.cs` (`System.Text` becomes unused in Task 5; leave it if still referenced). + +- [ ] **Step 2: Run validation** + +```bash +dotnet build -c Release +dotnet test --project tests/PriceNegotiationApp.IntegrationTests --filter AuthFlow +``` + +Behavior is unchanged; the suite passing proves the rewire. + +- [ ] **Step 3: Commit** + +```bash +git add -A src/PriceNegotiationApp.Api tests/PriceNegotiationApp.IntegrationTests +git commit -m "refactor(auth): validate bearer settings through the module-owned JwtOptions" +``` + +--- + +### Task 5: ES256 asymmetric signing + JWKS publication (spec F7) + +Replace the shared HMAC secret with an EC P-256 key pair. Private half signs; public half (published at `/.well-known/jwks.json` with an RFC 7638 thumbprint `kid`) validates — future resource servers or extracted identity services never hold signing material. + +**Files:** +- Create: `src/PriceNegotiationApp.Modules.Identity/Features/Auth/EcSigningKey.cs` +- Modify: `src/PriceNegotiationApp.Modules.Identity/Features/Auth/JwtOptions.cs` +- Modify: `src/PriceNegotiationApp.Modules.Identity/Features/Auth/JwtOptionsValidator.cs` +- Modify: `src/PriceNegotiationApp.Modules.Identity/Features/Auth/JwtManager.cs` +- Modify: `src/PriceNegotiationApp.Modules.Identity/IdentityModule.cs:41` +- Modify: `src/PriceNegotiationApp.Api/Extensions/WebApplicationBuilderExtensions.cs` +- Modify: `src/PriceNegotiationApp.Api/Extensions/PipelineExtensions.cs` +- Modify: `src/PriceNegotiationApp.Api/appsettings.json:15` +- Modify: `docker-compose.yml:12`, `.env.example:3`, `README.md:107-115` + config table row +- Modify: `tests/PriceNegotiationApp.IntegrationTests/Support/IntegrationTestFactory.cs:20` +- Test: rewrite `tests/PriceNegotiationApp.Modules.Identity.Tests/JwtManagerShould.cs` +- Create: `tests/PriceNegotiationApp.Modules.Identity.Tests/JwtOptionsValidatorShould.cs` +- Create: `tests/PriceNegotiationApp.IntegrationTests/JwksShould.cs` + +**Interfaces:** +- Consumes: `JwtOptions.PrivateKey` (PKCS#8 PEM; literal newlines or `\n`-escaped). +- Produces: + - `EcSigningKey` — singleton; `JsonWebKey PublicJwk` (public-only, `Kid` set), `string Kid`, `ECDsa CreatePrivateEcdsa()`, `const string Algorithm = SecurityAlgorithms.EcdSa256`. + - JWKS endpoint `GET /.well-known/jwks.json` → `{ "keys": [ { kty, crv, x, y, kid } ] }`. + - Config surface renamed: `Jwt:SecretKey` → `Jwt:PrivateKey`; compose env `JWT_SECRET_KEY` → `JWT_PRIVATE_KEY`. + +- [ ] **Step 1: Create the key holder** + +`src/PriceNegotiationApp.Modules.Identity/Features/Auth/EcSigningKey.cs`: + +```csharp +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; +using System.Security.Cryptography; + +namespace PriceNegotiationApp.Modules.Identity.Features.Auth; + +/// +/// Holds the ES256 private key PEM and derives the public JWK once. +/// Only PublicJwk ever leaves this class: bearer validation and the JWKS +/// endpoint can verify tokens without holding signing material. +/// +internal sealed class EcSigningKey +{ + public const string Algorithm = SecurityAlgorithms.EcdSa256; + + private const string Usage = + "Jwt:PrivateKey must be an EC P-256 private key in PKCS#8 PEM " + + "(generate: openssl ecparam -name prime256v1 -genkey -noout)."; + + private readonly string _privateKeyPem; + + internal JsonWebKey PublicJwk { get; } + + internal string Kid { get; } + + public EcSigningKey(IOptions options) + { + _privateKeyPem = Normalize(options.Value.PrivateKey); + using var ecdsa = Import(_privateKeyPem); + var jwk = JsonWebKeyConverter.ConvertFromECDsaPublicKey(ecdsa); + Kid = jwk.ComputeJwkThumbprint(); + jwk.Kid = Kid; + PublicJwk = jwk; + } + + internal ECDsa CreatePrivateEcdsa() => Import(_privateKeyPem); + + private static ECDsa Import(string pem) + { + var ecdsa = ECDsa.Create(); + try + { + ecdsa.ImportFromPem(pem); + } + catch (CryptographicException ex) + { + ecdsa.Dispose(); + throw new InvalidOperationException(Usage, ex); + } + + if (ecdsa.KeySize != 256) + { + ecdsa.Dispose(); + throw new InvalidOperationException(Usage); + } + + return ecdsa; + } + + private static string Normalize(string raw) => raw.Replace("\\n", "\n").Trim(); +} +``` + +(A fresh `ECDsa` per sign call keeps concurrent requests off one crypto instance; parsing cost is negligible.) + +- [ ] **Step 2: Swap the options property and validator** + +`JwtOptions.cs` — replace `SecretKey` with: + +```csharp + /// EC P-256 private key, PKCS#8 PEM; newlines may be literal or \n-escaped. + public required string PrivateKey { get; init; } +``` + +`JwtOptionsValidator.cs` — replace the secret-length check with: + +```csharp + if (string.IsNullOrWhiteSpace(options.PrivateKey)) + { + failures.Add("Jwt:PrivateKey is required (ES256 PKCS#8 PEM; malformed keys fail at startup with generation instructions)."); + } +``` + +(Deep PEM parsing stays in `EcSigningKey`'s constructor — single source of truth.) + +- [ ] **Step 3: Sign with ES256** + +`JwtManager.cs` — constructor takes `EcSigningKey signingKey`; the credentials block becomes: + +```csharp + using var ecdsa = signingKey.CreatePrivateEcdsa(); + var credentials = new SigningCredentials( + new ECDsaSecurityKey(ecdsa) { KeyId = signingKey.Kid }, + EcSigningKey.Algorithm); +``` + +Everything else in `Generate` is unchanged (`using System.Text;` becomes unused — remove it). + +`IdentityModule.cs` — register the singleton next to `JwtManager`: + +```csharp + services.AddSingleton(); + services.AddSingleton(); +``` + +- [ ] **Step 4: Validate with the public key** + +In `WebApplicationBuilderExtensions.cs`, the `Configure>` lambda from Task 4 gains the dependency and algorithm pinning: + +```csharp + builder.Services.AddOptions(JwtBearerDefaults.AuthenticationScheme) + .Configure, EcSigningKey>((bearer, jwt, signingKey) => + bearer.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidIssuer = jwt.Value.Issuer, + ValidateAudience = true, + ValidAudience = jwt.Value.Audience, + ValidateIssuerSigningKey = true, + IssuerSigningKey = signingKey.PublicJwk, + ValidAlgorithms = [EcSigningKey.Algorithm], + ValidateLifetime = true, + ClockSkew = TimeSpan.FromMinutes(1), + }); +``` + +Add `using PriceNegotiationApp.Modules.Identity.Features.Auth;`; remove `System.Text` if unused. + +- [ ] **Step 5: Publish JWKS** + +In `PipelineExtensions.MapModules`, before the health mappings: + +```csharp + app.MapGet("/.well-known/jwks.json", (Features.Auth.EcSigningKey signingKey) => TypedResults.Json( + new JwksResponse([new JwkKey( + signingKey.PublicJwk.Kty, + signingKey.PublicJwk.Crv, + signingKey.PublicJwk.X, + signingKey.PublicJwk.Y, + signingKey.Kid)]))) + .AllowAnonymous(); +``` + +Add to `IsInfrastructurePath`: `path.StartsWithSegments("/.well-known", StringComparison.OrdinalIgnoreCase) ||`. + +Append below the class: + +```csharp +internal sealed record JwksResponse(IReadOnlyList Keys); + +// Deliberate DTO: serializes exactly the five public fields, so private material +// can never leak even if JsonWebKey grows properties later. +internal sealed record JwkKey(string Kty, string Crv, string X, string Y, string Kid); +``` + +- [ ] **Step 6: Config churn** + +`appsettings.json` — inside `Jwt`, replace `"SecretKey": ""` with `"PrivateKey": ""`. + +`docker-compose.yml:12` — replace the `Jwt__SecretKey` line: + +```yaml + Jwt__PrivateKey: ${JWT_PRIVATE_KEY:?set JWT_PRIVATE_KEY (ES256 PKCS#8 PEM, see README)} +``` + +`.env.example` — replace the `JWT_SECRET_KEY` line: + +``` +# ES256 signing key: PKCS#8 PEM generated per README; escape newlines as \n for one line +JWT_PRIVATE_KEY=replace-with-key-generated-per-README +``` + +`README.md` — in Quickstart, replace the `Jwt:SecretKey` user-secret line with a generate-then-load pair, and add the PowerShell-native generator right after: + +```bash +openssl ecparam -name prime256v1 -genkey -noout -out jwt-es256.pem +dotnet user-secrets set "Jwt:PrivateKey" "(Get-Content -Raw jwt-es256.pem)" --project src/PriceNegotiationApp.Api +``` + +```powershell +$ec = [System.Security.Cryptography.ECDsa]::Create([System.Security.Cryptography.ECCurve]::NamedCurves.nistP256) +[IO.File]::WriteAllText("$PWD/jwt-es256.pem", $ec.ExportPkcs8PrivateKeyPem()) +``` + +For docker-compose, document escaping newlines for a single-line `.env` value: + +```powershell +$env:JWT_PRIVATE_KEY = ((Get-Content -Raw jwt-es256.pem) -replace "`r?`n", "\n") +``` + +Update the Configuration table row: `Jwt:Issuer` / `Jwt:Audience` / `Jwt:PrivateKey` (ES256 PKCS#8 PEM, ≥ P-256) / `Jwt:ExpiryMinutes`. Also refresh the Stack table's Identity row wording to "ASP.NET Core Identity + JWT Bearer (ES256, strict issuer/audience/lifetime validation)". + +- [ ] **Step 7: Point the integration fixture at an ephemeral key** + +`IntegrationTestFactory.cs` — replace the `Jwt:SecretKey` setting: + +```csharp + private static readonly string SigningPem = CreateSigningPem(); + + private static string CreateSigningPem() + { + using var ecdsa = System.Security.Cryptography.ECDsa.Create( + System.Security.Cryptography.ECCurve.NamedCurves.nistP256); + return ecdsa.ExportPkcs8PrivateKeyPem(); + } +``` + +and inside `ConfigureWebHost`: + +```csharp + builder.UseSetting("Jwt:PrivateKey", SigningPem); +``` + +Search for stragglers: `rg "SecretKey"` — remaining hits must only be inside this plan/spec docs, or in `JwtManagerShould` being rewritten next. + +- [ ] **Step 8: Rewrite the unit tests** + +Full content of `tests/PriceNegotiationApp.Modules.Identity.Tests/JwtManagerShould.cs`: + +```csharp +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; +using PriceNegotiationApp.Modules.Identity.Features.Auth; +using PriceNegotiationApp.TestKit; +using Shouldly; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text.Json; +using Xunit; + +namespace PriceNegotiationApp.Modules.Identity.Tests; + +public class JwtManagerShould +{ + private sealed class FixedTimeProvider : TimeProvider + { + public override DateTimeOffset GetUtcNow() => new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); + } + + private static (JwtManager Manager, EcSigningKey Key) BuildSut() + { + using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256); + var options = Options.Create(new JwtOptions + { + Issuer = "test-issuer", + Audience = "test-audience", + PrivateKey = ecdsa.ExportPkcs8PrivateKeyPem(), + ExpiryMinutes = 30, + }); + var key = new EcSigningKey(options); + return (new JwtManager(key, new FixedTimeProvider()), key); + } + + private static TokenValidationParameters Parameters(EcSigningKey key) => new() + { + ValidateIssuer = true, + ValidIssuer = "test-issuer", + ValidateAudience = true, + ValidAudience = "test-audience", + ValidateIssuerSigningKey = true, + IssuerSigningKey = key.PublicJwk, + ValidAlgorithms = [EcSigningKey.Algorithm], + ValidateLifetime = true, + ClockSkew = TimeSpan.Zero, + }; + + [Fact] + public void Generate_es256_token_with_kid_email_role_and_expiry() + { + var email = Fuzz.Email(); + var (sut, _) = BuildSut(); + + var (token, expiresAtUtc) = sut.Generate(Guid.NewGuid(), email, ["Customer"]); + + var parts = token.Split('.'); + parts.Length.ShouldBe(3); + var header = DecodeJson(parts[0]); + header.GetProperty("alg").GetString().ShouldBe("ES256"); + header.GetProperty("kid").GetString().ShouldNotBeNullOrEmpty(); + DecodeJson(parts[1]).ShouldContain(email); + var expected = new FixedTimeProvider().GetUtcNow().AddMinutes(30); + (expiresAtUtc - expected).Duration().ShouldBeLessThan(TimeSpan.FromSeconds(1)); + } + + [Fact] + public void Token_validates_against_the_published_public_key() + { + var userId = Guid.NewGuid(); + var (sut, key) = BuildSut(); + var (token, _) = sut.Generate(userId, Fuzz.Email(), ["Staff"]); + + var principal = new JwtSecurityTokenHandler().ValidateToken(token, Parameters(key), out _); + + principal!.FindFirst(ClaimTypes.NameIdentifier)!.Value.ShouldBe(userId.ToString()); + principal.FindFirst(ClaimTypes.Role)!.Value.ShouldBe("Staff"); + } + + [Fact] + public void Token_signed_by_a_different_key_is_rejected() + { + var (sut, _) = BuildSut(); + var (_, stranger) = BuildSut(); + var (token, _) = sut.Generate(Guid.NewGuid(), Fuzz.Email(), []); + + Should.Throw( + () => new JwtSecurityTokenHandler().ValidateToken(token, Parameters(stranger), out _)); + } + + private static JsonElement DecodeJson(string base64Url) + { + var padded = base64Url.Replace('-', '+').Replace('_', '/'); + switch (padded.Length % 4) + { + case 2: padded += "=="; break; + case 3: padded += "="; break; + } + + return JsonSerializer.Deserialize(Convert.FromBase64String(padded)); + } +} +``` + +New `tests/PriceNegotiationApp.Modules.Identity.Tests/JwtOptionsValidatorShould.cs`: + +```csharp +using Microsoft.Extensions.Options; +using PriceNegotiationApp.Modules.Identity.Features.Auth; +using Shouldly; +using Xunit; + +namespace PriceNegotiationApp.Modules.Identity.Tests; + +public class JwtOptionsValidatorShould +{ + private readonly JwtOptionsValidator _sut = new(); + + [Fact] + public void Accept_a_complete_configuration() + { + var result = _sut.Validate(null, new JwtOptions + { + Issuer = Fuzz.NewFaker().Internet.DomainName(), + Audience = "price-negotiation-api", + PrivateKey = "not-parsed-here", + ExpiryMinutes = 30, + }); + + result.Succeeded.ShouldBeTrue(); + } + + [Fact] + public void Reject_blank_private_key() + { + var result = _sut.Validate(null, new JwtOptions + { + Issuer = "i", + Audience = "a", + PrivateKey = " ", + ExpiryMinutes = 30, + }); + + result.Failed.ShouldBeTrue(); + result.Failures.ShouldContain(f => f.Contains("PrivateKey")); + } + + [Fact] + public void Reject_non_positive_expiry() + { + var result = _sut.Validate(null, new JwtOptions + { + Issuer = "i", + Audience = "a", + PrivateKey = "pem", + ExpiryMinutes = 0, + }); + + result.Failed.ShouldBeTrue(); + } +} +``` + +New `tests/PriceNegotiationApp.IntegrationTests/JwksShould.cs`: + +```csharp +using System.Net; +using System.Text.Json; +using PriceNegotiationApp.IntegrationTests.Support; +using Shouldly; +using Xunit; + +namespace PriceNegotiationApp.IntegrationTests; + +[Collection(ApiCollection.Name)] +public class JwksShould(IntegrationTestFixture fixture) +{ + [Fact] + public async Task Publish_only_public_material_matching_issued_tokens() + { + var session = await fixture.CreateUserAsync(); + + var header = DecodeJson(session.Token.Split('.')[0]); + header.GetProperty("alg").GetString().ShouldBe("ES256"); + var kid = header.GetProperty("kid").GetString(); + + var response = await fixture.Anonymous.GetAsync("/.well-known/jwks.json", TestContext.Current.CancellationToken); + + response.StatusCode.ShouldBe(HttpStatusCode.OK); + var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + body.ShouldContain(kid); + body.ShouldContain("\"crv\":\"P-256\""); + body.ShouldNotContain("\"d\""); + body.ShouldNotContain("PRIVATE"); + } + + private static JsonElement DecodeJson(string base64Url) + { + var padded = base64Url.Replace('-', '+').Replace('_', '/'); + switch (padded.Length % 4) + { + case 2: padded += "=="; break; + case 3: padded += "="; break; + } + + return JsonSerializer.Deserialize(Convert.FromBase64String(padded)); + } +} +``` + +- [ ] **Step 9: Run validation** + +```bash +dotnet test --project tests/PriceNegotiationApp.Modules.Identity.Tests +dotnet test --project tests/PriceNegotiationApp.IntegrationTests +``` + +If `ComputeJwkThumbprint` or `ExportPkcs8PrivateKeyPem` fail to compile, confirm the transitive `Microsoft.IdentityModel.*` 8.x packages resolve; do not add new package references unless compilation proves one missing. + +- [ ] **Step 10: Commit** + +```bash +git add -A src tests docker-compose.yml .env.example README.md +git commit -m "feat(auth): sign tokens with ES256 and publish the public key via JWKS" +``` + +--- + +### Task 6: Supply-chain scan, findings report, deliberate trade-offs (spec B4) + +**Files:** +- Modify: `docs/superpowers/specs/2026-08-26-security-review-design.md` (append executed findings table + finalized trade-offs) +- Possibly modify: `README.md` security notes (rate-limit posture sentence) + +**Interfaces:** +- Consumes: everything implemented in Tasks 1–5. +- Produces: the review artifact — findings table with severities/evidence/status, trade-off ledger. + +- [ ] **Step 1: Run the supply-chain scan** + +```bash +dotnet list PriceNegotiationApp.slnx package --vulnerable --include-transitive +``` + +Record the outcome. Any hits: triage here (pin/bump in `Directory.Packages.props` within this task) or record as accepted risk with rationale in the spec appendix. Also skim `.github/dependabot.yml` covers both nuget and actions ecosystems (fix the config if it does not). + +- [ ] **Step 2: Document rate-limit posture** + +Append two sentences to the README Configuration section noting: auth endpoints are fixed-window limited per IP (default 30/min); the app expects direct exposure (compose) — put forwarded-header handling in front if deployed behind a reverse proxy. + +- [ ] **Step 3: Append the findings report to the spec** + +Append to `docs/superpowers/specs/2026-08-26-security-review-design.md`: + +```markdown +## Executed findings (2026-08-26) + +| ID | Severity | Finding | Evidence | Resolution | +|---|---|---|---|---| +| F1 | Medium | Anonymous `/health/ready` leaked unhealthy-check exception text | `ReadyHealthReport.WriteAsync` | Fixed: detail logged server-side only; body always `{status,durationMs}` | +| F2 | Low | Login distinguished locked accounts (`account_locked`) — enumeration oracle | `LoginUserHandler` | Fixed: every auth failure returns `invalid_credentials` | +| F3 | Medium | Seed credentials accepted 8-char passwords; examples shipped `Admin123!` | `SeedingOptionsValidator`, `.env.example` | Fixed: ≥12 chars mixed classes; placeholders fail fast if deployed | +| F4 | Info | Per-IP fixed-window limit assumes direct exposure (no forwarded headers) | `AddRateLimiter` | Accepted: documented proxy posture in README | +| F5 | Low | Duplicate JWT config contract skipped expiry validation | `Api/Extensions/JwtSettings.cs` | Fixed: deleted; bearer binds module-validated `JwtOptions` | +| F6 | Info | No limiter on authenticated writes | endpoint map | Accepted: authenticated abuse is rate-limited upstream in real deployments; revisit with real traffic profile | +| F7 | High | Shared HMAC secret made every replica a token minter; blocked issuance/validation split | `JwtManager`, bearer setup | Fixed: ES256 key pair, `kid`, JWKS publication | + +## Deliberate trade-offs + +- Short-lived access tokens only; no refresh/revocation machinery until multi-device sessions exist. +- Every replica holds the signing key because login runs everywhere; JWKS is the extraction path when issuance centralizes. +- Manual key rotation supported (`kid` in JWKS, validators trust the published set); no automated rotation. +- Registration conflict responses still confirm existing emails (standard UX trade-off); the login path itself is uniform. Timing side-channel between unknown-email and wrong-password paths remains (one PBKDF2 evaluation) — acceptable at portfolio threat level. +- Readiness failure detail is available in server logs, not the anonymous HTTP body. +``` + +Fill severity/status cells from actual execution observations; add rows for anything discovered mid-implementation. + +- [ ] **Step 4: Full-suite gate** + +```bash +dotnet format --verify-no-changes +dotnet build -c Release +dotnet test --solution PriceNegotiationApp.slnx -c Release +``` + +All three must pass clean (CI parity). + +- [ ] **Step 5: Commit** + +```bash +git add docs README.md Directory.Packages.props +git commit -m "docs(security): record review findings, supply-chain scan, trade-offs" +``` diff --git a/docs/superpowers/plans/2026-08-26-transaction-management-hardening.md b/docs/superpowers/plans/2026-08-26-transaction-management-hardening.md new file mode 100644 index 0000000..5cba776 --- /dev/null +++ b/docs/superpowers/plans/2026-08-26-transaction-management-hardening.md @@ -0,0 +1,423 @@ +# Transaction Management Hardening Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make concurrency conflicts return 409 instead of 500, make the create-negotiation flow one atomic commit, and mechanically pin who may commit — per the approved spec `docs/superpowers/specs/2026-08-26-transaction-management-review-design.md`. + +**Architecture:** All fixes ride existing seams: the global exception mapper already owns status translation, handlers already own their single `SaveChangesAsync`, and ArchUnit-style doctrine tests already pin architectural laws. No MediatR, no pipeline behavior, no schema changes. + +**Tech Stack:** .NET 10 / EF Core 10 + Npgsql (xmin system-column tokens already configured), xUnit v3 + Shouldly, Testcontainers PostgreSQL, existing `WebApplicationFactory` fixture. + +## Global Constraints + +- `TreatWarningsAsErrors=true`; zero warnings allowed. +- Endpoints stay transport-only; handlers own persistence (existing ArchUnit laws must keep passing). +- Stable machine-readable `code` extension on every ProblemDetails response. +- Module domain/persistence types are `internal` — integration tests must go through HTTP or EF Core metadata APIs (`EF.Property`, `Entry().Property()`), never internals. +- Integration tests need Docker (Testcontainers); plain unit facts live in the same projects without `[Collection]`. +- Full gate at the end: `dotnet format --verify-no-changes`, Release build, whole solution green. + +--- + +### Task 1: Map concurrency conflicts to 409 (spec F-G1, part 1) + +**Files:** +- Modify: `src/PriceNegotiationApp.SharedKernel/ErrorCodes.cs:7` +- Modify: `src/PriceNegotiationApp.Api/GlobalExceptionHandler.cs:30-50` +- Create: `tests/PriceNegotiationApp.IntegrationTests/GlobalExceptionHandlerShould.cs` + +**Interfaces:** +- Produces: every `DbUpdateConcurrencyException` escaping a handler becomes HTTP 409, title "Resource changed meanwhile", `code = "concurrency_conflict"` (constant `ErrorCodes.ConcurrencyConflict`, renamed value — it currently reads `"conflict"` and has zero usages). + +- [ ] **Step 1: Rename the error-code value** + +In `src/PriceNegotiationApp.SharedKernel/ErrorCodes.cs` change: + +```csharp + public const string ConcurrencyConflict = "conflict"; +``` + +to: + +```csharp + public const string ConcurrencyConflict = "concurrency_conflict"; +``` + +- [ ] **Step 2: Add the mapper case** + +In `GlobalExceptionHandler.TryHandleAsync`, add `using Microsoft.EntityFrameworkCore;` and insert this arm into the switch immediately above the `NotFoundException` line: + +```csharp + // 409 — another writer committed this aggregate first (xmin token fired) + DbUpdateConcurrencyException => (StatusCodes.Status409Conflict, "Resource changed meanwhile", ErrorCodes.ConcurrencyConflict), +``` + +- [ ] **Step 3: Unit-test the mapping** + +Create `tests/PriceNegotiationApp.IntegrationTests/GlobalExceptionHandlerShould.cs`: + +```csharp +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Logging.Abstractions; +using PriceNegotiationApp.Api; +using Shouldly; +using System.Text.Json; +using Xunit; + +namespace PriceNegotiationApp.IntegrationTests; + +// Plain unit facts over the exception mapper; no Docker container required. +public class GlobalExceptionHandlerShould +{ + [Fact] + public async Task Map_concurrency_conflicts_to_409_with_stable_code() + { + var (status, code) = await HandleAsync(new DbUpdateConcurrencyException("xmin race")); + + status.ShouldBe(StatusCodes.Status409Conflict); + code.ShouldBe("concurrency_conflict"); + } + + [Fact] + public async Task Keep_unknown_exceptions_on_the_internal_error_fallback() + { + var (status, code) = await HandleAsync(new InvalidOperationException("boom")); + + status.ShouldBe(StatusCodes.Status500InternalServerError); + code.ShouldBe("internal_error"); + } + + private static async Task<(int Status, string Code)> HandleAsync(Exception exception) + { + var services = new ServiceCollection().AddProblemDetails().BuildServiceProvider(); + var sut = new GlobalExceptionHandler( + services.GetRequiredService(), + new TestEnvironment(), + NullLogger.Instance); + + var context = new DefaultHttpContext(); + context.Response.Body = new MemoryStream(); + + await sut.TryHandleAsync(context, exception, TestContext.Current.CancellationToken); + + context.Response.Body.Position = 0; + var body = await new StreamReader(context.Response.Body).ReadToEndAsync(TestContext.Current.CancellationToken); + using var document = JsonDocument.Parse(body); + return (context.Response.StatusCode, document.RootElement.GetProperty("code").GetString()!); + } + + private sealed class TestEnvironment : IHostEnvironment + { + public string ApplicationName { get; set; } = "tests"; + public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider(); + public string ContentRootPath { get; set; } = Directory.GetCurrentDirectory(); + public string EnvironmentName { get; set; } = "Testing"; + } +} +``` + +- [ ] **Step 4: Run validation** + +```bash +dotnet test --project tests/PriceNegotiationApp.IntegrationTests --filter GlobalExceptionHandler +``` + +- [ ] **Step 5: Commit** + +```bash +git add src/PriceNegotiationApp.SharedKernel/ErrorCodes.cs src/PriceNegotiationApp.Api/GlobalExceptionHandler.cs tests/PriceNegotiationApp.IntegrationTests/GlobalExceptionHandlerShould.cs +git commit -m "fix(api): map concurrency conflicts to 409 problem details" +``` + +--- + +### Task 2: Prove the xmin token fires end-to-end (spec F-G1, part 2) + +Module internals are invisible to the integration-test assembly, so both writers mutate their tracked entity through the EF metadata API (`Entry().Property(...).CurrentValue`) — provider-typed values, no domain calls needed. + +**Files:** +- Create: `tests/PriceNegotiationApp.IntegrationTests/ConcurrencyShould.cs` + +**Interfaces:** +- Consumes: `IntegrationTestFixture.Factory.Services` (root `IServiceProvider` of the hosted app, for creating real configured scopes), HTTP endpoints `/api/v1/products`, `/api/v1/negotiations`. +- Produces: regression proof that two concurrent `SaveChangesAsync` calls on one negotiation yield `DbUpdateConcurrencyException` from the loser. + +- [ ] **Step 1: Write the race test** + +Create `tests/PriceNegotiationApp.IntegrationTests/ConcurrencyShould.cs`: + +```csharp +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using PriceNegotiationApp.IntegrationTests.Support; +using PriceNegotiationApp.TestKit; +using Shouldly; +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Xunit; + +namespace PriceNegotiationApp.IntegrationTests; + +[Collection(ApiCollection.Name)] +public class ConcurrencyShould(IntegrationTestFixture fixture) +{ + private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web); + + [Fact] + public async Task Second_writer_of_one_negotiation_gets_a_concurrency_exception() + { + var negotiationId = await OpenNegotiationAsync(); + + await using var scope1 = fixture.Factory.Services.CreateScope(); + await using var scope2 = fixture.Factory.Services.CreateScope(); + var db1 = scope1.ServiceProvider.GetRequiredService(); + var db2 = scope2.ServiceProvider.GetRequiredService(); + + var first = await db1.Negotiations.SingleAsync( + n => EF.Property(n, "Id") == negotiationId, TestContext.Current.CancellationToken); + var second = await db2.Negotiations.SingleAsync( + n => EF.Property(n, "Id") == negotiationId, TestContext.Current.CancellationToken); + + // Both writers loaded the same row; each mutates its tracked copy. + db1.Entry(first).Property("CurrentOffer").CurrentValue = 70m; + db2.Entry(second).Property("CurrentOffer").CurrentValue = 71m; + + await db1.SaveChangesAsync(TestContext.Current.CancellationToken); + + Should.Throw( + () => db2.SaveChangesAsync(TestContext.Current.CancellationToken)); + + // The winner's state survives untouched. + await using var verify = fixture.Factory.Services.CreateScope(); + var stored = await verify.ServiceProvider + .GetRequiredService() + .Negotiations.AsNoTracking() + .SingleAsync(n => EF.Property(n, "Id") == negotiationId, TestContext.Current.CancellationToken); + verify.ServiceProvider.GetRequiredService() + .Entry(stored).Property("CurrentOffer").CurrentValue.ShouldBe(70m); + } + + private async Task OpenNegotiationAsync() + { + var staff = await fixture.LoginAsStaffAsync(); + var createProduct = await staff.Client.PostAsJsonAsync("/api/v1/products", + new { name = Fuzz.NewFaker().ProductName(), price = 100m }, TestContext.Current.CancellationToken); + createProduct.StatusCode.ShouldBe(HttpStatusCode.Created); + var product = await createProduct.Content.ReadFromJsonAsync(Json, TestContext.Current.CancellationToken); + + var customer = await fixture.CreateUserAsync(); + var open = await customer.Client.PostAsJsonAsync("/api/v1/negotiations", + new { productId = product!.Id, proposedPrice = 80m }, TestContext.Current.CancellationToken); + open.StatusCode.ShouldBe(HttpStatusCode.Created); + var created = await open.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + return created.GetProperty("id").GetGuid(); + } +} +``` + +Note: `NegotiationsDbContext` is `internal`; the fully-qualified references compile because modules grant `InternalsVisibleTo` only to the composition root and their own test projects — if the compiler rejects them from this assembly, fall back to resolving the context through the interface-free route used here but declared as follows: extract `var dbType = Type.GetType("PriceNegotiationApp.Modules.Negotiations.Persistence.NegotiationsDbContext, PriceNegotiationApp.Modules.Negotiations")!` and resolve via `scope.ServiceProvider.GetRequiredService(dbType)` casting to `DbContext` (add `using Microsoft.EntityFrameworkCore;`). Prefer the direct generic form; use reflection only if IVT denies access. + +- [ ] **Step 2: Run validation** + +```bash +dotnet test --project tests/PriceNegotiationApp.IntegrationTests --filter ConcurrencyShould +``` + +- [ ] **Step 3: Commit** + +```bash +git add tests/PriceNegotiationApp.IntegrationTests/ConcurrencyShould.cs +git commit -m "test(integration): prove xmin token rejects concurrent negotiation writers" +``` + +--- + +### Task 3: One atomic commit for create-negotiation (spec F-G2) + +**Files:** +- Modify: `src/PriceNegotiationApp.Modules.Negotiations/Features/Negotiations/CreateNegotiationHandler.cs:14-38` + +**Interfaces:** +- Consumes: unchanged `NegotiationAccess.GetOrCreateCustomerIdAsync` (its internal save joins the surrounding transaction), unchanged `DbWriteGuard.SaveOrConflictAsync`. + +- [ ] **Step 1: Wrap provisioning + insert in one explicit transaction** + +Replace the body of `HandleAsync` with: + +```csharp + var snapshot = await products.GetAsync(command.ProductId, ct) + ?? throw new NotFoundException("Product", command.ProductId); + + if (await NegotiationAccess.FindOpenAsync(db, snapshot.ProductId, caller.UserId, ct) is not null) + { + throw new ConflictException(NegotiationErrorCodes.NegotiationAlreadyOpen, + "An open negotiation already exists for this product."); + } + + // Provisioning the customer row and inserting the negotiation commit together: + // a failed insert must not strand a permanent customer row (one commit point). + await using var tx = await db.Database.BeginTransactionAsync(ct); + var customerId = await NegotiationAccess.GetOrCreateCustomerIdAsync(db, caller.UserId, ct); + var negotiation = Negotiation.Start(customerId, snapshot.ProductId, snapshot.Price, + command.ProposedPrice, clock.GetUtcNow(), policy); + await db.Negotiations.AddAsync(negotiation, ct); + + // The partial unique index is the real guard; a race that slipped past the + // pre-check above surfaces here as a 409 instead of a 500. + await db.SaveOrConflictAsync( + _ => new ConflictException(NegotiationErrorCodes.NegotiationAlreadyOpen, + "An open negotiation already exists for this product."), ct); + await tx.CommitAsync(ct); + + return NegotiationResponses.ToResponse(negotiation); +``` + +- [ ] **Step 2: Run validation** + +```bash +dotnet test --project tests/PriceNegotiationApp.IntegrationTests --filter FullyQualifiedName~NegotiationsShould|FullyQualifiedName~ConcurrencyShould +``` + +Creation, conflict, lifecycle, and the new race test all exercise this path. + +- [ ] **Step 3: Commit** + +```bash +git add src/PriceNegotiationApp.Modules.Negotiations/Features/Negotiations/CreateNegotiationHandler.cs +git commit -m "fix(negotiations): make create flow one atomic commit" +``` + +--- + +### Task 4: Pin who may commit — architecture test (spec F-G3) + +A source-level doctrine test in the existing architecture-tests project: direct invocations of `SaveChangesAsync` under `src/` are allowed only in `*Handler.cs`, `*SeedingHostedService.cs`, `DbWriteGuard.cs`, and `NegotiationAccess.cs` (the provisioning save inside the create-flow transaction). Deliberately simple and false-positive-free where an ArchUnit method-call graph would be fragile; matches the precedent of `Repository_ceremony_stays_out_of_the_codebase`. + +**Files:** +- Modify: `tests/PriceNegotiationApp.ArchitectureTests/ArchitectureShould.cs` + +**Interfaces:** +- Consumes: nothing from earlier tasks. +- Produces: build-time failure for any future stray commit point. + +- [ ] **Step 1: Add the rule** + +Append to `ArchitectureShould` (namespace usings already present; add none): + +```csharp + [Fact] + public void Only_handlers_seeding_and_the_write_guard_commit_the_unit_of_work() + { + // F-05 doctrine, enforcement side: the single commit point lives in the + // owning handler (or the seeding services / write guard / provisioning + // helper inside the create-flow transaction). Nothing else may flush. + var suffixAllowList = new[] { "Handler.cs", "SeedingHostedService.cs" }; + var nameAllowList = new[] { "DbWriteGuard.cs", "NegotiationAccess.cs" }; + + var offenders = Directory + .EnumerateFiles(Path.Combine(FindRepoRoot(), "src"), "*.cs", SearchOption.AllDirectories) + .Where(path => !path.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}") + && !path.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}")) + .Where(path => File.ReadAllText(path).Contains(".SaveChangesAsync(", StringComparison.Ordinal)) + .Where(path => !suffixAllowList.Any(path.EndsWith) + && !nameAllowList.Contains(Path.GetFileName(path))) + .Select(path => Path.GetRelativePath(FindRepoRoot(), path)) + .ToList(); + + offenders.ShouldBeEmpty( + "SaveChangesAsync commits belong to feature handlers, seeding services, " + + "DbWriteGuard, or NegotiationAccess provisioning"); + } + + private static string FindRepoRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "PriceNegotiationApp.slnx"))) + { + directory = directory.Parent; + } + + return directory!.FullName; + } +``` + +(`Directory`, `File`, `Path`, `StringComparison`, `AppContext` come from `System.IO`/base class library via ImplicitUsings.) + +- [ ] **Step 2: Run validation** + +```bash +dotnet test --project tests/PriceNegotiationApp.ArchitectureTests +``` + +All rules including the new one must pass against the tree produced by Tasks 1–3. + +- [ ] **Step 3: Commit** + +```bash +git add tests/PriceNegotiationApp.ArchitectureTests/ArchitectureShould.cs +git commit -m "test(architecture): pin SaveChangesAsync callers to handlers, seeding, and the write guard" +``` + +--- + +### Task 5: Docs tell the truth about this repo (spec F-G4) + full gate + +**Files:** +- Modify: `docs/transaction-management-patterns.md:627-634` ("Recommendation for this repo") +- Modify: `README.md:15` (Persistence stack-table row) + +- [ ] **Step 1: Replace the transplanted recommendation** + +Replace everything from `### Recommendation for this repo` to end of file with: + +```markdown +### Recommendation for this repo (2026-08-26 audit) + +This codebase implements Option 3 with Option 5 deliberately deferred: + +- Three module-owned scoped `DbContext`s (Identity, Catalog, Negotiations); one commit + point per use case, owned by that use case's `*Handler`; cross-module reads only via + the `IProductPriceProvider` port. +- Client-generated GUIDv7 keys everywhere, so flows fit one flush; the single + multi-save flow (`CreateNegotiationHandler`) wraps provisioning + insert in one + explicit transaction (Case B above). +- `xmin` optimistic tokens sit on both write aggregates; conflicts surface as + `DbUpdateConcurrencyException` mapped to HTTP 409 (`concurrency_conflict`). +- Unique-index races translate to 409 through `DbWriteGuard.SaveOrConflictAsync`. +- No MediatR pipeline behavior (Option 4) by design: handlers own persistence, and the + architecture test `Only_handlers_seeding_and_the_write_guard_commit_the_unit_of_work` + pins who may call `SaveChangesAsync`. +- Outbox/events arrive with the first real subscriber (ddd-audit spec §F-04); today's + cross-module edge is a synchronous read, not a workflow. +``` + +- [ ] **Step 2: Update the README claim** + +Change the Persistence row in the Stack table: + +```markdown +| Persistence | EF Core 10 + Npgsql (PostgreSQL 17), snake_case schema, xmin concurrency (conflicts surface as 409) | +``` + +- [ ] **Step 3: Run the full gate** + +```bash +dotnet format --verify-no-changes +dotnet build -c Release +dotnet test --solution PriceNegotiationApp.slnx -c Release +``` + +All three clean. + +- [ ] **Step 4: Commit** + +```bash +git add docs/transaction-management-patterns.md README.md +git commit -m "docs: reconcile transaction pattern guidance with this codebase" +``` diff --git a/docs/superpowers/plans/2026-08-30-clean-architecture-split.md b/docs/superpowers/plans/2026-08-30-clean-architecture-split.md new file mode 100644 index 0000000..5c8f81a --- /dev/null +++ b/docs/superpowers/plans/2026-08-30-clean-architecture-split.md @@ -0,0 +1,614 @@ +# Clean Architecture Split — Multi-Project per Module + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Split each module into 4 projects (Domain, Application, Infrastructure, Contracts) to enforce Clean Architecture boundaries at compile time. + +**Architecture:** Each module's files are distributed across 4 projects. Compile-time enforcement via project references. ArchUnitNET tests updated to reflect new project structure. + +**Tech Stack:** .NET 10, .slnx solution format, ArchUnitNET, FluentValidation + +## Global Constraints + +- Assembly names follow pattern: `PriceNegotiationApp.Modules.{Module}.{Layer}` +- All `InternalsVisibleTo` must be updated to reference new assembly names +- All `using` directives must be updated when namespaces change +- Migration files stay in Infrastructure (they depend on DbContext) +- Build must pass with 0 errors, 0 warnings before committing +- All 124 tests must pass + +## File Structure + +### New Projects (12 total) + +| Project | Assembly Name | +|---------|---------------| +| `PriceNegotiationApp.Modules.Catalog.Domain` | `PriceNegotiationApp.Modules.Catalog.Domain` | +| `PriceNegotiationApp.Modules.Catalog.Application` | `PriceNegotiationApp.Modules.Catalog.Application` | +| `PriceNegotiationApp.Modules.Catalog.Infrastructure` | `PriceNegotiationApp.Modules.Catalog.Infrastructure` | +| `PriceNegotiationApp.Modules.Catalog.Contracts` | `PriceNegotiationApp.Modules.Catalog.Contracts` | +| `PriceNegotiationApp.Modules.Negotiations.Domain` | `PriceNegotiationApp.Modules.Negotiations.Domain` | +| `PriceNegotiationApp.Modules.Negotiations.Application` | `PriceNegotiationApp.Modules.Negotiations.Application` | +| `PriceNegotiationApp.Modules.Negotiations.Infrastructure` | `PriceNegotiationApp.Modules.Negotiations.Infrastructure` | +| `PriceNegotiationApp.Modules.Negotiations.Contracts` | `PriceNegotiationApp.Modules.Negotiations.Contracts` | +| `PriceNegotiationApp.Modules.Identity.Domain` | `PriceNegotiationApp.Modules.Identity.Domain` | +| `PriceNegotiationApp.Modules.Identity.Application` | `PriceNegotiationApp.Modules.Identity.Application` | +| `PriceNegotiationApp.Modules.Identity.Infrastructure` | `PriceNegotiationApp.Modules.Identity.Infrastructure` | +| `PriceNegotiationApp.Modules.Identity.Contracts` | `PriceNegotiationApp.Modules.Identity.Contracts` | + +### Files to Delete (after split) + +| File | Reason | +|------|--------| +| `src/Modules/PriceNegotiationApp.Modules.Catalog/` | Replaced by 4 projects | +| `src/Modules/PriceNegotiationApp.Modules.Negotiations/` | Replaced by 4 projects | +| `src/Modules/PriceNegotiationApp.Modules.Identity/` | Replaced by 4 projects | + +--- + +### Task 1: Create Catalog module projects and move files + +**Files to create:** +- `src/Modules/PriceNegotiationApp.Modules.Catalog.Domain/` +- `src/Modules/PriceNegotiationApp.Modules.Catalog.Application/` +- `src/Modules/PriceNegotiationApp.Modules.Catalog.Infrastructure/` +- `src/Modules/PriceNegotiationApp.Modules.Catalog.Contracts/` + +**Steps:** + +- [ ] **Step 1: Create Catalog.Domain project** + +```bash +mkdir -p src/Modules/PriceNegotiationApp.Modules.Catalog.Domain +``` + +Create `PriceNegotiationApp.Modules.Catalog.Domain.csproj`: +```xml + + + + + + + + +``` + +Move files from `Catalog/Domain/` to `Catalog.Domain/`: +- `Product.cs` +- `ProductId.cs` +- `Price.cs` + +- [ ] **Step 2: Create Catalog.Contracts project** + +```bash +mkdir -p src/Modules/PriceNegotiationApp.Modules.Catalog.Contracts +``` + +Create `PriceNegotiationApp.Modules.Catalog.Contracts.csproj`: +```xml + + + + + +``` + +Move files: +- `Ports/IProductPriceProvider.cs` → `Catalog.Contracts/IProductPriceProvider.cs` + +- [ ] **Step 3: Create Catalog.Application project** + +```bash +mkdir -p src/Modules/PriceNegotiationApp.Modules.Catalog.Application +``` + +Create `PriceNegotiationApp.Modules.Catalog.Application.csproj`: +```xml + + + + + + + + + + +``` + +Move files: +- `Features/Products/ProductModels.cs` → `Catalog.Application/ProductModels.cs` +- `Features/Products/ProductQuery.cs` → `Catalog.Application/ProductQuery.cs` +- `Features/Products/Create/*` → `Catalog.Application/Create/*` +- `Features/Products/Update/*` → `Catalog.Application/Update/*` +- `Features/Products/Delete/*` → `Catalog.Application/Delete/*` +- `Features/Products/Get/*` → `Catalog.Application/Get/*` +- `Features/Products/List/*` → `Catalog.Application/List/*` + +- [ ] **Step 4: Create Catalog.Infrastructure project** + +```bash +mkdir -p src/Modules/PriceNegotiationApp.Modules.Catalog.Infrastructure +``` + +Create `PriceNegotiationApp.Modules.Catalog.Infrastructure.csproj`: +```xml + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + +``` + +Move files: +- `Persistence/*` → `Catalog.Infrastructure/Persistence/*` +- `Adapters/ProductPriceProvider.cs` → `Catalog.Infrastructure/ProductPriceProvider.cs` +- `Seeding/*` → `Catalog.Infrastructure/Seeding/*` +- `CatalogModule.cs` → `Catalog.Infrastructure/CatalogModule.cs` + +- [ ] **Step 5: Update namespaces in Catalog files** + +Update all `namespace` declarations and `using` directives to reflect new project namespaces. + +- [ ] **Step 6: Delete old Catalog project** + +```bash +rm -rf src/Modules/PriceNegotiationApp.Modules.Catalog +``` + +- [ ] **Step 7: Build and verify** + +```bash +dotnet build +``` + +- [ ] **Step 8: Commit** + +```bash +git add -A +git commit -m "Split Catalog module into Domain/Application/Infrastructure/Contracts projects" +``` + +--- + +### Task 2: Create Negotiations module projects and move files + +**Files to create:** +- `src/Modules/PriceNegotiationApp.Modules.Negotiations.Domain/` +- `src/Modules/PriceNegotiationApp.Modules.Negotiations.Application/` +- `src/Modules/PriceNegotiationApp.Modules.Negotiations.Infrastructure/` +- `src/Modules/PriceNegotiationApp.Modules.Negotiations.Contracts/` + +**Steps:** + +- [ ] **Step 1: Create Negotiations.Domain project** + +Create `PriceNegotiationApp.Modules.Negotiations.Domain.csproj`: +```xml + + + + + + + + +``` + +Move files: +- `Domain/Negotiation.cs`, `NegotiationId.cs`, `NegotiationStatus.cs`, `NegotiationOutcome.cs` +- `Domain/Customer.cs`, `CustomerId.cs` +- `Domain/Price.cs` +- `Domain/INegotiationPolicy.cs`, `DefaultNegotiationPolicy.cs` +- `Domain/ClosedNegotiationException.cs`, `ProposalExceedsLimitException.cs` + +- [ ] **Step 2: Create Negotiations.Contracts project** + +Create `PriceNegotiationApp.Modules.Negotiations.Contracts.csproj`: +```xml + + + + + +``` + +Move files: +- `Features/Negotiations/NegotiationErrorCodes.cs` → `Negotiations.Contracts/NegotiationErrorCodes.cs` + +- [ ] **Step 3: Create Negotiations.Application project** + +Create `PriceNegotiationApp.Modules.Negotiations.Application.csproj`: +```xml + + + + + + + + + + + + +``` + +Move files: +- `Features/Negotiations/NegotiationModels.cs` → `Negotiations.Application/NegotiationModels.cs` +- `Features/Negotiations/Create/*` → `Negotiations.Application/Create/*` +- `Features/Negotiations/CounterPropose/*` → `Negotiations.Application/CounterPropose/*` +- `Features/Negotiations/Accept/*` → `Negotiations.Application/Accept/*` +- `Features/Negotiations/RejectCurrentOffer/*` → `Negotiations.Application/RejectCurrentOffer/*` +- `Features/Negotiations/Withdraw/*` → `Negotiations.Application/Withdraw/*` +- `Features/Negotiations/Get/*` → `Negotiations.Application/Get/*` +- `Features/Negotiations/List/*` → `Negotiations.Application/List/*` +- `Features/Negotiations/ListMine/*` → `Negotiations.Application/ListMine/*` + +- [ ] **Step 4: Create Negotiations.Infrastructure project** + +Create `PriceNegotiationApp.Modules.Negotiations.Infrastructure.csproj`: +```xml + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + +``` + +Move files: +- `Persistence/*` → `Negotiations.Infrastructure/Persistence/*` +- `Features/Negotiations/NegotiationAccess.cs` → `Negotiations.Infrastructure/NegotiationAccess.cs` +- `NegotiationsModule.cs` → `Negotiations.Infrastructure/NegotiationsModule.cs` + +- [ ] **Step 5: Update namespaces in Negotiations files** + +- [ ] **Step 6: Delete old Negotiations project** + +```bash +rm -rf src/Modules/PriceNegotiationApp.Modules.Negotiations +``` + +- [ ] **Step 7: Build and verify** + +```bash +dotnet build +``` + +- [ ] **Step 8: Commit** + +```bash +git add -A +git commit -m "Split Negotiations module into Domain/Application/Infrastructure/Contracts projects" +``` + +--- + +### Task 3: Create Identity module projects and move files + +**Files to create:** +- `src/Modules/PriceNegotiationApp.Modules.Identity.Domain/` (empty) +- `src/Modules/PriceNegotiationApp.Modules.Identity.Application/` +- `src/Modules/PriceNegotiationApp.Modules.Identity.Infrastructure/` +- `src/Modules/PriceNegotiationApp.Modules.Identity.Contracts/` + +**Steps:** + +- [ ] **Step 1: Create Identity.Domain project (empty)** + +Create `PriceNegotiationApp.Modules.Identity.Domain.csproj`: +```xml + + + + + +``` + +- [ ] **Step 2: Create Identity.Contracts project** + +Create `PriceNegotiationApp.Modules.Identity.Contracts.csproj`: +```xml + + + + + +``` + +Move files: +- `Features/Auth/AuthModels.cs` → `Identity.Contracts/AuthModels.cs` +- `Features/Auth/IdentityErrorCodes.cs` → `Identity.Contracts/IdentityErrorCodes.cs` + +- [ ] **Step 3: Create Identity.Application project** + +Create `PriceNegotiationApp.Modules.Identity.Application.csproj`: +```xml + + + + + + + + + + + +``` + +Move files: +- `Features/Auth/Register/*` → `Identity.Application/Register/*` +- `Features/Auth/Login/*` → `Identity.Application/Login/*` + +- [ ] **Step 4: Create Identity.Infrastructure project** + +Create `PriceNegotiationApp.Modules.Identity.Infrastructure.csproj`: +```xml + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + +``` + +Move files: +- `Persistence/*` → `Identity.Infrastructure/Persistence/*` +- `Features/Auth/JwtManager.cs` → `Identity.Infrastructure/JwtManager.cs` +- `Features/Auth/EcSigningKey.cs` → `Identity.Infrastructure/EcSigningKey.cs` +- `Features/Auth/JwtOptions.cs` → `Identity.Infrastructure/JwtOptions.cs` +- `Features/Auth/JwtOptionsValidator.cs` → `Identity.Infrastructure/JwtOptionsValidator.cs` +- `Seeding/*` → `Identity.Infrastructure/Seeding/*` +- `IdentityModule.cs` → `Identity.Infrastructure/IdentityModule.cs` + +- [ ] **Step 5: Update namespaces in Identity files** + +- [ ] **Step 6: Delete old Identity project** + +```bash +rm -rf src/Modules/PriceNegotiationApp.Modules.Identity +``` + +- [ ] **Step 7: Build and verify** + +```bash +dotnet build +``` + +- [ ] **Step 8: Commit** + +```bash +git add -A +git commit -m "Split Identity module into Domain/Application/Infrastructure/Contracts projects" +``` + +--- + +### Task 4: Update Api project references + +**Files to modify:** +- `src/PriceNegotiationApp.Api/PriceNegotiationApp.Api.csproj` + +**Steps:** + +- [ ] **Step 1: Update Api csproj** + +Replace module references: +```xml + + + + + + + + + + + + +``` + +- [ ] **Step 2: Build and verify** + +```bash +dotnet build +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/PriceNegotiationApp.Api/ +git commit -m "Update Api project references for Clean Architecture split" +``` + +--- + +### Task 5: Update solution file + +**Files to modify:** +- `PriceNegotiationApp.slnx` + +**Steps:** + +- [ ] **Step 1: Update slnx** + +Replace 3 module project entries with 12 new project entries. + +- [ ] **Step 2: Build and verify** + +```bash +dotnet build +``` + +- [ ] **Step 3: Commit** + +```bash +git add PriceNegotiationApp.slnx +git commit -m "Update solution file for Clean Architecture split" +``` + +--- + +### Task 6: Update test project references + +**Files to modify:** +- `tests/PriceNegotiationApp.ArchitectureTests/PriceNegotiationApp.ArchitectureTests.csproj` +- `tests/PriceNegotiationApp.Modules.Catalog.Tests/PriceNegotiationApp.Modules.Catalog.Tests.csproj` +- `tests/PriceNegotiationApp.Modules.Identity.Tests/PriceNegotiationApp.Modules.Identity.Tests.csproj` +- `tests/PriceNegotiationApp.Modules.Negotiations.Tests/PriceNegotiationApp.Modules.Negotiations.Tests.csproj` +- `tests/PriceNegotiationApp.IntegrationTests/PriceNegotiationApp.IntegrationTests.csproj` + +**Steps:** + +- [ ] **Step 1: Update test csproj references** + +Each test project needs references to the appropriate new projects. + +- [ ] **Step 2: Update ArchitectureTests** + +Update project references to point to new Infrastructure and Contracts projects. + +- [ ] **Step 3: Update module test projects** + +Each module test project needs references to the new Application, Infrastructure, and Contracts projects. + +- [ ] **Step 4: Build and verify** + +```bash +dotnet build +``` + +- [ ] **Step 5: Commit** + +```bash +git add tests/ +git commit -m "Update test project references for Clean Architecture split" +``` + +--- + +### Task 7: Update ArchitectureTests + +**Files to modify:** +- `tests/PriceNegotiationApp.ArchitectureTests/ArchitectureShould.cs` + +**Steps:** + +- [ ] **Step 1: Update architecture test references** + +Update namespace references and type references to reflect new assembly names. + +- [ ] **Step 2: Update dependency rule assertions** + +Update ArchUnitNET rules to enforce: +- Domain → nothing +- Application → Domain +- Contracts → Domain +- Infrastructure → Application + Contracts + Domain +- Other modules → only target module's Contracts + +- [ ] **Step 3: Build and run tests** + +```bash +dotnet test +``` + +- [ ] **Step 4: Commit** + +```bash +git add tests/PriceNegotiationApp.ArchitectureTests/ +git commit -m "Update architecture tests for Clean Architecture split" +``` + +--- + +### Task 8: Run full validation + +**Steps:** + +- [ ] **Step 1: Clean and rebuild** + +```bash +dotnet clean +dotnet build +``` + +- [ ] **Step 2: Run all tests** + +```bash +dotnet test +``` + +Expected: All 124 tests pass. + +- [ ] **Step 3: Verify project structure** + +```bash +ls src/Modules/ +``` + +Expected: 12 new projects, no old monolithic module projects. + +- [ ] **Step 4: Final commit (if any fixes needed)** + +```bash +git add -A +git commit -m "Fix any issues from Clean Architecture split" +``` diff --git a/docs/superpowers/plans/2026-08-30-move-endpoints-to-api-layer.md b/docs/superpowers/plans/2026-08-30-move-endpoints-to-api-layer.md new file mode 100644 index 0000000..1864ddb --- /dev/null +++ b/docs/superpowers/plans/2026-08-30-move-endpoints-to-api-layer.md @@ -0,0 +1,872 @@ +# Move Endpoints to API Layer + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Move all HTTP endpoint mapping code from application modules to the API layer, making modules delivery-mechanism agnostic. + +**Architecture:** All 16 individual endpoint files and 3 aggregator files move from `Modules.*/Features/*/` to `Api/Endpoints/`. Modules retain only handlers, DTOs, domain logic, and persistence. The API layer owns the full HTTP contract. + +**Tech Stack:** C# 12, .NET 10, ASP.NET Core Minimal APIs, Meziantou.Analyzer (MA0049) + +## Global Constraints + +- File-scoped namespaces throughout +- `internal` visibility for all module-internal types +- `internal static` for endpoint classes (same assembly, no InternalsVisibleTo needed) +- Endpoint classes use `Endpoint` suffix to satisfy MA0049 +- Follow existing code conventions (no comments unless asked) +- All 11 projects must build with 0 errors and 0 warnings + +--- + +## File Structure + +### Files to Create (API layer) + +``` +PriceNegotiationApp.Api/Endpoints/ + Catalog/ + CreateEndpoint.cs + DeleteEndpoint.cs + GetEndpoint.cs + ListEndpoint.cs + UpdateEndpoint.cs + CatalogEndpoints.cs + Negotiations/ + AcceptEndpoint.cs + CounterProposeEndpoint.cs + CreateEndpoint.cs + GetEndpoint.cs + ListEndpoint.cs + ListMineEndpoint.cs + RejectCurrentOfferEndpoint.cs + WithdrawEndpoint.cs + NegotiationEndpoints.cs + Identity/ + LoginEndpoint.cs + RegisterEndpoint.cs + MeEndpoint.cs + IdentityEndpoints.cs +``` + +### Files to Delete (from modules) + +``` +Modules.Catalog/Features/Products/Create/CreateEndpoint.cs +Modules.Catalog/Features/Products/Delete/DeleteEndpoint.cs +Modules.Catalog/Features/Products/Get/GetEndpoint.cs +Modules.Catalog/Features/Products/List/ListEndpoint.cs +Modules.Catalog/Features/Products/Update/UpdateEndpoint.cs +Modules.Catalog/CatalogEndpoints.cs + +Modules.Negotiations/Features/Negotiations/Accept/AcceptEndpoint.cs +Modules.Negotiations/Features/Negotiations/CounterPropose/CounterProposeEndpoint.cs +Modules.Negotiations/Features/Negotiations/Create/CreateEndpoint.cs +Modules.Negotiations/Features/Negotiations/Get/GetEndpoint.cs +Modules.Negotiations/Features/Negotiations/List/ListEndpoint.cs +Modules.Negotiations/Features/Negotiations/ListMine/ListMineEndpoint.cs +Modules.Negotiations/Features/Negotiations/RejectCurrentOffer/RejectCurrentOfferEndpoint.cs +Modules.Negotiations/Features/Negotiations/Withdraw/WithdrawEndpoint.cs +Modules.Negotiations/NegotiationEndpoints.cs + +Modules.Identity/Features/Auth/Login/LoginEndpoint.cs +Modules.Identity/Features/Auth/Register/RegisterEndpoint.cs +Modules.Identity/Features/Auth/Me/MeEndpoint.cs +Modules.Identity/IdentityEndpoints.cs +``` + +### Files to Modify + +- `Modules.Catalog/PriceNegotiationApp.Modules.Catalog.csproj` — remove `FrameworkReference` +- `Modules.Negotiations/PriceNegotiationApp.Modules.Negotiations.csproj` — remove `FrameworkReference` +- `Modules.Identity/PriceNegotiationApp.Modules.Identity.csproj` — remove `FrameworkReference` +- `Modules.Identity/IdentityModule.cs` — remove unused ASP.NET Core using statements +- `Api/Extensions/PipelineExtensions.cs` — update using statements, remove old aggregator calls + +--- + +## Task 1: Move Catalog endpoints to API layer + +**Files:** +- Create: `src/PriceNegotiationApp.Api/Endpoints/Catalog/CreateEndpoint.cs` +- Create: `src/PriceNegotiationApp.Api/Endpoints/Catalog/DeleteEndpoint.cs` +- Create: `src/PriceNegotiationApp.Api/Endpoints/Catalog/GetEndpoint.cs` +- Create: `src/PriceNegotiationApp.Api/Endpoints/Catalog/ListEndpoint.cs` +- Create: `src/PriceNegotiationApp.Api/Endpoints/Catalog/UpdateEndpoint.cs` +- Create: `src/PriceNegotiationApp.Api/Endpoints/Catalog/CatalogEndpoints.cs` +- Delete: All 5 endpoint files + `CatalogEndpoints.cs` from the module + +**Interfaces:** +- Consumes: `CreateProductHandler`, `DeleteProductHandler`, `GetProductHandler`, `ListProductsHandler`, `UpdateProductHandler` (all `internal sealed` in Catalog module, resolved via DI) +- Consumes: `CreateProductRequest`, `UpdateProductRequest`, `ProductQuery` (internal DTOs in Catalog module) +- Consumes: `UserRoles`, `Policies` (public in SharedKernel) +- Produces: `MapCatalogEndpoints()` extension method (called from `PipelineExtensions.cs`) + +- [ ] **Step 1: Create API endpoint directory** + +```bash +New-Item -ItemType Directory -Force -Path "src/PriceNegotiationApp.Api/Endpoints/Catalog" +``` + +- [ ] **Step 2: Create endpoint files in API layer** + +Each file gets namespace `PriceNegotiationApp.Api.Endpoints.Catalog.*` and adds `using` for the handler/DTO types from the Catalog module. + +`CreateEndpoint.cs`: +```csharp +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using PriceNegotiationApp.Modules.Catalog.Features.Products; +using PriceNegotiationApp.Modules.Catalog.Features.Products.Create; +using PriceNegotiationApp.SharedKernel; + +namespace PriceNegotiationApp.Api.Endpoints.Catalog.Create; + +internal static class CreateEndpoint +{ + internal static void MapCreate(this RouteGroupBuilder group) + { + group.MapPost("/", async (CreateProductRequest request, CreateProductHandler handler, + CancellationToken ct) => + { + var response = await handler.HandleAsync(request, ct); + return TypedResults.CreatedAtRoute(response, "GetProductById", new { id = response.Id }); + }) + .RequireRoles(UserRoles.Admin, UserRoles.Staff); + } +} +``` + +`DeleteEndpoint.cs`: +```csharp +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using PriceNegotiationApp.Modules.Catalog.Features.Products.Delete; +using PriceNegotiationApp.SharedKernel; + +namespace PriceNegotiationApp.Api.Endpoints.Catalog.Delete; + +internal static class DeleteEndpoint +{ + internal static void MapDelete(this RouteGroupBuilder group) + { + group.MapDelete("/{id:guid}", async (Guid id, DeleteProductHandler handler, CancellationToken ct) => + { + await handler.HandleAsync(id, ct); + return TypedResults.NoContent(); + }) + .RequireRoles(UserRoles.Admin); + } +} +``` + +`GetEndpoint.cs`: +```csharp +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.OutputCaching; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using PriceNegotiationApp.Modules.Catalog.Features.Products.Get; +using PriceNegotiationApp.SharedKernel; + +namespace PriceNegotiationApp.Api.Endpoints.Catalog.Get; + +internal static class GetEndpoint +{ + internal static void MapGetOne(this RouteGroupBuilder group) + { + group.MapGet("/{id:guid}", async (Guid id, GetProductHandler handler, CancellationToken ct) => + TypedResults.Ok(await handler.HandleAsync(id, ct))) + .WithName("GetProductById") + .CacheOutput(Policies.ShortCachePolicy) + .AllowAnonymous(); + } +} +``` + +`ListEndpoint.cs`: +```csharp +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.OutputCaching; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using PriceNegotiationApp.Modules.Catalog.Features.Products; +using PriceNegotiationApp.Modules.Catalog.Features.Products.List; +using PriceNegotiationApp.SharedKernel; + +namespace PriceNegotiationApp.Api.Endpoints.Catalog.List; + +internal static class ListEndpoint +{ + internal static void MapList(this RouteGroupBuilder group) + { + group.MapGet("/", async (ListProductsHandler handler, CancellationToken ct, + string? search = null, decimal? minPrice = null, decimal? maxPrice = null, + string? sortBy = null, bool sortDesc = false, int page = 1, int pageSize = 20) => + TypedResults.Ok(await handler.HandleAsync( + new ProductQuery(search, minPrice, maxPrice, sortBy, sortDesc, page, pageSize), ct))) + .CacheOutput(Policies.ShortCachePolicy) + .AllowAnonymous(); + } +} +``` + +`UpdateEndpoint.cs`: +```csharp +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using PriceNegotiationApp.Modules.Catalog.Features.Products; +using PriceNegotiationApp.Modules.Catalog.Features.Products.Update; +using PriceNegotiationApp.SharedKernel; + +namespace PriceNegotiationApp.Api.Endpoints.Catalog.Update; + +internal static class UpdateEndpoint +{ + internal static void MapUpdate(this RouteGroupBuilder group) + { + group.MapPut("/{id:guid}", async (Guid id, UpdateProductRequest request, + UpdateProductHandler handler, CancellationToken ct) => + TypedResults.Ok(await handler.HandleAsync(id, request, ct))) + .RequireRoles(UserRoles.Admin, UserRoles.Staff); + } +} +``` + +`CatalogEndpoints.cs`: +```csharp +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using PriceNegotiationApp.Api.Endpoints.Catalog.Create; +using PriceNegotiationApp.Api.Endpoints.Catalog.Delete; +using PriceNegotiationApp.Api.Endpoints.Catalog.Get; +using PriceNegotiationApp.Api.Endpoints.Catalog.List; +using PriceNegotiationApp.Api.Endpoints.Catalog.Update; + +namespace PriceNegotiationApp.Api.Endpoints.Catalog; + +public static class CatalogEndpoints +{ + public static IEndpointRouteBuilder MapCatalogEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/api/v1/products") + .WithTags("Products") + .RequireAuthorization(); + group.MapList(); + group.MapGetOne(); + group.MapCreate(); + group.MapUpdate(); + group.MapDelete(); + return app; + } +} +``` + +- [ ] **Step 3: Delete old endpoint files from Catalog module** + +```bash +Remove-Item "src/PriceNegotiationApp.Modules.Catalog/Features/Products/Create/CreateEndpoint.cs" +Remove-Item "src/PriceNegotiationApp.Modules.Catalog/Features/Products/Delete/DeleteEndpoint.cs" +Remove-Item "src/PriceNegotiationApp.Modules.Catalog/Features/Products/Get/GetEndpoint.cs" +Remove-Item "src/PriceNegotiationApp.Modules.Catalog/Features/Products/List/ListEndpoint.cs" +Remove-Item "src/PriceNegotiationApp.Modules.Catalog/Features/Products/Update/UpdateEndpoint.cs" +Remove-Item "src/PriceNegotiationApp.Modules.Catalog/CatalogEndpoints.cs" +``` + +- [ ] **Step 4: Update PipelineExtensions.cs using statements** + +Replace: +```csharp +using PriceNegotiationApp.Modules.Catalog; +``` +With: +```csharp +using PriceNegotiationApp.Api.Endpoints.Catalog; +``` + +- [ ] **Step 5: Run build validation** + +```bash +dotnet build --no-restore +``` + +- [ ] **Step 6: Commit** + +```bash +git add -A +git commit -m "refactor: move Catalog endpoints from module to API layer" +``` + +--- + +## Task 2: Move Negotiations endpoints to API layer + +**Files:** +- Create: 8 endpoint files + `NegotiationEndpoints.cs` in `Api/Endpoints/Negotiations/` +- Delete: All 8 endpoint files + `NegotiationEndpoints.cs` from the module + +**Interfaces:** +- Consumes: All 8 handlers (internal sealed in Negotiations module, resolved via DI) +- Consumes: `CreateNegotiationRequest`, `CounterProposalRequest`, `NegotiationModels` (internal DTOs) +- Consumes: `UserRoles`, `PageQuery`, `CallerContextExtensions` (public in SharedKernel) +- Produces: `MapNegotiationsEndpoints()` extension method + +- [ ] **Step 1: Create API endpoint directory** + +```bash +New-Item -ItemType Directory -Force -Path "src/PriceNegotiationApp.Api/Endpoints/Negotiations" +``` + +- [ ] **Step 2: Create all 9 endpoint files in API layer** + +Each file gets namespace `PriceNegotiationApp.Api.Endpoints.Negotiations.*`. + +`CreateEndpoint.cs`: +```csharp +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using PriceNegotiationApp.Modules.Negotiations.Features.Negotiations; +using PriceNegotiationApp.Modules.Negotiations.Features.Negotiations.Create; +using PriceNegotiationApp.SharedKernel; +using System.Security.Claims; + +namespace PriceNegotiationApp.Api.Endpoints.Negotiations.Create; + +internal static class CreateEndpoint +{ + internal static void MapCreate(this RouteGroupBuilder group) + { + group.MapPost("/", async (CreateNegotiationRequest request, ClaimsPrincipal principal, + CreateNegotiationHandler handler, CancellationToken ct) => + TypedResults.Created("/api/v1/negotiations/mine", + await handler.HandleAsync(request, principal.ToCallerContext(), ct))) + .RequireRoles(UserRoles.Customer); + } +} +``` + +`GetEndpoint.cs`: +```csharp +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using PriceNegotiationApp.Modules.Negotiations.Features.Negotiations.Get; +using System.Security.Claims; + +namespace PriceNegotiationApp.Api.Endpoints.Negotiations.Get; + +internal static class GetEndpoint +{ + internal static void MapGetOne(this RouteGroupBuilder group) + { + group.MapGet("/{id:guid}", async (Guid id, ClaimsPrincipal principal, + GetNegotiationHandler handler, CancellationToken ct) => + TypedResults.Ok(await handler.HandleAsync(id, principal.ToCallerContext(), ct))); + } +} +``` + +`ListEndpoint.cs`: +```csharp +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using PriceNegotiationApp.Modules.Negotiations.Features.Negotiations.List; +using PriceNegotiationApp.SharedKernel; + +namespace PriceNegotiationApp.Api.Endpoints.Negotiations.List; + +internal static class ListEndpoint +{ + internal static void MapList(this RouteGroupBuilder group) + { + group.MapGet("/", async (ListNegotiationsHandler handler, CancellationToken ct, + int page = 1, int pageSize = 20) => + TypedResults.Ok(await handler.HandleAsync(new PageQuery(page, pageSize), ct))) + .RequireRoles(UserRoles.Admin, UserRoles.Staff); + } +} +``` + +`ListMineEndpoint.cs`: +```csharp +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using PriceNegotiationApp.Modules.Negotiations.Features.Negotiations.ListMine; +using PriceNegotiationApp.SharedKernel; +using System.Security.Claims; + +namespace PriceNegotiationApp.Api.Endpoints.Negotiations.ListMine; + +internal static class ListMineEndpoint +{ + internal static void MapListMine(this RouteGroupBuilder group) + { + group.MapGet("/mine", async (ClaimsPrincipal principal, ListMyNegotiationsHandler handler, + CancellationToken ct, int page = 1, int pageSize = 20) => + TypedResults.Ok(await handler.HandleAsync( + new PageQuery(page, pageSize), principal.ToCallerContext(), ct))) + .RequireRoles(UserRoles.Customer); + } +} +``` + +`AcceptEndpoint.cs`: +```csharp +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using PriceNegotiationApp.Modules.Negotiations.Features.Negotiations.Accept; +using PriceNegotiationApp.SharedKernel; + +namespace PriceNegotiationApp.Api.Endpoints.Negotiations.Accept; + +internal static class AcceptEndpoint +{ + internal static void MapAccept(this RouteGroupBuilder group) + { + group.MapPost("/{id:guid}/accept", async (Guid id, AcceptHandler handler, CancellationToken ct) => + TypedResults.Ok(await handler.HandleAsync(id, ct))) + .RequireRoles(UserRoles.Admin, UserRoles.Staff); + } +} +``` + +`CounterProposeEndpoint.cs`: +```csharp +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using PriceNegotiationApp.Modules.Negotiations.Features.Negotiations; +using PriceNegotiationApp.Modules.Negotiations.Features.Negotiations.CounterPropose; +using System.Security.Claims; + +namespace PriceNegotiationApp.Api.Endpoints.Negotiations.CounterPropose; + +internal static class CounterProposeEndpoint +{ + internal static void MapCounterPropose(this RouteGroupBuilder group) + { + group.MapPatch("/{id:guid}/proposals", async (Guid id, CounterProposalRequest request, + ClaimsPrincipal principal, CounterProposeHandler handler, CancellationToken ct) => + TypedResults.Ok(await handler.HandleAsync(id, request, principal.ToCallerContext(), ct))); + } +} +``` + +`RejectCurrentOfferEndpoint.cs`: +```csharp +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using PriceNegotiationApp.Modules.Negotiations.Features.Negotiations.RejectCurrentOffer; +using PriceNegotiationApp.SharedKernel; + +namespace PriceNegotiationApp.Api.Endpoints.Negotiations.RejectCurrentOffer; + +internal static class RejectCurrentOfferEndpoint +{ + internal static void MapRejectCurrentOffer(this RouteGroupBuilder group) + { + group.MapPost("/{id:guid}/decline", async (Guid id, RejectCurrentOfferHandler handler, + CancellationToken ct) => + TypedResults.Ok(await handler.HandleAsync(id, ct))) + .RequireRoles(UserRoles.Admin, UserRoles.Staff); + } +} +``` + +`WithdrawEndpoint.cs`: +```csharp +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using PriceNegotiationApp.Modules.Negotiations.Features.Negotiations.Withdraw; +using System.Security.Claims; + +namespace PriceNegotiationApp.Api.Endpoints.Negotiations.Withdraw; + +internal static class WithdrawEndpoint +{ + internal static void MapWithdraw(this RouteGroupBuilder group) + { + group.MapDelete("/{id:guid}", async (Guid id, ClaimsPrincipal principal, + WithdrawHandler handler, CancellationToken ct) => + { + await handler.HandleAsync(id, principal.ToCallerContext(), ct); + return TypedResults.NoContent(); + }); + } +} +``` + +`NegotiationEndpoints.cs`: +```csharp +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using PriceNegotiationApp.Api.Endpoints.Negotiations.Accept; +using PriceNegotiationApp.Api.Endpoints.Negotiations.CounterPropose; +using PriceNegotiationApp.Api.Endpoints.Negotiations.Create; +using PriceNegotiationApp.Api.Endpoints.Negotiations.Get; +using PriceNegotiationApp.Api.Endpoints.Negotiations.List; +using PriceNegotiationApp.Api.Endpoints.Negotiations.ListMine; +using PriceNegotiationApp.Api.Endpoints.Negotiations.RejectCurrentOffer; +using PriceNegotiationApp.Api.Endpoints.Negotiations.Withdraw; + +namespace PriceNegotiationApp.Api.Endpoints.Negotiations; + +public static class NegotiationEndpoints +{ + public static IEndpointRouteBuilder MapNegotiationsEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/api/v1/negotiations") + .WithTags("Negotiations") + .RequireAuthorization(); + group.MapCreate(); + group.MapListMine(); + group.MapList(); + group.MapGetOne(); + group.MapCounterPropose(); + group.MapAccept(); + group.MapRejectCurrentOffer(); + group.MapWithdraw(); + return app; + } +} +``` + +- [ ] **Step 3: Delete old endpoint files from Negotiations module** + +```bash +Remove-Item "src/PriceNegotiationApp.Modules.Negotiations/Features/Negotiations/Accept/AcceptEndpoint.cs" +Remove-Item "src/PriceNegotiationApp.Modules.Negotiations/Features/Negotiations/CounterPropose/CounterProposeEndpoint.cs" +Remove-Item "src/PriceNegotiationApp.Modules.Negotiations/Features/Negotiations/Create/CreateEndpoint.cs" +Remove-Item "src/PriceNegotiationApp.Modules.Negotiations/Features/Negotiations/Get/GetEndpoint.cs" +Remove-Item "src/PriceNegotiationApp.Modules.Negotiations/Features/Negotiations/List/ListEndpoint.cs" +Remove-Item "src/PriceNegotiationApp.Modules.Negotiations/Features/Negotiations/ListMine/ListMineEndpoint.cs" +Remove-Item "src/PriceNegotiationApp.Modules.Negotiations/Features/Negotiations/RejectCurrentOffer/RejectCurrentOfferEndpoint.cs" +Remove-Item "src/PriceNegotiationApp.Modules.Negotiations/Features/Negotiations/Withdraw/WithdrawEndpoint.cs" +Remove-Item "src/PriceNegotiationApp.Modules.Negotiations/NegotiationEndpoints.cs" +``` + +- [ ] **Step 4: Update PipelineExtensions.cs using statements** + +Replace: +```csharp +using PriceNegotiationApp.Modules.Negotiations; +``` +With: +```csharp +using PriceNegotiationApp.Api.Endpoints.Negotiations; +``` + +- [ ] **Step 5: Run build validation** + +```bash +dotnet build --no-restore +``` + +- [ ] **Step 6: Commit** + +```bash +git add -A +git commit -m "refactor: move Negotiations endpoints from module to API layer" +``` + +--- + +## Task 3: Move Identity endpoints to API layer + +**Files:** +- Create: 3 endpoint files + `IdentityEndpoints.cs` in `Api/Endpoints/Identity/` +- Delete: All 3 endpoint files + `IdentityEndpoints.cs` from the module + +**Interfaces:** +- Consumes: `LoginUserHandler`, `RegisterUserHandler` (internal sealed in Identity module, resolved via DI) +- Consumes: `LoginRequest`, `RegisterRequest`, `CurrentUserResponse` (internal DTOs) +- Consumes: `Policies`, `CallerContextExtensions` (public in SharedKernel) +- Produces: `MapAuthEndpoints()` extension method + +- [ ] **Step 1: Create API endpoint directory** + +```bash +New-Item -ItemType Directory -Force -Path "src/PriceNegotiationApp.Api/Endpoints/Identity" +``` + +- [ ] **Step 2: Create all 4 endpoint files in API layer** + +`LoginEndpoint.cs`: +```csharp +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using PriceNegotiationApp.Modules.Identity.Features.Auth; +using PriceNegotiationApp.Modules.Identity.Features.Auth.Login; +using PriceNegotiationApp.SharedKernel; + +namespace PriceNegotiationApp.Api.Endpoints.Identity.Login; + +internal static class LoginEndpoint +{ + internal static void MapLogin(this RouteGroupBuilder group) + { + group.MapPost("/login", async (LoginRequest request, LoginUserHandler handler, + CancellationToken ct) => + TypedResults.Ok(await handler.HandleAsync(request))) + .RequireRateLimiting(Policies.AuthRateLimitPolicy) + .AllowAnonymous() + .WithName("Login") + .WithSummary("Authenticate and issue an access token") + .ProducesProblem(StatusCodes.Status401Unauthorized) + .ProducesProblem(StatusCodes.Status422UnprocessableEntity) + .ProducesProblem(StatusCodes.Status429TooManyRequests); + } +} +``` + +`RegisterEndpoint.cs`: +```csharp +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using PriceNegotiationApp.Modules.Identity.Features.Auth; +using PriceNegotiationApp.Modules.Identity.Features.Auth.Register; +using PriceNegotiationApp.SharedKernel; + +namespace PriceNegotiationApp.Api.Endpoints.Identity.Register; + +internal static class RegisterEndpoint +{ + internal static void MapRegister(this RouteGroupBuilder group) + { + group.MapPost("/register", async (RegisterRequest request, + RegisterUserHandler handler, CancellationToken ct) => + TypedResults.Created("/api/v1/auth/me", await handler.HandleAsync(request))) + .RequireRateLimiting(Policies.AuthRateLimitPolicy) + .AllowAnonymous() + .WithName("RegisterUser") + .WithSummary("Register a new customer account") + .ProducesProblem(StatusCodes.Status409Conflict) + .ProducesProblem(StatusCodes.Status422UnprocessableEntity) + .ProducesProblem(StatusCodes.Status429TooManyRequests); + } +} +``` + +`MeEndpoint.cs`: +```csharp +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using PriceNegotiationApp.Modules.Identity.Features.Auth; +using PriceNegotiationApp.SharedKernel; +using System.Security.Claims; + +namespace PriceNegotiationApp.Api.Endpoints.Identity.Me; + +internal static class MeEndpoint +{ + internal static void MapMe(this RouteGroupBuilder group) + { + group.MapGet("/me", (ClaimsPrincipal principal) => + { + var caller = principal.ToCallerContext(); + return TypedResults.Ok(new CurrentUserResponse(caller.UserId, caller.Email, caller.Roles.ToList())); + }) + .WithName("GetCurrentUser") + .WithSummary("Return the authenticated caller's profile"); + } +} +``` + +`IdentityEndpoints.cs`: +```csharp +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using PriceNegotiationApp.Api.Endpoints.Identity.Login; +using PriceNegotiationApp.Api.Endpoints.Identity.Me; +using PriceNegotiationApp.Api.Endpoints.Identity.Register; + +namespace PriceNegotiationApp.Api.Endpoints.Identity; + +public static class IdentityEndpoints +{ + public static IEndpointRouteBuilder MapAuthEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/api/v1/auth") + .WithTags("Auth") + .RequireAuthorization(); + group.MapRegister(); + group.MapLogin(); + group.MapMe(); + return app; + } +} +``` + +- [ ] **Step 3: Delete old endpoint files from Identity module** + +```bash +Remove-Item "src/PriceNegotiationApp.Modules.Identity/Features/Auth/Login/LoginEndpoint.cs" +Remove-Item "src/PriceNegotiationApp.Modules.Identity/Features/Auth/Register/RegisterEndpoint.cs" +Remove-Item "src/PriceNegotiationApp.Modules.Identity/Features/Auth/Me/MeEndpoint.cs" +Remove-Item "src/PriceNegotiationApp.Modules.Identity/IdentityEndpoints.cs" +``` + +- [ ] **Step 4: Update PipelineExtensions.cs using statements** + +Replace: +```csharp +using PriceNegotiationApp.Modules.Identity; +``` +With: +```csharp +using PriceNegotiationApp.Api.Endpoints.Identity; +``` + +- [ ] **Step 5: Run build validation** + +```bash +dotnet build --no-restore +``` + +- [ ] **Step 6: Commit** + +```bash +git add -A +git commit -m "refactor: move Identity endpoints from module to API layer" +``` + +--- + +## Task 4: Remove ASP.NET Core FrameworkReference from module csprojs + +**Files:** +- Modify: `src/PriceNegotiationApp.Modules.Catalog/PriceNegotiationApp.Modules.Catalog.csproj` +- Modify: `src/PriceNegotiationApp.Modules.Negotiations/PriceNegotiationApp.Modules.Negotiations.csproj` +- Modify: `src/PriceNegotiationApp.Modules.Identity/PriceNegotiationApp.Modules.Identity.csproj` + +**Interfaces:** +- Consumes: Nothing (cleanup step) +- Produces: Modules no longer depend on `Microsoft.AspNetCore.App` FrameworkReference + +- [ ] **Step 1: Remove FrameworkReference from Catalog csproj** + +Remove this block from `PriceNegotiationApp.Modules.Catalog.csproj`: +```xml + +``` + +- [ ] **Step 2: Remove FrameworkReference from Negotiations csproj** + +Remove this block from `PriceNegotiationApp.Modules.Negotiations.csproj`: +```xml + +``` + +- [ ] **Step 3: Remove FrameworkReference from Identity csproj** + +Remove this block from `PriceNegotiationApp.Modules.Identity.csproj`: +```xml + +``` + +- [ ] **Step 4: Clean unused ASP.NET Core using statements from IdentityModule.cs** + +Remove these unused using statements from `IdentityModule.cs`: +```csharp +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +``` + +- [ ] **Step 5: Run build validation** + +```bash +dotnet build --no-restore +``` + +- [ ] **Step 6: Commit** + +```bash +git add -A +git commit -m "refactor: remove ASP.NET Core FrameworkReference from module csprojs" +``` + +--- + +## Task 5: Clean up empty directories left behind + +**Files:** +- Delete: Empty use-case subdirectories in all 3 modules + +- [ ] **Step 1: Remove empty Catalog feature subdirectories** + +```bash +Remove-Item "src/PriceNegotiationApp.Modules.Catalog/Features/Products/Create" -Recurse -Force +Remove-Item "src/PriceNegotiationApp.Modules.Catalog/Features/Products/Delete" -Recurse -Force +Remove-Item "src/PriceNegotiationApp.Modules.Catalog/Features/Products/Get" -Recurse -Force +Remove-Item "src/PriceNegotiationApp.Modules.Catalog/Features/Products/List" -Recurse -Force +Remove-Item "src/PriceNegotiationApp.Modules.Catalog/Features/Products/Update" -Recurse -Force +``` + +- [ ] **Step 2: Remove empty Negotiations feature subdirectories** + +```bash +Remove-Item "src/PriceNegotiationApp.Modules.Negotiations/Features/Negotiations/Accept" -Recurse -Force +Remove-Item "src/PriceNegotiationApp.Modules.Negotiations/Features/Negotiations/CounterPropose" -Recurse -Force +Remove-Item "src/PriceNegotiationApp.Modules.Negotiations/Features/Negotiations/Create" -Recurse -Force +Remove-Item "src/PriceNegotiationApp.Modules.Negotiations/Features/Negotiations/Get" -Recurse -Force +Remove-Item "src/PriceNegotiationApp.Modules.Negotiations/Features/Negotiations/List" -Recurse -Force +Remove-Item "src/PriceNegotiationApp.Modules.Negotiations/Features/Negotiations/ListMine" -Recurse -Force +Remove-Item "src/PriceNegotiationApp.Modules.Negotiations/Features/Negotiations/RejectCurrentOffer" -Recurse -Force +Remove-Item "src/PriceNegotiationApp.Modules.Negotiations/Features/Negotiations/Withdraw" -Recurse -Force +``` + +- [ ] **Step 3: Remove empty Identity feature subdirectories** + +```bash +Remove-Item "src/PriceNegotiationApp.Modules.Identity/Features/Auth/Login" -Recurse -Force +Remove-Item "src/PriceNegotiationApp.Modules.Identity/Features/Auth/Register" -Recurse -Force +Remove-Item "src/PriceNegotiationApp.Modules.Identity/Features/Auth/Me" -Recurse -Force +Remove-Item "src/PriceNegotiationApp.Modules.Identity/Auth" -Recurse -Force +``` + +- [ ] **Step 4: Run full build validation** + +```bash +dotnet build --no-restore +``` + +- [ ] **Step 5: Run tests** + +```bash +dotnet test --no-build +``` + +- [ ] **Step 6: Commit** + +```bash +git add -A +git commit -m "refactor: clean up empty directories after endpoint migration" +``` + +--- + +## Self-Review Checklist + +1. **Spec coverage:** All 16 endpoint files + 3 aggregator files moved. All module csprojs cleaned. All empty dirs removed. +2. **Placeholder scan:** No TBD/TODO. All code blocks are complete. +3. **Type consistency:** Handler class names, DTO names, and extension method names are consistent across all tasks. The `PipelineExtensions.cs` using updates match the new namespaces. +4. **Architecture test compatibility:** Rule #9 (`Endpoint_mapping_types_stay_transport_only`) targets types ending with `"Endpoints"`. The new `CatalogEndpoints.cs`, `NegotiationEndpoints.cs`, `IdentityEndpoints.cs` are in the API assembly (loaded as `typeof(GlobalExceptionHandler).Assembly`). The rule should still pass since these types only reference ASP.NET Core and handler types, not EF Core or Persistence. +5. **InternalsVisibleTo:** All 3 modules keep `InternalsVisibleTo Include="PriceNegotiationApp.Api"` so the API can reference internal handler types in endpoint lambda parameters. diff --git a/docs/superpowers/plans/2026-08-30-move-modules-to-directory.md b/docs/superpowers/plans/2026-08-30-move-modules-to-directory.md new file mode 100644 index 0000000..a0c00a4 --- /dev/null +++ b/docs/superpowers/plans/2026-08-30-move-modules-to-directory.md @@ -0,0 +1,344 @@ +# Move Modules Under `src/Modules/` Directory + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Reorganize the `src/` directory so module projects sit under a `Modules/` subdirectory, grouping them logically while keeping SharedKernel and Api at the top level. + +**Architecture:** Move three module projects (`Modules.Catalog`, `Modules.Identity`, `Modules.Negotiations`) from `src/` into `src/Modules/`. Update all project references, solution file entries, and architecture tests to reflect the new paths. + +**Tech Stack:** .NET 10, .slnx solution format, ArchUnitNET + +## Global Constraints + +- Assembly names must NOT change (only folder paths change) +- `InternalsVisibleTo` entries are assembly-name-based, not path-based — no changes needed there +- Test projects stay under `tests/` (unchanged) +- SharedKernel and Api stay at `src/` top level (unchanged) + +## File Structure + +### Files to Move (rename paths, not assembly names) + +| Current Path | New Path | +|--------------|----------| +| `src/PriceNegotiationApp.Modules.Catalog/` | `src/Modules/PriceNegotiationApp.Modules.Catalog/` | +| `src/PriceNegotiationApp.Modules.Identity/` | `src/Modules/PriceNegotiationApp.Modules.Identity/` | +| `src/PriceNegotiationApp.Modules.Negotiations/` | `src/Modules/PriceNegotiationApp.Modules.Negotiations/` | + +### Files to Modify + +| File | Change | +|------|--------| +| `PriceNegotiationApp.slnx` | Update project paths for all 3 modules | +| `src/Modules/PriceNegotiationApp.Modules.Catalog/*.csproj` | Update `ProjectReference` to SharedKernel | +| `src/Modules/PriceNegotiationApp.Modules.Identity/*.csproj` | Update `ProjectReference` to SharedKernel | +| `src/Modules/PriceNegotiationApp.Modules.Negotiations/*.csproj` | Update `ProjectReference` to SharedKernel and Catalog | +| `src/PriceNegotiationApp.Api/*.csproj` | Update `ProjectReference` to all 3 modules | +| `tests/PriceNegotiationApp.ArchitectureTests/*.csproj` | Update `ProjectReference` to all 3 modules | +| `tests/PriceNegotiationApp.Modules.Catalog.Tests/*.csproj` | Update `ProjectReference` to Catalog | +| `tests/PriceNegotiationApp.Modules.Identity.Tests/*.csproj` | Update `ProjectReference` to Identity | +| `tests/PriceNegotiationApp.Modules.Negotiations.Tests/*.csproj` | Update `ProjectReference` to Negotiations | +| `tests/PriceNegotiationApp.IntegrationTests/*.csproj` | Update `ProjectReference` to all modules | +| `tests/PriceNegotiationApp.TestKit/*.csproj` | Update `ProjectReference` to modules if present | + +--- + +### Task 1: Move module directories under `src/Modules/` + +**Files:** +- Move: `src/PriceNegotiationApp.Modules.Catalog/` → `src/Modules/PriceNegotiationApp.Modules.Catalog/` +- Move: `src/PriceNegotiationApp.Modules.Identity/` → `src/Modules/PriceNegotiationApp.Modules.Identity/` +- Move: `src/PriceNegotiationApp.Modules.Negotiations/` → `src/Modules/PriceNegotiationApp.Modules.Negotiations/` + +**Steps:** + +- [ ] **Step 1: Create `src/Modules/` directory** + +```bash +mkdir -p src/Modules +``` + +- [ ] **Step 2: Move Catalog module** + +```bash +git mv src/PriceNegotiationApp.Modules.Catalog src/Modules/PriceNegotiationApp.Modules.Catalog +``` + +- [ ] **Step 3: Move Identity module** + +```bash +git mv src/PriceNegotiationApp.Modules.Identity src/Modules/PriceNegotiationApp.Modules.Identity +``` + +- [ ] **Step 4: Move Negotiations module** + +```bash +git mv src/PriceNegotiationApp.Modules.Negotiations src/Modules/PriceNegotiationApp.Modules.Negotiations +``` + +- [ ] **Step 5: Verify directories moved correctly** + +```bash +ls src/Modules/ +``` + +Expected output: `PriceNegotiationApp.Modules.Catalog`, `PriceNegotiationApp.Modules.Identity`, `PriceNegotiationApp.Modules.Negotiations` + +- [ ] **Step 6: Commit directory move** + +```bash +git add src/Modules/ +git commit -m "Move module projects under src/Modules/ directory" +``` + +--- + +### Task 2: Update module .csproj ProjectReference paths + +**Files:** +- Modify: `src/Modules/PriceNegotiationApp.Modules.Catalog/PriceNegotiationApp.Modules.Catalog.csproj` +- Modify: `src/Modules/PriceNegotiationApp.Modules.Identity/PriceNegotiationApp.Modules.Identity.csproj` +- Modify: `src/Modules/PriceNegotiationApp.Modules.Negotiations/PriceNegotiationApp.Modules.Negotiations.csproj` + +**Steps:** + +- [ ] **Step 1: Update Catalog csproj** + +Update `ProjectReference` from: +```xml + +``` +To: +```xml + +``` + +Wait — the relative path from `src/Modules/PriceNegotiationApp.Modules.Catalog/` to `src/PriceNegotiationApp.SharedKernel/` is `..\..\src\PriceNegotiationApp.SharedKernel\PriceNegotiationApp.SharedKernel.csproj` (two directories up from Modules, then into src). This is correct as-is because the old path was `..\..\src\` from `src/PriceNegotiationApp.Modules.Catalog/` which was actually `..\PriceNegotiationApp.SharedKernel\`. Let me verify: + +Old path: `src/PriceNegotiationApp.Modules.Catalog/` → `..\PriceNegotiationApp.SharedKernel\...` (one directory up to `src/`, then into SharedKernel) + +New path: `src/Modules/PriceNegotiationApp.Modules.Catalog/` → `..\..\PriceNegotiationApp.SharedKernel\...` (two directories up to `src/`, then into SharedKernel) + +So the path changes from `..` to `..\..` for SharedKernel references. + +Update Catalog csproj `ProjectReference` to: +```xml + +``` + +- [ ] **Step 2: Update Identity csproj** + +Update `ProjectReference` from: +```xml + +``` +To: +```xml + +``` + +- [ ] **Step 3: Update Negotiations csproj** + +Update `ProjectReference` to SharedKernel from: +```xml + +``` +To: +```xml + +``` + +Update `ProjectReference` to Catalog from: +```xml + +``` +To: +```xml + +``` + +- [ ] **Step 4: Build to verify module csproj changes** + +```bash +dotnet build --no-restore +``` + +- [ ] **Step 5: Commit csproj updates** + +```bash +git add src/Modules/ +git commit -m "Update module ProjectReference paths for new directory structure" +``` + +--- + +### Task 3: Update Api project .csproj references + +**Files:** +- Modify: `src/PriceNegotiationApp.Api/PriceNegotiationApp.Api.csproj` + +**Steps:** + +- [ ] **Step 1: Read current Api csproj** + +Read `src/PriceNegotiationApp.Api/PriceNegotiationApp.Api.csproj` to see current `ProjectReference` entries. + +- [ ] **Step 2: Update Api csproj ProjectReferences** + +Update all module `ProjectReference` paths from: +```xml + + + +``` +To (paths vary based on current structure — adjust relative paths): +```xml + + + +``` + +- [ ] **Step 3: Build to verify Api references** + +```bash +dotnet build --no-restore +``` + +- [ ] **Step 4: Commit Api csproj update** + +```bash +git add src/PriceNegotiationApp.Api/ +git commit -m "Update Api ProjectReference paths for module directory move" +``` + +--- + +### Task 4: Update test project .csproj references + +**Files:** +- Modify: `tests/PriceNegotiationApp.ArchitectureTests/*.csproj` +- Modify: `tests/PriceNegotiationApp.Modules.Catalog.Tests/*.csproj` +- Modify: `tests/PriceNegotiationApp.Modules.Identity.Tests/*.csproj` +- Modify: `tests/PriceNegotiationApp.Modules.Negotiations.Tests/*.csproj` +- Modify: `tests/PriceNegotiationApp.IntegrationTests/*.csproj` +- Modify: `tests/PriceNegotiationApp.TestKit/*.csproj` + +**Steps:** + +- [ ] **Step 1: Read all test csproj files** + +Read each test project's `.csproj` to see current `ProjectReference` entries. + +- [ ] **Step 2: Update test csproj references** + +For each test project, update `ProjectReference` paths to reflect the new module locations under `src/Modules/`. + +Example for Catalog Tests (adjust paths based on current structure): +```xml + + + + +``` + +- [ ] **Step 3: Build entire solution** + +```bash +dotnet build +``` + +- [ ] **Step 4: Commit test csproj updates** + +```bash +git add tests/ +git commit -m "Update test ProjectReference paths for module directory move" +``` + +--- + +### Task 5: Update solution file + +**Files:** +- Modify: `PriceNegotiationApp.slnx` + +**Steps:** + +- [ ] **Step 1: Read current solution file** + +Read `PriceNegotiationApp.slnx` to see current project paths. + +- [ ] **Step 2: Update module project paths** + +Update the `ProjectPath` attributes for all 3 module projects from: +```xml + + + +``` +To: +```xml + + + +``` + +- [ ] **Step 3: Verify solution loads correctly** + +```bash +dotnet build +``` + +- [ ] **Step 4: Commit solution file update** + +```bash +git add PriceNegotiationApp.slnx +git commit -m "Update solution file paths for module directory move" +``` + +--- + +### Task 6: Run full validation + +**Files:** None (verification only) + +**Steps:** + +- [ ] **Step 1: Clean and rebuild** + +```bash +dotnet clean +dotnet build +``` + +- [ ] **Step 2: Run all tests** + +```bash +dotnet test +``` + +Expected: All 124 tests pass. + +- [ ] **Step 3: Verify directory structure** + +```bash +ls -la src/ +ls -la src/Modules/ +``` + +Expected: +``` +src/ +├── PriceNegotiationApp.Api/ +├── PriceNegotiationApp.SharedKernel/ +└── Modules/ + ├── PriceNegotiationApp.Modules.Catalog/ + ├── PriceNegotiationApp.Modules.Identity/ + └── PriceNegotiationApp.Modules.Negotiations/ +``` + +- [ ] **Step 4: Final commit (if any fixes needed)** + +```bash +git add -A +git commit -m "Fix any issues from directory reorganization" +``` diff --git a/docs/superpowers/specs/2026-08-23-modernization-design.md b/docs/superpowers/specs/2026-08-23-modernization-design.md new file mode 100644 index 0000000..191cfcf --- /dev/null +++ b/docs/superpowers/specs/2026-08-23-modernization-design.md @@ -0,0 +1,290 @@ +# PriceNegotiationApp — Full Modernization Design + +Date: 2026-08-23 +Status: Approved (design gate passed) +Mandate: Greenfield-in-place modernization of every layer — structure, API surface, domain, persistence, security, platform, tests, docs. + +--- + +## 1. Goals & Non-Goals + +### Goals +1. Fix all latent runtime bugs (broken `FindAsync` calls, one-negotiation-per-customer index, anonymous-user crash, broken test project reference). +2. Close all security gaps (committed secrets, disabled JWT validations, leaked exception details, unauthenticated response caching, no rate limiting). +3. Collapse 7 source projects to 4; delete all dead code, redundant DTO/mapper layers, and committed artifacts. +4. Replace the EF InMemory production database with PostgreSQL + checked-in migrations + Testcontainers-based integration tests. +5. Rebuild the API surface with minimal APIs, built-in .NET 10 validation, explicit filtering/paging, and a complete negotiation lifecycle (staff accept/decline is currently missing from the API despite the README advertising it). +6. Add missing platform pieces: Dockerfile, docker-compose, GitHub Actions CI, Dependabot, OpenTelemetry. +7. Rebuild the test suite around the negotiation lifecycle; remove FluentAssertions (v8 commercial-license risk). + +### Non-Goals +- No frontend client. +- No message broker / event sourcing / CQRS framework (YAGNI at this scale). +- No multi-node concerns beyond stateless app design (JWT already stateless; DB is the only state). +- No asymmetric JWT signing (RS256) — noted as future work; HMAC-SHA256 stays for single-instance deployment. + +--- + +## 2. Target Solution Structure + +``` +PriceNegotiationApp.slnx +Directory.Build.props (unchanged: net10.0, nullable, warnings-as-errors) +Directory.Packages.props (rewritten — see §10) +src/ + PriceNegotiationApp.Domain/ entities, value objects, business rules, + policy, factories, domain exceptions + PriceNegotiationApp.Application/ use-case services, ports, result types, + request/response models per feature + PriceNegotiationApp.Infrastructure/ EF Core 10 + Npgsql, Identity, JWT issuance, + repositories, migrations, seeding + PriceNegotiationApp.Api/ host: minimal-API endpoint groups, authz + policies, ProblemDetails mapping, OpenAPI, + health, Serilog+OTel, rate limiting, CORS +tests/ + PriceNegotiationApp.UnitTests/ domain + application logic (fast, no host) + PriceNegotiationApp.IntegrationTests/ WebApplicationFactory + Testcontainers PG +``` + +**Deleted:** `Contracts`, `Presentation`, `SharedKernel`, empty root-level project folders, `logs/`, committed `PriceNegotiationApp.Api.json`, stale `.http` scratch entries. + +### Dependency graph (strictly outward) +`Api → { Application, Infrastructure } → Domain` +- Api also references Infrastructure only for composition-root registration. +- Domain references **no packages except Vogen** (source-generator/attributes only). +- Application references **no ASP.NET packages** (no FrameworkReference, no Identity types, no OData). It defines ports; Infrastructure implements them. +- SharedKernel dissolves: ID value objects (`ProductId`, `NegotiationId`, `CustomerId`) move to `Domain/ValueObjects/Ids` under correct namespaces; EF converters are configured in Infrastructure using Vogen-generated converter classes. + +### Program.cs split +Thin `Program.cs` (~30 lines: builder → module registration → pipeline → run) plus: +- `Api/Modules/ProductsModule.cs`, `NegotiationsModule.cs`, `AuthModule.cs` (`MapXxx(this IEndpointRouteBuilder)` endpoint groups) +- `Api/Extensions/{ServiceCollectionExtensions, PipelineExtensions}.cs` + +--- + +## 3. Domain Model + +### Value objects +- **IDs** (Vogen): `ProductId`, `NegotiationId`, `CustomerId` — `[ValueObject(conversions: EfCore)]`. +- **Price**: single value object for base prices and proposals. Invariants: `Value > 0`, precision `decimal(18,2)`. Replaces hand-written `ProductPrice` and `ProposedPrice`. + +### Enums +```csharp +enum NegotiationStatus { Open = 1, Accepted = 2, Declined = 3 } +enum NegotiationOutcome { CounterProposed = 1, AutoRejected = 2, NoProposalsRemaining = 3 } +``` +The string-record status class, unused `Archived` status, and duplicated `NegotiationOutcome`-vs-status modeling are deleted. + +### Policy (single source of truth) +```csharp +interface INegotiationPolicy +{ + int MaxProposalsPerNegotiation { get; } // 3 — total proposals including initial + decimal ProposalMultiplierLimit { get; } // 2.0 — auto-reject above base × limit +} +``` +Implementation in Domain (`DefaultNegotiationPolicy`), injected into factories/services. All `[Range]` annotations, magic numbers, and the Application-layer duplicate policy are deleted. + +### Negotiation semantics (made explicit) +- `Negotiation.Start(product, customer, proposedPrice, time, policy)`: + - Validates proposal ≤ `base × multiplier` else throws `ProposalExceedsLimitDomainException` (creation-time violation is caller error → 400). + - Sets `BasePrice` snapshot (product price at creation), `CurrentOffer = proposedPrice`, `Status = Open`, `ProposalsUsed = 1`. Snapshot protects ongoing negotiations from product price changes. +- `CounterPropose(price, time)` returns `NegotiationOutcome` (normal flow, not an exception): + - Requires `Status == Open` (else `NegotiationClosedDomainException` → mapped to 409). + - Budget guard first: if `ProposalsUsed >= Max`, return `NoProposalsRemaining` (status unchanged; service maps to 409 with remaining=0). + - If `price > BasePrice × multiplier`: `Status = Declined`, return `AutoRejected`. + - Otherwise store: `CurrentOffer = price`, `ProposalsUsed += 1`, `LastProposalAtUtc = time`, return `CounterProposed`. +- `Accept(time)` / `Decline(time)` (staff decisions): require `Status == Open`; set terminal status + `DecidedAtUtc`. `Decline` on a negotiation with `ProposalsUsed >= Max` also yields `Declined` (terminal either way). +- `Withdraw` is not a status: customers delete their own Open negotiations (hard delete, history gone by design; Admin delete likewise). +- Removed entirely: `Archive()`, `ResetRetries()` (+ its endpoint), `[Obsolete] UpdateNegotiationAsync`, `[NonAction]` PUT endpoint. + +### Product & Customer +- `Product`: `Id`, `Name` (≤200 chars), `Price` (Price VO), `Update(name, price)` keeps no-op guard rule. Rules stay as IBusinessRule implementations. +- `Customer`: retained as identity-linked profile row (`Id`, `IdentityUserId` unique); created lazily on first negotiation. +- `DateTimeOffset.UtcNow` never called inside entities — time always injected via parameter or `TimeProvider`. + +### Concurrency +`uint Version` mapped to PostgreSQL `xmin` system column on `Product` and `Negotiation` → optimistic concurrency; DB exception maps to 409 ProblemDetails. + +--- + +## 4. Persistence (Infrastructure) + +- Provider: **Npgsql EF Core 10**, snake_case naming via `EFCore.NamingConventions`. +- `AppDbContext : IdentityDbContext, Guid>` — internal to Infrastructure; **no `IAppDbContext` interface** (deleted). +- Ports defined in Application, implemented here: + - `IProductRepository`: `GetByIdAsync`, `Query()` (IQueryable for composition in read paths), `AddAsync`, `Remove`, plus `IUnitOfWork.SaveChangesAsync(ct)` shared interface. + - `INegotiationRepository`: same shape scoped to aggregate root. + - `IUserAccountStore`: register / find-by-email / check-password / roles (implemented over UserManager/SignInManager — ASP.NET Identity stays, it correctly handles hashing/lockout/validation). +- Configurations (explicit `IEntityTypeConfiguration`): + - Product: OwnsOne `Price`; Name maxlen 200. + - Negotiation: FKs Product/Customer; **partial unique index `(product_id, customer_id) WHERE status = 'Open'`** (one open negotiation per customer per product; closed history preserved — fixes the old whole-table `UserId` unique index bug); status stored as smallint; xmin version. + - Customer: unique index `identity_user_id`. + - ApplicationUser: drop custom `Role` string property (duplicate role claims fix) — roles live only in Identity role store. +- Migrations checked in under `Infrastructure/Data/Migrations`; startup hosted service runs `Database.MigrateAsync()` then idempotent seeding. +- Seeding (config-driven, never hardcoded secrets): roles Admin/Staff/Customer; admin + staff accounts from configuration section (compose supplies env vars; dev uses user-secrets); sample products seeded only in Development. + +## 5. Authentication & Authorization + +### JWT +- Validation hardened: `ValidateIssuer = true`, `ValidateAudience = true`, `ValidateLifetime = true` (all against configured values), valid issuer/audience bound from options; clock skew default. +- Secret: ≥32 chars, enforced by real options validator (`ValidateOnStart`) replacing the TODO stub; supplied via user-secrets (dev) / environment (compose, prod). +- Token contents: `sub`, `email`, `jti`, `iat`, role claims from Identity role store (single source; no custom claim duplication). +- Login lockout enabled: 5 failed attempts → 15 min lockout. + +### Roles & policies +- Role constants in Domain-adjacent shared location (`UserRoles` static class in Application): `Admin`, `Staff`, `Customer`. +- Registration is public but always assigns `Customer` role. Staff/Admin exist only via seeding. +- Endpoint gating: role constants in policy strings (e.g., `RequireRoles(UserRoles.Admin, UserRoles.Staff)` helper extension replacing magic strings). +- Ownership checks become plain data comparisons inside application services: services receive a `CallerContext { UserId, IsInRole(...) }` record resolved in Api from `ClaimsPrincipal`. The resource-based `AuthorizationHandler`/`Operations` classes are deleted (they existed to work around services calling `IAuthorizationService`). +### Error contract +`ProblemDetails` everywhere with stable machine-readable extension property `code`. +Status semantics: **400** = request could not be understood (framework binding failures); +**422** = well-formed payload that fails input/business validation; **409** = well-formed +request that conflicts with current persistent state. + +| Situation | HTTP | code | +|---|---|---| +| Malformed request (binding failure) | 400 | framework default | +| Validation failure (value object / entity rule) | 422 | `validation_failed` | +| Domain rule violation on input | 422 | `domain_rule_violated` | +| Registration policy failure | 422 | `registration_invalid` | +| Proposal exceeds limit | 422 | `proposal_exceeds_limit` | +| Entity not found | 404 | `{entity}_not_found` | +| Negotiation closed/terminal | 409 | `negotiation_closed` | +| No proposals remaining | 409 | `no_proposals_remaining` | +| Open negotiation already exists | 409 | `negotiation_already_open` | +| Email already registered | 409 | `email_already_registered` | +| Optimistic concurrency | 409 | `conflict` | +| Auth required / bad credentials | 401 | `unauthorized` | +| Forbidden | 403 | `forbidden` | + +Production responses contain no internal exception text +(`ExceptionDetail` shown only in Development). The 499-mapped `OperationCanceledException` handler is kept (client-abort is legitimate telemetry). + +--- + +## 6. API Surface (minimal APIs) + +Version prefix `/api/v1`. All endpoints accept `CancellationToken`. Success responses use `TypedResults` (typed `Ok<>`, `Created<>`, `NoContent`). Request records are plain DTOs with no validation attributes; all input invariants are enforced by the domain (entity rules, value objects, Identity policy) and mapped to ProblemDetails by the global exception handler. FluentValidation deleted. + +### Auth — `/api/v1/auth` +| Method | Route | Auth | Body → Response | +|---|---|---|---| +| POST | `/register` | anon | `{email, password}` → 201 `{userId}` | +| POST | `/login` | anon | `{email, password}` → 200 `{accessToken, expiresAtUtc, email, roles[]}` | +| GET | `/me` | authed | → 200 `{userId, email, roles[]}` | + +### Products — `/api/v1/products` +| Method | Route | Auth | Notes | +|---|---|---|---| +| GET | `/` | anon | query: `search`, `minPrice`, `maxPrice`, `sortBy(name|price)`, `sortDesc(bool)`, `page≥1`, `pageSize∈[1,100]` → 200 `PagedResult` | +| GET | `/{id}` | anon | → 200 / 404 | +| POST | `/` | Admin, Staff | → 201 + Location | +| PUT | `/{id}` | Admin, Staff | → 200 updated / 404 / 400 | +| DELETE | `/{id}` | Admin | → 204 / 404 | + +OData deleted (package, EDM config, `[EnableQuery]`). Filtering/sorting/paging composed over projected `IQueryable` server-side; response envelope `{items, page, pageSize, totalCount}`. Response caching retained only on the two anonymous GET product routes (`OutputCache` 30s, no authenticated-route caching — fixes vary-by-user hazard). + +### Negotiations — `/api/v1/negotiations` +| Method | Route | Auth | Notes | +|---|---|---|---| +| POST | `/` | Customer | `{productId, proposedPrice}` → 201; 409 if open negotiation exists for pair; 400 over-limit | +| GET | `/mine` | Customer | own negotiations, paged | +| GET | `/` | Admin, Staff | all, paged | +| GET | `/{id}` | owner ∨ Admin ∨ Staff | 404 vs 403 distinction preserved | +| PATCH | `/{id}/proposals` | owner (Customer) | `{proposedPrice}` → 200 `{outcome, proposalsRemaining, currentOffer}`; outcome ∈ `counter_proposed \| auto_rejected`; 409 when closed/no-budget | +| POST | `/{id}/accept` | Admin, Staff | → 200 terminal state | +| POST | `/{id}/decline` | Admin, Staff | → 200 terminal-or-open state | +| DELETE | `/{id}` | owner ∨ Admin | withdraw/delete → 204 | + +`GET /me` and ownership resolution replace anonymous-claims guessing; `HttpExecutionContext` crash fixed by safe claim parsing returning null for anonymous. + +--- + +## 7. Cross-Cutting Platform + +### Observability +- Serilog: console + rolling file; Loki sink becomes **opt-in** (enabled only when config section present). Request correlation comes from OpenTelemetry trace context instead of the CorrelationId enricher package. +- **OpenTelemetry** added: ASP.NET Core + EF Core + runtime instrumentation, OTLP exporter activated by standard `OTEL_*` env vars; disabled by default locally. + +### Resilience & safety +- Rate limiting (.NET built-in): fixed window 10 req/min per IP on `/auth/*`; global concurrency limiter sane defaults. +- CORS: policy from config (`Cors:AllowedOrigins`); empty ⇒ no cross-origin allowed; Development defaults add localhost origins. +- Health checks: `/health/live` (self), `/health/ready` (Npgsql DB connectivity) via built-in EF health check. AspNetCore.HealthChecks.UI trio of packages deleted (dashboard YAGNI). + +### Packaging & CI +- Multi-stage `Dockerfile` (build → test → runtime, non-root user, healthcheck hitting `/health/live`). +- `docker-compose.yml`: `api` + `postgres:17-alpine` volume-backed; env-driven config incl. seed credentials. +- `.dockerignore`, `.gitignore` additions (`artifacts/`, `logs/`). +- `.github/workflows/ci.yml`: checkout → setup-dotnet 10 (NuGet cache) → `dotnet restore` → `dotnet format --verify-no-changes` → `dotnet build -c Release` → `dotnet test --collect coverage` → upload OpenAPI doc artifact. +- `dependabot.yml`: nuget + github-actions ecosystems, weekly. +- OpenAPI doc generation moves to `artifacts/openapi/` (gitignored; CI artifact). + +### Configuration schema (options pattern, custom `IValidateOptions` + `ValidateOnStart`) +``` +Jwt:{Issuer, Audience, SecretKey, ExpiryMinutes} +Database:ConnectionString +Cors:AllowedOrigins[] +Seeding:{AdminEmail, AdminPassword, StaffEmail, StaffPassword, SeedSampleProducts} +Loki:{Url, ...} (optional presence enables sink) +``` +All committed secrets removed from appsettings*.json; appsettings contains only structural defaults. + +--- + +## 8. Testing Strategy + +### UnitTests (fast, no host, NSubstitute + Bogus) +- Negotiation lifecycle exhaustive: start (valid/over-limit), counter-propose happy path, auto-reject boundary (=limit passes, just-over rejects), budget exhaustion, accept/decline transitions, operations on terminal states. +- Product create/update rules incl. no-op guard; Price VO invariants; factories; policy boundaries. +- Application services with substituted repos: result mapping, caller-context ownership branches, paging math. +- JWT token generator: claims/expiry shape (fixed TimeProvider). +- Plain xUnit asserts (FluentAssertions removed). + +### IntegrationTests (WebApplicationFactory + Testcontainers Postgres, Refit clients) +- Real end-to-end auth: register → login (real JWT, no test scheme) → /me. Bad-credentials and lockout paths. +- Products CRUD full role matrix (anon/customer/staff/admin × 5 routes) incl. validation failures, 404s, filter/sort/page correctness. +- Negotiations: create→counter×2→accept; decline→counter→exhaustion→409; auto-reject >2×; double-open conflict; withdraw; cross-user access matrix; concurrency 409 (update stale product). +- ProblemDetails `code` assertions on every error path. +- Factory resets schema between test classes (migrations applied once per collection fixture). + +--- + +## 9. Documentation & Hygiene +- README rewritten: stack, quickstart (compose + user-secrets), endpoint table, negotiation rules, config reference, default seeded accounts (env-configurable, documented for local compose only). +- `.http` file rewritten with real routes; malformed XML docs fixed; PL/EN mixed messages unified to English; typo "appliable" fixed. +- Repo cleanup commit: delete empty dirs/artifacts/logs. + +--- + +## 10. Package Manifest Changes + +**Removed:** `Microsoft.AspNetCore.OData`, `FluentValidation.*`, `AspNetCore.HealthChecks.UI*` (×3), `Microsoft.AspNetCore.Components.QuickGrid.EntityFrameworkAdapter`, `Microsoft.VisualStudio.Web.CodeGeneration.Design`, `NuGet.Common`, `NuGet.Protocol`, `Microsoft.EntityFrameworkCore.InMemory`, `Serilog.Enrichers.CorrelationId` (replaced by OTel trace correlation). + +**Kept deliberately:** `System.IdentityModel.Tokens.Jwt` in Infrastructure — explicit reference beats transitive for security-sensitive token minting. + +**Added:** `Npgsql.EntityFrameworkCore.PostgreSQL`, `EFCore.NamingConventions`, `OpenTelemetry.Extensions.Hosting`, `OpenTelemetry.Instrumentation.AspNetCore`, `OpenTelemetry.Instrumentation.EntityFrameworkCore`, `OpenTelemetry.Exporter.OpenTelemetryProtocol`, `Testcontainers.PostgreSql` (tests). + +**Unchanged:** Vogen, Serilog.AspNetCore, Serilog.Sinks.Grafana.Loki (opt-in), Scalar.AspNetCore, xunit.v3, NSubstitute, Bogus, coverlet, SonarAnalyzer + Meziantou analyzers, Refit (tests), Microsoft.AspNetCore.Authentication.JwtBearer, Identity.EntityFrameworkCore, Microsoft.AspNetCore.OpenApi, MVC.Testing. + +--- + +## 11. Implementation Phases (order matters) +1. **Cleanup & structure** — delete dead projects/dirs/artifacts/deps; move IDs into Domain; fix csproj graph; solution builds green. +2. **Domain rebuild** — Price VO consolidation, status enum, explicit lifecycle methods + outcomes, policy relocation; unit tests for lifecycle. +3. **Application rebuild** — feature-scoped services with CallerContext, ports, result unions; delete obsolete flows; unit tests. +4. **Infrastructure rebuild** — AppDbContext + Npgsql configurations, partial index, xmin versions, repositories, hardened JWT manager + options validation, migrations, async seeding. +5. **Api rebuild** — minimal-API modules, validation, ProblemDetails mapping, policies, rate limiting, CORS, output caching, health; Program.cs split. +6. **Platform** — Dockerfile, compose, CI workflow, dependabot, OTel wiring, gitignore/OpenAPI artifact moves. +7. **Integration tests** — Testcontainers factory + Refit clients; full matrices from §8. +8. **Docs & final sweep** — README, .http, analyzer-clean build, `dotnet format`, full test run. + +Each phase ends with: build zero-warnings-as-errors green + relevant tests passing. + +## 12. Risks & Mitigations +- **Behavior change risk** (proposal semantics now total-of-3 including initial): documented explicitly in spec + README; matches README's "propose a price for 3 times". +- **Testcontainers requires Docker in CI**: GH Actions `ubuntu-latest` provides Docker natively. +- **Vogen EF converter naming**: verified convention `EfCoreValueConverter`; fallback is manual `HasConversion` lambdas in Infrastructure. +- **Identity + snake_case naming**: Identity tables explicitly re-mapped to their conventional names to avoid breaking UserManager SQL expectations (configurations pin table names). + diff --git a/docs/superpowers/specs/2026-08-23-modular-monolith-design.md b/docs/superpowers/specs/2026-08-23-modular-monolith-design.md new file mode 100644 index 0000000..5ed46ed --- /dev/null +++ b/docs/superpowers/specs/2026-08-23-modular-monolith-design.md @@ -0,0 +1,348 @@ +# PriceNegotiationApp — Modular Monolith Design + +Date: 2026-08-23 +Status: Proposed +Mandate: Restructure the solution into a true modular monolith with one DbContext and one database schema per module, enforcing module boundaries at the compiler level. Greenfield evaluation of all existing abstractions. + +--- + +## 1. Current-State Audit + +The codebase already completed a modernization pass (see `2026-08-23-modernization-design.md`): 4 layered projects (`Domain → Application → Infrastructure → Api`), PostgreSQL + EF Core 10, hardened JWT, minimal APIs, Testcontainers CI. That baseline is solid. This design evaluates it with fresh eyes and finds the following structural weaknesses: + +| # | Finding | Consequence | +|---|---|---| +| F1 | **One `AppDbContext` owns everything** — Identity tables, products, negotiations, customers | Every module's schema changes collide in one migration stream; nothing prevents a negotiation query from joining `products`; modules cannot evolve or be extracted independently | +| F2 | **Layer projects are not boundaries** — `Application` references `Microsoft.EntityFrameworkCore` directly (`NegotiationService` composes `IQueryable` with `LongCountAsync/Skip/Take`) | The "ports" are leaky: repository abstractions expose EF-specific queryables; swapping persistence would break every service | +| F3 | **Repository + UnitOfWork wrappers add ceremony without value** — `UnitOfWork` only forwards `SaveChangesAsync` and translates one exception; repositories forward `Set()` calls | Boilerplate that must be read, maintained, and mocked; EF Core *is* already a unit-of-work + repository over the mapped model | +| F4 | **Feature logic is split across four assemblies** — a single endpoint's code lives in `Api/Modules`, `Application/Features`, `Application/Abstractions`, `Infrastructure/Persistence` | High cost of change per feature; no ownership cohesion; navigation requires jumping between layers even for trivial edits | +| F5 | **Cross-module data need is invisible** — negotiations snapshot the product price at creation via the shared `IProductRepository`; the coupling point is unmarked and unpoliced | Nothing stops future code from adding live joins to products inside negotiation queries | +| F6 | Hygiene: root-level stale project folders (bin/obj only), `.env` tracked in git (placeholder values today, but one `git add .` away from leaking real secrets) | Repo noise; secret-leak hazard | + +### What is worth keeping + +- The domain logic itself: `Negotiation` state machine (proposal budget, auto-reject multiplier, base-price snapshot), policy abstraction, Vogen IDs, xmin optimistic concurrency. +- Operational hardening: strict JWT validation, ProblemDetails + stable error codes, rate limiting, health checks, OTel, Testcontainers integration suite, warnings-as-errors, central package management. +- The API contract (routes, status semantics 400/422/409) — unchanged by this design. + +--- + +## 2. Goals & Non-Goals + +### Goals +1. Three genuine bounded contexts — **Identity**, **Catalog**, **Negotiations** — each a self-contained project owning its domain, features, endpoints, and persistence. +2. **One DbContext + one Postgres schema per module** (`identity`, `catalog`, `negotiations`), with independent migration streams and per-module connection-string override. +3. Compiler-enforced boundaries: modules reference only `BuildingBlocks`, never each other; inter-module interaction exclusively through consumer-defined ports wired in the composition root. +4. Delete ceremony that fails the greenfield test: generic repositories, forwarding UnitOfWork, cross-layer IQueryable leaks, single-use business-rule classes. +5. Keep routes, payloads, status-code semantics, and business rules compatible (verified by the existing integration suite) — with exactly one pinned semantic change, documented in §6 (product-delete vs negotiation history). +6. Fix hygiene items from the audit. + +### Non-Goals +- Microservices, message broker, outbox/inbox, event sourcing — YAGNI at this scale; the design keeps an extraction path open instead of pre-building it. +- Changing any HTTP route, payload, status-code semantic, or business rule. +- Frontend client. +- Multi-database deployment (all three schemas live in one Postgres database by default). + +--- + +## 3. Considered Approaches + +### A. Split contexts inside the existing layered projects (minimal) +Keep `Domain/Application/Infrastructure/Api`; replace `AppDbContext` with three contexts in `Infrastructure`. +- ✔ Cheapest (~2 phases). +- ✘ Fake modularity: all contexts share one assembly, so any code can touch any context; F2–F4 remain; "separate DbContexts" becomes a naming convention rather than a boundary. + +### B. Modular monolith — vertical module projects + schema/context per module (**recommended**) +Three self-contained module projects, a thin host, a tiny shared kernel. Contexts, schemas, and migrations follow the project boundaries. +- ✔ Real boundaries enforced by project references; feature cohesion (one place per feature); independent migrations; honest extraction path (point a module's connection string at another DB later); reads naturally match the bounded contexts. +- ✘ More projects (7 vs 6), one-time restructuring cost, multi-context EF tooling discipline required. + +### C. Full DDD modular monolith with integration-event bus + inbox/outbox (à la enterprise templates) +- ✘ One inter-module interaction exists in the whole system, and it is synchronous-by-nature (price snapshot at creation). An event bus here is pure infrastructure tax. Rejected. + +**Decision: B.** The app has exactly three contexts with distinct lifecycles (auth/security churn vs catalog CRUD vs negotiation state machine) and a single, well-defined cross-context dependency — precisely the shape modular monoliths exist for. + +--- + +## 4. Target Solution Structure + +``` +PriceNegotiationApp.slnx +src/ + PriceNegotiationApp.AppHost/ composition root (renamed from Api): + │ pipeline, authN/authZ validation, ProblemDetails, + │ rate limiting, CORS, output cache, health checks, + │ OTel, Scalar; wires module registration + adapters + PriceNegotiationApp.BuildingBlocks/ ~10 small stable types, zero domain logic: + │ CallerContext, PageQuery, PagedResult, shared + │ exception types + ProblemDetails mapping helpers, + │ shared error codes (unauthorized, forbidden, + │ validation_failed, concurrency_conflict) + PriceNegotiationApp.Modules.Identity/ + │ IdentityModule.cs AddIdentityModule(cfg) / MapAuthEndpoints(ep) + │ Features/Auth/ Register, Login, Me — endpoint + handler + + │ │ request/response records colocated per operation + │ Auth/JwtManager.cs token issuance (module-private) + │ Persistence/ IdentityModuleDbContext (schema "identity"), + │ Configurations/, Migrations/, design-time factory + │ Seeding/ roles + admin/staff accounts (config-driven) + │ Public/UserRoles.cs role constants consumed by AppHost policies + PriceNegotiationApp.Modules.Catalog/ + │ CatalogModule.cs AddCatalogModule(cfg) / MapCatalogEndpoints(ep) + │ Features/Products/ List, Get, Create, Update, Delete slices + │ Domain/Product.cs entity + guards + module-local Price invariant + │ Persistence/ CatalogDbContext (schema "catalog"), Migrations/ + │ Seeding/ sample products (Development only) + PriceNegotiationApp.Modules.Negotiations/ + NegotiationsModule.cs AddNegotiationsModule(cfg) / MapNegotiationEndpoints(ep) + Features/Negotiations/ Create, List(Mine|All), Get, CounterPropose, + │ Accept, Decline, Withdraw slices + Domain/ Negotiation state machine, Customer, + │ INegotiationPolicy + DefaultNegotiationPolicy, + │ module-local Price invariant, Vogen IDs + Ports/IProductPriceProvider.cs the ONE cross-module port (consumer-owned) + Persistence/ NegotiationsDbContext (schema "negotiations"), + Migrations/ +tests/ + PriceNegotiationApp.Modules.Identity.Tests/ JwtManager claims/expiry, lockout config + PriceNegotiationApp.Modules.Catalog.Tests/ product rules, update idempotency + PriceNegotiationApp.Modules.Negotiations.Tests/ lifecycle matrix, policy boundaries, Price VO + PriceNegotiationApp.IntegrationTests/ WebApplicationFactory + Testcontainers PG + (harness unchanged; matrices extended) +``` + +### Dependency rules (enforced by csproj references) + +``` +BuildingBlocks → BCL + DI/logging abstractions only +Modules.{Identity,Catalog,Negotiations} → BuildingBlocks (+ their NuGet packages). NEVER another module. +AppHost → all three modules (registration + adapters only) +``` + +Any PR introducing a module-to-module project reference should fail review by rule; the architecture is auditable by reading five `.csproj` files. + +### Module anatomy convention + +Inside a module there are no internal layer names — the module *is* the boundary. Layout is feature-first: + +``` +Features//.cs // route group declaration + handler + DTOs together +Domain/ // entities, value objects, pure policy — no I/O +Persistence/ // DbContext, configurations, migrations +Module.cs // AddXxx(IConfiguration), MapXxx(IEndpointRouteBuilder) +Public/ // only what other assemblies legitimately consume +``` + +Handlers take their module's `DbContext` directly. No repository interfaces, no IUnitOfWork (rationale: §7). + +--- + +## 5. Persistence Design + +### Contexts & schemas + +| Context | Project | Schema | Tables | +|---|---|---|---| +| `IdentityModuleDbContext : IdentityDbContext, Guid>` | Modules.Identity | `identity` | users, roles, user_roles, user_claims, user_roles claims/logins/tokens (pinned snake_case names — carried over unchanged) | +| `CatalogDbContext` | Modules.Catalog | `catalog` | products | +| `NegotiationsDbContext` | Modules.Negotiations | `negotiations` | negotiations, customers | + +Each context: `UseNpgsql(...)` + `UseSnakeCaseNamingConvention()` + `HasDefaultSchema(...)`. `ApplyConfigurationsFromAssembly(.Assembly)` now resolves per-module automatically — a side benefit: configurations physically cannot leak across contexts. + +**No cross-schema foreign keys.** Logical references are plain columns: +- `negotiations.product_id` (Guid) — no FK to `catalog.products` +- `negotiations.customer_id` → FK within schema to `customers` +- `customers.identity_user_id` (Guid, unique index) — no FK to `identity.users` + +This is what makes each module's schema independently migratable and extractable. Referential honesty is enforced in handlers (the producer must exist *now*, then is snapshotted — see §6). + +Carried over unchanged: xmin-system-column optimistic concurrency on `products` and `negotiations`; partial unique index `(product_id, customer_id) WHERE status = 'Open'`; Guid v7 keys; status stored as int. + +### Migrations + +- Per-context migration sets in each module's `Persistence/Migrations/`. +- Tooling: `dotnet ef migrations add X --project src/...Modules.Y --context YDbContext` (context flag mandatory — documented in README to prevent foot-guns). +- Startup: one `MigrationHostedService` (AppHost) applies contexts in fixed order **identity → catalog → negotiations**, fail-fast on error. Idempotent by nature. +- Existing deployments: this is a schema rename/relocation. For disposable environments (compose volume, CI) start clean. A one-off SQL copy script (old public tables → new schemas, remapping identity table names) ships under `docs/sql/legacy-data-migration.sql` for any persistent environment. + +### Connection strings + +```jsonc +"Database": { + "ConnectionString": "", + "Modules": { + "Identity": { "ConnectionString": "" }, + "Catalog": { "ConnectionString": "" }, + "Negotiations": { "ConnectionString": "" } + } +} +``` + +Default topology = one database, three schemas. Overrides exist so a module can be moved to its own database (or replaced by a stub) without code change — the seam that makes "monolith first" honest. + +### Health & readiness + +`AddDbContextCheck()` per context under the `ready` tag; `/health/ready` reports all three. Liveness unchanged. + +--- + +## 6. Inter-Module Communication + +There is exactly **one** runtime cross-module interaction: creating a negotiation must verify the product exists and capture its price for the snapshot. + +Pattern: **consumer-owned port, host-wired adapter**. + +```csharp +// Modules.Negotiations/Ports/IProductPriceProvider.cs +public interface IProductPriceProvider +{ + /// null when the product does not exist + Task GetAsync(Guid productId, CancellationToken ct); +} +public readonly record struct ProductSnapshot(Guid ProductId, decimal Price); + +// AppHost/Composition/CatalogToNegotiations.cs — the entire integration surface +internal sealed class CatalogToNegotiations(CatalogDbContext db) : IProductPriceProvider +{ + public async Task GetAsync(Guid productId, CancellationToken ct) => + await db.Products + .Where(p => p.Id.Value == productId) + .Select(p => new ProductSnapshot(p.Id.Value, p.Price)) + .FirstOrDefaultAsync(ct); +} +``` + +Properties: +- Zero module→module references; Negotiations compiles without Catalog existing. +- The adapter is the audit point: grep `Composition/` and you see every inter-module edge. +- Reads project primitives (Guid/decimal) across the boundary — foreign aggregate types never leak. +- Future replacement (cached façade, gRPC client, message-driven replica) touches one file. + +Everything else stays strictly intra-module: staff accept/decline, withdraw, listing — none need catalog data because the base-price snapshot already decoupled ongoing negotiations from product rows. `GET /negotiations/{id}` responses intentionally do not join product names (existing behavior, now an explicit architectural property). + +**Pinned behavioral decision:** today, `negotiations.product_id` carries a real FK (`Restrict`), so deleting a product that has *any* negotiation history fails at the database level (surfacing as a server error). Under separate schemas that FK must go, and the chosen replacement semantics are: **deleting a product succeeds; its negotiations survive on their price snapshots** (consistent with how closed-negotiation history is already preserved). This is the one intentional behavior change in the refactor; the integration suite pins it with an explicit test, and the README notes it. If the business later prefers delete-blocking when open negotiations exist, it becomes an explicit port call in the catalog delete handler returning `409 negotiation_exists_for_product` — not a hidden FK side effect. + +--- + +## 7. Application-Code Simplification (greenfield deletions) + +| Removed | Replacement | Rationale | +|---|---|---| +| `IProductRepository`, `INegotiationRepository`, `ICustomerRepository` + implementations | Handlers use the module `DbContext` directly | EF Core already implements repository/UoW semantics over mapped aggregates; wrappers only forwarded calls and leaked `IQueryable` into Application (F2/F3) | +| `IUnitOfWork` + `UnitOfWork` | `SaveChangesAsync` on the context inside each handler; concurrency translation via a `BuildingBlocks` extension method `SaveChangesWithConflictDetectionAsync(this DbContext)` | One exception-mapping line does not justify an abstraction and a second DI registration per module | +| `IUserAccountStore` | Identity handlers use `UserManager`/`SignInManager` directly (already referenced by the module) | Port existed only to keep ASP.NET types out of the old Application layer; the Identity module legitimately hosts them | +| `IJwtTokenGenerator` (public port) | `JwtManager` stays, becomes module-private | Sole consumer is the login handler inside the same module | +| `IBusinessRule` / `Entity.CheckRule` pattern + two single-use rule classes | Inline guard methods on entities | Two single-use classes validating name length is ceremony, not modeling; domain exceptions (which remain) carry the same semantics | +| `Application` project, `Domain` project, `Infrastructure` project, old `Api` project | Distributed into modules as per §4 | F4: feature cohesion beats technical layering at this scale | +| Root stale project folders; `logs/` artifacts | Deleted; `.env` gitignored (`.env.example` remains tracked) | F6 | + +What deliberately survives: +- **Vogen** ID value objects — cheap type safety; IDs become module-private except where a port signature needs them (none currently — ports speak `Guid`). +- **`INegotiationPolicy`/`DefaultNegotiationPolicy`** — pure domain logic, injected into `Negotiation.Start/CounterPropose`; unit-testable without I/O. +- **Domain exceptions + global exception handler + stable ProblemDetails `code` values** — the error contract is frozen; per-entity codes (`product_not_found`, …) move next to their features, generic ones stay in BuildingBlocks. +- **CallerContext** resolved in AppHost from `ClaimsPrincipal`, passed to handlers as a plain record. + +--- + +## 8. Host Composition (AppHost) + +`Program.cs` remains thin; composition order makes the system legible: + +```csharp +var builder = WebApplication.CreateBuilder(args); + +builder.Host.UseSerilog(/* unchanged */); + +builder.Services + .AddBuildingBlocks() // ProblemDetails + exception handler + .AddIdentityModule(builder.Configuration) // context, Identity core, JWT issuance, seeding + .AddCatalogModule(builder.Configuration) // context, output-cache usage, dev seeder + .AddNegotiationsModule(builder.Configuration) // context, policy singleton + .AddSingleton(); // the adapter seam + +builder.Services.AddJwtAuthentication(builder.Configuration); // validation only (issuer/audience/lifetime/key) +// rate limiting ("auth" policy), CORS, output cache ("short"), health checks ×3, +// OpenAPI + Scalar, OTel — all unchanged, host-owned + +var app = builder.Build(); +app.UsePipeline(); +app.MapAuthEndpoints(); +app.MapCatalogEndpoints(); +app.MapNegotiationEndpoints(); +await app.RunAsync(); +``` + +Endpoint groups keep their existing routes, auth requirements, rate-limit (`auth` policy on register/login), and cache (`short` policy on anonymous product GETs) declarations — only the file they live in moves. + +--- + +## 9. Testing Strategy + +**Per-module unit test projects are part of the boundary enforcement**: a module's tests can only reach the module's public surface plus its internals via project reference — which is fine, since tests ship with the module. What they may not do is reference another module; the reference graph proves it. + +- **Negotiations.Tests**: exhaustive lifecycle matrix (start valid/over-limit; counter happy path; auto-reject at ±ε of `base × multiplier`; budget exhaustion; accept/decline/withdraw transitions; operations on terminal states), policy boundaries, Price invariant, remaining-proposal math. Pure domain — no mocks needed after the repository deletion; NSubstitute shrinks to near-zero. +- **Catalog.Tests**: create/update rules, idempotent PUT no-op guard, decimal precision. +- **Identity.Tests**: JwtManager claim shape/expiry with `FakeTimeProvider`, options validator rejection cases. +- **IntegrationTests**: harness untouched (WebApplicationFactory + Testcontainers PG, `UseSetting` overrides gain per-module connection defaults implicitly). Suites re-pointed at unchanged routes. Additions: + - migration ordering smoke (fresh container → all three schemas present); + - `/health/ready` aggregates three DB checks; + - product deleted ⇒ negotiation survives with snapshot (§6); + - existing RBAC/validation/paging/lifecycle/error-code matrices pass byte-identically — the regression gate for the whole refactor. + +CI pipeline steps are unchanged (restore → format → build → unit → integration); only project discovery broadens automatically. + +--- + +## 10. Configuration Schema (delta only) + +All existing keys unchanged. Added: + +| Key | Purpose | +|---|---| +| `Database:Modules:{Identity,Catalog,Negotiations}:ConnectionString` | optional per-module override; falls back to `Database:ConnectionString` | + +`.env.example` gains nothing (compose keeps single DB). `.env` becomes gitignored. + +--- + +## 11. Implementation Plan + +Sequencing principle: **split the data layer first inside known-good structure, then move code** — EF tooling risk and structural-move risk never land in the same phase. Every phase ends with zero-warning build + green tests. + +| Phase | Scope | Verify | Size | +|---|---|---|---| +| **0. Hygiene** | Delete stale root project folders; gitignore `.env` + `logs/`; confirm slnx untouched | `git status` clean-ish; build green | S | +| **1. Contexts split (in place)** | Inside current `Infrastructure`: create `IdentityModuleDbContext` (schema-pinned Identity tables), `CatalogDbContext`, `NegotiationsDbContext` with `HasDefaultSchema`; regenerate three migration sets; repos take their context; `MigrationHostedService` applies ordered; health checks ×3; per-connection-string plumbing | Full integration suite vs migrated-fresh DB + legacy-copy script smoke | M | +| **2. BuildingBlocks** | New project; move `CallerContext`, `PageQuery`, `PagedResult`, shared exceptions + codes, `SaveChangesWithConflictDetectionAsync`; all projects reference it | Build green; unit tests move compile | S | +| **3. Negotiations module carve-out** | New project; move Negotiation/Customer/policy/IDs + negotiation endpoints; introduce `IProductPriceProvider` port + temporary adapter over legacy context; delete negotiation repos; move lifecycle unit tests into module test project | Negotiations suites green end-to-end | M | +| **4. Catalog module carve-out** | Same motion for Product + product endpoints + dev seeding + `Catalog.Tests`; swap §6 adapter onto `CatalogDbContext` | Products matrix + cross-module snapshot tests green | M | +| **5. Identity module carve-out** | Move ApplicationUser, Identity store wiring (UserManager direct), JwtManager + options validator, auth endpoints + seeding + `Identity.Tests`; auth rate-limit declarations ride along | Auth flow + lockout tests green | M | +| **6. Legacy deletion + rename** | Delete `Domain/Application/Infrastructure/Api` projects; rename `Api→AppHost` (root namespace, OTel service name, Dockerfile paths, slnx); final sln graph check (5 src + 4 test projects) | Clean build from scratch; `dotnet format` | S | +| **7. Docs & polish** | README architecture section (modular diagram, multi-context EF cheat-sheet, config delta), `.http` sanity pass, legacy SQL script finalized | CI green end-to-end | S | + +Critical path: 1 → 3 → 4; phases 2 and 5 parallelize off it. Estimated total ≈ 3–5 focused sessions. + +### Definition of done +- [ ] `grep -r "DbContext" src --include=*.csproj -l` shows each context in exactly one module project. +- [ ] No module `.csproj` references another module. +- [ ] `dotnet ef migrations list --context X` shows an independent stream per module. +- [ ] Integration suite passes unchanged, plus the four additions in §9. +- [ ] No `IRepository`/`IUnitOfWork` symbols remain; Application/EF leakage impossible (project gone). +- [ ] `.env` untracked; stale folders gone; warnings-as-errors build green. + +--- + +## 12. Risks & Mitigations + +| Risk | Mitigation | +|---|---| +| Multi-context EF tooling mistakes (migrations landing in wrong context/folder) | Mandatory `--context` flag documented; per-project migrations folder asserted in review; Phase 1 isolates all tooling risk before any code moves | +| Identity table remap regressions (UserManager raw SQL expectations) | Pinned snake_case mappings carried over verbatim; auth integration suite exercises register/login/lockout against the remapped schema before any structural move | +| Data loss on schema relocation for persistent envs | Ship `docs/sql/legacy-data-migration.sql`; compose/CI default to fresh volume (documented) | +| Seeding double-execution across three hosted services | Seeders stay idempotent (existence checks), one per schema — order-independent by construction | +| Boundary erosion over time ("just one more join") | Adapter file is the single sanctioned edge + per-module test projects enforce reference isolation + README documents the rule | +| Output-cache/auth-policy drift while endpoints move | Route/auth/cache attributes copied verbatim; integration matrices assert identical status codes before/after | + +## 13. Explicitly Deferred (extraction path) + +When a module actually needs independence: point its `Database:Modules:*:ConnectionString` at a separate database, replace the host adapter with an HTTP/gRPC client or event consumer behind the same port. Nothing above pre-builds that — but nothing above blocks it either. That asymmetry is the whole point of the exercise. diff --git a/docs/superpowers/specs/2026-08-24-project-structure-design.md b/docs/superpowers/specs/2026-08-24-project-structure-design.md new file mode 100644 index 0000000..93c4046 --- /dev/null +++ b/docs/superpowers/specs/2026-08-24-project-structure-design.md @@ -0,0 +1,139 @@ +# Project Structure Audit & Restructure — Design + +Date: 2026-08-24 +Status: Approved (Approach A — normalized modular monolith) + +## Context + +The solution is a disciplined modular monolith targeting .NET 10: three feature modules +(Catalog, Identity, Negotiations), a shared `SharedKernel` kernel, and a single host. +Dependency rules are already strict (modules reference only SharedKernel; the host is the +composition root; one sanctioned cross-module edge via dependency inversion). This restructure +tightens ownership boundaries, normalizes layout, removes duplication and cruft. It changes no +API routes, database schemas/migrations, or feature behavior. + +Audit findings addressed: + +- `AppHost` name falsely implies .NET Aspire (it is a plain ASP.NET Core host). +- Leaky shared kernel: `SharedKernel.ProductQuery` is Catalog domain logic. + `UserRoles` was re-examined and stays: it is cross-cutting authorization vocabulary used by + the host policies plus Catalog, Identity, and Negotiations endpoint gates — same category as + `Policies`/`ErrorCodes`. +- Asymmetric module internals: only Negotiations has `Ports/`; only Identity has `Public/` and + `Auth/`; Negotiations' `Features/` is flat while Catalog nests per entity. +- Ownership is convention-only: module implementation types are `public`, so nothing prevents + cross-assembly reach-ins at compile time. +- Duplicated plumbing: three near-identical seeding hosted services and design-time DbContext + factories. +- Cruft: ghost `tests/PriceNegotiationApp.UnitTests/` directory (no csproj, untracked), unused + `NSubstitute` CPM entry, dead transitive-overrides block in `Directory.Packages.props`. + +## 1. Solution & project graph + +``` +PriceNegotiationApp.slnx +├── src/ +│ ├── PriceNegotiationApp.Api/ ← renamed from AppHost +│ ├── PriceNegotiationApp.SharedKernel/ ← slimmed generic-only kernel +│ ├── PriceNegotiationApp.Modules.Catalog/ +│ ├── PriceNegotiationApp.Modules.Identity/ +│ └── PriceNegotiationApp.Modules.Negotiations/ +└── tests/ + ├── PriceNegotiationApp.IntegrationTests/ + └── PriceNegotiationApp.Modules.{Catalog|Identity|Negotiations}.Tests/ +``` + +- Dependency rule unchanged: modules reference **only** SharedKernel; Api references all + modules as composition root; tests reference their subject (IntegrationTests → Api). +- The single inter-module edge remains `Ports/IProductPriceProvider` implemented by + `Api/Composition/CatalogToNegotiations`. +- No projects added or merged; one rename (`AppHost` → `Api`) with full namespace updates. + +## 2. Normalized module layout + +Every module uses the identical structure: + +``` +Modules.X/ +├── XModule.cs ← public registration entry point +├── XEndpoints.cs ← public endpoint mapping +├── Domain/ ← entities, value objects, domain policies (internal) +├── Features// ← one folder per feature group: handlers + models together +├── Persistence/ ← DbContext, configurations, migrations, design-time factory (internal) +├── Ports/ ← interfaces this module needs from outside (public — the +│ host must be able to implement them for DI wiring) +├── Public/ ← contracts other assemblies may consume — ONLY public surface +└── Seeding/ ← module seeder built on shared base +``` + +Concrete moves: + +- Negotiations: flat `Features/*` files move under `Features/Negotiations/`; + `Features/NegotiationAccess.cs` and `Features/NegotiationModels.cs` follow. +- Identity: `Auth/JwtManager.cs`, `Auth/JwtOptions.cs`, `Auth/JwtOptionsValidator.cs` fold into + `Features/Auth/`; `Features/Auth/` content stays there. +- Catalog gains `Public/` only if it has a cross-module contract today; if it exposes none, the + folder is omitted rather than created empty. +- `UserRoles` stays in SharedKernel (see audit notes above). +- `ProductQuery` moves from SharedKernel to `Modules.Catalog/Features/Products/ProductQuery.cs`. + +Rule: **`Public/` plus the two root files are the ownership boundary** — everything else in a +module is an implementation detail. Folders exist only when they have content; no empty folders +are created for symmetry's sake. + +## 3. Slim shared kernel + +`SharedKernel` keeps only assembly-agnostic primitives: + +- `CallerContext` + extensions, `DbConnections`, `EndpointConventionExtensions`, `ErrorCodes`, + `Exceptions`, `PagedResult`, `PageQuery`, `Policies`, `UserRoles` +- New shared plumbing bases: `ModuleSeedingHostedServiceBase`, + `DesignTimeDbContextFactoryBase` + +Nothing Catalog-, Identity-, or Negotiations-specific remains in the kernel. + +## 4. Ownership enforcement (compile-time) + +- All `Domain/`, `Features/`, `Persistence/`, `Seeding/` types become `internal`. Types in + `Ports/` stay `public`: they are the module's required-services contract, which the host must + implement and register against. +- Only `Public/` contents and `XModule.cs` / `XEndpoints.cs` remain `public`. +- The composition root (Api) is granted `InternalsVisibleTo` by each module: it legitimately + reaches into module internals for the sanctioned adapter (`CatalogToNegotiations`) and the + central exception mapper. This privilege applies only to the host — never to other modules. +- Test projects get access via `InternalsVisibleTo` in each module's csproj. +- Module-to-module access stays impossible: no module references another and internals are not + visible to them; violations become build errors under the existing warnings-as-errors policy. + +## 5. De-duplication + +- Seeding: one generic base hosted service in SharedKernel; each module supplies DbContext, + seed logic, and options binding only. +- Design-time factories: shared abstract base in SharedKernel; each module's factory reduces + to a few lines naming its DbContext and connection string. +- The duplicated `Price` value object in Catalog vs Negotiations domains intentionally remains — + bounded-context hygiene, not an accident. + +## 6. Cruft & hygiene + +- Delete ghost `tests/PriceNegotiationApp.UnitTests/`. +- Remove unused `NSubstitute` from `Directory.Packages.props`; remove the empty + transitive-overrides block. +- Update `PriceNegotiationApp.http` host/port to match launchSettings. +- Update Dockerfile/docker-compose for the `Api` rename; refresh README architecture section. + +## 7. Verification + +1. Full solution build with warnings-as-errors and enforced code style (existing gates). +2. All test suites green: per-module unit tests + IntegrationTests (Testcontainers PostgreSQL) + covering auth flow, products CRUD, negotiation lifecycle end-to-end. +3. Boundary audit greps: no module namespace referenced outside itself except through its + `Public/` surface; host touches only public module entry points. +4. No new package dependencies introduced. + +## Out of scope + +- API route/response contract changes +- Database schema or migration changes +- Feature behavior changes +- Aspire adoption or service decomposition diff --git a/docs/superpowers/specs/2026-08-25-bogus-test-data-design.md b/docs/superpowers/specs/2026-08-25-bogus-test-data-design.md new file mode 100644 index 0000000..552d4c3 --- /dev/null +++ b/docs/superpowers/specs/2026-08-25-bogus-test-data-design.md @@ -0,0 +1,107 @@ +# Bogus Test Data Adoption & Failure-Diagnostic Artifacts — Design + +Date: 2026-08-25 +Scope: all five test projects. Goal: every non-semantic test value comes from Bogus, +every generated value is recorded so failures explain themselves, and failed runs are +reproducible byte-for-byte via a seed. + +--- + +## 1. Doctrine — what stays literal, what becomes Bogus + +| Kind | Definition | Rule | +|---|---|---| +| **Semantic literal** | The specific value decides the outcome | Stays inline. Prices at boundaries (`0`, `-1`, `200m` exactly-at-limit, `201m` one-over), empty/null/whitespace partitions, lockout threshold `5`, status/error-code strings | +| **Arbitrary instance** | Any valid value proves the same proposition | **Bogus.** Names, valid emails, descriptions, denial payloads (`"X"`, `"C"`), jwt subject email | +| **Unique-constrained** | Must not repeat within a run (DB unique indexes, duplicate-registration tests) | **Bogus + monotonic suffix** (`UniqueEmail()`), never raw `Guid` string-mashing | +| **Pattern-value** | Shape matters, content does not | Either a documented literal (`'k'×48` JWT secret — length is the point) or a shaped Bogus call | + +Litmus test used in review: *"If this value were randomly different, would the test still +verify exactly the same behavior?"* Yes → Bogus. No → keep the literal and it should be +obvious from the test name why that value is special. + +## 2. TestKit — one small shared project + +New `tests/PriceNegotiationApp.TestKit/PriceNegotiationApp.TestKit.csproj` +(classlib, `net10.0`, references **Bogus only** — no xunit dependency). Referenced by all +five existing test projects. + +```csharp +public static class Fuzz +{ + public static int RunSeed { get; } // TEST_SEED env or 8675309 + public static Faker NewFaker(string scope); // UseSeed(stable hash of scope+counter) + public static decimal Price(...); // positive, 2dp + public static string ProductName(); // Commerce.ProductName, clamped ≤ 200 chars + public static string Email(); + public static string UniqueEmail(); // Email + monotonic suffix + public static string Password(int len=14); // upper+lower+digit+symbol guaranteed + public static string Text(int minLen, int maxLen); + public static void Dump(string label, object value); // JSON line -> Sink + public static Action? Sink; +} +``` + +- **Determinism:** `UseSeed` derives from `RunSeed` + scope name + per-process counter — + identical command lines produce identical data; parallel collections never share a Faker. +- **Reproduction:** run a failing test again with `TEST_SEED=` and + the same `--filter` — every generated value repeats exactly. +- **Visibility:** each test assembly carries a tiny `[ModuleInitializer]` bootstrap that + sets `Fuzz.Sink = line => TestContext.Current?.TestOutputHelper?.WriteLine(line)`, + so dumps flow into xunit/MTP output (and therefore into TRX files) without any + per-test boilerplate beyond calling `Dump` once after arranging data. + +## 3. Failure artifacts — where results live + +- All five test projects add `Microsoft.Testing.Extensions.CodeCoverage` + *(already present)* **and** `Microsoft.Testing.Extensions.TrxReport`. +- Local + CI test invocations append `--report-trx`; MTP writes + `TestResults/.trx` per assembly containing: test results, captured output + (seeds + Fuzz dumps + Shouldly actual-vs-expected), duration, failure messages. +- CI gains an `upload-artifact@v4` step publishing `TestResults/**` + (TRX + cobertura) with 7-day retention — click a failed CI run, download, open the TRX, + read the exact generated data. +- README testing section documents: how to run with TRX, how to read `TEST_SEED` from a + failure, how to reproduce. + +## 4. Per-file migration map + +Legend: 🔄 convert to Bogus · ✅ keep as-is · ➕ new case + +| File | Changes | +|---|---| +| `Modules.Catalog.Tests/ProductRulesShould.cs` | 🔄 `"Keyboard"`, `"Old"→"New"`, `"Same"`, `"Thing"`, `10m/20m/99.5m` → `Fuzz.ProductName()/Price()`; trimming fact pads a Fuzz name with spaces and asserts equality against `.Trim()` ✅ all InlineData partitions (null/""/" ", 0/-1, 201-char) stay | +| `Modules.Catalog.Tests/UpdateIdempotencyShould.cs` | ✅ already Bogus — migrate `new Faker()` → `Fuzz.NewFaker(scope)` + `Dump` the pair | +| `Modules.Identity.Tests/JwtManagerShould.cs` | 🔄 `"user@test.dev"` → `Fuzz.Email()` ✅ roles `["Customer"]`, secret `'k'×48` stay | +| `Modules.Identity.Tests/SeedingOptionsValidatorShould.cs` | 🔄 happy-path `Options()` defaults → `Fuzz.Email()/Fuzz.Password()`; ➕ whitespace-only email theory case `" "` (validator uses IsNullOrWhiteSpace — currently untested branch) ✅ null/empty/`not-an-email`/`short` partitions stay | +| `Modules.Negotiations.Tests/NegotiationLifecycleShould.cs` | ✅ every number stays (state-machine semantics); 🔄 `_faker = new()` → `Fuzz.NewFaker(scope)`; `Dump(customerId, offers…)` once per arrange | +| `Modules.Negotiations.Tests/DbWriteGuardShould.cs` | ✅ untouched (pure exception plumbing, constraint name is semantic) | +| `IntegrationTests/AuthFlowShould.cs` | 🔄 `"dup.{guid}@"` → `Fuzz.UniqueEmail()`; `"Passw0rd!x"` literals → `Fuzz.Password()` captured in a variable and Dump-ed ✅ `"not-an-email"`, `"short"`, `"WrongPass1!"` stay | +| `IntegrationTests/ProductsShould.cs` | 🔄 `"Anon Probe"`, `"X"`, `"C"`, `"Staff Updated"`, standalone `1m/2m/42m` → Fuzz; ✅ `created.Price + 1` stays relative | +| `IntegrationTests/NegotiationsShould.cs` | 🔄 `"NegProduct{guid}"[..20]` → `Fuzz.ProductName()` ✅ base `100m` and all offer values stay | +| `IntegrationTests/Support/IntegrationTestFixture.cs` | 🔄 `CreateUserAsync` email template → `Fuzz.UniqueEmail()`; password → `Fuzz.Password()` (stored on session for reuse) | +| `IntegrationTests/ConfigurationValidationShould.cs` | 🔄 well-formed origin URLs → `Fuzz` URL helper (scheme https + random host) ✅ malformed partitions stay | +| `ArchitectureTests/*` | ✅ untouched | + +Rule enforced during implementation: **a converted test may not lose an InlineData edge** +— conversion targets only arbitrary instances. + +## 5. Error handling / failure story + +Failed test output contains, in order: `Fuzz` seed banner (`run-seed=… scope=…`), +the JSON dump of arranged values, then the Shouldly failure (actual vs expected). +Nothing else changes about error propagation. + +## 6. Testing the change itself + +- Full suite green in both modes: default seed and `TEST_SEED=` (proves no hidden + coupling to the constant). +- One deliberate flake-check: run Negotiations suite 3× consecutively with different seeds — + all green (proves semantic literals were correctly preserved and Bogus values respect + domain constraints, e.g., generated prices always > 0, generated names ≤ 200 chars). +- CI parity: format check, Release build, full MTP suite with TRX + cobertura artifacts. + +## 7. Non-goals + +Property-based frameworks (CsCheck/FsCheck), shrinking/minimization, golden-file +snapshots, fuzzing concurrency paths, changing production code. diff --git a/docs/superpowers/specs/2026-08-25-ddd-audit-design.md b/docs/superpowers/specs/2026-08-25-ddd-audit-design.md new file mode 100644 index 0000000..e5f5d45 --- /dev/null +++ b/docs/superpowers/specs/2026-08-25-ddd-audit-design.md @@ -0,0 +1,137 @@ +# DDD Audit & Optimized Design — PriceNegotiationApp + +Date: 2026-08-25 +Question set: strategic vs tactical DDD? proper aggregates? protected invariants? proper +bounded contexts? proper domain/integration event handling? +Verdict up front: **strategic DDD is genuinely implemented and above average for a modular +monolith; tactical DDD is solid at the core aggregate with two real findings and one +deliberate anemia. Events are absent — correctly so today — but the seam is undefined.** + +--- + +## 1. Audit Matrix + +| Question | Verdict | Evidence | +|---|---|---| +| Strategic DDD? | **Yes — real** | Three bounded contexts as modules: internal implementations, `InternalsVisibleTo` only to composition root + own tests, one PostgreSQL schema each, zero cross-module type references (ArchUnitNET-enforced) | +| Proper bounded contexts? | **Yes** | Identity = *generic subdomain* (deliberately delegated to ASP.NET Identity, no hand-rolled domain); Catalog = supporting; Negotiations = core. Language inside the core matches the business rules (proposal budget, offer cap, reject-current-offer vs auto-rejection) | +| Context mapping done well? | **Yes** | Single sanctioned edge: consumer-owned port (`IProductPriceProvider` in Negotiations.Ports) satisfied by a composition-root adapter reading Catalog's DbContext — anti-corruption without ceremony | +| SharedKernel right-sized? | **Yes** | Only primitives both sides truly need (CallerContext, paging, error semantics, DbWriteGuard); no leaked domain types | +| Aggregates proper? | **Mostly** | See §2 | +| Invariants protected? | **Core: yes. Cross-aggregate: yes, but placement is implicit** | See F-02 | +| Domain events? | **None exist — correct for current behavior, but the seam is undefined** | F-04 | +| Integration events? | **None exist; no outbox** | Same finding | +| Repositories/UoW? | **DbContext-as-UoW, DbSet-as-collection — deliberate, undocumented** | F-05 | + +## 2. Tactical assessment per aggregate + +### Negotiation (core, root) — exemplary after the lifecycle redesign +- Explicit state machine (`Open → Accepted/Rejected/Withdrawn`), single decision path, + terminal states refuse all operations. +- Invariants live **inside**: proposal budget (`ProposalsUsed < MaxProposals`), offer cap + (`offer ≤ BasePrice × OfferMultiplierLimit`), price positivity via `Price` VO. +- Policy values are **snapshotted at creation** — in-flight negotiations are immune to + config changes (mirrors BasePrice snapshot philosophy). +- References other aggregates by identity only (`ProductId: Guid`, `CustomerId`) — no + object navigation across aggregates. Textbook. +- Optimistic concurrency (`xmin`) guards interleaved counter-proposals. + +### Product (supporting, root) — sound +- Factory + `Update` enforce name ≤200 trimmed / price > 0; idempotent PUT handled by + returning change-flag; `xmin` concurrency. + +### Customer (core, root?) — deliberately anemic, now formally justified +Two fields, a factory, zero behavior. It exists solely to bind an ASP.NET Identity user to +the negotiations schema. It protects exactly one invariant (non-empty identity link) and is +never mutated after creation. Verdict: keep as-is; it is a **reference row**, not a +behavioral aggregate — but the repo should say so (F-03). + +## 3. Findings + +### F-01 — Money inconsistency between contexts (real, fix) +Catalog wraps money in `Price` VO; Negotiation stores `BasePrice`, `CurrentOffer`, +`OfferMultiplierLimit` as raw decimals and validates only at the boundary +(`Price.From(offer)`). Two representations of the same ubiquitous concept across the core +domain. Fix: introduce/adopt `Price` VO inside Negotiation for `BasePrice`/`CurrentOffer` +(EF value conversion already proven in Catalog); multiplier stays decimal (it is a ratio, +not money). + +### F-02 — Cross-aggregate invariant placement is correct but invisible (document + pin) +*"At most one Open negotiation per (product, customer)"* spans two aggregates, so it cannot +live inside `Negotiation`. Current enforcement is actually the recommended stack — partial +unique index (authoritative), endpoint pre-check (friendly 409 fast-path) — but nothing +records that this is intentional or warns against "moving it into the aggregate". +Fix: doc-comment on the aggregate + configuration, plus an integration assertion already +exists (conflict test). No code change beyond comments. + +### F-03 — Anemic Customer (justify in-code) +Add the §2 rationale as XML docs on the entity so nobody "fixes" it into a fake aggregate +later. No behavioral change. + +### F-04 — Event strategy undefined (design the seam, implement nothing yet) +Current flows are single-context and synchronous; no subscriber exists. Introducing +domain/integration events now would be speculative machinery. However BF-01 (deal on +acceptance) and BF-04 (notifications) will need them, so define the pattern now: +- **Domain events:** aggregates collect `IDomainEvent` instances; a SaveChanges + interceptor dispatches to in-process handlers after successful save (same-unit-of-work + consistency). +- **Integration events:** per-module append-only outbox table written in the same + transaction; hosted service publishes (in-proc for monolith, broker-ready later). +- **Trigger:** implement the day BF-01 lands — not before. This spec fixes vocabulary and + location (`Modules./Domain/Events`, `Persistence/Outbox`) so the first feature doesn't + invent its own. + +### F-05 — Repository/UoW stance undocumented (codify) +EF Core conventions here are deliberate: module DbContext = unit of work, `DbSet` = +aggregate collection, feature classes own queries, no generic repositories, no MediatR. +Record as architecture law in README architecture section; add ArchUnitNET guard rails. + +### F-06 — Drift guards missing for DDD rules (cheap, high value) +Existing architecture tests cover modules/kernel/domain-EF-purity. Add three rules: +1. Types in `*.Modules.*.Features*` may depend on EF Core, but types in `*.Domain` must not + reference `*.Persistence` namespaces (reverse leakage guard). +2. No type named `IRepository`/`Repository` anywhere (prevents ceremony re-entry). +3. Only `Api` composition root references more than one module (already covered) — extend + with: `SharedKernel` must not reference any module (covered) — skip duplicates; add + rule: `Ports/*` types never reference `Persistence` (contract purity). + +## 4. Approaches considered + +- **A. Codify + targeted polish (chosen):** F-01 VO fix, F-02/F-03 documentation, F-05 + README law, F-06 three arch rules, F-04 pattern definition only. ~1 day, zero risk to + behavior. +- **B. Full tactical treatment now:** repositories, domain-service layer, events infra, + outbox, CQRS-lite read models. Rejected: speculative complexity with zero subscribers; + violates YAGNI and this repo's own doctrine. +- **C. Docs-only:** leaves F-01 (genuine model inconsistency) unresolved. Rejected. + +## 5. Targeted changes (Approach A) + +| # | Change | Files | +|---|---|---| +| 1 | `Price` VO (Vogen) for `Negotiation.BasePrice`, `Negotiation.CurrentOffer`; EF conversions to `numeric(18,2)`; constructor/boundary validation simplifies (VO validates >0); multiplier stays decimal | `Negotiation.cs`, `NegotiationConfiguration.cs`, migration (type-preserving: numeric(18,2)→numeric(18,2), no data change), response mapping untouched externally (JSON still emits number) | +| 2 | Doc-comments: cross-aggregate uniqueness note on `Negotiation` class + index config; anemia rationale on `Customer` | 2 files | +| 3 | Architecture rules (NetArchTest→ArchUnitNET equivalents): Features↔EF allowed, Domain↛Persistence-namespaces, forbidden `IRepository`/`Repository` type names | `ArchitectureShould.cs` | +| 4 | README architecture section: "Tactical DDD laws" block (aggregate collection = DbSet; no repositories; events deferred to BF-01; policy snapshot rule) | README | +| 5 | Event seam vocabulary section appended to spec (§F-04 above) — no code | this doc | + +Migration note: column types unchanged (`numeric(18,2)` already), so the EF model diff is +conversion-only → **no database migration required** if conversions map identically; +verify via `dotnet ef migrations has-pending-model-changes` (or add empty migration check) +during implementation. + +## 6. Testing strategy + +- Existing Negotiation unit suite must stay green unchanged (proves VO swap is + behavior-preserving); assertions compare `.Value` where they previously compared decimal. +- New unit facts: `BasePrice ≤ 0` rejected through VO path (previously impossible — VO was + only consulted for offers). +- New ArchUnitNET rules get positive+negative fixtures (negative fixture creates a temp + offending type in-memory? Not feasible — instead assert rules pass and rely on CI). +- Full MTP suite + format + Release build (CI parity). + +## 7. Non-goals + +Repositories/UoW abstractions; MediatR; domain events implementation; outbox tables; +changing Customer into a behavioral aggregate; splitting Negotiations further; CQRS read +models. diff --git a/docs/superpowers/specs/2026-08-25-engineering-hardening-design.md b/docs/superpowers/specs/2026-08-25-engineering-hardening-design.md new file mode 100644 index 0000000..87a6e8c --- /dev/null +++ b/docs/superpowers/specs/2026-08-25-engineering-hardening-design.md @@ -0,0 +1,258 @@ +# Engineering Hardening Design — E-01, E-03, E-04, E-10, E-12, E-13, E-18, E-11 + +Date: 2026-08-25 +Source backlog: `docs/engineering-backlog.md` +Scope: eight engineering-experience items. All are additive or config-level changes; +no business behavior changes. E-11 is implemented last and intentionally left +uncommitted. + +--- + +## 1. E-01 — Local telemetry consumer (Aspire Dashboard) + +### Decisions +- **Consumer:** standalone `mcr.microsoft.com/dotnet/aspire-dashboard` container + (image pinned to the current `9.x` minor at implementation time). Rejected: full + Grafana+Loki+Tempo provisioning (deferred as backlog E-02); Seq/custom collector. +- **Wiring:** a separate override file `compose.observability.yml` adds the dashboard + service and injects `OTEL_EXPORTER_OTLP_ENDPOINT=http://aspire-dashboard:18889` + into the api service. The **base `docker-compose.yml` remains untouched** — running + without the override keeps production-shaped behavior identical to today. +- **Code change (required):** `AddApiServices` currently calls `.UseOtlpExporter()` + unconditionally, which makes the exporter hammer `localhost:4317` inside containers + where nothing listens. Gate registration on presence of the endpoint: + +```csharp +var otlpEndpoint = configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]; +if (!string.IsNullOrEmpty(otlpEndpoint)) +{ + builder.Services.AddOpenTelemetry() + .ConfigureResource(resource => resource.AddService("PriceNegotiationApp.Api")) + .WithTracing(tracing => tracing + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation()) + .WithMetrics(metrics => metrics + .AddAspNetCoreInstrumentation() + .AddRuntimeInstrumentation()) + .UseOtlpExporter(); +} +``` + +- **Dashboard security:** `DASHBOARD__FRONTEND__AUTHMODE=Unsecured` acceptable because + the UI port is published only on `127.0.0.1:18888`; OTLP receiver port `18889` stays + internal to the compose network (no host publishing). +- **Local `dotnet run` flow:** start the dashboard via compose, then point the API at it + with a user-secret/environment value `OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:18889`. + Documented in README (short subsection under Health & telemetry). + +### Verification +`docker compose -f docker-compose.yml -f compose.observability.yml up --profile observability` +→ dashboard reachable at http://127.0.0.1:18888, traces/metrics/logs visible after hitting +any endpoint; plain `docker compose up` produces zero exporter error noise in api logs. + +--- + +## 2. E-03 — Meaningful request logs + +Single modification of the existing `UseSerilogRequestLogging()` call site: + +```csharp +app.UseSerilogRequestLogging(options => +{ + options.EnrichDiagnosticContext = (diagnosticContext, httpContext) => + { + diagnosticContext.Set("UserId", httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier)); + diagnosticContext.Set("Roles", string.Join(',', httpContext.User.FindAll(ClaimTypes.Role).Select(c => c.Value))); + diagnosticContext.Set("Endpoint", httpContext.GetEndpoint()?.DisplayName); + diagnosticContext.Set("RemoteIp", httpContext.Connection.RemoteIpAddress?.ToString()); + }; + options.GetLevel = (httpContext, elapsed, ex) => ex is not null + ? LogEventLevel.Error + : IsNoise(httpContext.Request.Path) + ? LogEventLevel.Verbose + : elapsed > 500 ? LogEventLevel.Warning : LogEventLevel.Information; +}); +``` + +- `IsNoise` returns true for paths starting with `/health`, `/scalar`, `/openapi`, + `/favicon` — those requests log at Verbose, invisible under the default Information + sinks but available when debugging locally by flipping the minimum level. +- Slow-request threshold (500 ms) elevates otherwise-informational completions to Warning, + making latency outliers grep-able. +- Enrichments read `HttpContext.User` inside `EnrichDiagnosticContext`, which executes at + response completion — after authentication has populated the principal, despite the + middleware sitting early in the pipeline. + +Rejected: suppressing via a separate short-circuit middleware (loses even Verbose records), +and per-route logger scopes (redundant with Endpoint display name). + +--- + +## 3. E-04 — Readiness detail surface + +`/health/live` stays a bare liveness probe. `/health/ready` gains a custom writer: + +```csharp +app.MapHealthChecks("/health/ready", new HealthCheckOptions +{ + Predicate = r => r.Tags.Contains("ready"), + ResponseWriter = ReadyHealthReport.WriteAsync, +}); +``` + +`ReadyHealthReport.WriteAsync` (new file in `Api`) serializes: + +```json +{ + "status": "Unhealthy", + "totalDurationMs": 42, + "entries": { + "database-identity": { "status": "Healthy", "durationMs": 5 }, + "database-catalog": { "status": "Healthy", "durationMs": 4 }, + "database-negotiations": { "status": "Unhealthy", "durationMs": 30, + "description": "Connection refused (postgres:5432)" } + } +} +``` + +Rules: `description` included **only** when `status != Healthy`; HTTP status code continues +to follow overall health (200/503) via the standard `HealthCheckOptions.ResultStatusCodes` +defaults. Anonymous access unchanged. System.Text.Json, camelCase, no source generation +needed for this fixed small shape. + +--- + +## 4. E-10 — Pin the SDK + +`global.json` becomes: + +```json +{ + "sdk": { + "version": "10.0.303", + "rollForward": "latestFeature" + }, + "test": { + "runner": "Microsoft.Testing.Platform" + } +} +``` + +`latestFeature` accepts any newer patch within the 10.0.3xx feature band (local machine +runs 10.0.303 today; CI's `setup-dotnet@v4` with `10.0.x` resolves the newest 10.0.3xx). +Other bands and future majors are blocked until the pin is deliberately raised. + +--- + +## 5. E-12 — Centralized test-project conventions + +New `Directory.Build.targets` (auto-imported by every project): + +```xml + + + Exe + false + $(NoWarn);CA1707;S1118 + + +``` + +All five test csprojs (`Modules.{Catalog,Identity,Negotiations}.Tests`, +`IntegrationTests`, `ArchitectureTests`) drop their duplicated `` blocks; +they retain only project/package references. Naming condition (`.Tests` suffix) was chosen +over directory sniffing — deterministic and readable. Future test projects inherit the +conventions with zero ceremony. + +--- + +## 6. E-13 — Deterministic/CI build support (non-default parts only) + +`Deterministic` and `TreatWarningsAsErrors`-style flags are already SDK defaults — not +touched. Added to `Directory.Build.props`: + +```xml + + true + + + true + true + + + + +``` + +(GitHub Actions exports `CI=true`; the guard keeps local builds unaffected.) PDBs produced +in CI become reproducible and linked to the GitHub commit. No snupkg work — no packages +are packed today. + +--- + +## 7. E-18 — Uniform startup configuration validation + +Replicates the established JWT pattern (`AddOptions().Bind().ValidateOnStart()` + +`IValidateOptions` singleton): + +| Validator | Location | Rules | +|---|---|---| +| `SeedingOptionsValidator` | Identity module, `Seeding/` | `AdminEmail`/`StaffEmail` non-empty and contain `@`; `AdminPassword`/`StaffPassword` length ≥ 8 (ASP.NET Identity default floor) | +| `RateLimitingOptionsValidator` | Api, `Extensions/` | `AuthPermitLimit` ≥ 1 | +| Cors origins check | Api, `Extensions/` | Each entry in `Cors:AllowedOrigins` parses as absolute `http(s)` URI | + +Registration mirrors `IdentityModule.cs:38-44` (`JwtOptions` precedent): +validators registered as `AddSingleton, TV>();` with +`.ValidateOnStart()` on the corresponding `AddOptions()` binder. Failures abort startup +with an `OptionsValidationException` aggregating every violated rule — identical semantics +to the existing JWT path. + +**Explicitly skipped:** `CatalogSeedingOptions` — a single optional bool has no meaningful +validation surface (YAGNI). A comment in `CatalogModule` notes the deliberate omission. + +### Testing +Unit tests per validator covering the happy path and each individual failure branch. +The two Api-owned validators are tested from the existing `PriceNegotiationApp.IntegrationTests` +assembly as plain xUnit facts (the project already references Api; these tests need no +Docker container). The Identity `SeedingOptionsValidator` tests live in +`PriceNegotiationApp.Modules.Identity.Tests` alongside the other module units. + +--- + +## 8. E-11 — `nuget.config` supply-chain lockdown (LAST, NOT COMMITTED) + +Implemented as the final step of the plan, deliberately excluded from any commit and left +as a local working-tree file: + +```xml + + + + + + + + + + +``` + +Rationale for staying uncommitted: it is a personal-machine hardening preference; committing +would impose feed policy on every consumer of the portfolio repo. `packageSourceMapping` +is omitted — pointless with a single source. + +--- + +## 9. Error handling & rollout notes + +- All changes fail fast or degrade silently-by-design; none alter runtime business paths. +- Rollout order within implementation: E-12/E-10/E-13 (build files) → E-03/E-04/E-01+E-18 + (code) → E-11 last. Each item is independently revertible. +- Compatibility check after build-file changes: full clean restore + Release build + + entire MTP test suite green before proceeding to code items. + +## 10. Out of scope + +Grafana/Loki/Tempo dashboards (backlog E-02), coverage reporting (E-05), image publishing +(E-08), OpenAPI XML docs (E-15) — later waves. diff --git a/docs/superpowers/specs/2026-08-25-handler-extraction-design.md b/docs/superpowers/specs/2026-08-25-handler-extraction-design.md new file mode 100644 index 0000000..be93da1 --- /dev/null +++ b/docs/superpowers/specs/2026-08-25-handler-extraction-design.md @@ -0,0 +1,102 @@ +# Handler Extraction Design — Endpoints Stop Touching Persistence + +Date: 2026-08-25 +Problem: minimal-API lambdas inject `CatalogDbContext` / `NegotiationsDbContext` directly +(13 route handlers) and Identity endpoints consume `UserManager` +directly (2 more). Transport concerns and persistence/application concerns are fused in +the same lambda body. + +--- + +## 1. Chosen pattern — per-operation handler classes (vertical slices) + +Each operation becomes an injectable service; the endpoint becomes a transport adapter. + +```csharp +// BEFORE (Create.cs, abridged) +group.MapPost("/", async (CreateNegotiationRequest request, ClaimsPrincipal principal, + NegotiationsDbContext db, IProductPriceProvider products, INegotiationPolicy policy, + TimeProvider clock, CancellationToken ct) => { /* query + mutate + save + map */ }) + .RequireRoles(UserRoles.Customer); + +// AFTER +internal sealed class CreateNegotiationHandler( + NegotiationsDbContext db, IProductPriceProvider products, + INegotiationPolicy policy, TimeProvider clock) +{ + public async Task HandleAsync( + CreateNegotiationRequest command, CallerContext caller, CancellationToken ct) { … } +} + +// endpoint keeps only transport concerns +group.MapPost("/", async (CreateNegotiationRequest request, ClaimsPrincipal principal, + CreateNegotiationHandler handler, CancellationToken ct) => + TypedResults.Created("/api/v1/negotiations/mine", + await handler.HandleAsync(request, principal.ToCallerContext(), ct))) + .RequireRoles(UserRoles.Customer); +``` + +## 2. Contract rules + +1. **Endpoint files keep:** routes/verbs, status-code shaping via `TypedResults`, auth + roles/attributes, rate-limit & output-cache policies, `ClaimsPrincipal → CallerContext` + translation, named-route metadata (`GetProductById`), cache-policy attachments. +2. **Handler files own:** queries (incl. `AsNoTracking`, `ILike` filters, sorting), + tracking semantics, aggregate mutation, `SaveChangesAsync`, uniqueness-violation + translation via `DbWriteGuard`, response-DTO construction. +3. **Errors:** handlers throw the existing SharedKernel/module exceptions + (`NotFoundException`, `ConflictException`, `ForbiddenAccessException`, + `ClosedNegotiationException`, `ValueObjectValidationException`, …). The + `GlobalExceptionHandler` contract is untouched — zero API-behavior change. +4. **Caller representation:** handlers accept `CallerContext`; `ClaimsPrincipal` never + crosses the endpoint boundary. +5. **Registration:** `internal sealed` handlers registered `AddScoped` explicitly inside + each module's `AddXModule` — greppable, no assembly-scanning magic. +6. **Shared helpers survive:** `NegotiationAccess` remains the persistence-aware helper + consumed by negotiation handlers; `NegotiationResponses.ToResponse` stays the mapper; + Catalog's static `RequireAsync` / `SearchAsync` fold into their handlers. + +## 3. Scope matrix (15 handlers) + +| Module | Operation → Handler | Returns | +|---|---|---| +| Negotiations | POST create → `CreateNegotiationHandler` | `NegotiationResponse` | +| | PATCH proposals → `CounterProposeHandler` | `CounterProposalOutcome` | +| | POST accept → `AcceptHandler` | `StaffActionResponse` | +| | POST decline → `RejectCurrentOfferHandler` | `StaffActionResponse` | +| | DELETE (owner withdraw / admin delete) → `WithdrawHandler` | void (endpoint → 204) | +| | GET one → `GetNegotiationHandler` | `NegotiationResponse` | +| | GET list (staff/admin) → `ListNegotiationsHandler` | `PagedResult` | +| | GET mine → `ListMyNegotiationsHandler` | `PagedResult` | +| Catalog | POST → `CreateProductHandler` | `ProductResponse` | +| | PUT → `UpdateProductHandler` | `ProductResponse` | +| | DELETE → `DeleteProductHandler` | void → 204 | +| | GET one → `GetProductHandler` (absorbs `RequireAsync`) | `ProductResponse` | +| | GET list → `ListProductsHandler` (absorbs `SearchAsync`, keeps `ProductQuery`) | `PagedResult` | +| Identity | POST register → `RegisterUserHandler` (incl. DbWriteGuard race mapping) | `RegistrationResponse` | +| | POST login → `LoginUserHandler` | `AuthResponse` | + +Unchanged / out of scope: `Me` endpoint (pure claims projection, no infrastructure), +health endpoints, seeding hosted services, the `Api` composition-root adapter +(`CatalogToNegotiations` — sanctioned edge), all business behavior. + +## 4. Enforcement + +New ArchUnitNET fact: every type whose name ends with `Endpoints` must not depend on +`Microsoft.EntityFrameworkCore` nor on either module's `.Persistence` namespace. +README *Tactical DDD laws* gains: **"Endpoints are transport adapters; application logic +lives in per-operation handlers (`Features/**/*Handler`)."** + +## 5. Testing strategy + +The full integration suite (37 tests) is the regression harness — routes, verbs, status +codes, ProblemDetails codes are all pinned there and must not move. No unit tests are +added by this refactor itself; handlers become independently constructible (real DbContext +against Testcontainers) enabling cheap handler-level tests later if a slice grows logic. + +## 6. Rejected alternatives + +- Repository/UoW interfaces — violates recorded law F-05 and the `Repository*` + architecture guard; leaks `IQueryable` anyway. +- MediatR/in-box messaging — ceremony without pipeline payoff at 15 operations. +- Keep-as-is — leaves transport/persistence fusion and untestable lambdas. diff --git a/docs/superpowers/specs/2026-08-25-services-application-business-logic-audit-design.md b/docs/superpowers/specs/2026-08-25-services-application-business-logic-audit-design.md new file mode 100644 index 0000000..b46aad0 --- /dev/null +++ b/docs/superpowers/specs/2026-08-25-services-application-business-logic-audit-design.md @@ -0,0 +1,123 @@ +# Services, Application & Business Logic — Audit and Optimized Design + +Date: 2026-08-25 +Scope: `src/PriceNegotiationApp.Modules.*` (domain, features, persistence wiring), cross-module composition, and the tests that pin their behavior. + +## 1. Audit Verdict + +**The overall architecture is correct and should be kept.** The modular monolith with vertical-slice features, rich aggregates, compile-time module boundaries, consumer-owned ports, and direct `DbContext` injection is what a modern team would build for a system of this size in 2026. Explicitly rejected alternatives (they add ceremony without payoff at this scale): + +- Repository/Unit-of-Work layer over EF Core — leaky indirection that duplicates `DbContext`. +- MediatR/CQRS bus — one handler per endpoint with zero pipeline value here. +- Dedicated application-service layer between endpoints and domain — the endpoint lambda *is* the application service; extracting it would only relocate code. +- Domain events/outbox — no cross-module reaction exists today; YAGNI. + +The audit did find **five substantive defects** concentrated in the Negotiations module's lifecycle semantics, concurrency mapping, and policy versioning. They are design flaws, not style issues: each produces wrong behavior under realistic conditions. + +## 2. Findings + +### F1 — "Decline" means two different things (correctness of the model) + +- `Negotiation.Decline()` (`Domain/Negotiation.cs:89`) is a **no-op** that only asserts openness; staff decline persists nothing. +- Yet `NegotiationStatus.Declined` is a **terminal state**, set only by auto-rejection inside `CounterPropose` (`Domain/Negotiation.cs:71`). +- Same word, two opposite behaviors: an API action named `decline` that changes nothing, and a status named `Declined` that clients can only reach by over-proposing. The state machine lives partly in method names, partly in a comment (`Features/Negotiations/Accept.cs` neighbor file), partly in README prose. + +### F2 — Withdrawal physically deletes business history (data loss by design) + +`Withdraw.cs:26` hard-deletes the negotiation row. A customer "withdrawing" an **Accepted** negotiation destroys the record that a deal was struck. The API surface says DELETE but the README calls it "withdraw" — two intents collapsed into destructive storage semantics. + +### F3 — Negotiation policy is evaluated live, not snapshotted (retroactive rule change bug) + +`INegotiationPolicy` is threaded as a parameter into every aggregate call (`Start`, `CounterPropose`, `RemainingProposals`). Consequences: + +- Changing `ProposalMultiplierLimit` or `MaxProposalsPerNegotiation` in config retroactively rewrites the rules for **in-flight** negotiations created under old limits. +- The response mapper (`NegotiationResponses.ToResponse(negotiation, policy)`) drags policy plumbing into presentation; six endpoint signatures carry `INegotiationPolicy` solely to compute `ProposalsRemaining`. + +This contradicts the codebase's own snapshot philosophy (BasePrice is snapshotted precisely to be immune to later change). + +### F4 — Uniqueness races surface as HTTP 500 instead of 409 + +The schema already has the right constraints (`CustomerConfiguration.cs:15` unique `identity_user_id`; `NegotiationConfiguration.cs:22-24` partial unique index on open `(product_id, customer_id)`). But the write paths perform check-then-insert (`Create.cs:24-30`, `NegotiationAccess.GetOrCreateCustomerIdAsync`) and never map the resulting `23505` violation to its semantic error. Two concurrent first-negotiation requests → one 201 and one **500**; same for concurrent customer provisioning. The database enforces correctness; the application fails to translate it. + +### F5 — Read-path inconsistencies + +- `Get.cs` loads a tracked entity for a read-only response (List paths use `AsNoTracking()`). +- `ListMine.cs:22` embeds `customer != null` inside the SQL predicate instead of short-circuiting to an empty page. +- Minor: `JwtManager.GenerateAsync` returns `Task.FromResult` — a sync operation wearing an async contract. + +## 3. Optimized Design + +Keep modules, slices, ports, DI shape, and persistence layout unchanged. Apply five targeted redesigns. + +### D1 — One word, one meaning: explicit negotiation state machine + +``` +Open ──accept──▶ Accepted (staff, terminal) +Open ──over-propose──▶ Rejected (auto-rejection, terminal) +Open ──withdraw──▶ Withdrawn (owner, terminal) +Open ──reject-current-offer──▶ Open (staff feedback; budget NOT consumed; + recorded via LastStaffActionAtUtc) +``` + +- Rename aggregate methods: `Accept()`, `RejectCurrentOffer(now)`, `CounterPropose(...)`, new `Withdraw(now)`. Delete the misleading `Decline()`. +- Status enum becomes `Open | Accepted | Rejected | Withdrawn`. `Rejected` replaces `Declined` as the auto-rejection terminal state. +- Staff decline keeps the negotiation open (unchanged business rule) but is now honest: it stamps `LastStaffActionAtUtc` and the response reports `"outcome": "current_offer_rejected"` so clients observe real state transitions. +- Migration: map legacy `Declined(=2)` rows → `Rejected`; add nullable `last_staff_action_at_utc`. + +### D2 — Withdraw = close, Delete = destroy + +- Owner `DELETE /negotiations/{id}` → `Withdraw(now)`: sets terminal `Withdrawn`, keeps the row and full history. Withdraw is **not** idempotent: withdrawing an already-terminal negotiation returns 409 `negotiation_closed`, consistent with other transitions. +- Hard delete remains available to Admins only (retention/GDPR-style removal), same route, role-gated. + +### D3 — Policy snapshot at creation (fixes F3) + +`Negotiation` stores `MaxProposals` and `OfferMultiplierLimit` alongside `BasePrice` at `Start(...)` from the injected singleton policy — the single read of `INegotiationPolicy` in the whole module. Aggregate behavior uses instance values; `RemainingProposals()` becomes parameterless; `INegotiationPolicy` disappears from all feature signatures and from `NegotiationResponses`. In-flight negotiations become immune to config changes, matching the BasePrice precedent. Cost: two small columns; benefit: deterministic historical behavior and a visibly simpler call graph. + +### D4 — Translate uniqueness violations at the edge (fixes F4) + +SharedKernel gains one helper: + +```csharp +public static async Task SaveOrConflictAsync( + this DbContext db, Func conflict, CancellationToken ct) +``` + +It calls `SaveChangesAsync`, catches `DbUpdateException` where the inner `PostgresException.SqlState == "23505"`, extracts the constraint name, and throws `conflict(constraintName)`. Usage: + +- `Create.cs`: constraint `uq_open_negotiation_product_customer` → `ConflictException("negotiation_already_open", ...)`. +- `GetOrCreateCustomerIdAsync`: unique `ix_customers_identity_user_id` → refetch existing customer and continue (idempotent upsert semantics). + +The check-then-insert pre-checks stay (fast path, friendly 409 before work), but they are no longer load-bearing for correctness. Same helper adopted in Identity register path for duplicate-email races. + +### D5 — Read-path hygiene (fixes F5) + +- `Get.cs` uses `AsNoTracking()`. +- `ListMine` returns an empty page when the caller has no Customer row. +- `JwtManager.Generate` renamed sync (drop `Task` wrapper). + +## 4. Data Flow (post-change, counter-propose example) + +1. `PATCH /negotiations/{id}/proposals` → endpoint resolves caller, loads owned negotiation. +2. `negotiation.CounterPropose(price, now)` consults **snapshotted** limits; may return `NoProposalsRemaining`, transition to `Rejected`, or apply the offer. +3. Outcome enum mapped once at the endpoint: conflict → 409 ProblemDetails, success → 200 with outcome + response. +4. `SaveOrConflictAsync` guards the flush; unique-violation races cannot escape as 500s. + +No new layers, no new abstractions beyond one helper and one enum rename. + +## 5. Error Handling + +Unchanged RFC 7807 + stable `code` contract, plus: `23505` violations are always translated (constraint→code table lives next to the DbContext configurations); `ClosedNegotiationException` continues to map to `negotiation_closed`. + +## 6. Testing Strategy + +- **Unit (Negotiations.Tests)**: state-machine matrix — accept/reject-current-offer/withdraw/counter paths from every state; policy snapshot immutability (create under limit A, counter under limit B must use A); withdraw preserves row data. +- **Unit (Catalog.Tests)**: untouched. +- **Integration**: concurrent double-create → exactly one 201, one 409 `negotiation_already_open`; owner withdraw then admin hard-delete; staff decline leaves status Open with updated `last_staff_action_at_utc`; legacy-row migration test seeding `status=2`. + +## 7. Migration & Rollout + +Single EF migration per affected context (Negotiations only): enum remap `Declined→Rejected`, added columns (`max_proposals`, `offer_multiplier_limit`, `last_staff_action_at_utc`), backfill from current constants. API contract change limited to status string values (`Declined`→`Rejected`, possible new `Withdrawn`) — documented as v1 additive/breaking-minor since consumers are internal. + +## 8. Non-Goals + +Repository/UoW layers, MediatR, separate application-service classes, domain events/outbox, multi-user staff assignment, audit-log tables beyond retained negotiation history. diff --git a/docs/superpowers/specs/2026-08-26-security-review-design.md b/docs/superpowers/specs/2026-08-26-security-review-design.md new file mode 100644 index 0000000..9ba214f --- /dev/null +++ b/docs/superpowers/specs/2026-08-26-security-review-design.md @@ -0,0 +1,105 @@ +# Security Review & Portfolio-Grade Hardening — Design + +- Date: 2026-08-26 +- Status: Approved (pending implementation) +- Scope decision: Report + fixes, portfolio-grade hardening bar, tests + full-suite verification. + +## Goal + +Prove the codebase's security posture and close the gaps an interviewer could spot or +exploit in a live demo. The review artifact doubles as portfolio evidence: every OWASP +category ends with an explicit verdict, and deliberate non-builds are documented as +decisions rather than oversights. + +## Method + +1. **OWASP-mapped checklist** over: authentication, authorization (incl. object-level + access), injection, security misconfiguration, secrets handling, transport security, + logging/telemetry exposure, resource limits/DoS, supply chain, container/compose/CI. + Each category gets `checked` / `finding(s)` / `N/A` with evidence. +2. **Adversarial route sweep**: all 17 API routes re-checked pairwise for + role attribute + object-ownership correctness (IDOR, mass assignment, privilege + mixing). +3. **Supply chain**: `dotnet list package --vulnerable --include-transitive`, plus + review of NuGetAudit config, Dependabot config, and CI pinning. + +## Deliverables + +1. Findings report embedded in this doc's "Findings" section after execution: + severity / evidence (`file:line`) / recommendation / status. +2. Implemented fixes in risk-ordered batches, each batch ending green on the full suite. +3. A "Deliberate trade-offs" section documenting intentionally unbuilt machinery. + +## Candidate findings (pre-identified, to be confirmed during execution) + +| # | Candidate | Evidence | Planned fix | +|---|---|---|---| +| F1 | `/health/ready` is anonymous; unhealthy entries return `description = exception.Message`, leaking dependency internals | `src/PriceNegotiationApp.Api/ReadyHealthReport.cs:27` | Strip exception text from the public body (status + duration only) | +| F2 | Login distinguishes `account_locked` from `invalid_credentials` — account enumeration oracle | `src/PriceNegotiationApp.Modules.Identity/Features/Auth/LoginUserHandler.cs:16-29` | Uniform `invalid_credentials` response for unknown user, wrong password, and locked account | +| F3 | Seed credential floor is 8 chars; `.env.example` and README ship `Admin123!`; a deployed demo runs guessable admin creds | `SeedingOptionsValidator.cs:21-29`, `.env.example:4-5`, `README.md:111-112` | Validator requires ≥12 chars with upper+lower+digit; examples use obviously-fake placeholders; compose refuses known-weak values | +| F4 | Rate limiting is fixed-window per raw `RemoteIpAddress`; no forwarded-header story behind a reverse proxy | `WebApplicationBuilderExtensions.cs:88-97` | Document posture in README; keep YAGNI unless a proxy deployment is in scope | +| F5 | `JwtSettings` (Api) duplicates `JwtOptions` (Identity) with weaker validation coverage (no expiry check) | `src/PriceNegotiationApp.Api/Extensions/JwtSettings.cs` vs `JwtOptions.cs` + validators | Single source of truth: Api binds the Identity module's validated options | +| F6 | Authenticated write endpoints have no rate limit beyond auth endpoints | `Login.cs`, `Register.cs` only call `RequireRateLimiting` | Evaluate global partitioned limiter during execution; add only if cheap, else document | +| F7 | Symmetric HMAC signing key: every replica that validates tokens can also mint them, and there is no path to split issuance from validation when Identity extracts into a dedicated service | `WebApplicationBuilderExtensions.cs:66`, `JwtManager.cs:26-28` | Switch to ES256 (ECDSA P-256): private key signs on issuance, public key validates, JWKS endpoint publishes the public key so future resource servers never hold signing material | + +## Fix batches + +- **B1 — Info disclosure & enumeration** (F1, F2): health body shape + uniform login + failures. Regression tests: ready-body shape assertion; login-response uniformity test. +- **B2 — Config foolproofness** (F3, F5): seed password validator hardening, example + placeholder churn, JWT options de-duplication. Tests: validator unit tests, + startup-validation integration test. +- **B3 — Asymmetric token signing** (F7): ES256 key pair via PEM config, `kid` header + (RFC 7638 thumbprint), `/.well-known/jwks.json` endpoint. Config accepts native or + `\n`-escaped PEM; startup validator rejects unparseable keys; `Jwt:SecretKey` removed + (single path, no dual HMAC/EC support). Tests: token signed with correct key validates, + wrong-key/tampered tokens rejected, JWKS matches the signing key id. +- **B4 — Documentation & report** (F4, F6 disposition, trade-offs, findings table): + README security notes + key-generation one-liner, final findings table committed here. + +## Verification + +- New regression/integration tests per fix where practical (repo already has + WebApplicationFactory + Testcontainers infrastructure). +- `dotnet list package --vulnerable --include-transitive` clean or triaged with rationale. +- Full suite green: `dotnet test --solution PriceNegotiationApp.slnx`. + +## Deliberate trade-offs (documented, not built) + +- No refresh tokens / token revocation: access tokens are short-lived, single-audience, + no sensitive writes beyond demo scope. +- No forwarded-headers middleware: app is deployed directly exposed (compose), not + behind a proxy. +- Single issuer with manual key rotation support (`kid` in JWKS) but no automated + rotation machinery; every replica holds the private key because login runs on each — + the JWKS endpoint is the extraction path when issuance centralizes. +- Registration/login email-enumeration via register-conflict responses: standard UX + trade-off; login path is being made uniform under B1. + +## Executed findings (2026-08-26) + +Supply-chain scan (`dotnet list package --vulnerable --include-transitive`): clean across +all 11 projects against nuget.org at execution time. + +| ID | Severity | Finding | Resolution | +|---|---|---|---| +| F1 | Medium | Anonymous `/health/ready` returned unhealthy-check `description`/exception text, leaking dependency internals | Fixed: detail logged server-side only; body entries are always `{status, durationMs}` (`ReadyHealthReport.cs`) | +| F2 | Low | Login answered locked accounts with `account_locked` vs `invalid_credentials` — account enumeration oracle | Fixed: every authentication failure returns 401 + `invalid_credentials`; lockout mechanics unchanged internally | +| F3 | Medium | Seed credentials accepted any ≥8-char password; examples shipped `Admin123!`, so a deployed demo could run guessable admin creds | Fixed: validator requires ≥12 chars with upper/lower/digit/symbol; seed-user creation failures now log instead of silently skipping; example placeholders fail startup if deployed verbatim | +| F4 | Info | Per-IP fixed-window rate limit assumes direct exposure (no forwarded-header handling) | Accepted: proxy posture documented in README | +| F5 | Low | Api duplicated the JWT config contract as `JwtSettings`, bypassing validation (expiry never checked on that copy) | Fixed: deleted; bearer options configured from the module's validated `JwtOptions` via the options pattern | +| F6 | Info | No limiter on authenticated write endpoints | Accepted: revisit with a real traffic/deployment profile; auth endpoints remain limited | +| F7 | High | Symmetric HMAC secret made every replica a token minter with no issuance/validation split; shared-secret sprawl grows with scale | Fixed: ES256 key pair — private PEM signs (per-call ECDsa, non-cached provider), public JWK validates, `kid` (RFC 7638 thumbprint) published at anonymous `/.well-known/jwks.json`; malformed keys fail startup with generation instructions | + +## Deliberate trade-offs + +- Short-lived access tokens only; no refresh/revocation machinery until multi-device + sessions or sensitive long-lived grants exist. +- Every replica holds the private key because login runs everywhere; JWKS is the + extraction path when issuance centralizes into a dedicated identity service. +- Key rotation is supported by design (`kid` in JWKS) but manual — no automated rotation. +- Registration conflict responses still confirm existing emails (standard UX trade-off); + the login path itself is uniform. A timing side-channel between unknown-email and + wrong-password paths remains (one PBKDF2 evaluation) — accepted at portfolio threat level. +- Readiness failure detail lives in server logs, not the anonymous HTTP body. + diff --git a/docs/superpowers/specs/2026-08-26-transaction-management-review-design.md b/docs/superpowers/specs/2026-08-26-transaction-management-review-design.md new file mode 100644 index 0000000..6b4ce63 --- /dev/null +++ b/docs/superpowers/specs/2026-08-26-transaction-management-review-design.md @@ -0,0 +1,119 @@ +# Transaction Management Pattern Review — Design + +- Date: 2026-08-26 +- Status: Approved (pending implementation) +- Reference: `docs/transaction-management-patterns.md` +- Scope decision: fix the two correctness gaps, enforce handler-owned commits via an + architecture test (no pipeline behavior), reconcile docs. Verified by new tests + + full suite. + +## Goal + +Verify the codebase actually implements what `transaction-management-patterns.md` +prescribes, close the gaps where it does not, and make the compliance story mechanical +rather than disciplinary — without importing machinery the repo's architecture does not +want (MediatR, pipeline behaviors). + +## Audit result + +**Compliant today** + +- Three module-owned scoped `DbContext`s (Identity / Catalog / Negotiations); one + writing context per use case; cross-module access only through the read-only + `IProductPriceProvider` port. +- None of the doc's "never"s present: no shared UoW, no repositories over `DbSet`, + no `TransactionScope`, no per-request transaction filter. +- Client-generated GUIDv7 keys everywhere (`Guid.CreateVersion7()`), which is why every + use case except creation fits one flush. +- Optimistic concurrency tokens correctly configured (`uint Version` → `xmin` system + column) on both write aggregates (`Negotiation`, `Product`). +- Creation races on the partial unique index already translate to 409 via + `DbWriteGuard.SaveOrConflictAsync`. +- Outbox/events rationally deferred until the first subscriber exists (pinned decision, + ddd-audit spec §F-04); there are no cross-module state workflows to serve. + +**Gaps** + +| ID | Severity | Gap | +|---|---|---| +| G1 | High | Nobody handles `DbUpdateConcurrencyException`: concurrent accept-vs-counter-propose, dual staff product edits, or delete-vs-update races surface as HTTP 500 instead of the prescribed 409. The tokens fire; nothing reacts. | +| G2 | Medium | `CreateNegotiationHandler` performs two saves and the first commits alone (customer provisioning in `NegotiationAccess.GetOrCreateCustomerIdAsync`). If the negotiation insert later fails, an orphaned customer row persists — the exact mid-flow commit the reference doc forbids. | +| G3 | Low | Doc Option 4 (commit-on-success behavior) absent by design. Accepted — but then nothing mechanically prevents future handlers from scattering saves. | +| G4 | Low | The patterns doc's closing section ("Recommendation for this repo") references `BookingDbContext`/`Worker`/payments — transplanted from another codebase. README's "xmin concurrency" claim is half-true while conflicts map to 500. | + +## Fixes + +### F-G1 — concurrency conflicts become 409 + +- Add `ErrorCodes.ConcurrencyConflict = "concurrency_conflict"` to SharedKernel. +- In `GlobalExceptionHandler`, map `DbUpdateConcurrencyException` before the fallback: + status 409, title "Resource changed meanwhile", code `concurrency_conflict`. +- Handlers stay ceremony-free; the exception propagates from their single + `SaveChangesAsync`. + +Tests: + +1. Unit: `GlobalExceptionHandler.TryHandleAsync` given a thrown + `DbUpdateConcurrencyException` writes status 409 + `code=concurrency_conflict`. +2. Integration (Testcontainers): load the same negotiation through two scopes, mutate + both, save both — assert the loser throws `DbUpdateConcurrencyException` (proves the + xmin token fires end-to-end) and, through the mapper test above, maps to 409. + +### F-G2 — atomic create-negotiation flow (Option A: explicit transaction) + +Wrap provisioning + insert in one explicit transaction inside `CreateNegotiationHandler` +(the reference doc's Case B): + +```csharp +await using var tx = await db.Database.BeginTransactionAsync(ct); +var customerId = await NegotiationAccess.GetOrCreateCustomerIdAsync(db, caller.UserId, ct); +var negotiation = Negotiation.Start(...); +await db.Negotiations.AddAsync(negotiation, ct); +await db.SaveOrConflictAsync( + _ => new ConflictException(NegotiationErrorCodes.NegotiationAlreadyOpen, "..."), ct); +await tx.CommitAsync(ct); +``` + +Failure anywhere rolls back the provisioned customer together with the failed insert; +the existing unique-violation re-fetch logic inside `GetOrCreateCustomerIdAsync` is +untouched. Rejected alternative: single-flush restructure with constraint-name-aware +recovery — purer commit count but trickier error-path code for no observable gain here. + +Test: covered structurally (the transaction block is the guarantee); the full suite +guards the happy path and conflict paths already. + +### F-G3 — mechanical enforcement without new dependencies + +Add an ArchUnitNET rule to the existing `tests/PriceNegotiationApp.ArchitectureTests`: +invocations of `SaveChangesAsync` are allowed only from + +- `*Handler` feature classes (the single commit point per use case), +- module seeding hosted services, +- `DbWriteGuard` (which wraps the call itself). + +Any other caller fails the build. This pins the doc's "one commit point, owned by the +handler" invariant the same way transport-only endpoints are already pinned. Deliberate +non-build: MediatR + `TransactionBehavior` — the repo has no mediator pipeline and +recent architecture work deliberately made handlers own persistence. + +### F-G4 — docs tell the truth about this repo + +- Replace the transplanted closing section of `docs/transaction-management-patterns.md` + ("Recommendation for this repo") with one describing this codebase: compliant points + above, the deliberate absence of Option 4 enforcement (replaced by the ArchUnit rule), + outbox deferral rationale, and pointer to this spec. +- README stack-table claim "xmin concurrency" becomes fully true once F-G1 lands; add + half a sentence noting conflicts return 409. + +## Verification + +- New tests from F-G1 pass; ArchUnit rule passes. +- Full gate green: `dotnet format --verify-no-changes`, Release build, + `dotnet test --solution PriceNegotiationApp.slnx -c Release`. + +## Out of scope + +- MediatR / `TransactionBehavior` (rejected, see F-G3). +- Outbox/integration events (first-subscriber trigger documented in ddd-audit §F-04). +- Isolation-level escalation anywhere (no multi-statement business rule needs it; the + create-flow now uses a transaction only for atomicity, still READ COMMITTED). diff --git a/docs/superpowers/specs/2026-08-30-fluentvalidation-design.md b/docs/superpowers/specs/2026-08-30-fluentvalidation-design.md new file mode 100644 index 0000000..f1c62f4 --- /dev/null +++ b/docs/superpowers/specs/2026-08-30-fluentvalidation-design.md @@ -0,0 +1,125 @@ +# FluentValidation Integration Design + +**Date:** 2026-08-30 +**Status:** Approved + +## Problem + +Request DTO validation was removed with FluentValidation and never replaced. Validation is scattered across domain entities and handlers with no consistent API boundary validation. + +## Solution + +Reintroduce FluentValidation with vertical-slice co-location, module-owned registration, and a global endpoint filter. + +## Architecture + +### Validators — co-located in vertical folders + +Each use case folder contains its validator alongside its request DTO and handler: + +``` +Features/Products/Create/ + CreateProductRequest.cs + CreateProductRequestValidator.cs + CreateProductHandler.cs +``` + +### Registration — module-owned assembly scanning + +Each module registers its own validators: + +```csharp +// CatalogModule.cs +services.AddValidatorsFromAssemblyContaining(); + +// NegotiationsModule.cs +services.AddValidatorsFromAssemblyContaining(); + +// IdentityModule.cs +services.AddValidatorsFromAssemblyContaining(); +``` + +No API-layer coupling to individual validators. + +### Global endpoint filter + +`ValidateRequestFilter` registered in `PipelineExtensions.cs`: + +1. Resolves `IValidator` from DI +2. Calls `ValidateAsync(request)` +3. Invalid → returns 422 ProblemDetails (existing format with `errors` dict) +4. Valid → passes through + +No exception thrown for validation failures. Short-circuits the pipeline cleanly. + +### Error response format + +Matches existing ProblemDetails pattern: + +```json +{ + "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1", + "title": "Invalid request", + "status": 422, + "extensions": { "code": "validation_failed" }, + "errors": { + "Name": ["'Name' must not be empty."], + "Price": ["'Price' must be greater than '0'."] + } +} +``` + +## Validators + +| DTO | Rules | Messages | +|-----|-------|----------| +| `CreateProductRequest` | `NotEmpty` + `MaximumLength(200)` + `GreaterThan(0)` | defaults | +| `UpdateProductRequest` | `NotEmpty` + `MaximumLength(200)` + `GreaterThan(0)` | defaults | +| `CreateNegotiationRequest` | `NotEmpty` (ProductId) + `GreaterThan(0)` | defaults | +| `CounterProposalRequest` | `GreaterThan(0)` | default | +| `LoginRequest` | `NotEmpty` + `EmailAddress` + `NotEmpty` (password) | defaults | +| `RegisterRequest` | `NotEmpty` + `EmailAddress` + `NotEmpty` + password regex | custom `.WithMessage()` on regex only | + +All default messages except password regex (where FluentValidation's default is bad). + +## Code Review Fixes + +### Fix A — NegotiationErrorCodes XML doc + +Restore removed documentation: + +```csharp +/// Machine-readable error codes owned by this feature (frozen contract). +internal static class NegotiationErrorCodes { ... } +``` + +### Fix B — Negotiations CreateEndpoint Location header + +Replace hard-coded `/api/v1/negotiations/mine` with `CreatedAtRoute`: + +```csharp +var response = await handler.HandleAsync(request, principal.ToCallerContext(), ct); +return TypedResults.CreatedAtRoute(response, "GetNegotiationById", new { id = response.Id }); +``` + +Requires adding `.WithName("GetNegotiationById")` to Negotiations `GetEndpoint.cs`. + +## Packages + +- `FluentValidation` +- `FluentValidation.DependencyInjectionExtensions` + +## Files Modified + +| File | Change | +|------|--------| +| 3× module `.csproj` | Add FluentValidation packages | +| `CatalogModule.cs` | Add `AddValidatorsFromAssemblyContaining()` | +| `NegotiationsModule.cs` | Add `AddValidatorsFromAssemblyContaining()` | +| `IdentityModule.cs` | Add `AddValidatorsFromAssemblyContaining()` | +| 6× new validator files | Create validators | +| `PipelineExtensions.cs` | Register `ValidateRequestFilter` globally | +| `ValidateRequestFilter.cs` | New file | +| `NegotiationErrorCodes.cs` | Restore XML doc | +| `Negotiations/GetEndpoint.cs` | Add `.WithName("GetNegotiationById")` | +| `Negotiations/CreateEndpoint.cs` | Use `CreatedAtRoute` | diff --git a/docs/superpowers/specs/2026-08-30-optimized-architecture-design.md b/docs/superpowers/specs/2026-08-30-optimized-architecture-design.md new file mode 100644 index 0000000..8dc2284 --- /dev/null +++ b/docs/superpowers/specs/2026-08-30-optimized-architecture-design.md @@ -0,0 +1,231 @@ +# Optimized Architecture Design + +**Date:** 2026-08-30 +**Status:** Approved (v2 — Clean Architecture multi-project per module) + +## Design Rationale + +Single project per module with folder-based layers is sufficient for small projects. However, this project assumes growth to a large codebase with multiple developers. Multi-project per module provides: + +1. **Compile-time boundary enforcement** — Compiler prevents dependency violations, not just ArchUnitNET tests +2. **Clearer code ownership** — Each team owns specific projects, not folders +3. **Better onboarding** — Project structure teaches architecture to new developers +4. **Future-proofing** — Module extraction to services is already prepared + +## Project Structure + +``` +PriceNegotiationApp.sln +├── src/ +│ ├── PriceNegotiationApp.SharedKernel/ # Domain primitives, exceptions, utilities +│ │ +│ ├── Modules/ +│ │ ├── PriceNegotiationApp.Modules.Catalog.Domain/ # Pure domain: entities, value objects +│ │ ├── PriceNegotiationApp.Modules.Catalog.Application/ # Use cases: handlers, DTOs, validators +│ │ ├── PriceNegotiationApp.Modules.Catalog.Infrastructure/ # Implementation: persistence, adapters, seeding +│ │ ├── PriceNegotiationApp.Modules.Catalog.Contracts/ # Public API: port interfaces, shared DTOs +│ │ │ +│ │ ├── PriceNegotiationApp.Modules.Negotiations.Domain/ +│ │ ├── PriceNegotiationApp.Modules.Negotiations.Application/ +│ │ ├── PriceNegotiationApp.Modules.Negotiations.Infrastructure/ +│ │ ├── PriceNegotiationApp.Modules.Negotiations.Contracts/ +│ │ │ +│ │ ├── PriceNegotiationApp.Modules.Identity.Domain/ # Empty — Identity uses framework entities +│ │ ├── PriceNegotiationApp.Modules.Identity.Application/ +│ │ ├── PriceNegotiationApp.Modules.Identity.Infrastructure/ +│ │ └── PriceNegotiationApp.Modules.Identity.Contracts/ +│ │ +│ └── PriceNegotiationApp.Api/ # Shared host (composition root) +│ ├── Endpoints/ +│ │ ├── Catalog/ +│ │ ├── Negotiations/ +│ │ └── Identity/ +│ ├── Extensions/ +│ ├── Composition/ +│ ├── ValidateRequestFilter.cs +│ └── GlobalExceptionHandler.cs +│ +└── tests/ + ├── PriceNegotiationApp.ArchitectureTests/ + ├── PriceNegotiationApp.IntegrationTests/ + ├── PriceNegotiationApp.Modules.Catalog.Tests/ + ├── PriceNegotiationApp.Modules.Negotiations.Tests/ + ├── PriceNegotiationApp.Modules.Identity.Tests/ + └── PriceNegotiationApp.TestKit/ +``` + +## Layer Responsibilities + +| Layer | Project | Contains | Dependencies | +|-------|---------|----------|--------------| +| **Domain** | `*.Domain` | Entities, value objects, domain events, domain interfaces, domain exceptions | SharedKernel only | +| **Application** | `*.Application` | Handlers, request/response DTOs, validators, use-case logic | Domain | +| **Infrastructure** | `*.Infrastructure` | DbContext, EF configs, migrations, adapters, seeding, external services, module composition root | Application + Domain + Contracts | +| **Contracts** | `*.Contracts` | Port interfaces, shared DTOs, error codes (public API surface) | Domain (minimal) | + +## Dependency Rules + +``` +SharedKernel → nothing (pure domain primitives) + +Module.Domain → SharedKernel +Module.Application → Module.Domain, SharedKernel +Module.Contracts → Module.Domain, SharedKernel +Module.Infrastructure → Module.Application, Module.Contracts, Module.Domain, SharedKernel + +Host → all Modules.Infrastructure, all Modules.Contracts, SharedKernel +Other Modules → only target Module.Contracts +Tests → target Module + Host +``` + +**Enforcement:** ArchUnitNET tests verify all dependency rules at build time. Compiler enforces project references. + +## Module File Mapping + +### Catalog Module + +| File | Layer | Project | +|------|-------|---------| +| `Product.cs`, `ProductId.cs`, `Price.cs` | Domain | Catalog.Domain | +| `ProductModels.cs`, `ProductQuery.cs` | Application | Catalog.Application | +| `Create/CreateProductHandler.cs`, `Create/CreateProductRequest.cs`, `Create/CreateProductRequestValidator.cs` | Application | Catalog.Application | +| `Update/UpdateProductHandler.cs`, `Update/UpdateProductRequest.cs`, `Update/UpdateProductRequestValidator.cs` | Application | Catalog.Application | +| `Delete/DeleteProductHandler.cs` | Application | Catalog.Application | +| `Get/GetProductHandler.cs` | Application | Catalog.Application | +| `List/ListProductsHandler.cs` | Application | Catalog.Application | +| `CatalogDbContext.cs`, `DesignTimeDbContextFactory.cs` | Infrastructure | Catalog.Infrastructure | +| `Configurations/ProductConfiguration.cs` | Infrastructure | Catalog.Infrastructure | +| `Migrations/*` | Infrastructure | Catalog.Infrastructure | +| `ProductPriceProvider.cs` | Infrastructure | Catalog.Infrastructure | +| `Seeding/*` | Infrastructure | Catalog.Infrastructure | +| `CatalogModule.cs` | Infrastructure | Catalog.Infrastructure | +| `IProductPriceProvider.cs`, `ProductSnapshot` | Contracts | Catalog.Contracts | + +### Negotiations Module + +| File | Layer | Project | +|------|-------|---------| +| `Negotiation.cs`, `NegotiationId.cs`, `NegotiationStatus.cs`, `NegotiationOutcome.cs` | Domain | Negotiations.Domain | +| `Customer.cs`, `CustomerId.cs` | Domain | Negotiations.Domain | +| `Price.cs` | Domain | Negotiations.Domain | +| `INegotiationPolicy.cs`, `DefaultNegotiationPolicy.cs` | Domain | Negotiations.Domain | +| `ClosedNegotiationException.cs`, `ProposalExceedsLimitException.cs` | Domain | Negotiations.Domain | +| `NegotiationModels.cs` | Application | Negotiations.Application | +| `Create/CreateNegotiationHandler.cs`, `Create/CreateNegotiationRequest.cs`, `Create/CreateNegotiationRequestValidator.cs` | Application | Negotiations.Application | +| `CounterPropose/*` | Application | Negotiations.Application | +| `Accept/AcceptHandler.cs` | Application | Negotiations.Application | +| `RejectCurrentOffer/RejectCurrentOfferHandler.cs` | Application | Negotiations.Application | +| `Withdraw/WithdrawHandler.cs` | Application | Negotiations.Application | +| `Get/GetNegotiationHandler.cs` | Application | Negotiations.Application | +| `List/ListNegotiationsHandler.cs` | Application | Negotiations.Application | +| `ListMine/ListMyNegotiationsHandler.cs` | Application | Negotiations.Application | +| `NegotiationsDbContext.cs`, `DesignTimeDbContextFactory.cs` | Infrastructure | Negotiations.Infrastructure | +| `Configurations/*` | Infrastructure | Negotiations.Infrastructure | +| `Migrations/*` | Infrastructure | Negotiations.Infrastructure | +| `NegotiationAccess.cs` | Infrastructure | Negotiations.Infrastructure | +| `NegotiationsModule.cs` | Infrastructure | Negotiations.Infrastructure | +| `NegotiationErrorCodes.cs` | Contracts | Negotiations.Contracts | + +### Identity Module + +| File | Layer | Project | +|------|-------|---------| +| _(empty — no pure domain types)_ | Domain | Identity.Domain | +| `AuthModels.cs` | Contracts | Identity.Contracts | +| `IdentityErrorCodes.cs` | Contracts | Identity.Contracts | +| `Register/*` | Application | Identity.Application | +| `Login/*` | Application | Identity.Application | +| `IdentityModuleDbContext.cs`, `ApplicationUser.cs`, `DesignTimeDbContextFactory.cs` | Infrastructure | Identity.Infrastructure | +| `Migrations/*` | Infrastructure | Identity.Infrastructure | +| `JwtManager.cs`, `EcSigningKey.cs`, `JwtOptions.cs`, `JwtOptionsValidator.cs` | Infrastructure | Identity.Infrastructure | +| `Seeding/*` | Infrastructure | Identity.Infrastructure | +| `IdentityModule.cs` | Infrastructure | Identity.Infrastructure | + +## Inter-Module Communication + +**Pattern:** Provider-owned Contracts + +| Component | Location | Owner | +|-----------|----------|-------| +| Port interface | `{Provider}.Contracts` | Provider module | +| Port DTOs | `{Provider}.Contracts` | Provider module | +| Adapter | `{Provider}.Infrastructure` | Provider module | +| Consumer dependency | `{Provider}.Contracts` only | Consumer module | +| DI wiring | `WebApplicationBuilderExtensions.cs` | Host | + +**Cross-module reference example:** +``` +Negotiations.Application → Catalog.Contracts (for IProductPriceProvider, ProductSnapshot) +``` + +## Request Validation + +**Pattern:** FluentValidation with vertical-slice co-location + +| Component | Location | Purpose | +|-----------|----------|---------| +| Request DTO | `Application/{Entity}/{UseCase}/` | Defines request shape | +| Validator | `Application/{Entity}/{UseCase}/` | Validates request before handler | +| Handler | `Application/{Entity}/{UseCase}/` | Business logic | +| Response DTO | `Application/{Entity}/{Entity}Models.cs` | Shared response types | + +**Registration:** `AddValidatorsFromAssemblyContaining()` in each module's Infrastructure composition root. + +**Filter:** `ValidateRequestFilter` added to each endpoint via `.AddEndpointFilter<>()`. + +**Error format:** 422 ProblemDetails with `code: "validation_failed"` and field-level errors in `extensions.errors`. + +## Naming Conventions + +| Type | Naming | Example | +|------|--------|---------| +| Request DTO | `{Action}{Entity}Request` | `CreateProductRequest` | +| Response DTO | `{Entity}Response` | `ProductResponse` | +| Action response | `{Action}{Entity}Response` | `CounterProposalResponse` | +| Handler | `{Action}{Entity}Handler` | `CreateProductHandler` | +| Validator | `{Action}{Entity}RequestValidator` | `CreateProductRequestValidator` | +| Endpoint | `{Action}Endpoint` | `CreateEndpoint` | +| Port | `I{Capability}Provider` | `IProductPriceProvider` | +| Adapter | `{Capability}Provider` | `ProductPriceProvider` | +| Domain entity | `{Name}` | `Product`, `Negotiation` | +| Value object | `{Name}` | `Price`, `ProductId` | +| Domain exception | `{Name}Exception` | `DomainException` | +| DbContext | `{Module}DbContext` | `CatalogDbContext` | + +## Error Handling + +| Exception | HTTP Status | When | +|-----------|-------------|------| +| `NotFoundException` | 404 | Resource not found | +| `ConflictException` | 409 | State conflict | +| `InvalidRequestException` | 422 | Request validation | +| `UnauthorizedException` | 401 | Authentication failed | +| `ForbiddenAccessException` | 403 | Authorization failed | +| `DomainException` | 422 | Business rule violated | +| `ValueObjectValidationException` | 422 | Value object invalid | +| Validation filter | 422 | DTO validation failed | + +All errors return ProblemDetails with `code` extension and optional `errors` dictionary. + +## Customer Decision + +Stays in Negotiations module. It's a Negotiations-specific reference row that maps Identity users to the Negotiations context. Only used within Negotiations. Never exposed in API responses. Lazily provisioned on first negotiation. + +## Host Responsibilities + +The shared host is the composition root. It: +- Configures ASP.NET Core pipeline (auth, CORS, rate limiting, health checks) +- Registers all modules via `AddXxxModule()` from each module's Infrastructure project +- Wires cross-module adapters behind port interfaces +- Maps module handlers to HTTP endpoints +- Handles global exception processing +- Runs database migrations + +Host contains zero business logic. Endpoints are thin HTTP mappings to handler calls. +Host references `*.Infrastructure` projects (for DI registration) and `*.Contracts` projects (for types in endpoint signatures). + +## Future Considerations + +1. **Repository pattern** — Extract `IProductRepository`, `INegotiationRepository` interfaces into Domain/Contracts to remove direct DbContext injection in handlers +2. **Identity.Domain** — Create `IUserContext` domain interface when custom user logic is needed +3. **Module extraction** — If a module needs to become a separate service, the Contracts project becomes the API contract and the Infrastructure project contains all implementation details diff --git a/docs/transaction-management-patterns.md b/docs/transaction-management-patterns.md new file mode 100644 index 0000000..40f3042 --- /dev/null +++ b/docs/transaction-management-patterns.md @@ -0,0 +1,644 @@ +# Unit of Work & Concurrency Strategy in ASP.NET (EF Core) + +> **Question:** what's the optimal approach — ad-hoc `SaveChanges()` here and there, a global unit of work across clean-architecture modules, or something else? +> +> **Short answer:** something else. `DbContext` *already is* a unit of work — and in a modular/DDD codebase you typically have **many** of them, one per bounded context. The modern consensus: **each module owns its own scoped DbContext; every use case has exactly one commit point, in exactly one of those contexts**, optionally enforced by a pipeline behavior. Real write conflicts → **optimistic concurrency**. Cross-module workflows → **events + outbox**, never a shared transaction or a shared UoW. In microservices the same answer splits cleanly along the seam: **Options 3+4 *inside* every service, Option 5 (outbox/sagas) *between* services.** + +--- + +## 0. The problem, in one picture + +Concurrency means many HTTP requests hit the same tables simultaneously. Two things must be true: + +1. **Each business operation is atomic** — either all of it persists or none of it does. +2. **Simultaneous conflicting writes are detected**, not silently overwritten (lost update problem). + +Ad-hoc saves break #1; no strategy alone solves #2 without concurrency tokens. + +```csharp +// What goes wrong with ad-hoc SaveChanges: +public async Task BookTicket(BookTicketCommand cmd) +{ + var booking = Booking.Create(...); + _db.Bookings.Add(booking); + await _db.SaveChangesAsync(); // commit #1 — already visible to everyone + + var payment = await _payments.ChargeAsync(cmd.CardToken, cmd.Amount); + booking.MarkPaid(payment.TransactionId); + await _db.SaveChangesAsync(); // commit #2 +} +``` + +If the process dies (or `_payments` throws) between commit #1 and commit #2, the database contains a **booking that was never paid for and can never be rolled back** — commit #1 is permanent. No unit-of-work pattern added *later* fixes code written this way; the save points themselves are the bug. + +--- + +## 1. The options + +Seven candidate strategies found in real ASP.NET + EF Core codebases, ordered roughly from what-not-to-do to what-to-do — including the two "legacy defaults" many teams still live with (per-request filters and ambient `TransactionScope`). + +### Option 1 — Ad-hoc `SaveChanges()` wherever convenient + +**What it is:** every service method (or worse, every few lines) calls `SaveChangesAsync()` whenever it feels data "should" go to the DB now. Each call opens its own autocommit transaction and **commits immediately** — so a use case with five saves is really five independent mini-transactions, none aware of the others. Transaction boundary = whatever line the developer happened to save on. + +| Criterion | Score | +|---|---| +| Simplicity (initial) | 65 | +| Correctness / atomicity | **25** | +| Testability | 30 | +| Performance | 45 | +| Long-term maintainability | 20 | +| Clean-architecture fit | 25 | +| Concurrency safety | 20 | +| Developer experience | 40 | +| **Average** | **34** | + +**Popularity: ~30%** — dominant in tutorials, junior codebases, and apps that never met a failure mid-operation. + +**Why it fails:** with N saves there are N commit points, and no mechanism rolls back the earlier ones when step 3 fails: + +- **partial commits on failure** — saves #1..k are permanent, the rest never run; compensation code is left to write by hand; +- **half-written state is visible** to other concurrent requests between save #1 and save #N; +- **unclear ownership** — nobody can say which layer owns "the" transaction, because there isn't one; +- **no retry story** — outbox/idempotent-replay patterns need a single atomic unit to retry; here there is none. + +The crucial nuance: multiple saves per use case are **not inherently wrong** — they're wrong when each one *commits alone*. Compare Option 3's fallback ("several saves inside one transaction"). + +**Verdict:** acceptable only in throwaway prototypes. + +--- + +### Option 2 — Global Unit of Work abstraction shared across modules + +**What it is:** the classic DDD-era pattern: repositories register changes without persisting them; a separate `IUnitOfWork` wraps the context and exposes `CommitAsync()`; somebody calls it once per request. In practice the UoW is injected broadly — including across module boundaries — precisely so everyone "shares" one commit. + +```csharp +public interface IUnitOfWork : IDisposable +{ + IRepository Bookings { get; } + IRepository Events { get; } + Task CommitAsync(CancellationToken ct); +} + +// usage — but in a ServiceA → ServiceB → Repository call chain, WHO owns this call? +await _uow.Bookings.AddAsync(booking); +await _uow.CommitAsync(ct); +``` + +| Criterion | Score | +|---|---| +| Simplicity | 35 | +| Correctness / atomicity | 55 | +| Testability | 45 | +| Performance | 50 | +| Long-term maintainability | 35 | +| Clean-architecture fit | 40 | +| Concurrency safety | 45 | +| Developer experience | 45 | +| **Average** | **44** | + +**Popularity: ~18%** — legacy enterprise codebases, declining steadily since ~2019 as "DbContext is enough" became mainstream advice. + +**Why it's an anti-pattern today:** + +- `DbContext` **already implements Unit of Work** (its change tracker batches all changes into one transaction on `SaveChanges`) and `DbSet` **already is the repository**. Wrapping them adds ceremony without adding capability. +- Generic repositories leak anyway (`Include`, projections, `IQueryable`) — you end up with `repository.Query().Include(...).Where(...)` = repository with extra steps. +- Mocking `IUnitOfWork` in tests is painful and tests nothing real; testing against a real context is easier. +- **Sharing one UoW across clean-architecture modules is actively harmful:** modules become coupled through one shared context/schema/lifetime, one module's uncommitted changes leak into another's reads (change tracker pollution), and cross-module transactions are a distributed-systems smell pretending to be a local one. +- **It doesn't even solve its own core problem:** deciding *who* calls `CommitAsync` in a deep call graph (`Controller` → `ServiceA` → `ServiceB`, both using repositories) yields either double-commit bugs or hidden filter magic — i.e., the same discipline problem Option 4 solves properly, minus the enforcement. +- **And it doesn't help with the ID problem either:** whether you call `_db.SaveChangesAsync()` or `_uow.CommitAsync()`, needing an entity's database-generated ID mid-flow is identical work. The abstraction adds nothing where it's supposedly needed. + +**Verdict:** reject. This is the trap the question hints at. + +--- + +### Option 3 — Scoped DbContexts (one per module), one commit point per use case ✅ baseline + +**What it is:** register **each module's** DbContext as **scoped** (one instance per module per HTTP request). The handler mutates tracked entities freely during execution and flushes at the end — **one commit point per use case**, which EF Core turns into an implicit transaction. Note the precise invariant: it's *one commit*, not literally *one `SaveChanges` call*. When a later step in the flow genuinely needs earlier data persisted (IDs, raw SQL, stored procedures), you add more saves — but inside one explicit transaction, so the commit point stays single. See below. + +One more silent premise worth making explicit: **everything here also runs at the database's default transaction isolation level** (READ COMMITTED on SQL Server and PostgreSQL). The model above buys you atomicity and hides uncommitted writes from others — but it does *not* isolate you from every interleaving of concurrent transactions. When that matters, isolation has to be chosen deliberately; see "What if another isolation level is needed?" further down. + +```csharp +// Composition root — one scoped context PER MODULE, disposed with the request +builder.Services.AddDbContext(o => o.UseNpgsql(cs)); // Bookings module +builder.Services.AddDbContext(o => o.UseNpgsql(cs)); // Payments module + +// A handler works ONLY with its own module's context: +public sealed class BookTicketHandler(BookingDbContext db) // Bookings module +{ + public async Task Handle(BookTicketCommand cmd, CancellationToken ct) + { + var ev = await db.Events.SingleAsync(e => e.Id == cmd.EventId, ct); + var booking = Booking.Create(ev, cmd.Seats); + db.Bookings.Add(booking); + ev.ReserveSeats(cmd.Seats); + + await db.SaveChangesAsync(ct); // ← single commit point: all-or-nothing + return booking.Id; + } +} +``` + +The rule generalizes cleanly to N modules: **N scoped contexts, each an independent unit of work; one use case → one context → one commit point.** A use case that seems to need two contexts is really two use cases (see the playbook after Option 5). + +#### "But my flow can't survive on one SaveChanges!" — yes it can, here's how + +Real flows often seem to require persistence *before* the end of the use case: a later step needs an entity's generated ID, or must reference a row created earlier in the same command. Three facts dissolve most of these cases: + +1. **EF Core already orders everything within one `SaveChanges`.** Inserts are sorted topologically by FK dependencies (parents before children), and database-generated IDs are propagated across the whole object graph inside that same flush. Booking → Tickets works with identity columns and zero extra effort. +2. **Client-generated IDs remove nearly all remaining cases.** With `Guid.CreateVersion7()` (.NET 9+), ULIDs or HiLo/sequence keys, an entity has its real ID from the moment it's constructed — usable for events, logs, external references before any save happens. +3. **If a step truly requires rows already in the DB** (legacy identity keys consumed by raw SQL or stored procedures, read-your-own-writes via SQL), use several saves **inside one explicit transaction**. Atomicity comes from the transaction, not from counting saves. + +```csharp +// Case A — client-generated ID: one flush covers everything +var booking = new Booking(Guid.CreateVersion7(), ev, cmd.Seats); // real ID from birth +db.Bookings.Add(booking); +foreach (var seatId in cmd.Seats) + db.Tickets.Add(new Ticket(booking.Id, seatId)); // references ID freely +await db.SaveChangesAsync(ct); // EF orders inserts itself +``` + +```csharp +// Case B — legacy identity key + a step that genuinely needs persisted rows: +await using var tx = await db.Database.BeginTransactionAsync(ct); + +db.Bookings.Add(booking); +await db.SaveChangesAsync(ct); // save #1 — visible ONLY inside this tx + +var reservation = await _seatMap.ReserveAsync(booking.Id); // needed booking.Id +booking.AttachSeatReservation(reservation.Handle); + +await db.SaveChangesAsync(ct); // save #2 +await tx.CommitAsync(ct); // BOTH saves become permanent together — + // failure anywhere rolls back BOTH +``` + +> **The invariant, stated honestly:** any number of `SaveChanges` calls are safe as long as they share one transaction and none commits alone. What's forbidden is letting an intermediate save become permanent while later steps can still fail — that's Option 1 again. + +Two closing notes: if you own the schema, prefer fixing Case B at the root by switching keys to client-generated GUIDs rather than carrying two-save patterns forever. And if the "later step" is an *external* call that merely wants an ID (charge card with booking reference), that call doesn't belong mid-command at all — move it behind after-commit events/outbox (Option 5). + +#### What if another isolation level is needed? + +The default — **READ COMMITTED** (SQL Server: plain locking READ COMMITTED unless the DB enables RCSI; PostgreSQL: MVCC variant of the same) — gives you per-statement consistency: you never see anyone's uncommitted data, and each statement sees a committed snapshot at its moment of execution. What it does **not** give you is stability across statements: another transaction can commit between your `SELECT` and your `UPDATE`, producing lost updates or write skew. The baseline recipe accepts that and closes the gap with short transactions + optimistic concurrency tokens (section 3). That's correct for ~95% of commands. + +Reach for a stronger level only when a business rule itself spans multiple statements and interleaving breaks it — the classic example being *"count free seats, then insert booking"*: + +| Level | Use when | Cost / caveat | +|---|---|---| +| **READ COMMITTED** *(default)* | normal CRUD; conflicts delegated to concurrency tokens | none | +| **SNAPSHOT** (SQL Server) | long multi-read flows needing one consistent view without blocking writers | version-store/tempdb overhead | +| **REPEATABLE READ** | rows re-read inside one tx must not change underneath you | PG: snapshot-based; SQL Server: shared locks held to commit | +| **SERIALIZABLE** | rule forbids phantoms/write-skew ("check capacity, then insert" must be atomic vs concurrent bookings) | highest contention; abort-and-retry is *expected* operation | + +Escalating in EF Core — either imperatively: + +```csharp +await using var tx = await db.Database.BeginTransactionAsync( + System.Data.IsolationLevel.Serializable, ct); + +// capacity check + insert now execute against a serialized world; +// concurrent conflicting commits are rejected instead of silently interleaving + +await tx.CommitAsync(ct); +``` + +or declaratively, letting the Option 4 behavior pick the level per command: + +```csharp +public interface IIsolationScopedCommand { IsolationLevel Level { get; } } + +// in TransactionBehavior: +var level = request is IIsolationScopedCommand c ? c.Level : IsolationLevel.ReadCommitted; +await using var tx = await db.Database.BeginTransactionAsync(level, ct); +``` + +Rules of thumb when escalating: + +1. **Escalate one command, never a module or request.** Contention scales brutally with time-under-lock; a SERIALIZABLE wrapper around everything turns the load test into a deadlock generator. +2. **Every escalated command needs a retry loop.** SQL Server throws deadlock (error 1205)/update conflict; PostgreSQL's SERIALIZABLE aborts with serialization failure (40001). These are normal traffic under contention — translate to HTTP 409/503 + retry, don't log them as bugs. +3. **Before escalating, check the cheaper alternatives**: an optimistic token on the contested aggregate, or one atomic conditional statement (`UPDATE Events SET SeatsAvailable -= @n WHERE Id = @id AND SeatsAvailable >= @n`) achieves most SERIALIZABLE guarantees at READ COMMITTED prices. Escalation is the last tool, not the first. + +| Criterion | Score | +|---|---| +| Simplicity | 90 | +| Correctness / atomicity | 80 | +| Testability | 88 | +| Performance | 85 | +| Long-term maintainability | 85 | +| Clean-architecture fit | 85 | +| Concurrency safety | 70 | +| Developer experience | 88 | +| **Average** | **84** | + +**Popularity: ~28%** — the default style of serious modern EF Core codebases. + +**Strengths:** zero ceremony; change tracker accumulates work cheaply in memory, one short transaction at the end holds locks briefly (great under concurrency); trivially testable (real context + testcontainers/SQLite); scales linearly to modular/DDD designs — N modules simply means N independent contexts. + +**Weakness:** correctness relies on **discipline** — nothing mechanically stops a developer from calling `SaveChanges` where no enclosing transaction exists (the exact bug from Option 1). That gap is what Option 4 closes. + +--- + +### Option 4 — Commit-on-success pipeline behavior, resolved per module ✅ enforcement + +**What it is:** same as Option 3, but the commit is moved out of handlers into infrastructure: a MediatR pipeline behavior (or ASP.NET action filter, or Scrutor decorator) opens a transaction before the handler runs and commits only if the handler returns successfully. The behavior's final `SaveChangesAsync` is the normal single flush — but a handler that legitimately needs intermediate persistence may call it mid-flow too, staying inside the behavior's transaction either way. With multiple module contexts, the behavior resolves which context serves each request (here: marker interfaces on commands). Handlers contain **zero** transaction ceremony; atomicity is guaranteed by convention instead of discipline. + +```csharp +// Commands announce their module; the behavior picks the matching context: +public interface IBookingsCommand : ICommand { } +public interface IPaymentsCommand : ICommand { } + +public sealed class TransactionBehavior( + BookingDbContext bookingsDb, + PaymentsDbContext paymentsDb) + : IPipelineBehavior where TRequest : notnull +{ + public async Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken ct) + { + var db = + request is IBookingsCommand ? bookingsDb : + request is IPaymentsCommand ? paymentsDb : + null; // queries / non-persisted requests + + if (db is null) + return await next(); + + await using var tx = await db.Database.BeginTransactionAsync(ct); + try + { + var response = await next(); + await db.SaveChangesAsync(ct); // the ONLY save for this use case — unless the handler flushed mid-flow; then this is a no-op + await tx.CommitAsync(ct); + return response; + } + catch + { + await tx.RollbackAsync(ct); + throw; + } + } +} +``` + +Injecting all contexts into one behavior is fine — it lives in the composition root/Infrastructure, not in domain code. (Contexts are cheap to construct and EF opens a DB connection only on first real use, so a request touching one module doesn't pay for the others.) Alternatives: one behavior per module registered for that module's requests, or a Scrutor decorator applied per module. + +// Handler shrinks to pure domain orchestration (Bookings module): +public sealed class BookTicketHandler(BookingDbContext db) +{ + public async Task Handle(BookTicketCommand cmd, CancellationToken ct) + { + var ev = await db.Events.SingleAsync(e => e.Id == cmd.EventId, ct); + var booking = Booking.Create(ev, cmd.Seats); + db.Bookings.Add(booking); + ev.ReserveSeats(cmd.Seats); + return booking.Id; // committed by the behavior + } +} + +| Criterion | Score | +|---|---| +| Simplicity | 75 | +| Correctness / atomicity | **92** | +| Testability | 82 | +| Performance | 83 | +| Long-term maintainability | 88 | +| Clean-architecture fit | **90** | +| Concurrency safety | 72 | +| Developer experience | 87 | +| **Average** | **84** | + +**Popularity: ~14%** — growing fast; standard in MediatR-based codebases and modular monoliths. + +**Caveats worth knowing:** +- External side effects inside the handler (HTTP payment charge, email send) happen **inside the open transaction**. Keep external calls out of the command handler, or move them behind events published after commit (which pairs naturally with Option 5's outbox). +- One behavior = **one transaction** (one commit) per command — not necessarily one `SaveChanges` call. A handler may flush mid-flow when a step genuinely needs earlier rows; everything still commits or rolls back together. If a command needs multiple sequential *commits*, that's a design smell to fix, not a pattern to extend. +- Mid-flow saves are also the natural place to notice an external side effect trying to sneak inside your transaction — treat that as a signal to move it behind events/outbox instead. +- **Never let one handler commit two module contexts** — that would be a distributed transaction in disguise. Cross-module workflows go through events/outbox (playbook below). + +**Verdict:** best-in-class when paired with Option 3 — 3 defines the shape, 4 enforces it. + +--- + +### Option 5 — Module-owned DbContexts + domain events / outbox (cross-module consistency) + +**What it is:** each clean-architecture module owns its own DbContext and commits independently — internally following the same one-commit-point rule (multiple flushes allowed, but only within its own local transaction). There is deliberately **no shared unit of work across module boundaries**. Instead, a module writes its state change *plus* an event record to an outbox table **in the same local transaction**, and a background dispatcher publishes those events; other modules react asynchronously with their own transactions. + +```csharp +// Inside the Sales module — ONE local transaction: +db.Bookings.Add(booking); +ev.ReserveSeats(cmd.Seats); +db.OutboxMessages.Add(new OutboxMessage( + type: "booking.created", + payload: JsonSerializer.Serialize(new BookingCreated(booking.Id, ev.Id)))); +await db.SaveChangesAsync(ct); // state + event are atomic together + +// Worker later publishes outbox rows; PaymentsModule consumes with ITS OWN DbContext: +// consume → create Payment row → publish "payment.confirmed" → Sales marks paid. +``` + +| Criterion | Score | +|---|---| +| Simplicity | 45 | +| Correctness / atomicity (local) | 85 | +| Testability | 78 | +| Performance | **92** | +| Long-term maintainability | 72 | +| Clean-architecture fit | **95** | +| Concurrency safety | 82 | +| Developer experience | 55 | +| **Average** | **76** | + +**Popularity: ~10%** — niche but rising; standard in serious modular monoliths and the mental model of microservices done right. + +**Trade-off:** consistency across modules becomes **eventual** ("booking exists, payment confirmation arrives a second later") instead of immediate. That's a product decision, not just a technical one. Within a single module you still use Options 3/4 — this option governs only the *boundaries*. + +**Verdict:** mandatory *at module seams* once you have more than one module — which is the normal state of a DDD/modular codebase, not an exotic one. Unnecessary inside a single module. + +--- + +--- + +### Option 6 — One transaction per HTTP request (action filter / middleware) + +**What it is:** infrastructure opens a transaction at the start of every mutating HTTP request and commits when the response turns out successful (2xx); rolls back otherwise. Usually an `IAsyncActionFilter` attribute on controllers or middleware around minimal-API endpoints. Services just use the scoped context — nobody calls `BeginTransaction` explicitly, and the HTTP layer owns atomicity. + +```csharp +public sealed class TransactionalAttribute : Attribute, IAsyncActionFilter +{ + public async Task OnActionExecutionAsync( + ActionExecutingContext ctx, ActionExecutionDelegate next) + { + var db = ctx.HttpContext.RequestServices + .GetRequiredService(); + + await using var tx = await db.Database.BeginTransactionAsync(); + var executed = await next(); + + if (executed.Exception is null && + executed.Result is not ObjectResult { StatusCode: >= 400 }) + { + await db.SaveChangesAsync(); // single flush for the whole action + await tx.CommitAsync(); + } + // disposing without commit rolls everything back + } +} + +[HttpPost, Transactional] +public IActionResult Book(BookTicketCommand cmd) { ... } // zero save ceremony here either +``` + +| Criterion | Score | +|---|---| +| Simplicity | 80 | +| Correctness / atomicity | 70 | +| Testability | 75 | +| Performance | 60 | +| Long-term maintainability | 65 | +| Clean-architecture fit | 60 | +| Concurrency safety | 65 | +| Developer experience | 78 | +| **Average** | **69** | + +**Popularity: ~12%** — very common in plain MVC / non-CQRS apps (action-filter flavor); in minimal-API apps the same shape appears as `AddEndpointFilter` transaction filters. The natural first "serious" step up from ad-hoc saves. + +**Why it's weaker than Option 4:** + +- **Commit is decided by HTTP status codes.** Business correctness becomes load-bearing on response mapping: exception-middleware ordering, result filters, and "400 vs exception" conventions silently change whether data commits. +- **Transaction scope = request scope.** A bulk endpoint performing several independent logical operations shares one transaction; one failure nukes all of them even when most succeeded legitimately. +- **Only exists for HTTP.** Background jobs, message consumers, hosted services don't pass through the filter — you'll reinvent Option 4 there anyway, so you end up maintaining two commit mechanisms. +- Encourages "one request = one use case" thinking that breaks as endpoints grow. + +**Verdict:** acceptable default for small MVC CRUD apps and admin panels; becomes structurally wrong once requests do more than one logical thing. + +--- + +### Option 7 — Ambient transactions (`TransactionScope`) + +**What it is:** .NET's `System.Transactions` ambient model from the .NET Framework era: open a scope block, and any connection created inside enlists automatically; call `Complete()` to commit on dispose. Historically THE way to span multiple resources atomically — which is exactly why it's dangerous with EF Core today. + +```csharp +using var scope = new TransactionScope( + TransactionScopeOption.Required, + new TransactionOptions { IsolationLevel = IsolationLevel.ReadCommitted }, + TransactionScopeAsyncFlowOption.Enabled); // MANDATORY for await — omit it and + // the tx silently doesn't flow + +await _bookingDb.SaveChangesAsync(ct); +await _auditDb.SaveChangesAsync(ct); // second context enlisted → + // escalation to MSDTC risk +scope.Complete(); +``` + +| Criterion | Score | +|---|---| +| Simplicity | 55 | +| Correctness / atomicity | 50 | +| Testability | 45 | +| Performance | **35** | +| Long-term maintainability | 40 | +| Clean-architecture fit | 30 | +| Concurrency safety | 50 | +| Developer experience | 45 | +| **Average** | **44** | + +**Popularity: ~7%** — legacy enterprise carryover, steadily fading. + +**Why it lost:** + +- **Around one context it adds nothing** over `BeginTransactionAsync` — same transaction, more ceremony, ambient magic. +- **Around two contexts it escalates to a distributed transaction**: SQL Server pulls in MSDTC (a Windows service — hostile to containers/cloud); Npgsql/PostgreSQL refuses distributed enlistment outright and throws. The pattern's only superpower is its biggest liability (see playbook rule 3). +- **Async pitfalls**: forget `TransactionScopeAsyncFlowOption.Enabled` and continuations run *outside* your transaction — code that passes tests and corrupts data under load. +- **Silent SERIALIZABLE default.** Without an explicit `TransactionOptions`, every scope runs at `IsolationLevel.Serializable` — far stricter than anyone assumes — multiplying lock contention and deadlock risk while looking like ordinary transactions in code review. (Confirmed in the wild: see Case 5.) +- Ambient state is invisible in method signatures — testability and reasoning suffer. + +#### "But I've seen this run in production!" + +A very common report — and worth dissecting, because it reveals the actual failure model. Multi-context `TransactionScope` deployments historically survived through a few recurring shapes. Provenance is explicit throughout: **Cases 1–3 are generic industry patterns**, while **Cases 4–5** document two different real repositories — **R1** (keyed-UoW setup; observed first-hand, reconstructed from memory, *not* re-audited) and **R2** (ambient endpoint filter; verified by an automated read-only scan): + +**Case 1 — effectively single-resource scope.** Only one context did writes inside the block (or was touched at all). Single-phase promotion (PSPE) keeps *one* enlisted connection fully local — no MSDTC involved. Those scopes were genuinely fine, and they're the majority. + +**Case 2 — escalation absorbed by MSDTC.** On-premises Windows fleets run the MS DTC service by default. When a second context enlisted, the transaction went distributed *transparently* — and kept working until someone hit the classic bug class: DTC security/firewall/RPC configuration differing between environments, a move toward Linux containers, or cloud PaaS with no DTC. This matches "distributed transactions only ever appeared as bugs": the mechanism functioned until the environment shifted underneath it. Correctness that depends on infrastructure configuration is correctness on borrowed time. + +**Case 3 — nothing atomic was actually happening.** Whether the second context truly joins the ambient transaction depends on subtle details — `Enlist=` connection-string settings, pool-reuse and open/close ordering. Some long-running "working" setups were quietly committing parts independently and surviving because mid-flow failures were rare enough to patch by manual reconciliation. + +**Case 4 — real repository R1, observed first-hand *(reconstructed from memory, not re-audited)*: keyed UoWs dodge the problem entirely.** R1's signature move — keyed `IUnitOfWork`s with manual saves in the middle — deserves separate decoding, because that pattern is the interesting one: + +```csharp +using var bookingsUow = _uowFactory.Create("bookings"); // wraps BookingDbContext +using var auditUow = _uowFactory.Create("audit"); // wraps AuditDbContext + +// ... mutate bookings ... +await bookingsUow.SaveChangesAsync(ct); // stage 1 — commits its own local tx + +// ... mutate audit ... +await auditUow.SaveChangesAsync(ct); // stage 2 — best-effort follow-up +``` + +What's really going on here: each keyed UoW keeps its own context's transaction **single-context** so escalation never triggers; cross-context consistency is maintained by *sequenced stages* — stage 1 permanent, stage 2 best-effort with manual retry/reconciliation when it fails. In other words, **a hand-rolled saga**. It worked — but every guarantee lived in tribal knowledge: nothing stops a future developer from putting the wrong two saves adjacent, and the "what do we do when stage 2 failed?" procedure lives in ops runbooks rather than code. (The keying itself usually served a simpler purpose too: routing work to the right module's context/connection — which is Option 5's ownership rule emerging informally.) + +That's exactly the gap modern patterns close: keyed contexts became **module-owned DbContexts** (Option 5), and staged-save-plus-hope became an explicit **outbox + consumer**, where stage 2 is retried mechanically and idempotently instead of remembered culturally. Seen this way, R1 wasn't proof that Option 7 scales — it was Option 5 assembled by hand under deadline pressure, wearing Option 7's jacket. + +**Case 5 — real repository R2, agent-audited: ambient endpoint filter over module-owned contexts.** Verified structure of R2 (a second production system, unrelated to R1): one shared `TransactionFilter` registered via `AddEndpointFilter`, wrapping 36 write endpoints across four modules; inside it a single `new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)`; `scope.Complete()` fires only when the response status is < 400; repositories call `SaveChangesAsync` themselves; **no unit-of-work abstraction exists at all** — unlike R1. Seven module-owned scoped contexts (two business, order, notification, authorization, file, outbox) all register against **one shared SQL Server connection string**; the primary runtime is **Linux Docker (.NET 10)**. + +Three distinct patterns live inside that filter: + +- **A · single writer (the majority):** one business context writes; other contexts untouched. One enlisted resource → stays fully local via PSPE. Genuinely fine. +- **B · cross-module read + single write:** query module X's context, write module Y's. Still only one *writing* connection; strictly sequential awaits. Works. +- **C · dual writers in one scope:** a release flow writes stock via one context, then order state via another — sequential `SaveChangesAsync` calls from two contexts under one ambient scope. + +Pattern C is textbook escalation territory: two enlisted connections inside one scope is exactly what triggers promotion — and on a Linux container runtime there is no MSDTC standing by to absorb it. Whether any given execution actually throws depends on details invisible in source (physical connection lifetimes, pool behavior, precise open/close ordering — the audit lists these as explicit unknowns). That uncertainty *is* the diagnosis: intermittent, environment-dependent failures — the same "distributed transactions only ever showed up as bugs" signature as Cases 2–3, except modern .NET on Linux doesn't even ship the safety net. + +Two portable lessons fall out of the audit: + +1. **`TransactionScope`'s default isolation is SERIALIZABLE, not READ COMMITTED.** Passing no `TransactionOptions` silently ran all 36 endpoints at Serializable — maximum locking, deadlock-prone — while reading as "plain transactions" in review; and the repo contains no 1205/40001 retry helper to survive the deadlocks that invites. +2. **Commit-adjacent side effects leave a gap:** the message-bus queue release is awaited *after* `Complete()` but *before* disposal — if that publish fails, the DB changes are already permanent and nothing compensates. An at-least-once hole wearing rollback clothing. + +To be fair to R2's design: module-owned contexts satisfy the playbook's ownership rule, optimistic-concurrency conflicts return proper problem responses, and non-HTTP components use clean explicit local transactions. But the HTTP glue composes Option 6's commit-by-status-code around Option 7's escalation roulette — inheriting the weaknesses of both while resembling neither. + +**Verdict:** avoid in greenfield ASP.NET Core. Justifiable only when wrapping legacy resources (old ADO.NET/COM+/message queue clients) that require ambient enlistment. + +--- + +### The multi-context playbook (N modules ⇒ N DbContexts) + +In a modular/DDD codebase each bounded context owns its own `DbContext`, its own migrations and its own transactions. Everything above still applies — **per module**. Four rules keep it coherent: + +1. **Ownership:** a `DbContext` belongs to exactly one module. Nothing outside that module references it directly; other layers see only abstractions or integration contracts defined in Application. +2. **One commit per use case, in exactly one context.** A handler may read other modules' data only through published read models/events — never by querying their context. If a use case seems to need writes in two contexts, it is actually two use cases coordinated by events (or your module boundaries are wrong). +3. **No ambient transactions across contexts.** Wrapping two contexts in a `TransactionScope` creates a distributed transaction (the Option 7 trap): lock windows stretch across modules, most managed Postgres/MySQL providers don't support it, and it quietly reintroduces the shared-database coupling you split modules to avoid. The replacement is rule 4. +4. **Outbox lives inside each module's own database**, dispatched by a worker, so "state change + event" is always one local atomic transaction. + +What a cross-module workflow looks like end-to-end: + +```text +Bookings module Payments module +──────────────── ──────────────── +BookTicketCommand + tx#1: INSERT booking ← consumes "booking.created" + + outbox(booking.created) ChargeCardCommand + tx#2: INSERT payment + + outbox(payment.confirmed) +MarkPaidCommand ← consumes "payment.confirmed" + tx#3: UPDATE booking.status +``` + +Three local transactions, zero distributed ones, each independently retryable — that is the whole trick. + +--- + +### Microservices: which option wins there? + +The options above were described for monolith/modular monolith, but microservices neither add a new kind of option nor remove one — they **collapse the choice**: + +- **Inside each service: Options 3 + 4, unchanged.** Each service is one bounded context owning its own `DbContext` and its own database; commands still get exactly one commit point, enforced by the pipeline behavior. If one service feels it needs two internal contexts, question its boundaries first. +- **Between services: Option 5 stops being an optimization and becomes *the architecture*.** Database-per-service means a cross-service transaction cannot exist even in principle — 2PC/distributed transactions are effectively dead in modern practice — so every workflow spanning services is a **saga** (choreographed or orchestrated) built on outbox + message broker. + +What changes versus the modular-monolith playbook is mostly transport and failure semantics, not the unit-of-work model: + +| Concern | Modular monolith | Microservices | +|---|---|---| +| Event transport | in-process dispatcher / Worker over shared infra | message broker (RabbitMQ / Kafka / Azure Service Bus) | +| Delivery guarantees | effectively once | at-least-once → consumers **must be idempotent** | +| Workflow failures | local retry, rarely visible | saga compensation ("cancel booking when payment fails") | +| Consistency visibility | usually hidden from users | often user-visible → APIs designed for pending states (`202 Accepted` + status endpoint) | + +What does **not** change: ad-hoc saves are still broken; a global UoW goes from anti-pattern to *physical impossibility* (no shared process, no shared database); optimistic concurrency remains per-aggregate inside each service; and the intra-service invariant stays *one commit point per command*. + +**Stated plainly — go-to baseline for microservices:** Options 3+4 inside every service, Option 5 (outbox + sagas) as the inter-service contract. Nothing else survives contact with distributed reality. + +--- + +## 2. Master comparison + +Scores 1–100. Popularity figures are rough estimates of production ASP.NET + EF Core codebases (styles overlap, so they don't sum to 100). + +| Criterion | 1 · Ad-hoc saves | 2 · Global UoW | 3 · Scoped + explicit save | 4 · Auto-commit behavior | 5 · Modules + outbox | 6 · Tx per request | 7 · `TransactionScope` | +|---|---:|---:|---:|---:|---:|---:|---:| +| Simplicity | 65 | 35 | 90 | 75 | 45 | 80 | 55 | +| Correctness / atomicity | 25 | 55 | 80 | 92 | 85 | 70 | 50 | +| Testability | 30 | 45 | 88 | 82 | 78 | 75 | 45 | +| Performance | 45 | 50 | 85 | 83 | 92 | 60 | 35 | +| Maintainability (long-term) | 20 | 35 | 85 | 88 | 72 | 65 | 40 | +| Clean-architecture fit | 25 | 40 | 85 | 90 | 95 | 60 | 30 | +| Concurrency safety | 20 | 45 | 70 | 72 | 82 | 65 | 50 | +| Developer experience | 40 | 45 | 88 | 87 | 55 | 78 | 45 | +| **Average** | **34** | **44** | **84** | **84** | **76** | **69** | **44** | +| Popularity | ~30% | ~18% | ~28% | ~14% | ~10% | ~12% | ~7% | + +- **Options 6 and 7 are the "legacy defaults":** per-request filter transactions are still a respectable choice for simple MVC apps; ambient `TransactionScope` is the mainframe-era relic to retire on contact. + +Reading the table: + +- **Option 1 is popular precisely because it's easy until it isn't** — its scores collapse exactly on the criteria that matter most (correctness, concurrency). +- **Option 2 loses on almost everything**: it's abstraction for abstraction's sake over a framework feature that already exists. +- **Options 3+4 tie at 84 while covering each other's weaknesses** (3 lacks enforcement, 4 has slightly more moving parts). Together they're the sweet spot. +- **Option 5 wins on architecture fit and throughput** but costs eventual consistency — deploy it at boundaries, not everywhere. + +--- + +## 3. The other "concurrency": lost updates between transactions + +No unit-of-work arrangement protects two simultaneous requests from this race: + +1. Request A reads `SeatsAvailable = 1` +2. Request B reads `SeatsAvailable = 1` +3. A saves `-1` → `0`; B saves `-1` → `0` — **one seat sold twice**, no exception anywhere. + +The fix is **optimistic concurrency control**: a version token on hot aggregates; EF rejects the second write. + +```csharp +public class Event +{ + public Guid Id { get; set; } + + [Timestamp] // SQL Server rowversion; Postgres: UseXminAsConcurrencyToken() + public byte[] Version { get; set; } = default!; +} +``` + +```csharp +try +{ + await db.SaveChangesAsync(ct); // second concurrent writer throws here +} +catch (DbUpdateConcurrencyException) +{ + throw new ConflictException("Event changed meanwhile — please retry."); +} +``` + +This converts silent corruption into a detectable conflict (map to HTTP 409 / retry). For extreme hot spots (single counter row hammered by hundreds of requests), prefer making the decrement itself atomic — `UPDATE ... SET SeatsAvailable -= @n WHERE Id = @id AND SeatsAvailable >= @n` — or serializable isolation scoped to that one command. Bigger/longer transactions are *never* the fix. In a multi-module system these tokens live on aggregates inside their **owning** context; cross-module write races don't exist by design, because modules never write each other's tables. + +--- + +## 4. Decision guide + +- **Inside a use case:** Option 3 as the baseline — one commit point in that use case's own module context; Option 4's behavior to enforce it mechanically; extra `SaveChanges` calls allowed when genuinely needed, but always inside the behavior's transaction. Never let a mid-command save commit alone. +- **Between modules:** Option 5 — separate contexts, outbox + events, eventual consistency. Never share a transaction across a module boundary. +- **Between concurrent writers:** optimistic concurrency tokens on contested aggregates; atomic SQL updates for hot counters. +- **Isolation levels:** stay on the DB default (READ COMMITTED) for everything; escalate to REPEATABLE READ/SERIALIZABLE per command only when a business rule genuinely spans multiple statements, and always pair with a retry policy. +- **Microservices:** nothing new — Options 3+4 inside each service, Option 5's outbox + sagas between services. Database-per-service makes cross-service transactions physically impossible, which conveniently removes the temptation. +- **External side effects** (payments, emails): out of the handler's transaction; trigger via after-commit events/outbox so a rollback can't strand them. +- **Never:** `IUnitOfWork` wrapping `DbContext`, repositories that merely re-expose `DbSet`, a UoW injected into every module "so they share transactions," or `TransactionScope` spanning two module contexts (Option 7). + +### Recommendation for this repo (2026-08-26 audit) + +This codebase implements Option 3 with Option 5 deliberately deferred: + +- Three module-owned scoped `DbContext`s (Identity, Catalog, Negotiations); one commit + point per use case, owned by that use case's `*Handler`; cross-module reads only via + the `IProductPriceProvider` port. +- Client-generated GUIDv7 keys everywhere, so flows fit one flush; the single + multi-save flow (`CreateNegotiationHandler`) wraps provisioning + insert in one + explicit transaction (Case B above). +- `xmin` optimistic tokens sit on both write aggregates; conflicts surface as + `DbUpdateConcurrencyException` mapped to HTTP 409 (`concurrency_conflict`). +- Unique-index races translate to 409 through `DbWriteGuard.SaveOrConflictAsync`. +- No MediatR pipeline behavior (Option 4) by design: handlers own persistence, and the + architecture test `Only_handlers_seeding_and_the_write_guard_commit_the_unit_of_work` + pins who may call `SaveChangesAsync`. +- Outbox/events arrive with the first real subscriber (ddd-audit spec §F-04); today's + cross-module edge is a synchronous read, not a workflow. diff --git a/global.json b/global.json new file mode 100644 index 0000000..fdbdeb3 --- /dev/null +++ b/global.json @@ -0,0 +1,9 @@ +{ + "sdk": { + "version": "10.0.303", + "rollForward": "latestFeature" + }, + "test": { + "runner": "Microsoft.Testing.Platform" + } +} diff --git a/nuget.config b/nuget.config new file mode 100644 index 0000000..a11ce1e --- /dev/null +++ b/nuget.config @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Application/Create/CreateProductRequest.cs b/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Application/Create/CreateProductRequest.cs new file mode 100644 index 0000000..f240ef9 --- /dev/null +++ b/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Application/Create/CreateProductRequest.cs @@ -0,0 +1,8 @@ +namespace PriceNegotiationApp.Modules.Catalog.Application.Create; + +internal sealed class CreateProductRequest +{ + public string Name { get; init; } = string.Empty; + + public decimal Price { get; init; } +} diff --git a/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Application/Create/CreateProductRequestValidator.cs b/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Application/Create/CreateProductRequestValidator.cs new file mode 100644 index 0000000..7505efb --- /dev/null +++ b/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Application/Create/CreateProductRequestValidator.cs @@ -0,0 +1,19 @@ +using FluentValidation; + +namespace PriceNegotiationApp.Modules.Catalog.Application.Create; + +// MA0182: used via DI assembly scanning (AddValidatorsFromAssemblyContaining), invisible to static analysis. +#pragma warning disable MA0182 +internal sealed class CreateProductRequestValidator : AbstractValidator +#pragma warning restore MA0182 +{ + public CreateProductRequestValidator() + { + RuleFor(x => x.Name) + .NotEmpty() + .MaximumLength(200); + + RuleFor(x => x.Price) + .GreaterThan(0m); + } +} diff --git a/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Application/PriceNegotiationApp.Modules.Catalog.Application.csproj b/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Application/PriceNegotiationApp.Modules.Catalog.Application.csproj new file mode 100644 index 0000000..5426662 --- /dev/null +++ b/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Application/PriceNegotiationApp.Modules.Catalog.Application.csproj @@ -0,0 +1,20 @@ + + + $(NoWarn);MA0182 + + + + + + + + + + + + + + + + + diff --git a/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Application/ProductModels.cs b/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Application/ProductModels.cs new file mode 100644 index 0000000..60c789f --- /dev/null +++ b/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Application/ProductModels.cs @@ -0,0 +1,3 @@ +namespace PriceNegotiationApp.Modules.Catalog.Application; + +internal sealed record ProductResponse(Guid Id, string Name, decimal Price); diff --git a/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Application/ProductQuery.cs b/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Application/ProductQuery.cs new file mode 100644 index 0000000..77331f0 --- /dev/null +++ b/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Application/ProductQuery.cs @@ -0,0 +1,10 @@ +namespace PriceNegotiationApp.Modules.Catalog.Application; + +internal sealed record ProductQuery( + string? Search = null, + decimal? MinPrice = null, + decimal? MaxPrice = null, + string? SortBy = null, + bool SortDesc = false, + int Page = 1, + int PageSize = 20); diff --git a/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Application/Update/UpdateProductRequest.cs b/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Application/Update/UpdateProductRequest.cs new file mode 100644 index 0000000..886a3b4 --- /dev/null +++ b/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Application/Update/UpdateProductRequest.cs @@ -0,0 +1,8 @@ +namespace PriceNegotiationApp.Modules.Catalog.Application.Update; + +internal sealed class UpdateProductRequest +{ + public string Name { get; init; } = string.Empty; + + public decimal Price { get; init; } +} diff --git a/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Application/Update/UpdateProductRequestValidator.cs b/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Application/Update/UpdateProductRequestValidator.cs new file mode 100644 index 0000000..7db9308 --- /dev/null +++ b/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Application/Update/UpdateProductRequestValidator.cs @@ -0,0 +1,19 @@ +using FluentValidation; + +namespace PriceNegotiationApp.Modules.Catalog.Application.Update; + +// MA0182: used via DI assembly scanning (AddValidatorsFromAssemblyContaining), invisible to static analysis. +#pragma warning disable MA0182 +internal sealed class UpdateProductRequestValidator : AbstractValidator +#pragma warning restore MA0182 +{ + public UpdateProductRequestValidator() + { + RuleFor(x => x.Name) + .NotEmpty() + .MaximumLength(200); + + RuleFor(x => x.Price) + .GreaterThan(0m); + } +} diff --git a/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Contracts/IProductPriceProvider.cs b/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Contracts/IProductPriceProvider.cs new file mode 100644 index 0000000..d883b68 --- /dev/null +++ b/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Contracts/IProductPriceProvider.cs @@ -0,0 +1,9 @@ +namespace PriceNegotiationApp.Modules.Catalog.Contracts; + +public interface IProductPriceProvider +{ + /// Returns null when the product does not exist. + Task GetAsync(Guid productId, CancellationToken ct); +} + +public readonly record struct ProductSnapshot(Guid ProductId, decimal Price); diff --git a/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Contracts/PriceNegotiationApp.Modules.Catalog.Contracts.csproj b/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Contracts/PriceNegotiationApp.Modules.Catalog.Contracts.csproj new file mode 100644 index 0000000..6bd7894 --- /dev/null +++ b/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Contracts/PriceNegotiationApp.Modules.Catalog.Contracts.csproj @@ -0,0 +1,5 @@ + + + + + diff --git a/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Domain/Price.cs b/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Domain/Price.cs new file mode 100644 index 0000000..a394d9b --- /dev/null +++ b/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Domain/Price.cs @@ -0,0 +1,10 @@ +using Vogen; + +namespace PriceNegotiationApp.Modules.Catalog.Domain; + +[ValueObject(Conversions.None)] +internal readonly partial record struct Price +{ + private static Validation Validate(decimal value) => + value > 0m ? Validation.Ok : Validation.Invalid("Price must be greater than zero."); +} diff --git a/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Domain/PriceNegotiationApp.Modules.Catalog.Domain.csproj b/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Domain/PriceNegotiationApp.Modules.Catalog.Domain.csproj new file mode 100644 index 0000000..66a6dfa --- /dev/null +++ b/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Domain/PriceNegotiationApp.Modules.Catalog.Domain.csproj @@ -0,0 +1,17 @@ + + + $(NoWarn);MA0097;MA0182 + + + + + + + + + + + + + + diff --git a/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Domain/Product.cs b/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Domain/Product.cs new file mode 100644 index 0000000..e858a55 --- /dev/null +++ b/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Domain/Product.cs @@ -0,0 +1,69 @@ +using PriceNegotiationApp.SharedKernel; +using Vogen; +using PriceVo = PriceNegotiationApp.Modules.Catalog.Domain.Price; + +namespace PriceNegotiationApp.Modules.Catalog.Domain; + + +internal sealed class Product +{ + public const int MaxNameLength = 200; + + public ProductId Id { get; private set; } + + public string Name { get; private set; } = null!; + + public decimal Price { get; private set; } + + /// Optimistic-concurrency token mapped to PostgreSQL xmin. + public uint Version { get; private set; } + + private Product() + { + } + + private Product(ProductId id, string name, decimal price) + { + EnsureValid(name, price); + Id = id; + Name = name.Trim(); + Price = PriceVo.From(price).Value; + } + + public static Product Create(string name, decimal price) => + new(ProductId.From(Guid.CreateVersion7()), name, price); + + /// Applies changes. Returns false when nothing changed (PUT stays idempotent). + public bool Update(string name, decimal price) + { + EnsureValid(name, price); + var validated = PriceVo.From(price).Value; + var trimmed = name.Trim(); + if (string.Equals(Name, trimmed, StringComparison.Ordinal) && Price == validated) + { + return false; + } + + Name = trimmed; + Price = validated; + return true; + } + + private static void EnsureValid(string? name, decimal price) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new DomainException("Product name must not be empty."); + } + + if (name.Trim().Length > MaxNameLength) + { + throw new DomainException($"Product name must not exceed {MaxNameLength} characters."); + } + + PriceVo.From(price); + } +} + + + diff --git a/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Domain/ProductId.cs b/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Domain/ProductId.cs new file mode 100644 index 0000000..10b31e9 --- /dev/null +++ b/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Domain/ProductId.cs @@ -0,0 +1,8 @@ +using Vogen; + +namespace PriceNegotiationApp.Modules.Catalog.Domain; + +[ValueObject(Conversions.None)] +internal readonly partial record struct ProductId; + + diff --git a/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Infrastructure/CatalogModule.cs b/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Infrastructure/CatalogModule.cs new file mode 100644 index 0000000..6b5280a --- /dev/null +++ b/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Infrastructure/CatalogModule.cs @@ -0,0 +1,40 @@ +using FluentValidation; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using PriceNegotiationApp.Modules.Catalog.Application.Create; +using PriceNegotiationApp.Modules.Catalog.Application.Update; +using PriceNegotiationApp.Modules.Catalog.Infrastructure.Create; +using PriceNegotiationApp.Modules.Catalog.Infrastructure.Delete; +using PriceNegotiationApp.Modules.Catalog.Infrastructure.Get; +using PriceNegotiationApp.Modules.Catalog.Infrastructure.List; +using PriceNegotiationApp.Modules.Catalog.Infrastructure.Update; +using PriceNegotiationApp.Modules.Catalog.Infrastructure.Persistence; +using PriceNegotiationApp.Modules.Catalog.Infrastructure.Seeding; +using PriceNegotiationApp.SharedKernel; + +namespace PriceNegotiationApp.Modules.Catalog.Infrastructure; + +public static class CatalogModule +{ + public static IServiceCollection AddCatalogModule( + this IServiceCollection services, IConfiguration configuration) + { + services.AddDbContext(options => options + .UseNpgsql(DbConnections.Resolve(configuration, "Catalog"), + npgsql => npgsql.MigrationsHistoryTable("__EFMigrationsHistory_Catalog")) + .UseSnakeCaseNamingConvention()); + // Deliberately unvalidated: CatalogSeedingOptions is a single optional bool + // with no meaningful validation surface (engineering-hardening spec §7). + services.AddOptions() + .Bind(configuration.GetSection(CatalogSeedingOptions.SectionName)); + services.AddHostedService(); + services.AddValidatorsFromAssemblyContaining(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + return services; + } +} diff --git a/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Infrastructure/Create/CreateProductHandler.cs b/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Infrastructure/Create/CreateProductHandler.cs new file mode 100644 index 0000000..b610ae5 --- /dev/null +++ b/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Infrastructure/Create/CreateProductHandler.cs @@ -0,0 +1,17 @@ +using PriceNegotiationApp.Modules.Catalog.Application.Create; +using PriceNegotiationApp.Modules.Catalog.Domain; +using PriceNegotiationApp.Modules.Catalog.Application; +using PriceNegotiationApp.Modules.Catalog.Infrastructure.Persistence; + +namespace PriceNegotiationApp.Modules.Catalog.Infrastructure.Create; + +internal sealed class CreateProductHandler(CatalogDbContext db) +{ + public async Task HandleAsync(CreateProductRequest request, CancellationToken ct) + { + var product = Product.Create(request.Name, request.Price); + db.Products.Add(product); + await db.SaveChangesAsync(ct); + return new ProductResponse(product.Id.Value, product.Name, product.Price); + } +} diff --git a/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Infrastructure/Delete/DeleteProductHandler.cs b/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Infrastructure/Delete/DeleteProductHandler.cs new file mode 100644 index 0000000..49ebd70 --- /dev/null +++ b/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Infrastructure/Delete/DeleteProductHandler.cs @@ -0,0 +1,18 @@ +using Microsoft.EntityFrameworkCore; +using PriceNegotiationApp.Modules.Catalog.Domain; +using PriceNegotiationApp.Modules.Catalog.Infrastructure.Persistence; +using PriceNegotiationApp.SharedKernel; + +namespace PriceNegotiationApp.Modules.Catalog.Infrastructure.Delete; + +internal sealed class DeleteProductHandler(CatalogDbContext db) +{ + // Negotiations survive on their snapshots by design. + public async Task HandleAsync(Guid id, CancellationToken ct) + { + var product = await db.Products.FirstOrDefaultAsync(p => p.Id == ProductId.From(id), ct) + ?? throw new NotFoundException("Product", id); + db.Products.Remove(product); + await db.SaveChangesAsync(ct); + } +} diff --git a/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Infrastructure/Get/GetProductHandler.cs b/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Infrastructure/Get/GetProductHandler.cs new file mode 100644 index 0000000..c40dd25 --- /dev/null +++ b/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Infrastructure/Get/GetProductHandler.cs @@ -0,0 +1,17 @@ +using Microsoft.EntityFrameworkCore; +using PriceNegotiationApp.Modules.Catalog.Domain; +using PriceNegotiationApp.Modules.Catalog.Application; +using PriceNegotiationApp.Modules.Catalog.Infrastructure.Persistence; +using PriceNegotiationApp.SharedKernel; + +namespace PriceNegotiationApp.Modules.Catalog.Infrastructure.Get; + +internal sealed class GetProductHandler(CatalogDbContext db) +{ + public async Task HandleAsync(Guid id, CancellationToken ct) => + await db.Products.AsNoTracking() + .Where(p => p.Id == ProductId.From(id)) + .Select(p => new ProductResponse(p.Id.Value, p.Name, p.Price)) + .FirstOrDefaultAsync(ct) + ?? throw new NotFoundException("Product", id); +} diff --git a/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Infrastructure/List/ListProductsHandler.cs b/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Infrastructure/List/ListProductsHandler.cs new file mode 100644 index 0000000..210ee6b --- /dev/null +++ b/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Infrastructure/List/ListProductsHandler.cs @@ -0,0 +1,47 @@ +using Microsoft.EntityFrameworkCore; +using PriceNegotiationApp.Modules.Catalog.Application; +using PriceNegotiationApp.Modules.Catalog.Infrastructure.Persistence; +using PriceNegotiationApp.SharedKernel; + +namespace PriceNegotiationApp.Modules.Catalog.Infrastructure.List; + +internal sealed class ListProductsHandler(CatalogDbContext db) +{ + public async Task> HandleAsync(ProductQuery query, CancellationToken ct) + { + var page = new PageQuery(query.Page, query.PageSize); + var q = db.Products.AsNoTracking(); + + if (!string.IsNullOrWhiteSpace(query.Search)) + { + q = q.Where(p => EF.Functions.ILike(p.Name, $"%{query.Search.Trim()}%")); + } + + if (query.MinPrice.HasValue) + { + q = q.Where(p => p.Price >= query.MinPrice.Value); + } + + if (query.MaxPrice.HasValue) + { + q = q.Where(p => p.Price <= query.MaxPrice.Value); + } + + q = (query.SortBy?.Trim().ToLowerInvariant(), query.SortDesc) switch + { + ("price", true) => q.OrderByDescending(p => p.Price), + ("price", false) => q.OrderBy(p => p.Price), + (_, true) => q.OrderByDescending(p => p.Name), + _ => q.OrderBy(p => p.Name), + }; + + var total = await q.LongCountAsync(ct); + var items = await q + .Skip(page.Skip) + .Take(page.SafePageSize) + .Select(p => new ProductResponse(p.Id.Value, p.Name, p.Price)) + .ToListAsync(ct); + + return new PagedResult(items, page.SafePage, page.SafePageSize, total); + } +} diff --git a/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Infrastructure/Persistence/CatalogDbContext.cs b/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Infrastructure/Persistence/CatalogDbContext.cs new file mode 100644 index 0000000..6cf9e49 --- /dev/null +++ b/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Infrastructure/Persistence/CatalogDbContext.cs @@ -0,0 +1,17 @@ +using Microsoft.EntityFrameworkCore; +using PriceNegotiationApp.Modules.Catalog.Domain; +using PriceNegotiationApp.Modules.Catalog.Infrastructure.Persistence.Configurations; + +namespace PriceNegotiationApp.Modules.Catalog.Infrastructure.Persistence; + +internal sealed class CatalogDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Products => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + modelBuilder.HasDefaultSchema("catalog"); + modelBuilder.ApplyConfiguration(new ProductConfiguration()); + } +} diff --git a/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Infrastructure/Persistence/Configurations/ProductConfiguration.cs b/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Infrastructure/Persistence/Configurations/ProductConfiguration.cs new file mode 100644 index 0000000..fea2427 --- /dev/null +++ b/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Infrastructure/Persistence/Configurations/ProductConfiguration.cs @@ -0,0 +1,20 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using PriceNegotiationApp.Modules.Catalog.Domain; + +namespace PriceNegotiationApp.Modules.Catalog.Infrastructure.Persistence.Configurations; + +internal sealed class ProductConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("products"); + builder.HasKey(p => p.Id); + builder.Property(p => p.Id).HasConversion(id => id.Value, value => ProductId.From(value)).ValueGeneratedNever(); + builder.Property(p => p.Name).HasMaxLength(200).IsRequired(); + builder.Property(p => p.Price).HasColumnType("numeric(18,2)"); + builder.Property(p => p.Version).IsRowVersion(); + } +} + + diff --git a/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Infrastructure/Persistence/DesignTimeDbContextFactory.cs b/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Infrastructure/Persistence/DesignTimeDbContextFactory.cs new file mode 100644 index 0000000..4ca287d --- /dev/null +++ b/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Infrastructure/Persistence/DesignTimeDbContextFactory.cs @@ -0,0 +1,18 @@ +using PriceNegotiationApp.Modules.Catalog.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using PriceNegotiationApp.SharedKernel; + +using System.Diagnostics.CodeAnalysis; + +namespace PriceNegotiationApp.Modules.Catalog.Infrastructure.Persistence; + +[SuppressMessage("Meziantou.Analyzer", "MA0182", Justification = "Instantiated by EF Core design-time tooling via reflection.")] +internal sealed class DesignTimeDbContextFactory : DesignTimeDbContextFactoryBase +{ + protected override void Configure(DbContextOptionsBuilder builder) => + builder.UseNpgsql(LocalConnectionString, + npgsql => npgsql.MigrationsHistoryTable("__EFMigrationsHistory_Catalog")) + .UseSnakeCaseNamingConvention(); + + protected override CatalogDbContext Create(DbContextOptions options) => new(options); +} diff --git a/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Infrastructure/Persistence/Migrations/20260824052228_Initial.Designer.cs b/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Infrastructure/Persistence/Migrations/20260824052228_Initial.Designer.cs new file mode 100644 index 0000000..3d10646 --- /dev/null +++ b/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Infrastructure/Persistence/Migrations/20260824052228_Initial.Designer.cs @@ -0,0 +1,59 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using PriceNegotiationApp.Modules.Catalog.Infrastructure.Persistence; + +#nullable disable + +namespace PriceNegotiationApp.Modules.Catalog.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(CatalogDbContext))] + [Migration("20260824052228_Initial")] + partial class Initial + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("catalog") + .HasAnnotation("ProductVersion", "10.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("PriceNegotiationApp.Modules.Catalog.Domain.Product", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Price") + .HasColumnType("numeric(18,2)") + .HasColumnName("price"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id") + .HasName("pk_products"); + + b.ToTable("products", "catalog"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Infrastructure/Persistence/Migrations/20260824052228_Initial.cs b/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Infrastructure/Persistence/Migrations/20260824052228_Initial.cs new file mode 100644 index 0000000..4e099e3 --- /dev/null +++ b/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Infrastructure/Persistence/Migrations/20260824052228_Initial.cs @@ -0,0 +1,41 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using System; + +#nullable disable + +namespace PriceNegotiationApp.Modules.Catalog.Infrastructure.Persistence.Migrations +{ + /// + public partial class Initial : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "catalog"); + + migrationBuilder.CreateTable( + name: "products", + schema: "catalog", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + price = table.Column(type: "numeric(18,2)", nullable: false), + xmin = table.Column(type: "xid", rowVersion: true, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_products", x => x.id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "products", + schema: "catalog"); + } + } +} diff --git a/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Infrastructure/Persistence/Migrations/CatalogDbContextModelSnapshot.cs b/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Infrastructure/Persistence/Migrations/CatalogDbContextModelSnapshot.cs new file mode 100644 index 0000000..30ca57d --- /dev/null +++ b/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Infrastructure/Persistence/Migrations/CatalogDbContextModelSnapshot.cs @@ -0,0 +1,56 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using PriceNegotiationApp.Modules.Catalog.Infrastructure.Persistence; + +#nullable disable + +namespace PriceNegotiationApp.Modules.Catalog.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(CatalogDbContext))] + partial class CatalogDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("catalog") + .HasAnnotation("ProductVersion", "10.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("PriceNegotiationApp.Modules.Catalog.Domain.Product", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Price") + .HasColumnType("numeric(18,2)") + .HasColumnName("price"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id") + .HasName("pk_products"); + + b.ToTable("products", "catalog"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Infrastructure/PriceNegotiationApp.Modules.Catalog.Infrastructure.csproj b/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Infrastructure/PriceNegotiationApp.Modules.Catalog.Infrastructure.csproj new file mode 100644 index 0000000..3cd00ce --- /dev/null +++ b/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Infrastructure/PriceNegotiationApp.Modules.Catalog.Infrastructure.csproj @@ -0,0 +1,23 @@ + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + diff --git a/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Infrastructure/ProductPriceProvider.cs b/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Infrastructure/ProductPriceProvider.cs new file mode 100644 index 0000000..f88266f --- /dev/null +++ b/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Infrastructure/ProductPriceProvider.cs @@ -0,0 +1,19 @@ +using Microsoft.EntityFrameworkCore; +using PriceNegotiationApp.Modules.Catalog.Domain; +using PriceNegotiationApp.Modules.Catalog.Infrastructure.Persistence; +using PriceNegotiationApp.Modules.Catalog.Contracts; + +namespace PriceNegotiationApp.Modules.Catalog.Infrastructure; + +// MA0182: used via DI registration (AddScoped), invisible to static analysis. +#pragma warning disable MA0182 +/// Adapter: Negotiations reads product price snapshots from Catalog's persistence. +internal sealed class ProductPriceProvider(CatalogDbContext db) : IProductPriceProvider +#pragma warning restore MA0182 +{ + public async Task GetAsync(Guid productId, CancellationToken ct) => + await db.Products.AsNoTracking() + .Where(p => p.Id == ProductId.From(productId)) + .Select(p => new ProductSnapshot(productId, p.Price)) + .FirstOrDefaultAsync(ct); +} diff --git a/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Infrastructure/Seeding/CatalogSeedingHostedService.cs b/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Infrastructure/Seeding/CatalogSeedingHostedService.cs new file mode 100644 index 0000000..027d833 --- /dev/null +++ b/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Infrastructure/Seeding/CatalogSeedingHostedService.cs @@ -0,0 +1,35 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using PriceNegotiationApp.Modules.Catalog.Domain; +using PriceNegotiationApp.Modules.Catalog.Infrastructure.Persistence; +using PriceNegotiationApp.SharedKernel; + +namespace PriceNegotiationApp.Modules.Catalog.Infrastructure.Seeding; + +internal sealed class CatalogSeedingHostedService( + IServiceScopeFactory scopeFactory, + IOptions options, + ILogger logger) : ModuleSeedingHostedServiceBase(scopeFactory) +{ + protected override async Task SeedAsync(IServiceProvider services, CancellationToken cancellationToken) + { + if (!options.Value.SeedSampleProducts) + { + return; + } + + var db = services.GetRequiredService(); + if (!await db.Products.AnyAsync(cancellationToken)) + { + db.Products.AddRange( + Product.Create("Mechanical Keyboard", 249.00m), + Product.Create("Wireless Mouse", 79.90m), + Product.Create("USB-C Docking Station", 189.50m)); + await db.SaveChangesAsync(cancellationToken); + } + + logger.LogInformation("Catalog seed data ensured."); + } +} diff --git a/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Infrastructure/Seeding/CatalogSeedingOptions.cs b/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Infrastructure/Seeding/CatalogSeedingOptions.cs new file mode 100644 index 0000000..5edfdb2 --- /dev/null +++ b/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Infrastructure/Seeding/CatalogSeedingOptions.cs @@ -0,0 +1,8 @@ +namespace PriceNegotiationApp.Modules.Catalog.Infrastructure.Seeding; + +internal sealed class CatalogSeedingOptions +{ + public const string SectionName = "Seeding"; + + public bool SeedSampleProducts { get; init; } +} diff --git a/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Infrastructure/Update/UpdateProductHandler.cs b/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Infrastructure/Update/UpdateProductHandler.cs new file mode 100644 index 0000000..d895af3 --- /dev/null +++ b/src/Modules/Catalog/PriceNegotiationApp.Modules.Catalog.Infrastructure/Update/UpdateProductHandler.cs @@ -0,0 +1,22 @@ +using PriceNegotiationApp.Modules.Catalog.Application.Update; +using PriceNegotiationApp.Modules.Catalog.Application.Create; +using Microsoft.EntityFrameworkCore; +using PriceNegotiationApp.Modules.Catalog.Domain; +using PriceNegotiationApp.Modules.Catalog.Application; +using PriceNegotiationApp.Modules.Catalog.Infrastructure.Persistence; +using PriceNegotiationApp.SharedKernel; + +namespace PriceNegotiationApp.Modules.Catalog.Infrastructure.Update; + +internal sealed class UpdateProductHandler(CatalogDbContext db) +{ + public async Task HandleAsync(Guid id, UpdateProductRequest request, CancellationToken ct) + { + var product = await db.Products.FirstOrDefaultAsync(p => p.Id == ProductId.From(id), ct) + ?? throw new NotFoundException("Product", id); + + product.Update(request.Name, request.Price); + await db.SaveChangesAsync(ct); + return new ProductResponse(product.Id.Value, product.Name, product.Price); + } +} diff --git a/src/Modules/Identity/PriceNegotiationApp.Modules.Identity.Application/Login/LoginModels.cs b/src/Modules/Identity/PriceNegotiationApp.Modules.Identity.Application/Login/LoginModels.cs new file mode 100644 index 0000000..6786044 --- /dev/null +++ b/src/Modules/Identity/PriceNegotiationApp.Modules.Identity.Application/Login/LoginModels.cs @@ -0,0 +1,14 @@ +namespace PriceNegotiationApp.Modules.Identity.Application.Login; + +internal sealed class LoginRequest +{ + public string Email { get; init; } = string.Empty; + + public string Password { get; init; } = string.Empty; +} + +internal sealed record AuthResponse( + string AccessToken, + DateTimeOffset ExpiresAtUtc, + string Email, + IReadOnlyList Roles); diff --git a/src/Modules/Identity/PriceNegotiationApp.Modules.Identity.Application/Login/LoginRequestValidator.cs b/src/Modules/Identity/PriceNegotiationApp.Modules.Identity.Application/Login/LoginRequestValidator.cs new file mode 100644 index 0000000..919e19a --- /dev/null +++ b/src/Modules/Identity/PriceNegotiationApp.Modules.Identity.Application/Login/LoginRequestValidator.cs @@ -0,0 +1,19 @@ +using FluentValidation; + +namespace PriceNegotiationApp.Modules.Identity.Application.Login; + +// MA0182: used via DI assembly scanning (AddValidatorsFromAssemblyContaining), invisible to static analysis. +#pragma warning disable MA0182 +internal sealed class LoginRequestValidator : AbstractValidator +#pragma warning restore MA0182 +{ + public LoginRequestValidator() + { + RuleFor(x => x.Email) + .NotEmpty() + .EmailAddress(); + + RuleFor(x => x.Password) + .NotEmpty(); + } +} diff --git a/src/Modules/Identity/PriceNegotiationApp.Modules.Identity.Application/PriceNegotiationApp.Modules.Identity.Application.csproj b/src/Modules/Identity/PriceNegotiationApp.Modules.Identity.Application/PriceNegotiationApp.Modules.Identity.Application.csproj new file mode 100644 index 0000000..e14bfd4 --- /dev/null +++ b/src/Modules/Identity/PriceNegotiationApp.Modules.Identity.Application/PriceNegotiationApp.Modules.Identity.Application.csproj @@ -0,0 +1,18 @@ + + + $(NoWarn);MA0182 + + + + + + + + + + + + + + + diff --git a/src/Modules/Identity/PriceNegotiationApp.Modules.Identity.Application/Register/RegisterModels.cs b/src/Modules/Identity/PriceNegotiationApp.Modules.Identity.Application/Register/RegisterModels.cs new file mode 100644 index 0000000..fbebdf6 --- /dev/null +++ b/src/Modules/Identity/PriceNegotiationApp.Modules.Identity.Application/Register/RegisterModels.cs @@ -0,0 +1,10 @@ +namespace PriceNegotiationApp.Modules.Identity.Application.Register; + +internal sealed class RegisterRequest +{ + public string Email { get; init; } = string.Empty; + + public string Password { get; init; } = string.Empty; +} + +internal sealed record RegistrationResponse(Guid UserId); diff --git a/src/Modules/Identity/PriceNegotiationApp.Modules.Identity.Application/Register/RegisterRequestValidator.cs b/src/Modules/Identity/PriceNegotiationApp.Modules.Identity.Application/Register/RegisterRequestValidator.cs new file mode 100644 index 0000000..8b54bd0 --- /dev/null +++ b/src/Modules/Identity/PriceNegotiationApp.Modules.Identity.Application/Register/RegisterRequestValidator.cs @@ -0,0 +1,24 @@ +using FluentValidation; + +namespace PriceNegotiationApp.Modules.Identity.Application.Register; + +// MA0182: used via DI assembly scanning (AddValidatorsFromAssemblyContaining), invisible to static analysis. +#pragma warning disable MA0182 +internal sealed class RegisterRequestValidator : AbstractValidator +#pragma warning restore MA0182 +{ + public RegisterRequestValidator() + { + RuleFor(x => x.Email) + .NotEmpty() + .EmailAddress(); + + RuleFor(x => x.Password) + .NotEmpty() + .MinimumLength(8) + .Matches(@"[A-Z]").WithMessage("Password must contain at least one uppercase letter.") + .Matches(@"[a-z]").WithMessage("Password must contain at least one lowercase letter.") + .Matches(@"\d").WithMessage("Password must contain at least one digit.") + .Matches(@"[!@#$%^&*()\-_=+\[\]{}|;:'"",.<>?/\\]").WithMessage("Password must contain at least one special character."); + } +} diff --git a/src/Modules/Identity/PriceNegotiationApp.Modules.Identity.Contracts/AuthModels.cs b/src/Modules/Identity/PriceNegotiationApp.Modules.Identity.Contracts/AuthModels.cs new file mode 100644 index 0000000..d2fb483 --- /dev/null +++ b/src/Modules/Identity/PriceNegotiationApp.Modules.Identity.Contracts/AuthModels.cs @@ -0,0 +1,3 @@ +namespace PriceNegotiationApp.Modules.Identity.Contracts; + +public sealed record CurrentUserResponse(Guid UserId, string Email, IReadOnlyList Roles); diff --git a/src/Modules/Identity/PriceNegotiationApp.Modules.Identity.Contracts/IdentityErrorCodes.cs b/src/Modules/Identity/PriceNegotiationApp.Modules.Identity.Contracts/IdentityErrorCodes.cs new file mode 100644 index 0000000..4429945 --- /dev/null +++ b/src/Modules/Identity/PriceNegotiationApp.Modules.Identity.Contracts/IdentityErrorCodes.cs @@ -0,0 +1,10 @@ +namespace PriceNegotiationApp.Modules.Identity.Contracts; + +public static class IdentityErrorCodes +{ + public const string EmailAlreadyRegistered = "email_already_registered"; + + public const string InvalidCredentials = "invalid_credentials"; + + public const string RegistrationInvalid = "registration_invalid"; +} diff --git a/src/Modules/Identity/PriceNegotiationApp.Modules.Identity.Contracts/PriceNegotiationApp.Modules.Identity.Contracts.csproj b/src/Modules/Identity/PriceNegotiationApp.Modules.Identity.Contracts/PriceNegotiationApp.Modules.Identity.Contracts.csproj new file mode 100644 index 0000000..00fd72d --- /dev/null +++ b/src/Modules/Identity/PriceNegotiationApp.Modules.Identity.Contracts/PriceNegotiationApp.Modules.Identity.Contracts.csproj @@ -0,0 +1,8 @@ + + + $(NoWarn);MA0182 + + + + + diff --git a/src/Modules/Identity/PriceNegotiationApp.Modules.Identity.Infrastructure/EcSigningKey.cs b/src/Modules/Identity/PriceNegotiationApp.Modules.Identity.Infrastructure/EcSigningKey.cs new file mode 100644 index 0000000..7fb8b40 --- /dev/null +++ b/src/Modules/Identity/PriceNegotiationApp.Modules.Identity.Infrastructure/EcSigningKey.cs @@ -0,0 +1,73 @@ +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; +using System.Security.Cryptography; + +namespace PriceNegotiationApp.Modules.Identity.Infrastructure; + +/// +/// Holds the ES256 private key PEM and derives the public JWK once. +/// Only PublicJwk ever leaves this class: bearer validation and the JWKS +/// endpoint can verify tokens without holding signing material. +/// +internal sealed class EcSigningKey +{ + // Literal JOSE name: SecurityAlgorithms.EcdsaSha256* hold the XML-DSig URI, + // which JwtSecurityTokenHandler would copy verbatim into the token header. + public const string Algorithm = "ES256"; + + private const string Usage = + "Jwt:PrivateKey must be an EC P-256 private key in PKCS#8 PEM " + + "(generate: openssl ecparam -name prime256v1 -genkey -noout)."; + + private readonly string _privateKeyPem; + + internal JsonWebKey PublicJwk { get; } + + internal string Kid { get; } + + public EcSigningKey(IOptions options) + { + _privateKeyPem = Normalize(options.Value.PrivateKey); + using var ecdsa = Import(_privateKeyPem); + + // Pure-data JWK built from exported coordinates: nothing here references + // the disposed probe instance, and verifiers construct their own providers. + var publicKey = ecdsa.ExportParameters(includePrivateParameters: false); + var jwk = new JsonWebKey + { + Kty = JsonWebAlgorithmsKeyTypes.EllipticCurve, + Crv = "P-256", + X = Base64UrlEncoder.Encode(publicKey.Q.X), + Y = Base64UrlEncoder.Encode(publicKey.Q.Y), + }; + Kid = Base64UrlEncoder.Encode(jwk.ComputeJwkThumbprint()); + jwk.Kid = Kid; + PublicJwk = jwk; + } + + internal ECDsa CreatePrivateEcdsa() => Import(_privateKeyPem); + + private static ECDsa Import(string pem) + { + var ecdsa = ECDsa.Create(); + try + { + ecdsa.ImportFromPem(pem); + } + catch (CryptographicException ex) + { + ecdsa.Dispose(); + throw new InvalidOperationException(Usage, ex); + } + + if (ecdsa.KeySize != 256) + { + ecdsa.Dispose(); + throw new InvalidOperationException(Usage); + } + + return ecdsa; + } + + private static string Normalize(string raw) => raw.Replace("\\n", "\n").Trim(); +} diff --git a/src/Modules/Identity/PriceNegotiationApp.Modules.Identity.Infrastructure/IdentityModule.cs b/src/Modules/Identity/PriceNegotiationApp.Modules.Identity.Infrastructure/IdentityModule.cs new file mode 100644 index 0000000..089df10 --- /dev/null +++ b/src/Modules/Identity/PriceNegotiationApp.Modules.Identity.Infrastructure/IdentityModule.cs @@ -0,0 +1,56 @@ +using FluentValidation; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using PriceNegotiationApp.Modules.Identity.Contracts; +using PriceNegotiationApp.Modules.Identity.Application.Login; +using PriceNegotiationApp.Modules.Identity.Infrastructure.Login; +using PriceNegotiationApp.Modules.Identity.Infrastructure.Register; +using PriceNegotiationApp.Modules.Identity.Infrastructure.Persistence; +using PriceNegotiationApp.Modules.Identity.Infrastructure.Seeding; +using PriceNegotiationApp.SharedKernel; + +namespace PriceNegotiationApp.Modules.Identity.Infrastructure; + +public static class IdentityModule +{ + public static IServiceCollection AddIdentityModule( + this IServiceCollection services, IConfiguration configuration) + { + services.AddDbContext(options => options + .UseNpgsql(DbConnections.Resolve(configuration, "Identity"), + npgsql => npgsql.MigrationsHistoryTable("__EFMigrationsHistory_Identity")) + .UseSnakeCaseNamingConvention()); + + services.AddIdentityCore(options => + { + options.Lockout.AllowedForNewUsers = true; + options.Lockout.MaxFailedAccessAttempts = 5; + options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15); + }) + .AddRoles>() + .AddEntityFrameworkStores() + .AddDefaultTokenProviders(); + + services.AddOptions() + .Bind(configuration.GetSection(JwtOptions.SectionName)) + .ValidateOnStart(); + services.AddSingleton, JwtOptionsValidator>(); + services.AddSingleton(TimeProvider.System); + services.AddValidatorsFromAssemblyContaining(); + services.AddSingleton(); + services.AddSingleton(); + services.AddScoped(); + services.AddScoped(); + + services.AddOptions() + .Bind(configuration.GetSection(SeedingOptions.SectionName)) + .ValidateOnStart(); + services.AddSingleton, SeedingOptionsValidator>(); + services.AddHostedService(); + + return services; + } +} diff --git a/src/Modules/Identity/PriceNegotiationApp.Modules.Identity.Infrastructure/JwtManager.cs b/src/Modules/Identity/PriceNegotiationApp.Modules.Identity.Infrastructure/JwtManager.cs new file mode 100644 index 0000000..38c3d63 --- /dev/null +++ b/src/Modules/Identity/PriceNegotiationApp.Modules.Identity.Infrastructure/JwtManager.cs @@ -0,0 +1,48 @@ +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; + +namespace PriceNegotiationApp.Modules.Identity.Infrastructure; + +internal sealed class JwtManager(IOptions options, EcSigningKey signingKey, TimeProvider clock) +{ + public (string Token, DateTimeOffset ExpiresAtUtc) Generate(Guid userId, string email, IReadOnlyCollection roles) + { + var settings = options.Value; + var now = clock.GetUtcNow(); + var expiresAtUtc = now.AddMinutes(settings.ExpiryMinutes); + + var claims = new List + { + new(JwtRegisteredClaimNames.Sub, userId.ToString()), + new(JwtRegisteredClaimNames.Email, email), + new(JwtRegisteredClaimNames.Jti, Guid.CreateVersion7().ToString()), + }; + claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role))); + + // Each call owns a fresh ECDsa, so provider caching must stay off: the + // default cache would hand request #2 a provider wrapping request #1's + // already-disposed key instance (both share the same Kid). + using var ecdsa = signingKey.CreatePrivateEcdsa(); + var credentials = new SigningCredentials( + new ECDsaSecurityKey(ecdsa) { KeyId = signingKey.Kid }, + EcSigningKey.Algorithm) + { + CryptoProviderFactory = new CryptoProviderFactory { CacheSignatureProviders = false }, + }; + + var token = new JwtSecurityToken( + issuer: settings.Issuer, + audience: settings.Audience, + claims: claims, + notBefore: now.UtcDateTime, + expires: expiresAtUtc.UtcDateTime, + signingCredentials: credentials); + + return (new JwtSecurityTokenHandler().WriteToken(token), expiresAtUtc); + } +} + + + diff --git a/src/Modules/Identity/PriceNegotiationApp.Modules.Identity.Infrastructure/JwtOptions.cs b/src/Modules/Identity/PriceNegotiationApp.Modules.Identity.Infrastructure/JwtOptions.cs new file mode 100644 index 0000000..286a70f --- /dev/null +++ b/src/Modules/Identity/PriceNegotiationApp.Modules.Identity.Infrastructure/JwtOptions.cs @@ -0,0 +1,16 @@ +namespace PriceNegotiationApp.Modules.Identity.Infrastructure; + +internal sealed class JwtOptions +{ + public const string SectionName = "Jwt"; + + public required string Issuer { get; init; } + + public required string Audience { get; init; } + + /// EC P-256 private key, PKCS#8 PEM; newlines may be literal or \n-escaped. + public required string PrivateKey { get; init; } + + public int ExpiryMinutes { get; init; } = 60; +} + diff --git a/src/Modules/Identity/PriceNegotiationApp.Modules.Identity.Infrastructure/JwtOptionsValidator.cs b/src/Modules/Identity/PriceNegotiationApp.Modules.Identity.Infrastructure/JwtOptionsValidator.cs new file mode 100644 index 0000000..d2faf53 --- /dev/null +++ b/src/Modules/Identity/PriceNegotiationApp.Modules.Identity.Infrastructure/JwtOptionsValidator.cs @@ -0,0 +1,33 @@ +using Microsoft.Extensions.Options; + +namespace PriceNegotiationApp.Modules.Identity.Infrastructure; + +internal sealed class JwtOptionsValidator : IValidateOptions +{ + public ValidateOptionsResult Validate(string? name, JwtOptions options) + { + var failures = new List(); + if (string.IsNullOrWhiteSpace(options.PrivateKey)) + { + failures.Add("Jwt:PrivateKey is required (ES256 PKCS#8 PEM; malformed keys fail at startup with generation instructions)."); + } + + if (string.IsNullOrWhiteSpace(options.Issuer)) + { + failures.Add("Jwt:Issuer is required."); + } + + if (string.IsNullOrWhiteSpace(options.Audience)) + { + failures.Add("Jwt:Audience is required."); + } + + if (options.ExpiryMinutes < 1) + { + failures.Add("Jwt:ExpiryMinutes must be >= 1."); + } + + return failures.Count > 0 ? ValidateOptionsResult.Fail(failures) : ValidateOptionsResult.Success; + } +} + diff --git a/src/Modules/Identity/PriceNegotiationApp.Modules.Identity.Infrastructure/Login/LoginUserHandler.cs b/src/Modules/Identity/PriceNegotiationApp.Modules.Identity.Infrastructure/Login/LoginUserHandler.cs new file mode 100644 index 0000000..d955915 --- /dev/null +++ b/src/Modules/Identity/PriceNegotiationApp.Modules.Identity.Infrastructure/Login/LoginUserHandler.cs @@ -0,0 +1,38 @@ +using PriceNegotiationApp.Modules.Identity.Contracts; +using PriceNegotiationApp.Modules.Identity.Infrastructure; +using PriceNegotiationApp.Modules.Identity.Application.Login; +using Microsoft.AspNetCore.Identity; +using PriceNegotiationApp.Modules.Identity.Infrastructure.Persistence; +using PriceNegotiationApp.SharedKernel; + +namespace PriceNegotiationApp.Modules.Identity.Infrastructure.Login; + +internal sealed class LoginUserHandler(UserManager userManager, JwtManager jwt) +{ + public async Task HandleAsync(LoginRequest request) + { + var user = await userManager.FindByNameAsync(request.Email) + ?? throw Unauthorized(); + + // Lockout keeps enforcing internally but reads identically to any other failure. + if (await userManager.IsLockedOutAsync(user)) + { + throw Unauthorized(); + } + + if (!await userManager.CheckPasswordAsync(user, request.Password)) + { + await userManager.AccessFailedAsync(user); + throw Unauthorized(); + } + + await userManager.ResetAccessFailedCountAsync(user); + + var roles = (IReadOnlyList)await userManager.GetRolesAsync(user); + var (token, expiresAtUtc) = jwt.Generate(user.Id, request.Email, roles); + return new AuthResponse(token, expiresAtUtc, request.Email, roles); + } + + private static UnauthorizedException Unauthorized() => + new(IdentityErrorCodes.InvalidCredentials, "Invalid credentials."); +} diff --git a/src/Modules/Identity/PriceNegotiationApp.Modules.Identity.Infrastructure/Persistence/ApplicationUser.cs b/src/Modules/Identity/PriceNegotiationApp.Modules.Identity.Infrastructure/Persistence/ApplicationUser.cs new file mode 100644 index 0000000..085be94 --- /dev/null +++ b/src/Modules/Identity/PriceNegotiationApp.Modules.Identity.Infrastructure/Persistence/ApplicationUser.cs @@ -0,0 +1,6 @@ +using Microsoft.AspNetCore.Identity; + +namespace PriceNegotiationApp.Modules.Identity.Infrastructure.Persistence; + +internal sealed class ApplicationUser : IdentityUser; + diff --git a/src/Modules/Identity/PriceNegotiationApp.Modules.Identity.Infrastructure/Persistence/DesignTimeDbContextFactory.cs b/src/Modules/Identity/PriceNegotiationApp.Modules.Identity.Infrastructure/Persistence/DesignTimeDbContextFactory.cs new file mode 100644 index 0000000..84051b3 --- /dev/null +++ b/src/Modules/Identity/PriceNegotiationApp.Modules.Identity.Infrastructure/Persistence/DesignTimeDbContextFactory.cs @@ -0,0 +1,17 @@ +using Microsoft.EntityFrameworkCore; +using PriceNegotiationApp.SharedKernel; + +using System.Diagnostics.CodeAnalysis; + +namespace PriceNegotiationApp.Modules.Identity.Infrastructure.Persistence; + +[SuppressMessage("Meziantou.Analyzer", "MA0182", Justification = "Instantiated by EF Core design-time tooling via reflection.")] +internal sealed class DesignTimeDbContextFactory : DesignTimeDbContextFactoryBase +{ + protected override void Configure(DbContextOptionsBuilder builder) => + builder.UseNpgsql(LocalConnectionString, + npgsql => npgsql.MigrationsHistoryTable("__EFMigrationsHistory_Identity")) + .UseSnakeCaseNamingConvention(); + + protected override IdentityModuleDbContext Create(DbContextOptions options) => new(options); +} diff --git a/src/Modules/Identity/PriceNegotiationApp.Modules.Identity.Infrastructure/Persistence/IdentityModuleDbContext.cs b/src/Modules/Identity/PriceNegotiationApp.Modules.Identity.Infrastructure/Persistence/IdentityModuleDbContext.cs new file mode 100644 index 0000000..a6afb4a --- /dev/null +++ b/src/Modules/Identity/PriceNegotiationApp.Modules.Identity.Infrastructure/Persistence/IdentityModuleDbContext.cs @@ -0,0 +1,25 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using PriceNegotiationApp.Modules.Identity.Infrastructure.Persistence; + +namespace PriceNegotiationApp.Modules.Identity.Infrastructure.Persistence; + +internal sealed class IdentityModuleDbContext(DbContextOptions options) + : IdentityDbContext, Guid>(options) +{ + protected override void OnModelCreating(ModelBuilder builder) + { + base.OnModelCreating(builder); + builder.HasDefaultSchema("identity"); + // Pin snake_case names so Identity stores never depend on naming conventions. + builder.Entity().ToTable("users"); + builder.Entity>().ToTable("roles"); + builder.Entity>().ToTable("user_roles"); + builder.Entity>().ToTable("user_claims"); + builder.Entity>().ToTable("role_claims"); + builder.Entity>().ToTable("user_logins"); + builder.Entity>().ToTable("user_tokens"); + } +} + diff --git a/src/Modules/Identity/PriceNegotiationApp.Modules.Identity.Infrastructure/Persistence/Migrations/20260824053344_Initial.Designer.cs b/src/Modules/Identity/PriceNegotiationApp.Modules.Identity.Infrastructure/Persistence/Migrations/20260824053344_Initial.Designer.cs new file mode 100644 index 0000000..faad94d --- /dev/null +++ b/src/Modules/Identity/PriceNegotiationApp.Modules.Identity.Infrastructure/Persistence/Migrations/20260824053344_Initial.Designer.cs @@ -0,0 +1,331 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using PriceNegotiationApp.Modules.Identity.Infrastructure.Persistence; + +#nullable disable + +namespace PriceNegotiationApp.Modules.Identity.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(IdentityModuleDbContext))] + [Migration("20260824053344_Initial")] + partial class Initial + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("identity") + .HasAnnotation("ProductVersion", "10.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text") + .HasColumnName("concurrency_stamp"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("normalized_name"); + + b.HasKey("Id") + .HasName("pk_roles"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("roles", "identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text") + .HasColumnName("claim_type"); + + b.Property("ClaimValue") + .HasColumnType("text") + .HasColumnName("claim_value"); + + b.Property("RoleId") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.HasKey("Id") + .HasName("pk_role_claims"); + + b.HasIndex("RoleId") + .HasDatabaseName("ix_role_claims_role_id"); + + b.ToTable("role_claims", "identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text") + .HasColumnName("claim_type"); + + b.Property("ClaimValue") + .HasColumnType("text") + .HasColumnName("claim_value"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_user_claims"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_user_claims_user_id"); + + b.ToTable("user_claims", "identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text") + .HasColumnName("login_provider"); + + b.Property("ProviderKey") + .HasColumnType("text") + .HasColumnName("provider_key"); + + b.Property("ProviderDisplayName") + .HasColumnType("text") + .HasColumnName("provider_display_name"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("LoginProvider", "ProviderKey") + .HasName("pk_user_logins"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_user_logins_user_id"); + + b.ToTable("user_logins", "identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("RoleId") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.HasKey("UserId", "RoleId") + .HasName("pk_user_roles"); + + b.HasIndex("RoleId") + .HasDatabaseName("ix_user_roles_role_id"); + + b.ToTable("user_roles", "identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("LoginProvider") + .HasColumnType("text") + .HasColumnName("login_provider"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Value") + .HasColumnType("text") + .HasColumnName("value"); + + b.HasKey("UserId", "LoginProvider", "Name") + .HasName("pk_user_tokens"); + + b.ToTable("user_tokens", "identity"); + }); + + modelBuilder.Entity("PriceNegotiationApp.Modules.Identity.Persistence.ApplicationUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AccessFailedCount") + .HasColumnType("integer") + .HasColumnName("access_failed_count"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text") + .HasColumnName("concurrency_stamp"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("email"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean") + .HasColumnName("email_confirmed"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean") + .HasColumnName("lockout_enabled"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone") + .HasColumnName("lockout_end"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("normalized_email"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("normalized_user_name"); + + b.Property("PasswordHash") + .HasColumnType("text") + .HasColumnName("password_hash"); + + b.Property("PhoneNumber") + .HasColumnType("text") + .HasColumnName("phone_number"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean") + .HasColumnName("phone_number_confirmed"); + + b.Property("SecurityStamp") + .HasColumnType("text") + .HasColumnName("security_stamp"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean") + .HasColumnName("two_factor_enabled"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("user_name"); + + b.HasKey("Id") + .HasName("pk_users"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("users", "identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_role_claims_roles_role_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("PriceNegotiationApp.Modules.Identity.Persistence.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_claims_users_user_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("PriceNegotiationApp.Modules.Identity.Persistence.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_logins_users_user_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_roles_roles_role_id"); + + b.HasOne("PriceNegotiationApp.Modules.Identity.Persistence.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_roles_users_user_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("PriceNegotiationApp.Modules.Identity.Persistence.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_tokens_users_user_id"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Modules/Identity/PriceNegotiationApp.Modules.Identity.Infrastructure/Persistence/Migrations/20260824053344_Initial.cs b/src/Modules/Identity/PriceNegotiationApp.Modules.Identity.Infrastructure/Persistence/Migrations/20260824053344_Initial.cs new file mode 100644 index 0000000..756b7a7 --- /dev/null +++ b/src/Modules/Identity/PriceNegotiationApp.Modules.Identity.Infrastructure/Persistence/Migrations/20260824053344_Initial.cs @@ -0,0 +1,253 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using System; + +#nullable disable + +namespace PriceNegotiationApp.Modules.Identity.Infrastructure.Persistence.Migrations +{ + /// + public partial class Initial : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "identity"); + + migrationBuilder.CreateTable( + name: "roles", + schema: "identity", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + name = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + normalized_name = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + concurrency_stamp = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_roles", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "users", + schema: "identity", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + user_name = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + normalized_user_name = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + email = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + normalized_email = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + email_confirmed = table.Column(type: "boolean", nullable: false), + password_hash = table.Column(type: "text", nullable: true), + security_stamp = table.Column(type: "text", nullable: true), + concurrency_stamp = table.Column(type: "text", nullable: true), + phone_number = table.Column(type: "text", nullable: true), + phone_number_confirmed = table.Column(type: "boolean", nullable: false), + two_factor_enabled = table.Column(type: "boolean", nullable: false), + lockout_end = table.Column(type: "timestamp with time zone", nullable: true), + lockout_enabled = table.Column(type: "boolean", nullable: false), + access_failed_count = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_users", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "role_claims", + schema: "identity", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + role_id = table.Column(type: "uuid", nullable: false), + claim_type = table.Column(type: "text", nullable: true), + claim_value = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_role_claims", x => x.id); + table.ForeignKey( + name: "fk_role_claims_roles_role_id", + column: x => x.role_id, + principalSchema: "identity", + principalTable: "roles", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "user_claims", + schema: "identity", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + user_id = table.Column(type: "uuid", nullable: false), + claim_type = table.Column(type: "text", nullable: true), + claim_value = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_user_claims", x => x.id); + table.ForeignKey( + name: "fk_user_claims_users_user_id", + column: x => x.user_id, + principalSchema: "identity", + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "user_logins", + schema: "identity", + columns: table => new + { + login_provider = table.Column(type: "text", nullable: false), + provider_key = table.Column(type: "text", nullable: false), + provider_display_name = table.Column(type: "text", nullable: true), + user_id = table.Column(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_user_logins", x => new { x.login_provider, x.provider_key }); + table.ForeignKey( + name: "fk_user_logins_users_user_id", + column: x => x.user_id, + principalSchema: "identity", + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "user_roles", + schema: "identity", + columns: table => new + { + user_id = table.Column(type: "uuid", nullable: false), + role_id = table.Column(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_user_roles", x => new { x.user_id, x.role_id }); + table.ForeignKey( + name: "fk_user_roles_roles_role_id", + column: x => x.role_id, + principalSchema: "identity", + principalTable: "roles", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "fk_user_roles_users_user_id", + column: x => x.user_id, + principalSchema: "identity", + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "user_tokens", + schema: "identity", + columns: table => new + { + user_id = table.Column(type: "uuid", nullable: false), + login_provider = table.Column(type: "text", nullable: false), + name = table.Column(type: "text", nullable: false), + value = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_user_tokens", x => new { x.user_id, x.login_provider, x.name }); + table.ForeignKey( + name: "fk_user_tokens_users_user_id", + column: x => x.user_id, + principalSchema: "identity", + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "ix_role_claims_role_id", + schema: "identity", + table: "role_claims", + column: "role_id"); + + migrationBuilder.CreateIndex( + name: "RoleNameIndex", + schema: "identity", + table: "roles", + column: "normalized_name", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_user_claims_user_id", + schema: "identity", + table: "user_claims", + column: "user_id"); + + migrationBuilder.CreateIndex( + name: "ix_user_logins_user_id", + schema: "identity", + table: "user_logins", + column: "user_id"); + + migrationBuilder.CreateIndex( + name: "ix_user_roles_role_id", + schema: "identity", + table: "user_roles", + column: "role_id"); + + migrationBuilder.CreateIndex( + name: "EmailIndex", + schema: "identity", + table: "users", + column: "normalized_email"); + + migrationBuilder.CreateIndex( + name: "UserNameIndex", + schema: "identity", + table: "users", + column: "normalized_user_name", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "role_claims", + schema: "identity"); + + migrationBuilder.DropTable( + name: "user_claims", + schema: "identity"); + + migrationBuilder.DropTable( + name: "user_logins", + schema: "identity"); + + migrationBuilder.DropTable( + name: "user_roles", + schema: "identity"); + + migrationBuilder.DropTable( + name: "user_tokens", + schema: "identity"); + + migrationBuilder.DropTable( + name: "roles", + schema: "identity"); + + migrationBuilder.DropTable( + name: "users", + schema: "identity"); + } + } +} diff --git a/src/Modules/Identity/PriceNegotiationApp.Modules.Identity.Infrastructure/Persistence/Migrations/IdentityModuleDbContextModelSnapshot.cs b/src/Modules/Identity/PriceNegotiationApp.Modules.Identity.Infrastructure/Persistence/Migrations/IdentityModuleDbContextModelSnapshot.cs new file mode 100644 index 0000000..6b85a3a --- /dev/null +++ b/src/Modules/Identity/PriceNegotiationApp.Modules.Identity.Infrastructure/Persistence/Migrations/IdentityModuleDbContextModelSnapshot.cs @@ -0,0 +1,328 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using PriceNegotiationApp.Modules.Identity.Infrastructure.Persistence; + +#nullable disable + +namespace PriceNegotiationApp.Modules.Identity.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(IdentityModuleDbContext))] + partial class IdentityModuleDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("identity") + .HasAnnotation("ProductVersion", "10.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text") + .HasColumnName("concurrency_stamp"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("normalized_name"); + + b.HasKey("Id") + .HasName("pk_roles"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("roles", "identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text") + .HasColumnName("claim_type"); + + b.Property("ClaimValue") + .HasColumnType("text") + .HasColumnName("claim_value"); + + b.Property("RoleId") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.HasKey("Id") + .HasName("pk_role_claims"); + + b.HasIndex("RoleId") + .HasDatabaseName("ix_role_claims_role_id"); + + b.ToTable("role_claims", "identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text") + .HasColumnName("claim_type"); + + b.Property("ClaimValue") + .HasColumnType("text") + .HasColumnName("claim_value"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_user_claims"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_user_claims_user_id"); + + b.ToTable("user_claims", "identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text") + .HasColumnName("login_provider"); + + b.Property("ProviderKey") + .HasColumnType("text") + .HasColumnName("provider_key"); + + b.Property("ProviderDisplayName") + .HasColumnType("text") + .HasColumnName("provider_display_name"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("LoginProvider", "ProviderKey") + .HasName("pk_user_logins"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_user_logins_user_id"); + + b.ToTable("user_logins", "identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("RoleId") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.HasKey("UserId", "RoleId") + .HasName("pk_user_roles"); + + b.HasIndex("RoleId") + .HasDatabaseName("ix_user_roles_role_id"); + + b.ToTable("user_roles", "identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("LoginProvider") + .HasColumnType("text") + .HasColumnName("login_provider"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Value") + .HasColumnType("text") + .HasColumnName("value"); + + b.HasKey("UserId", "LoginProvider", "Name") + .HasName("pk_user_tokens"); + + b.ToTable("user_tokens", "identity"); + }); + + modelBuilder.Entity("PriceNegotiationApp.Modules.Identity.Persistence.ApplicationUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AccessFailedCount") + .HasColumnType("integer") + .HasColumnName("access_failed_count"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text") + .HasColumnName("concurrency_stamp"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("email"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean") + .HasColumnName("email_confirmed"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean") + .HasColumnName("lockout_enabled"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone") + .HasColumnName("lockout_end"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("normalized_email"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("normalized_user_name"); + + b.Property("PasswordHash") + .HasColumnType("text") + .HasColumnName("password_hash"); + + b.Property("PhoneNumber") + .HasColumnType("text") + .HasColumnName("phone_number"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean") + .HasColumnName("phone_number_confirmed"); + + b.Property("SecurityStamp") + .HasColumnType("text") + .HasColumnName("security_stamp"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean") + .HasColumnName("two_factor_enabled"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("user_name"); + + b.HasKey("Id") + .HasName("pk_users"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("users", "identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_role_claims_roles_role_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("PriceNegotiationApp.Modules.Identity.Persistence.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_claims_users_user_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("PriceNegotiationApp.Modules.Identity.Persistence.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_logins_users_user_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_roles_roles_role_id"); + + b.HasOne("PriceNegotiationApp.Modules.Identity.Persistence.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_roles_users_user_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("PriceNegotiationApp.Modules.Identity.Persistence.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_tokens_users_user_id"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Modules/Identity/PriceNegotiationApp.Modules.Identity.Infrastructure/PriceNegotiationApp.Modules.Identity.Infrastructure.csproj b/src/Modules/Identity/PriceNegotiationApp.Modules.Identity.Infrastructure/PriceNegotiationApp.Modules.Identity.Infrastructure.csproj new file mode 100644 index 0000000..9bf6d3e --- /dev/null +++ b/src/Modules/Identity/PriceNegotiationApp.Modules.Identity.Infrastructure/PriceNegotiationApp.Modules.Identity.Infrastructure.csproj @@ -0,0 +1,27 @@ + + + $(NoWarn);MA0182 + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + diff --git a/src/Modules/Identity/PriceNegotiationApp.Modules.Identity.Infrastructure/Register/RegisterUserHandler.cs b/src/Modules/Identity/PriceNegotiationApp.Modules.Identity.Infrastructure/Register/RegisterUserHandler.cs new file mode 100644 index 0000000..4be7630 --- /dev/null +++ b/src/Modules/Identity/PriceNegotiationApp.Modules.Identity.Infrastructure/Register/RegisterUserHandler.cs @@ -0,0 +1,43 @@ +using PriceNegotiationApp.Modules.Identity.Contracts; +using PriceNegotiationApp.Modules.Identity.Application.Register; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using PriceNegotiationApp.Modules.Identity.Infrastructure.Persistence; +using PriceNegotiationApp.SharedKernel; + +namespace PriceNegotiationApp.Modules.Identity.Infrastructure.Register; + +internal sealed class RegisterUserHandler(UserManager userManager) +{ + public async Task HandleAsync(RegisterRequest request) + { + var user = new ApplicationUser { UserName = request.Email, Email = request.Email }; + IdentityResult result; + try + { + result = await userManager.CreateAsync(user, request.Password); + } + catch (DbUpdateException ex) when (DbWriteGuard.IsUniqueViolation(ex, out _)) + { + // Two concurrent registrations for the same email: Identity's pre-check + // lost the race, the unique index caught it — same conflict as usual. + throw new ConflictException(IdentityErrorCodes.EmailAlreadyRegistered, + "Email already registered."); + } + + if (!result.Succeeded) + { + if (result.Errors.Any(e => e.Code is "DuplicateEmail" or "DuplicateUserName")) + { + throw new ConflictException(IdentityErrorCodes.EmailAlreadyRegistered, + "Email already registered."); + } + + throw new InvalidRequestException(IdentityErrorCodes.RegistrationInvalid, + string.Join("; ", result.Errors.Select(e => e.Description))); + } + + await userManager.AddToRoleAsync(user, UserRoles.Customer); + return new RegistrationResponse(user.Id); + } +} diff --git a/src/Modules/Identity/PriceNegotiationApp.Modules.Identity.Infrastructure/Seeding/IdentitySeedingHostedService.cs b/src/Modules/Identity/PriceNegotiationApp.Modules.Identity.Infrastructure/Seeding/IdentitySeedingHostedService.cs new file mode 100644 index 0000000..2829f7f --- /dev/null +++ b/src/Modules/Identity/PriceNegotiationApp.Modules.Identity.Infrastructure/Seeding/IdentitySeedingHostedService.cs @@ -0,0 +1,53 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using PriceNegotiationApp.Modules.Identity.Infrastructure.Persistence; +using PriceNegotiationApp.SharedKernel; + +namespace PriceNegotiationApp.Modules.Identity.Infrastructure.Seeding; + +internal sealed class IdentitySeedingHostedService( + IServiceScopeFactory scopeFactory, + IOptions options, + ILogger logger) : ModuleSeedingHostedServiceBase(scopeFactory) +{ + protected override async Task SeedAsync(IServiceProvider services, CancellationToken cancellationToken) + { + var roleManager = services.GetRequiredService>>(); + foreach (var role in new[] { UserRoles.Admin, UserRoles.Staff, UserRoles.Customer }) + { + if (!await roleManager.RoleExistsAsync(role)) + { + await roleManager.CreateAsync(new IdentityRole(role)); + } + } + + var userManager = services.GetRequiredService>(); + await EnsureUserAsync(userManager, options.Value.AdminEmail, options.Value.AdminPassword, UserRoles.Admin); + await EnsureUserAsync(userManager, options.Value.StaffEmail, options.Value.StaffPassword, UserRoles.Staff); + logger.LogInformation("Identity seed data ensured."); + } + + private async Task EnsureUserAsync( + UserManager userManager, string email, string password, string role) + { + if (string.IsNullOrWhiteSpace(password) + || await userManager.FindByEmailAsync(email) is not null) + { + return; + } + + var user = new ApplicationUser { UserName = email, Email = email }; + var result = await userManager.CreateAsync(user, password); + if (result.Succeeded) + { + await userManager.AddToRoleAsync(user, role); + } + else + { + logger.LogError("Seeded user {Email} could not be created: {Errors}", + email, string.Join("; ", result.Errors.Select(e => $"{e.Code} {e.Description}"))); + } + } +} diff --git a/src/Modules/Identity/PriceNegotiationApp.Modules.Identity.Infrastructure/Seeding/SeedingOptions.cs b/src/Modules/Identity/PriceNegotiationApp.Modules.Identity.Infrastructure/Seeding/SeedingOptions.cs new file mode 100644 index 0000000..4a36a04 --- /dev/null +++ b/src/Modules/Identity/PriceNegotiationApp.Modules.Identity.Infrastructure/Seeding/SeedingOptions.cs @@ -0,0 +1,15 @@ +namespace PriceNegotiationApp.Modules.Identity.Infrastructure.Seeding; + +internal sealed class SeedingOptions +{ + public const string SectionName = "Seeding"; + + public string AdminEmail { get; init; } = "admin@app.com"; + + public string AdminPassword { get; init; } = string.Empty; + + public string StaffEmail { get; init; } = "staff@app.com"; + + public string StaffPassword { get; init; } = string.Empty; +} + diff --git a/src/Modules/Identity/PriceNegotiationApp.Modules.Identity.Infrastructure/Seeding/SeedingOptionsValidator.cs b/src/Modules/Identity/PriceNegotiationApp.Modules.Identity.Infrastructure/Seeding/SeedingOptionsValidator.cs new file mode 100644 index 0000000..2e384c9 --- /dev/null +++ b/src/Modules/Identity/PriceNegotiationApp.Modules.Identity.Infrastructure/Seeding/SeedingOptionsValidator.cs @@ -0,0 +1,40 @@ +using Microsoft.Extensions.Options; + +namespace PriceNegotiationApp.Modules.Identity.Infrastructure.Seeding; + +internal sealed class SeedingOptionsValidator : IValidateOptions +{ + public ValidateOptionsResult Validate(string? name, SeedingOptions options) + { + var failures = new List(); + + if (string.IsNullOrWhiteSpace(options.AdminEmail) || !options.AdminEmail.Contains('@')) + { + failures.Add("Seeding:AdminEmail must be a non-empty email address."); + } + + if (string.IsNullOrWhiteSpace(options.StaffEmail) || !options.StaffEmail.Contains('@')) + { + failures.Add("Seeding:StaffEmail must be a non-empty email address."); + } + + if (string.IsNullOrWhiteSpace(options.AdminPassword) || !IsStrong(options.AdminPassword)) + { + failures.Add("Seeding:AdminPassword must be at least 12 characters and mix upper-case, lower-case, digit and symbol characters."); + } + + if (string.IsNullOrWhiteSpace(options.StaffPassword) || !IsStrong(options.StaffPassword)) + { + failures.Add("Seeding:StaffPassword must be at least 12 characters and mix upper-case, lower-case, digit and symbol characters."); + } + + return failures.Count > 0 ? ValidateOptionsResult.Fail(failures) : ValidateOptionsResult.Success; + } + + private static bool IsStrong(string password) => + password.Length >= 12 + && password.Any(char.IsUpper) + && password.Any(char.IsLower) + && password.Any(char.IsDigit) + && password.Any(c => !char.IsLetterOrDigit(c)); +} diff --git a/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Application/CounterPropose/CounterProposalRequest.cs b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Application/CounterPropose/CounterProposalRequest.cs new file mode 100644 index 0000000..e4b30c7 --- /dev/null +++ b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Application/CounterPropose/CounterProposalRequest.cs @@ -0,0 +1,6 @@ +namespace PriceNegotiationApp.Modules.Negotiations.Application.CounterPropose; + +internal sealed class CounterProposalRequest +{ + public decimal ProposedPrice { get; init; } +} diff --git a/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Application/CounterPropose/CounterProposalRequestValidator.cs b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Application/CounterPropose/CounterProposalRequestValidator.cs new file mode 100644 index 0000000..fc214cd --- /dev/null +++ b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Application/CounterPropose/CounterProposalRequestValidator.cs @@ -0,0 +1,15 @@ +using FluentValidation; + +namespace PriceNegotiationApp.Modules.Negotiations.Application.CounterPropose; + +// MA0182: used via DI assembly scanning (AddValidatorsFromAssemblyContaining), invisible to static analysis. +#pragma warning disable MA0182 +internal sealed class CounterProposalRequestValidator : AbstractValidator +#pragma warning restore MA0182 +{ + public CounterProposalRequestValidator() + { + RuleFor(x => x.ProposedPrice) + .GreaterThan(0m); + } +} diff --git a/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Application/CounterPropose/CounterProposalResponse.cs b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Application/CounterPropose/CounterProposalResponse.cs new file mode 100644 index 0000000..ba0e8f8 --- /dev/null +++ b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Application/CounterPropose/CounterProposalResponse.cs @@ -0,0 +1,5 @@ +using PriceNegotiationApp.Modules.Negotiations.Application; + +namespace PriceNegotiationApp.Modules.Negotiations.Application.CounterPropose; + +internal sealed record CounterProposalResponse(string Outcome, NegotiationResponse Negotiation); diff --git a/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Application/Create/CreateNegotiationRequest.cs b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Application/Create/CreateNegotiationRequest.cs new file mode 100644 index 0000000..c03ec69 --- /dev/null +++ b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Application/Create/CreateNegotiationRequest.cs @@ -0,0 +1,8 @@ +namespace PriceNegotiationApp.Modules.Negotiations.Application.Create; + +internal sealed class CreateNegotiationRequest +{ + public Guid ProductId { get; init; } + + public decimal ProposedPrice { get; init; } +} diff --git a/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Application/Create/CreateNegotiationRequestValidator.cs b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Application/Create/CreateNegotiationRequestValidator.cs new file mode 100644 index 0000000..d915799 --- /dev/null +++ b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Application/Create/CreateNegotiationRequestValidator.cs @@ -0,0 +1,18 @@ +using FluentValidation; + +namespace PriceNegotiationApp.Modules.Negotiations.Application.Create; + +// MA0182: used via DI assembly scanning (AddValidatorsFromAssemblyContaining), invisible to static analysis. +#pragma warning disable MA0182 +internal sealed class CreateNegotiationRequestValidator : AbstractValidator +#pragma warning restore MA0182 +{ + public CreateNegotiationRequestValidator() + { + RuleFor(x => x.ProductId) + .NotEmpty(); + + RuleFor(x => x.ProposedPrice) + .GreaterThan(0m); + } +} diff --git a/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Application/NegotiationModels.cs b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Application/NegotiationModels.cs new file mode 100644 index 0000000..82ad2b5 --- /dev/null +++ b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Application/NegotiationModels.cs @@ -0,0 +1,24 @@ +using PriceNegotiationApp.Modules.Negotiations.Domain; + +namespace PriceNegotiationApp.Modules.Negotiations.Application; + +internal sealed record NegotiationResponse( + Guid Id, + Guid ProductId, + decimal BasePrice, + decimal CurrentOffer, + string Status, + int ProposalsUsed, + int ProposalsRemaining, + DateTimeOffset CreatedAtUtc, + DateTimeOffset LastProposalAtUtc, + DateTimeOffset? DecidedAtUtc); + +internal static class NegotiationResponses +{ + internal static NegotiationResponse ToResponse(Negotiation n) => + new(n.Id.Value, n.ProductId, n.BasePrice.Value, n.CurrentOffer.Value, n.Status.ToString(), + n.ProposalsUsed, n.RemainingProposals(), n.CreatedAtUtc, n.LastProposalAtUtc, n.DecidedAtUtc); +} + +internal sealed record StaffActionResponse(string Outcome, NegotiationResponse Negotiation); diff --git a/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Application/PriceNegotiationApp.Modules.Negotiations.Application.csproj b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Application/PriceNegotiationApp.Modules.Negotiations.Application.csproj new file mode 100644 index 0000000..1f9d635 --- /dev/null +++ b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Application/PriceNegotiationApp.Modules.Negotiations.Application.csproj @@ -0,0 +1,19 @@ + + + $(NoWarn);MA0182 + + + + + + + + + + + + + + + + diff --git a/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Contracts/NegotiationErrorCodes.cs b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Contracts/NegotiationErrorCodes.cs new file mode 100644 index 0000000..3662792 --- /dev/null +++ b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Contracts/NegotiationErrorCodes.cs @@ -0,0 +1,13 @@ +namespace PriceNegotiationApp.Modules.Negotiations.Contracts; + +/// Machine-readable error codes owned by this feature (frozen contract). +public static class NegotiationErrorCodes +{ + public const string NegotiationClosed = "negotiation_closed"; + + public const string ProposalExceedsLimit = "proposal_exceeds_limit"; + + public const string NegotiationAlreadyOpen = "negotiation_already_open"; + + public const string NoProposalsRemaining = "no_proposals_remaining"; +} diff --git a/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Contracts/PriceNegotiationApp.Modules.Negotiations.Contracts.csproj b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Contracts/PriceNegotiationApp.Modules.Negotiations.Contracts.csproj new file mode 100644 index 0000000..00fd72d --- /dev/null +++ b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Contracts/PriceNegotiationApp.Modules.Negotiations.Contracts.csproj @@ -0,0 +1,8 @@ + + + $(NoWarn);MA0182 + + + + + diff --git a/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Domain/ClosedNegotiationException.cs b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Domain/ClosedNegotiationException.cs new file mode 100644 index 0000000..1279c56 --- /dev/null +++ b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Domain/ClosedNegotiationException.cs @@ -0,0 +1,7 @@ +using PriceNegotiationApp.SharedKernel; +namespace PriceNegotiationApp.Modules.Negotiations.Domain; + +/// Thrown when an operation targets a negotiation that has already reached a terminal state. +internal sealed class ClosedNegotiationException() + : DomainException("Negotiation is already closed."); + diff --git a/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Domain/Customer.cs b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Domain/Customer.cs new file mode 100644 index 0000000..403a52e --- /dev/null +++ b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Domain/Customer.cs @@ -0,0 +1,27 @@ +namespace PriceNegotiationApp.Modules.Negotiations.Domain; + +/// Reference row binding an Identity user to this context. Intentionally +/// anemic — see CustomerConfiguration for rationale. Do not enrich without cause. +internal sealed class Customer +{ + public CustomerId Id { get; private set; } + + public Guid IdentityUserId { get; private set; } + + private Customer() + { + } + + private Customer(CustomerId id, Guid identityUserId) + { + Id = id; + IdentityUserId = identityUserId; + } + + public static Customer Create(Guid identityUserId) => + new(CustomerId.From(Guid.CreateVersion7()), identityUserId); +} + + + + diff --git a/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Domain/CustomerId.cs b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Domain/CustomerId.cs new file mode 100644 index 0000000..8e23d22 --- /dev/null +++ b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Domain/CustomerId.cs @@ -0,0 +1,7 @@ +using Vogen; + +namespace PriceNegotiationApp.Modules.Negotiations.Domain; + +[ValueObject(Conversions.None)] +internal readonly partial record struct CustomerId; + diff --git a/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Domain/DefaultNegotiationPolicy.cs b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Domain/DefaultNegotiationPolicy.cs new file mode 100644 index 0000000..f96bfb9 --- /dev/null +++ b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Domain/DefaultNegotiationPolicy.cs @@ -0,0 +1,10 @@ +namespace PriceNegotiationApp.Modules.Negotiations.Domain; + +internal sealed class DefaultNegotiationPolicy : INegotiationPolicy +{ + public int MaxProposalsPerNegotiation => 3; + + public decimal ProposalMultiplierLimit => 2.0m; +} + + diff --git a/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Domain/INegotiationPolicy.cs b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Domain/INegotiationPolicy.cs new file mode 100644 index 0000000..12bc9ae --- /dev/null +++ b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Domain/INegotiationPolicy.cs @@ -0,0 +1,10 @@ +namespace PriceNegotiationApp.Modules.Negotiations.Domain; + +internal interface INegotiationPolicy +{ + int MaxProposalsPerNegotiation { get; } + + decimal ProposalMultiplierLimit { get; } +} + + diff --git a/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Domain/Negotiation.cs b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Domain/Negotiation.cs new file mode 100644 index 0000000..37ac4bf --- /dev/null +++ b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Domain/Negotiation.cs @@ -0,0 +1,136 @@ +namespace PriceNegotiationApp.Modules.Negotiations.Domain; + +internal sealed class Negotiation +{ + /// + /// Cross-aggregate invariant "at most one Open negotiation per (product, customer)" + /// cannot live here: it spans aggregates. Enforcement stack is intentional — + /// partial unique index uq_negotiations_open_product_customer (authoritative), + /// endpoint pre-check (friendly fast-path 409). Do NOT move it into this class. + /// + /// Base price snapshot taken at creation; protects ongoing negotiations from later product price changes. + public Price BasePrice { get; private set; } + + public Price CurrentOffer { get; private set; } + + public NegotiationId Id { get; private set; } + + public Guid ProductId { get; private set; } + + public CustomerId CustomerId { get; private set; } + + public NegotiationStatus Status { get; private set; } + + /// Total proposals recorded, including the initial one. + public int ProposalsUsed { get; private set; } + + /// Proposal budget snapshotted from the active policy at creation time. + public int MaxProposals { get; private set; } + + /// Offer multiplier limit snapshotted from the active policy at creation time. + public decimal OfferMultiplierLimit { get; private set; } + + public DateTimeOffset CreatedAtUtc { get; private set; } + + public DateTimeOffset LastProposalAtUtc { get; private set; } + + /// Most recent staff reject-current-offer action; does not change status. + public DateTimeOffset? LastStaffActionAtUtc { get; private set; } + + public DateTimeOffset? DecidedAtUtc { get; private set; } + + public uint Version { get; private set; } + + private Negotiation() + { + } + + private Negotiation( + NegotiationId id, Guid productId, CustomerId customerId, Price basePrice, Price initialOffer, + INegotiationPolicy policy, DateTimeOffset createdAtUtc) + { + Id = id; + ProductId = productId; + CustomerId = customerId; + BasePrice = basePrice; + CurrentOffer = initialOffer; + MaxProposals = policy.MaxProposalsPerNegotiation; + OfferMultiplierLimit = policy.ProposalMultiplierLimit; + Status = NegotiationStatus.Open; + ProposalsUsed = 1; + CreatedAtUtc = createdAtUtc; + LastProposalAtUtc = createdAtUtc; + } + + public static Negotiation Start( + CustomerId customerId, Guid productId, decimal basePriceSnapshot, decimal initialOffer, + DateTimeOffset now, INegotiationPolicy policy) + { + var basePrice = Price.From(basePriceSnapshot); + var offer = Price.From(initialOffer); + var limit = decimal.Round(basePrice.Value * policy.ProposalMultiplierLimit, 2); + if (offer.Value > limit) + { + throw new ProposalExceedsLimitException(limit); + } + + return new Negotiation(NegotiationId.From(Guid.CreateVersion7()), productId, customerId, + basePrice, offer, policy, now); + } + + public NegotiationOutcome CounterPropose(decimal offer, DateTimeOffset now) + { + EnsureOpen(); + var candidate = Price.From(offer); + if (ProposalsUsed >= MaxProposals) + { + return NegotiationOutcome.NoProposalsRemaining; + } + + var limit = decimal.Round(BasePrice.Value * OfferMultiplierLimit, 2); + if (candidate.Value > limit) + { + Status = NegotiationStatus.Rejected; + DecidedAtUtc = now; + return NegotiationOutcome.AutoRejected; + } + + CurrentOffer = candidate; + ProposalsUsed++; + LastProposalAtUtc = now; + return NegotiationOutcome.CounterProposed; + } + + public void Accept(DateTimeOffset now) => Decide(NegotiationStatus.Accepted, now); + + /// + /// Staff rejects the current offer. The negotiation deliberately stays open so the + /// customer may spend a remaining proposal; the proposal budget is untouched. + /// It terminates only via Accept, auto-rejection, or withdrawal. + /// + public void RejectCurrentOffer(DateTimeOffset now) + { + EnsureOpen(); + LastStaffActionAtUtc = now; + } + + /// Owner abandons the negotiation; state becomes terminal, history is preserved. + public void Withdraw(DateTimeOffset now) => Decide(NegotiationStatus.Withdrawn, now); + + public int RemainingProposals() => Math.Max(0, MaxProposals - ProposalsUsed); + + private void Decide(NegotiationStatus terminalStatus, DateTimeOffset now) + { + EnsureOpen(); + Status = terminalStatus; + DecidedAtUtc = now; + } + + private void EnsureOpen() + { + if (Status != NegotiationStatus.Open) + { + throw new ClosedNegotiationException(); + } + } +} diff --git a/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Domain/NegotiationId.cs b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Domain/NegotiationId.cs new file mode 100644 index 0000000..f50fdbc --- /dev/null +++ b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Domain/NegotiationId.cs @@ -0,0 +1,10 @@ +using Vogen; + +namespace PriceNegotiationApp.Modules.Negotiations.Domain; + +[ValueObject(Conversions.None)] +internal readonly partial record struct NegotiationId; + + + + diff --git a/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Domain/NegotiationOutcome.cs b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Domain/NegotiationOutcome.cs new file mode 100644 index 0000000..c1b14fb --- /dev/null +++ b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Domain/NegotiationOutcome.cs @@ -0,0 +1,10 @@ +namespace PriceNegotiationApp.Modules.Negotiations.Domain; + +internal enum NegotiationOutcome +{ + CounterProposed = 1, + AutoRejected = 2, + NoProposalsRemaining = 3, +} + + diff --git a/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Domain/NegotiationStatus.cs b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Domain/NegotiationStatus.cs new file mode 100644 index 0000000..801fa15 --- /dev/null +++ b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Domain/NegotiationStatus.cs @@ -0,0 +1,13 @@ +namespace PriceNegotiationApp.Modules.Negotiations.Domain; + +internal enum NegotiationStatus +{ + Open = 1, + Accepted = 2, + + /// Terminal. Reached only via auto-rejection of an over-limit counter-proposal. + Rejected = 3, + + /// Terminal. Owner withdrew; row and history are preserved. + Withdrawn = 4, +} diff --git a/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Domain/Price.cs b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Domain/Price.cs new file mode 100644 index 0000000..52d8329 --- /dev/null +++ b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Domain/Price.cs @@ -0,0 +1,13 @@ +using Vogen; + +namespace PriceNegotiationApp.Modules.Negotiations.Domain; + +[ValueObject(Conversions.None)] +internal readonly partial record struct Price +{ + private static Validation Validate(decimal value) => + value > 0m ? Validation.Ok : Validation.Invalid("Price must be greater than zero."); +} + + + diff --git a/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Domain/PriceNegotiationApp.Modules.Negotiations.Domain.csproj b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Domain/PriceNegotiationApp.Modules.Negotiations.Domain.csproj new file mode 100644 index 0000000..2d40e8b --- /dev/null +++ b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Domain/PriceNegotiationApp.Modules.Negotiations.Domain.csproj @@ -0,0 +1,18 @@ + + + $(NoWarn);MA0097;MA0182 + + + + + + + + + + + + + + + diff --git a/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Domain/ProposalExceedsLimitException.cs b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Domain/ProposalExceedsLimitException.cs new file mode 100644 index 0000000..f2738a7 --- /dev/null +++ b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Domain/ProposalExceedsLimitException.cs @@ -0,0 +1,10 @@ +using PriceNegotiationApp.SharedKernel; +using System.Globalization; + +namespace PriceNegotiationApp.Modules.Negotiations.Domain; + +internal sealed class ProposalExceedsLimitException(decimal limit) + : DomainException($"Proposal exceeds the allowed limit of {limit.ToString(CultureInfo.InvariantCulture)}.") +{ + public decimal Limit { get; } = limit; +} diff --git a/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Infrastructure/Accept/AcceptHandler.cs b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Infrastructure/Accept/AcceptHandler.cs new file mode 100644 index 0000000..6ea6292 --- /dev/null +++ b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Infrastructure/Accept/AcceptHandler.cs @@ -0,0 +1,16 @@ +using PriceNegotiationApp.Modules.Negotiations.Infrastructure; +using PriceNegotiationApp.Modules.Negotiations.Application; +using PriceNegotiationApp.Modules.Negotiations.Infrastructure.Persistence; + +namespace PriceNegotiationApp.Modules.Negotiations.Infrastructure.Accept; + +internal sealed class AcceptHandler(NegotiationsDbContext db, TimeProvider clock) +{ + public async Task HandleAsync(Guid id, CancellationToken ct) + { + var negotiation = await NegotiationAccess.RequireAsync(db, id, ct); + negotiation.Accept(clock.GetUtcNow()); + await db.SaveChangesAsync(ct); + return new StaffActionResponse("accepted", NegotiationResponses.ToResponse(negotiation)); + } +} diff --git a/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Infrastructure/CounterPropose/CounterProposeHandler.cs b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Infrastructure/CounterPropose/CounterProposeHandler.cs new file mode 100644 index 0000000..c0696f3 --- /dev/null +++ b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Infrastructure/CounterPropose/CounterProposeHandler.cs @@ -0,0 +1,28 @@ +using PriceNegotiationApp.Modules.Negotiations.Contracts; +using PriceNegotiationApp.Modules.Negotiations.Infrastructure; +using PriceNegotiationApp.Modules.Negotiations.Application.CounterPropose; +using PriceNegotiationApp.Modules.Negotiations.Domain; +using PriceNegotiationApp.Modules.Negotiations.Application; +using PriceNegotiationApp.Modules.Negotiations.Infrastructure.Persistence; +using PriceNegotiationApp.SharedKernel; + +namespace PriceNegotiationApp.Modules.Negotiations.Infrastructure.CounterPropose; + +internal sealed class CounterProposeHandler(NegotiationsDbContext db, TimeProvider clock) +{ + public async Task HandleAsync( + Guid id, CounterProposalRequest request, CallerContext caller, CancellationToken ct) + { + var negotiation = await NegotiationAccess.RequireOwnedAsync(db, caller, id, ct); + + var outcome = negotiation.CounterPropose(request.ProposedPrice, clock.GetUtcNow()); + if (outcome == NegotiationOutcome.NoProposalsRemaining) + { + throw new ConflictException(NegotiationErrorCodes.NoProposalsRemaining, + "No proposals remain for this negotiation."); + } + + await db.SaveChangesAsync(ct); + return new CounterProposalResponse(outcome.ToString(), NegotiationResponses.ToResponse(negotiation)); + } +} diff --git a/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Infrastructure/Create/CreateNegotiationHandler.cs b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Infrastructure/Create/CreateNegotiationHandler.cs new file mode 100644 index 0000000..9ff8624 --- /dev/null +++ b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Infrastructure/Create/CreateNegotiationHandler.cs @@ -0,0 +1,47 @@ +using PriceNegotiationApp.Modules.Negotiations.Contracts; +using PriceNegotiationApp.Modules.Negotiations.Infrastructure; +using PriceNegotiationApp.Modules.Negotiations.Application.Create; +using PriceNegotiationApp.Modules.Catalog.Contracts; +using PriceNegotiationApp.Modules.Negotiations.Domain; +using PriceNegotiationApp.Modules.Negotiations.Application; +using PriceNegotiationApp.Modules.Negotiations.Infrastructure.Persistence; +using PriceNegotiationApp.SharedKernel; + +namespace PriceNegotiationApp.Modules.Negotiations.Infrastructure.Create; + +internal sealed class CreateNegotiationHandler( + NegotiationsDbContext db, + IProductPriceProvider products, + INegotiationPolicy policy, + TimeProvider clock) +{ + public async Task HandleAsync( + CreateNegotiationRequest command, CallerContext caller, CancellationToken ct) + { + var snapshot = await products.GetAsync(command.ProductId, ct) + ?? throw new NotFoundException("Product", command.ProductId); + + if (await NegotiationAccess.FindOpenAsync(db, snapshot.ProductId, caller.UserId, ct) is not null) + { + throw new ConflictException(NegotiationErrorCodes.NegotiationAlreadyOpen, + "An open negotiation already exists for this product."); + } + + // Provisioning the customer row and inserting the negotiation commit together: + // a failed insert must not strand a permanent customer row (one commit point). + await using var tx = await db.Database.BeginTransactionAsync(ct); + var customerId = await NegotiationAccess.GetOrCreateCustomerIdAsync(db, caller.UserId, ct); + var negotiation = Negotiation.Start(customerId, snapshot.ProductId, snapshot.Price, + command.ProposedPrice, clock.GetUtcNow(), policy); + db.Negotiations.Add(negotiation); + + // The partial unique index is the real guard; a race that slipped past the + // pre-check above surfaces here as a 409 instead of a 500. + await db.SaveOrConflictAsync( + _ => new ConflictException(NegotiationErrorCodes.NegotiationAlreadyOpen, + "An open negotiation already exists for this product."), ct); + await tx.CommitAsync(ct); + + return NegotiationResponses.ToResponse(negotiation); + } +} diff --git a/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Infrastructure/Get/GetNegotiationHandler.cs b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Infrastructure/Get/GetNegotiationHandler.cs new file mode 100644 index 0000000..9fbb63d --- /dev/null +++ b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Infrastructure/Get/GetNegotiationHandler.cs @@ -0,0 +1,20 @@ +using PriceNegotiationApp.Modules.Negotiations.Infrastructure; +using PriceNegotiationApp.Modules.Negotiations.Application; +using PriceNegotiationApp.Modules.Negotiations.Infrastructure.Persistence; +using PriceNegotiationApp.SharedKernel; + +namespace PriceNegotiationApp.Modules.Negotiations.Infrastructure.Get; + +internal sealed class GetNegotiationHandler(NegotiationsDbContext db) +{ + public async Task HandleAsync(Guid id, CallerContext caller, CancellationToken ct) + { + var negotiation = await NegotiationAccess.RequireReadOnlyAsync(db, id, ct); + if (!await NegotiationAccess.CanAccessAsync(db, caller, negotiation, ct)) + { + throw new ForbiddenAccessException(); + } + + return NegotiationResponses.ToResponse(negotiation); + } +} diff --git a/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Infrastructure/List/ListNegotiationsHandler.cs b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Infrastructure/List/ListNegotiationsHandler.cs new file mode 100644 index 0000000..e97bcc7 --- /dev/null +++ b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Infrastructure/List/ListNegotiationsHandler.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore; +using PriceNegotiationApp.Modules.Negotiations.Application; +using PriceNegotiationApp.Modules.Negotiations.Infrastructure.Persistence; +using PriceNegotiationApp.SharedKernel; + +namespace PriceNegotiationApp.Modules.Negotiations.Infrastructure.List; + +internal sealed class ListNegotiationsHandler(NegotiationsDbContext db) +{ + public async Task> HandleAsync(PageQuery page, CancellationToken ct) + { + var q = db.Negotiations.AsNoTracking(); + var total = await q.LongCountAsync(ct); + var items = await q.OrderByDescending(n => n.CreatedAtUtc) + .Skip(page.Skip).Take(page.SafePageSize) + .ToListAsync(ct); + + return new PagedResult( + items.Select(NegotiationResponses.ToResponse).ToList(), + page.SafePage, page.SafePageSize, total); + } +} diff --git a/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Infrastructure/ListMine/ListMyNegotiationsHandler.cs b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Infrastructure/ListMine/ListMyNegotiationsHandler.cs new file mode 100644 index 0000000..b121735 --- /dev/null +++ b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Infrastructure/ListMine/ListMyNegotiationsHandler.cs @@ -0,0 +1,30 @@ +using PriceNegotiationApp.Modules.Negotiations.Infrastructure; +using Microsoft.EntityFrameworkCore; +using PriceNegotiationApp.Modules.Negotiations.Application; +using PriceNegotiationApp.Modules.Negotiations.Infrastructure.Persistence; +using PriceNegotiationApp.SharedKernel; + +namespace PriceNegotiationApp.Modules.Negotiations.Infrastructure.ListMine; + +internal sealed class ListMyNegotiationsHandler(NegotiationsDbContext db) +{ + public async Task> HandleAsync( + PageQuery page, CallerContext caller, CancellationToken ct) + { + var customer = await NegotiationAccess.CustomerByIdentityAsync(db, caller.UserId, ct); + if (customer is null) + { + return new PagedResult([], page.SafePage, page.SafePageSize, 0); + } + + var q = db.Negotiations.AsNoTracking().Where(n => n.CustomerId == customer.Id); + var total = await q.LongCountAsync(ct); + var items = await q.OrderByDescending(n => n.CreatedAtUtc) + .Skip(page.Skip).Take(page.SafePageSize) + .ToListAsync(ct); + + return new PagedResult( + items.Select(NegotiationResponses.ToResponse).ToList(), + page.SafePage, page.SafePageSize, total); + } +} diff --git a/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Infrastructure/NegotiationAccess.cs b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Infrastructure/NegotiationAccess.cs new file mode 100644 index 0000000..4db4013 --- /dev/null +++ b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Infrastructure/NegotiationAccess.cs @@ -0,0 +1,91 @@ +using Microsoft.EntityFrameworkCore; +using PriceNegotiationApp.Modules.Negotiations.Domain; +using PriceNegotiationApp.Modules.Negotiations.Infrastructure.Persistence; +using PriceNegotiationApp.SharedKernel; + +namespace PriceNegotiationApp.Modules.Negotiations.Infrastructure; + +internal static class NegotiationAccess +{ + public static async Task RequireAsync(NegotiationsDbContext db, Guid id, CancellationToken ct) => + await db.Negotiations.FirstOrDefaultAsync(n => n.Id == NegotiationId.From(id), ct) + ?? throw new NotFoundException(nameof(Negotiation), id); + + /// Read-only load for endpoints that never mutate the entity. + public static async Task RequireReadOnlyAsync(NegotiationsDbContext db, Guid id, CancellationToken ct) => + await db.Negotiations.AsNoTracking().FirstOrDefaultAsync(n => n.Id == NegotiationId.From(id), ct) + ?? throw new NotFoundException(nameof(Negotiation), id); + + public static async Task RequireOwnedAsync( + NegotiationsDbContext db, CallerContext caller, Guid id, CancellationToken ct) + { + var negotiation = await RequireAsync(db, id, ct); + if (!await IsOwnerAsync(db, caller.UserId, negotiation, ct)) + { + throw new ForbiddenAccessException(); + } + + return negotiation; + } + + public static async Task CanAccessAsync( + NegotiationsDbContext db, CallerContext caller, Negotiation negotiation, CancellationToken ct) + { + if (caller.IsInRole(UserRoles.Admin) || caller.IsInRole(UserRoles.Staff)) + { + return true; + } + + return await IsOwnerAsync(db, caller.UserId, negotiation, ct); + } + + public static async Task IsOwnerAsync( + NegotiationsDbContext db, Guid identityUserId, Negotiation negotiation, CancellationToken ct) + { + var customer = await CustomerByIdentityAsync(db, identityUserId, ct); + return customer is not null && customer.Id == negotiation.CustomerId; + } + + public static Task CustomerByIdentityAsync( + NegotiationsDbContext db, Guid identityUserId, CancellationToken ct) => + db.Customers.FirstOrDefaultAsync(c => c.IdentityUserId == identityUserId, ct); + + public static async Task GetOrCreateCustomerIdAsync( + NegotiationsDbContext db, Guid identityUserId, CancellationToken ct) + { + var existing = await CustomerByIdentityAsync(db, identityUserId, ct); + if (existing is not null) + { + return existing.Id; + } + + var customer = Customer.Create(identityUserId); + db.Customers.Add(customer); + try + { + await db.SaveChangesAsync(ct); + } + catch (DbUpdateException ex) when (DbWriteGuard.IsUniqueViolation(ex, out _)) + { + // A concurrent first request already provisioned the customer row. + db.Entry(customer).State = EntityState.Detached; + return (await CustomerByIdentityAsync(db, identityUserId, ct))!.Id; + } + + return customer.Id; + } + + public static async Task FindOpenAsync( + NegotiationsDbContext db, Guid productId, Guid identityUserId, CancellationToken ct) + { + var customer = await CustomerByIdentityAsync(db, identityUserId, ct); + return customer is null + ? null + : await db.Negotiations.AsNoTracking().FirstOrDefaultAsync( + n => n.ProductId == productId && n.CustomerId == customer.Id && n.Status == NegotiationStatus.Open, + ct); + } +} + + + diff --git a/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Infrastructure/NegotiationsModule.cs b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Infrastructure/NegotiationsModule.cs new file mode 100644 index 0000000..2bc3044 --- /dev/null +++ b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Infrastructure/NegotiationsModule.cs @@ -0,0 +1,42 @@ +using FluentValidation; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using PriceNegotiationApp.Modules.Negotiations.Domain; +using PriceNegotiationApp.Modules.Negotiations.Application.Create; +using PriceNegotiationApp.Modules.Negotiations.Infrastructure.Accept; +using PriceNegotiationApp.Modules.Negotiations.Infrastructure.CounterPropose; +using PriceNegotiationApp.Modules.Negotiations.Infrastructure.Create; +using PriceNegotiationApp.Modules.Negotiations.Infrastructure.Get; +using PriceNegotiationApp.Modules.Negotiations.Infrastructure.List; +using PriceNegotiationApp.Modules.Negotiations.Infrastructure.ListMine; +using PriceNegotiationApp.Modules.Negotiations.Infrastructure.RejectCurrentOffer; +using PriceNegotiationApp.Modules.Negotiations.Infrastructure.Withdraw; +using PriceNegotiationApp.Modules.Negotiations.Infrastructure.Persistence; +using PriceNegotiationApp.SharedKernel; + +namespace PriceNegotiationApp.Modules.Negotiations.Infrastructure; + +public static class NegotiationsModule +{ + public static IServiceCollection AddNegotiationsModule( + this IServiceCollection services, IConfiguration configuration) + { + services.AddDbContext(options => options + .UseNpgsql(DbConnections.Resolve(configuration, "Negotiations"), + npgsql => npgsql.MigrationsHistoryTable("__EFMigrationsHistory_Negotiations")) + .UseSnakeCaseNamingConvention()); + services.AddSingleton(); + services.AddSingleton(TimeProvider.System); + services.AddValidatorsFromAssemblyContaining(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + return services; + } +} diff --git a/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Infrastructure/Persistence/Configurations/CustomerConfiguration.cs b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Infrastructure/Persistence/Configurations/CustomerConfiguration.cs new file mode 100644 index 0000000..572e59e --- /dev/null +++ b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Infrastructure/Persistence/Configurations/CustomerConfiguration.cs @@ -0,0 +1,21 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using PriceNegotiationApp.Modules.Negotiations.Domain; + +namespace PriceNegotiationApp.Modules.Negotiations.Infrastructure.Persistence.Configurations; + +internal sealed class CustomerConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + // DELIBERATE ANEMIC DESIGN (ddd-audit spec F-03): Customer is a reference row + // binding an ASP.NET Identity user into this context. It is created once and + // never mutated; it has no behavioral invariants beyond a non-empty identity + // link. Do not "enrich" it into a fake aggregate without a real use case. + builder.ToTable("customers"); + builder.HasKey(c => c.Id); + builder.Property(c => c.Id).HasConversion(id => id.Value, value => CustomerId.From(value)) + .ValueGeneratedNever(); + builder.HasIndex(c => c.IdentityUserId).IsUnique(); + } +} diff --git a/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Infrastructure/Persistence/Configurations/NegotiationConfiguration.cs b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Infrastructure/Persistence/Configurations/NegotiationConfiguration.cs new file mode 100644 index 0000000..e6c2a46 --- /dev/null +++ b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Infrastructure/Persistence/Configurations/NegotiationConfiguration.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using PriceNegotiationApp.Modules.Negotiations.Domain; + +namespace PriceNegotiationApp.Modules.Negotiations.Infrastructure.Persistence.Configurations; + +internal sealed class NegotiationConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("negotiations"); + builder.HasKey(n => n.Id); + builder.Property(n => n.Id).HasConversion(id => id.Value, value => NegotiationId.From(value)) + .ValueGeneratedNever(); + // Plain Guid key: product_id has NO FK by design (separate schemas/modules). + // Existence is validated at creation; negotiations survive deletion on snapshots. + builder.Property(n => n.CustomerId).HasConversion(id => id.Value, value => CustomerId.From(value)); + builder.Property(n => n.BasePrice).HasConversion( + price => price.Value, value => Domain.Price.From(value)).HasColumnType("numeric(18,2)"); + builder.Property(n => n.CurrentOffer).HasConversion( + price => price.Value, value => Domain.Price.From(value)).HasColumnType("numeric(18,2)"); + builder.Property(n => n.OfferMultiplierLimit).HasColumnType("numeric(5,2)"); + builder.Property(n => n.Status).HasConversion(); + builder.HasOne().WithMany().HasForeignKey(n => n.CustomerId).OnDelete(DeleteBehavior.Cascade); + builder.HasIndex(n => new { n.ProductId, n.CustomerId }) + .HasDatabaseName("uq_negotiations_open_product_customer") + .IsUnique() + .HasFilter($"status = {(int)NegotiationStatus.Open}"); + builder.Property(n => n.Version).IsRowVersion(); + } +} diff --git a/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Infrastructure/Persistence/DesignTimeDbContextFactory.cs b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Infrastructure/Persistence/DesignTimeDbContextFactory.cs new file mode 100644 index 0000000..2a0cd93 --- /dev/null +++ b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Infrastructure/Persistence/DesignTimeDbContextFactory.cs @@ -0,0 +1,18 @@ +using PriceNegotiationApp.Modules.Negotiations.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using PriceNegotiationApp.SharedKernel; + +using System.Diagnostics.CodeAnalysis; + +namespace PriceNegotiationApp.Modules.Negotiations.Infrastructure.Persistence; + +[SuppressMessage("Meziantou.Analyzer", "MA0182", Justification = "Instantiated by EF Core design-time tooling via reflection.")] +internal sealed class DesignTimeDbContextFactory : DesignTimeDbContextFactoryBase +{ + protected override void Configure(DbContextOptionsBuilder builder) => + builder.UseNpgsql(LocalConnectionString, + npgsql => npgsql.MigrationsHistoryTable("__EFMigrationsHistory_Negotiations")) + .UseSnakeCaseNamingConvention(); + + protected override NegotiationsDbContext Create(DbContextOptions options) => new(options); +} diff --git a/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Infrastructure/Persistence/Migrations/20260824050105_Initial.Designer.cs b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Infrastructure/Persistence/Migrations/20260824050105_Initial.Designer.cs new file mode 100644 index 0000000..208f3b3 --- /dev/null +++ b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Infrastructure/Persistence/Migrations/20260824050105_Initial.Designer.cs @@ -0,0 +1,123 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using PriceNegotiationApp.Modules.Negotiations.Infrastructure.Persistence; + +#nullable disable + +namespace PriceNegotiationApp.Modules.Negotiations.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(NegotiationsDbContext))] + [Migration("20260824050105_Initial")] + partial class Initial + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("negotiations") + .HasAnnotation("ProductVersion", "10.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("PriceNegotiationApp.Modules.Negotiations.Domain.Customer", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("IdentityUserId") + .HasColumnType("uuid") + .HasColumnName("identity_user_id"); + + b.HasKey("Id") + .HasName("pk_customers"); + + b.HasIndex("IdentityUserId") + .IsUnique() + .HasDatabaseName("ix_customers_identity_user_id"); + + b.ToTable("customers", "negotiations"); + }); + + modelBuilder.Entity("PriceNegotiationApp.Modules.Negotiations.Domain.Negotiation", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("BasePrice") + .HasColumnType("numeric(18,2)") + .HasColumnName("base_price"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at_utc"); + + b.Property("CurrentOffer") + .HasColumnType("numeric(18,2)") + .HasColumnName("current_offer"); + + b.Property("CustomerId") + .HasColumnType("uuid") + .HasColumnName("customer_id"); + + b.Property("DecidedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("decided_at_utc"); + + b.Property("LastProposalAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_proposal_at_utc"); + + b.Property("ProductId") + .HasColumnType("uuid") + .HasColumnName("product_id"); + + b.Property("ProposalsUsed") + .HasColumnType("integer") + .HasColumnName("proposals_used"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id") + .HasName("pk_negotiations"); + + b.HasIndex("CustomerId") + .HasDatabaseName("ix_negotiations_customer_id"); + + b.HasIndex("ProductId", "CustomerId") + .IsUnique() + .HasDatabaseName("ix_negotiations_product_id_customer_id") + .HasFilter("status = 1"); + + b.ToTable("negotiations", "negotiations"); + }); + + modelBuilder.Entity("PriceNegotiationApp.Modules.Negotiations.Domain.Negotiation", b => + { + b.HasOne("PriceNegotiationApp.Modules.Negotiations.Domain.Customer", null) + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_negotiations_customers_customer_id"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Infrastructure/Persistence/Migrations/20260824050105_Initial.cs b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Infrastructure/Persistence/Migrations/20260824050105_Initial.cs new file mode 100644 index 0000000..a566f7b --- /dev/null +++ b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Infrastructure/Persistence/Migrations/20260824050105_Initial.cs @@ -0,0 +1,93 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using System; + +#nullable disable + +namespace PriceNegotiationApp.Modules.Negotiations.Infrastructure.Persistence.Migrations +{ + /// + public partial class Initial : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "negotiations"); + + migrationBuilder.CreateTable( + name: "customers", + schema: "negotiations", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + identity_user_id = table.Column(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_customers", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "negotiations", + schema: "negotiations", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + base_price = table.Column(type: "numeric(18,2)", nullable: false), + current_offer = table.Column(type: "numeric(18,2)", nullable: false), + product_id = table.Column(type: "uuid", nullable: false), + customer_id = table.Column(type: "uuid", nullable: false), + status = table.Column(type: "integer", nullable: false), + proposals_used = table.Column(type: "integer", nullable: false), + created_at_utc = table.Column(type: "timestamp with time zone", nullable: false), + last_proposal_at_utc = table.Column(type: "timestamp with time zone", nullable: false), + decided_at_utc = table.Column(type: "timestamp with time zone", nullable: true), + xmin = table.Column(type: "xid", rowVersion: true, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_negotiations", x => x.id); + table.ForeignKey( + name: "fk_negotiations_customers_customer_id", + column: x => x.customer_id, + principalSchema: "negotiations", + principalTable: "customers", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "ix_customers_identity_user_id", + schema: "negotiations", + table: "customers", + column: "identity_user_id", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_negotiations_customer_id", + schema: "negotiations", + table: "negotiations", + column: "customer_id"); + + migrationBuilder.CreateIndex( + name: "ix_negotiations_product_id_customer_id", + schema: "negotiations", + table: "negotiations", + columns: new[] { "product_id", "customer_id" }, + unique: true, + filter: "status = 1"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "negotiations", + schema: "negotiations"); + + migrationBuilder.DropTable( + name: "customers", + schema: "negotiations"); + } + } +} diff --git a/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Infrastructure/Persistence/Migrations/20260825131537_SnapshotPolicyLimitsAndWithdrawn.Designer.cs b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Infrastructure/Persistence/Migrations/20260825131537_SnapshotPolicyLimitsAndWithdrawn.Designer.cs new file mode 100644 index 0000000..f69fb46 --- /dev/null +++ b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Infrastructure/Persistence/Migrations/20260825131537_SnapshotPolicyLimitsAndWithdrawn.Designer.cs @@ -0,0 +1,135 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using PriceNegotiationApp.Modules.Negotiations.Infrastructure.Persistence; + +#nullable disable + +namespace PriceNegotiationApp.Modules.Negotiations.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(NegotiationsDbContext))] + [Migration("20260825131537_SnapshotPolicyLimitsAndWithdrawn")] + partial class SnapshotPolicyLimitsAndWithdrawn + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("negotiations") + .HasAnnotation("ProductVersion", "10.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("PriceNegotiationApp.Modules.Negotiations.Domain.Customer", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("IdentityUserId") + .HasColumnType("uuid") + .HasColumnName("identity_user_id"); + + b.HasKey("Id") + .HasName("pk_customers"); + + b.HasIndex("IdentityUserId") + .IsUnique() + .HasDatabaseName("ix_customers_identity_user_id"); + + b.ToTable("customers", "negotiations"); + }); + + modelBuilder.Entity("PriceNegotiationApp.Modules.Negotiations.Domain.Negotiation", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("BasePrice") + .HasColumnType("numeric(18,2)") + .HasColumnName("base_price"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at_utc"); + + b.Property("CurrentOffer") + .HasColumnType("numeric(18,2)") + .HasColumnName("current_offer"); + + b.Property("CustomerId") + .HasColumnType("uuid") + .HasColumnName("customer_id"); + + b.Property("DecidedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("decided_at_utc"); + + b.Property("LastProposalAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_proposal_at_utc"); + + b.Property("LastStaffActionAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_staff_action_at_utc"); + + b.Property("MaxProposals") + .HasColumnType("integer") + .HasColumnName("max_proposals"); + + b.Property("OfferMultiplierLimit") + .HasColumnType("numeric(5,2)") + .HasColumnName("offer_multiplier_limit"); + + b.Property("ProductId") + .HasColumnType("uuid") + .HasColumnName("product_id"); + + b.Property("ProposalsUsed") + .HasColumnType("integer") + .HasColumnName("proposals_used"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id") + .HasName("pk_negotiations"); + + b.HasIndex("CustomerId") + .HasDatabaseName("ix_negotiations_customer_id"); + + b.HasIndex("ProductId", "CustomerId") + .IsUnique() + .HasDatabaseName("uq_negotiations_open_product_customer") + .HasFilter("status = 1"); + + b.ToTable("negotiations", "negotiations"); + }); + + modelBuilder.Entity("PriceNegotiationApp.Modules.Negotiations.Domain.Negotiation", b => + { + b.HasOne("PriceNegotiationApp.Modules.Negotiations.Domain.Customer", null) + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_negotiations_customers_customer_id"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Infrastructure/Persistence/Migrations/20260825131537_SnapshotPolicyLimitsAndWithdrawn.cs b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Infrastructure/Persistence/Migrations/20260825131537_SnapshotPolicyLimitsAndWithdrawn.cs new file mode 100644 index 0000000..0a22559 --- /dev/null +++ b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Infrastructure/Persistence/Migrations/20260825131537_SnapshotPolicyLimitsAndWithdrawn.cs @@ -0,0 +1,69 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using System; + +#nullable disable + +namespace PriceNegotiationApp.Modules.Negotiations.Infrastructure.Persistence.Migrations +{ + /// + public partial class SnapshotPolicyLimitsAndWithdrawn : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameIndex( + name: "ix_negotiations_product_id_customer_id", + schema: "negotiations", + table: "negotiations", + newName: "uq_negotiations_open_product_customer"); + + migrationBuilder.AddColumn( + name: "last_staff_action_at_utc", + schema: "negotiations", + table: "negotiations", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "max_proposals", + schema: "negotiations", + table: "negotiations", + type: "integer", + nullable: false, + defaultValue: 3); + + migrationBuilder.AddColumn( + name: "offer_multiplier_limit", + schema: "negotiations", + table: "negotiations", + type: "numeric(5,2)", + nullable: false, + defaultValue: 2.0m); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "last_staff_action_at_utc", + schema: "negotiations", + table: "negotiations"); + + migrationBuilder.DropColumn( + name: "max_proposals", + schema: "negotiations", + table: "negotiations"); + + migrationBuilder.DropColumn( + name: "offer_multiplier_limit", + schema: "negotiations", + table: "negotiations"); + + migrationBuilder.RenameIndex( + name: "uq_negotiations_open_product_customer", + schema: "negotiations", + table: "negotiations", + newName: "ix_negotiations_product_id_customer_id"); + } + } +} diff --git a/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Infrastructure/Persistence/Migrations/NegotiationsDbContextModelSnapshot.cs b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Infrastructure/Persistence/Migrations/NegotiationsDbContextModelSnapshot.cs new file mode 100644 index 0000000..3cc5bb1 --- /dev/null +++ b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Infrastructure/Persistence/Migrations/NegotiationsDbContextModelSnapshot.cs @@ -0,0 +1,132 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using PriceNegotiationApp.Modules.Negotiations.Infrastructure.Persistence; + +#nullable disable + +namespace PriceNegotiationApp.Modules.Negotiations.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(NegotiationsDbContext))] + partial class NegotiationsDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("negotiations") + .HasAnnotation("ProductVersion", "10.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("PriceNegotiationApp.Modules.Negotiations.Domain.Customer", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("IdentityUserId") + .HasColumnType("uuid") + .HasColumnName("identity_user_id"); + + b.HasKey("Id") + .HasName("pk_customers"); + + b.HasIndex("IdentityUserId") + .IsUnique() + .HasDatabaseName("ix_customers_identity_user_id"); + + b.ToTable("customers", "negotiations"); + }); + + modelBuilder.Entity("PriceNegotiationApp.Modules.Negotiations.Domain.Negotiation", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("BasePrice") + .HasColumnType("numeric(18,2)") + .HasColumnName("base_price"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at_utc"); + + b.Property("CurrentOffer") + .HasColumnType("numeric(18,2)") + .HasColumnName("current_offer"); + + b.Property("CustomerId") + .HasColumnType("uuid") + .HasColumnName("customer_id"); + + b.Property("DecidedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("decided_at_utc"); + + b.Property("LastProposalAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_proposal_at_utc"); + + b.Property("LastStaffActionAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_staff_action_at_utc"); + + b.Property("MaxProposals") + .HasColumnType("integer") + .HasColumnName("max_proposals"); + + b.Property("OfferMultiplierLimit") + .HasColumnType("numeric(5,2)") + .HasColumnName("offer_multiplier_limit"); + + b.Property("ProductId") + .HasColumnType("uuid") + .HasColumnName("product_id"); + + b.Property("ProposalsUsed") + .HasColumnType("integer") + .HasColumnName("proposals_used"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id") + .HasName("pk_negotiations"); + + b.HasIndex("CustomerId") + .HasDatabaseName("ix_negotiations_customer_id"); + + b.HasIndex("ProductId", "CustomerId") + .IsUnique() + .HasDatabaseName("uq_negotiations_open_product_customer") + .HasFilter("status = 1"); + + b.ToTable("negotiations", "negotiations"); + }); + + modelBuilder.Entity("PriceNegotiationApp.Modules.Negotiations.Domain.Negotiation", b => + { + b.HasOne("PriceNegotiationApp.Modules.Negotiations.Domain.Customer", null) + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_negotiations_customers_customer_id"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Infrastructure/Persistence/NegotiationsDbContext.cs b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Infrastructure/Persistence/NegotiationsDbContext.cs new file mode 100644 index 0000000..228ce8a --- /dev/null +++ b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Infrastructure/Persistence/NegotiationsDbContext.cs @@ -0,0 +1,23 @@ +using Microsoft.EntityFrameworkCore; +using PriceNegotiationApp.Modules.Negotiations.Domain; +using PriceNegotiationApp.Modules.Negotiations.Infrastructure.Persistence.Configurations; +using PriceNegotiationApp.SharedKernel; + +namespace PriceNegotiationApp.Modules.Negotiations.Infrastructure.Persistence; + +internal sealed class NegotiationsDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Customers => Set(); + + public DbSet Negotiations => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + modelBuilder.HasDefaultSchema("negotiations"); + modelBuilder.ApplyConfiguration(new CustomerConfiguration()); + modelBuilder.ApplyConfiguration(new NegotiationConfiguration()); + } +} + + diff --git a/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Infrastructure/PriceNegotiationApp.Modules.Negotiations.Infrastructure.csproj b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Infrastructure/PriceNegotiationApp.Modules.Negotiations.Infrastructure.csproj new file mode 100644 index 0000000..cb520d5 --- /dev/null +++ b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Infrastructure/PriceNegotiationApp.Modules.Negotiations.Infrastructure.csproj @@ -0,0 +1,26 @@ + + + $(NoWarn);MA0182 + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Infrastructure/RejectCurrentOffer/RejectCurrentOfferHandler.cs b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Infrastructure/RejectCurrentOffer/RejectCurrentOfferHandler.cs new file mode 100644 index 0000000..dfdae5f --- /dev/null +++ b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Infrastructure/RejectCurrentOffer/RejectCurrentOfferHandler.cs @@ -0,0 +1,17 @@ +using PriceNegotiationApp.Modules.Negotiations.Infrastructure; +using PriceNegotiationApp.Modules.Negotiations.Application; +using PriceNegotiationApp.Modules.Negotiations.Infrastructure.Persistence; + +namespace PriceNegotiationApp.Modules.Negotiations.Infrastructure.RejectCurrentOffer; + +internal sealed class RejectCurrentOfferHandler(NegotiationsDbContext db, TimeProvider clock) +{ + public async Task HandleAsync(Guid id, CancellationToken ct) + { + var negotiation = await NegotiationAccess.RequireAsync(db, id, ct); + negotiation.RejectCurrentOffer(clock.GetUtcNow()); + await db.SaveChangesAsync(ct); + return new StaffActionResponse("current_offer_rejected", + NegotiationResponses.ToResponse(negotiation)); + } +} diff --git a/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Infrastructure/Withdraw/WithdrawHandler.cs b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Infrastructure/Withdraw/WithdrawHandler.cs new file mode 100644 index 0000000..42d9fa5 --- /dev/null +++ b/src/Modules/Negotiations/PriceNegotiationApp.Modules.Negotiations.Infrastructure/Withdraw/WithdrawHandler.cs @@ -0,0 +1,30 @@ +using PriceNegotiationApp.Modules.Negotiations.Infrastructure; +using PriceNegotiationApp.Modules.Negotiations.Application; +using PriceNegotiationApp.Modules.Negotiations.Infrastructure.Persistence; +using PriceNegotiationApp.SharedKernel; + +namespace PriceNegotiationApp.Modules.Negotiations.Infrastructure.Withdraw; + +internal sealed class WithdrawHandler(NegotiationsDbContext db, TimeProvider clock) +{ + public async Task HandleAsync(Guid id, CallerContext caller, CancellationToken ct) + { + var negotiation = await NegotiationAccess.RequireAsync(db, id, ct); + + if (caller.IsInRole(UserRoles.Admin)) + { + db.Negotiations.Remove(negotiation); + } + else + { + if (!await NegotiationAccess.IsOwnerAsync(db, caller.UserId, negotiation, ct)) + { + throw new ForbiddenAccessException(); + } + + negotiation.Withdraw(clock.GetUtcNow()); + } + + await db.SaveChangesAsync(ct); + } +} diff --git a/src/PriceNegotiationApp.Api/Composition/MigrationHostedService.cs b/src/PriceNegotiationApp.Api/Composition/MigrationHostedService.cs new file mode 100644 index 0000000..9f88b60 --- /dev/null +++ b/src/PriceNegotiationApp.Api/Composition/MigrationHostedService.cs @@ -0,0 +1,35 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using PriceNegotiationApp.Modules.Catalog.Infrastructure.Persistence; +using PriceNegotiationApp.Modules.Identity.Infrastructure.Persistence; +using PriceNegotiationApp.Modules.Negotiations.Infrastructure.Persistence; + +namespace PriceNegotiationApp.Api.Composition; + +public sealed class MigrationHostedService(IServiceScopeFactory scopeFactory, ILogger logger) + : IHostedService +{ + public async Task StartAsync(CancellationToken cancellationToken) + { + await using var scope = scopeFactory.CreateAsyncScope(); + await MigrateAsync(scope, cancellationToken); + await MigrateAsync(scope, cancellationToken); + await MigrateAsync(scope, cancellationToken); + logger.LogInformation("Module databases migrated."); + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + private static async Task MigrateAsync(IServiceScope scope, CancellationToken ct) where T : DbContext + { + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.MigrateAsync(ct); + } +} + + + + + diff --git a/src/PriceNegotiationApp.Api/Endpoints/Catalog/CatalogEndpoints.cs b/src/PriceNegotiationApp.Api/Endpoints/Catalog/CatalogEndpoints.cs new file mode 100644 index 0000000..78fb620 --- /dev/null +++ b/src/PriceNegotiationApp.Api/Endpoints/Catalog/CatalogEndpoints.cs @@ -0,0 +1,26 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using PriceNegotiationApp.Api.Endpoints.Catalog.Create; +using PriceNegotiationApp.Api.Endpoints.Catalog.Delete; +using PriceNegotiationApp.Api.Endpoints.Catalog.Get; +using PriceNegotiationApp.Api.Endpoints.Catalog.List; +using PriceNegotiationApp.Api.Endpoints.Catalog.Update; + +namespace PriceNegotiationApp.Api.Endpoints.Catalog; + +public static class CatalogEndpoints +{ + public static IEndpointRouteBuilder MapCatalogEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/api/v1/products") + .WithTags("Products") + .RequireAuthorization(); + group.MapList(); + group.MapGetOne(); + group.MapCreate(); + group.MapUpdate(); + group.MapDelete(); + return app; + } +} diff --git a/src/PriceNegotiationApp.Api/Endpoints/Catalog/CreateEndpoint.cs b/src/PriceNegotiationApp.Api/Endpoints/Catalog/CreateEndpoint.cs new file mode 100644 index 0000000..0faf45a --- /dev/null +++ b/src/PriceNegotiationApp.Api/Endpoints/Catalog/CreateEndpoint.cs @@ -0,0 +1,24 @@ +using PriceNegotiationApp.Modules.Catalog.Infrastructure.Create; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using PriceNegotiationApp.Api; +using PriceNegotiationApp.Modules.Catalog.Application.Create; +using PriceNegotiationApp.SharedKernel; + +namespace PriceNegotiationApp.Api.Endpoints.Catalog.Create; + +internal static class CreateEndpoint +{ + internal static void MapCreate(this RouteGroupBuilder group) + { + group.MapPost("/", async (CreateProductRequest request, CreateProductHandler handler, + CancellationToken ct) => + { + var response = await handler.HandleAsync(request, ct); + return TypedResults.CreatedAtRoute(response, "GetProductById", new { id = response.Id }); + }) + .AddEndpointFilter>() + .RequireRoles(UserRoles.Admin, UserRoles.Staff); + } +} diff --git a/src/PriceNegotiationApp.Api/Endpoints/Catalog/DeleteEndpoint.cs b/src/PriceNegotiationApp.Api/Endpoints/Catalog/DeleteEndpoint.cs new file mode 100644 index 0000000..a4e6b5a --- /dev/null +++ b/src/PriceNegotiationApp.Api/Endpoints/Catalog/DeleteEndpoint.cs @@ -0,0 +1,21 @@ +using PriceNegotiationApp.Modules.Catalog.Infrastructure.Delete; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using PriceNegotiationApp.Modules.Catalog.Application; +using PriceNegotiationApp.SharedKernel; + +namespace PriceNegotiationApp.Api.Endpoints.Catalog.Delete; + +internal static class DeleteEndpoint +{ + internal static void MapDelete(this RouteGroupBuilder group) + { + group.MapDelete("/{id:guid}", async (Guid id, DeleteProductHandler handler, CancellationToken ct) => + { + await handler.HandleAsync(id, ct); + return TypedResults.NoContent(); + }) + .RequireRoles(UserRoles.Admin); + } +} diff --git a/src/PriceNegotiationApp.Api/Endpoints/Catalog/GetEndpoint.cs b/src/PriceNegotiationApp.Api/Endpoints/Catalog/GetEndpoint.cs new file mode 100644 index 0000000..5cdd35b --- /dev/null +++ b/src/PriceNegotiationApp.Api/Endpoints/Catalog/GetEndpoint.cs @@ -0,0 +1,22 @@ +using PriceNegotiationApp.Modules.Catalog.Infrastructure.Get; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.OutputCaching; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using PriceNegotiationApp.Modules.Catalog.Application; +using PriceNegotiationApp.SharedKernel; + +namespace PriceNegotiationApp.Api.Endpoints.Catalog.Get; + +internal static class GetEndpoint +{ + internal static void MapGetOne(this RouteGroupBuilder group) + { + group.MapGet("/{id:guid}", async (Guid id, GetProductHandler handler, CancellationToken ct) => + TypedResults.Ok(await handler.HandleAsync(id, ct))) + .WithName("GetProductById") + .CacheOutput(Policies.ShortCachePolicy) + .AllowAnonymous(); + } +} diff --git a/src/PriceNegotiationApp.Api/Endpoints/Catalog/ListEndpoint.cs b/src/PriceNegotiationApp.Api/Endpoints/Catalog/ListEndpoint.cs new file mode 100644 index 0000000..deb6fb7 --- /dev/null +++ b/src/PriceNegotiationApp.Api/Endpoints/Catalog/ListEndpoint.cs @@ -0,0 +1,24 @@ +using PriceNegotiationApp.Modules.Catalog.Infrastructure.List; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.OutputCaching; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using PriceNegotiationApp.Modules.Catalog.Application; +using PriceNegotiationApp.SharedKernel; + +namespace PriceNegotiationApp.Api.Endpoints.Catalog.List; + +internal static class ListEndpoint +{ + internal static void MapList(this RouteGroupBuilder group) + { + group.MapGet("/", async (ListProductsHandler handler, CancellationToken ct, + string? search = null, decimal? minPrice = null, decimal? maxPrice = null, + string? sortBy = null, bool sortDesc = false, int page = 1, int pageSize = 20) => + TypedResults.Ok(await handler.HandleAsync( + new ProductQuery(search, minPrice, maxPrice, sortBy, sortDesc, page, pageSize), ct))) + .CacheOutput(Policies.ShortCachePolicy) + .AllowAnonymous(); + } +} diff --git a/src/PriceNegotiationApp.Api/Endpoints/Catalog/UpdateEndpoint.cs b/src/PriceNegotiationApp.Api/Endpoints/Catalog/UpdateEndpoint.cs new file mode 100644 index 0000000..ea838b1 --- /dev/null +++ b/src/PriceNegotiationApp.Api/Endpoints/Catalog/UpdateEndpoint.cs @@ -0,0 +1,21 @@ +using PriceNegotiationApp.Modules.Catalog.Infrastructure.Update; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using PriceNegotiationApp.Api; +using PriceNegotiationApp.Modules.Catalog.Application.Update; +using PriceNegotiationApp.SharedKernel; + +namespace PriceNegotiationApp.Api.Endpoints.Catalog.Update; + +internal static class UpdateEndpoint +{ + internal static void MapUpdate(this RouteGroupBuilder group) + { + group.MapPut("/{id:guid}", async (Guid id, UpdateProductRequest request, + UpdateProductHandler handler, CancellationToken ct) => + TypedResults.Ok(await handler.HandleAsync(id, request, ct))) + .AddEndpointFilter>() + .RequireRoles(UserRoles.Admin, UserRoles.Staff); + } +} diff --git a/src/PriceNegotiationApp.Api/Endpoints/Identity/IdentityEndpoints.cs b/src/PriceNegotiationApp.Api/Endpoints/Identity/IdentityEndpoints.cs new file mode 100644 index 0000000..e0247d5 --- /dev/null +++ b/src/PriceNegotiationApp.Api/Endpoints/Identity/IdentityEndpoints.cs @@ -0,0 +1,22 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using PriceNegotiationApp.Api.Endpoints.Identity.Login; +using PriceNegotiationApp.Api.Endpoints.Identity.Me; +using PriceNegotiationApp.Api.Endpoints.Identity.Register; + +namespace PriceNegotiationApp.Api.Endpoints.Identity; + +public static class IdentityEndpoints +{ + public static IEndpointRouteBuilder MapAuthEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/api/v1/auth") + .WithTags("Auth") + .RequireAuthorization(); + group.MapRegister(); + group.MapLogin(); + group.MapMe(); + return app; + } +} diff --git a/src/PriceNegotiationApp.Api/Endpoints/Identity/LoginEndpoint.cs b/src/PriceNegotiationApp.Api/Endpoints/Identity/LoginEndpoint.cs new file mode 100644 index 0000000..459190b --- /dev/null +++ b/src/PriceNegotiationApp.Api/Endpoints/Identity/LoginEndpoint.cs @@ -0,0 +1,27 @@ +using PriceNegotiationApp.Modules.Identity.Infrastructure.Login; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using PriceNegotiationApp.Api; +using PriceNegotiationApp.Modules.Identity.Application.Login; +using PriceNegotiationApp.SharedKernel; + +namespace PriceNegotiationApp.Api.Endpoints.Identity.Login; + +internal static class LoginEndpoint +{ + internal static void MapLogin(this RouteGroupBuilder group) + { + group.MapPost("/login", async (LoginRequest request, LoginUserHandler handler, + CancellationToken ct) => + TypedResults.Ok(await handler.HandleAsync(request))) + .AddEndpointFilter>() + .RequireRateLimiting(Policies.AuthRateLimitPolicy) + .AllowAnonymous() + .WithName("Login") + .WithSummary("Authenticate and issue an access token") + .ProducesProblem(StatusCodes.Status401Unauthorized) + .ProducesProblem(StatusCodes.Status422UnprocessableEntity) + .ProducesProblem(StatusCodes.Status429TooManyRequests); + } +} diff --git a/src/PriceNegotiationApp.Api/Endpoints/Identity/MeEndpoint.cs b/src/PriceNegotiationApp.Api/Endpoints/Identity/MeEndpoint.cs new file mode 100644 index 0000000..3485f43 --- /dev/null +++ b/src/PriceNegotiationApp.Api/Endpoints/Identity/MeEndpoint.cs @@ -0,0 +1,22 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using PriceNegotiationApp.Modules.Identity.Contracts; +using PriceNegotiationApp.SharedKernel; +using System.Security.Claims; + +namespace PriceNegotiationApp.Api.Endpoints.Identity.Me; + +internal static class MeEndpoint +{ + internal static void MapMe(this RouteGroupBuilder group) + { + group.MapGet("/me", (ClaimsPrincipal principal) => + { + var caller = principal.ToCallerContext(); + return TypedResults.Ok(new CurrentUserResponse(caller.UserId, caller.Email, caller.Roles.ToList())); + }) + .WithName("GetCurrentUser") + .WithSummary("Return the authenticated caller's profile"); + } +} diff --git a/src/PriceNegotiationApp.Api/Endpoints/Identity/RegisterEndpoint.cs b/src/PriceNegotiationApp.Api/Endpoints/Identity/RegisterEndpoint.cs new file mode 100644 index 0000000..ffa1579 --- /dev/null +++ b/src/PriceNegotiationApp.Api/Endpoints/Identity/RegisterEndpoint.cs @@ -0,0 +1,27 @@ +using PriceNegotiationApp.Modules.Identity.Infrastructure.Register; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using PriceNegotiationApp.Api; +using PriceNegotiationApp.Modules.Identity.Application.Register; +using PriceNegotiationApp.SharedKernel; + +namespace PriceNegotiationApp.Api.Endpoints.Identity.Register; + +internal static class RegisterEndpoint +{ + internal static void MapRegister(this RouteGroupBuilder group) + { + group.MapPost("/register", async (RegisterRequest request, + RegisterUserHandler handler, CancellationToken ct) => + TypedResults.Created("/api/v1/auth/me", await handler.HandleAsync(request))) + .AddEndpointFilter>() + .RequireRateLimiting(Policies.AuthRateLimitPolicy) + .AllowAnonymous() + .WithName("RegisterUser") + .WithSummary("Register a new customer account") + .ProducesProblem(StatusCodes.Status409Conflict) + .ProducesProblem(StatusCodes.Status422UnprocessableEntity) + .ProducesProblem(StatusCodes.Status429TooManyRequests); + } +} diff --git a/src/PriceNegotiationApp.Api/Endpoints/Negotiations/AcceptEndpoint.cs b/src/PriceNegotiationApp.Api/Endpoints/Negotiations/AcceptEndpoint.cs new file mode 100644 index 0000000..0b29805 --- /dev/null +++ b/src/PriceNegotiationApp.Api/Endpoints/Negotiations/AcceptEndpoint.cs @@ -0,0 +1,17 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using PriceNegotiationApp.Modules.Negotiations.Infrastructure.Accept; +using PriceNegotiationApp.SharedKernel; + +namespace PriceNegotiationApp.Api.Endpoints.Negotiations.Accept; + +internal static class AcceptEndpoint +{ + internal static void MapAccept(this RouteGroupBuilder group) + { + group.MapPost("/{id:guid}/accept", async (Guid id, AcceptHandler handler, CancellationToken ct) => + TypedResults.Ok(await handler.HandleAsync(id, ct))) + .RequireRoles(UserRoles.Admin, UserRoles.Staff); + } +} diff --git a/src/PriceNegotiationApp.Api/Endpoints/Negotiations/CounterProposeEndpoint.cs b/src/PriceNegotiationApp.Api/Endpoints/Negotiations/CounterProposeEndpoint.cs new file mode 100644 index 0000000..75cd50a --- /dev/null +++ b/src/PriceNegotiationApp.Api/Endpoints/Negotiations/CounterProposeEndpoint.cs @@ -0,0 +1,21 @@ +using PriceNegotiationApp.Modules.Negotiations.Infrastructure.CounterPropose; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using PriceNegotiationApp.Api; +using PriceNegotiationApp.Modules.Negotiations.Application.CounterPropose; +using PriceNegotiationApp.SharedKernel; +using System.Security.Claims; + +namespace PriceNegotiationApp.Api.Endpoints.Negotiations.CounterPropose; + +internal static class CounterProposeEndpoint +{ + internal static void MapCounterPropose(this RouteGroupBuilder group) + { + group.MapPatch("/{id:guid}/proposals", async (Guid id, CounterProposalRequest request, + ClaimsPrincipal principal, CounterProposeHandler handler, CancellationToken ct) => + TypedResults.Ok(await handler.HandleAsync(id, request, principal.ToCallerContext(), ct))) + .AddEndpointFilter>(); + } +} diff --git a/src/PriceNegotiationApp.Api/Endpoints/Negotiations/CreateEndpoint.cs b/src/PriceNegotiationApp.Api/Endpoints/Negotiations/CreateEndpoint.cs new file mode 100644 index 0000000..17b41d2 --- /dev/null +++ b/src/PriceNegotiationApp.Api/Endpoints/Negotiations/CreateEndpoint.cs @@ -0,0 +1,25 @@ +using PriceNegotiationApp.Modules.Negotiations.Infrastructure.Create; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using PriceNegotiationApp.Api; +using PriceNegotiationApp.Modules.Negotiations.Application.Create; +using PriceNegotiationApp.SharedKernel; +using System.Security.Claims; + +namespace PriceNegotiationApp.Api.Endpoints.Negotiations.Create; + +internal static class CreateEndpoint +{ + internal static void MapCreate(this RouteGroupBuilder group) + { + group.MapPost("/", async (CreateNegotiationRequest request, ClaimsPrincipal principal, + CreateNegotiationHandler handler, CancellationToken ct) => + { + var response = await handler.HandleAsync(request, principal.ToCallerContext(), ct); + return TypedResults.CreatedAtRoute(response, "GetNegotiationById", new { id = response.Id }); + }) + .AddEndpointFilter>() + .RequireRoles(UserRoles.Customer); + } +} diff --git a/src/PriceNegotiationApp.Api/Endpoints/Negotiations/GetEndpoint.cs b/src/PriceNegotiationApp.Api/Endpoints/Negotiations/GetEndpoint.cs new file mode 100644 index 0000000..44c0a66 --- /dev/null +++ b/src/PriceNegotiationApp.Api/Endpoints/Negotiations/GetEndpoint.cs @@ -0,0 +1,19 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using PriceNegotiationApp.Modules.Negotiations.Infrastructure.Get; +using PriceNegotiationApp.SharedKernel; +using System.Security.Claims; + +namespace PriceNegotiationApp.Api.Endpoints.Negotiations.Get; + +internal static class GetEndpoint +{ + internal static void MapGetOne(this RouteGroupBuilder group) + { + group.MapGet("/{id:guid}", async (Guid id, ClaimsPrincipal principal, + GetNegotiationHandler handler, CancellationToken ct) => + TypedResults.Ok(await handler.HandleAsync(id, principal.ToCallerContext(), ct))) + .WithName("GetNegotiationById"); + } +} diff --git a/src/PriceNegotiationApp.Api/Endpoints/Negotiations/ListEndpoint.cs b/src/PriceNegotiationApp.Api/Endpoints/Negotiations/ListEndpoint.cs new file mode 100644 index 0000000..2eaabd8 --- /dev/null +++ b/src/PriceNegotiationApp.Api/Endpoints/Negotiations/ListEndpoint.cs @@ -0,0 +1,18 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using PriceNegotiationApp.Modules.Negotiations.Infrastructure.List; +using PriceNegotiationApp.SharedKernel; + +namespace PriceNegotiationApp.Api.Endpoints.Negotiations.List; + +internal static class ListEndpoint +{ + internal static void MapList(this RouteGroupBuilder group) + { + group.MapGet("/", async (ListNegotiationsHandler handler, CancellationToken ct, + int page = 1, int pageSize = 20) => + TypedResults.Ok(await handler.HandleAsync(new PageQuery(page, pageSize), ct))) + .RequireRoles(UserRoles.Admin, UserRoles.Staff); + } +} diff --git a/src/PriceNegotiationApp.Api/Endpoints/Negotiations/ListMineEndpoint.cs b/src/PriceNegotiationApp.Api/Endpoints/Negotiations/ListMineEndpoint.cs new file mode 100644 index 0000000..1ba143f --- /dev/null +++ b/src/PriceNegotiationApp.Api/Endpoints/Negotiations/ListMineEndpoint.cs @@ -0,0 +1,20 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using PriceNegotiationApp.Modules.Negotiations.Infrastructure.ListMine; +using PriceNegotiationApp.SharedKernel; +using System.Security.Claims; + +namespace PriceNegotiationApp.Api.Endpoints.Negotiations.ListMine; + +internal static class ListMineEndpoint +{ + internal static void MapListMine(this RouteGroupBuilder group) + { + group.MapGet("/mine", async (ClaimsPrincipal principal, ListMyNegotiationsHandler handler, + CancellationToken ct, int page = 1, int pageSize = 20) => + TypedResults.Ok(await handler.HandleAsync( + new PageQuery(page, pageSize), principal.ToCallerContext(), ct))) + .RequireRoles(UserRoles.Customer); + } +} diff --git a/src/PriceNegotiationApp.Api/Endpoints/Negotiations/NegotiationEndpoints.cs b/src/PriceNegotiationApp.Api/Endpoints/Negotiations/NegotiationEndpoints.cs new file mode 100644 index 0000000..9a9b92b --- /dev/null +++ b/src/PriceNegotiationApp.Api/Endpoints/Negotiations/NegotiationEndpoints.cs @@ -0,0 +1,32 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using PriceNegotiationApp.Api.Endpoints.Negotiations.Accept; +using PriceNegotiationApp.Api.Endpoints.Negotiations.CounterPropose; +using PriceNegotiationApp.Api.Endpoints.Negotiations.Create; +using PriceNegotiationApp.Api.Endpoints.Negotiations.Get; +using PriceNegotiationApp.Api.Endpoints.Negotiations.List; +using PriceNegotiationApp.Api.Endpoints.Negotiations.ListMine; +using PriceNegotiationApp.Api.Endpoints.Negotiations.RejectCurrentOffer; +using PriceNegotiationApp.Api.Endpoints.Negotiations.Withdraw; + +namespace PriceNegotiationApp.Api.Endpoints.Negotiations; + +public static class NegotiationEndpoints +{ + public static IEndpointRouteBuilder MapNegotiationsEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/api/v1/negotiations") + .WithTags("Negotiations") + .RequireAuthorization(); + group.MapCreate(); + group.MapListMine(); + group.MapList(); + group.MapGetOne(); + group.MapCounterPropose(); + group.MapAccept(); + group.MapRejectCurrentOffer(); + group.MapWithdraw(); + return app; + } +} diff --git a/src/PriceNegotiationApp.Api/Endpoints/Negotiations/RejectCurrentOfferEndpoint.cs b/src/PriceNegotiationApp.Api/Endpoints/Negotiations/RejectCurrentOfferEndpoint.cs new file mode 100644 index 0000000..0ec6a54 --- /dev/null +++ b/src/PriceNegotiationApp.Api/Endpoints/Negotiations/RejectCurrentOfferEndpoint.cs @@ -0,0 +1,18 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using PriceNegotiationApp.Modules.Negotiations.Infrastructure.RejectCurrentOffer; +using PriceNegotiationApp.SharedKernel; + +namespace PriceNegotiationApp.Api.Endpoints.Negotiations.RejectCurrentOffer; + +internal static class RejectCurrentOfferEndpoint +{ + internal static void MapRejectCurrentOffer(this RouteGroupBuilder group) + { + group.MapPost("/{id:guid}/decline", async (Guid id, RejectCurrentOfferHandler handler, + CancellationToken ct) => + TypedResults.Ok(await handler.HandleAsync(id, ct))) + .RequireRoles(UserRoles.Admin, UserRoles.Staff); + } +} diff --git a/src/PriceNegotiationApp.Api/Endpoints/Negotiations/WithdrawEndpoint.cs b/src/PriceNegotiationApp.Api/Endpoints/Negotiations/WithdrawEndpoint.cs new file mode 100644 index 0000000..3c3b38e --- /dev/null +++ b/src/PriceNegotiationApp.Api/Endpoints/Negotiations/WithdrawEndpoint.cs @@ -0,0 +1,21 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using PriceNegotiationApp.Modules.Negotiations.Infrastructure.Withdraw; +using PriceNegotiationApp.SharedKernel; +using System.Security.Claims; + +namespace PriceNegotiationApp.Api.Endpoints.Negotiations.Withdraw; + +internal static class WithdrawEndpoint +{ + internal static void MapWithdraw(this RouteGroupBuilder group) + { + group.MapDelete("/{id:guid}", async (Guid id, ClaimsPrincipal principal, + WithdrawHandler handler, CancellationToken ct) => + { + await handler.HandleAsync(id, principal.ToCallerContext(), ct); + return TypedResults.NoContent(); + }); + } +} diff --git a/src/PriceNegotiationApp.Api/Extensions/CorsOriginsGuard.cs b/src/PriceNegotiationApp.Api/Extensions/CorsOriginsGuard.cs new file mode 100644 index 0000000..e41afff --- /dev/null +++ b/src/PriceNegotiationApp.Api/Extensions/CorsOriginsGuard.cs @@ -0,0 +1,19 @@ +namespace PriceNegotiationApp.Api.Extensions; + +public static class CorsOriginsGuard +{ + /// Throws at startup when a configured CORS origin is not an absolute http(s) URI. + public static void EnsureValid(IEnumerable? origins) + { + foreach (var origin in origins ?? []) + { + var valid = Uri.TryCreate(origin, UriKind.Absolute, out var parsed) + && parsed.Scheme is "http" or "https"; + if (!valid) + { + throw new InvalidOperationException( + $"Cors:AllowedOrigins entry '{origin}' is not a valid absolute http(s) URI."); + } + } + } +} diff --git a/src/PriceNegotiationApp.Api/Extensions/PipelineExtensions.cs b/src/PriceNegotiationApp.Api/Extensions/PipelineExtensions.cs new file mode 100644 index 0000000..2338cef --- /dev/null +++ b/src/PriceNegotiationApp.Api/Extensions/PipelineExtensions.cs @@ -0,0 +1,108 @@ +using PriceNegotiationApp.Modules.Identity.Infrastructure; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.AspNetCore.Routing; +using PriceNegotiationApp.Api.Endpoints.Catalog; +using PriceNegotiationApp.Api.Endpoints.Identity; +using PriceNegotiationApp.Modules.Identity.Contracts; +using PriceNegotiationApp.Api.Endpoints.Negotiations; +using Scalar.AspNetCore; +using Serilog; +using Serilog.Events; +using System.Security.Claims; + +namespace PriceNegotiationApp.Api.Extensions; + +public static class PipelineExtensions +{ + public static WebApplication UsePipeline(this WebApplication app) + { + app.UseSerilogRequestLogging(options => + { + // Enrichment runs at response completion, so HttpContext.User is populated. + options.EnrichDiagnosticContext = (diagnosticContext, httpContext) => + { + diagnosticContext.Set("UserId", + httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier)); + diagnosticContext.Set("Roles", string.Join(',', + httpContext.User.FindAll(ClaimTypes.Role).Select(claim => claim.Value))); + diagnosticContext.Set("Endpoint", httpContext.GetEndpoint()?.DisplayName); + diagnosticContext.Set("RemoteIp", + httpContext.Connection.RemoteIpAddress?.ToString()); + }; + options.GetLevel = (httpContext, elapsed, ex) => ex is not null + ? LogEventLevel.Error + : IsInfrastructurePath(httpContext.Request.Path) + ? LogEventLevel.Verbose + : elapsed > 500 ? LogEventLevel.Warning : LogEventLevel.Information; + }); + app.UseStatusCodePages(); + + app.UseExceptionHandler(); + if (!app.Environment.IsDevelopment()) + { + app.UseHsts(); + } + + app.UseHttpsRedirection(); + + app.UseCors(WebApplicationBuilderExtensions.CorsPolicy); + + if (app.Environment.IsDevelopment()) + { + app.MapOpenApi(); + app.MapScalarApiReference(); + } + + app.UseAuthentication(); + app.UseAuthorization(); + app.UseRateLimiter(); + app.UseOutputCache(); + + app.MapModules(); + + return app; + } + + private static bool IsInfrastructurePath(PathString path) => + path.StartsWithSegments("/health", StringComparison.OrdinalIgnoreCase) || + path.StartsWithSegments("/scalar", StringComparison.OrdinalIgnoreCase) || + path.StartsWithSegments("/openapi", StringComparison.OrdinalIgnoreCase) || + path.StartsWithSegments("/.well-known", StringComparison.OrdinalIgnoreCase) || + path.StartsWithSegments("/favicon", StringComparison.OrdinalIgnoreCase); + + internal sealed record JwksResponse(IReadOnlyList Keys); + + // Deliberate DTO: serializes exactly the five public fields, so private material + // can never leak even if JsonWebKey grows properties later. + internal sealed record JwkKey(string Kty, string Crv, string X, string Y, string Kid); + + private static void MapModules(this WebApplication app) + { + app.MapGet("/.well-known/jwks.json", (EcSigningKey signingKey) => TypedResults.Json( + new JwksResponse([new JwkKey( + signingKey.PublicJwk.Kty, + signingKey.PublicJwk.Crv, + signingKey.PublicJwk.X, + signingKey.PublicJwk.Y, + signingKey.Kid)]))) + .AllowAnonymous(); + + app.MapHealthChecks("/health/live", new HealthCheckOptions { Predicate = r => r.Tags.Contains("live") }); + app.MapHealthChecks("/health/ready", new HealthCheckOptions + { + Predicate = r => r.Tags.Contains("ready"), + ResponseWriter = ReadyHealthReport.WriteAsync, + }); + app.MapAuthEndpoints(); + app.MapCatalogEndpoints(); + app.MapNegotiationsEndpoints(); + } +} + + + + + + + + diff --git a/src/PriceNegotiationApp.Api/Extensions/RateLimitingOptions.cs b/src/PriceNegotiationApp.Api/Extensions/RateLimitingOptions.cs new file mode 100644 index 0000000..749ab9b --- /dev/null +++ b/src/PriceNegotiationApp.Api/Extensions/RateLimitingOptions.cs @@ -0,0 +1,9 @@ +namespace PriceNegotiationApp.Api.Extensions; + +public sealed class RateLimitingOptions +{ + public const string SectionName = "RateLimiting"; + + public int AuthPermitLimit { get; init; } = 30; +} + diff --git a/src/PriceNegotiationApp.Api/Extensions/RateLimitingOptionsValidator.cs b/src/PriceNegotiationApp.Api/Extensions/RateLimitingOptionsValidator.cs new file mode 100644 index 0000000..277bd39 --- /dev/null +++ b/src/PriceNegotiationApp.Api/Extensions/RateLimitingOptionsValidator.cs @@ -0,0 +1,11 @@ +using Microsoft.Extensions.Options; + +namespace PriceNegotiationApp.Api.Extensions; + +public sealed class RateLimitingOptionsValidator : IValidateOptions +{ + public ValidateOptionsResult Validate(string? name, RateLimitingOptions options) => + options.AuthPermitLimit >= 1 + ? ValidateOptionsResult.Success + : ValidateOptionsResult.Fail($"{RateLimitingOptions.SectionName}:AuthPermitLimit must be >= 1."); +} diff --git a/src/PriceNegotiationApp.Api/Extensions/WebApplicationBuilderExtensions.cs b/src/PriceNegotiationApp.Api/Extensions/WebApplicationBuilderExtensions.cs new file mode 100644 index 0000000..f77a36e --- /dev/null +++ b/src/PriceNegotiationApp.Api/Extensions/WebApplicationBuilderExtensions.cs @@ -0,0 +1,141 @@ +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.RateLimiting; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Resources; +using OpenTelemetry.Trace; +using PriceNegotiationApp.Modules.Catalog.Infrastructure; +using PriceNegotiationApp.Modules.Catalog.Infrastructure.Persistence; +using PriceNegotiationApp.Modules.Identity.Infrastructure; +using PriceNegotiationApp.Modules.Identity.Contracts; +using PriceNegotiationApp.Modules.Identity.Infrastructure.Persistence; +using PriceNegotiationApp.Modules.Negotiations.Infrastructure; +using PriceNegotiationApp.Modules.Negotiations.Infrastructure.Persistence; +using PriceNegotiationApp.SharedKernel; +using Scalar.AspNetCore; +using Serilog; +using System.Text; +using System.Threading.RateLimiting; + +namespace PriceNegotiationApp.Api.Extensions; + +public static class WebApplicationBuilderExtensions +{ + public const string CorsPolicy = "api"; + + public static WebApplicationBuilder AddApiServices(this WebApplicationBuilder builder) + { + var configuration = builder.Configuration; + + // Migrations must run before any module seeder (hosted services start in registration order). + builder.Services.AddHostedService(); + + builder.Host.UseSerilog((context, _, logConfiguration) => logConfiguration + .ReadFrom.Configuration(context.Configuration) + .Enrich.FromLogContext() + .WriteTo.Console() + .WriteTo.File(Path.Combine("logs", "api-.log"), rollingInterval: RollingInterval.Day)); + + builder.Services.AddIdentityModule(configuration); + builder.Services.AddCatalogModule(configuration); + builder.Services.AddNegotiationsModule(configuration); + builder.Services.AddScoped(); + + builder.Services.AddProblemDetails(options => + options.CustomizeProblemDetails = context => + context.ProblemDetails.Extensions.TryAdd("traceId", context.HttpContext.TraceIdentifier)) + .AddExceptionHandler(); + + builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(); + builder.Services.AddOptions(JwtBearerDefaults.AuthenticationScheme) + .Configure, EcSigningKey>((bearer, jwt, signingKey) => + { + bearer.MapInboundClaims = true; + bearer.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidIssuer = jwt.Value.Issuer, + ValidateAudience = true, + ValidAudience = jwt.Value.Audience, + ValidateIssuerSigningKey = true, + IssuerSigningKey = signingKey.PublicJwk, + ValidAlgorithms = [EcSigningKey.Algorithm], + ValidateLifetime = true, + ClockSkew = TimeSpan.FromMinutes(1), + }; + }); + builder.Services.AddAuthorization(); + + // Accepts all configuration shapes: JSON array, indexed keys + // ("Cors:AllowedOrigins:0"), or one flat comma-separated value. + var corsOrigins = configuration.GetSection("Cors:AllowedOrigins"); + var origins = corsOrigins.GetChildren().Select(child => child.Value).OfType().ToList(); + if (origins.Count == 0 && corsOrigins.Value is { } flatValue) + { + origins.AddRange(flatValue.Split(',', + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)); + } + + CorsOriginsGuard.EnsureValid(origins); + builder.Services.AddCors(options => options.AddPolicy(CorsPolicy, policy => + policy.WithOrigins([.. origins]).AllowAnyHeader().AllowAnyMethod())); + + var rateLimits = configuration.GetSection(RateLimitingOptions.SectionName).Get() + ?? new RateLimitingOptions(); + builder.Services.AddOptions() + .Bind(configuration.GetSection(RateLimitingOptions.SectionName)) + .ValidateOnStart(); + builder.Services.AddSingleton, + RateLimitingOptionsValidator>(); + builder.Services.AddRateLimiter(options => + { + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + options.AddFixedWindowLimiter(Policies.AuthRateLimitPolicy, windowOptions => + { + windowOptions.PermitLimit = rateLimits.AuthPermitLimit; + windowOptions.Window = TimeSpan.FromMinutes(1); + windowOptions.QueueLimit = 0; + }); + }); + + builder.Services.AddOutputCache(options => options.AddPolicy(Policies.ShortCachePolicy, + policy => policy.Expire(TimeSpan.FromSeconds(30)) + .SetVaryByQuery("search", "minPrice", "maxPrice", "sortBy", "sortDesc", "page", "pageSize") + .SetVaryByHeader("Origin"))); + + builder.Services.AddHealthChecks() + .AddCheck("self", () => HealthCheckResult.Healthy(), tags: ["live"]) + .AddDbContextCheck("database-identity", tags: ["ready"]) + .AddDbContextCheck("database-catalog", tags: ["ready"]) + .AddDbContextCheck("database-negotiations", tags: ["ready"]); + + builder.Services.AddOpenApi(); + + // Telemetry ships only when a consumer is configured (Aspire dashboard overlay, + // Grafana stack, or any OTLP endpoint). Prevents endless export retries against + // localhost:4317 where nothing listens. + var otlpEndpoint = configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]; + if (!string.IsNullOrEmpty(otlpEndpoint)) + { + builder.Services.AddOpenTelemetry() + .ConfigureResource(resource => resource.AddService("PriceNegotiationApp.Api")) + .WithTracing(tracing => tracing + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation()) + .WithMetrics(metrics => metrics + .AddAspNetCoreInstrumentation() + .AddRuntimeInstrumentation()) + .UseOtlpExporter(); + } + + return builder; + } +} + diff --git a/src/PriceNegotiationApp.Api/GlobalExceptionHandler.cs b/src/PriceNegotiationApp.Api/GlobalExceptionHandler.cs new file mode 100644 index 0000000..f709925 --- /dev/null +++ b/src/PriceNegotiationApp.Api/GlobalExceptionHandler.cs @@ -0,0 +1,77 @@ +using PriceNegotiationApp.Modules.Negotiations.Contracts; +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using PriceNegotiationApp.Modules.Negotiations.Domain; +using PriceNegotiationApp.Modules.Negotiations.Application; +using PriceNegotiationApp.SharedKernel; +using Vogen; + +namespace PriceNegotiationApp.Api; + +public sealed class GlobalExceptionHandler( + IProblemDetailsService problemDetailsService, + IHostEnvironment environment, + ILogger logger) + : IExceptionHandler +{ + // HTTP status semantics used below: + // 400 — the request could not be understood (handled by framework binding; nothing maps here). + // 401/403/404 — authentication, authorization, missing resource. + // 409 — well-formed request that conflicts with the current persistent state. + // 422 — well-formed request whose payload fails input/business validation. + public async ValueTask TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken) + { + if (exception is not OperationCanceledException) + { + logger.LogError(exception, "Unhandled exception while processing {Method} {Path}", + httpContext.Request.Method, httpContext.Request.Path); + } + + var (status, title, code) = exception switch + { + // 422 Unprocessable Content — payload fails validation or business input rules + ProposalExceedsLimitException => (StatusCodes.Status422UnprocessableEntity, "Proposal rejected", NegotiationErrorCodes.ProposalExceedsLimit), + ValueObjectValidationException => (StatusCodes.Status422UnprocessableEntity, "Invalid value", ErrorCodes.ValidationFailed), + InvalidRequestException invalidRequest => (StatusCodes.Status422UnprocessableEntity, "Invalid request", invalidRequest.Code), + + // 409 Conflict — request collides with current persistent state + ConflictException conflict => (StatusCodes.Status409Conflict, "Conflict", conflict.Code), + ClosedNegotiationException => (StatusCodes.Status409Conflict, "Business rule violated", NegotiationErrorCodes.NegotiationClosed), + + // 409 — another writer committed this aggregate first (xmin token fired) + DbUpdateConcurrencyException => (StatusCodes.Status409Conflict, "Resource changed meanwhile", ErrorCodes.ConcurrencyConflict), + + // remaining domain exceptions are input-validation failures + DomainException => (StatusCodes.Status422UnprocessableEntity, "Business rule violated", ErrorCodes.DomainRuleViolated), + + NotFoundException notFound => (StatusCodes.Status404NotFound, "Resource not found", notFound.Code), + ForbiddenAccessException => (StatusCodes.Status403Forbidden, "Forbidden", ErrorCodes.Forbidden), + UnauthorizedException unauthorized => (StatusCodes.Status401Unauthorized, "Authentication failed", unauthorized.Code), + OperationCanceledException when httpContext.RequestAborted.IsCancellationRequested + => (499, "Request cancelled", "client_closed_request"), + _ => (StatusCodes.Status500InternalServerError, "Unexpected error", ErrorCodes.InternalError), + }; + + httpContext.Response.StatusCode = status; + return await problemDetailsService.TryWriteAsync(new ProblemDetailsContext + { + HttpContext = httpContext, + ProblemDetails = new ProblemDetails + { + Status = status, + Title = title, + Detail = environment.IsDevelopment() && exception is not OperationCanceledException ? exception.Message : null, + Extensions = { ["code"] = code }, + }, + }); + } +} + + + + + + + diff --git a/src/PriceNegotiationApp.Api/PriceNegotiationApp.Api.csproj b/src/PriceNegotiationApp.Api/PriceNegotiationApp.Api.csproj new file mode 100644 index 0000000..0baa03f --- /dev/null +++ b/src/PriceNegotiationApp.Api/PriceNegotiationApp.Api.csproj @@ -0,0 +1,41 @@ + + + true + true + + $(NoWarn);S1118;1591 + $(MSBuildThisFileDirectory)../../artifacts/openapi + true + false + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + diff --git a/src/PriceNegotiationApp.Api/Program.cs b/src/PriceNegotiationApp.Api/Program.cs new file mode 100644 index 0000000..233600f --- /dev/null +++ b/src/PriceNegotiationApp.Api/Program.cs @@ -0,0 +1,9 @@ +using PriceNegotiationApp.Api.Extensions; + +var builder = WebApplication.CreateBuilder(args); +builder.AddApiServices(); + +var app = builder.Build(); +app.UsePipeline(); + +await app.RunAsync(); diff --git a/src/PriceNegotiationApp.Api/Properties/launchSettings.json b/src/PriceNegotiationApp.Api/Properties/launchSettings.json new file mode 100644 index 0000000..653ff81 --- /dev/null +++ b/src/PriceNegotiationApp.Api/Properties/launchSettings.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "launchBrowser": false, + "applicationUrl": "http://localhost:5185", + "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } + }, + "https": { + "commandName": "Project", + "launchBrowser": false, + "applicationUrl": "https://localhost:7004;http://localhost:5185", + "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } + } + } +} diff --git a/src/PriceNegotiationApp.Api/ReadyHealthReport.cs b/src/PriceNegotiationApp.Api/ReadyHealthReport.cs new file mode 100644 index 0000000..14047c5 --- /dev/null +++ b/src/PriceNegotiationApp.Api/ReadyHealthReport.cs @@ -0,0 +1,40 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; + +namespace PriceNegotiationApp.Api; + +/// +/// JSON body for /health/ready naming every dependency and its verdict. +/// Failure detail goes to logs only; the anonymous body stays free of it. +/// +public static class ReadyHealthReport +{ + public static async Task WriteAsync(HttpContext context, HealthReport report) + { + var logger = context.RequestServices + .GetRequiredService() + .CreateLogger(nameof(ReadyHealthReport)); + + foreach (var (name, entry) in report.Entries.Where(e => e.Value.Status != HealthStatus.Healthy)) + { + logger.LogWarning("Readiness check '{Check}' is unhealthy: {Detail}", + name, entry.Description ?? entry.Exception?.Message); + } + + var payload = new + { + status = report.Status.ToString(), + totalDurationMs = report.TotalDuration.TotalMilliseconds, + entries = report.Entries.ToDictionary( + entry => entry.Key, + entry => new + { + status = entry.Value.Status.ToString(), + durationMs = entry.Value.Duration.TotalMilliseconds, + }), + }; + + await context.Response.WriteAsJsonAsync(payload); + } +} diff --git a/src/PriceNegotiationApp.Api/ValidateRequestFilter.cs b/src/PriceNegotiationApp.Api/ValidateRequestFilter.cs new file mode 100644 index 0000000..7d513d3 --- /dev/null +++ b/src/PriceNegotiationApp.Api/ValidateRequestFilter.cs @@ -0,0 +1,48 @@ +using FluentValidation; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using PriceNegotiationApp.SharedKernel; + +namespace PriceNegotiationApp.Api; + +internal sealed class ValidateRequestFilter : IEndpointFilter where TRequest : class +{ + public async ValueTask InvokeAsync( + EndpointFilterInvocationContext context, EndpointFilterDelegate next) + { + var request = context.Arguments.OfType().FirstOrDefault(); + if (request is null) + { + return await next(context); + } + + var validator = context.HttpContext.RequestServices.GetService>(); + if (validator is null) + { + return await next(context); + } + + var result = await validator.ValidateAsync(request, context.HttpContext.RequestAborted); + if (result.IsValid) + { + return await next(context); + } + + var errors = result.Errors + .GroupBy(e => e.PropertyName) + .ToDictionary( + g => g.Key, + g => g.Select(e => e.ErrorMessage).ToArray()); + + return Results.UnprocessableEntity(new Microsoft.AspNetCore.Mvc.ProblemDetails + { + Status = StatusCodes.Status422UnprocessableEntity, + Title = "Invalid request", + Extensions = + { + ["code"] = ErrorCodes.ValidationFailed, + ["errors"] = errors, + }, + }); + } +} diff --git a/src/PriceNegotiationApp.Api/appsettings.json b/src/PriceNegotiationApp.Api/appsettings.json new file mode 100644 index 0000000..75d3bbf --- /dev/null +++ b/src/PriceNegotiationApp.Api/appsettings.json @@ -0,0 +1,24 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "Cors": { + "AllowedOrigins": [] + }, + "Jwt": { + "Issuer": "", + "Audience": "", + "PrivateKey": "", + "ExpiryMinutes": 60 + }, + "Database": { + "ConnectionString": "" + }, + "Seeding": { + "SeedSampleProducts": false + } +} diff --git a/src/PriceNegotiationApp.SharedKernel/CallerContext.cs b/src/PriceNegotiationApp.SharedKernel/CallerContext.cs new file mode 100644 index 0000000..000fffa --- /dev/null +++ b/src/PriceNegotiationApp.SharedKernel/CallerContext.cs @@ -0,0 +1,12 @@ +namespace PriceNegotiationApp.SharedKernel; + +public sealed record CallerContext(Guid UserId, string Email, IReadOnlySet Roles) +{ + private static readonly IReadOnlySet EmptyRoles = new HashSet(); + + public static readonly CallerContext Anonymous = new(Guid.Empty, string.Empty, EmptyRoles); + + public bool IsAuthenticated => UserId != Guid.Empty; + + public bool IsInRole(string role) => Roles.Contains(role); +} diff --git a/src/PriceNegotiationApp.SharedKernel/CallerContextExtensions.cs b/src/PriceNegotiationApp.SharedKernel/CallerContextExtensions.cs new file mode 100644 index 0000000..22b5eb2 --- /dev/null +++ b/src/PriceNegotiationApp.SharedKernel/CallerContextExtensions.cs @@ -0,0 +1,19 @@ +using System.Security.Claims; + +namespace PriceNegotiationApp.SharedKernel; + +public static class CallerContextExtensions +{ + public static CallerContext ToCallerContext(this ClaimsPrincipal principal) + { + if (principal.Identity?.IsAuthenticated != true) + { + return CallerContext.Anonymous; + } + + _ = Guid.TryParse(principal.FindFirstValue(ClaimTypes.NameIdentifier), out var userId); + var email = principal.FindFirstValue(ClaimTypes.Email) ?? string.Empty; + var roles = principal.FindAll(ClaimTypes.Role).Select(c => c.Value).ToHashSet(); + return new CallerContext(userId, email, roles); + } +} diff --git a/src/PriceNegotiationApp.SharedKernel/DbConnections.cs b/src/PriceNegotiationApp.SharedKernel/DbConnections.cs new file mode 100644 index 0000000..0056838 --- /dev/null +++ b/src/PriceNegotiationApp.SharedKernel/DbConnections.cs @@ -0,0 +1,22 @@ +using Microsoft.Extensions.Configuration; + +namespace PriceNegotiationApp.SharedKernel; + +public static class DbConnections +{ + private const string DefaultKey = "Database:ConnectionString"; + + /// Per-module override wins; falls back to the shared connection string. + public static string Resolve(IConfiguration configuration, string moduleName) + { + var moduleOverride = configuration[$"Database:Modules:{moduleName}:ConnectionString"]; + if (!string.IsNullOrWhiteSpace(moduleOverride)) + { + return moduleOverride; + } + + return configuration[DefaultKey] + ?? throw new InvalidOperationException( + $"{DefaultKey} is not configured (module '{moduleName}')."); + } +} diff --git a/src/PriceNegotiationApp.SharedKernel/DbWriteGuard.cs b/src/PriceNegotiationApp.SharedKernel/DbWriteGuard.cs new file mode 100644 index 0000000..024d50a --- /dev/null +++ b/src/PriceNegotiationApp.SharedKernel/DbWriteGuard.cs @@ -0,0 +1,40 @@ +using Microsoft.EntityFrameworkCore; +using Npgsql; + +namespace PriceNegotiationApp.SharedKernel; + +/// +/// Translates PostgreSQL uniqueness violations raised during SaveChanges into +/// caller-supplied semantic exceptions, so check-then-insert races surface as +/// conflicts instead of HTTP 500. +/// +public static class DbWriteGuard +{ + public static bool IsUniqueViolation(Exception exception, out string constraintName) + { + constraintName = string.Empty; + for (var current = (Exception?)exception; current is not null; current = current.InnerException) + { + if (current is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation } postgres) + { + constraintName = postgres.ConstraintName ?? string.Empty; + return true; + } + } + + return false; + } + + public static async Task SaveOrConflictAsync( + this DbContext db, Func conflictFactory, CancellationToken ct) + { + try + { + await db.SaveChangesAsync(ct); + } + catch (DbUpdateException ex) when (IsUniqueViolation(ex, out var constraint)) + { + throw conflictFactory(constraint); + } + } +} diff --git a/src/PriceNegotiationApp.SharedKernel/DesignTimeDbContextFactoryBase.cs b/src/PriceNegotiationApp.SharedKernel/DesignTimeDbContextFactoryBase.cs new file mode 100644 index 0000000..52df187 --- /dev/null +++ b/src/PriceNegotiationApp.SharedKernel/DesignTimeDbContextFactoryBase.cs @@ -0,0 +1,30 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace PriceNegotiationApp.SharedKernel; + +/// +/// Common plumbing for EF Core design-time factories. Provider configuration stays in each +/// module on purpose: Npgsql must not become a SharedKernel dependency. +/// +public abstract class DesignTimeDbContextFactoryBase : IDesignTimeDbContextFactory + where TContext : DbContext +{ +#pragma warning disable S2068 // Design-time default only; never used in production wiring. + protected const string LocalConnectionString = + "Host=localhost;Port=5432;Database=pricenego_design;Username=postgres;Password=postgres"; +#pragma warning restore S2068 + + public TContext CreateDbContext(string[] args) + { + var builder = new DbContextOptionsBuilder(); + Configure(builder); + return Create(builder.Options); + } + + /// Apply provider options, e.g. UseNpgsql(LocalConnectionString, ...) plus naming conventions. + protected abstract void Configure(DbContextOptionsBuilder builder); + + /// Create the context instance, typically `new TContext(options)`. + protected abstract TContext Create(DbContextOptions options); +} diff --git a/src/PriceNegotiationApp.SharedKernel/EndpointConventionExtensions.cs b/src/PriceNegotiationApp.SharedKernel/EndpointConventionExtensions.cs new file mode 100644 index 0000000..eafd777 --- /dev/null +++ b/src/PriceNegotiationApp.SharedKernel/EndpointConventionExtensions.cs @@ -0,0 +1,11 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Builder; + +namespace PriceNegotiationApp.SharedKernel; + +public static class EndpointConventionExtensions +{ + public static TBuilder RequireRoles(this TBuilder builder, params string[] roles) + where TBuilder : IEndpointConventionBuilder => + builder.RequireAuthorization(new AuthorizeAttribute { Roles = string.Join(',', roles) }); +} diff --git a/src/PriceNegotiationApp.SharedKernel/ErrorCodes.cs b/src/PriceNegotiationApp.SharedKernel/ErrorCodes.cs new file mode 100644 index 0000000..c3cab31 --- /dev/null +++ b/src/PriceNegotiationApp.SharedKernel/ErrorCodes.cs @@ -0,0 +1,14 @@ +namespace PriceNegotiationApp.SharedKernel; + +public static class ErrorCodes +{ + public const string Forbidden = "forbidden"; + + public const string ConcurrencyConflict = "concurrency_conflict"; + + public const string ValidationFailed = "validation_failed"; + + public const string DomainRuleViolated = "domain_rule_violated"; + + public const string InternalError = "internal_error"; +} diff --git a/src/PriceNegotiationApp.SharedKernel/Exceptions.cs b/src/PriceNegotiationApp.SharedKernel/Exceptions.cs new file mode 100644 index 0000000..58939b8 --- /dev/null +++ b/src/PriceNegotiationApp.SharedKernel/Exceptions.cs @@ -0,0 +1,27 @@ +namespace PriceNegotiationApp.SharedKernel; + +public sealed class ConflictException(string code, string message) : Exception(message) +{ + public string Code { get; } = code; +} + +public sealed class NotFoundException(string entityName, object key) + : Exception($"{entityName} '{key}' was not found.") +{ + public string Code { get; } = $"{entityName.ToLowerInvariant().Replace(" ", string.Empty)}_not_found"; +} + +/// Request payload rejected before any business state was touched. +public sealed class InvalidRequestException(string code, string message) : Exception(message) +{ + public string Code { get; } = code; +} + +public sealed class UnauthorizedException(string code, string message) : Exception(message) +{ + public string Code { get; } = code; +} + +public sealed class ForbiddenAccessException() : Exception("Access to the requested resource is forbidden."); + +public class DomainException(string message) : Exception(message); diff --git a/src/PriceNegotiationApp.SharedKernel/ModuleSeedingHostedServiceBase.cs b/src/PriceNegotiationApp.SharedKernel/ModuleSeedingHostedServiceBase.cs new file mode 100644 index 0000000..f3bd24f --- /dev/null +++ b/src/PriceNegotiationApp.SharedKernel/ModuleSeedingHostedServiceBase.cs @@ -0,0 +1,21 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace PriceNegotiationApp.SharedKernel; + +/// +/// Runs a module's seed routine once at host start inside a scope that is disposed afterwards. +/// +public abstract class ModuleSeedingHostedServiceBase(IServiceScopeFactory scopeFactory) : IHostedService +{ + public async Task StartAsync(CancellationToken cancellationToken) + { + await using var scope = scopeFactory.CreateAsyncScope(); + await SeedAsync(scope.ServiceProvider, cancellationToken); + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + /// Seed the module's data. Resolve services from . + protected abstract Task SeedAsync(IServiceProvider services, CancellationToken cancellationToken); +} diff --git a/src/PriceNegotiationApp.SharedKernel/PageQuery.cs b/src/PriceNegotiationApp.SharedKernel/PageQuery.cs new file mode 100644 index 0000000..510bfef --- /dev/null +++ b/src/PriceNegotiationApp.SharedKernel/PageQuery.cs @@ -0,0 +1,10 @@ +namespace PriceNegotiationApp.SharedKernel; + +public sealed record PageQuery(int Page, int PageSize) +{ + public int SafePage => Math.Max(1, Page); + + public int SafePageSize => Math.Clamp(PageSize, 1, 100); + + public int Skip => (SafePage - 1) * SafePageSize; +} diff --git a/src/PriceNegotiationApp.SharedKernel/PagedResult.cs b/src/PriceNegotiationApp.SharedKernel/PagedResult.cs new file mode 100644 index 0000000..2765e08 --- /dev/null +++ b/src/PriceNegotiationApp.SharedKernel/PagedResult.cs @@ -0,0 +1,3 @@ +namespace PriceNegotiationApp.SharedKernel; + +public sealed record PagedResult(IReadOnlyList Items, int Page, int PageSize, long TotalCount); diff --git a/src/PriceNegotiationApp.SharedKernel/Policies.cs b/src/PriceNegotiationApp.SharedKernel/Policies.cs new file mode 100644 index 0000000..f64d177 --- /dev/null +++ b/src/PriceNegotiationApp.SharedKernel/Policies.cs @@ -0,0 +1,9 @@ +namespace PriceNegotiationApp.SharedKernel; + +/// Shared policy names so host registrations and module endpoint annotations agree. +public static class Policies +{ + public const string AuthRateLimitPolicy = "auth"; + + public const string ShortCachePolicy = "short"; +} diff --git a/src/PriceNegotiationApp.SharedKernel/PriceNegotiationApp.SharedKernel.csproj b/src/PriceNegotiationApp.SharedKernel/PriceNegotiationApp.SharedKernel.csproj new file mode 100644 index 0000000..5a0b873 --- /dev/null +++ b/src/PriceNegotiationApp.SharedKernel/PriceNegotiationApp.SharedKernel.csproj @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/PriceNegotiationApp.SharedKernel/UserRoles.cs b/src/PriceNegotiationApp.SharedKernel/UserRoles.cs new file mode 100644 index 0000000..57e5feb --- /dev/null +++ b/src/PriceNegotiationApp.SharedKernel/UserRoles.cs @@ -0,0 +1,11 @@ +namespace PriceNegotiationApp.SharedKernel; + +/// Role-name contract shared by host authorization policies and module endpoint gates. +public static class UserRoles +{ + public const string Admin = "Admin"; + + public const string Staff = "Staff"; + + public const string Customer = "Customer"; +} diff --git a/tests/PriceNegotiationApp.ArchitectureTests/ArchitectureShould.cs b/tests/PriceNegotiationApp.ArchitectureTests/ArchitectureShould.cs new file mode 100644 index 0000000..6504fea --- /dev/null +++ b/tests/PriceNegotiationApp.ArchitectureTests/ArchitectureShould.cs @@ -0,0 +1,193 @@ +using ArchUnitNET.Domain; +using ArchUnitNET.Fluent; +using ArchUnitNET.Loader; +using ArchUnitNET.xUnitV3; +using PriceNegotiationApp.Api; +using PriceNegotiationApp.Modules.Catalog.Contracts; +using PriceNegotiationApp.Modules.Catalog.Infrastructure; +using PriceNegotiationApp.Modules.Identity.Contracts; +using PriceNegotiationApp.Modules.Identity.Infrastructure; +using PriceNegotiationApp.Modules.Negotiations.Contracts; +using PriceNegotiationApp.Modules.Negotiations.Infrastructure; +using PriceNegotiationApp.SharedKernel; +using Shouldly; +using Xunit; +using static ArchUnitNET.Fluent.ArchRuleDefinition; + +namespace PriceNegotiationApp.ArchitectureTests; + +/// +/// Executable architecture rules: the composition root is the only cross-module edge, +/// the kernel depends on nobody, and domain namespaces stay persistence-free. +/// Each rule throws an ArchRuleException listing every violating type on failure. +/// +public class ArchitectureShould +{ + private const string Api = "PriceNegotiationApp.Api"; + private const string Kernel = "PriceNegotiationApp.SharedKernel"; + private const string Catalog = "PriceNegotiationApp.Modules.Catalog"; + private const string Identity = "PriceNegotiationApp.Modules.Identity"; + private const string Negotiations = "PriceNegotiationApp.Modules.Negotiations"; + + private static readonly Architecture Architecture = new ArchLoader() + .LoadAssemblies( + typeof(ProductSnapshot).Assembly, + typeof(IdentityErrorCodes).Assembly, + typeof(NegotiationErrorCodes).Assembly, + typeof(ErrorCodes).Assembly, + typeof(GlobalExceptionHandler).Assembly, + typeof(CatalogModule).Assembly, + typeof(IdentityModule).Assembly, + typeof(NegotiationsModule).Assembly) + .Build(); + + private static readonly IObjectProvider KernelTypes = + Types().That().ResideInAssembly(typeof(ErrorCodes).Assembly).As("shared kernel"); + + private static readonly IObjectProvider CatalogTypes = + Types().That().ResideInAssembly(typeof(CatalogModule).Assembly).As("catalog module"); + + private static readonly IObjectProvider IdentityTypes = + Types().That().ResideInAssembly(typeof(IdentityModule).Assembly).As("identity module"); + + private static readonly IObjectProvider NegotiationsTypes = + Types().That().ResideInAssembly(typeof(NegotiationsModule).Assembly).As("negotiations module"); + + private static readonly IObjectProvider CompositionRoot = + Types().That().ResideInAssembly(typeof(GlobalExceptionHandler).Assembly).As("composition root"); + + private static readonly IObjectProvider EntityFramework = + Types().That().ResideInNamespace("Microsoft.EntityFrameworkCore").As("EF Core"); + + private static readonly IObjectProvider CatalogPersistence = + Types().That().ResideInNamespace($"{Catalog}.Infrastructure.Persistence").As("catalog persistence"); + + private static readonly IObjectProvider NegotiationsPersistence = + Types().That().ResideInNamespace($"{Negotiations}.Infrastructure.Persistence").As("negotiations persistence"); + + private static readonly IObjectProvider PersistenceNamespaces = + Types().That().ResideInNamespace($"{Catalog}.Infrastructure.Persistence") + .Or().ResideInNamespace($"{Negotiations}.Infrastructure.Persistence") + .As("persistence namespaces"); + + [Fact] + public void Catalog_module_never_references_other_modules_or_the_composition_root() => + Types().That().Are(CatalogTypes) + .Should().NotDependOnAny(AnyOf(IdentityTypes, NegotiationsTypes, CompositionRoot)) + .Check(Architecture); + + [Fact] + public void Identity_module_never_references_other_modules_or_the_composition_root() => + Types().That().Are(IdentityTypes) + .Should().NotDependOnAny(AnyOf(CatalogTypes, NegotiationsTypes, CompositionRoot)) + .Check(Architecture); + + [Fact] + public void Negotiations_module_depends_on_catalog_ports_only() + { + // Negotiations must not depend on Identity, CompositionRoot, or Catalog internals + var forbidden = Types().That().Are(IdentityTypes).As("identity module") + .Or().Are(CompositionRoot).As("composition root") + .Or().ResideInNamespace($"{Catalog}.Infrastructure.Persistence").As("catalog persistence") + .Or().ResideInNamespace($"{Catalog}.Infrastructure.Seeding").As("catalog seeding"); + + Types().That().Are(NegotiationsTypes) + .Should().NotDependOnAny(forbidden) + .Check(Architecture); + } + + [Fact] + public void Shared_kernel_depends_on_nothing_above_itself() => + Types().That().Are(KernelTypes) + .Should().NotDependOnAny(AnyOf(CatalogTypes, IdentityTypes, NegotiationsTypes, CompositionRoot)) + .Check(Architecture); + + [Fact] + public void Catalog_infrastructure_persistence_isolation() + { + // Persistence types must not depend on EF Core directly (DbContext handles it) + var catalogPersistenceTypes = Types().That().ResideInNamespace($"{Catalog}.Infrastructure.Persistence"); + catalogPersistenceTypes.Should().NotDependOnAny(EntityFramework).Check(Architecture); + } + + [Fact] + public void Negotiations_infrastructure_persistence_isolation() + { + var negotiationsPersistenceTypes = Types().That().ResideInNamespace($"{Negotiations}.Infrastructure.Persistence"); + negotiationsPersistenceTypes.Should().NotDependOnAny(EntityFramework).Check(Architecture); + } + + [Fact] + public void Port_contracts_stay_persistence_free() + { + var catalogPorts = Types().That().ResideInNamespace($"{Catalog}.Contracts").As("catalog ports"); + + catalogPorts.Should().NotDependOnAny(PersistenceNamespaces).Check(Architecture); + } + + [Fact] + public void Repository_ceremony_stays_out_of_the_codebase() + { + // F-05 doctrine: module DbContext is the unit of work, DbSet the aggregate + // collection. A repository layer re-introduces ceremony without payoff here. + var repositories = Types().That().HaveFullNameContaining("Repository"); + + repositories.GetObjects(Architecture).ShouldBeEmpty( + "repository-style types must not appear; use the module DbContext directly"); + } + + [Fact] + public void Endpoint_mapping_types_stay_transport_only() + { + var endpoints = Types().That().HaveFullNameEndingWith("Endpoints").As("endpoint mapping types"); + + endpoints.Should().NotDependOnAny(EntityFramework).Check(Architecture); + endpoints.Should().NotDependOnAny(PersistenceNamespaces).Check(Architecture); + } + + [Fact] + public void Only_handlers_seeding_and_the_write_guard_commit_the_unit_of_work() + { + // F-05 doctrine, enforcement side: the single commit point lives in the + // owning handler (or the seeding services / write guard / provisioning + // helper inside the create-flow transaction). Nothing else may flush. + var suffixAllowList = new[] { "Handler.cs", "SeedingHostedService.cs" }; + var nameAllowList = new[] { "DbWriteGuard.cs", "NegotiationAccess.cs" }; + + var offenders = Directory + .EnumerateFiles(Path.Combine(FindRepoRoot(), "src"), "*.cs", SearchOption.AllDirectories) + .Where(path => !path.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}") + && !path.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}") + && File.ReadAllText(path).Contains(".SaveChangesAsync(", StringComparison.Ordinal) + && !suffixAllowList.Any(path.EndsWith) + && !nameAllowList.Contains(Path.GetFileName(path))) + .Select(path => Path.GetRelativePath(FindRepoRoot(), path)) + .ToList(); + + offenders.ShouldBeEmpty( + "SaveChangesAsync commits belong to feature handlers, seeding services, " + + "DbWriteGuard, or NegotiationAccess provisioning"); + } + + private static string FindRepoRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "PriceNegotiationApp.slnx"))) + { + directory = directory.Parent; + } + + return directory!.FullName; + } + + private static IObjectProvider AnyOf(params IObjectProvider[] sets) + { + var clause = Types().That().Are(sets[0]); + for (var i = 1; i < sets.Length; i++) + { + clause = clause.Or().Are(sets[i]); + } + + return clause.As(string.Join(" or ", sets.Select(s => s.Description))); + } +} diff --git a/tests/PriceNegotiationApp.ArchitectureTests/PriceNegotiationApp.ArchitectureTests.csproj b/tests/PriceNegotiationApp.ArchitectureTests/PriceNegotiationApp.ArchitectureTests.csproj new file mode 100644 index 0000000..8899a8c --- /dev/null +++ b/tests/PriceNegotiationApp.ArchitectureTests/PriceNegotiationApp.ArchitectureTests.csproj @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/PriceNegotiationApp.IntegrationTests/AuthFlowShould.cs b/tests/PriceNegotiationApp.IntegrationTests/AuthFlowShould.cs new file mode 100644 index 0000000..b6df8a9 --- /dev/null +++ b/tests/PriceNegotiationApp.IntegrationTests/AuthFlowShould.cs @@ -0,0 +1,114 @@ +using PriceNegotiationApp.IntegrationTests.Support; +using PriceNegotiationApp.TestKit; +using Shouldly; +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Xunit; + +namespace PriceNegotiationApp.IntegrationTests; + +[Collection(ApiCollection.Name)] +public class AuthFlowShould(IntegrationTestFixture fixture) +{ + [Fact] + public async Task Register_login_and_read_current_user() + { + var session = await fixture.CreateUserAsync(); + + var me = await session.Client.GetAsync("/api/v1/auth/me", TestContext.Current.CancellationToken); + + me.StatusCode.ShouldBe(HttpStatusCode.OK); + var user = await me.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + user!.Email.ShouldBe(session.Email); + user.Roles.ShouldContain("Customer"); + } + + [Fact] + public async Task Duplicate_registration_conflicts() + { + var email = Fuzz.UniqueEmail(); + var password = Fuzz.Password(); + var body = new RegisterRequest { Email = email, Password = password }; + + var first = await fixture.Anonymous.PostAsJsonAsync("/api/v1/auth/register", body, TestContext.Current.CancellationToken); + var second = await fixture.Anonymous.PostAsJsonAsync("/api/v1/auth/register", body, TestContext.Current.CancellationToken); + + first.StatusCode.ShouldBe(HttpStatusCode.Created); + second.StatusCode.ShouldBe(HttpStatusCode.Conflict); + } + + [Fact] + public async Task Invalid_registration_payload_is_unprocessable() + { + var response = await fixture.Anonymous.PostAsJsonAsync("/api/v1/auth/register", + new RegisterRequest { Email = "not-an-email", Password = "short" }, TestContext.Current.CancellationToken); + + response.StatusCode.ShouldBe(HttpStatusCode.UnprocessableEntity); + var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + body.ShouldContain("registration_invalid"); + } + + [Fact] + public async Task Bad_password_is_unauthorized_with_stable_code() + { + var session = await fixture.CreateUserAsync(); + + var response = await fixture.Anonymous.PostAsJsonAsync("/api/v1/auth/login", + new LoginRequest { Email = session.Email, Password = "WrongPass1!" }, TestContext.Current.CancellationToken); + + response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); + var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + body.ShouldContain("invalid_credentials"); + } + + [Fact] + public async Task Locked_account_reports_invalid_credentials_like_any_failure() + { + var session = await fixture.CreateUserAsync(); + + for (var i = 0; i < 5; i++) + { + await fixture.Anonymous.PostAsJsonAsync("/api/v1/auth/login", + new LoginRequest { Email = session.Email, Password = "WrongPass1!" }, TestContext.Current.CancellationToken); + } + + // Even the correct password is now rejected because of the lockout + var retry = await fixture.Anonymous.PostAsJsonAsync("/api/v1/auth/login", + new LoginRequest { Email = session.Email, Password = session.Password }, TestContext.Current.CancellationToken); + + retry.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); + var body = await retry.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + CodeOf(body).ShouldBe("invalid_credentials"); + } + + [Fact] + public async Task Unknown_email_and_wrong_password_are_indistinguishable() + { + var session = await fixture.CreateUserAsync(); + + var unknown = await fixture.Anonymous.PostAsJsonAsync("/api/v1/auth/login", + new LoginRequest { Email = Fuzz.UniqueEmail(), Password = "Whatever1!" }, TestContext.Current.CancellationToken); + var wrongPassword = await fixture.Anonymous.PostAsJsonAsync("/api/v1/auth/login", + new LoginRequest { Email = session.Email, Password = "WrongPass1!" }, TestContext.Current.CancellationToken); + + unknown.StatusCode.ShouldBe(wrongPassword.StatusCode); + CodeOf(await unknown.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)) + .ShouldBe(CodeOf(await wrongPassword.Content.ReadAsStringAsync(TestContext.Current.CancellationToken))); + } + + private static string CodeOf(string problemDetails) + { + using var document = JsonDocument.Parse(problemDetails); + return document.RootElement.GetProperty("code").GetString()!; + } + + [Fact] + public async Task Me_requires_authentication() + { + var response = await fixture.Anonymous.GetAsync("/api/v1/auth/me", TestContext.Current.CancellationToken); + + response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); + } +} + diff --git a/tests/PriceNegotiationApp.IntegrationTests/ConcurrencyShould.cs b/tests/PriceNegotiationApp.IntegrationTests/ConcurrencyShould.cs new file mode 100644 index 0000000..73931aa --- /dev/null +++ b/tests/PriceNegotiationApp.IntegrationTests/ConcurrencyShould.cs @@ -0,0 +1,105 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using PriceNegotiationApp.IntegrationTests.Support; +using PriceNegotiationApp.TestKit; +using Shouldly; +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Xunit; + +namespace PriceNegotiationApp.IntegrationTests; + +[Collection(ApiCollection.Name)] +public class ConcurrencyShould(IntegrationTestFixture fixture) +{ + private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web); + + private static readonly string EntityTypeName = + "PriceNegotiationApp.Modules.Negotiations.Domain.Negotiation, PriceNegotiationApp.Modules.Negotiations.Domain"; + + [Fact] + public async Task Second_writer_of_one_negotiation_gets_a_concurrency_exception() + { + var negotiationId = await OpenNegotiationAsync(); + + // Both writers load the same row (same xmin) before either commits. + await using var scope1 = fixture.Factory.Services.CreateAsyncScope(); + var db1 = ResolveContext(scope1); + var first = LoadNegotiation(db1, negotiationId); + + await using var scope2 = fixture.Factory.Services.CreateAsyncScope(); + var db2 = ResolveContext(scope2); + var second = LoadNegotiation(db2, negotiationId); + + db1.Entry(first).Property("CurrentOffer").CurrentValue = PriceOf(70m); + await db1.SaveChangesAsync(TestContext.Current.CancellationToken); + + db2.Entry(second).Property("CurrentOffer").CurrentValue = PriceOf(71m); + await Should.ThrowAsync( + () => db2.SaveChangesAsync(TestContext.Current.CancellationToken)); + + // The winner's state survives untouched. + await using var verifyScope = fixture.Factory.Services.CreateAsyncScope(); + var stored = LoadNegotiation(ResolveContext(verifyScope), negotiationId); + ResolveContext(verifyScope).Entry(stored).Property("CurrentOffer") + .CurrentValue.ShouldBe(PriceOf(70m)); + } + + /// + /// Loads a real tracked aggregate by reflecting over the module-internal DbSet and + /// invoking FromSqlRaw with the runtime entity type — the domain is invisible to + /// this assembly, but materialization needs no compile-time reference. + /// + private static object LoadNegotiation(DbContext context, Guid id) + { + var entityType = Type.GetType(EntityTypeName)!; + var dbSet = context.GetType().GetProperty("Negotiations")!.GetValue(context)!; + + var fromSqlRaw = typeof(RelationalQueryableExtensions).GetMethod("FromSqlRaw")!; + var queryable = (IQueryable)fromSqlRaw.MakeGenericMethod(entityType) + .Invoke(null, [dbSet, + "SELECT id, base_price, created_at_utc, current_offer, customer_id, decided_at_utc, " + + "last_proposal_at_utc, last_staff_action_at_utc, max_proposals, offer_multiplier_limit, " + + $"product_id, proposals_used, status, xmin FROM negotiations.negotiations WHERE id = '{id}'", + Array.Empty()])!; + + var results = new List(); + foreach (var entity in queryable) + { + results.Add(entity); + } + + return results.Single(); + } + + private static DbContext ResolveContext(AsyncServiceScope scope) + { + var contextType = Type.GetType( + "PriceNegotiationApp.Modules.Negotiations.Infrastructure.Persistence.NegotiationsDbContext, " + + "PriceNegotiationApp.Modules.Negotiations.Infrastructure")!; + return (DbContext)scope.ServiceProvider.GetRequiredService(contextType); + } + + private static object PriceOf(decimal value) => + Type.GetType("PriceNegotiationApp.Modules.Negotiations.Domain.Price, " + + "PriceNegotiationApp.Modules.Negotiations.Domain")! + .GetMethod("From", [typeof(decimal)])! + .Invoke(null, [value])!; + + private async Task OpenNegotiationAsync() + { + var staff = await fixture.LoginAsStaffAsync(); + var createProduct = await staff.Client.PostAsJsonAsync("/api/v1/products", + new { name = Fuzz.NewFaker().ProductName(), price = 100m }, TestContext.Current.CancellationToken); + createProduct.StatusCode.ShouldBe(HttpStatusCode.Created); + var product = await createProduct.Content.ReadFromJsonAsync(Json, TestContext.Current.CancellationToken); + + var customer = await fixture.CreateUserAsync(); + var open = await customer.Client.PostAsJsonAsync("/api/v1/negotiations", + new { productId = product!.Id, proposedPrice = 80m }, TestContext.Current.CancellationToken); + open.StatusCode.ShouldBe(HttpStatusCode.Created); + var created = await open.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + return created.GetProperty("id").GetGuid(); + } +} diff --git a/tests/PriceNegotiationApp.IntegrationTests/ConfigurationValidationShould.cs b/tests/PriceNegotiationApp.IntegrationTests/ConfigurationValidationShould.cs new file mode 100644 index 0000000..1e0038a --- /dev/null +++ b/tests/PriceNegotiationApp.IntegrationTests/ConfigurationValidationShould.cs @@ -0,0 +1,52 @@ +using PriceNegotiationApp.Api.Extensions; +using PriceNegotiationApp.TestKit; +using Shouldly; +using Xunit; + +namespace PriceNegotiationApp.IntegrationTests; + +// Plain unit facts for Api-owned validators; no Docker container required. +public class ConfigurationValidationShould +{ + [Theory] + [InlineData(1)] + [InlineData(30)] + [InlineData(int.MaxValue)] + public void Accept_permit_limits_of_at_least_one(int limit) => + new RateLimitingOptionsValidator() + .Validate(null, new RateLimitingOptions { AuthPermitLimit = limit }) + .Succeeded.ShouldBeTrue(); + + [Theory] + [InlineData(0)] + [InlineData(-5)] + public void Reject_non_positive_permit_limits(int limit) => + new RateLimitingOptionsValidator() + .Validate(null, new RateLimitingOptions { AuthPermitLimit = limit }) + .Failed.ShouldBeTrue(); + + [Fact] + public void Accept_well_formed_cors_origins() + { + var origins = new[] + { + Fuzz.HttpsUrl(), + $"http://{Fuzz.NewFaker().Internet.DomainName()}", + }; + + Should.NotThrow(() => CorsOriginsGuard.EnsureValid(origins)); + } + + [Fact] + public void Tolerate_null_or_empty_cors_lists() => + Should.NotThrow(() => CorsOriginsGuard.EnsureValid(null)); + + [Theory] + [InlineData("app.example.com")] + [InlineData("ftp://app.example.com")] + [InlineData("https://")] + public void Reject_malformed_cors_origins(string origin) => + Should.Throw( + () => CorsOriginsGuard.EnsureValid([origin])) + .Message.ShouldContain(origin); +} diff --git a/tests/PriceNegotiationApp.IntegrationTests/CorsShould.cs b/tests/PriceNegotiationApp.IntegrationTests/CorsShould.cs new file mode 100644 index 0000000..6406d12 --- /dev/null +++ b/tests/PriceNegotiationApp.IntegrationTests/CorsShould.cs @@ -0,0 +1,36 @@ +using PriceNegotiationApp.IntegrationTests.Support; +using Shouldly; +using System.Net; +using Xunit; + +namespace PriceNegotiationApp.IntegrationTests; + +[Collection(ApiCollection.Name)] +public class CorsShould(IntegrationTestFixture fixture) +{ + private const string AllowedOrigin = "https://app.test.local"; + + [Fact] + public async Task Configured_origin_receives_allow_origin_header() + { + var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/products"); + request.Headers.TryAddWithoutValidation("Origin", AllowedOrigin); + + var response = await fixture.Anonymous.SendAsync(request, TestContext.Current.CancellationToken); + + response.StatusCode.ShouldBe(HttpStatusCode.OK); + response.Headers.TryGetValues("Access-Control-Allow-Origin", out var allowed).ShouldBeTrue(); + allowed!.ShouldBe([AllowedOrigin]); + } + + [Fact] + public async Task Unlisted_origin_receives_no_allow_origin_header() + { + var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/products"); + request.Headers.TryAddWithoutValidation("Origin", "https://evil.example"); + + var response = await fixture.Anonymous.SendAsync(request, TestContext.Current.CancellationToken); + + response.Headers.Contains("Access-Control-Allow-Origin").ShouldBeFalse(); + } +} diff --git a/tests/PriceNegotiationApp.IntegrationTests/GlobalExceptionHandlerShould.cs b/tests/PriceNegotiationApp.IntegrationTests/GlobalExceptionHandlerShould.cs new file mode 100644 index 0000000..69f6463 --- /dev/null +++ b/tests/PriceNegotiationApp.IntegrationTests/GlobalExceptionHandlerShould.cs @@ -0,0 +1,72 @@ +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using PriceNegotiationApp.Api; +using Shouldly; +using System.Text.Json; +using Xunit; + +namespace PriceNegotiationApp.IntegrationTests; + +// Plain unit facts over the exception mapper; no Docker container required. +public class GlobalExceptionHandlerShould +{ + [Fact] + public async Task Map_concurrency_conflicts_to_409_with_stable_code() + { + var (status, code) = await HandleAsync(new DbUpdateConcurrencyException("xmin race")); + + status.ShouldBe(StatusCodes.Status409Conflict); + code.ShouldBe("concurrency_conflict"); + } + + [Fact] + public async Task Keep_unknown_exceptions_on_the_internal_error_fallback() + { + var (status, code) = await HandleAsync(new InvalidOperationException("boom")); + + status.ShouldBe(StatusCodes.Status500InternalServerError); + code.ShouldBe("internal_error"); + } + + private static async Task<(int Status, string Code)> HandleAsync(Exception exception) + { + var services = new ServiceCollection() + .AddSingleton>( + Options.Create(new ProblemDetailsOptions())) + .AddSingleton>( + Options.Create(new Microsoft.AspNetCore.Http.Json.JsonOptions())) + .AddLogging() + .AddProblemDetails() + .BuildServiceProvider(); + var sut = new GlobalExceptionHandler( + services.GetRequiredService(), + new TestEnvironment(), + NullLogger.Instance); + + var context = new DefaultHttpContext(); + context.Response.Body = new MemoryStream(); + + await sut.TryHandleAsync(context, exception, TestContext.Current.CancellationToken); + + context.Response.Body.Position = 0; + var body = await new StreamReader(context.Response.Body).ReadToEndAsync(TestContext.Current.CancellationToken); + using var document = JsonDocument.Parse(body); + return (context.Response.StatusCode, document.RootElement.GetProperty("code").GetString()!); + } + + private sealed class TestEnvironment : IHostEnvironment + { + public string ApplicationName { get; set; } = "tests"; + public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider(); + public string ContentRootPath { get; set; } = Directory.GetCurrentDirectory(); + public string EnvironmentName { get; set; } = "Testing"; + } +} diff --git a/tests/PriceNegotiationApp.IntegrationTests/JwksShould.cs b/tests/PriceNegotiationApp.IntegrationTests/JwksShould.cs new file mode 100644 index 0000000..996ba97 --- /dev/null +++ b/tests/PriceNegotiationApp.IntegrationTests/JwksShould.cs @@ -0,0 +1,47 @@ +using PriceNegotiationApp.IntegrationTests.Support; +using Shouldly; +using System.Net; +using System.Text.Json; +using Xunit; + +namespace PriceNegotiationApp.IntegrationTests; + +[Collection(ApiCollection.Name)] +public class JwksShould(IntegrationTestFixture fixture) +{ + [Fact] + public async Task Publish_only_public_material_matching_issued_tokens() + { + var session = await fixture.CreateUserAsync(); + + var header = DecodeJson(session.Token.Split('.')[0]); + header.GetProperty("alg").GetString().ShouldBe("ES256"); + var kid = header.GetProperty("kid").GetString(); + kid.ShouldNotBeNullOrEmpty(); + + var response = await fixture.Anonymous.GetAsync("/.well-known/jwks.json", TestContext.Current.CancellationToken); + + response.StatusCode.ShouldBe(HttpStatusCode.OK); + var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + body.ShouldContain(kid); + body.ShouldContain("\"crv\":\"P-256\""); + body.ShouldNotContain("\"d\""); + body.ShouldNotContain("PRIVATE"); + } + + private static JsonElement DecodeJson(string base64Url) + { + var padded = base64Url.Replace('-', '+').Replace('_', '/'); + switch (padded.Length % 4) + { + case 2: + padded += "=="; + break; + case 3: + padded += "="; + break; + } + + return JsonSerializer.Deserialize(Convert.FromBase64String(padded)); + } +} diff --git a/tests/PriceNegotiationApp.IntegrationTests/NegotiationsShould.cs b/tests/PriceNegotiationApp.IntegrationTests/NegotiationsShould.cs new file mode 100644 index 0000000..d3b2a30 --- /dev/null +++ b/tests/PriceNegotiationApp.IntegrationTests/NegotiationsShould.cs @@ -0,0 +1,283 @@ +using PriceNegotiationApp.IntegrationTests.Support; +using PriceNegotiationApp.TestKit; +using Shouldly; +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Xunit; + +namespace PriceNegotiationApp.IntegrationTests; + +[Collection(ApiCollection.Name)] +public class NegotiationsShould(IntegrationTestFixture fixture) +{ + private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web); + + [Fact] + public async Task Customer_can_open_negotiation_within_limit() + { + var product = await CreateProductAsync(); + var customer = await fixture.CreateUserAsync(); + + var response = await customer.Client.PostAsJsonAsync("/api/v1/negotiations", + new { productId = product.Id, proposedPrice = 80m }, TestContext.Current.CancellationToken); + + response.StatusCode.ShouldBe(HttpStatusCode.Created); + var mine = await customer.Client.GetFromJsonAsync("/api/v1/negotiations/mine", Json, TestContext.Current.CancellationToken); + var negotiation = mine!.Items.ShouldHaveSingleItem(); + negotiation.Status.ShouldBe("Open"); + negotiation.ProposalsRemaining.ShouldBe(2); + negotiation.BasePrice.ShouldBe(100m); + } + + [Fact] + public async Task Creation_over_double_base_price_is_rejected_422() + { + var product = await CreateProductAsync(); + var customer = await fixture.CreateUserAsync(); + + var response = await customer.Client.PostAsJsonAsync("/api/v1/negotiations", + new { productId = product.Id, proposedPrice = 250m }, TestContext.Current.CancellationToken); + + response.StatusCode.ShouldBe(HttpStatusCode.UnprocessableEntity); + var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + body.ShouldContain("proposal_exceeds_limit"); + } + + [Fact] + public async Task Second_open_negotiation_for_same_product_conflicts() + { + var product = await CreateProductAsync(); + var customer = await fixture.CreateUserAsync(); + + var first = await customer.Client.PostAsJsonAsync("/api/v1/negotiations", + new { productId = product.Id, proposedPrice = 80m }, TestContext.Current.CancellationToken); + var second = await customer.Client.PostAsJsonAsync("/api/v1/negotiations", + new { productId = product.Id, proposedPrice = 85m }, TestContext.Current.CancellationToken); + + first.StatusCode.ShouldBe(HttpStatusCode.Created); + second.StatusCode.ShouldBe(HttpStatusCode.Conflict); + var body = await second.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + body.ShouldContain("negotiation_already_open"); + } + + [Fact] + public async Task Full_back_and_forth_then_accept() + { + var (customer, staff, negotiationId) = await StartOpenNegotiationAsync(); + + // Round 1: staff rejects the current offer (stays open), customer counters + var decline1 = await staff.Client.PostAsJsonAsync($"/api/v1/negotiations/{negotiationId}/decline", new { }, TestContext.Current.CancellationToken); + decline1.StatusCode.ShouldBe(HttpStatusCode.OK); + var decision1 = await decline1.Content.ReadFromJsonAsync(Json, TestContext.Current.CancellationToken); + decision1!.Outcome.ShouldBe("current_offer_rejected"); + decision1.Negotiation.Status.ShouldBe("Open"); + (await CounterProposeAsync(customer, negotiationId, 90m)).StatusCode.ShouldBe(HttpStatusCode.OK); + + // Round 2: staff declines again, customer uses the last proposal + await StaffDecideAsync(staff, negotiationId, decline: true); + (await CounterProposeAsync(customer, negotiationId, 95m)).StatusCode.ShouldBe(HttpStatusCode.OK); + + // Staff accepts the final offer + var accept = await staff.Client.PostAsJsonAsync($"/api/v1/negotiations/{negotiationId}/accept", new { }, TestContext.Current.CancellationToken); + accept.StatusCode.ShouldBe(HttpStatusCode.OK); + var accepted = await accept.Content.ReadFromJsonAsync(Json, TestContext.Current.CancellationToken); + accepted!.Outcome.ShouldBe("accepted"); + + var view = await GetNegotiationAsync(staff, negotiationId); + view.Status.ShouldBe("Accepted"); + + // Terminal state refuses further proposals + var late = await CounterProposeAsync(customer, negotiationId, 50m); + late.StatusCode.ShouldBe(HttpStatusCode.Conflict); + var lateBody = await late.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + lateBody.ShouldContain("negotiation_closed"); + } + + [Fact] + public async Task Budget_exhaustion_yields_409_no_proposals_remaining() + { + var (customer, staff, negotiationId) = await StartOpenNegotiationAsync(); + + await StaffDecideAsync(staff, negotiationId, decline: true); + await CounterProposeAsync(customer, negotiationId, 90m); + await StaffDecideAsync(staff, negotiationId, decline: true); + await CounterProposeAsync(customer, negotiationId, 91m); + await StaffDecideAsync(staff, negotiationId, decline: true); // budget now spent + + var third = await CounterProposeAsync(customer, negotiationId, 92m); + + third.StatusCode.ShouldBe(HttpStatusCode.Conflict); + var body = await third.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + body.ShouldContain("no_proposals_remaining"); + } + + [Fact] + public async Task Counter_over_limit_auto_rejects_and_closes() + { + var (customer, _, negotiationId) = await StartOpenNegotiationAsync(); + + var response = await CounterProposeAsync(customer, negotiationId, 500m); + + response.StatusCode.ShouldBe(HttpStatusCode.OK); + var outcome = await response.Content.ReadFromJsonAsync(Json, TestContext.Current.CancellationToken); + outcome!.Outcome.ShouldBe("AutoRejected"); + outcome.Negotiation.Status.ShouldBe("Rejected"); + outcome.Negotiation.DecidedAtUtc.ShouldNotBeNull(); + } + + [Fact] + public async Task Access_matrix_view_and_counter() + { + var (owner, staff, negotiationId) = await StartOpenNegotiationAsync(); + var stranger = await fixture.CreateUserAsync(); + var admin = await fixture.LoginAsAdminAsync(); + + // Stranger cannot view or counter-propose + (await stranger.Client.GetAsync($"/api/v1/negotiations/{negotiationId}", TestContext.Current.CancellationToken)) + .StatusCode.ShouldBe(HttpStatusCode.Forbidden); + (await CounterProposeAsync(stranger, negotiationId, 50m)).StatusCode.ShouldBe(HttpStatusCode.Forbidden); + + // Staff and admin can view + (await staff.Client.GetAsync($"/api/v1/negotiations/{negotiationId}", TestContext.Current.CancellationToken)) + .StatusCode.ShouldBe(HttpStatusCode.OK); + (await admin.Client.GetAsync($"/api/v1/negotiations/{negotiationId}", TestContext.Current.CancellationToken)) + .StatusCode.ShouldBe(HttpStatusCode.OK); + + // Owner withdraw soft-closes; admin hard-deletes + (await stranger.Client.DeleteAsync($"/api/v1/negotiations/{negotiationId}", TestContext.Current.CancellationToken)) + .StatusCode.ShouldBe(HttpStatusCode.Forbidden); + (await owner.Client.DeleteAsync($"/api/v1/negotiations/{negotiationId}", TestContext.Current.CancellationToken)) + .StatusCode.ShouldBe(HttpStatusCode.NoContent); + } + + [Fact] + public async Task Owner_withdraw_closes_but_preserves_history_admin_delete_destroys() + { + var (customer, _, negotiationId) = await StartOpenNegotiationAsync(); + + var withdraw = await customer.Client.DeleteAsync($"/api/v1/negotiations/{negotiationId}", TestContext.Current.CancellationToken); + withdraw.StatusCode.ShouldBe(HttpStatusCode.NoContent); + + var view = await GetNegotiationAsync(customer, negotiationId); + view.Status.ShouldBe("Withdrawn"); + view.DecidedAtUtc.ShouldNotBeNull(); + view.BasePrice.ShouldBe(100m); // snapshot history intact + + // Withdrawn is terminal + var counter = await CounterProposeAsync(customer, negotiationId, 50m); + counter.StatusCode.ShouldBe(HttpStatusCode.Conflict); + + // Only an admin can hard-delete; afterwards it is gone + var admin = await fixture.LoginAsAdminAsync(); + (await admin.Client.DeleteAsync($"/api/v1/negotiations/{negotiationId}", TestContext.Current.CancellationToken)) + .StatusCode.ShouldBe(HttpStatusCode.NoContent); + (await admin.Client.GetAsync($"/api/v1/negotiations/{negotiationId}", TestContext.Current.CancellationToken)) + .StatusCode.ShouldBe(HttpStatusCode.NotFound); + } + + [Fact] + public async Task Customer_cannot_hard_delete_another_users_negotiation() + { + var (_, _, otherId) = await StartOpenNegotiationAsync(); + var stranger = await fixture.CreateUserAsync(); + + // stranger is a customer who owns no negotiation here; + // DELETE must be forbidden, not silently withdraw someone else's deal + (await stranger.Client.DeleteAsync($"/api/v1/negotiations/{otherId}", TestContext.Current.CancellationToken)) + .StatusCode.ShouldBe(HttpStatusCode.Forbidden); + } + + [Fact] + public async Task Concurrent_creates_produce_single_winner_and_conflicts_never_500() + { + var product = await CreateProductAsync(); + var customer = await fixture.CreateUserAsync(); + + var attempts = await Task.WhenAll(Enumerable.Range(0, 6).Select(_ => + customer.Client.PostAsJsonAsync("/api/v1/negotiations", + new { productId = product.Id, proposedPrice = 80m }, TestContext.Current.CancellationToken))); + + attempts.Count(r => r.StatusCode == HttpStatusCode.Created).ShouldBe(1); + attempts.Count(r => r.StatusCode == HttpStatusCode.Conflict).ShouldBe(5); + } + + [Fact] + public async Task Negotiations_survive_when_referenced_product_is_deleted() + { + var product = await CreateProductAsync(); + var customer = await fixture.CreateUserAsync(); + + var create = await customer.Client.PostAsJsonAsync("/api/v1/negotiations", + new { productId = product.Id, proposedPrice = 80m }, TestContext.Current.CancellationToken); + create.EnsureSuccessStatusCode(); + + var admin = await fixture.LoginAsAdminAsync(); + var delete = await admin.Client.DeleteAsync($"/api/v1/products/{product.Id}", TestContext.Current.CancellationToken); + delete.StatusCode.ShouldBe(HttpStatusCode.NoContent); + + var mine = await customer.Client.GetFromJsonAsync( + "/api/v1/negotiations/mine?page=1&pageSize=10", Json, TestContext.Current.CancellationToken); + mine!.TotalCount.ShouldBe(1); + mine.Items.ShouldHaveSingleItem().BasePrice.ShouldBe(100m); + } + + [Fact] + public async Task Ready_endpoint_reports_all_module_schemas() + { + var response = await fixture.Anonymous.GetAsync("/health/ready", TestContext.Current.CancellationToken); + + response.EnsureSuccessStatusCode(); + (await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)).ShouldContain("Healthy"); + } + + private sealed record CounterResponse(string Outcome, NegotiationView Negotiation); + + private sealed record StaffAction(string Outcome, NegotiationView Negotiation); + + private sealed record NegotiationView( + Guid Id, Guid ProductId, decimal BasePrice, decimal CurrentOffer, string Status, + int ProposalsUsed, int ProposalsRemaining, DateTimeOffset CreatedAtUtc, + DateTimeOffset LastProposalAtUtc, DateTimeOffset? DecidedAtUtc); + + private sealed record PagedNegotiations(IReadOnlyList Items, int Page, int PageSize, long TotalCount); + + private async Task CreateProductAsync() + { + var staff = await fixture.LoginAsStaffAsync(); + var response = await staff.Client.PostAsJsonAsync("/api/v1/products", + new { name = Fuzz.NewFaker().ProductName(), price = 100m }, TestContext.Current.CancellationToken); + response.EnsureSuccessStatusCode(); + return (await response.Content.ReadFromJsonAsync(Json, TestContext.Current.CancellationToken))!; + } + + private async Task<(UserSession Customer, UserSession Staff, Guid NegotiationId)> StartOpenNegotiationAsync() + { + var product = await CreateProductAsync(); + var customer = await fixture.CreateUserAsync(); + var create = await customer.Client.PostAsJsonAsync("/api/v1/negotiations", + new { productId = product.Id, proposedPrice = 80m }, TestContext.Current.CancellationToken); + create.EnsureSuccessStatusCode(); + var created = await create.Content.ReadFromJsonAsync(Json, TestContext.Current.CancellationToken); + return (customer, await fixture.LoginAsStaffAsync(), created!.Id); + } + + private async Task CounterProposeAsync(UserSession customer, Guid id, decimal offer) => + await customer.Client.PatchAsJsonAsync($"/api/v1/negotiations/{id}/proposals", + new { proposedPrice = offer }, TestContext.Current.CancellationToken); + + private async Task StaffDecideAsync(UserSession staff, Guid id, bool decline) + { + var route = decline ? "decline" : "accept"; + var response = await staff.Client.PostAsJsonAsync($"/api/v1/negotiations/{id}/{route}", new { }, TestContext.Current.CancellationToken); + response.EnsureSuccessStatusCode(); + } + + private async Task GetNegotiationAsync(UserSession session, Guid id) + { + var response = await session.Client.GetAsync($"/api/v1/negotiations/{id}", TestContext.Current.CancellationToken); + response.EnsureSuccessStatusCode(); + return (await response.Content.ReadFromJsonAsync(Json, TestContext.Current.CancellationToken))!; + } +} + diff --git a/tests/PriceNegotiationApp.IntegrationTests/PriceNegotiationApp.IntegrationTests.csproj b/tests/PriceNegotiationApp.IntegrationTests/PriceNegotiationApp.IntegrationTests.csproj new file mode 100644 index 0000000..79dc804 --- /dev/null +++ b/tests/PriceNegotiationApp.IntegrationTests/PriceNegotiationApp.IntegrationTests.csproj @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/PriceNegotiationApp.IntegrationTests/ProductsShould.cs b/tests/PriceNegotiationApp.IntegrationTests/ProductsShould.cs new file mode 100644 index 0000000..8d240ff --- /dev/null +++ b/tests/PriceNegotiationApp.IntegrationTests/ProductsShould.cs @@ -0,0 +1,171 @@ +using PriceNegotiationApp.IntegrationTests.Support; +using PriceNegotiationApp.TestKit; +using Shouldly; +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Xunit; + +namespace PriceNegotiationApp.IntegrationTests; + +[Collection(ApiCollection.Name)] +public class ProductsShould(IntegrationTestFixture fixture) +{ + private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web); + + [Fact] + public async Task Anonymous_can_list_and_get_but_not_write() + { + var staff = await fixture.LoginAsStaffAsync(); + var created = await CreateProductAsync(staff); + + var list = await fixture.Anonymous.GetAsync("/api/v1/products?page=1&pageSize=10", TestContext.Current.CancellationToken); + list.StatusCode.ShouldBe(HttpStatusCode.OK); + + var single = await fixture.Anonymous.GetAsync($"/api/v1/products/{created.Id}", TestContext.Current.CancellationToken); + single.StatusCode.ShouldBe(HttpStatusCode.OK); + + var post = await fixture.Anonymous.PostAsJsonAsync("/api/v1/products", + DenialPayload(1m), TestContext.Current.CancellationToken); + post.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); + + var put = await fixture.Anonymous.PutAsJsonAsync($"/api/v1/products/{created.Id}", + DenialPayload(2m), TestContext.Current.CancellationToken); + put.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); + + var delete = await fixture.Anonymous.DeleteAsync($"/api/v1/products/{created.Id}", TestContext.Current.CancellationToken); + delete.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task Customer_blocked_from_all_writes() + { + var customer = await fixture.CreateUserAsync(); + + (await customer.Client.PostAsJsonAsync("/api/v1/products", + DenialPayload(1m), TestContext.Current.CancellationToken)).StatusCode.ShouldBe(HttpStatusCode.Forbidden); + (await customer.Client.PutAsJsonAsync($"/api/v1/products/{Guid.NewGuid()}", + DenialPayload(1m), TestContext.Current.CancellationToken)).StatusCode.ShouldBe(HttpStatusCode.Forbidden); + (await customer.Client.DeleteAsync( + $"/api/v1/products/{Guid.NewGuid()}", TestContext.Current.CancellationToken)).StatusCode.ShouldBe(HttpStatusCode.Forbidden); + } + + [Fact] + public async Task Staff_can_update_but_not_delete() + { + var admin = await fixture.LoginAsAdminAsync(); + var created = await CreateProductAsync(admin); + var staff = await fixture.LoginAsStaffAsync(); + + var put = await staff.Client.PutAsJsonAsync($"/api/v1/products/{created.Id}", + new { name = Fuzz.NewFaker().ProductName(), price = created.Price + 1 }, TestContext.Current.CancellationToken); + put.StatusCode.ShouldBe(HttpStatusCode.OK); + + var delete = await staff.Client.DeleteAsync($"/api/v1/products/{created.Id}", TestContext.Current.CancellationToken); + delete.StatusCode.ShouldBe(HttpStatusCode.Forbidden); + } + + [Fact] + public async Task Admin_can_delete() + { + var admin = await fixture.LoginAsAdminAsync(); + var created = await CreateProductAsync(admin); + + var delete = await admin.Client.DeleteAsync($"/api/v1/products/{created.Id}", TestContext.Current.CancellationToken); + delete.StatusCode.ShouldBe(HttpStatusCode.NoContent); + + var get = await fixture.Anonymous.GetAsync($"/api/v1/products/{created.Id}", TestContext.Current.CancellationToken); + get.StatusCode.ShouldBe(HttpStatusCode.NotFound); + } + + [Fact] + public async Task Missing_product_returns_404_with_stable_code() + { + var response = await fixture.Anonymous.GetAsync( + $"/api/v1/products/{Guid.NewGuid()}", TestContext.Current.CancellationToken); + + response.StatusCode.ShouldBe(HttpStatusCode.NotFound); + var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + body.ShouldContain("product_not_found"); + } + + [Fact] + public async Task Domain_rejects_invalid_product_payloads_with_422() + { + var staff = await fixture.LoginAsStaffAsync(); + + var emptyName = await staff.Client.PostAsJsonAsync("/api/v1/products", + new { name = string.Empty, price = Fuzz.NewFaker().Price() }, TestContext.Current.CancellationToken); + emptyName.StatusCode.ShouldBe(HttpStatusCode.UnprocessableEntity); + var emptyNameBody = await emptyName.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + emptyNameBody.ShouldContain("domain_rule_violated"); + + var negativePrice = await staff.Client.PostAsJsonAsync("/api/v1/products", + new { name = Fuzz.NewFaker().ProductName(), price = -5m }, TestContext.Current.CancellationToken); + negativePrice.StatusCode.ShouldBe(HttpStatusCode.UnprocessableEntity); + var negativeBody = await negativePrice.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + negativeBody.ShouldContain("validation_failed"); + } + + [Fact] + public async Task Filtering_sorting_and_paging_work() + { + var admin = await fixture.LoginAsAdminAsync(); + var marker = $"ZProbe{Guid.NewGuid():N}"[..12]; + await CreateProductAsync(admin, $"{marker} Alpha", 10m); + await CreateProductAsync(admin, $"{marker} Beta", 30m); + await CreateProductAsync(admin, $"{marker} Gamma", 20m); + + var search = await fixture.Anonymous.GetAsync( + $"/api/v1/products?search={marker}", TestContext.Current.CancellationToken); + var paged = await search.Content.ReadFromJsonAsync(Json, TestContext.Current.CancellationToken); + paged!.TotalCount.ShouldBe(3); + + var sorted = await fixture.Anonymous.GetAsync( + $"/api/v1/products?search={marker}&sortBy=price&sortDesc=true&page=1&pageSize=2", + TestContext.Current.CancellationToken); + sorted.StatusCode.ShouldBe(HttpStatusCode.OK); + var sortedPage = await sorted.Content.ReadFromJsonAsync(Json, TestContext.Current.CancellationToken); + sortedPage!.TotalCount.ShouldBe(3); + sortedPage.Items.Count.ShouldBe(2); + sortedPage.Items[0].Price.ShouldBeGreaterThanOrEqualTo(sortedPage.Items[1].Price); + sortedPage.Items[0].Price.ShouldBe(30m); + + var range = await fixture.Anonymous.GetAsync( + $"/api/v1/products?search={marker}&minPrice=15&maxPrice=25", + TestContext.Current.CancellationToken); + var rangePage = await range.Content.ReadFromJsonAsync(Json, TestContext.Current.CancellationToken); + var item = rangePage!.Items.ShouldHaveSingleItem(); + item.Price.ShouldBe(20m); + } + + [Fact] + public async Task Put_with_identical_body_is_idempotent() + { + var staff = await fixture.LoginAsStaffAsync(); + var created = await CreateProductAsync(staff); + + var put = await staff.Client.PutAsJsonAsync($"/api/v1/products/{created.Id}", + new { name = created.Name, price = created.Price }, TestContext.Current.CancellationToken); + + put.StatusCode.ShouldBe(HttpStatusCode.OK); + var body = await put.Content.ReadFromJsonAsync(Json, TestContext.Current.CancellationToken); + body!.Name.ShouldBe(created.Name); + } + + private static object DenialPayload(decimal price) => new + { + name = Fuzz.NewFaker().ProductName(), + price, + }; + + private static async Task CreateProductAsync(UserSession session, string? name = null, decimal? price = null) + { + var response = await session.Client.PostAsJsonAsync("/api/v1/products", + new { name = name ?? Fuzz.NewFaker().ProductName(), price = price ?? Fuzz.NewFaker().Price() }, TestContext.Current.CancellationToken); + response.EnsureSuccessStatusCode(); + return (await response.Content.ReadFromJsonAsync(Json, TestContext.Current.CancellationToken))!; + } +} + + diff --git a/tests/PriceNegotiationApp.IntegrationTests/PublicSurfaceShould.cs b/tests/PriceNegotiationApp.IntegrationTests/PublicSurfaceShould.cs new file mode 100644 index 0000000..ac5e185 --- /dev/null +++ b/tests/PriceNegotiationApp.IntegrationTests/PublicSurfaceShould.cs @@ -0,0 +1,41 @@ +using PriceNegotiationApp.IntegrationTests.Support; +using Shouldly; +using System.Net; +using System.Text; +using Xunit; + +namespace PriceNegotiationApp.IntegrationTests; + +[Collection(ApiCollection.Name)] +public class PublicSurfaceShould(IntegrationTestFixture fixture) +{ + public static TheoryData ProtectedRoutes => new() + { + { HttpMethod.Get, "/api/v1/auth/me" }, + { HttpMethod.Post, "/api/v1/products" }, + { HttpMethod.Put, $"/api/v1/products/{Guid.NewGuid()}" }, + { HttpMethod.Delete, $"/api/v1/products/{Guid.NewGuid()}" }, + { HttpMethod.Post, "/api/v1/negotiations" }, + { HttpMethod.Get, "/api/v1/negotiations/mine" }, + { HttpMethod.Get, "/api/v1/negotiations" }, + { HttpMethod.Get, $"/api/v1/negotiations/{Guid.NewGuid()}" }, + { HttpMethod.Patch, $"/api/v1/negotiations/{Guid.NewGuid()}/proposals" }, + { HttpMethod.Post, $"/api/v1/negotiations/{Guid.NewGuid()}/accept" }, + { HttpMethod.Post, $"/api/v1/negotiations/{Guid.NewGuid()}/decline" }, + { HttpMethod.Delete, $"/api/v1/negotiations/{Guid.NewGuid()}" }, + }; + + [Theory] + [MemberData(nameof(ProtectedRoutes))] + public async Task Unauthenticated_requests_are_challenged(HttpMethod method, string path) + { + var request = new HttpRequestMessage(method, path) + { + Content = new StringContent(string.Empty, Encoding.UTF8, "application/json"), + }; + + var response = await fixture.Anonymous.SendAsync(request, TestContext.Current.CancellationToken); + + response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized, $"{method} {path} must stay behind authentication"); + } +} diff --git a/tests/PriceNegotiationApp.IntegrationTests/ReadyHealthReportShould.cs b/tests/PriceNegotiationApp.IntegrationTests/ReadyHealthReportShould.cs new file mode 100644 index 0000000..68fbe74 --- /dev/null +++ b/tests/PriceNegotiationApp.IntegrationTests/ReadyHealthReportShould.cs @@ -0,0 +1,41 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using PriceNegotiationApp.Api; +using Shouldly; +using Xunit; + +namespace PriceNegotiationApp.IntegrationTests; + +// Plain unit fact over the response writer; no Docker container required. +public class ReadyHealthReportShould +{ + [Fact] + public async Task Never_leak_failure_detail_in_body_even_when_unhealthy() + { + var context = new DefaultHttpContext + { + RequestServices = new ServiceCollection().AddLogging().BuildServiceProvider(), + }; + context.Response.Body = new MemoryStream(); + var secret = "password authentication failed for user 'postgres'"; + var report = new HealthReport( + new Dictionary + { + ["database-catalog"] = new( + HealthStatus.Unhealthy, secret, TimeSpan.FromMilliseconds(3), + new InvalidOperationException(secret), null), + ["self"] = new( + HealthStatus.Healthy, null, TimeSpan.FromMilliseconds(1), null, null), + }, + totalDuration: TimeSpan.FromMilliseconds(4)); + + await ReadyHealthReport.WriteAsync(context, report); + + context.Response.Body.Position = 0; + var body = await new StreamReader(context.Response.Body).ReadToEndAsync(TestContext.Current.CancellationToken); + body.ShouldNotContain(secret); + body.ShouldNotContain("description"); + body.ShouldContain("\"Unhealthy\""); + } +} diff --git a/tests/PriceNegotiationApp.IntegrationTests/ReadyHealthShould.cs b/tests/PriceNegotiationApp.IntegrationTests/ReadyHealthShould.cs new file mode 100644 index 0000000..e18178e --- /dev/null +++ b/tests/PriceNegotiationApp.IntegrationTests/ReadyHealthShould.cs @@ -0,0 +1,36 @@ +using PriceNegotiationApp.IntegrationTests.Support; +using Shouldly; +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Xunit; + +namespace PriceNegotiationApp.IntegrationTests; + +[Collection(ApiCollection.Name)] +public class ReadyHealthShould(IntegrationTestFixture fixture) +{ + [Fact] + public async Task Ready_reports_json_status_per_dependency() + { + var response = await fixture.Anonymous.GetAsync("/health/ready", TestContext.Current.CancellationToken); + + response.StatusCode.ShouldBe(HttpStatusCode.OK); + response.Content.Headers.ContentType!.MediaType.ShouldBe("application/json"); + + var body = await response.Content.ReadFromJsonAsync( + cancellationToken: TestContext.Current.CancellationToken); + + body.GetProperty("status").GetString().ShouldBe("Healthy"); + body.GetProperty("totalDurationMs").GetDouble().ShouldBeGreaterThanOrEqualTo(0); + + var entries = body.GetProperty("entries"); + foreach (var name in new[] { "database-identity", "database-catalog", "database-negotiations" }) + { + entries.TryGetProperty(name, out _).ShouldBeTrue($"missing health entry '{name}'"); + entries.GetProperty(name).GetProperty("status").GetString().ShouldBe("Healthy"); + entries.GetProperty(name).TryGetProperty("description", out _) + .ShouldBeFalse("healthy checks must not carry a description"); + } + } +} diff --git a/tests/PriceNegotiationApp.IntegrationTests/Support/ApiCollection.cs b/tests/PriceNegotiationApp.IntegrationTests/Support/ApiCollection.cs new file mode 100644 index 0000000..dcacf95 --- /dev/null +++ b/tests/PriceNegotiationApp.IntegrationTests/Support/ApiCollection.cs @@ -0,0 +1,9 @@ +using Xunit; + +namespace PriceNegotiationApp.IntegrationTests.Support; + +[CollectionDefinition(Name)] +public sealed class ApiCollection : ICollectionFixture +{ + public const string Name = "api"; +} diff --git a/tests/PriceNegotiationApp.IntegrationTests/Support/AuthRequests.cs b/tests/PriceNegotiationApp.IntegrationTests/Support/AuthRequests.cs new file mode 100644 index 0000000..b9efbfe --- /dev/null +++ b/tests/PriceNegotiationApp.IntegrationTests/Support/AuthRequests.cs @@ -0,0 +1,15 @@ +namespace PriceNegotiationApp.IntegrationTests.Support; + +public sealed class RegisterRequest +{ + public string Email { get; init; } = string.Empty; + + public string Password { get; init; } = string.Empty; +} + +public sealed class LoginRequest +{ + public string Email { get; init; } = string.Empty; + + public string Password { get; init; } = string.Empty; +} diff --git a/tests/PriceNegotiationApp.IntegrationTests/Support/BearerTokenHandler.cs b/tests/PriceNegotiationApp.IntegrationTests/Support/BearerTokenHandler.cs new file mode 100644 index 0000000..de8a044 --- /dev/null +++ b/tests/PriceNegotiationApp.IntegrationTests/Support/BearerTokenHandler.cs @@ -0,0 +1,19 @@ +namespace PriceNegotiationApp.IntegrationTests.Support; + +public sealed class TokenHolder +{ + public string? Token { get; set; } +} + +public sealed class BearerTokenHandler(TokenHolder holder) : DelegatingHandler +{ + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (holder.Token is { } token) + { + request.Headers.Authorization = new("Bearer", token); + } + + return base.SendAsync(request, cancellationToken); + } +} diff --git a/tests/PriceNegotiationApp.IntegrationTests/Support/IntegrationTestFactory.cs b/tests/PriceNegotiationApp.IntegrationTests/Support/IntegrationTestFactory.cs new file mode 100644 index 0000000..565538d --- /dev/null +++ b/tests/PriceNegotiationApp.IntegrationTests/Support/IntegrationTestFactory.cs @@ -0,0 +1,39 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; + +namespace PriceNegotiationApp.IntegrationTests.Support; + +public sealed class IntegrationTestFactory(string connectionString) : WebApplicationFactory +{ + public const string AdminEmail = "admin@test.local"; + + public const string StaffEmail = "staff@test.local"; + + public const string SeedPassword = "Seed123!Apricot!"; + + private static readonly string SigningPem = CreateSigningPem(); + + private static string CreateSigningPem() + { + using var ecdsa = System.Security.Cryptography.ECDsa.Create( + System.Security.Cryptography.ECCurve.NamedCurves.nistP256); + return ecdsa.ExportPkcs8PrivateKeyPem(); + } + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment("Testing"); + builder.UseSetting("Database:ConnectionString", connectionString); + builder.UseSetting("Jwt:Issuer", "integration-tests"); + builder.UseSetting("Jwt:Audience", "integration-tests"); + builder.UseSetting("Jwt:PrivateKey", SigningPem); + builder.UseSetting("Jwt:ExpiryMinutes", "30"); + builder.UseSetting("Seeding:AdminEmail", AdminEmail); + builder.UseSetting("Seeding:AdminPassword", SeedPassword); + builder.UseSetting("Seeding:StaffEmail", StaffEmail); + builder.UseSetting("Seeding:StaffPassword", SeedPassword); + builder.UseSetting("Seeding:SeedSampleProducts", "true"); + builder.UseSetting("RateLimiting:AuthPermitLimit", "1000"); + builder.UseSetting("Cors:AllowedOrigins", "https://app.test.local"); + } +} diff --git a/tests/PriceNegotiationApp.IntegrationTests/Support/IntegrationTestFixture.cs b/tests/PriceNegotiationApp.IntegrationTests/Support/IntegrationTestFixture.cs new file mode 100644 index 0000000..283d9e1 --- /dev/null +++ b/tests/PriceNegotiationApp.IntegrationTests/Support/IntegrationTestFixture.cs @@ -0,0 +1,62 @@ +using PriceNegotiationApp.IntegrationTests.Support; +using PriceNegotiationApp.TestKit; +using System.Net.Http.Json; +using Testcontainers.PostgreSql; +using Xunit; + +namespace PriceNegotiationApp.IntegrationTests.Support; + +public sealed class IntegrationTestFixture : IAsyncLifetime +{ + private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder("postgres:17-alpine").Build(); + + public IntegrationTestFactory Factory { get; private set; } = null!; + + public HttpClient Anonymous { get; private set; } = null!; + + public async ValueTask InitializeAsync() + { + await _postgres.StartAsync(); + Factory = new IntegrationTestFactory(_postgres.GetConnectionString()); + Anonymous = Factory.CreateClient(); + } + + public async ValueTask DisposeAsync() + { + Anonymous.Dispose(); + await Factory.DisposeAsync(); + await _postgres.DisposeAsync(); + } + + /// Registers and logs in a fresh customer; returns an authorized client session. + public async Task CreateUserAsync() + { + var email = Fuzz.UniqueEmail(); + var password = Fuzz.Password(); + + var register = await Anonymous.PostAsJsonAsync("/api/v1/auth/register", + new RegisterRequest { Email = email, Password = password }); + register.EnsureSuccessStatusCode(); + + var session = await LoginAsync(email, password); + Fuzz.Dump("user", new { email, password }); + return session; + } + + public async Task LoginAsync(string email, string password) + { + var login = await Anonymous.PostAsJsonAsync("/api/v1/auth/login", + new LoginRequest { Email = email, Password = password }); + login.EnsureSuccessStatusCode(); + var content = await login.Content.ReadFromJsonAsync(); + return new UserSession(Factory, email, content!.AccessToken, password); + } + + public Task LoginAsAdminAsync() => + LoginAsync(IntegrationTestFactory.AdminEmail, IntegrationTestFactory.SeedPassword); + + public Task LoginAsStaffAsync() => + LoginAsync(IntegrationTestFactory.StaffEmail, IntegrationTestFactory.SeedPassword); +} + + diff --git a/tests/PriceNegotiationApp.IntegrationTests/Support/LoginResponse.cs b/tests/PriceNegotiationApp.IntegrationTests/Support/LoginResponse.cs new file mode 100644 index 0000000..864f301 --- /dev/null +++ b/tests/PriceNegotiationApp.IntegrationTests/Support/LoginResponse.cs @@ -0,0 +1,3 @@ +namespace PriceNegotiationApp.IntegrationTests.Support; + +public sealed record LoginResponse(string AccessToken, DateTimeOffset ExpiresAtUtc, string Email, IReadOnlyList Roles); diff --git a/tests/PriceNegotiationApp.IntegrationTests/Support/MeResponse.cs b/tests/PriceNegotiationApp.IntegrationTests/Support/MeResponse.cs new file mode 100644 index 0000000..db4e7af --- /dev/null +++ b/tests/PriceNegotiationApp.IntegrationTests/Support/MeResponse.cs @@ -0,0 +1,3 @@ +namespace PriceNegotiationApp.IntegrationTests.Support; + +public sealed record MeResponse(Guid UserId, string Email, IReadOnlyList Roles); diff --git a/tests/PriceNegotiationApp.IntegrationTests/Support/PagedProducts.cs b/tests/PriceNegotiationApp.IntegrationTests/Support/PagedProducts.cs new file mode 100644 index 0000000..3603a40 --- /dev/null +++ b/tests/PriceNegotiationApp.IntegrationTests/Support/PagedProducts.cs @@ -0,0 +1,3 @@ +namespace PriceNegotiationApp.IntegrationTests.Support; + +public sealed record PagedProducts(IReadOnlyList Items, int Page, int PageSize, long TotalCount); diff --git a/tests/PriceNegotiationApp.IntegrationTests/Support/ProductResponse.cs b/tests/PriceNegotiationApp.IntegrationTests/Support/ProductResponse.cs new file mode 100644 index 0000000..1400bc5 --- /dev/null +++ b/tests/PriceNegotiationApp.IntegrationTests/Support/ProductResponse.cs @@ -0,0 +1,3 @@ +namespace PriceNegotiationApp.IntegrationTests.Support; + +public sealed record ProductResponse(Guid Id, string Name, decimal Price); diff --git a/tests/PriceNegotiationApp.IntegrationTests/Support/UserSession.cs b/tests/PriceNegotiationApp.IntegrationTests/Support/UserSession.cs new file mode 100644 index 0000000..d2b8a66 --- /dev/null +++ b/tests/PriceNegotiationApp.IntegrationTests/Support/UserSession.cs @@ -0,0 +1,14 @@ +namespace PriceNegotiationApp.IntegrationTests.Support; + +public sealed class UserSession( + IntegrationTestFactory factory, string email, string token, string password) +{ + public string Email { get; } = email; + + public string Token { get; } = token; + + public string Password { get; } = password; + + public HttpClient Client { get; } = + factory.CreateDefaultClient(new BearerTokenHandler(new TokenHolder { Token = token })); +} diff --git a/tests/PriceNegotiationApp.IntegrationTests/TestBootstrap.cs b/tests/PriceNegotiationApp.IntegrationTests/TestBootstrap.cs new file mode 100644 index 0000000..dedc2ae --- /dev/null +++ b/tests/PriceNegotiationApp.IntegrationTests/TestBootstrap.cs @@ -0,0 +1,12 @@ +using PriceNegotiationApp.TestKit; +using System.Runtime.CompilerServices; +using Xunit; + +namespace PriceNegotiationApp.IntegrationTests; + +public static class TestBootstrap +{ + [ModuleInitializer] + internal static void WireFuzzSink() => + Fuzz.Sink = line => TestContext.Current?.TestOutputHelper?.WriteLine(line); +} diff --git a/tests/PriceNegotiationApp.Modules.Catalog.Tests/PriceNegotiationApp.Modules.Catalog.Tests.csproj b/tests/PriceNegotiationApp.Modules.Catalog.Tests/PriceNegotiationApp.Modules.Catalog.Tests.csproj new file mode 100644 index 0000000..e397457 --- /dev/null +++ b/tests/PriceNegotiationApp.Modules.Catalog.Tests/PriceNegotiationApp.Modules.Catalog.Tests.csproj @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/tests/PriceNegotiationApp.Modules.Catalog.Tests/ProductRulesShould.cs b/tests/PriceNegotiationApp.Modules.Catalog.Tests/ProductRulesShould.cs new file mode 100644 index 0000000..be04edd --- /dev/null +++ b/tests/PriceNegotiationApp.Modules.Catalog.Tests/ProductRulesShould.cs @@ -0,0 +1,80 @@ +using PriceNegotiationApp.Modules.Catalog.Domain; +using PriceNegotiationApp.SharedKernel; +using PriceNegotiationApp.TestKit; +using Shouldly; +using Vogen; +using Xunit; + +namespace PriceNegotiationApp.Modules.Catalog.Tests; + +public class ProductRulesShould +{ + // Semantic partitions stay inline: null/empty/whitespace and zero/negative are + // distinct validation branches; 'x' x201 is the length boundary. + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Create_rejects_null_or_whitespace_name(string? name) => + Should.Throw(() => Product.Create(name!, Fuzz.NewFaker().Price())); + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void Create_rejects_non_positive_price(decimal price) => + Should.Throw( + () => Product.Create(Fuzz.NewFaker().ProductName(), price)); + + [Fact] + public void Create_rejects_name_over_200_characters() => + Should.Throw(() => Product.Create(new string('x', 201), Fuzz.NewFaker().Price())); + + [Fact] + public void Create_trims_surrounding_whitespace_and_assigns_id_and_price() + { + var faker = Fuzz.NewFaker(); + var rawName = $" {faker.ProductName()} "; + var price = faker.Price(); + + var product = Product.Create(rawName, price); + + product.Name.ShouldBe(rawName.Trim()); + product.Id.Value.ShouldNotBe(Guid.Empty); + product.Price.ShouldBe(price); + } + + [Fact] + public void Update_returns_true_and_applies_changes_when_changed() + { + var faker = Fuzz.NewFaker(); + var originalName = faker.ProductName(); + var originalPrice = faker.Price(); + var product = Product.Create(originalName, originalPrice); + var newName = faker.ProductName(); + var newPrice = faker.Price(); + Fuzz.Dump("update-pair", new { originalName, originalPrice, newName, newPrice }); + + // Collision-immune: Bogus could legitimately generate identical values. + var expectedChanged = + !string.Equals(originalName, newName, StringComparison.Ordinal) || originalPrice != newPrice; + + var changed = product.Update(newName, newPrice); + + changed.ShouldBe(expectedChanged); + product.Name.ShouldBe(newName); + product.Price.ShouldBe(newPrice); + } + + [Fact] + public void Update_returns_false_when_identical() + { + var faker = Fuzz.NewFaker(); + var name = faker.ProductName(); + var price = faker.Price(); + var product = Product.Create(name, price); + + var changed = product.Update(name, price); + + changed.ShouldBeFalse(); + } +} diff --git a/tests/PriceNegotiationApp.Modules.Catalog.Tests/TestBootstrap.cs b/tests/PriceNegotiationApp.Modules.Catalog.Tests/TestBootstrap.cs new file mode 100644 index 0000000..3db1b23 --- /dev/null +++ b/tests/PriceNegotiationApp.Modules.Catalog.Tests/TestBootstrap.cs @@ -0,0 +1,12 @@ +using PriceNegotiationApp.TestKit; +using System.Runtime.CompilerServices; +using Xunit; + +namespace PriceNegotiationApp.Modules.Catalog.Tests; + +public static class TestBootstrap +{ + [ModuleInitializer] + internal static void WireFuzzSink() => + Fuzz.Sink = line => TestContext.Current?.TestOutputHelper?.WriteLine(line); +} diff --git a/tests/PriceNegotiationApp.Modules.Catalog.Tests/UpdateIdempotencyShould.cs b/tests/PriceNegotiationApp.Modules.Catalog.Tests/UpdateIdempotencyShould.cs new file mode 100644 index 0000000..bd0d912 --- /dev/null +++ b/tests/PriceNegotiationApp.Modules.Catalog.Tests/UpdateIdempotencyShould.cs @@ -0,0 +1,35 @@ +using PriceNegotiationApp.Modules.Catalog.Domain; +using PriceNegotiationApp.TestKit; +using Shouldly; +using Xunit; + +namespace PriceNegotiationApp.Modules.Catalog.Tests; + +public class UpdateIdempotencyShould +{ + [Fact] + public void Return_false_when_nothing_changed() + { + var faker = Fuzz.NewFaker(); + var name = faker.ProductName(); + var price = faker.Price(); + var product = Product.Create(name, price); + + var changed = product.Update(name, price); + + changed.ShouldBeFalse(); + } + + [Fact] + public void Return_true_when_only_whitespace_differs() + { + var faker = Fuzz.NewFaker(); + var padded = $"{faker.ProductName()} "; + var product = Product.Create(faker.ProductName(), faker.Price()); + + var changed = product.Update(padded, product.Price); + + changed.ShouldBeTrue(); + product.Name.ShouldBe(padded.Trim()); + } +} diff --git a/tests/PriceNegotiationApp.Modules.Identity.Tests/JwtManagerShould.cs b/tests/PriceNegotiationApp.Modules.Identity.Tests/JwtManagerShould.cs new file mode 100644 index 0000000..5c6e07d --- /dev/null +++ b/tests/PriceNegotiationApp.Modules.Identity.Tests/JwtManagerShould.cs @@ -0,0 +1,106 @@ +using PriceNegotiationApp.Modules.Identity.Infrastructure; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; +using PriceNegotiationApp.Modules.Identity.Contracts; +using PriceNegotiationApp.TestKit; +using Shouldly; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text.Json; +using Xunit; + +namespace PriceNegotiationApp.Modules.Identity.Tests; + +public class JwtManagerShould +{ + private sealed class FixedTimeProvider : TimeProvider + { + public override DateTimeOffset GetUtcNow() => new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); + } + + private static (JwtManager Manager, EcSigningKey Key) BuildSut(TimeProvider? clock = null) + { + using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256); + var options = Options.Create(new JwtOptions + { + Issuer = "test-issuer", + Audience = "test-audience", + PrivateKey = ecdsa.ExportPkcs8PrivateKeyPem(), + ExpiryMinutes = 30, + }); + var key = new EcSigningKey(options); + return (new JwtManager(options, key, clock ?? TimeProvider.System), key); + } + + private static TokenValidationParameters Parameters(EcSigningKey key) => new() + { + ValidateIssuer = true, + ValidIssuer = "test-issuer", + ValidateAudience = true, + ValidAudience = "test-audience", + ValidateIssuerSigningKey = true, + IssuerSigningKey = key.PublicJwk, + ValidAlgorithms = [EcSigningKey.Algorithm], + ValidateLifetime = true, + ClockSkew = TimeSpan.Zero, + }; + + [Fact] + public void Generate_es256_token_with_kid_email_role_and_expiry() + { + var email = Fuzz.Email(); + var (sut, _) = BuildSut(new FixedTimeProvider()); + + var (token, expiresAtUtc) = sut.Generate(Guid.NewGuid(), email, ["Customer"]); + + var parts = token.Split('.'); + parts.Length.ShouldBe(3); + var header = DecodeJson(parts[0]); + header.GetProperty("alg").GetString().ShouldBe("ES256"); + header.GetProperty("kid").GetString().ShouldNotBeNullOrEmpty(); + DecodeJson(parts[1]).GetRawText().ShouldContain(email); + var expected = new FixedTimeProvider().GetUtcNow().AddMinutes(30); + (expiresAtUtc - expected).Duration().ShouldBeLessThan(TimeSpan.FromSeconds(1)); + } + + [Fact] + public void Token_validates_against_the_published_public_key() + { + var userId = Guid.NewGuid(); + var (sut, key) = BuildSut(); + var (token, _) = sut.Generate(userId, Fuzz.Email(), ["Staff"]); + + var principal = new JwtSecurityTokenHandler().ValidateToken(token, Parameters(key), out _); + + principal!.FindFirst(ClaimTypes.NameIdentifier)!.Value.ShouldBe(userId.ToString()); + principal.FindFirst(ClaimTypes.Role)!.Value.ShouldBe("Staff"); + } + + [Fact] + public void Token_signed_by_a_different_key_is_rejected() + { + var (sut, _) = BuildSut(); + var (_, stranger) = BuildSut(); + var (token, _) = sut.Generate(Guid.NewGuid(), Fuzz.Email(), []); + + Should.Throw( + () => new JwtSecurityTokenHandler().ValidateToken(token, Parameters(stranger), out _)); + } + + private static JsonElement DecodeJson(string base64Url) + { + var padded = base64Url.Replace('-', '+').Replace('_', '/'); + switch (padded.Length % 4) + { + case 2: + padded += "=="; + break; + case 3: + padded += "="; + break; + } + + return JsonSerializer.Deserialize(Convert.FromBase64String(padded)); + } +} diff --git a/tests/PriceNegotiationApp.Modules.Identity.Tests/JwtOptionsValidatorShould.cs b/tests/PriceNegotiationApp.Modules.Identity.Tests/JwtOptionsValidatorShould.cs new file mode 100644 index 0000000..9bf0e5d --- /dev/null +++ b/tests/PriceNegotiationApp.Modules.Identity.Tests/JwtOptionsValidatorShould.cs @@ -0,0 +1,55 @@ +using PriceNegotiationApp.Modules.Identity.Infrastructure; +using PriceNegotiationApp.Modules.Identity.Contracts; +using PriceNegotiationApp.TestKit; +using Shouldly; +using Xunit; + +namespace PriceNegotiationApp.Modules.Identity.Tests; + +public class JwtOptionsValidatorShould +{ + private readonly JwtOptionsValidator _sut = new(); + + [Fact] + public void Accept_a_complete_configuration() + { + var result = _sut.Validate(null, new JwtOptions + { + Issuer = Fuzz.NewFaker().Internet.DomainName(), + Audience = "price-negotiation-api", + PrivateKey = "not-parsed-here", + ExpiryMinutes = 30, + }); + + result.Succeeded.ShouldBeTrue(); + } + + [Fact] + public void Reject_blank_private_key() + { + var result = _sut.Validate(null, new JwtOptions + { + Issuer = "i", + Audience = "a", + PrivateKey = " ", + ExpiryMinutes = 30, + }); + + result.Failed.ShouldBeTrue(); + result.Failures.ShouldContain(f => f.Contains("PrivateKey")); + } + + [Fact] + public void Reject_non_positive_expiry() + { + var result = _sut.Validate(null, new JwtOptions + { + Issuer = "i", + Audience = "a", + PrivateKey = "pem", + ExpiryMinutes = 0, + }); + + result.Failed.ShouldBeTrue(); + } +} diff --git a/tests/PriceNegotiationApp.Modules.Identity.Tests/PriceNegotiationApp.Modules.Identity.Tests.csproj b/tests/PriceNegotiationApp.Modules.Identity.Tests/PriceNegotiationApp.Modules.Identity.Tests.csproj new file mode 100644 index 0000000..3fb8409 --- /dev/null +++ b/tests/PriceNegotiationApp.Modules.Identity.Tests/PriceNegotiationApp.Modules.Identity.Tests.csproj @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/tests/PriceNegotiationApp.Modules.Identity.Tests/SeedingOptionsValidatorShould.cs b/tests/PriceNegotiationApp.Modules.Identity.Tests/SeedingOptionsValidatorShould.cs new file mode 100644 index 0000000..128c97c --- /dev/null +++ b/tests/PriceNegotiationApp.Modules.Identity.Tests/SeedingOptionsValidatorShould.cs @@ -0,0 +1,113 @@ +using PriceNegotiationApp.Modules.Identity.Infrastructure; +using PriceNegotiationApp.Modules.Identity.Infrastructure.Seeding; +using PriceNegotiationApp.TestKit; +using Shouldly; +using Xunit; + +namespace PriceNegotiationApp.Modules.Identity.Tests; + +public class SeedingOptionsValidatorShould +{ + private readonly SeedingOptionsValidator _sut = new(); + + // Unspecified fields fall back to fresh Fuzz values, so every happy-path run + // exercises different-but-valid data. Invalid partitions stay inline. + private static SeedingOptions Options( + string? adminEmail = null, + string? adminPassword = null, + string? staffEmail = null, + string? staffPassword = null) => new() + { + AdminEmail = adminEmail ?? Fuzz.Email(), + AdminPassword = adminPassword ?? Fuzz.Password(), + StaffEmail = staffEmail ?? Fuzz.Email(), + StaffPassword = staffPassword ?? Fuzz.Password(), + }; + + [Fact] + public void Accept_a_complete_configuration_with_generated_values() + { + var options = Options(); + Fuzz.Dump("seeding-options", options); + + _sut.Validate(null, options).Succeeded.ShouldBeTrue(); + } + + // Null must be passed as an explicit object-initializer branch: the ?? fallback in + // Options() would otherwise generate a valid value and silently skip the case. + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("not-an-email")] + public void Reject_invalid_admin_email(string email) => + _sut.Validate(null, Options(adminEmail: email)).Failed.ShouldBeTrue(); + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("not-an-email")] + public void Reject_invalid_staff_email(string email) => + _sut.Validate(null, Options(staffEmail: email)).Failed.ShouldBeTrue(); + + [Fact] + public void Reject_null_admin_email() + { + var options = new SeedingOptions + { + AdminEmail = null!, + AdminPassword = Fuzz.Password(), + StaffEmail = Fuzz.Email(), + StaffPassword = Fuzz.Password(), + }; + + _sut.Validate(null, options).Failed.ShouldBeTrue(); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("short")] + [InlineData("alllowercase123!")] + [InlineData("ALLUPPERCASE123!")] + [InlineData("NoDigitsHereOnly!!")] + [InlineData("NoSymbols12345xY")] + public void Reject_admin_password_below_strength_floor(string password) + { + var options = new SeedingOptions + { + AdminEmail = Fuzz.Email(), + AdminPassword = password, + StaffEmail = Fuzz.Email(), + StaffPassword = Fuzz.Password(), + }; + + _sut.Validate(null, options).Failed.ShouldBeTrue(); + } + + [Theory] + [InlineData("Seed123!Apricot!")] + [InlineData("Str0ng-Passphrase!42")] + public void Accept_strong_admin_passwords(string password) + { + var options = new SeedingOptions + { + AdminEmail = Fuzz.Email(), + AdminPassword = password, + StaffEmail = Fuzz.Email(), + StaffPassword = Fuzz.Password(), + }; + + _sut.Validate(null, options).Succeeded.ShouldBeTrue(); + } + + [Fact] + public void Aggregate_every_violation_in_one_result() + { + var result = _sut.Validate(null, new SeedingOptions()); + + result.Failed.ShouldBeTrue(); + result.Failures.Count().ShouldBe(2); + result.Failures.ShouldContain(f => f.Contains("AdminPassword")); + result.Failures.ShouldContain(f => f.Contains("StaffPassword")); + } +} diff --git a/tests/PriceNegotiationApp.Modules.Identity.Tests/TestBootstrap.cs b/tests/PriceNegotiationApp.Modules.Identity.Tests/TestBootstrap.cs new file mode 100644 index 0000000..536a6d7 --- /dev/null +++ b/tests/PriceNegotiationApp.Modules.Identity.Tests/TestBootstrap.cs @@ -0,0 +1,12 @@ +using PriceNegotiationApp.TestKit; +using System.Runtime.CompilerServices; +using Xunit; + +namespace PriceNegotiationApp.Modules.Identity.Tests; + +public static class TestBootstrap +{ + [ModuleInitializer] + internal static void WireFuzzSink() => + Fuzz.Sink = line => TestContext.Current?.TestOutputHelper?.WriteLine(line); +} diff --git a/tests/PriceNegotiationApp.Modules.Negotiations.Tests/DbWriteGuardShould.cs b/tests/PriceNegotiationApp.Modules.Negotiations.Tests/DbWriteGuardShould.cs new file mode 100644 index 0000000..c6e54cf --- /dev/null +++ b/tests/PriceNegotiationApp.Modules.Negotiations.Tests/DbWriteGuardShould.cs @@ -0,0 +1,80 @@ +using Microsoft.EntityFrameworkCore; +using Npgsql; +using PriceNegotiationApp.SharedKernel; +using Shouldly; +using Xunit; + +namespace PriceNegotiationApp.Modules.Negotiations.Tests; + +public class DbWriteGuardShould +{ + private const string ConstraintName = "uq_negotiations_open_product_customer"; + + [Fact] + public void Detect_unique_violation_wrapped_in_DbUpdateException() + { + var inner = new PostgresException("duplicate key value", "ERROR", "ERROR", + PostgresErrorCodes.UniqueViolation, constraintName: ConstraintName); + + var found = DbWriteGuard.IsUniqueViolation(new DbUpdateException("save failed", inner), + out var constraint); + + found.ShouldBeTrue(); + constraint.ShouldBe(ConstraintName); + } + + [Fact] + public void Ignore_other_postgres_error_codes() + { + var inner = new PostgresException("foreign key violation", "ERROR", "ERROR", + PostgresErrorCodes.ForeignKeyViolation, constraintName: ConstraintName); + + DbWriteGuard.IsUniqueViolation(new DbUpdateException("save failed", inner), out _) + .ShouldBeFalse(); + } + + [Fact] + public void Ignore_unrelated_exception_types() + { + DbWriteGuard.IsUniqueViolation(new InvalidOperationException("nope"), out _) + .ShouldBeFalse(); + } + + [Fact] + public void SaveOrConflict_throws_factory_exception_with_constraint_name() + { + var db = new ThrowingDbContext(withUniqueViolation: true); + + var thrown = Should.Throw(() => + db.SaveOrConflictAsync( + constraint => new ConflictException($"hit:{constraint}", "conflict"), + TestContext.Current.CancellationToken).GetAwaiter().GetResult()); + + thrown.Code.ShouldBe($"hit:{ConstraintName}"); + } + + [Fact] + public void SaveOrConflict_rerethrows_non_unique_failures() + { + var db = new ThrowingDbContext(withUniqueViolation: false); + + var thrown = Should.Throw(() => + db.SaveOrConflictAsync( + constraint => new ConflictException(constraint, "conflict"), + TestContext.Current.CancellationToken).GetAwaiter().GetResult()); + + thrown.InnerException.ShouldBeOfType(); + } + + private sealed class ThrowingDbContext(bool withUniqueViolation = true) : DbContext + { + public override Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + Exception inner = withUniqueViolation + ? new PostgresException("duplicate key value", "ERROR", "ERROR", + PostgresErrorCodes.UniqueViolation, constraintName: ConstraintName) + : new InvalidOperationException("boom"); + throw new DbUpdateException("save failed", inner); + } + } +} diff --git a/tests/PriceNegotiationApp.Modules.Negotiations.Tests/NegotiationLifecycleShould.cs b/tests/PriceNegotiationApp.Modules.Negotiations.Tests/NegotiationLifecycleShould.cs new file mode 100644 index 0000000..dc6914a --- /dev/null +++ b/tests/PriceNegotiationApp.Modules.Negotiations.Tests/NegotiationLifecycleShould.cs @@ -0,0 +1,185 @@ +using Bogus; +using PriceNegotiationApp.Modules.Negotiations.Domain; +using PriceNegotiationApp.TestKit; +using Shouldly; +using Vogen; +using Xunit; + +namespace PriceNegotiationApp.Modules.Negotiations.Tests; + +public class NegotiationLifecycleShould +{ + private static readonly DefaultNegotiationPolicy Policy = new(); + private readonly Faker _faker = Fuzz.NewFaker(); + private readonly Guid _productId = Guid.CreateVersion7(); + + private const decimal BasePrice = 100m; + private readonly DateTimeOffset _now = new(2026, 1, 1, 12, 0, 0, TimeSpan.Zero); + + private Negotiation StartValid() + { + var customerId = CustomerId.From(_faker.Random.Guid()); + Fuzz.Dump("start-valid", new { customer = customerId.Value, product = _productId }); + return Negotiation.Start(customerId, _productId, BasePrice, 80m, _now, Policy); + } + + [Fact] + public void Start_records_initial_proposal_snapshots_policy_and_consumes_one_of_three_budgets() + { + var negotiation = StartValid(); + + negotiation.Status.ShouldBe(NegotiationStatus.Open); + negotiation.ProposalsUsed.ShouldBe(1); + negotiation.MaxProposals.ShouldBe(3); + negotiation.OfferMultiplierLimit.ShouldBe(2.0m); + negotiation.BasePrice.Value.ShouldBe(100m); + negotiation.RemainingProposals().ShouldBe(2); + } + + [Fact] + public void Start_rejects_offer_over_twice_base_price() => + Should.Throw( + () => Negotiation.Start(CustomerId.From(_faker.Random.Guid()), _productId, BasePrice, 201m, _now, Policy)); + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void Start_rejects_non_positive_base_price(decimal badBase) => + Should.Throw( + () => Negotiation.Start(CustomerId.From(_faker.Random.Guid()), _productId, badBase, 80m, _now, Policy)); + + [Fact] + public void CounterPropose_rejects_non_positive_offer() + { + var negotiation = StartValid(); + + Should.Throw( + () => negotiation.CounterPropose(0m, _now.AddMinutes(5))); + } + + [Fact] + public void Start_accepts_offer_exactly_at_limit() + { + var negotiation = Negotiation.Start(CustomerId.From(_faker.Random.Guid()), _productId, BasePrice, 200m, _now, Policy); + + negotiation.CurrentOffer.Value.ShouldBe(200m); + } + + [Fact] + public void CounterPropose_stores_new_offer_within_limit() + { + var negotiation = StartValid(); + + var outcome = negotiation.CounterPropose(90m, _now.AddMinutes(5)); + + outcome.ShouldBe(NegotiationOutcome.CounterProposed); + negotiation.CurrentOffer.Value.ShouldBe(90m); + negotiation.ProposalsUsed.ShouldBe(2); + negotiation.Status.ShouldBe(NegotiationStatus.Open); + } + + [Fact] + public void CounterPropose_over_limit_auto_rejects_and_closes() + { + var negotiation = StartValid(); + + var outcome = negotiation.CounterPropose(500m, _now.AddMinutes(5)); + + outcome.ShouldBe(NegotiationOutcome.AutoRejected); + negotiation.Status.ShouldBe(NegotiationStatus.Rejected); + negotiation.DecidedAtUtc.ShouldNotBeNull(); + } + + [Fact] + public void CounterPropose_uses_limits_snapshotted_at_creation_not_current_config() + { + var generousPolicy = new StaticPolicy(maxProposals: 5, multiplierLimit: 3.0m); + var negotiation = Negotiation.Start( + CustomerId.From(_faker.Random.Guid()), _productId, BasePrice, 80m, _now, generousPolicy); + + // The DI container now hands out the default (stricter) policy; the aggregate + // must still obey the rules it was created under. + var outcome = negotiation.CounterPropose(250m, _now.AddMinutes(5)); + + outcome.ShouldBe(NegotiationOutcome.CounterProposed); // legal under 3.0x, illegal under 2.0x + negotiation.ProposalsUsed.ShouldBe(2); + negotiation.RemainingProposals().ShouldBe(3); + } + + [Fact] + public void CounterPropose_after_budget_exhaustion_returns_NoProposalsRemaining() + { + var negotiation = StartValid(); + negotiation.CounterPropose(90m, _now); + negotiation.CounterPropose(91m, _now); + + var outcome = negotiation.CounterPropose(92m, _now); + + outcome.ShouldBe(NegotiationOutcome.NoProposalsRemaining); + negotiation.CurrentOffer.Value.ShouldNotBe(92m); + negotiation.Status.ShouldBe(NegotiationStatus.Open); + } + + [Fact] + public void Accept_closes_negotiation_as_Accepted() + { + var negotiation = StartValid(); + + negotiation.Accept(_now.AddDays(1)); + + negotiation.Status.ShouldBe(NegotiationStatus.Accepted); + negotiation.DecidedAtUtc.ShouldNotBeNull(); + } + + [Fact] + public void RejectCurrentOffer_keeps_open_and_stamps_staff_action_without_touching_budget() + { + var negotiation = StartValid(); + + negotiation.RejectCurrentOffer(_now.AddMinutes(10)); + + negotiation.Status.ShouldBe(NegotiationStatus.Open); + negotiation.LastStaffActionAtUtc.ShouldBe(_now.AddMinutes(10)); + negotiation.ProposalsUsed.ShouldBe(1); + negotiation.DecidedAtUtc.ShouldBeNull(); + } + + [Fact] + public void Withdraw_moves_open_negotiation_to_terminal_Withdrawn() + { + var negotiation = StartValid(); + negotiation.CounterPropose(90m, _now); + + negotiation.Withdraw(_now.AddHours(1)); + + negotiation.Status.ShouldBe(NegotiationStatus.Withdrawn); + negotiation.DecidedAtUtc.ShouldNotBeNull(); + negotiation.CurrentOffer.Value.ShouldBe(90m); // history preserved + } + + [Fact] + public void Terminal_negotiations_refuse_further_operations() + { + var withdrawn = StartValid(); + withdrawn.Withdraw(_now); + var accepted = StartValid(); + accepted.Accept(_now); + var rejected = StartValid(); + rejected.CounterPropose(500m, _now); + + foreach (var terminal in new[] { withdrawn, accepted, rejected }) + { + Should.Throw(() => terminal.CounterPropose(50m, _now)); + Should.Throw(() => terminal.Accept(_now)); + Should.Throw(() => terminal.RejectCurrentOffer(_now)); + Should.Throw(() => terminal.Withdraw(_now)); + } + } + + private sealed class StaticPolicy(int maxProposals, decimal multiplierLimit) : INegotiationPolicy + { + public int MaxProposalsPerNegotiation { get; } = maxProposals; + + public decimal ProposalMultiplierLimit { get; } = multiplierLimit; + } +} diff --git a/tests/PriceNegotiationApp.Modules.Negotiations.Tests/PriceNegotiationApp.Modules.Negotiations.Tests.csproj b/tests/PriceNegotiationApp.Modules.Negotiations.Tests/PriceNegotiationApp.Modules.Negotiations.Tests.csproj new file mode 100644 index 0000000..e6e717e --- /dev/null +++ b/tests/PriceNegotiationApp.Modules.Negotiations.Tests/PriceNegotiationApp.Modules.Negotiations.Tests.csproj @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/tests/PriceNegotiationApp.Modules.Negotiations.Tests/PriceShould.cs b/tests/PriceNegotiationApp.Modules.Negotiations.Tests/PriceShould.cs new file mode 100644 index 0000000..659c755 --- /dev/null +++ b/tests/PriceNegotiationApp.Modules.Negotiations.Tests/PriceShould.cs @@ -0,0 +1,23 @@ +using PriceNegotiationApp.Modules.Negotiations.Domain; +using Shouldly; +using Vogen; +using Xunit; + +namespace PriceNegotiationApp.Modules.Negotiations.Tests; + +public class PriceShould +{ + [Fact] + public void Accept_positive_values() + { + var price = Price.From(19.99m); + price.Value.ShouldBe(19.99m); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void Reject_zero_or_negative_values(decimal value) => + Should.Throw(() => Price.From(value)); +} + diff --git a/tests/PriceNegotiationApp.Modules.Negotiations.Tests/TestBootstrap.cs b/tests/PriceNegotiationApp.Modules.Negotiations.Tests/TestBootstrap.cs new file mode 100644 index 0000000..4016689 --- /dev/null +++ b/tests/PriceNegotiationApp.Modules.Negotiations.Tests/TestBootstrap.cs @@ -0,0 +1,12 @@ +using PriceNegotiationApp.TestKit; +using System.Runtime.CompilerServices; +using Xunit; + +namespace PriceNegotiationApp.Modules.Negotiations.Tests; + +public static class TestBootstrap +{ + [ModuleInitializer] + internal static void WireFuzzSink() => + Fuzz.Sink = line => TestContext.Current?.TestOutputHelper?.WriteLine(line); +} diff --git a/tests/PriceNegotiationApp.TestKit/Fuzz.cs b/tests/PriceNegotiationApp.TestKit/Fuzz.cs new file mode 100644 index 0000000..68a5418 --- /dev/null +++ b/tests/PriceNegotiationApp.TestKit/Fuzz.cs @@ -0,0 +1,107 @@ +using Bogus; +using System.Collections.Concurrent; +using System.Globalization; +using System.Runtime.CompilerServices; +using System.Text.Json; + +namespace PriceNegotiationApp.TestKit; + +/// +/// Deterministic test-data generation. Faker instances are seeded from +/// (TEST_SEED, call-site), so re-running the same command line replays identical data. +/// Dump() reports every arranged value into test output, which lands in TRX artifacts. +/// +public static class Fuzz +{ + public static int RunSeed { get; } = + int.TryParse(Environment.GetEnvironmentVariable("TEST_SEED"), CultureInfo.InvariantCulture, out var seed) + ? seed + : 8675309; + + /// Attached by each test assembly's module initializer to xunit v3 output. + public static Action? Sink { get; set; } + + private static readonly ConcurrentDictionary SiteCounters = new(); + private static int _uniqueSequence; + + public static Faker NewFaker( + int salt = 0, + [CallerFilePath] string filePath = "", + [CallerMemberName] string member = "") + { + var site = $"{filePath}:{member}"; + var occurrence = SiteCounters.AddOrUpdate(site, 1, static (_, current) => current + 1); + var seed = HashCode.Combine(RunSeed, site, salt, occurrence); + Sink?.Invoke(string.Create(CultureInfo.InvariantCulture, + $"fuzz run-seed={RunSeed} scope={member} site-occurrence={occurrence} seed={seed}")); + return new Faker { Random = new Randomizer(seed) }; + } + + public static decimal Price(this Faker faker, decimal min = 0.01m, decimal max = 1000m) => + Math.Round(faker.Random.Decimal(min, max), 2); + + public static string ProductName(this Faker faker) + { + var name = faker.Commerce.ProductName(); + return name.Length <= 200 ? name : name[..200]; + } + + public static string Text(this Faker faker, int minLen, int maxLen) => + faker.Random.String2(faker.Random.Int(minLen, maxLen)); + + public static string Email() + { + var sequence = Interlocked.Increment(ref _uniqueSequence); + var faker = new Faker { Random = new Randomizer(HashCode.Combine(RunSeed, sequence)) }; + return faker.Internet.Email(); + } + + public static string UniqueEmail() + { + var sequence = Interlocked.Increment(ref _uniqueSequence); + var local = new Faker { Random = new Randomizer(HashCode.Combine(RunSeed, sequence)) } + .Internet.UserName().ToLowerInvariant().Replace("'", "").Replace(".", ""); + return $"{local}.f{sequence.ToString(CultureInfo.InvariantCulture)}@test.local"; + } + + public static string Password(int length = 14) + { + ArgumentOutOfRangeException.ThrowIfLessThan(length, 4); + + const string upper = "ABCDEFGHJKLMNPQRSTUVWXYZ"; + const string lower = "abcdefghijkmnpqrstuvwxyz"; + const string digits = "23456789"; + const string symbols = "!@#$%^&*"; + var all = string.Concat(upper, lower, digits, symbols); + + var randomizer = new Randomizer(HashCode.Combine(RunSeed, Interlocked.Increment(ref _uniqueSequence))); + var chars = new char[length]; + chars[0] = upper[randomizer.Int(0, upper.Length - 1)]; + chars[1] = lower[randomizer.Int(0, lower.Length - 1)]; + chars[2] = digits[randomizer.Int(0, digits.Length - 1)]; + chars[3] = symbols[randomizer.Int(0, symbols.Length - 1)]; + for (var i = 4; i < length; i++) + { + chars[i] = all[randomizer.Int(0, all.Length - 1)]; + } + + for (var i = length - 1; i > 0; i--) + { + var swap = randomizer.Int(0, i); + (chars[i], chars[swap]) = (chars[swap], chars[i]); + } + + return new string(chars); + } + + public static string HttpsUrl() + { + var sequence = Interlocked.Increment(ref _uniqueSequence); + var domain = new Faker { Random = new Randomizer(HashCode.Combine(RunSeed, sequence)) } + .Internet.DomainName(); + return $"https://{domain}"; + } + + public static void Dump(string label, object value) => + Sink?.Invoke($"fuzz {label} = {JsonSerializer.Serialize(value)}"); +} diff --git a/tests/PriceNegotiationApp.TestKit/PriceNegotiationApp.TestKit.csproj b/tests/PriceNegotiationApp.TestKit/PriceNegotiationApp.TestKit.csproj new file mode 100644 index 0000000..e25d06f --- /dev/null +++ b/tests/PriceNegotiationApp.TestKit/PriceNegotiationApp.TestKit.csproj @@ -0,0 +1,5 @@ + + + + + diff --git a/transaction-management-patterns-improvements.patch b/transaction-management-patterns-improvements.patch new file mode 100644 index 0000000..628625e --- /dev/null +++ b/transaction-management-patterns-improvements.patch @@ -0,0 +1,186 @@ +diff --git a/docs/transaction-management-patterns.md b/docs/transaction-management-patterns.md +index 40f3042..03782f0 100644 +--- a/docs/transaction-management-patterns.md ++++ b/docs/transaction-management-patterns.md +@@ -174,6 +174,10 @@ await tx.CommitAsync(ct); // BOTH saves become permanent togethe + ``` + + > **The invariant, stated honestly:** any number of `SaveChanges` calls are safe as long as they share one transaction and none commits alone. What's forbidden is letting an intermediate save become permanent while later steps can still fail — that's Option 1 again. ++> ++> **Named gray zone — idempotent provisioning saves.** A save whose row has independent meaning and fails safe (an idempotently-created customer/profile/shadow record) leaves, on later failure, at worst a *benign orphan*. Strict atomicity is still the default — wrapping such saves costs one `BeginTransactionAsync` — but recognize you are buying polish, not correctness, there. What stays indefensible is letting a save commit alone when later steps carry business consequences. ++ ++> **⚠ Interplay with connection resiliency:** if the context enables `EnableRetryOnFailure` (execution strategy), EF Core refuses user-initiated `BeginTransactionAsync` unless the whole unit runs inside the strategy: `var strategy = db.Database.CreateExecutionStrategy(); await strategy.ExecuteAsync(async () => { /* begin, save(s), commit */ });` — or disable automatic retries for such flows. This is among the most common "worked until I added a transaction" runtime exceptions in real deployments, and none of the patterns below work without accounting for it. + + Two closing notes: if you own the schema, prefer fixing Case B at the root by switching keys to client-generated GUIDs rather than carrying two-save patterns forever. And if the "later step" is an *external* call that merely wants an ID (charge card with booking reference), that call doesn't belong mid-command at all — move it behind after-commit events/outbox (Option 5). + +@@ -218,6 +222,30 @@ Rules of thumb when escalating: + 2. **Every escalated command needs a retry loop.** SQL Server throws deadlock (error 1205)/update conflict; PostgreSQL's SERIALIZABLE aborts with serialization failure (40001). These are normal traffic under contention — translate to HTTP 409/503 + retry, don't log them as bugs. + 3. **Before escalating, check the cheaper alternatives**: an optimistic token on the contested aggregate, or one atomic conditional statement (`UPDATE Events SET SeatsAvailable -= @n WHERE Id = @id AND SeatsAvailable >= @n`) achieves most SERIALIZABLE guarantees at READ COMMITTED prices. Escalation is the last tool, not the first. + ++And the retry policy itself — aborts under escalation are normal traffic, not errors: ++ ++```csharp ++for (var attempt = 1; ; attempt++) ++{ ++ try ++ { ++ return await RunCommandAsync(ct); ++ } ++ catch (Exception ex) when (attempt < 3 && IsTransientSerializationFailure(ex)) ++ { ++ // debug-log; optional jittered back-off; loop retries ++ } ++} ++ ++static bool IsTransientSerializationFailure(Exception ex) => ++ ex is DbUpdateConcurrencyException ++ || (ex is PostgresException pg ++ && pg.SqlState is PostgresErrorCodes.SerializationFailure ++ or PostgresErrorCodes.DeadlockDetected) ++ || (ex is SqlException sql ++ && sql.Number is 1205 or 41301 or 41305); ++``` ++ + | Criterion | Score | + |---|---| + | Simplicity | 90 | +@@ -232,7 +260,7 @@ Rules of thumb when escalating: + + **Popularity: ~28%** — the default style of serious modern EF Core codebases. + +-**Strengths:** zero ceremony; change tracker accumulates work cheaply in memory, one short transaction at the end holds locks briefly (great under concurrency); trivially testable (real context + testcontainers/SQLite); scales linearly to modular/DDD designs — N modules simply means N independent contexts. ++**Strengths:** zero ceremony; change tracker accumulates work cheaply in memory, one short transaction at the end holds locks briefly (great under concurrency); trivially testable against a real database (Testcontainers) — SQLite is fine for CRUD-only tests, but optimistic-concurrency tests need the real provider (SQLite does not implement rowversion/xmin semantics, so `DbUpdateConcurrencyException` tests written against it lie); scales linearly to modular/DDD designs — N modules simply means N independent contexts. + + **Weakness:** correctness relies on **discipline** — nothing mechanically stops a developer from calling `SaveChanges` where no enclosing transaction exists (the exact bug from Option 1). That gap is what Option 4 closes. + +@@ -266,18 +294,13 @@ public sealed class TransactionBehavior( + return await next(); + + await using var tx = await db.Database.BeginTransactionAsync(ct); +- try +- { +- var response = await next(); +- await db.SaveChangesAsync(ct); // the ONLY save for this use case — unless the handler flushed mid-flow; then this is a no-op +- await tx.CommitAsync(ct); +- return response; +- } +- catch +- { +- await tx.RollbackAsync(ct); +- throw; +- } ++ var response = await next(); ++ await db.SaveChangesAsync(ct); // the ONLY save for this use case — unless the handler flushed mid-flow; then this is a no-op ++ await tx.CommitAsync(ct); ++ return response; ++ ++ // No catch/rollback needed: disposing without commit rolls everything back ++ // (the same rule Option 6 relies on). + } + } + ``` +@@ -317,7 +340,11 @@ public sealed class BookTicketHandler(BookingDbContext db) + - Mid-flow saves are also the natural place to notice an external side effect trying to sneak inside your transaction — treat that as a signal to move it behind events/outbox instead. + - **Never let one handler commit two module contexts** — that would be a distributed transaction in disguise. Cross-module workflows go through events/outbox (playbook below). + +-**Verdict:** best-in-class when paired with Option 3 — 3 defines the shape, 4 enforces it. ++#### Enforcement alternative — doctrine test instead of a behavior ++ ++When a codebase has no mediator pipeline (handlers own persistence directly), importing MediatR *solely* for commit enforcement is ceremony. A cheaper mechanical gate with the same guarantee: an architecture/source test asserting `SaveChangesAsync` is invoked only by `*Handler` types, seeding services, and sanctioned helpers — any other caller fails the build. It checks structure rather than wrapping execution, so it also catches stray commits in non-pipeline code paths (hosted services, background jobs) that a behavior would never see. ++ ++**Verdict:** best-in-class when paired with Option 3 — 3 defines the shape, 4 enforces it. Where a behavior doesn't fit the architecture, enforce 3 with a build-time doctrine test instead. + + --- + +@@ -358,8 +385,6 @@ await db.SaveChangesAsync(ct); // state + event are atomic together + + --- + +---- +- + ### Option 6 — One transaction per HTTP request (action filter / middleware) + + **What it is:** infrastructure opens a transaction at the start of every mutating HTTP request and commits when the response turns out successful (2xx); rolls back otherwise. Usually an `IAsyncActionFilter` attribute on controllers or middleware around minimal-API endpoints. Services just use the scoped context — nobody calls `BeginTransaction` explicitly, and the HTTP layer owns atomicity. +@@ -410,6 +435,7 @@ public IActionResult Book(BookTicketCommand cmd) { ... } // zero save ceremony + - **Transaction scope = request scope.** A bulk endpoint performing several independent logical operations shares one transaction; one failure nukes all of them even when most succeeded legitimately. + - **Only exists for HTTP.** Background jobs, message consumers, hosted services don't pass through the filter — you'll reinvent Option 4 there anyway, so you end up maintaining two commit mechanisms. + - Encourages "one request = one use case" thinking that breaks as endpoints grow. ++- **Result-shape coupling:** the sample only inspects `ObjectResult`; `StatusCodeResult`, redirects carrying errors, and results rewritten by later filters bypass the check — commit decisions silently diverge from what clients actually see. + + **Verdict:** acceptable default for small MVC CRUD apps and admin panels; becomes structurally wrong once requests do more than one logical thing. + +@@ -529,28 +555,6 @@ Three local transactions, zero distributed ones, each independently retryable + + --- + +-### Microservices: which option wins there? +- +-The options above were described for monolith/modular monolith, but microservices neither add a new kind of option nor remove one — they **collapse the choice**: +- +-- **Inside each service: Options 3 + 4, unchanged.** Each service is one bounded context owning its own `DbContext` and its own database; commands still get exactly one commit point, enforced by the pipeline behavior. If one service feels it needs two internal contexts, question its boundaries first. +-- **Between services: Option 5 stops being an optimization and becomes *the architecture*.** Database-per-service means a cross-service transaction cannot exist even in principle — 2PC/distributed transactions are effectively dead in modern practice — so every workflow spanning services is a **saga** (choreographed or orchestrated) built on outbox + message broker. +- +-What changes versus the modular-monolith playbook is mostly transport and failure semantics, not the unit-of-work model: +- +-| Concern | Modular monolith | Microservices | +-|---|---|---| +-| Event transport | in-process dispatcher / Worker over shared infra | message broker (RabbitMQ / Kafka / Azure Service Bus) | +-| Delivery guarantees | effectively once | at-least-once → consumers **must be idempotent** | +-| Workflow failures | local retry, rarely visible | saga compensation ("cancel booking when payment fails") | +-| Consistency visibility | usually hidden from users | often user-visible → APIs designed for pending states (`202 Accepted` + status endpoint) | +- +-What does **not** change: ad-hoc saves are still broken; a global UoW goes from anti-pattern to *physical impossibility* (no shared process, no shared database); optimistic concurrency remains per-aggregate inside each service; and the intra-service invariant stays *one commit point per command*. +- +-**Stated plainly — go-to baseline for microservices:** Options 3+4 inside every service, Option 5 (outbox + sagas) as the inter-service contract. Nothing else survives contact with distributed reality. +- +---- +- + ## 2. Master comparison + + Scores 1–100. Popularity figures are rough estimates of production ASP.NET + EF Core codebases (styles overlap, so they don't sum to 100). +@@ -610,7 +614,15 @@ catch (DbUpdateConcurrencyException) + } + ``` + +-This converts silent corruption into a detectable conflict (map to HTTP 409 / retry). For extreme hot spots (single counter row hammered by hundreds of requests), prefer making the decrement itself atomic — `UPDATE ... SET SeatsAvailable -= @n WHERE Id = @id AND SeatsAvailable >= @n` — or serializable isolation scoped to that one command. Bigger/longer transactions are *never* the fix. In a multi-module system these tokens live on aggregates inside their **owning** context; cross-module write races don't exist by design, because modules never write each other's tables. ++This converts silent corruption into a detectable conflict (map to HTTP 409 / retry). For extreme hot spots (single counter row hammered by hundreds of requests), prefer making the decrement itself atomic. In EF Core 7+ that is `ExecuteUpdateAsync` — type-safe, single round-trip, compiles to the classic conditional statement: ++ ++```csharp ++await db.Events ++ .Where(e => e.Id == eventId && e.SeatsAvailable > 0) ++ .ExecuteUpdateAsync(s => s.SetProperty(e => e.SeatsAvailable, v => v - seats), ct); ++``` ++ ++(equivalent SQL: `UPDATE ... SET SeatsAvailable = SeatsAvailable - @n WHERE Id = @id AND SeatsAvailable > 0`), or serializable isolation scoped to that one command. Bigger/longer transactions are *never* the fix. In a multi-module system these tokens live on aggregates inside their **owning** context; cross-module write races don't exist by design, because modules never write each other's tables. + + --- + +@@ -624,6 +636,28 @@ This converts silent corruption into a detectable conflict (map to HTTP 409 / re + - **External side effects** (payments, emails): out of the handler's transaction; trigger via after-commit events/outbox so a rollback can't strand them. + - **Never:** `IUnitOfWork` wrapping `DbContext`, repositories that merely re-expose `DbSet`, a UoW injected into every module "so they share transactions," or `TransactionScope` spanning two module contexts (Option 7). + ++--- ++ ++## 5. Microservices: which option wins there? ++ ++Microservices neither add a new kind of option nor remove one — they **collapse the choice**: ++ ++- **Inside each service: Options 3 + 4, unchanged.** Each service is one bounded context owning its own `DbContext` and its own database; commands still get exactly one commit point, enforced by the pipeline behavior. If one service feels it needs two internal contexts, question its boundaries first. ++- **Between services: Option 5 stops being an optimization and becomes *the architecture*.** Database-per-service means a cross-service transaction cannot exist even in principle — 2PC/distributed transactions are effectively dead in modern practice — so every workflow spanning services is a **saga** (choreographed or orchestrated) built on outbox + message broker. ++ ++What changes versus the modular-monolith playbook is mostly transport and failure semantics, not the unit-of-work model: ++ ++| Concern | Modular monolith | Microservices | ++|---|---|---| ++| Event transport | in-process dispatcher / Worker over shared infra | message broker (RabbitMQ / Kafka / Azure Service Bus) | ++| Delivery guarantees | effectively once | at-least-once → consumers **must be idempotent** | ++| Workflow failures | local retry, rarely visible | saga compensation ("cancel booking when payment fails") | ++| Consistency visibility | usually hidden from users | often user-visible → APIs designed for pending states (`202 Accepted` + status endpoint) | ++ ++What does **not** change: ad-hoc saves are still broken; a global UoW goes from anti-pattern to *physical impossibility* (no shared process, no shared database); optimistic concurrency remains per-aggregate inside each service; and the intra-service invariant stays *one commit point per command*. ++ ++**Stated plainly — go-to baseline for microservices:** Options 3+4 inside every service, Option 5's outbox + sagas between services. Nothing else survives contact with distributed reality. ++ + ### Recommendation for this repo (2026-08-26 audit) + + This codebase implements Option 3 with Option 5 deliberately deferred: