diff --git a/src/.dockerignore b/src/.dockerignore deleted file mode 100644 index 3aae53927b..0000000000 --- a/src/.dockerignore +++ /dev/null @@ -1,32 +0,0 @@ -# Include any files or directories that you don't want to be copied to your -# container here (e.g., local build artifacts, temporary files, etc.). -# -# For more help, visit the .dockerignore file reference guide at -# https://docs.docker.com/engine/reference/builder/#dockerignore-file - -**/.DS_Store -**/.classpath -**/.dockerignore -**/.env -**/.git -**/.gitignore -**/.project -**/.settings -**/.toolstarget -**/.vs -**/.vscode -**/*.*proj.user -**/*.dbmdl -**/*.jfm -**/bin -**/charts -**/docker-compose* -**/compose* -**/Dockerfile* -**/node_modules -**/npm-debug.log -**/obj -**/secrets.dev.yaml -**/values.dev.yaml -LICENSE -README.md diff --git a/src/.editorconfig b/src/.editorconfig deleted file mode 100644 index b3fa9a701e..0000000000 --- a/src/.editorconfig +++ /dev/null @@ -1,397 +0,0 @@ -root = true - -# All files -[*] -indent_style = space - -# Xml files -[*.{xml,csproj,props,targets,ruleset,nuspec,resx}] -indent_size = 2 - -# Json files -[*.{json,config,nswag}] -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 - -#### .NET Coding Conventions #### -[*.{cs,vb}] - -# Organize usings -dotnet_separate_import_directive_groups = false -dotnet_sort_system_directives_first = true -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_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_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_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:suggestion - -# 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_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_local_function = true:warning -csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async:silent - -# Code-block preferences -csharp_prefer_braces = true:silent -csharp_prefer_simple_using_statement = true:suggestion - -# Expression-level preferences -csharp_prefer_simple_default_expression = true:suggestion -csharp_style_deconstructed_variable_declaration = true:suggestion -csharp_style_inlined_variable_declaration = true:suggestion -csharp_style_pattern_local_over_anonymous_function = true:suggestion -csharp_style_prefer_index_operator = true:suggestion -csharp_style_prefer_range_operator = 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_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 = false -csharp_space_before_colon_in_inheritance_clause = true -csharp_space_before_comma = false -csharp_space_before_dot = false -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 = true -csharp_style_namespace_declarations = file_scoped:silent -csharp_style_prefer_method_group_conversion = true:silent -csharp_style_prefer_top_level_statements = true:silent -csharp_style_prefer_primary_constructors = true:suggestion -csharp_style_prefer_null_check_over_type_check = true:suggestion -csharp_style_prefer_local_over_anonymous_function = true:suggestion -csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion -csharp_style_prefer_tuple_swap = true:suggestion -csharp_style_prefer_utf8_string_literals = true:suggestion -dotnet_diagnostic.CA1032.severity = none -dotnet_diagnostic.CA1812.severity = none -dotnet_diagnostic.S6667.severity = none - -#### 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 - -dotnet_style_namespace_match_folder = true:suggestion - -dotnet_diagnostic.CS1591.severity = none -dotnet_diagnostic.CA1724.severity = none -dotnet_diagnostic.CA1305.severity = none -dotnet_diagnostic.CA1040.severity = none -dotnet_diagnostic.CA1848.severity = none -dotnet_diagnostic.CA1034.severity = none -tab_width = 4 -indent_size = 4 -end_of_line = lf -dotnet_diagnostic.CA1711.severity = none -dotnet_diagnostic.CA1716.severity = none -dotnet_diagnostic.CA1062.severity = none -dotnet_diagnostic.CA1031.severity = none -dotnet_diagnostic.CA1861.severity = none -dotnet_diagnostic.CA2007.severity = none \ No newline at end of file diff --git a/src/Directory.Build.props b/src/Directory.Build.props deleted file mode 100644 index cf4b4bb3de..0000000000 --- a/src/Directory.Build.props +++ /dev/null @@ -1,18 +0,0 @@ - - - net9.0 - false - false - true - true - enable - enable - true - latest - All - 2.0.4-rc;latest - - - - - \ No newline at end of file diff --git a/src/Dockerfile.Blazor b/src/Dockerfile.Blazor deleted file mode 100644 index 2438ffea64..0000000000 --- a/src/Dockerfile.Blazor +++ /dev/null @@ -1,13 +0,0 @@ -FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build-env -WORKDIR /app - -COPY . ./ -RUN dotnet publish ./apps/blazor/client/Client.csproj -c Release -o output - -FROM nginx:alpine -WORKDIR /usr/share/nginx/html -COPY --from=build-env /app/output/wwwroot . - -COPY ./apps/blazor/nginx.conf /etc/nginx/nginx.conf - -EXPOSE 80 \ No newline at end of file diff --git a/src/FSH.Starter.sln b/src/FSH.Starter.sln deleted file mode 100644 index 904c59f770..0000000000 --- a/src/FSH.Starter.sln +++ /dev/null @@ -1,287 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.0.31903.59 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Modules", "Modules", "{F3DF5AC5-8CDC-46D4-969D-1245A6880215}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{A32CEFB3-4E50-401E-8835-787534414F41}" - ProjectSection(SolutionItems) = preProject - .editorconfig = .editorconfig - Directory.Build.props = Directory.Build.props - Directory.Packages.props = Directory.Packages.props - README.md = README.md - EndProjectSection -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Catalog", "Catalog", "{93324D12-DE1B-4C1B-934A-92AA140FF6F6}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Todo", "Todo", "{79981A5A-207A-4A16-A21B-5E80394082F6}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "_Framework", "_Framework", "{05248A38-0F34-4E59-A3D1-B07097987AFB}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Migrations", "Migrations", "{12F8343D-20A6-4E24-B0F5-3A66F2228CF6}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "WebApi", "WebApi", "{CE64E92B-E088-46FB-9028-7FB6B67DEC55}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Blazor", "Blazor", "{2B1F75CE-07A6-4C19-A2E3-F9E062CFDDFB}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Infrastructure", "api\framework\Infrastructure\Infrastructure.csproj", "{294D6FF8-9379-4AB8-A203-8D0CC85FBFBB}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Core", "api\framework\Core\Core.csproj", "{A1D828E4-6B83-4BA2-B8E9-B21CE3BE8A31}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Server", "api\server\Server.csproj", "{86BD3DF6-A3E9-4839-8036-813A20DC8AD6}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MSSQL", "api\migrations\MSSQL\MSSQL.csproj", "{ECCEA352-8953-49D6-8F87-8AB361499420}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PostgreSQL", "api\migrations\PostgreSQL\PostgreSQL.csproj", "{D64AD07C-A711-42D8-8653-EDCD7A825A44}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Todo", "api\modules\Todo\Todo.csproj", "{B3866EEF-8F46-4302-ABAC-A95EE2F27331}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Catalog.Application", "api\modules\Catalog\Catalog.Application\Catalog.Application.csproj", "{8C7DAF8E-F792-4092-8BBF-31A6B898B39A}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Catalog.Domain", "api\modules\Catalog\Catalog.Domain\Catalog.Domain.csproj", "{B15705B5-041C-4F1E-8342-AD03182EDD42}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Catalog.Infrastructure", "api\modules\Catalog\Catalog.Infrastructure\Catalog.Infrastructure.csproj", "{89FE1C3B-29D3-48A8-8E7D-90C261D266C5}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Client", "apps\blazor\client\Client.csproj", "{BCE4A428-8B97-4B56-AE45-496EE3906667}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Infrastructure", "apps\blazor\infrastructure\Infrastructure.csproj", "{27BEF279-AE73-43DC-92A9-FD7021A999D0}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Shared", "apps\blazor\shared\Shared.csproj", "{34359707-CE66-4DF0-9EF4-D7544B615564}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Aspire", "Aspire", "{D36E77BC-4568-4BC8-9506-1EFB7B1CD335}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ServiceDefaults", "aspire\service-defaults\ServiceDefaults.csproj", "{990CA37A-86D3-4FE6-B777-3CF0DDAC6BF6}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Host", "aspire\host\Host.csproj", "{2119CE89-308D-4932-BFCE-8CDC0A05EB9E}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Shared", "Shared\Shared.csproj", "{49AA63BF-3DBA-4490-9470-5AE0EB7F49F0}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Docs", "Docs", "{FE1B1E84-F993-4840-9CAB-9082EB523FDD}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Auth", "Auth", "{F17769D7-0E41-4E80-BDD4-282EBE7B5199}" - ProjectSection(SolutionItems) = preProject - GetToken.http = GetToken.http - EndProjectSection -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Debug|x64 = Debug|x64 - Debug|x86 = Debug|x86 - Release|Any CPU = Release|Any CPU - Release|x64 = Release|x64 - Release|x86 = Release|x86 - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {294D6FF8-9379-4AB8-A203-8D0CC85FBFBB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {294D6FF8-9379-4AB8-A203-8D0CC85FBFBB}.Debug|Any CPU.Build.0 = Debug|Any CPU - {294D6FF8-9379-4AB8-A203-8D0CC85FBFBB}.Debug|x64.ActiveCfg = Debug|Any CPU - {294D6FF8-9379-4AB8-A203-8D0CC85FBFBB}.Debug|x64.Build.0 = Debug|Any CPU - {294D6FF8-9379-4AB8-A203-8D0CC85FBFBB}.Debug|x86.ActiveCfg = Debug|Any CPU - {294D6FF8-9379-4AB8-A203-8D0CC85FBFBB}.Debug|x86.Build.0 = Debug|Any CPU - {294D6FF8-9379-4AB8-A203-8D0CC85FBFBB}.Release|Any CPU.ActiveCfg = Release|Any CPU - {294D6FF8-9379-4AB8-A203-8D0CC85FBFBB}.Release|Any CPU.Build.0 = Release|Any CPU - {294D6FF8-9379-4AB8-A203-8D0CC85FBFBB}.Release|x64.ActiveCfg = Release|Any CPU - {294D6FF8-9379-4AB8-A203-8D0CC85FBFBB}.Release|x64.Build.0 = Release|Any CPU - {294D6FF8-9379-4AB8-A203-8D0CC85FBFBB}.Release|x86.ActiveCfg = Release|Any CPU - {294D6FF8-9379-4AB8-A203-8D0CC85FBFBB}.Release|x86.Build.0 = Release|Any CPU - {A1D828E4-6B83-4BA2-B8E9-B21CE3BE8A31}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {A1D828E4-6B83-4BA2-B8E9-B21CE3BE8A31}.Debug|Any CPU.Build.0 = Debug|Any CPU - {A1D828E4-6B83-4BA2-B8E9-B21CE3BE8A31}.Debug|x64.ActiveCfg = Debug|Any CPU - {A1D828E4-6B83-4BA2-B8E9-B21CE3BE8A31}.Debug|x64.Build.0 = Debug|Any CPU - {A1D828E4-6B83-4BA2-B8E9-B21CE3BE8A31}.Debug|x86.ActiveCfg = Debug|Any CPU - {A1D828E4-6B83-4BA2-B8E9-B21CE3BE8A31}.Debug|x86.Build.0 = Debug|Any CPU - {A1D828E4-6B83-4BA2-B8E9-B21CE3BE8A31}.Release|Any CPU.ActiveCfg = Release|Any CPU - {A1D828E4-6B83-4BA2-B8E9-B21CE3BE8A31}.Release|Any CPU.Build.0 = Release|Any CPU - {A1D828E4-6B83-4BA2-B8E9-B21CE3BE8A31}.Release|x64.ActiveCfg = Release|Any CPU - {A1D828E4-6B83-4BA2-B8E9-B21CE3BE8A31}.Release|x64.Build.0 = Release|Any CPU - {A1D828E4-6B83-4BA2-B8E9-B21CE3BE8A31}.Release|x86.ActiveCfg = Release|Any CPU - {A1D828E4-6B83-4BA2-B8E9-B21CE3BE8A31}.Release|x86.Build.0 = Release|Any CPU - {86BD3DF6-A3E9-4839-8036-813A20DC8AD6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {86BD3DF6-A3E9-4839-8036-813A20DC8AD6}.Debug|Any CPU.Build.0 = Debug|Any CPU - {86BD3DF6-A3E9-4839-8036-813A20DC8AD6}.Debug|x64.ActiveCfg = Debug|Any CPU - {86BD3DF6-A3E9-4839-8036-813A20DC8AD6}.Debug|x64.Build.0 = Debug|Any CPU - {86BD3DF6-A3E9-4839-8036-813A20DC8AD6}.Debug|x86.ActiveCfg = Debug|Any CPU - {86BD3DF6-A3E9-4839-8036-813A20DC8AD6}.Debug|x86.Build.0 = Debug|Any CPU - {86BD3DF6-A3E9-4839-8036-813A20DC8AD6}.Release|Any CPU.ActiveCfg = Release|Any CPU - {86BD3DF6-A3E9-4839-8036-813A20DC8AD6}.Release|Any CPU.Build.0 = Release|Any CPU - {86BD3DF6-A3E9-4839-8036-813A20DC8AD6}.Release|x64.ActiveCfg = Release|Any CPU - {86BD3DF6-A3E9-4839-8036-813A20DC8AD6}.Release|x64.Build.0 = Release|Any CPU - {86BD3DF6-A3E9-4839-8036-813A20DC8AD6}.Release|x86.ActiveCfg = Release|Any CPU - {86BD3DF6-A3E9-4839-8036-813A20DC8AD6}.Release|x86.Build.0 = Release|Any CPU - {ECCEA352-8953-49D6-8F87-8AB361499420}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {ECCEA352-8953-49D6-8F87-8AB361499420}.Debug|Any CPU.Build.0 = Debug|Any CPU - {ECCEA352-8953-49D6-8F87-8AB361499420}.Debug|x64.ActiveCfg = Debug|Any CPU - {ECCEA352-8953-49D6-8F87-8AB361499420}.Debug|x64.Build.0 = Debug|Any CPU - {ECCEA352-8953-49D6-8F87-8AB361499420}.Debug|x86.ActiveCfg = Debug|Any CPU - {ECCEA352-8953-49D6-8F87-8AB361499420}.Debug|x86.Build.0 = Debug|Any CPU - {ECCEA352-8953-49D6-8F87-8AB361499420}.Release|Any CPU.ActiveCfg = Release|Any CPU - {ECCEA352-8953-49D6-8F87-8AB361499420}.Release|Any CPU.Build.0 = Release|Any CPU - {ECCEA352-8953-49D6-8F87-8AB361499420}.Release|x64.ActiveCfg = Release|Any CPU - {ECCEA352-8953-49D6-8F87-8AB361499420}.Release|x64.Build.0 = Release|Any CPU - {ECCEA352-8953-49D6-8F87-8AB361499420}.Release|x86.ActiveCfg = Release|Any CPU - {ECCEA352-8953-49D6-8F87-8AB361499420}.Release|x86.Build.0 = Release|Any CPU - {D64AD07C-A711-42D8-8653-EDCD7A825A44}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {D64AD07C-A711-42D8-8653-EDCD7A825A44}.Debug|Any CPU.Build.0 = Debug|Any CPU - {D64AD07C-A711-42D8-8653-EDCD7A825A44}.Debug|x64.ActiveCfg = Debug|Any CPU - {D64AD07C-A711-42D8-8653-EDCD7A825A44}.Debug|x64.Build.0 = Debug|Any CPU - {D64AD07C-A711-42D8-8653-EDCD7A825A44}.Debug|x86.ActiveCfg = Debug|Any CPU - {D64AD07C-A711-42D8-8653-EDCD7A825A44}.Debug|x86.Build.0 = Debug|Any CPU - {D64AD07C-A711-42D8-8653-EDCD7A825A44}.Release|Any CPU.ActiveCfg = Release|Any CPU - {D64AD07C-A711-42D8-8653-EDCD7A825A44}.Release|Any CPU.Build.0 = Release|Any CPU - {D64AD07C-A711-42D8-8653-EDCD7A825A44}.Release|x64.ActiveCfg = Release|Any CPU - {D64AD07C-A711-42D8-8653-EDCD7A825A44}.Release|x64.Build.0 = Release|Any CPU - {D64AD07C-A711-42D8-8653-EDCD7A825A44}.Release|x86.ActiveCfg = Release|Any CPU - {D64AD07C-A711-42D8-8653-EDCD7A825A44}.Release|x86.Build.0 = Release|Any CPU - {B3866EEF-8F46-4302-ABAC-A95EE2F27331}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {B3866EEF-8F46-4302-ABAC-A95EE2F27331}.Debug|Any CPU.Build.0 = Debug|Any CPU - {B3866EEF-8F46-4302-ABAC-A95EE2F27331}.Debug|x64.ActiveCfg = Debug|Any CPU - {B3866EEF-8F46-4302-ABAC-A95EE2F27331}.Debug|x64.Build.0 = Debug|Any CPU - {B3866EEF-8F46-4302-ABAC-A95EE2F27331}.Debug|x86.ActiveCfg = Debug|Any CPU - {B3866EEF-8F46-4302-ABAC-A95EE2F27331}.Debug|x86.Build.0 = Debug|Any CPU - {B3866EEF-8F46-4302-ABAC-A95EE2F27331}.Release|Any CPU.ActiveCfg = Release|Any CPU - {B3866EEF-8F46-4302-ABAC-A95EE2F27331}.Release|Any CPU.Build.0 = Release|Any CPU - {B3866EEF-8F46-4302-ABAC-A95EE2F27331}.Release|x64.ActiveCfg = Release|Any CPU - {B3866EEF-8F46-4302-ABAC-A95EE2F27331}.Release|x64.Build.0 = Release|Any CPU - {B3866EEF-8F46-4302-ABAC-A95EE2F27331}.Release|x86.ActiveCfg = Release|Any CPU - {B3866EEF-8F46-4302-ABAC-A95EE2F27331}.Release|x86.Build.0 = Release|Any CPU - {8C7DAF8E-F792-4092-8BBF-31A6B898B39A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {8C7DAF8E-F792-4092-8BBF-31A6B898B39A}.Debug|Any CPU.Build.0 = Debug|Any CPU - {8C7DAF8E-F792-4092-8BBF-31A6B898B39A}.Debug|x64.ActiveCfg = Debug|Any CPU - {8C7DAF8E-F792-4092-8BBF-31A6B898B39A}.Debug|x64.Build.0 = Debug|Any CPU - {8C7DAF8E-F792-4092-8BBF-31A6B898B39A}.Debug|x86.ActiveCfg = Debug|Any CPU - {8C7DAF8E-F792-4092-8BBF-31A6B898B39A}.Debug|x86.Build.0 = Debug|Any CPU - {8C7DAF8E-F792-4092-8BBF-31A6B898B39A}.Release|Any CPU.ActiveCfg = Release|Any CPU - {8C7DAF8E-F792-4092-8BBF-31A6B898B39A}.Release|Any CPU.Build.0 = Release|Any CPU - {8C7DAF8E-F792-4092-8BBF-31A6B898B39A}.Release|x64.ActiveCfg = Release|Any CPU - {8C7DAF8E-F792-4092-8BBF-31A6B898B39A}.Release|x64.Build.0 = Release|Any CPU - {8C7DAF8E-F792-4092-8BBF-31A6B898B39A}.Release|x86.ActiveCfg = Release|Any CPU - {8C7DAF8E-F792-4092-8BBF-31A6B898B39A}.Release|x86.Build.0 = Release|Any CPU - {B15705B5-041C-4F1E-8342-AD03182EDD42}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {B15705B5-041C-4F1E-8342-AD03182EDD42}.Debug|Any CPU.Build.0 = Debug|Any CPU - {B15705B5-041C-4F1E-8342-AD03182EDD42}.Debug|x64.ActiveCfg = Debug|Any CPU - {B15705B5-041C-4F1E-8342-AD03182EDD42}.Debug|x64.Build.0 = Debug|Any CPU - {B15705B5-041C-4F1E-8342-AD03182EDD42}.Debug|x86.ActiveCfg = Debug|Any CPU - {B15705B5-041C-4F1E-8342-AD03182EDD42}.Debug|x86.Build.0 = Debug|Any CPU - {B15705B5-041C-4F1E-8342-AD03182EDD42}.Release|Any CPU.ActiveCfg = Release|Any CPU - {B15705B5-041C-4F1E-8342-AD03182EDD42}.Release|Any CPU.Build.0 = Release|Any CPU - {B15705B5-041C-4F1E-8342-AD03182EDD42}.Release|x64.ActiveCfg = Release|Any CPU - {B15705B5-041C-4F1E-8342-AD03182EDD42}.Release|x64.Build.0 = Release|Any CPU - {B15705B5-041C-4F1E-8342-AD03182EDD42}.Release|x86.ActiveCfg = Release|Any CPU - {B15705B5-041C-4F1E-8342-AD03182EDD42}.Release|x86.Build.0 = Release|Any CPU - {89FE1C3B-29D3-48A8-8E7D-90C261D266C5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {89FE1C3B-29D3-48A8-8E7D-90C261D266C5}.Debug|Any CPU.Build.0 = Debug|Any CPU - {89FE1C3B-29D3-48A8-8E7D-90C261D266C5}.Debug|x64.ActiveCfg = Debug|Any CPU - {89FE1C3B-29D3-48A8-8E7D-90C261D266C5}.Debug|x64.Build.0 = Debug|Any CPU - {89FE1C3B-29D3-48A8-8E7D-90C261D266C5}.Debug|x86.ActiveCfg = Debug|Any CPU - {89FE1C3B-29D3-48A8-8E7D-90C261D266C5}.Debug|x86.Build.0 = Debug|Any CPU - {89FE1C3B-29D3-48A8-8E7D-90C261D266C5}.Release|Any CPU.ActiveCfg = Release|Any CPU - {89FE1C3B-29D3-48A8-8E7D-90C261D266C5}.Release|Any CPU.Build.0 = Release|Any CPU - {89FE1C3B-29D3-48A8-8E7D-90C261D266C5}.Release|x64.ActiveCfg = Release|Any CPU - {89FE1C3B-29D3-48A8-8E7D-90C261D266C5}.Release|x64.Build.0 = Release|Any CPU - {89FE1C3B-29D3-48A8-8E7D-90C261D266C5}.Release|x86.ActiveCfg = Release|Any CPU - {89FE1C3B-29D3-48A8-8E7D-90C261D266C5}.Release|x86.Build.0 = Release|Any CPU - {BCE4A428-8B97-4B56-AE45-496EE3906667}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {BCE4A428-8B97-4B56-AE45-496EE3906667}.Debug|Any CPU.Build.0 = Debug|Any CPU - {BCE4A428-8B97-4B56-AE45-496EE3906667}.Debug|x64.ActiveCfg = Debug|Any CPU - {BCE4A428-8B97-4B56-AE45-496EE3906667}.Debug|x64.Build.0 = Debug|Any CPU - {BCE4A428-8B97-4B56-AE45-496EE3906667}.Debug|x86.ActiveCfg = Debug|Any CPU - {BCE4A428-8B97-4B56-AE45-496EE3906667}.Debug|x86.Build.0 = Debug|Any CPU - {BCE4A428-8B97-4B56-AE45-496EE3906667}.Release|Any CPU.ActiveCfg = Release|Any CPU - {BCE4A428-8B97-4B56-AE45-496EE3906667}.Release|Any CPU.Build.0 = Release|Any CPU - {BCE4A428-8B97-4B56-AE45-496EE3906667}.Release|x64.ActiveCfg = Release|Any CPU - {BCE4A428-8B97-4B56-AE45-496EE3906667}.Release|x64.Build.0 = Release|Any CPU - {BCE4A428-8B97-4B56-AE45-496EE3906667}.Release|x86.ActiveCfg = Release|Any CPU - {BCE4A428-8B97-4B56-AE45-496EE3906667}.Release|x86.Build.0 = Release|Any CPU - {27BEF279-AE73-43DC-92A9-FD7021A999D0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {27BEF279-AE73-43DC-92A9-FD7021A999D0}.Debug|Any CPU.Build.0 = Debug|Any CPU - {27BEF279-AE73-43DC-92A9-FD7021A999D0}.Debug|x64.ActiveCfg = Debug|Any CPU - {27BEF279-AE73-43DC-92A9-FD7021A999D0}.Debug|x64.Build.0 = Debug|Any CPU - {27BEF279-AE73-43DC-92A9-FD7021A999D0}.Debug|x86.ActiveCfg = Debug|Any CPU - {27BEF279-AE73-43DC-92A9-FD7021A999D0}.Debug|x86.Build.0 = Debug|Any CPU - {27BEF279-AE73-43DC-92A9-FD7021A999D0}.Release|Any CPU.ActiveCfg = Release|Any CPU - {27BEF279-AE73-43DC-92A9-FD7021A999D0}.Release|Any CPU.Build.0 = Release|Any CPU - {27BEF279-AE73-43DC-92A9-FD7021A999D0}.Release|x64.ActiveCfg = Release|Any CPU - {27BEF279-AE73-43DC-92A9-FD7021A999D0}.Release|x64.Build.0 = Release|Any CPU - {27BEF279-AE73-43DC-92A9-FD7021A999D0}.Release|x86.ActiveCfg = Release|Any CPU - {27BEF279-AE73-43DC-92A9-FD7021A999D0}.Release|x86.Build.0 = Release|Any CPU - {34359707-CE66-4DF0-9EF4-D7544B615564}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {34359707-CE66-4DF0-9EF4-D7544B615564}.Debug|Any CPU.Build.0 = Debug|Any CPU - {34359707-CE66-4DF0-9EF4-D7544B615564}.Debug|x64.ActiveCfg = Debug|Any CPU - {34359707-CE66-4DF0-9EF4-D7544B615564}.Debug|x64.Build.0 = Debug|Any CPU - {34359707-CE66-4DF0-9EF4-D7544B615564}.Debug|x86.ActiveCfg = Debug|Any CPU - {34359707-CE66-4DF0-9EF4-D7544B615564}.Debug|x86.Build.0 = Debug|Any CPU - {34359707-CE66-4DF0-9EF4-D7544B615564}.Release|Any CPU.ActiveCfg = Release|Any CPU - {34359707-CE66-4DF0-9EF4-D7544B615564}.Release|Any CPU.Build.0 = Release|Any CPU - {34359707-CE66-4DF0-9EF4-D7544B615564}.Release|x64.ActiveCfg = Release|Any CPU - {34359707-CE66-4DF0-9EF4-D7544B615564}.Release|x64.Build.0 = Release|Any CPU - {34359707-CE66-4DF0-9EF4-D7544B615564}.Release|x86.ActiveCfg = Release|Any CPU - {34359707-CE66-4DF0-9EF4-D7544B615564}.Release|x86.Build.0 = Release|Any CPU - {990CA37A-86D3-4FE6-B777-3CF0DDAC6BF6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {990CA37A-86D3-4FE6-B777-3CF0DDAC6BF6}.Debug|Any CPU.Build.0 = Debug|Any CPU - {990CA37A-86D3-4FE6-B777-3CF0DDAC6BF6}.Debug|x64.ActiveCfg = Debug|Any CPU - {990CA37A-86D3-4FE6-B777-3CF0DDAC6BF6}.Debug|x64.Build.0 = Debug|Any CPU - {990CA37A-86D3-4FE6-B777-3CF0DDAC6BF6}.Debug|x86.ActiveCfg = Debug|Any CPU - {990CA37A-86D3-4FE6-B777-3CF0DDAC6BF6}.Debug|x86.Build.0 = Debug|Any CPU - {990CA37A-86D3-4FE6-B777-3CF0DDAC6BF6}.Release|Any CPU.ActiveCfg = Release|Any CPU - {990CA37A-86D3-4FE6-B777-3CF0DDAC6BF6}.Release|Any CPU.Build.0 = Release|Any CPU - {990CA37A-86D3-4FE6-B777-3CF0DDAC6BF6}.Release|x64.ActiveCfg = Release|Any CPU - {990CA37A-86D3-4FE6-B777-3CF0DDAC6BF6}.Release|x64.Build.0 = Release|Any CPU - {990CA37A-86D3-4FE6-B777-3CF0DDAC6BF6}.Release|x86.ActiveCfg = Release|Any CPU - {990CA37A-86D3-4FE6-B777-3CF0DDAC6BF6}.Release|x86.Build.0 = Release|Any CPU - {2119CE89-308D-4932-BFCE-8CDC0A05EB9E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {2119CE89-308D-4932-BFCE-8CDC0A05EB9E}.Debug|Any CPU.Build.0 = Debug|Any CPU - {2119CE89-308D-4932-BFCE-8CDC0A05EB9E}.Debug|x64.ActiveCfg = Debug|Any CPU - {2119CE89-308D-4932-BFCE-8CDC0A05EB9E}.Debug|x64.Build.0 = Debug|Any CPU - {2119CE89-308D-4932-BFCE-8CDC0A05EB9E}.Debug|x86.ActiveCfg = Debug|Any CPU - {2119CE89-308D-4932-BFCE-8CDC0A05EB9E}.Debug|x86.Build.0 = Debug|Any CPU - {2119CE89-308D-4932-BFCE-8CDC0A05EB9E}.Release|Any CPU.ActiveCfg = Release|Any CPU - {2119CE89-308D-4932-BFCE-8CDC0A05EB9E}.Release|Any CPU.Build.0 = Release|Any CPU - {2119CE89-308D-4932-BFCE-8CDC0A05EB9E}.Release|x64.ActiveCfg = Release|Any CPU - {2119CE89-308D-4932-BFCE-8CDC0A05EB9E}.Release|x64.Build.0 = Release|Any CPU - {2119CE89-308D-4932-BFCE-8CDC0A05EB9E}.Release|x86.ActiveCfg = Release|Any CPU - {2119CE89-308D-4932-BFCE-8CDC0A05EB9E}.Release|x86.Build.0 = Release|Any CPU - {49AA63BF-3DBA-4490-9470-5AE0EB7F49F0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {49AA63BF-3DBA-4490-9470-5AE0EB7F49F0}.Debug|Any CPU.Build.0 = Debug|Any CPU - {49AA63BF-3DBA-4490-9470-5AE0EB7F49F0}.Debug|x64.ActiveCfg = Debug|Any CPU - {49AA63BF-3DBA-4490-9470-5AE0EB7F49F0}.Debug|x64.Build.0 = Debug|Any CPU - {49AA63BF-3DBA-4490-9470-5AE0EB7F49F0}.Debug|x86.ActiveCfg = Debug|Any CPU - {49AA63BF-3DBA-4490-9470-5AE0EB7F49F0}.Debug|x86.Build.0 = Debug|Any CPU - {49AA63BF-3DBA-4490-9470-5AE0EB7F49F0}.Release|Any CPU.ActiveCfg = Release|Any CPU - {49AA63BF-3DBA-4490-9470-5AE0EB7F49F0}.Release|Any CPU.Build.0 = Release|Any CPU - {49AA63BF-3DBA-4490-9470-5AE0EB7F49F0}.Release|x64.ActiveCfg = Release|Any CPU - {49AA63BF-3DBA-4490-9470-5AE0EB7F49F0}.Release|x64.Build.0 = Release|Any CPU - {49AA63BF-3DBA-4490-9470-5AE0EB7F49F0}.Release|x86.ActiveCfg = Release|Any CPU - {49AA63BF-3DBA-4490-9470-5AE0EB7F49F0}.Release|x86.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(NestedProjects) = preSolution - {F3DF5AC5-8CDC-46D4-969D-1245A6880215} = {CE64E92B-E088-46FB-9028-7FB6B67DEC55} - {93324D12-DE1B-4C1B-934A-92AA140FF6F6} = {F3DF5AC5-8CDC-46D4-969D-1245A6880215} - {79981A5A-207A-4A16-A21B-5E80394082F6} = {F3DF5AC5-8CDC-46D4-969D-1245A6880215} - {05248A38-0F34-4E59-A3D1-B07097987AFB} = {CE64E92B-E088-46FB-9028-7FB6B67DEC55} - {12F8343D-20A6-4E24-B0F5-3A66F2228CF6} = {CE64E92B-E088-46FB-9028-7FB6B67DEC55} - {294D6FF8-9379-4AB8-A203-8D0CC85FBFBB} = {05248A38-0F34-4E59-A3D1-B07097987AFB} - {A1D828E4-6B83-4BA2-B8E9-B21CE3BE8A31} = {05248A38-0F34-4E59-A3D1-B07097987AFB} - {86BD3DF6-A3E9-4839-8036-813A20DC8AD6} = {CE64E92B-E088-46FB-9028-7FB6B67DEC55} - {ECCEA352-8953-49D6-8F87-8AB361499420} = {12F8343D-20A6-4E24-B0F5-3A66F2228CF6} - {D64AD07C-A711-42D8-8653-EDCD7A825A44} = {12F8343D-20A6-4E24-B0F5-3A66F2228CF6} - {B3866EEF-8F46-4302-ABAC-A95EE2F27331} = {79981A5A-207A-4A16-A21B-5E80394082F6} - {8C7DAF8E-F792-4092-8BBF-31A6B898B39A} = {93324D12-DE1B-4C1B-934A-92AA140FF6F6} - {B15705B5-041C-4F1E-8342-AD03182EDD42} = {93324D12-DE1B-4C1B-934A-92AA140FF6F6} - {89FE1C3B-29D3-48A8-8E7D-90C261D266C5} = {93324D12-DE1B-4C1B-934A-92AA140FF6F6} - {BCE4A428-8B97-4B56-AE45-496EE3906667} = {2B1F75CE-07A6-4C19-A2E3-F9E062CFDDFB} - {27BEF279-AE73-43DC-92A9-FD7021A999D0} = {2B1F75CE-07A6-4C19-A2E3-F9E062CFDDFB} - {34359707-CE66-4DF0-9EF4-D7544B615564} = {2B1F75CE-07A6-4C19-A2E3-F9E062CFDDFB} - {990CA37A-86D3-4FE6-B777-3CF0DDAC6BF6} = {D36E77BC-4568-4BC8-9506-1EFB7B1CD335} - {2119CE89-308D-4932-BFCE-8CDC0A05EB9E} = {D36E77BC-4568-4BC8-9506-1EFB7B1CD335} - {FE1B1E84-F993-4840-9CAB-9082EB523FDD} = {CE64E92B-E088-46FB-9028-7FB6B67DEC55} - {F17769D7-0E41-4E80-BDD4-282EBE7B5199} = {FE1B1E84-F993-4840-9CAB-9082EB523FDD} - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {EA8248C2-3877-4AF7-8777-A17E7881E030} - EndGlobalSection -EndGlobal diff --git a/src/GetToken.http b/src/GetToken.http deleted file mode 100644 index 0de6481c71..0000000000 --- a/src/GetToken.http +++ /dev/null @@ -1,10 +0,0 @@ -@Host = https://localhost:7000 - -POST {{Host}}/api/token/ -Accept: application/json -Content-Type: application/json -tenant: root -{ - "email":"admin@root.com", - "password":"123Pa$$word!" -} diff --git a/src/api/framework/Core/Audit/AuditTrail.cs b/src/api/framework/Core/Audit/AuditTrail.cs deleted file mode 100644 index 97448ac39f..0000000000 --- a/src/api/framework/Core/Audit/AuditTrail.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace FSH.Framework.Core.Audit; -public class AuditTrail -{ - public Guid Id { get; set; } - public Guid UserId { get; set; } - public string? Operation { get; set; } - public string? Entity { get; set; } - public DateTimeOffset DateTime { get; set; } - public string? PreviousValues { get; set; } - public string? NewValues { get; set; } - public string? ModifiedProperties { get; set; } - public string? PrimaryKey { get; set; } -} diff --git a/src/api/framework/Core/Audit/IAuditService.cs b/src/api/framework/Core/Audit/IAuditService.cs deleted file mode 100644 index 9c62f4d0db..0000000000 --- a/src/api/framework/Core/Audit/IAuditService.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace FSH.Framework.Core.Audit; -public interface IAuditService -{ - Task> GetUserTrailsAsync(Guid userId); -} diff --git a/src/api/framework/Core/Audit/TrailDto.cs b/src/api/framework/Core/Audit/TrailDto.cs deleted file mode 100644 index 8268e4b172..0000000000 --- a/src/api/framework/Core/Audit/TrailDto.cs +++ /dev/null @@ -1,37 +0,0 @@ -using System.Collections.ObjectModel; -using System.Text.Json; - -namespace FSH.Framework.Core.Audit; -public class TrailDto() -{ - public Guid Id { get; set; } - public DateTimeOffset DateTime { get; set; } - public Guid UserId { get; set; } - public Dictionary KeyValues { get; } = []; - public Dictionary OldValues { get; } = []; - public Dictionary NewValues { get; } = []; - public Collection ModifiedProperties { get; } = []; - public TrailType Type { get; set; } - public string? TableName { get; set; } - - private static readonly JsonSerializerOptions SerializerOptions = new() - { - WriteIndented = false, - }; - - public AuditTrail ToAuditTrail() - { - return new() - { - Id = Guid.NewGuid(), - UserId = UserId, - Operation = Type.ToString(), - Entity = TableName, - DateTime = DateTime, - PrimaryKey = JsonSerializer.Serialize(KeyValues, SerializerOptions), - PreviousValues = OldValues.Count == 0 ? null : JsonSerializer.Serialize(OldValues, SerializerOptions), - NewValues = NewValues.Count == 0 ? null : JsonSerializer.Serialize(NewValues, SerializerOptions), - ModifiedProperties = ModifiedProperties.Count == 0 ? null : JsonSerializer.Serialize(ModifiedProperties, SerializerOptions) - }; - } -} diff --git a/src/api/framework/Core/Audit/TrailType.cs b/src/api/framework/Core/Audit/TrailType.cs deleted file mode 100644 index a98bfa29b6..0000000000 --- a/src/api/framework/Core/Audit/TrailType.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace FSH.Framework.Core.Audit; -public enum TrailType -{ - None = 0, - Create = 1, - Update = 2, - Delete = 3 -} diff --git a/src/api/framework/Core/Auth/Jwt/JwtOptions.cs b/src/api/framework/Core/Auth/Jwt/JwtOptions.cs deleted file mode 100644 index 5d99d6702f..0000000000 --- a/src/api/framework/Core/Auth/Jwt/JwtOptions.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace FSH.Framework.Core.Auth.Jwt; -public class JwtOptions : IValidatableObject -{ - public string Key { get; set; } = string.Empty; - - public int TokenExpirationInMinutes { get; set; } = 60; - - public int RefreshTokenExpirationInDays { get; set; } = 7; - - public IEnumerable Validate(ValidationContext validationContext) - { - if (string.IsNullOrEmpty(Key)) - { - yield return new ValidationResult("No Key defined in JwtSettings config", [nameof(Key)]); - } - } -} diff --git a/src/api/framework/Core/Caching/ICacheService.cs b/src/api/framework/Core/Caching/ICacheService.cs deleted file mode 100644 index 54f3c09048..0000000000 --- a/src/api/framework/Core/Caching/ICacheService.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace FSH.Framework.Core.Caching; - -public interface ICacheService -{ - T? Get(string key); - Task GetAsync(string key, CancellationToken token = default); - - void Refresh(string key); - Task RefreshAsync(string key, CancellationToken token = default); - - void Remove(string key); - Task RemoveAsync(string key, CancellationToken token = default); - - void Set(string key, T value, TimeSpan? slidingExpiration = null); - Task SetAsync(string key, T value, TimeSpan? slidingExpiration = null, CancellationToken cancellationToken = default); -} \ No newline at end of file diff --git a/src/api/framework/Core/Domain/Contracts/IEntity.cs b/src/api/framework/Core/Domain/Contracts/IEntity.cs deleted file mode 100644 index 1d48d306d6..0000000000 --- a/src/api/framework/Core/Domain/Contracts/IEntity.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.Collections.ObjectModel; -using FSH.Framework.Core.Domain.Events; - -namespace FSH.Framework.Core.Domain.Contracts; - -public interface IEntity -{ - Collection DomainEvents { get; } -} - -public interface IEntity : IEntity -{ - TId Id { get; } -} diff --git a/src/api/framework/Core/Domain/Events/DomainEvent.cs b/src/api/framework/Core/Domain/Events/DomainEvent.cs deleted file mode 100644 index 5350854602..0000000000 --- a/src/api/framework/Core/Domain/Events/DomainEvent.cs +++ /dev/null @@ -1,7 +0,0 @@ -using MediatR; - -namespace FSH.Framework.Core.Domain.Events; -public abstract record DomainEvent : IDomainEvent, INotification -{ - public DateTime RaisedOn { get; protected set; } = DateTime.UtcNow; -} diff --git a/src/api/framework/Core/Domain/Events/IDomainEvent.cs b/src/api/framework/Core/Domain/Events/IDomainEvent.cs deleted file mode 100644 index 68d4c8f6c2..0000000000 --- a/src/api/framework/Core/Domain/Events/IDomainEvent.cs +++ /dev/null @@ -1,4 +0,0 @@ -namespace FSH.Framework.Core.Domain.Events; -public interface IDomainEvent -{ -} diff --git a/src/api/framework/Core/Exceptions/CustomException.cs b/src/api/framework/Core/Exceptions/CustomException.cs deleted file mode 100644 index 4d1af9af97..0000000000 --- a/src/api/framework/Core/Exceptions/CustomException.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System.Net; - -namespace FSH.Framework.Core.Exceptions; - -public class CustomException : Exception -{ - public List? ErrorMessages { get; } - - public HttpStatusCode StatusCode { get; } - - public CustomException(string message, List? errors = default, HttpStatusCode statusCode = HttpStatusCode.InternalServerError) - : base(message) - { - ErrorMessages = errors; - StatusCode = statusCode; - } -} diff --git a/src/api/framework/Core/Exceptions/ForbiddenException.cs b/src/api/framework/Core/Exceptions/ForbiddenException.cs deleted file mode 100644 index fdafead902..0000000000 --- a/src/api/framework/Core/Exceptions/ForbiddenException.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.Net; - -namespace FSH.Framework.Core.Exceptions; -public class ForbiddenException : FshException -{ - public ForbiddenException() - : base("unauthorized", [], HttpStatusCode.Forbidden) - { - } - public ForbiddenException(string message) - : base(message, [], HttpStatusCode.Forbidden) - { - } -} diff --git a/src/api/framework/Core/Exceptions/FshException.cs b/src/api/framework/Core/Exceptions/FshException.cs deleted file mode 100644 index 28597c5297..0000000000 --- a/src/api/framework/Core/Exceptions/FshException.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System.Net; - -namespace FSH.Framework.Core.Exceptions; -public class FshException : Exception -{ - public IEnumerable ErrorMessages { get; } - - public HttpStatusCode StatusCode { get; } - - public FshException(string message, IEnumerable errors, HttpStatusCode statusCode = HttpStatusCode.InternalServerError) - : base(message) - { - ErrorMessages = errors; - StatusCode = statusCode; - } - - public FshException(string message) : base(message) - { - ErrorMessages = new List(); - } -} diff --git a/src/api/framework/Core/Exceptions/NotFoundException.cs b/src/api/framework/Core/Exceptions/NotFoundException.cs deleted file mode 100644 index 351e25cfc7..0000000000 --- a/src/api/framework/Core/Exceptions/NotFoundException.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System.Collections.ObjectModel; -using System.Net; - -namespace FSH.Framework.Core.Exceptions; -public class NotFoundException : FshException -{ - public NotFoundException(string message) - : base(message, new Collection(), HttpStatusCode.NotFound) - { - } -} diff --git a/src/api/framework/Core/Exceptions/UnauthorizedException.cs b/src/api/framework/Core/Exceptions/UnauthorizedException.cs deleted file mode 100644 index 559eb060c8..0000000000 --- a/src/api/framework/Core/Exceptions/UnauthorizedException.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System.Collections.ObjectModel; -using System.Net; - -namespace FSH.Framework.Core.Exceptions; -public class UnauthorizedException : FshException -{ - public UnauthorizedException() - : base("authentication failed", new Collection(), HttpStatusCode.Unauthorized) - { - } - public UnauthorizedException(string message) - : base(message, new Collection(), HttpStatusCode.Unauthorized) - { - } -} diff --git a/src/api/framework/Core/Identity/Roles/Features/CreateOrUpdateRole/CreateOrUpdateRoleValidator.cs b/src/api/framework/Core/Identity/Roles/Features/CreateOrUpdateRole/CreateOrUpdateRoleValidator.cs deleted file mode 100644 index 68f4526661..0000000000 --- a/src/api/framework/Core/Identity/Roles/Features/CreateOrUpdateRole/CreateOrUpdateRoleValidator.cs +++ /dev/null @@ -1,11 +0,0 @@ -using FluentValidation; - -namespace FSH.Framework.Core.Identity.Roles.Features.CreateOrUpdateRole; - -public class CreateOrUpdateRoleValidator : AbstractValidator -{ - public CreateOrUpdateRoleValidator() - { - RuleFor(x => x.Name).NotEmpty().WithMessage("Role name is required."); - } -} diff --git a/src/api/framework/Core/Identity/Roles/Features/UpdatePermissions/UpdatePermissionsCommand.cs b/src/api/framework/Core/Identity/Roles/Features/UpdatePermissions/UpdatePermissionsCommand.cs deleted file mode 100644 index 900c153956..0000000000 --- a/src/api/framework/Core/Identity/Roles/Features/UpdatePermissions/UpdatePermissionsCommand.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace FSH.Framework.Core.Identity.Roles.Features.UpdatePermissions; -public class UpdatePermissionsCommand -{ - public string RoleId { get; set; } = default!; - public List Permissions { get; set; } = default!; -} diff --git a/src/api/framework/Core/Identity/Roles/Features/UpdatePermissions/UpdatePermissionsValidator.cs b/src/api/framework/Core/Identity/Roles/Features/UpdatePermissions/UpdatePermissionsValidator.cs deleted file mode 100644 index 34b0b7f01c..0000000000 --- a/src/api/framework/Core/Identity/Roles/Features/UpdatePermissions/UpdatePermissionsValidator.cs +++ /dev/null @@ -1,13 +0,0 @@ -using FluentValidation; - -namespace FSH.Framework.Core.Identity.Roles.Features.UpdatePermissions; -public class UpdatePermissionsValidator : AbstractValidator -{ - public UpdatePermissionsValidator() - { - RuleFor(r => r.RoleId) - .NotEmpty(); - RuleFor(r => r.Permissions) - .NotNull(); - } -} diff --git a/src/api/framework/Core/Identity/Roles/IRoleService.cs b/src/api/framework/Core/Identity/Roles/IRoleService.cs deleted file mode 100644 index dca61839af..0000000000 --- a/src/api/framework/Core/Identity/Roles/IRoleService.cs +++ /dev/null @@ -1,16 +0,0 @@ -using FSH.Framework.Core.Identity.Roles.Features.CreateOrUpdateRole; -using FSH.Framework.Core.Identity.Roles.Features.UpdatePermissions; - -namespace FSH.Framework.Core.Identity.Roles; - -public interface IRoleService -{ - Task> GetRolesAsync(); - Task GetRoleAsync(string id); - Task CreateOrUpdateRoleAsync(CreateOrUpdateRoleCommand command); - Task DeleteRoleAsync(string id); - Task GetWithPermissionsAsync(string id, CancellationToken cancellationToken); - - Task UpdatePermissionsAsync(UpdatePermissionsCommand request); -} - diff --git a/src/api/framework/Core/Identity/Tokens/Features/Generate/TokenGenerationCommand.cs b/src/api/framework/Core/Identity/Tokens/Features/Generate/TokenGenerationCommand.cs deleted file mode 100644 index dccc1e15d7..0000000000 --- a/src/api/framework/Core/Identity/Tokens/Features/Generate/TokenGenerationCommand.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System.ComponentModel; -using FluentValidation; -using FSH.Starter.Shared.Authorization; - -namespace FSH.Framework.Core.Identity.Tokens.Features.Generate; -public record TokenGenerationCommand( - [property: DefaultValue(TenantConstants.Root.EmailAddress)] string Email, - [property: DefaultValue(TenantConstants.DefaultPassword)] string Password); - -public class GenerateTokenValidator : AbstractValidator -{ - public GenerateTokenValidator() - { - RuleFor(p => p.Email).Cascade(CascadeMode.Stop).NotEmpty().EmailAddress(); - - RuleFor(p => p.Password).Cascade(CascadeMode.Stop).NotEmpty(); - } -} diff --git a/src/api/framework/Core/Identity/Tokens/Features/Refresh/RefreshTokenCommand.cs b/src/api/framework/Core/Identity/Tokens/Features/Refresh/RefreshTokenCommand.cs deleted file mode 100644 index 8fc45b8d24..0000000000 --- a/src/api/framework/Core/Identity/Tokens/Features/Refresh/RefreshTokenCommand.cs +++ /dev/null @@ -1,14 +0,0 @@ -using FluentValidation; - -namespace FSH.Framework.Core.Identity.Tokens.Features.Refresh; -public record RefreshTokenCommand(string Token, string RefreshToken); - -public class RefreshTokenValidator : AbstractValidator -{ - public RefreshTokenValidator() - { - RuleFor(p => p.Token).Cascade(CascadeMode.Stop).NotEmpty(); - - RuleFor(p => p.RefreshToken).Cascade(CascadeMode.Stop).NotEmpty(); - } -} diff --git a/src/api/framework/Core/Identity/Tokens/ITokenService.cs b/src/api/framework/Core/Identity/Tokens/ITokenService.cs deleted file mode 100644 index 86665ec818..0000000000 --- a/src/api/framework/Core/Identity/Tokens/ITokenService.cs +++ /dev/null @@ -1,11 +0,0 @@ -using FSH.Framework.Core.Identity.Tokens.Features.Generate; -using FSH.Framework.Core.Identity.Tokens.Features.Refresh; -using FSH.Framework.Core.Identity.Tokens.Models; - -namespace FSH.Framework.Core.Identity.Tokens; -public interface ITokenService -{ - Task GenerateTokenAsync(TokenGenerationCommand request, string ipAddress, CancellationToken cancellationToken); - Task RefreshTokenAsync(RefreshTokenCommand request, string ipAddress, CancellationToken cancellationToken); - -} diff --git a/src/api/framework/Core/Identity/Tokens/Models/TokenResponse.cs b/src/api/framework/Core/Identity/Tokens/Models/TokenResponse.cs deleted file mode 100644 index fc56f00d89..0000000000 --- a/src/api/framework/Core/Identity/Tokens/Models/TokenResponse.cs +++ /dev/null @@ -1,2 +0,0 @@ -namespace FSH.Framework.Core.Identity.Tokens.Models; -public record TokenResponse(string Token, string RefreshToken, DateTime RefreshTokenExpiryTime); diff --git a/src/api/framework/Core/Identity/Users/Abstractions/IUserService.cs b/src/api/framework/Core/Identity/Users/Abstractions/IUserService.cs deleted file mode 100644 index 95fbf9f577..0000000000 --- a/src/api/framework/Core/Identity/Users/Abstractions/IUserService.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System.Security.Claims; -using FSH.Framework.Core.Identity.Users.Dtos; -using FSH.Framework.Core.Identity.Users.Features.AssignUserRole; -using FSH.Framework.Core.Identity.Users.Features.ChangePassword; -using FSH.Framework.Core.Identity.Users.Features.ForgotPassword; -using FSH.Framework.Core.Identity.Users.Features.RegisterUser; -using FSH.Framework.Core.Identity.Users.Features.ResetPassword; -using FSH.Framework.Core.Identity.Users.Features.ToggleUserStatus; -using FSH.Framework.Core.Identity.Users.Features.UpdateUser; - -namespace FSH.Framework.Core.Identity.Users.Abstractions; -public interface IUserService -{ - Task ExistsWithNameAsync(string name); - Task ExistsWithEmailAsync(string email, string? exceptId = null); - Task ExistsWithPhoneNumberAsync(string phoneNumber, string? exceptId = null); - Task> GetListAsync(CancellationToken cancellationToken); - Task GetCountAsync(CancellationToken cancellationToken); - Task GetAsync(string userId, CancellationToken cancellationToken); - Task ToggleStatusAsync(ToggleUserStatusCommand request, CancellationToken cancellationToken); - Task GetOrCreateFromPrincipalAsync(ClaimsPrincipal principal); - Task RegisterAsync(RegisterUserCommand request, string origin, CancellationToken cancellationToken); - Task UpdateAsync(UpdateUserCommand request, string userId); - Task DeleteAsync(string userId); - Task ConfirmEmailAsync(string userId, string code, string tenant, CancellationToken cancellationToken); - Task ConfirmPhoneNumberAsync(string userId, string code); - - // permisions - Task HasPermissionAsync(string userId, string permission, CancellationToken cancellationToken = default); - - // passwords - Task ForgotPasswordAsync(ForgotPasswordCommand request, string origin, CancellationToken cancellationToken); - Task ResetPasswordAsync(ResetPasswordCommand request, CancellationToken cancellationToken); - Task?> GetPermissionsAsync(string userId, CancellationToken cancellationToken); - - Task ChangePasswordAsync(ChangePasswordCommand request, string userId); - Task AssignRolesAsync(string userId, AssignUserRoleCommand request, CancellationToken cancellationToken); - Task> GetUserRolesAsync(string userId, CancellationToken cancellationToken); -} diff --git a/src/api/framework/Core/Identity/Users/Features/AssignUserRole/AssignUserRoleCommand.cs b/src/api/framework/Core/Identity/Users/Features/AssignUserRole/AssignUserRoleCommand.cs deleted file mode 100644 index 34f3fadb89..0000000000 --- a/src/api/framework/Core/Identity/Users/Features/AssignUserRole/AssignUserRoleCommand.cs +++ /dev/null @@ -1,7 +0,0 @@ -using FSH.Framework.Core.Identity.Users.Dtos; - -namespace FSH.Framework.Core.Identity.Users.Features.AssignUserRole; -public class AssignUserRoleCommand -{ - public List UserRoles { get; set; } = new(); -} diff --git a/src/api/framework/Core/Identity/Users/Features/ChangePassword/ChangePasswordCommand.cs b/src/api/framework/Core/Identity/Users/Features/ChangePassword/ChangePasswordCommand.cs deleted file mode 100644 index 82abe1323c..0000000000 --- a/src/api/framework/Core/Identity/Users/Features/ChangePassword/ChangePasswordCommand.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace FSH.Framework.Core.Identity.Users.Features.ChangePassword; -public class ChangePasswordCommand -{ - public string Password { get; set; } = default!; - public string NewPassword { get; set; } = default!; - public string ConfirmNewPassword { get; set; } = default!; -} diff --git a/src/api/framework/Core/Identity/Users/Features/ChangePassword/ChangePasswordValidator.cs b/src/api/framework/Core/Identity/Users/Features/ChangePassword/ChangePasswordValidator.cs deleted file mode 100644 index 9d52f78856..0000000000 --- a/src/api/framework/Core/Identity/Users/Features/ChangePassword/ChangePasswordValidator.cs +++ /dev/null @@ -1,18 +0,0 @@ -using FluentValidation; - -namespace FSH.Framework.Core.Identity.Users.Features.ChangePassword; -public class ChangePasswordValidator : AbstractValidator -{ - public ChangePasswordValidator() - { - RuleFor(p => p.Password) - .NotEmpty(); - - RuleFor(p => p.NewPassword) - .NotEmpty(); - - RuleFor(p => p.ConfirmNewPassword) - .Equal(p => p.NewPassword) - .WithMessage("passwords do not match."); - } -} diff --git a/src/api/framework/Core/Identity/Users/Features/ForgotPassword/ForgotPasswordValidator.cs b/src/api/framework/Core/Identity/Users/Features/ForgotPassword/ForgotPasswordValidator.cs deleted file mode 100644 index 2df57f5be4..0000000000 --- a/src/api/framework/Core/Identity/Users/Features/ForgotPassword/ForgotPasswordValidator.cs +++ /dev/null @@ -1,12 +0,0 @@ -using FluentValidation; - -namespace FSH.Framework.Core.Identity.Users.Features.ForgotPassword; -public class ForgotPasswordValidator : AbstractValidator -{ - public ForgotPasswordValidator() - { - RuleFor(p => p.Email).Cascade(CascadeMode.Stop) - .NotEmpty() - .EmailAddress(); - } -} diff --git a/src/api/framework/Core/Identity/Users/Features/RegisterUser/RegisterUserResponse.cs b/src/api/framework/Core/Identity/Users/Features/RegisterUser/RegisterUserResponse.cs deleted file mode 100644 index 967539ae78..0000000000 --- a/src/api/framework/Core/Identity/Users/Features/RegisterUser/RegisterUserResponse.cs +++ /dev/null @@ -1,2 +0,0 @@ -namespace FSH.Framework.Core.Identity.Users.Features.RegisterUser; -public record RegisterUserResponse(string UserId); diff --git a/src/api/framework/Core/Identity/Users/Features/ResetPassword/ResetPasswordValidator.cs b/src/api/framework/Core/Identity/Users/Features/ResetPassword/ResetPasswordValidator.cs deleted file mode 100644 index 4141905651..0000000000 --- a/src/api/framework/Core/Identity/Users/Features/ResetPassword/ResetPasswordValidator.cs +++ /dev/null @@ -1,13 +0,0 @@ -using FluentValidation; - -namespace FSH.Framework.Core.Identity.Users.Features.ResetPassword; - -public class ResetPasswordValidator : AbstractValidator -{ - public ResetPasswordValidator() - { - RuleFor(x => x.Email).NotEmpty().EmailAddress(); - RuleFor(x => x.Password).NotEmpty(); - RuleFor(x => x.Token).NotEmpty(); - } -} diff --git a/src/api/framework/Core/Paging/Extensions.cs b/src/api/framework/Core/Paging/Extensions.cs deleted file mode 100644 index a9c5544eb9..0000000000 --- a/src/api/framework/Core/Paging/Extensions.cs +++ /dev/null @@ -1,18 +0,0 @@ -using Ardalis.Specification; - -namespace FSH.Framework.Core.Paging; -public static class Extensions -{ - public static async Task> PaginatedListAsync( - this IReadRepositoryBase repository, ISpecification spec, PaginationFilter filter, CancellationToken cancellationToken = default) - where T : class - where TDestination : class - { - ArgumentNullException.ThrowIfNull(repository); - - var items = await repository.ListAsync(spec, cancellationToken).ConfigureAwait(false); - int totalCount = await repository.CountAsync(spec, cancellationToken).ConfigureAwait(false); - - return new PagedList(items, filter.PageNumber, filter.PageSize, totalCount); - } -} diff --git a/src/api/framework/Core/Paging/Filter.cs b/src/api/framework/Core/Paging/Filter.cs deleted file mode 100644 index fdbdc29387..0000000000 --- a/src/api/framework/Core/Paging/Filter.cs +++ /dev/null @@ -1,34 +0,0 @@ -namespace FSH.Framework.Core.Paging; - -public static class FilterOperator -{ - public const string EQ = "eq"; - public const string NEQ = "neq"; - public const string LT = "lt"; - public const string LTE = "lte"; - public const string GT = "gt"; - public const string GTE = "gte"; - public const string STARTSWITH = "startswith"; - public const string ENDSWITH = "endswith"; - public const string CONTAINS = "contains"; -} - -public static class FilterLogic -{ - public const string AND = "and"; - public const string OR = "or"; - public const string XOR = "xor"; -} - -public class Filter -{ - public string? Logic { get; set; } - - public IEnumerable? Filters { get; set; } - - public string? Field { get; set; } - - public string? Operator { get; set; } - - public object? Value { get; set; } -} diff --git a/src/api/framework/Core/Paging/IPageRequest.cs b/src/api/framework/Core/Paging/IPageRequest.cs deleted file mode 100644 index c4a2a7f147..0000000000 --- a/src/api/framework/Core/Paging/IPageRequest.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace FSH.Framework.Core.Paging; - -public interface IPageRequest -{ - int PageNumber { get; init; } - int PageSize { get; init; } - string? Filters { get; init; } - string? SortOrder { get; init; } -} diff --git a/src/api/framework/Core/Paging/IPagedList.cs b/src/api/framework/Core/Paging/IPagedList.cs deleted file mode 100644 index e2950b3984..0000000000 --- a/src/api/framework/Core/Paging/IPagedList.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace FSH.Framework.Core.Paging; - -public interface IPagedList - where T : class -{ - int TotalPages { get; } - bool HasPrevious { get; } - bool HasNext { get; } - IReadOnlyList Items { get; init; } - int TotalCount { get; init; } - int PageNumber { get; init; } - int PageSize { get; init; } - - IPagedList MapTo(Func map) - where TR : class; - IPagedList MapTo() - where TR : class; -} diff --git a/src/api/framework/Core/Paging/PagedList.cs b/src/api/framework/Core/Paging/PagedList.cs deleted file mode 100644 index 7f48292f7c..0000000000 --- a/src/api/framework/Core/Paging/PagedList.cs +++ /dev/null @@ -1,21 +0,0 @@ -using Mapster; - -namespace FSH.Framework.Core.Paging; - -public record PagedList(IReadOnlyList Items, int PageNumber, int PageSize, int TotalCount) : IPagedList - where T : class -{ - public int TotalPages => (int)Math.Ceiling(TotalCount / (double)PageSize); - public bool HasPrevious => PageNumber > 1; - public bool HasNext => PageNumber < TotalPages; - public IPagedList MapTo(Func map) - where TR : class - { - return new PagedList(Items.Select(map).ToList(), PageNumber, PageSize, TotalCount); - } - public IPagedList MapTo() - where TR : class - { - return new PagedList(Items.Adapt>(), PageNumber, PageSize, TotalCount); - } -} diff --git a/src/api/framework/Core/Paging/PaginationFilter.cs b/src/api/framework/Core/Paging/PaginationFilter.cs deleted file mode 100644 index 13be4026ee..0000000000 --- a/src/api/framework/Core/Paging/PaginationFilter.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace FSH.Framework.Core.Paging; - -public class PaginationFilter : BaseFilter -{ - public int PageNumber { get; set; } - - public int PageSize { get; set; } = int.MaxValue; - public string[]? OrderBy { get; set; } -} - -public static class PaginationFilterExtensions -{ - public static bool HasOrderBy(this PaginationFilter filter) => - filter.OrderBy?.Any() is true; -} diff --git a/src/api/framework/Core/Paging/Search.cs b/src/api/framework/Core/Paging/Search.cs deleted file mode 100644 index a2f2624980..0000000000 --- a/src/api/framework/Core/Paging/Search.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace FSH.Framework.Core.Paging; - -public class Search -{ - public List Fields { get; set; } = new(); - public string? Keyword { get; set; } -} diff --git a/src/api/framework/Core/Storage/File/Features/FileUploadCommand.cs b/src/api/framework/Core/Storage/File/Features/FileUploadCommand.cs deleted file mode 100644 index c4e5cb0e53..0000000000 --- a/src/api/framework/Core/Storage/File/Features/FileUploadCommand.cs +++ /dev/null @@ -1,11 +0,0 @@ -using MediatR; - -namespace FSH.Framework.Core.Storage.File.Features; - -public class FileUploadCommand : IRequest -{ - public string Name { get; set; } = default!; - public string Extension { get; set; } = default!; - public string Data { get; set; } = default!; -} - diff --git a/src/api/framework/Core/Storage/File/Features/FileUploadResponse.cs b/src/api/framework/Core/Storage/File/Features/FileUploadResponse.cs deleted file mode 100644 index f3af35debf..0000000000 --- a/src/api/framework/Core/Storage/File/Features/FileUploadResponse.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace FSH.Framework.Core.Storage.File.Features; - -public class FileUploadResponse -{ - public Uri Url { get; set; } = default!; -} - diff --git a/src/api/framework/Core/Storage/File/Features/FileUploadValidator.cs b/src/api/framework/Core/Storage/File/Features/FileUploadValidator.cs deleted file mode 100644 index c064cf93f3..0000000000 --- a/src/api/framework/Core/Storage/File/Features/FileUploadValidator.cs +++ /dev/null @@ -1,21 +0,0 @@ -using FluentValidation; - -namespace FSH.Framework.Core.Storage.File.Features; - -public class FileUploadRequestValidator : AbstractValidator -{ - public FileUploadRequestValidator() - { - RuleFor(p => p.Name) - .NotEmpty() - .MaximumLength(150); - - RuleFor(p => p.Extension) - .NotEmpty() - .MaximumLength(5); - - RuleFor(p => p.Data) - .NotEmpty(); - } -} - diff --git a/src/api/framework/Core/Storage/File/FileType.cs b/src/api/framework/Core/Storage/File/FileType.cs deleted file mode 100644 index 267968aaa6..0000000000 --- a/src/api/framework/Core/Storage/File/FileType.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System.ComponentModel; - -namespace FSH.Framework.Core.Storage.File; - -public enum FileType -{ - [Description(".jpg,.png,.jpeg")] - Image -} diff --git a/src/api/framework/Core/Storage/IStorageService.cs b/src/api/framework/Core/Storage/IStorageService.cs deleted file mode 100644 index 5e13d6ddec..0000000000 --- a/src/api/framework/Core/Storage/IStorageService.cs +++ /dev/null @@ -1,12 +0,0 @@ -using FSH.Framework.Core.Storage.File; -using FSH.Framework.Core.Storage.File.Features; - -namespace FSH.Framework.Core.Storage; - -public interface IStorageService -{ - public Task UploadAsync(FileUploadCommand? request, FileType supportedFileType, CancellationToken cancellationToken = default) - where T : class; - - public void Remove(Uri? path); -} diff --git a/src/api/framework/Core/Tenant/Features/ActivateTenant/ActivateTenantCommand.cs b/src/api/framework/Core/Tenant/Features/ActivateTenant/ActivateTenantCommand.cs deleted file mode 100644 index 01f902dfc9..0000000000 --- a/src/api/framework/Core/Tenant/Features/ActivateTenant/ActivateTenantCommand.cs +++ /dev/null @@ -1,4 +0,0 @@ -using MediatR; - -namespace FSH.Framework.Core.Tenant.Features.ActivateTenant; -public record ActivateTenantCommand(string TenantId) : IRequest; diff --git a/src/api/framework/Core/Tenant/Features/ActivateTenant/ActivateTenantHandler.cs b/src/api/framework/Core/Tenant/Features/ActivateTenant/ActivateTenantHandler.cs deleted file mode 100644 index ab018e532e..0000000000 --- a/src/api/framework/Core/Tenant/Features/ActivateTenant/ActivateTenantHandler.cs +++ /dev/null @@ -1,12 +0,0 @@ -using FSH.Framework.Core.Tenant.Abstractions; -using MediatR; - -namespace FSH.Framework.Core.Tenant.Features.ActivateTenant; -public sealed class ActivateTenantHandler(ITenantService service) : IRequestHandler -{ - public async Task Handle(ActivateTenantCommand request, CancellationToken cancellationToken) - { - var status = await service.ActivateAsync(request.TenantId, cancellationToken); - return new ActivateTenantResponse(status); - } -} diff --git a/src/api/framework/Core/Tenant/Features/ActivateTenant/ActivateTenantResponse.cs b/src/api/framework/Core/Tenant/Features/ActivateTenant/ActivateTenantResponse.cs deleted file mode 100644 index bd396891df..0000000000 --- a/src/api/framework/Core/Tenant/Features/ActivateTenant/ActivateTenantResponse.cs +++ /dev/null @@ -1,2 +0,0 @@ -namespace FSH.Framework.Core.Tenant.Features.ActivateTenant; -public record ActivateTenantResponse(string Status); diff --git a/src/api/framework/Core/Tenant/Features/ActivateTenant/ActivateTenantValidator.cs b/src/api/framework/Core/Tenant/Features/ActivateTenant/ActivateTenantValidator.cs deleted file mode 100644 index de9bceb45e..0000000000 --- a/src/api/framework/Core/Tenant/Features/ActivateTenant/ActivateTenantValidator.cs +++ /dev/null @@ -1,9 +0,0 @@ -using FluentValidation; - -namespace FSH.Framework.Core.Tenant.Features.ActivateTenant; -public sealed class ActivateTenantValidator : AbstractValidator -{ - public ActivateTenantValidator() => - RuleFor(t => t.TenantId) - .NotEmpty(); -} diff --git a/src/api/framework/Core/Tenant/Features/CreateTenant/CreateTenantCommand.cs b/src/api/framework/Core/Tenant/Features/CreateTenant/CreateTenantCommand.cs deleted file mode 100644 index a6bec85931..0000000000 --- a/src/api/framework/Core/Tenant/Features/CreateTenant/CreateTenantCommand.cs +++ /dev/null @@ -1,8 +0,0 @@ -using MediatR; - -namespace FSH.Framework.Core.Tenant.Features.CreateTenant; -public sealed record CreateTenantCommand(string Id, - string Name, - string? ConnectionString, - string AdminEmail, - string? Issuer) : IRequest; diff --git a/src/api/framework/Core/Tenant/Features/CreateTenant/CreateTenantHandler.cs b/src/api/framework/Core/Tenant/Features/CreateTenant/CreateTenantHandler.cs deleted file mode 100644 index d948367cb8..0000000000 --- a/src/api/framework/Core/Tenant/Features/CreateTenant/CreateTenantHandler.cs +++ /dev/null @@ -1,12 +0,0 @@ -using FSH.Framework.Core.Tenant.Abstractions; -using MediatR; - -namespace FSH.Framework.Core.Tenant.Features.CreateTenant; -public sealed class CreateTenantHandler(ITenantService service) : IRequestHandler -{ - public async Task Handle(CreateTenantCommand request, CancellationToken cancellationToken) - { - var tenantId = await service.CreateAsync(request, cancellationToken); - return new CreateTenantResponse(tenantId); - } -} diff --git a/src/api/framework/Core/Tenant/Features/CreateTenant/CreateTenantResponse.cs b/src/api/framework/Core/Tenant/Features/CreateTenant/CreateTenantResponse.cs deleted file mode 100644 index 7a778e4f79..0000000000 --- a/src/api/framework/Core/Tenant/Features/CreateTenant/CreateTenantResponse.cs +++ /dev/null @@ -1,2 +0,0 @@ -namespace FSH.Framework.Core.Tenant.Features.CreateTenant; -public record CreateTenantResponse(string Id); diff --git a/src/api/framework/Core/Tenant/Features/DisableTenant/DisableTenantCommand.cs b/src/api/framework/Core/Tenant/Features/DisableTenant/DisableTenantCommand.cs deleted file mode 100644 index bc0dc1fa95..0000000000 --- a/src/api/framework/Core/Tenant/Features/DisableTenant/DisableTenantCommand.cs +++ /dev/null @@ -1,4 +0,0 @@ -using MediatR; - -namespace FSH.Framework.Core.Tenant.Features.DisableTenant; -public record DisableTenantCommand(string TenantId) : IRequest; diff --git a/src/api/framework/Core/Tenant/Features/DisableTenant/DisableTenantHandler.cs b/src/api/framework/Core/Tenant/Features/DisableTenant/DisableTenantHandler.cs deleted file mode 100644 index d9cad8dcbd..0000000000 --- a/src/api/framework/Core/Tenant/Features/DisableTenant/DisableTenantHandler.cs +++ /dev/null @@ -1,12 +0,0 @@ -using FSH.Framework.Core.Tenant.Abstractions; -using MediatR; - -namespace FSH.Framework.Core.Tenant.Features.DisableTenant; -public sealed class DisableTenantHandler(ITenantService service) : IRequestHandler -{ - public async Task Handle(DisableTenantCommand request, CancellationToken cancellationToken) - { - var status = await service.DeactivateAsync(request.TenantId); - return new DisableTenantResponse(status); - } -} diff --git a/src/api/framework/Core/Tenant/Features/DisableTenant/DisableTenantResponse.cs b/src/api/framework/Core/Tenant/Features/DisableTenant/DisableTenantResponse.cs deleted file mode 100644 index 89ce0c0538..0000000000 --- a/src/api/framework/Core/Tenant/Features/DisableTenant/DisableTenantResponse.cs +++ /dev/null @@ -1,2 +0,0 @@ -namespace FSH.Framework.Core.Tenant.Features.DisableTenant; -public record DisableTenantResponse(string Status); diff --git a/src/api/framework/Core/Tenant/Features/DisableTenant/DisableTenantValidator.cs b/src/api/framework/Core/Tenant/Features/DisableTenant/DisableTenantValidator.cs deleted file mode 100644 index 2c0831e209..0000000000 --- a/src/api/framework/Core/Tenant/Features/DisableTenant/DisableTenantValidator.cs +++ /dev/null @@ -1,9 +0,0 @@ -using FluentValidation; - -namespace FSH.Framework.Core.Tenant.Features.DisableTenant; -public sealed class DisableTenantValidator : AbstractValidator -{ - public DisableTenantValidator() => - RuleFor(t => t.TenantId) - .NotEmpty(); -} diff --git a/src/api/framework/Core/Tenant/Features/GetTenantById/GetTenantByIdHandler.cs b/src/api/framework/Core/Tenant/Features/GetTenantById/GetTenantByIdHandler.cs deleted file mode 100644 index ec8e68737c..0000000000 --- a/src/api/framework/Core/Tenant/Features/GetTenantById/GetTenantByIdHandler.cs +++ /dev/null @@ -1,12 +0,0 @@ -using FSH.Framework.Core.Tenant.Abstractions; -using FSH.Framework.Core.Tenant.Dtos; -using MediatR; - -namespace FSH.Framework.Core.Tenant.Features.GetTenantById; -public sealed class GetTenantByIdHandler(ITenantService service) : IRequestHandler -{ - public async Task Handle(GetTenantByIdQuery request, CancellationToken cancellationToken) - { - return await service.GetByIdAsync(request.TenantId); - } -} diff --git a/src/api/framework/Core/Tenant/Features/GetTenantById/GetTenantByIdQuery.cs b/src/api/framework/Core/Tenant/Features/GetTenantById/GetTenantByIdQuery.cs deleted file mode 100644 index 9f75bc68c4..0000000000 --- a/src/api/framework/Core/Tenant/Features/GetTenantById/GetTenantByIdQuery.cs +++ /dev/null @@ -1,5 +0,0 @@ -using FSH.Framework.Core.Tenant.Dtos; -using MediatR; - -namespace FSH.Framework.Core.Tenant.Features.GetTenantById; -public record GetTenantByIdQuery(string TenantId) : IRequest; diff --git a/src/api/framework/Core/Tenant/Features/GetTenants/GetTenantsHandler.cs b/src/api/framework/Core/Tenant/Features/GetTenants/GetTenantsHandler.cs deleted file mode 100644 index 1ccd5f90eb..0000000000 --- a/src/api/framework/Core/Tenant/Features/GetTenants/GetTenantsHandler.cs +++ /dev/null @@ -1,12 +0,0 @@ -using FSH.Framework.Core.Tenant.Abstractions; -using FSH.Framework.Core.Tenant.Dtos; -using MediatR; - -namespace FSH.Framework.Core.Tenant.Features.GetTenants; -public sealed class GetTenantsHandler(ITenantService service) : IRequestHandler> -{ - public Task> Handle(GetTenantsQuery request, CancellationToken cancellationToken) - { - return service.GetAllAsync(); - } -} diff --git a/src/api/framework/Core/Tenant/Features/GetTenants/GetTenantsQuery.cs b/src/api/framework/Core/Tenant/Features/GetTenants/GetTenantsQuery.cs deleted file mode 100644 index dba6bc1896..0000000000 --- a/src/api/framework/Core/Tenant/Features/GetTenants/GetTenantsQuery.cs +++ /dev/null @@ -1,5 +0,0 @@ -using FSH.Framework.Core.Tenant.Dtos; -using MediatR; - -namespace FSH.Framework.Core.Tenant.Features.GetTenants; -public sealed class GetTenantsQuery : IRequest>; diff --git a/src/api/framework/Core/Tenant/Features/UpgradeSubscription/UpgradeSubscriptionCommand.cs b/src/api/framework/Core/Tenant/Features/UpgradeSubscription/UpgradeSubscriptionCommand.cs deleted file mode 100644 index f132f455b7..0000000000 --- a/src/api/framework/Core/Tenant/Features/UpgradeSubscription/UpgradeSubscriptionCommand.cs +++ /dev/null @@ -1,8 +0,0 @@ -using MediatR; - -namespace FSH.Framework.Core.Tenant.Features.UpgradeSubscription; -public class UpgradeSubscriptionCommand : IRequest -{ - public string Tenant { get; set; } = default!; - public DateTime ExtendedExpiryDate { get; set; } -} diff --git a/src/api/framework/Core/Tenant/Features/UpgradeSubscription/UpgradeSubscriptionHandler.cs b/src/api/framework/Core/Tenant/Features/UpgradeSubscription/UpgradeSubscriptionHandler.cs deleted file mode 100644 index e4cbbb4e7a..0000000000 --- a/src/api/framework/Core/Tenant/Features/UpgradeSubscription/UpgradeSubscriptionHandler.cs +++ /dev/null @@ -1,17 +0,0 @@ -using FSH.Framework.Core.Tenant.Abstractions; -using MediatR; - -namespace FSH.Framework.Core.Tenant.Features.UpgradeSubscription; - -public class UpgradeSubscriptionHandler : IRequestHandler -{ - private readonly ITenantService _tenantService; - - public UpgradeSubscriptionHandler(ITenantService tenantService) => _tenantService = tenantService; - - public async Task Handle(UpgradeSubscriptionCommand request, CancellationToken cancellationToken) - { - var validUpto = await _tenantService.UpgradeSubscription(request.Tenant, request.ExtendedExpiryDate); - return new UpgradeSubscriptionResponse(validUpto, request.Tenant); - } -} diff --git a/src/api/framework/Core/Tenant/Features/UpgradeSubscription/UpgradeSubscriptionResponse.cs b/src/api/framework/Core/Tenant/Features/UpgradeSubscription/UpgradeSubscriptionResponse.cs deleted file mode 100644 index ef14487b74..0000000000 --- a/src/api/framework/Core/Tenant/Features/UpgradeSubscription/UpgradeSubscriptionResponse.cs +++ /dev/null @@ -1,2 +0,0 @@ -namespace FSH.Framework.Core.Tenant.Features.UpgradeSubscription; -public record UpgradeSubscriptionResponse(DateTime NewValidity, string Tenant); diff --git a/src/api/framework/Core/Tenant/Features/UpgradeSubscription/UpgradeSubscriptionValidator.cs b/src/api/framework/Core/Tenant/Features/UpgradeSubscription/UpgradeSubscriptionValidator.cs deleted file mode 100644 index daddf1fbf1..0000000000 --- a/src/api/framework/Core/Tenant/Features/UpgradeSubscription/UpgradeSubscriptionValidator.cs +++ /dev/null @@ -1,11 +0,0 @@ -using FluentValidation; - -namespace FSH.Framework.Core.Tenant.Features.UpgradeSubscription; -public class UpgradeSubscriptionValidator : AbstractValidator -{ - public UpgradeSubscriptionValidator() - { - RuleFor(t => t.Tenant).NotEmpty(); - RuleFor(t => t.ExtendedExpiryDate).GreaterThan(DateTime.UtcNow); - } -} diff --git a/src/api/framework/Infrastructure/Auth/Jwt/JwtAuthConstants.cs b/src/api/framework/Infrastructure/Auth/Jwt/JwtAuthConstants.cs deleted file mode 100644 index b766fdf804..0000000000 --- a/src/api/framework/Infrastructure/Auth/Jwt/JwtAuthConstants.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace FSH.Framework.Infrastructure.Auth.Jwt; -internal static class JwtAuthConstants -{ - public const string Issuer = "https://fullstackhero.net"; - public const string Audience = "fullstackhero"; -} diff --git a/src/api/framework/Infrastructure/Behaviours/ValidationBehavior.cs b/src/api/framework/Infrastructure/Behaviours/ValidationBehavior.cs deleted file mode 100644 index 016652aeb7..0000000000 --- a/src/api/framework/Infrastructure/Behaviours/ValidationBehavior.cs +++ /dev/null @@ -1,23 +0,0 @@ -using FluentValidation; -using MediatR; - -namespace FSH.Framework.Infrastructure.Behaviours; -public class ValidationBehavior(IEnumerable> validators) : IPipelineBehavior - where TRequest : IRequest -{ - private readonly IEnumerable> _validators = validators; - - public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) - { - if (_validators.Any()) - { - var context = new ValidationContext(request); - var validationResults = await Task.WhenAll(_validators.Select(v => v.ValidateAsync(context, cancellationToken))); - var failures = validationResults.SelectMany(r => r.Errors).Where(f => f != null).ToList(); - - if (failures.Count > 0) - throw new ValidationException(failures); - } - return await next(); - } -} diff --git a/src/api/framework/Infrastructure/Extensions.cs b/src/api/framework/Infrastructure/Extensions.cs deleted file mode 100644 index 865bce172d..0000000000 --- a/src/api/framework/Infrastructure/Extensions.cs +++ /dev/null @@ -1,114 +0,0 @@ -using System.Reflection; -using Asp.Versioning.Conventions; -using FluentValidation; -using FSH.Framework.Core; -using FSH.Framework.Core.Origin; -using FSH.Framework.Infrastructure.Auth; -using FSH.Framework.Infrastructure.Auth.Jwt; -using FSH.Framework.Infrastructure.Behaviours; -using FSH.Framework.Infrastructure.Caching; -using FSH.Framework.Infrastructure.Cors; -using FSH.Framework.Infrastructure.Exceptions; -using FSH.Framework.Infrastructure.Identity; -using FSH.Framework.Infrastructure.Jobs; -using FSH.Framework.Infrastructure.Logging.Serilog; -using FSH.Framework.Infrastructure.Mail; -using FSH.Framework.Infrastructure.OpenApi; -using FSH.Framework.Infrastructure.Persistence; -using FSH.Framework.Infrastructure.RateLimit; -using FSH.Framework.Infrastructure.SecurityHeaders; -using FSH.Framework.Infrastructure.Storage.Files; -using FSH.Framework.Infrastructure.Tenant; -using FSH.Framework.Infrastructure.Tenant.Endpoints; -using FSH.Starter.Aspire.ServiceDefaults; -using MediatR; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.FileProviders; - -namespace FSH.Framework.Infrastructure; - -public static class Extensions -{ - public static WebApplicationBuilder ConfigureFshFramework(this WebApplicationBuilder builder) - { - ArgumentNullException.ThrowIfNull(builder); - builder.AddServiceDefaults(); - builder.ConfigureSerilog(); - builder.ConfigureDatabase(); - builder.Services.ConfigureMultitenancy(); - builder.Services.ConfigureIdentity(); - builder.Services.AddCorsPolicy(builder.Configuration); - builder.Services.ConfigureFileStorage(); - builder.Services.ConfigureJwtAuth(); - builder.Services.ConfigureOpenApi(); - builder.Services.ConfigureJobs(builder.Configuration); - builder.Services.ConfigureMailing(); - builder.Services.ConfigureCaching(builder.Configuration); - builder.Services.AddExceptionHandler(); - builder.Services.AddProblemDetails(); - builder.Services.AddHealthChecks(); - builder.Services.AddOptions().BindConfiguration(nameof(OriginOptions)); - - // Define module assemblies - var assemblies = new Assembly[] - { - typeof(FshCore).Assembly, - typeof(FshInfrastructure).Assembly - }; - - // Register validators - builder.Services.AddValidatorsFromAssemblies(assemblies); - - // Register MediatR - builder.Services.AddMediatR(cfg => - { - cfg.RegisterServicesFromAssemblies(assemblies); - cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>)); - }); - - builder.Services.ConfigureRateLimit(builder.Configuration); - builder.Services.ConfigureSecurityHeaders(builder.Configuration); - - return builder; - } - - public static WebApplication UseFshFramework(this WebApplication app) - { - app.MapDefaultEndpoints(); - app.UseRateLimit(); - app.UseSecurityHeaders(); - app.UseMultitenancy(); - app.UseExceptionHandler(); - app.UseCorsPolicy(); - app.UseOpenApi(); - app.UseJobDashboard(app.Configuration); - app.UseRouting(); - app.UseStaticFiles(); - app.UseStaticFiles(new StaticFileOptions() - { - FileProvider = new PhysicalFileProvider(Path.Combine(Directory.GetCurrentDirectory(), "assets")), - RequestPath = new PathString("/assets") - }); - app.UseAuthentication(); - app.UseAuthorization(); - app.MapTenantEndpoints(); - app.MapIdentityEndpoints(); - - // Current user middleware - app.UseMiddleware(); - - // Register API versions - var versions = app.NewApiVersionSet() - .HasApiVersion(1) - .HasApiVersion(2) - .ReportApiVersions() - .Build(); - - // Map versioned endpoint - app.MapGroup("api/v{version:apiVersion}").WithApiVersionSet(versions); - - return app; - } -} diff --git a/src/api/framework/Infrastructure/Identity/Audit/AuditPublishedEvent.cs b/src/api/framework/Infrastructure/Identity/Audit/AuditPublishedEvent.cs deleted file mode 100644 index 46587882d7..0000000000 --- a/src/api/framework/Infrastructure/Identity/Audit/AuditPublishedEvent.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System.Collections.ObjectModel; -using FSH.Framework.Core.Audit; -using MediatR; - -namespace FSH.Framework.Infrastructure.Identity.Audit; -public class AuditPublishedEvent : INotification -{ - public AuditPublishedEvent(Collection? trails) - { - Trails = trails; - } - public Collection? Trails { get; } -} diff --git a/src/api/framework/Infrastructure/Identity/Audit/AuditPublishedEventHandler.cs b/src/api/framework/Infrastructure/Identity/Audit/AuditPublishedEventHandler.cs deleted file mode 100644 index cb255f82af..0000000000 --- a/src/api/framework/Infrastructure/Identity/Audit/AuditPublishedEventHandler.cs +++ /dev/null @@ -1,24 +0,0 @@ -using FSH.Framework.Core.Audit; -using FSH.Framework.Infrastructure.Identity.Persistence; -using MediatR; -using Microsoft.Extensions.Logging; - -namespace FSH.Framework.Infrastructure.Identity.Audit; -public class AuditPublishedEventHandler(ILogger logger, IdentityDbContext context) : INotificationHandler -{ - public async Task Handle(AuditPublishedEvent notification, CancellationToken cancellationToken) - { - if (context == null) return; - logger.LogInformation("received audit trails"); - try - { - await context.Set().AddRangeAsync(notification.Trails!, default); - await context.SaveChangesAsync(default); - } - catch - { - logger.LogError("error while saving audit trail"); - } - return; - } -} diff --git a/src/api/framework/Infrastructure/Identity/Audit/AuditService.cs b/src/api/framework/Infrastructure/Identity/Audit/AuditService.cs deleted file mode 100644 index 823cb79576..0000000000 --- a/src/api/framework/Infrastructure/Identity/Audit/AuditService.cs +++ /dev/null @@ -1,17 +0,0 @@ -using FSH.Framework.Core.Audit; -using FSH.Framework.Infrastructure.Identity.Persistence; -using Microsoft.EntityFrameworkCore; - -namespace FSH.Framework.Infrastructure.Identity.Audit; -public class AuditService(IdentityDbContext context) : IAuditService -{ - public async Task> GetUserTrailsAsync(Guid userId) - { - var trails = await context.AuditTrails - .Where(a => a.UserId == userId) - .OrderByDescending(a => a.DateTime) - .Take(250) - .ToListAsync(); - return trails; - } -} diff --git a/src/api/framework/Infrastructure/Identity/Audit/Endpoints/GetUserAuditTrailEndpoint.cs b/src/api/framework/Infrastructure/Identity/Audit/Endpoints/GetUserAuditTrailEndpoint.cs deleted file mode 100644 index 78ca37942a..0000000000 --- a/src/api/framework/Infrastructure/Identity/Audit/Endpoints/GetUserAuditTrailEndpoint.cs +++ /dev/null @@ -1,22 +0,0 @@ -using FSH.Framework.Core.Audit; -using FSH.Framework.Infrastructure.Auth.Policy; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; - -namespace FSH.Framework.Infrastructure.Identity.Audit.Endpoints; - -public static class GetUserAuditTrailEndpoint -{ - internal static RouteHandlerBuilder MapGetUserAuditTrailEndpoint(this IEndpointRouteBuilder endpoints) - { - return endpoints.MapGet("/{id:guid}/audit-trails", (Guid id, IAuditService service) => - { - return service.GetUserTrailsAsync(id); - }) - .WithName(nameof(GetUserAuditTrailEndpoint)) - .WithSummary("Get user's audit trail details") - .RequirePermission("Permissions.AuditTrails.View") - .WithDescription("Get user's audit trail details."); - } -} diff --git a/src/api/framework/Infrastructure/Identity/Extensions.cs b/src/api/framework/Infrastructure/Identity/Extensions.cs deleted file mode 100644 index 4d20559295..0000000000 --- a/src/api/framework/Infrastructure/Identity/Extensions.cs +++ /dev/null @@ -1,66 +0,0 @@ -using FSH.Framework.Core.Audit; -using FSH.Framework.Core.Identity.Roles; -using FSH.Framework.Core.Identity.Tokens; -using FSH.Framework.Core.Identity.Users.Abstractions; -using FSH.Framework.Core.Persistence; -using FSH.Framework.Infrastructure.Auth; -using FSH.Framework.Infrastructure.Identity.Audit; -using FSH.Framework.Infrastructure.Identity.Persistence; -using FSH.Framework.Infrastructure.Identity.Roles; -using FSH.Framework.Infrastructure.Identity.Roles.Endpoints; -using FSH.Framework.Infrastructure.Identity.Tokens; -using FSH.Framework.Infrastructure.Identity.Tokens.Endpoints; -using FSH.Framework.Infrastructure.Identity.Users; -using FSH.Framework.Infrastructure.Identity.Users.Endpoints; -using FSH.Framework.Infrastructure.Identity.Users.Services; -using FSH.Framework.Infrastructure.Persistence; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Identity; -using Microsoft.AspNetCore.Routing; -using Microsoft.Extensions.DependencyInjection; -using IdentityConstants = FSH.Starter.Shared.Authorization.IdentityConstants; - -namespace FSH.Framework.Infrastructure.Identity; -internal static class Extensions -{ - internal static IServiceCollection ConfigureIdentity(this IServiceCollection services) - { - ArgumentNullException.ThrowIfNull(services); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(sp => (ICurrentUserInitializer)sp.GetRequiredService()); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.BindDbContext(); - services.AddScoped(); - services.AddIdentity(options => - { - options.Password.RequiredLength = IdentityConstants.PasswordLength; - options.Password.RequireDigit = false; - options.Password.RequireLowercase = false; - options.Password.RequireNonAlphanumeric = false; - options.Password.RequireUppercase = false; - options.User.RequireUniqueEmail = true; - }) - .AddEntityFrameworkStores() - .AddDefaultTokenProviders(); - return services; - } - - public static IEndpointRouteBuilder MapIdentityEndpoints(this IEndpointRouteBuilder app) - { - var users = app.MapGroup("api/users").WithTags("users"); - users.MapUserEndpoints(); - - var tokens = app.MapGroup("api/token").WithTags("token"); - tokens.MapTokenEndpoints(); - - var roles = app.MapGroup("api/roles").WithTags("roles"); - roles.MapRoleEndpoints(); - - return app; - } -} diff --git a/src/api/framework/Infrastructure/Identity/Roles/Endpoints/Extensions.cs b/src/api/framework/Infrastructure/Identity/Roles/Endpoints/Extensions.cs deleted file mode 100644 index b899bb362c..0000000000 --- a/src/api/framework/Infrastructure/Identity/Roles/Endpoints/Extensions.cs +++ /dev/null @@ -1,18 +0,0 @@ -using Microsoft.AspNetCore.Routing; - -namespace FSH.Framework.Infrastructure.Identity.Roles.Endpoints; - -internal static class Extensions -{ - public static IEndpointRouteBuilder MapRoleEndpoints(this IEndpointRouteBuilder app) - { - app.MapGetRoleEndpoint(); - app.MapGetRolesEndpoint(); - app.MapDeleteRoleEndpoint(); - app.MapCreateOrUpdateRoleEndpoint(); - app.MapGetRolePermissionsEndpoint(); - app.MapUpdateRolePermissionsEndpoint(); - return app; - } -} - diff --git a/src/api/framework/Infrastructure/Identity/Tokens/Endpoints/RefreshTokenEndpoint.cs b/src/api/framework/Infrastructure/Identity/Tokens/Endpoints/RefreshTokenEndpoint.cs deleted file mode 100644 index a8f27128ba..0000000000 --- a/src/api/framework/Infrastructure/Identity/Tokens/Endpoints/RefreshTokenEndpoint.cs +++ /dev/null @@ -1,28 +0,0 @@ -using FSH.Framework.Core.Identity.Tokens; -using FSH.Framework.Core.Identity.Tokens.Features.Refresh; -using FSH.Starter.Shared.Authorization; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Routing; - -namespace FSH.Framework.Infrastructure.Identity.Tokens.Endpoints; -public static class RefreshTokenEndpoint -{ - internal static RouteHandlerBuilder MapRefreshTokenEndpoint(this IEndpointRouteBuilder endpoints) - { - return endpoints.MapPost("/refresh", (RefreshTokenCommand request, - [FromHeader(Name = TenantConstants.Identifier)] string tenant, - ITokenService service, - HttpContext context, - CancellationToken cancellationToken) => - { - string ip = context.GetIpAddress(); - return service.RefreshTokenAsync(request, ip!, cancellationToken); - }) - .WithName(nameof(RefreshTokenEndpoint)) - .WithSummary("refresh JWTs") - .WithDescription("refresh JWTs") - .AllowAnonymous(); - } -} diff --git a/src/api/framework/Infrastructure/Identity/Tokens/Endpoints/TokenGenerationEndpoint.cs b/src/api/framework/Infrastructure/Identity/Tokens/Endpoints/TokenGenerationEndpoint.cs deleted file mode 100644 index e0bbc4796e..0000000000 --- a/src/api/framework/Infrastructure/Identity/Tokens/Endpoints/TokenGenerationEndpoint.cs +++ /dev/null @@ -1,28 +0,0 @@ -using FSH.Framework.Core.Identity.Tokens; -using FSH.Framework.Core.Identity.Tokens.Features.Generate; -using FSH.Starter.Shared.Authorization; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Routing; - -namespace FSH.Framework.Infrastructure.Identity.Tokens.Endpoints; -public static class TokenGenerationEndpoint -{ - internal static RouteHandlerBuilder MapTokenGenerationEndpoint(this IEndpointRouteBuilder endpoints) - { - return endpoints.MapPost("/", (TokenGenerationCommand request, - [FromHeader(Name = TenantConstants.Identifier)] string tenant, - ITokenService service, - HttpContext context, - CancellationToken cancellationToken) => - { - string ip = context.GetIpAddress(); - return service.GenerateTokenAsync(request, ip!, cancellationToken); - }) - .WithName(nameof(TokenGenerationEndpoint)) - .WithSummary("generate JWTs") - .WithDescription("generate JWTs") - .AllowAnonymous(); - } -} diff --git a/src/api/framework/Infrastructure/Identity/Users/Endpoints/AssignRolesToUserEndpoint.cs b/src/api/framework/Infrastructure/Identity/Users/Endpoints/AssignRolesToUserEndpoint.cs deleted file mode 100644 index 161fe18e8f..0000000000 --- a/src/api/framework/Infrastructure/Identity/Users/Endpoints/AssignRolesToUserEndpoint.cs +++ /dev/null @@ -1,28 +0,0 @@ -using FluentValidation; -using FSH.Framework.Core.Identity.Users.Abstractions; -using FSH.Framework.Core.Identity.Users.Features.AssignUserRole; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; - -namespace FSH.Framework.Infrastructure.Identity.Users.Endpoints; -public static class AssignRolesToUserEndpoint -{ - internal static RouteHandlerBuilder MapAssignRolesToUserEndpoint(this IEndpointRouteBuilder endpoints) - { - return endpoints.MapPost("/{id:guid}/roles", async (AssignUserRoleCommand command, - HttpContext context, - string id, - IUserService userService, - CancellationToken cancellationToken) => - { - - var message = await userService.AssignRolesAsync(id, command, cancellationToken); - return Results.Ok(message); - }) - .WithName(nameof(AssignRolesToUserEndpoint)) - .WithSummary("assign roles") - .WithDescription("assign roles"); - } - -} diff --git a/src/api/framework/Infrastructure/Identity/Users/Endpoints/Extensions.cs b/src/api/framework/Infrastructure/Identity/Users/Endpoints/Extensions.cs deleted file mode 100644 index cbc311c6be..0000000000 --- a/src/api/framework/Infrastructure/Identity/Users/Endpoints/Extensions.cs +++ /dev/null @@ -1,27 +0,0 @@ -using FSH.Framework.Infrastructure.Identity.Audit.Endpoints; -using Microsoft.AspNetCore.Routing; - -namespace FSH.Framework.Infrastructure.Identity.Users.Endpoints; -internal static class Extensions -{ - public static IEndpointRouteBuilder MapUserEndpoints(this IEndpointRouteBuilder app) - { - app.MapRegisterUserEndpoint(); - app.MapSelfRegisterUserEndpoint(); - app.MapUpdateUserEndpoint(); - app.MapGetUsersListEndpoint(); - app.MapDeleteUserEndpoint(); - app.MapForgotPasswordEndpoint(); - app.MapChangePasswordEndpoint(); - app.MapResetPasswordEndpoint(); - app.MapGetMeEndpoint(); - app.MapGetUserEndpoint(); - app.MapGetCurrentUserPermissionsEndpoint(); - app.ToggleUserStatusEndpointEndpoint(); - app.MapAssignRolesToUserEndpoint(); - app.MapGetUserRolesEndpoint(); - app.MapGetUserAuditTrailEndpoint(); - app.MapConfirmEmailEndpoint(); - return app; - } -} diff --git a/src/api/framework/Infrastructure/Storage/Files/Extension.cs b/src/api/framework/Infrastructure/Storage/Files/Extension.cs deleted file mode 100644 index 6699f126d2..0000000000 --- a/src/api/framework/Infrastructure/Storage/Files/Extension.cs +++ /dev/null @@ -1,25 +0,0 @@ -using FSH.Framework.Core.Storage; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.FileProviders; - -namespace FSH.Framework.Infrastructure.Storage.Files; - -internal static class Extension -{ - internal static IServiceCollection ConfigureFileStorage(this IServiceCollection services) - { - ArgumentNullException.ThrowIfNull(services); - services.AddTransient(); - - return services; - } - - internal static IApplicationBuilder UseFileStorage(this IApplicationBuilder app) => - app.UseStaticFiles(new StaticFileOptions() - { - FileProvider = new PhysicalFileProvider(Path.Combine(Directory.GetCurrentDirectory(), "Files")), - RequestPath = new PathString("/Files") - }); -} diff --git a/src/api/framework/Infrastructure/Storage/Files/LocalFileStorageService.cs b/src/api/framework/Infrastructure/Storage/Files/LocalFileStorageService.cs deleted file mode 100644 index 16b786da6f..0000000000 --- a/src/api/framework/Infrastructure/Storage/Files/LocalFileStorageService.cs +++ /dev/null @@ -1,133 +0,0 @@ -using System.Runtime.InteropServices; -using System.Text.RegularExpressions; -using FSH.Framework.Core.Origin; -using FSH.Framework.Core.Storage; -using FSH.Framework.Core.Storage.File; -using FSH.Framework.Core.Storage.File.Features; -using FSH.Framework.Infrastructure.Common.Extensions; -using Microsoft.Extensions.Options; -namespace FSH.Framework.Infrastructure.Storage.Files -{ - public class LocalFileStorageService(IOptions originSettings) : IStorageService - { - public async Task UploadAsync(FileUploadCommand? request, FileType supportedFileType, CancellationToken cancellationToken = default) - where T : class - { - if (request == null || request.Data == null) - { - return null!; - } - - if (request.Extension is null || !supportedFileType.GetDescriptionList().Contains(request.Extension.ToLower(System.Globalization.CultureInfo.CurrentCulture))) - throw new InvalidOperationException("File Format Not Supported."); - if (request.Name is null) - throw new InvalidOperationException("Name is required."); - - string base64Data = Regex.Match(request.Data, "data:image/(?.+?),(?.+)").Groups["data"].Value; - - var streamData = new MemoryStream(Convert.FromBase64String(base64Data)); - if (streamData.Length > 0) - { - string folder = typeof(T).Name; - if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) - { - folder = folder.Replace(@"\", "/", StringComparison.Ordinal); - } - - string folderName = supportedFileType switch - { - FileType.Image => Path.Combine("assets", "images", folder), - _ => Path.Combine("assets", "others", folder), - }; - string pathToSave = Path.Combine(Directory.GetCurrentDirectory(), folderName); - Directory.CreateDirectory(pathToSave); - - string fileName = request.Name.Trim('"'); - fileName = RemoveSpecialCharacters(fileName); - fileName = fileName.ReplaceWhitespace("-"); - fileName += request.Extension.Trim(); - string fullPath = Path.Combine(pathToSave, fileName); - string dbPath = Path.Combine(folderName, fileName); - if (File.Exists(dbPath)) - { - dbPath = NextAvailableFilename(dbPath); - fullPath = NextAvailableFilename(fullPath); - } - - using var stream = new FileStream(fullPath, FileMode.Create); - await streamData.CopyToAsync(stream, cancellationToken); - var path = dbPath.Replace("\\", "/", StringComparison.Ordinal); - var imageUri = new Uri(originSettings.Value.OriginUrl!, path); - return imageUri; - } - else - { - return null!; - } - } - - public static string RemoveSpecialCharacters(string str) - { - return Regex.Replace(str, "[^a-zA-Z0-9_.]+", string.Empty, RegexOptions.Compiled); - } - - public void Remove(Uri? path) - { - var pathString = path!.ToString(); - if (File.Exists(pathString)) - { - File.Delete(pathString); - } - } - - private const string NumberPattern = "-{0}"; - - private static string NextAvailableFilename(string path) - { - if (!File.Exists(path)) - { - return path; - } - - if (Path.HasExtension(path)) - { - return GetNextFilename(path.Insert(path.LastIndexOf(Path.GetExtension(path), StringComparison.Ordinal), NumberPattern)); - } - - return GetNextFilename(path + NumberPattern); - } - - private static string GetNextFilename(string pattern) - { - string tmp = string.Format(pattern, 1); - - if (!File.Exists(tmp)) - { - return tmp; - } - - int min = 1, max = 2; - - while (File.Exists(string.Format(pattern, max))) - { - min = max; - max *= 2; - } - - while (max != min + 1) - { - int pivot = (max + min) / 2; - if (File.Exists(string.Format(pattern, pivot))) - { - min = pivot; - } - else - { - max = pivot; - } - } - - return string.Format(pattern, max); - } - } -} diff --git a/src/api/framework/Infrastructure/Tenant/Abstractions/IFshTenantInfo.cs b/src/api/framework/Infrastructure/Tenant/Abstractions/IFshTenantInfo.cs deleted file mode 100644 index 843820200f..0000000000 --- a/src/api/framework/Infrastructure/Tenant/Abstractions/IFshTenantInfo.cs +++ /dev/null @@ -1,7 +0,0 @@ -using Finbuckle.MultiTenant.Abstractions; - -namespace FSH.Framework.Infrastructure.Tenant.Abstractions; -public interface IFshTenantInfo : ITenantInfo -{ - string? ConnectionString { get; set; } -} diff --git a/src/api/framework/Infrastructure/Tenant/Endpoints/ActivateTenantEndpoint.cs b/src/api/framework/Infrastructure/Tenant/Endpoints/ActivateTenantEndpoint.cs deleted file mode 100644 index 4f2f24f87b..0000000000 --- a/src/api/framework/Infrastructure/Tenant/Endpoints/ActivateTenantEndpoint.cs +++ /dev/null @@ -1,19 +0,0 @@ -using FSH.Framework.Core.Tenant.Features.ActivateTenant; -using FSH.Framework.Infrastructure.Auth.Policy; -using MediatR; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; - -namespace FSH.Framework.Infrastructure.Tenant.Endpoints; -public static class ActivateTenantEndpoint -{ - internal static RouteHandlerBuilder MapActivateTenantEndpoint(this IEndpointRouteBuilder endpoints) - { - return endpoints.MapPost("/{id}/activate", (ISender mediator, string id) => mediator.Send(new ActivateTenantCommand(id))) - .WithName(nameof(ActivateTenantEndpoint)) - .WithSummary("activate tenant") - .RequirePermission("Permissions.Tenants.Update") - .WithDescription("activate tenant"); - } -} diff --git a/src/api/framework/Infrastructure/Tenant/Endpoints/CreateTenantEndpoint.cs b/src/api/framework/Infrastructure/Tenant/Endpoints/CreateTenantEndpoint.cs deleted file mode 100644 index 51d8a9f6fd..0000000000 --- a/src/api/framework/Infrastructure/Tenant/Endpoints/CreateTenantEndpoint.cs +++ /dev/null @@ -1,19 +0,0 @@ -using FSH.Framework.Core.Tenant.Features.CreateTenant; -using FSH.Framework.Infrastructure.Auth.Policy; -using MediatR; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; - -namespace FSH.Framework.Infrastructure.Tenant.Endpoints; -public static class CreateTenantEndpoint -{ - internal static RouteHandlerBuilder MapRegisterTenantEndpoint(this IEndpointRouteBuilder endpoints) - { - return endpoints.MapPost("/", (CreateTenantCommand request, ISender mediator) => mediator.Send(request)) - .WithName(nameof(CreateTenantEndpoint)) - .WithSummary("creates a tenant") - .RequirePermission("Permissions.Tenants.Create") - .WithDescription("creates a tenant"); - } -} diff --git a/src/api/framework/Infrastructure/Tenant/Endpoints/Extensions.cs b/src/api/framework/Infrastructure/Tenant/Endpoints/Extensions.cs deleted file mode 100644 index bc88511001..0000000000 --- a/src/api/framework/Infrastructure/Tenant/Endpoints/Extensions.cs +++ /dev/null @@ -1,19 +0,0 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; - -namespace FSH.Framework.Infrastructure.Tenant.Endpoints; -public static class Extensions -{ - public static IEndpointRouteBuilder MapTenantEndpoints(this IEndpointRouteBuilder app) - { - var tenantGroup = app.MapGroup("api/tenants").WithTags("tenants"); - tenantGroup.MapRegisterTenantEndpoint(); - tenantGroup.MapGetTenantsEndpoint(); - tenantGroup.MapGetTenantByIdEndpoint(); - tenantGroup.MapUpgradeTenantSubscriptionEndpoint(); - tenantGroup.MapActivateTenantEndpoint(); - tenantGroup.MapDisableTenantEndpoint(); - return app; - } -} diff --git a/src/api/framework/Infrastructure/Tenant/Endpoints/GetTenantsEndpoint.cs b/src/api/framework/Infrastructure/Tenant/Endpoints/GetTenantsEndpoint.cs deleted file mode 100644 index 1bf590deb4..0000000000 --- a/src/api/framework/Infrastructure/Tenant/Endpoints/GetTenantsEndpoint.cs +++ /dev/null @@ -1,19 +0,0 @@ -using FSH.Framework.Core.Tenant.Features.GetTenants; -using FSH.Framework.Infrastructure.Auth.Policy; -using MediatR; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; - -namespace FSH.Framework.Infrastructure.Tenant.Endpoints; -public static class GetTenantsEndpoint -{ - internal static RouteHandlerBuilder MapGetTenantsEndpoint(this IEndpointRouteBuilder endpoints) - { - return endpoints.MapGet("/", (ISender mediator) => mediator.Send(new GetTenantsQuery())) - .WithName(nameof(GetTenantsEndpoint)) - .WithSummary("get tenants") - .RequirePermission("Permissions.Tenants.View") - .WithDescription("get tenants"); - } -} diff --git a/src/api/framework/Infrastructure/Tenant/Endpoints/UpgradeSubscriptionEndpoint.cs b/src/api/framework/Infrastructure/Tenant/Endpoints/UpgradeSubscriptionEndpoint.cs deleted file mode 100644 index 182330544f..0000000000 --- a/src/api/framework/Infrastructure/Tenant/Endpoints/UpgradeSubscriptionEndpoint.cs +++ /dev/null @@ -1,20 +0,0 @@ -using FSH.Framework.Core.Tenant.Features.UpgradeSubscription; -using FSH.Framework.Infrastructure.Auth.Policy; -using MediatR; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; - -namespace FSH.Framework.Infrastructure.Tenant.Endpoints; - -public static class UpgradeSubscriptionEndpoint -{ - internal static RouteHandlerBuilder MapUpgradeTenantSubscriptionEndpoint(this IEndpointRouteBuilder endpoints) - { - return endpoints.MapPost("/upgrade", (UpgradeSubscriptionCommand command, ISender mediator) => mediator.Send(command)) - .WithName(nameof(UpgradeSubscriptionEndpoint)) - .WithSummary("upgrade tenant subscription") - .RequirePermission("Permissions.Tenants.Update") - .WithDescription("upgrade tenant subscription"); - } -} diff --git a/src/api/framework/Infrastructure/Tenant/Extensions.cs b/src/api/framework/Infrastructure/Tenant/Extensions.cs deleted file mode 100644 index f7fea460cd..0000000000 --- a/src/api/framework/Infrastructure/Tenant/Extensions.cs +++ /dev/null @@ -1,136 +0,0 @@ -using Finbuckle.MultiTenant; -using Finbuckle.MultiTenant.Abstractions; -using Finbuckle.MultiTenant.Stores.DistributedCacheStore; -using FSH.Framework.Core.Persistence; -using FSH.Framework.Core.Tenant.Abstractions; -using FSH.Framework.Infrastructure.Persistence; -using FSH.Framework.Infrastructure.Persistence.Services; -using FSH.Framework.Infrastructure.Tenant.Persistence; -using FSH.Framework.Infrastructure.Tenant.Services; -using FSH.Starter.Shared.Authorization; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using Serilog; - -namespace FSH.Framework.Infrastructure.Tenant; -internal static class Extensions -{ - public static IServiceCollection ConfigureMultitenancy(this IServiceCollection services) - { - ArgumentNullException.ThrowIfNull(services); - services.AddTransient(); - services.BindDbContext(); - services - .AddMultiTenant(config => - { - // to save database calls to resolve tenant - // this was happening for every request earlier, leading to ineffeciency - config.Events.OnTenantResolveCompleted = async (context) => - { - if (context.MultiTenantContext.StoreInfo is null) return; - if (context.MultiTenantContext.StoreInfo.StoreType != typeof(DistributedCacheStore)) - { - var sp = ((HttpContext)context.Context!).RequestServices; - var distributedCacheStore = sp - .GetService>>()! - .FirstOrDefault(s => s.GetType() == typeof(DistributedCacheStore)); - - await distributedCacheStore!.TryAddAsync(context.MultiTenantContext.TenantInfo!); - } - await Task.FromResult(0); - }; - }) - .WithClaimStrategy(FshClaims.Tenant) - .WithHeaderStrategy(TenantConstants.Identifier) - .WithDelegateStrategy(async context => - { - if (context is not HttpContext httpContext) - return null; - if (!httpContext.Request.Query.TryGetValue("tenant", out var tenantIdentifier) || string.IsNullOrEmpty(tenantIdentifier)) - return null; - return await Task.FromResult(tenantIdentifier.ToString()); - }) - .WithDistributedCacheStore(TimeSpan.FromMinutes(60)) - .WithEFCoreStore(); - services.AddScoped(); - return services; - } - - public static WebApplication UseMultitenancy(this WebApplication app) - { - ArgumentNullException.ThrowIfNull(app); - app.UseMultiTenant(); - - // set up tenant store - var tenants = TenantStoreSetup(app); - - // set up tenant databases - app.SetupTenantDatabases(tenants); - - return app; - } - - private static IApplicationBuilder SetupTenantDatabases(this IApplicationBuilder app, IEnumerable tenants) - { - foreach (var tenant in tenants) - { - // create a scope for tenant - using var tenantScope = app.ApplicationServices.CreateScope(); - - //set current tenant so that the right connection string is used - tenantScope.ServiceProvider.GetRequiredService() - .MultiTenantContext = new MultiTenantContext() - { - TenantInfo = tenant - }; - - // using the scope, perform migrations / seeding - var initializers = tenantScope.ServiceProvider.GetServices(); - foreach (var initializer in initializers) - { - initializer.MigrateAsync(CancellationToken.None).Wait(); - initializer.SeedAsync(CancellationToken.None).Wait(); - } - } - return app; - } - - private static IEnumerable TenantStoreSetup(IApplicationBuilder app) - { - var scope = app.ApplicationServices.CreateScope(); - - // tenant master schema migration - var tenantDbContext = scope.ServiceProvider.GetRequiredService(); - if (tenantDbContext.Database.GetPendingMigrations().Any()) - { - tenantDbContext.Database.Migrate(); - Log.Information("applied database migrations for tenant module"); - } - - // default tenant seeding - if (tenantDbContext.TenantInfo.Find(TenantConstants.Root.Id) is null) - { - var rootTenant = new FshTenantInfo( - TenantConstants.Root.Id, - TenantConstants.Root.Name, - string.Empty, - TenantConstants.Root.EmailAddress); - - rootTenant.SetValidity(DateTime.UtcNow.AddYears(1)); - tenantDbContext.TenantInfo.Add(rootTenant); - tenantDbContext.SaveChanges(); - Log.Information("configured default tenant data"); - } - - // get all tenants from store - var tenantStore = scope.ServiceProvider.GetRequiredService>(); - var tenants = tenantStore.GetAllAsync().Result; - - //dispose scope - scope.Dispose(); - - return tenants; - } -} diff --git a/src/framework/.editorconfig b/src/framework/.editorconfig new file mode 100644 index 0000000000..6380dfb67b --- /dev/null +++ b/src/framework/.editorconfig @@ -0,0 +1,297 @@ +# Remove the line below if you want to inherit .editorconfig settings from higher directories +root = true + +# C# files +[*.cs] + +#### Core EditorConfig Options #### + +# Indentation and spacing +indent_size = 4 +indent_style = space +tab_width = 4 + +# New line preferences +end_of_line = crlf +insert_final_newline = false + +#### .NET Code Actions #### + +# Type members +dotnet_hide_advanced_members = false +dotnet_member_insertion_location = with_other_members_of_the_same_kind +dotnet_property_generation_behavior = prefer_throwing_properties + +# Symbol search +dotnet_search_reference_assemblies = true + +#### .NET Coding Conventions #### + +# 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 +dotnet_style_qualification_for_field = false +dotnet_style_qualification_for_method = false +dotnet_style_qualification_for_property = false + +# Language keywords vs BCL types preferences +dotnet_style_predefined_type_for_locals_parameters_members = true +dotnet_style_predefined_type_for_member_access = true + +# Parentheses preferences +dotnet_style_parentheses_in_arithmetic_binary_operators = never_if_unnecessary:suggestion +dotnet_style_parentheses_in_other_binary_operators = never_if_unnecessary:suggestion +dotnet_style_parentheses_in_other_operators = never_if_unnecessary:suggestion +dotnet_style_parentheses_in_relational_binary_operators = never_if_unnecessary:suggestion + +# Modifier preferences +dotnet_style_require_accessibility_modifiers = for_non_interface_members + +# Expression-level preferences +dotnet_prefer_system_hash_code = true +dotnet_style_coalesce_expression = true +dotnet_style_collection_initializer = true +dotnet_style_explicit_tuple_names = true +dotnet_style_namespace_match_folder = true +dotnet_style_null_propagation = true +dotnet_style_object_initializer = true +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 +dotnet_style_prefer_compound_assignment = true:error +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 +dotnet_style_prefer_inferred_anonymous_type_member_names = true +dotnet_style_prefer_inferred_tuple_names = true +dotnet_style_prefer_is_null_check_over_reference_equality_method = true +dotnet_style_prefer_simplified_boolean_expressions = true +dotnet_style_prefer_simplified_interpolation = true + +# Field preferences +dotnet_style_readonly_field = true + +# Parameter preferences +dotnet_code_quality_unused_parameters = all:error + +# Suppression preferences +dotnet_remove_unnecessary_suppression_exclusions = none + +# New line preferences +dotnet_style_allow_multiple_blank_lines_experimental = true +dotnet_style_allow_statement_immediately_after_block_experimental = true + +#### C# Coding Conventions #### + +# var preferences +csharp_style_var_elsewhere = false +csharp_style_var_for_built_in_types = false +csharp_style_var_when_type_is_apparent = false + +# 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:none +csharp_style_expression_bodied_local_functions = true:error +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 +csharp_style_pattern_matching_over_is_with_cast_check = true +csharp_style_prefer_extended_property_pattern = true +csharp_style_prefer_not_pattern = true +csharp_style_prefer_pattern_matching = true +csharp_style_prefer_switch_expression = true + +# Null-checking preferences +csharp_style_conditional_delegate_call = true + +# Modifier preferences +csharp_prefer_static_anonymous_function = true +csharp_prefer_static_local_function = true:error +csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async +csharp_style_prefer_readonly_struct = true +csharp_style_prefer_readonly_struct_member = true + +# Code-block preferences +csharp_prefer_braces = true:silent +csharp_prefer_simple_using_statement = true:error +csharp_prefer_system_threading_lock = true:suggestion +csharp_style_namespace_declarations = file_scoped:error +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 +csharp_style_deconstructed_variable_declaration = true +csharp_style_implicit_object_creation_when_type_is_apparent = true +csharp_style_inlined_variable_declaration = true +csharp_style_prefer_index_operator = true:silent +csharp_style_prefer_local_over_anonymous_function = true +csharp_style_prefer_null_check_over_type_check = true +csharp_style_prefer_range_operator = true:silent +csharp_style_prefer_tuple_swap = true +csharp_style_prefer_utf8_string_literals = true +csharp_style_throw_expression = true +csharp_style_unused_value_assignment_preference = discard_variable +csharp_style_unused_value_expression_statement_preference = discard_variable + +# 'using' directive preferences +csharp_using_directive_placement = outside_namespace:error + +# New line preferences +csharp_style_allow_blank_line_after_colon_in_constructor_initializer_experimental = true +csharp_style_allow_blank_line_after_token_in_arrow_expression_clause_experimental = true +csharp_style_allow_blank_line_after_token_in_conditional_expression_experimental = true +csharp_style_allow_blank_lines_between_consecutive_braces_experimental = true +csharp_style_allow_embedded_statements_on_same_line_experimental = true + +#### 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_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 = false +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_before_comma = false +csharp_space_before_dot = false +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 = true + +#### Naming styles #### + +# Naming rules + +dotnet_naming_rule.interface_should_be_begins_with_i.severity = suggestion +dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface +dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i + +dotnet_naming_rule.types_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.types_should_be_pascal_case.symbols = types +dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case + +dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members +dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case + +# Symbol specifications + +dotnet_naming_symbols.interface.applicable_kinds = interface +dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.interface.required_modifiers = + +dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum +dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.types.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 = + +# Naming styles + +dotnet_naming_style.pascal_case.required_prefix = +dotnet_naming_style.pascal_case.required_suffix = +dotnet_naming_style.pascal_case.word_separator = +dotnet_naming_style.pascal_case.capitalization = pascal_case + +dotnet_naming_style.begins_with_i.required_prefix = I +dotnet_naming_style.begins_with_i.required_suffix = +dotnet_naming_style.begins_with_i.word_separator = +dotnet_naming_style.begins_with_i.capitalization = pascal_case + +# Static code analysis rule customizations + +# CA2007: Consider calling ConfigureAwait on the awaited task +dotnet_diagnostic.CA2007.severity = none + +# CA1515: Consider making public types internal +dotnet_diagnostic.CA1515.severity = none + +# CA1724: Type name conflicts with namespace +dotnet_diagnostic.CA1724.severity = none + +# S2094: Classes should not be empty +dotnet_diagnostic.S2094.severity = none + +# IDE0058: Expression value is never used +dotnet_diagnostic.IDE0058.severity = none + +# IDE0005: Remove unnecessary usings/imports +dotnet_diagnostic.IDE0005.severity = none + +# CA1062: Validate arguments of public methods +dotnet_diagnostic.CA1062.severity = none + +# S125: Remove commented out code +dotnet_diagnostic.S125.severity = none + +# IDE0053: Use expression body for lambda expression +dotnet_diagnostic.IDE0053.severity = none + +# IDE0130: Namespace should match project structure +dotnet_diagnostic.IDE0130.severity = none +dotnet_diagnostic.CA1032.severity = none +dotnet_diagnostic.S2326.severity = none + +[*.{cs,vb}] +dotnet_style_operator_placement_when_wrapping = beginning_of_line +tab_width = 4 +indent_size = 4 +end_of_line = crlf +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion +dotnet_style_prefer_auto_properties = true:suggestion +dotnet_style_object_initializer = true:suggestion +dotnet_diagnostic.CA1034.severity = none +dotnet_style_collection_initializer = true:suggestion +dotnet_style_prefer_simplified_boolean_expressions = true:suggestion +dotnet_style_prefer_conditional_expression_over_assignment = true:suggestion +dotnet_style_prefer_conditional_expression_over_return = true:suggestion +dotnet_diagnostic.CA1711.severity = none +dotnet_diagnostic.CA1040.severity = none +dotnet_diagnostic.CA1707.severity = none \ No newline at end of file diff --git a/src/framework/Directory.Build.props b/src/framework/Directory.Build.props new file mode 100644 index 0000000000..80521c53b1 --- /dev/null +++ b/src/framework/Directory.Build.props @@ -0,0 +1,47 @@ + + + + net9.0 + + + latest + enable + enable + + + false + false + true + latest + AllEnabledByDefault + + + true + 1591 + + + 3.0.0-alpha;latest + + + true + + + + + + all + runtime; build; native; contentfiles; analyzers + + + + + + Mukesh Murugan + FullStackHero + 3.0.0 + https://github.com/fullstackhero/dotnet-starter-kit + FSH;Modular;CQRS;VerticalSlice + + true + + diff --git a/src/Directory.Packages.props b/src/framework/Directory.Packages.props similarity index 75% rename from src/Directory.Packages.props rename to src/framework/Directory.Packages.props index 7015fadbab..ea37c17f10 100644 --- a/src/Directory.Packages.props +++ b/src/framework/Directory.Packages.props @@ -9,57 +9,62 @@ true - - + + - - - + + + - - - - - - - - - + + + + + + + + + + + + - - + + + + + - - + + + - - + + - + - - - + @@ -73,7 +78,7 @@ - + @@ -93,4 +98,10 @@ + + + + + + \ No newline at end of file diff --git a/src/framework/FSH.Framework.sln b/src/framework/FSH.Framework.sln new file mode 100644 index 0000000000..c7d50b9650 --- /dev/null +++ b/src/framework/FSH.Framework.sln @@ -0,0 +1,131 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Modules.Common.Infrastructure", "Modules\Common\Modules.Common.Infrastructure\Modules.Common.Infrastructure.csproj", "{60DF219E-E1EB-428E-BB1F-F0342D42699F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Modules.Common.Shared", "Modules\Common\Modules.Common.Shared\Modules.Common.Shared.csproj", "{09380C15-F138-4305-A595-F4C7E16B6E78}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Modules", "Modules", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{8EC462FD-D22E-90A8-E5CE-7E832BA40C5D}" + ProjectSection(SolutionItems) = preProject + .editorconfig = .editorconfig + Directory.Build.props = Directory.Build.props + Directory.Packages.props = Directory.Packages.props + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "PlayGround", "PlayGround", "{C9FFFCC7-8CEA-4890-9B6D-91D815A0B04B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Migrations.PostgreSQL", "PlayGround\Migrations\PostgreSQL\Migrations.PostgreSQL.csproj", "{EEF3610C-BF3C-DA23-0AA8-FED171CE30A2}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PlayGround.Api", "PlayGround\PlayGround.Api\PlayGround.Api.csproj", "{2527B9BA-2168-ED23-7E12-CF3C7C901279}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Common", "Common", "{83139AD6-D37E-4209-84A2-5E9023CEDA78}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tenant", "Tenant", "{297D1E34-BA90-4B75-80F4-646CF1FF59CC}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Modules.Tenant", "Modules\Tenant\Modules.Tenant\Modules.Tenant.csproj", "{FE8B966D-94E8-EF2D-535B-DC58B1D93B2C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Modules.Tenant.Contracts", "Modules\Tenant\Modules.Tenant.Contracts\Modules.Tenant.Contracts.csproj", "{13FDC8C1-8926-DBD6-4915-885E6B5067C2}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Identity", "Identity", "{020A76FA-D28D-48AA-AFAF-D89E3FE55327}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Modules.Identity", "Modules\Identity\Modules.Identity\Modules.Identity.csproj", "{A3A85996-F456-91C2-FABA-DD56D9ECCFE0}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Modules.Identity.Contracts", "Modules\Identity\Modules.Identity.Contracts\Modules.Identity.Contracts.csproj", "{14E87A21-A0F3-867D-9628-863DF0EFC8B0}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Modules.Common.Core", "Modules\Common\Modules.Common.Core\Modules.Common.Core.csproj", "{0069E711-9C02-C4FC-1C3D-84B5BE0D0998}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Auditing", "Auditing", "{C39F3653-FC44-4E54-A13F-ECE30DDBE888}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Modules.Auditing", "Modules\Auditing\Modules.Auditing\Modules.Auditing.csproj", "{A47D120F-EA39-7479-5FF1-E8F0B9524F1B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Modules.Auditing.Contracts", "Modules\Auditing\Modules.Auditing.Contracts\Modules.Auditing.Contracts.csproj", "{84460CBC-D11D-D584-6F3C-EEDC6966689F}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{F631F5FA-FCC7-4133-B7DE-0A7E2ED97169}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Architecture.Tests", "Tests\Architecture.Tests\Architecture.Tests.csproj", "{55291917-A598-4DC0-86B4-BD350D8F7C9A}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {60DF219E-E1EB-428E-BB1F-F0342D42699F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {60DF219E-E1EB-428E-BB1F-F0342D42699F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {60DF219E-E1EB-428E-BB1F-F0342D42699F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {60DF219E-E1EB-428E-BB1F-F0342D42699F}.Release|Any CPU.Build.0 = Release|Any CPU + {09380C15-F138-4305-A595-F4C7E16B6E78}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {09380C15-F138-4305-A595-F4C7E16B6E78}.Debug|Any CPU.Build.0 = Debug|Any CPU + {09380C15-F138-4305-A595-F4C7E16B6E78}.Release|Any CPU.ActiveCfg = Release|Any CPU + {09380C15-F138-4305-A595-F4C7E16B6E78}.Release|Any CPU.Build.0 = Release|Any CPU + {EEF3610C-BF3C-DA23-0AA8-FED171CE30A2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EEF3610C-BF3C-DA23-0AA8-FED171CE30A2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EEF3610C-BF3C-DA23-0AA8-FED171CE30A2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EEF3610C-BF3C-DA23-0AA8-FED171CE30A2}.Release|Any CPU.Build.0 = Release|Any CPU + {2527B9BA-2168-ED23-7E12-CF3C7C901279}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2527B9BA-2168-ED23-7E12-CF3C7C901279}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2527B9BA-2168-ED23-7E12-CF3C7C901279}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2527B9BA-2168-ED23-7E12-CF3C7C901279}.Release|Any CPU.Build.0 = Release|Any CPU + {FE8B966D-94E8-EF2D-535B-DC58B1D93B2C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FE8B966D-94E8-EF2D-535B-DC58B1D93B2C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FE8B966D-94E8-EF2D-535B-DC58B1D93B2C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FE8B966D-94E8-EF2D-535B-DC58B1D93B2C}.Release|Any CPU.Build.0 = Release|Any CPU + {13FDC8C1-8926-DBD6-4915-885E6B5067C2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {13FDC8C1-8926-DBD6-4915-885E6B5067C2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {13FDC8C1-8926-DBD6-4915-885E6B5067C2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {13FDC8C1-8926-DBD6-4915-885E6B5067C2}.Release|Any CPU.Build.0 = Release|Any CPU + {A3A85996-F456-91C2-FABA-DD56D9ECCFE0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A3A85996-F456-91C2-FABA-DD56D9ECCFE0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A3A85996-F456-91C2-FABA-DD56D9ECCFE0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A3A85996-F456-91C2-FABA-DD56D9ECCFE0}.Release|Any CPU.Build.0 = Release|Any CPU + {14E87A21-A0F3-867D-9628-863DF0EFC8B0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {14E87A21-A0F3-867D-9628-863DF0EFC8B0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {14E87A21-A0F3-867D-9628-863DF0EFC8B0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {14E87A21-A0F3-867D-9628-863DF0EFC8B0}.Release|Any CPU.Build.0 = Release|Any CPU + {0069E711-9C02-C4FC-1C3D-84B5BE0D0998}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0069E711-9C02-C4FC-1C3D-84B5BE0D0998}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0069E711-9C02-C4FC-1C3D-84B5BE0D0998}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0069E711-9C02-C4FC-1C3D-84B5BE0D0998}.Release|Any CPU.Build.0 = Release|Any CPU + {A47D120F-EA39-7479-5FF1-E8F0B9524F1B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A47D120F-EA39-7479-5FF1-E8F0B9524F1B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A47D120F-EA39-7479-5FF1-E8F0B9524F1B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A47D120F-EA39-7479-5FF1-E8F0B9524F1B}.Release|Any CPU.Build.0 = Release|Any CPU + {84460CBC-D11D-D584-6F3C-EEDC6966689F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {84460CBC-D11D-D584-6F3C-EEDC6966689F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {84460CBC-D11D-D584-6F3C-EEDC6966689F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {84460CBC-D11D-D584-6F3C-EEDC6966689F}.Release|Any CPU.Build.0 = Release|Any CPU + {55291917-A598-4DC0-86B4-BD350D8F7C9A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {55291917-A598-4DC0-86B4-BD350D8F7C9A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {55291917-A598-4DC0-86B4-BD350D8F7C9A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {55291917-A598-4DC0-86B4-BD350D8F7C9A}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {60DF219E-E1EB-428E-BB1F-F0342D42699F} = {83139AD6-D37E-4209-84A2-5E9023CEDA78} + {09380C15-F138-4305-A595-F4C7E16B6E78} = {83139AD6-D37E-4209-84A2-5E9023CEDA78} + {EEF3610C-BF3C-DA23-0AA8-FED171CE30A2} = {C9FFFCC7-8CEA-4890-9B6D-91D815A0B04B} + {2527B9BA-2168-ED23-7E12-CF3C7C901279} = {C9FFFCC7-8CEA-4890-9B6D-91D815A0B04B} + {83139AD6-D37E-4209-84A2-5E9023CEDA78} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} + {297D1E34-BA90-4B75-80F4-646CF1FF59CC} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} + {FE8B966D-94E8-EF2D-535B-DC58B1D93B2C} = {297D1E34-BA90-4B75-80F4-646CF1FF59CC} + {13FDC8C1-8926-DBD6-4915-885E6B5067C2} = {297D1E34-BA90-4B75-80F4-646CF1FF59CC} + {020A76FA-D28D-48AA-AFAF-D89E3FE55327} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} + {A3A85996-F456-91C2-FABA-DD56D9ECCFE0} = {020A76FA-D28D-48AA-AFAF-D89E3FE55327} + {14E87A21-A0F3-867D-9628-863DF0EFC8B0} = {020A76FA-D28D-48AA-AFAF-D89E3FE55327} + {0069E711-9C02-C4FC-1C3D-84B5BE0D0998} = {83139AD6-D37E-4209-84A2-5E9023CEDA78} + {C39F3653-FC44-4E54-A13F-ECE30DDBE888} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} + {A47D120F-EA39-7479-5FF1-E8F0B9524F1B} = {C39F3653-FC44-4E54-A13F-ECE30DDBE888} + {84460CBC-D11D-D584-6F3C-EEDC6966689F} = {C39F3653-FC44-4E54-A13F-ECE30DDBE888} + {55291917-A598-4DC0-86B4-BD350D8F7C9A} = {F631F5FA-FCC7-4133-B7DE-0A7E2ED97169} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {438A0F87-0F3E-43C4-A352-264E0C21F77B} + SolutionGuid = {A2A6BABD-325C-4482-8830-41058E5D509D} + EndGlobalSection +EndGlobal diff --git a/src/framework/Modules/Auditing/Modules.Auditing.Contracts/AuditingConstants.cs b/src/framework/Modules/Auditing/Modules.Auditing.Contracts/AuditingConstants.cs new file mode 100644 index 0000000000..4b21461fc0 --- /dev/null +++ b/src/framework/Modules/Auditing/Modules.Auditing.Contracts/AuditingConstants.cs @@ -0,0 +1,18 @@ +namespace FSH.Framework.Auditing.Contracts; + +public static class AuditingConstants +{ + public const string SchemaName = "auditing"; + public const string ModuleName = "Auditing"; + + public static class Permissions + { + public const string View = "Permissions.Auditing.View"; + } + + public static class Routes + { + public const string Base = "/v1/auditing"; + public const string GetUserLogs = $"{Base}/logs/{{userId:guid}}"; + } +} \ No newline at end of file diff --git a/src/framework/Modules/Auditing/Modules.Auditing.Contracts/Dtos/TrailDto.cs b/src/framework/Modules/Auditing/Modules.Auditing.Contracts/Dtos/TrailDto.cs new file mode 100644 index 0000000000..059826e875 --- /dev/null +++ b/src/framework/Modules/Auditing/Modules.Auditing.Contracts/Dtos/TrailDto.cs @@ -0,0 +1,19 @@ +using FSH.Framework.Auditing.Contracts.Enums; +using System.Collections.ObjectModel; + +namespace FSH.Framework.Auditing.Contracts.Dtos; +public class TrailDto +{ + public Guid Id { get; set; } + public DateTimeOffset DateTime { get; set; } + public Guid UserId { get; set; } + public AuditOperation Operation { get; set; } + public string Description { get; set; } = default!; + public string EntityName { get; set; } = default!; + + // Uncomment if needed later + public Dictionary KeyValues { get; set; } = new(); + public Dictionary OldValues { get; set; } = new(); + public Dictionary NewValues { get; set; } = new(); + public Collection ModifiedProperties { get; set; } = new(); +} \ No newline at end of file diff --git a/src/framework/Modules/Auditing/Modules.Auditing.Contracts/Enums/AuditOperation.cs b/src/framework/Modules/Auditing/Modules.Auditing.Contracts/Enums/AuditOperation.cs new file mode 100644 index 0000000000..ff20b12d48 --- /dev/null +++ b/src/framework/Modules/Auditing/Modules.Auditing.Contracts/Enums/AuditOperation.cs @@ -0,0 +1,8 @@ +namespace FSH.Framework.Auditing.Contracts.Enums; +public enum AuditOperation +{ + None = 0, + Create = 1, + Update = 2, + Delete = 3 +} \ No newline at end of file diff --git a/src/framework/Modules/Auditing/Modules.Auditing.Contracts/Events/IntegrationEvents/AuditPublishedEvent.cs b/src/framework/Modules/Auditing/Modules.Auditing.Contracts/Events/IntegrationEvents/AuditPublishedEvent.cs new file mode 100644 index 0000000000..b42a68b331 --- /dev/null +++ b/src/framework/Modules/Auditing/Modules.Auditing.Contracts/Events/IntegrationEvents/AuditPublishedEvent.cs @@ -0,0 +1,14 @@ +using FSH.Framework.Auditing.Contracts.Dtos; +using FSH.Framework.Core.Messaging.Events; + +namespace FSH.Framework.Auditing.Contracts.Events.IntegrationEvents; + +public class AuditPublishedEvent : IEvent +{ + public IReadOnlyCollection Trails { get; } + + public AuditPublishedEvent(IReadOnlyCollection trails) + { + Trails = trails; + } +} \ No newline at end of file diff --git a/src/framework/Modules/Auditing/Modules.Auditing.Contracts/Modules.Auditing.Contracts.csproj b/src/framework/Modules/Auditing/Modules.Auditing.Contracts/Modules.Auditing.Contracts.csproj new file mode 100644 index 0000000000..ca796ab656 --- /dev/null +++ b/src/framework/Modules/Auditing/Modules.Auditing.Contracts/Modules.Auditing.Contracts.csproj @@ -0,0 +1,12 @@ + + + FSH.Modules.Auditing.Contracts + FSH.Modules.Auditing.Contracts + + + + + + + + diff --git a/src/framework/Modules/Auditing/Modules.Auditing.Contracts/v1/GetUserTrails/GetUserTrailsQuery.cs b/src/framework/Modules/Auditing/Modules.Auditing.Contracts/v1/GetUserTrails/GetUserTrailsQuery.cs new file mode 100644 index 0000000000..1edab3eaaa --- /dev/null +++ b/src/framework/Modules/Auditing/Modules.Auditing.Contracts/v1/GetUserTrails/GetUserTrailsQuery.cs @@ -0,0 +1,4 @@ +using FSH.Framework.Core.Messaging.CQRS; + +namespace FSH.Framework.Auditing.Contracts.v1.GetUserTrails; +public sealed record GetUserTrailsQuery(Guid UserId) : IQuery; \ No newline at end of file diff --git a/src/framework/Modules/Auditing/Modules.Auditing.Contracts/v1/GetUserTrails/GetUserTrailsQueryResponse.cs b/src/framework/Modules/Auditing/Modules.Auditing.Contracts/v1/GetUserTrails/GetUserTrailsQueryResponse.cs new file mode 100644 index 0000000000..8c9a426519 --- /dev/null +++ b/src/framework/Modules/Auditing/Modules.Auditing.Contracts/v1/GetUserTrails/GetUserTrailsQueryResponse.cs @@ -0,0 +1,4 @@ +using FSH.Framework.Auditing.Contracts.Dtos; + +namespace FSH.Framework.Auditing.Contracts.v1.GetUserTrails; +public sealed record GetUserTrailsQueryResponse(IReadOnlyList AuditTrails); \ No newline at end of file diff --git a/src/framework/Modules/Auditing/Modules.Auditing/AuditingModule.cs b/src/framework/Modules/Auditing/Modules.Auditing/AuditingModule.cs new file mode 100644 index 0000000000..e1380b67a8 --- /dev/null +++ b/src/framework/Modules/Auditing/Modules.Auditing/AuditingModule.cs @@ -0,0 +1,43 @@ +using Asp.Versioning; +using FSH.Framework.Auditing.Data; +using FSH.Framework.Auditing.Features.v1.GetUserTrails; +using FSH.Framework.Auditing.Services; +using FSH.Framework.Core.Persistence; +using FSH.Framework.Infrastructure.Messaging.CQRS; +using FSH.Framework.Infrastructure.Persistence; +using FSH.Modules.Common.Infrastructure.Modules; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace FSH.Modules.Auditing; +public class AuditingModule : IModule +{ + public void AddModule(IServiceCollection services, IConfiguration config) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddScoped(provider => provider.GetRequiredService()); + services.RegisterCommandAndQueryHandlers(typeof(AuditingModule).Assembly); + services.AddScoped(); + services.BindDbContext(); + services.AddScoped(); + } + + public void ConfigureModule(WebApplication app) + { + var apiVersionSet = app.NewApiVersionSet() + .HasApiVersion(new ApiVersion(1)) + .ReportApiVersions() + .Build(); + + var group = app + .MapGroup("api/v{version:apiVersion}/auditing") + .WithTags("Auditing") + .WithOpenApi() + .WithApiVersionSet(apiVersionSet); + + group.Map(); + } +} \ No newline at end of file diff --git a/src/framework/Modules/Auditing/Modules.Auditing/Data/AuditingConfiguration.cs b/src/framework/Modules/Auditing/Modules.Auditing/Data/AuditingConfiguration.cs new file mode 100644 index 0000000000..ac9eb07277 --- /dev/null +++ b/src/framework/Modules/Auditing/Modules.Auditing/Data/AuditingConfiguration.cs @@ -0,0 +1,18 @@ +using Finbuckle.MultiTenant; +using FSH.Framework.Auditing.Contracts; +using FSH.Framework.Auditing.Core.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FSH.Framework.Auditing.Data; +public class TrailConfig : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder + .ToTable("Trails", AuditingConstants.SchemaName) + .IsMultiTenant(); + + builder.HasKey(a => a.Id); + } +} \ No newline at end of file diff --git a/src/framework/Modules/Auditing/Modules.Auditing/Data/AuditingDbContext.cs b/src/framework/Modules/Auditing/Modules.Auditing/Data/AuditingDbContext.cs new file mode 100644 index 0000000000..2f523e44cf --- /dev/null +++ b/src/framework/Modules/Auditing/Modules.Auditing/Data/AuditingDbContext.cs @@ -0,0 +1,26 @@ +using Finbuckle.MultiTenant.Abstractions; +using FSH.Framework.Auditing.Core.Entities; +using FSH.Framework.Core.Messaging.Events; +using FSH.Framework.Core.Persistence; +using FSH.Framework.Infrastructure.Persistence; +using FSH.Framework.Shared.Multitenancy; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace FSH.Framework.Auditing.Data; +public class AuditingDbContext : FshDbContext, IAuditingDbContext +{ + public DbSet Trails { get; set; } + + public AuditingDbContext( + IMultiTenantContextAccessor multiTenantContextAccessor, + DbContextOptions options, + IEventPublisher publisher, + IOptions settings) : base(multiTenantContextAccessor, options, publisher, settings) { } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.ApplyConfigurationsFromAssembly(typeof(AuditingDbContext).Assembly); + } + +} \ No newline at end of file diff --git a/src/framework/Modules/Auditing/Modules.Auditing/Data/AuditingDbInitializer.cs b/src/framework/Modules/Auditing/Modules.Auditing/Data/AuditingDbInitializer.cs new file mode 100644 index 0000000000..b248631bbc --- /dev/null +++ b/src/framework/Modules/Auditing/Modules.Auditing/Data/AuditingDbInitializer.cs @@ -0,0 +1,19 @@ +using FSH.Framework.Core.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace FSH.Framework.Auditing.Data; +public class AuditingDbInitializer(AuditingDbContext context) : IDbInitializer +{ + public async Task MigrateAsync(CancellationToken cancellationToken) + { + if ((await context.Database.GetPendingMigrationsAsync(cancellationToken).ConfigureAwait(false)).Any()) + { + await context.Database.MigrateAsync(cancellationToken).ConfigureAwait(false); + } + } + + public Task SeedAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/src/framework/Modules/Auditing/Modules.Auditing/Data/IAuditingDbContext.cs b/src/framework/Modules/Auditing/Modules.Auditing/Data/IAuditingDbContext.cs new file mode 100644 index 0000000000..28e262da5d --- /dev/null +++ b/src/framework/Modules/Auditing/Modules.Auditing/Data/IAuditingDbContext.cs @@ -0,0 +1,9 @@ +using FSH.Framework.Auditing.Core.Entities; +using Microsoft.EntityFrameworkCore; + +namespace FSH.Framework.Auditing.Data; +public interface IAuditingDbContext +{ + DbSet Trails { get; } + Task SaveChangesAsync(CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/api/framework/Infrastructure/Persistence/Interceptors/AuditInterceptor.cs b/src/framework/Modules/Auditing/Modules.Auditing/Data/Interceptors/AuditInterceptor.cs similarity index 71% rename from src/api/framework/Infrastructure/Persistence/Interceptors/AuditInterceptor.cs rename to src/framework/Modules/Auditing/Modules.Auditing/Data/Interceptors/AuditInterceptor.cs index 6c2d819cac..f56d9de66d 100644 --- a/src/api/framework/Infrastructure/Persistence/Interceptors/AuditInterceptor.cs +++ b/src/framework/Modules/Auditing/Modules.Auditing/Data/Interceptors/AuditInterceptor.cs @@ -1,18 +1,17 @@ -using System.Collections.ObjectModel; -using FSH.Framework.Core.Audit; +using FSH.Framework.Auditing.Contracts.Dtos; +using FSH.Framework.Auditing.Contracts.Enums; +using FSH.Framework.Auditing.Contracts.Events.IntegrationEvents; using FSH.Framework.Core.Domain; -using FSH.Framework.Core.Domain.Contracts; -using FSH.Framework.Core.Identity.Users.Abstractions; -using FSH.Framework.Infrastructure.Identity.Audit; -using MediatR; +using FSH.Framework.Core.ExecutionContext; +using FSH.Framework.Core.Messaging.Events; +using FSH.Modules.Common.Core.Domain.Contracts; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.ChangeTracking; using Microsoft.EntityFrameworkCore.Diagnostics; -namespace FSH.Framework.Infrastructure.Persistence.Interceptors; -public class AuditInterceptor(ICurrentUser currentUser, TimeProvider timeProvider, IPublisher publisher) : SaveChangesInterceptor +namespace FSH.Framework.Auditing.Data.Interceptors; +public class AuditInterceptor(ICurrentUser currentUser, TimeProvider timeProvider, IEventPublisher publisher) : SaveChangesInterceptor { - public override ValueTask SavedChangesAsync(SaveChangesCompletedEventData eventData, int result, CancellationToken cancellationToken = default) { return base.SavedChangesAsync(eventData, result, cancellationToken); @@ -26,11 +25,11 @@ public override Task SaveChangesFailedAsync(DbContextErrorEventData eventData, C public override async ValueTask> SavingChangesAsync(DbContextEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) { UpdateEntities(eventData.Context); - await PublishAuditTrailsAsync(eventData); + await PublishAuditTrailsAsync(eventData, cancellationToken); return await base.SavingChangesAsync(eventData, result, cancellationToken); } - private async Task PublishAuditTrailsAsync(DbContextEventData eventData) + private async Task PublishAuditTrailsAsync(DbContextEventData eventData, CancellationToken cancellationToken) { if (eventData.Context == null) return; eventData.Context.ChangeTracker.DetectChanges(); @@ -39,10 +38,11 @@ private async Task PublishAuditTrailsAsync(DbContextEventData eventData) foreach (var entry in eventData.Context.ChangeTracker.Entries().Where(x => x.State is EntityState.Added or EntityState.Deleted or EntityState.Modified).ToList()) { var userId = currentUser.GetUserId(); - var trail = new TrailDto() + var audit = new TrailDto() { Id = Guid.NewGuid(), - TableName = entry.Entity.GetType().Name, + EntityName = entry.Entity.GetType().Name, + Description = entry.Entity.GetType().Name, UserId = userId, DateTime = utcNow }; @@ -56,20 +56,20 @@ private async Task PublishAuditTrailsAsync(DbContextEventData eventData) string propertyName = property.Metadata.Name; if (property.Metadata.IsPrimaryKey()) { - trail.KeyValues[propertyName] = property.CurrentValue; + audit.KeyValues[propertyName] = property.CurrentValue; continue; } switch (entry.State) { case EntityState.Added: - trail.Type = TrailType.Create; - trail.NewValues[propertyName] = property.CurrentValue; + audit.Operation = AuditOperation.Create; + audit.NewValues[propertyName] = property.CurrentValue; break; case EntityState.Deleted: - trail.Type = TrailType.Delete; - trail.OldValues[propertyName] = property.OriginalValue; + audit.Operation = AuditOperation.Delete; + audit.OldValues[propertyName] = property.OriginalValue; break; case EntityState.Modified: @@ -77,17 +77,17 @@ private async Task PublishAuditTrailsAsync(DbContextEventData eventData) { if (entry.Entity is ISoftDeletable && property.OriginalValue == null && property.CurrentValue != null) { - trail.ModifiedProperties.Add(propertyName); - trail.Type = TrailType.Delete; - trail.OldValues[propertyName] = property.OriginalValue; - trail.NewValues[propertyName] = property.CurrentValue; + audit.ModifiedProperties.Add(propertyName); + audit.Operation = AuditOperation.Delete; + audit.OldValues[propertyName] = property.OriginalValue; + audit.NewValues[propertyName] = property.CurrentValue; } else if (property.OriginalValue?.Equals(property.CurrentValue) == false) { - trail.ModifiedProperties.Add(propertyName); - trail.Type = TrailType.Update; - trail.OldValues[propertyName] = property.OriginalValue; - trail.NewValues[propertyName] = property.CurrentValue; + audit.ModifiedProperties.Add(propertyName); + audit.Operation = AuditOperation.Update; + audit.OldValues[propertyName] = property.OriginalValue; + audit.NewValues[propertyName] = property.CurrentValue; } else { @@ -98,15 +98,10 @@ private async Task PublishAuditTrailsAsync(DbContextEventData eventData) } } - trails.Add(trail); + trails.Add(audit); } if (trails.Count == 0) return; - var auditTrails = new Collection(); - foreach (var trail in trails) - { - auditTrails.Add(trail.ToAuditTrail()); - } - await publisher.Publish(new AuditPublishedEvent(auditTrails)); + await publisher.PublishAsync(new AuditPublishedEvent(trails), cancellationToken); } public void UpdateEntities(DbContext? context) @@ -125,7 +120,7 @@ public void UpdateEntities(DbContext? context) entry.Entity.LastModifiedBy = currentUser.GetUserId(); entry.Entity.LastModified = utcNow; } - if(entry.State is EntityState.Deleted && entry.Entity is ISoftDeletable softDelete) + if (entry.State is EntityState.Deleted && entry.Entity is ISoftDeletable softDelete) { softDelete.DeletedBy = currentUser.GetUserId(); softDelete.Deleted = utcNow; @@ -142,4 +137,4 @@ public static bool HasChangedOwnedEntities(this EntityEntry entry) => r.TargetEntry != null && r.TargetEntry.Metadata.IsOwned() && (r.TargetEntry.State == EntityState.Added || r.TargetEntry.State == EntityState.Modified)); -} +} \ No newline at end of file diff --git a/src/framework/Modules/Auditing/Modules.Auditing/EventHandlers/AuditPublishedIntegrationEventHandler.cs b/src/framework/Modules/Auditing/Modules.Auditing/EventHandlers/AuditPublishedIntegrationEventHandler.cs new file mode 100644 index 0000000000..877d22b408 --- /dev/null +++ b/src/framework/Modules/Auditing/Modules.Auditing/EventHandlers/AuditPublishedIntegrationEventHandler.cs @@ -0,0 +1,41 @@ +using FSH.Framework.Auditing.Contracts.Events.IntegrationEvents; +using FSH.Framework.Auditing.Data; +using FSH.Framework.Core.Messaging.Events; +using FSH.Modules.Auditing.Core.Mappings; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using System.Diagnostics.CodeAnalysis; + +namespace FSH.Framework.Auditing.EventHandlers; + +[SuppressMessage("Performance", "CA1848")] +[SuppressMessage("Design", "CA1031")] +public class AuditPublishedIntegrationEventHandler( + ILogger logger, + IAuditingDbContext context) + : IEventHandler +{ + public async Task HandleAsync(AuditPublishedEvent notification, CancellationToken cancellationToken = default) + { + if (notification.Trails == null || notification.Trails.Count == 0) + { + logger.LogDebug("No audit trails to persist."); + return; + } + try + { + var trailEntities = notification.Trails.ToEntityList(); + await context.Trails.AddRangeAsync(trailEntities, cancellationToken); + await context.SaveChangesAsync(cancellationToken); + logger.LogInformation("Persisted {Count} audit trail(s).", notification.Trails.Count); + } + catch (DbUpdateException ex) + { + logger.LogError(ex, "Database update error while saving audit trails."); + } + catch (Exception ex) + { + logger.LogError(ex, "Unexpected error while saving audit trails."); + } + } +} \ No newline at end of file diff --git a/src/framework/Modules/Auditing/Modules.Auditing/Features/Entities/Trail.cs b/src/framework/Modules/Auditing/Modules.Auditing/Features/Entities/Trail.cs new file mode 100644 index 0000000000..2ced3612c9 --- /dev/null +++ b/src/framework/Modules/Auditing/Modules.Auditing/Features/Entities/Trail.cs @@ -0,0 +1,69 @@ +using FSH.Framework.Auditing.Contracts.Enums; +using System.ComponentModel.DataAnnotations.Schema; +using System.Text.Json; + +namespace FSH.Framework.Auditing.Core.Entities; + +public class Trail +{ + public Guid Id { get; set; } + public Guid UserId { get; set; } + public DateTimeOffset DateTime { get; set; } + public AuditOperation Operation { get; set; } + public string Description { get; set; } = default!; + public string? EntityName { get; set; } + + // Backing fields for JSON storage (persisted) + public string KeyValuesJson { get; private set; } = "{}"; + public string OldValuesJson { get; private set; } = "{}"; + public string NewValuesJson { get; private set; } = "{}"; + public string ModifiedPropertiesJson { get; private set; } = "[]"; + + // Domain-facing properties (not mapped) + [NotMapped] + public IReadOnlyDictionary KeyValues => + DeserializeDict(KeyValuesJson); + + [NotMapped] + public IReadOnlyDictionary OldValues => + DeserializeDict(OldValuesJson); + + [NotMapped] + public IReadOnlyDictionary NewValues => + DeserializeDict(NewValuesJson); + + [NotMapped] + public IReadOnlyCollection ModifiedProperties => + JsonSerializer.Deserialize>(ModifiedPropertiesJson) + ?? []; + + // Setters for domain logic + public void SetKeyValues(Dictionary values) => + KeyValuesJson = Serialize(values); + + public void SetOldValues(Dictionary values) => + OldValuesJson = Serialize(values); + + public void SetNewValues(Dictionary values) => + NewValuesJson = Serialize(values); + + public void SetModifiedProperties(IEnumerable properties) => + ModifiedPropertiesJson = JsonSerializer.Serialize(properties.Distinct().ToList()); + + public void AddModifiedProperty(string property) + { + var props = ModifiedProperties.ToList(); + if (!props.Contains(property)) + props.Add(property); + ModifiedPropertiesJson = JsonSerializer.Serialize(props); + } + + // Helpers + private static Dictionary DeserializeDict(string json) + { + return JsonSerializer.Deserialize>(json) ?? new Dictionary(); + } + + private static string Serialize(object obj) => + JsonSerializer.Serialize(obj); +} \ No newline at end of file diff --git a/src/framework/Modules/Auditing/Modules.Auditing/Features/v1/GetUserTrails/GetUserTrailsEndpoint.cs b/src/framework/Modules/Auditing/Modules.Auditing/Features/v1/GetUserTrails/GetUserTrailsEndpoint.cs new file mode 100644 index 0000000000..7603ebafff --- /dev/null +++ b/src/framework/Modules/Auditing/Modules.Auditing/Features/v1/GetUserTrails/GetUserTrailsEndpoint.cs @@ -0,0 +1,28 @@ +using FSH.Framework.Auditing.Contracts.v1.GetUserTrails; +using FSH.Framework.Core.Messaging.CQRS; +using FSH.Framework.Shared.Authorization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; + +namespace FSH.Framework.Auditing.Features.v1.GetUserTrails; +public static class GetUserTrailsEndpoint +{ + public static RouteHandlerBuilder Map(this IEndpointRouteBuilder endpoints) + { + return endpoints.MapGet("/users/{userId:guid}/trails", async ( + Guid userId, + [FromServices] IQueryDispatcher dispatcher, + CancellationToken cancellationToken) => + { + var query = new GetUserTrailsQuery(userId); + var result = await dispatcher.SendAsync(query, cancellationToken); + return TypedResults.Ok(result); + }) + .WithName(nameof(GetUserTrailsEndpoint)) + .WithSummary("Get user's audit trail details") + .WithDescription("Returns the audit trail details for a specific user.") + .RequirePermission("Permissions.AuditTrails.View"); + } +} \ No newline at end of file diff --git a/src/framework/Modules/Auditing/Modules.Auditing/Features/v1/GetUserTrails/GetUserTrailsQueryHandler.cs b/src/framework/Modules/Auditing/Modules.Auditing/Features/v1/GetUserTrails/GetUserTrailsQueryHandler.cs new file mode 100644 index 0000000000..86ebddd801 --- /dev/null +++ b/src/framework/Modules/Auditing/Modules.Auditing/Features/v1/GetUserTrails/GetUserTrailsQueryHandler.cs @@ -0,0 +1,14 @@ +using FSH.Framework.Auditing.Contracts.v1.GetUserTrails; +using FSH.Framework.Auditing.Services; +using FSH.Framework.Core.Messaging.CQRS; + +namespace FSH.Framework.Auditing.Features.v1.GetUserTrails; +internal sealed class GetUserTrailsQueryHandler(IAuditService auditService) + : IQueryHandler +{ + public async Task HandleAsync(GetUserTrailsQuery query, CancellationToken cancellationToken = default) + { + var trails = await auditService.GetUserTrailsAsync(query.UserId); + return new GetUserTrailsQueryResponse(trails); + } +} \ No newline at end of file diff --git a/src/framework/Modules/Auditing/Modules.Auditing/Features/v1/GetUserTrails/GetUserTrailsQueryValidator.cs b/src/framework/Modules/Auditing/Modules.Auditing/Features/v1/GetUserTrails/GetUserTrailsQueryValidator.cs new file mode 100644 index 0000000000..cb2f5f2177 --- /dev/null +++ b/src/framework/Modules/Auditing/Modules.Auditing/Features/v1/GetUserTrails/GetUserTrailsQueryValidator.cs @@ -0,0 +1,11 @@ +using FluentValidation; +using FSH.Framework.Auditing.Contracts.v1.GetUserTrails; + +namespace FSH.Framework.Auditing.Features.v1.GetUserTrails; +internal sealed class GetUserTrailsQueryValidator : AbstractValidator +{ + public GetUserTrailsQueryValidator() + { + RuleFor(x => x.UserId).NotEmpty(); + } +} \ No newline at end of file diff --git a/src/framework/Modules/Auditing/Modules.Auditing/Mappings/TrailMappings.cs b/src/framework/Modules/Auditing/Modules.Auditing/Mappings/TrailMappings.cs new file mode 100644 index 0000000000..a50325f537 --- /dev/null +++ b/src/framework/Modules/Auditing/Modules.Auditing/Mappings/TrailMappings.cs @@ -0,0 +1,56 @@ +using FSH.Framework.Auditing.Contracts.Dtos; +using FSH.Framework.Auditing.Core.Entities; +using System.Collections.ObjectModel; + +namespace FSH.Modules.Auditing.Core.Mappings; + +public static class TrailMappings +{ + public static TrailDto ToDto(this Trail trail) + { + return new TrailDto + { + Id = trail.Id, + DateTime = trail.DateTime, + UserId = trail.UserId, + Operation = trail.Operation, + Description = trail.Description, + EntityName = trail.EntityName ?? string.Empty, + KeyValues = new Dictionary(trail.KeyValues), + OldValues = new Dictionary(trail.OldValues), + NewValues = new Dictionary(trail.NewValues), + ModifiedProperties = new Collection(trail.ModifiedProperties.ToList()) + }; + } + + + public static IReadOnlyList ToDtoList(this IEnumerable trails) + { + return trails.Select(t => t.ToDto()).ToList(); + } + + public static Trail ToEntity(this TrailDto dto) + { + var entity = new Trail + { + Id = dto.Id, + DateTime = dto.DateTime, + UserId = dto.UserId, + Operation = dto.Operation, + Description = dto.Description, + EntityName = dto.EntityName + }; + + entity.SetKeyValues(dto.KeyValues); + entity.SetOldValues(dto.OldValues); + entity.SetNewValues(dto.NewValues); + entity.SetModifiedProperties(dto.ModifiedProperties); + + return entity; + } + + public static IReadOnlyList ToEntityList(this IEnumerable dtos) + { + return dtos.Select(dto => dto.ToEntity()).ToList(); + } +} \ No newline at end of file diff --git a/src/framework/Modules/Auditing/Modules.Auditing/Modules.Auditing.csproj b/src/framework/Modules/Auditing/Modules.Auditing/Modules.Auditing.csproj new file mode 100644 index 0000000000..ccc2253e80 --- /dev/null +++ b/src/framework/Modules/Auditing/Modules.Auditing/Modules.Auditing.csproj @@ -0,0 +1,20 @@ + + + FSH.Modules.Auditing + FSH.Modules.Auditing + + + FSH.Modules.Auditing + Auditing module for FullStackHero + + + net9.0 + enable + enable + + + + + + + diff --git a/src/framework/Modules/Auditing/Modules.Auditing/Services/AuditService.cs b/src/framework/Modules/Auditing/Modules.Auditing/Services/AuditService.cs new file mode 100644 index 0000000000..82344479e6 --- /dev/null +++ b/src/framework/Modules/Auditing/Modules.Auditing/Services/AuditService.cs @@ -0,0 +1,19 @@ +using FSH.Framework.Auditing.Contracts.Dtos; +using FSH.Framework.Auditing.Data; +using FSH.Modules.Auditing.Core.Mappings; +using Microsoft.EntityFrameworkCore; + +namespace FSH.Framework.Auditing.Services; +public class AuditService(IAuditingDbContext context) : IAuditService +{ + public async Task> GetUserTrailsAsync(Guid userId) + { + var trails = await context.Trails + .Where(a => a.UserId == userId) + .OrderByDescending(a => a.DateTime) + .Take(250) + .ToListAsync(); + + return trails.ToDtoList(); + } +} \ No newline at end of file diff --git a/src/framework/Modules/Auditing/Modules.Auditing/Services/IAuditService.cs b/src/framework/Modules/Auditing/Modules.Auditing/Services/IAuditService.cs new file mode 100644 index 0000000000..1ac37e58bb --- /dev/null +++ b/src/framework/Modules/Auditing/Modules.Auditing/Services/IAuditService.cs @@ -0,0 +1,7 @@ +using FSH.Framework.Auditing.Contracts.Dtos; + +namespace FSH.Framework.Auditing.Services; +public interface IAuditService +{ + Task> GetUserTrailsAsync(Guid userId); +} \ No newline at end of file diff --git a/src/api/framework/Core/Caching/CacheOptions.cs b/src/framework/Modules/Common/Modules.Common.Core/Caching/CacheOptions.cs similarity index 65% rename from src/api/framework/Core/Caching/CacheOptions.cs rename to src/framework/Modules/Common/Modules.Common.Core/Caching/CacheOptions.cs index b861c2e06a..ae06641a3b 100644 --- a/src/api/framework/Core/Caching/CacheOptions.cs +++ b/src/framework/Modules/Common/Modules.Common.Core/Caching/CacheOptions.cs @@ -1,6 +1,6 @@ -namespace FSH.Framework.Core.Caching; +namespace FSH.Modules.Common.Core.Caching; public class CacheOptions { public string Redis { get; set; } = string.Empty; -} +} \ No newline at end of file diff --git a/src/api/framework/Core/Caching/CacheServiceExtensions.cs b/src/framework/Modules/Common/Modules.Common.Core/Caching/CacheServiceExtensions.cs similarity index 71% rename from src/api/framework/Core/Caching/CacheServiceExtensions.cs rename to src/framework/Modules/Common/Modules.Common.Core/Caching/CacheServiceExtensions.cs index c03f94cc1f..df86cce679 100644 --- a/src/api/framework/Core/Caching/CacheServiceExtensions.cs +++ b/src/framework/Modules/Common/Modules.Common.Core/Caching/CacheServiceExtensions.cs @@ -1,10 +1,10 @@ -namespace FSH.Framework.Core.Caching; +namespace FSH.Modules.Common.Core.Caching; public static class CacheServiceExtensions { public static T? GetOrSet(this ICacheService cache, string key, Func getItemCallback, TimeSpan? slidingExpiration = null) { - T? value = cache.Get(key); + T? value = cache.GetItem(key); if (value is not null) { @@ -15,7 +15,7 @@ public static class CacheServiceExtensions if (value is not null) { - cache.Set(key, value, slidingExpiration); + cache.SetItem(key, value, slidingExpiration); } return value; @@ -23,7 +23,7 @@ public static class CacheServiceExtensions public static async Task GetOrSetAsync(this ICacheService cache, string key, Func> task, TimeSpan? slidingExpiration = null, CancellationToken cancellationToken = default) { - T? value = await cache.GetAsync(key, cancellationToken); + T? value = await cache.GetItemAsync(key, cancellationToken); if (value is not null) { @@ -34,9 +34,9 @@ public static class CacheServiceExtensions if (value is not null) { - await cache.SetAsync(key, value, slidingExpiration, cancellationToken); + await cache.SetItemAsync(key, value, slidingExpiration, cancellationToken); } return value; } -} +} \ No newline at end of file diff --git a/src/framework/Modules/Common/Modules.Common.Core/Caching/ICacheService.cs b/src/framework/Modules/Common/Modules.Common.Core/Caching/ICacheService.cs new file mode 100644 index 0000000000..b0c95755b5 --- /dev/null +++ b/src/framework/Modules/Common/Modules.Common.Core/Caching/ICacheService.cs @@ -0,0 +1,16 @@ +namespace FSH.Modules.Common.Core.Caching; + +public interface ICacheService +{ + T? GetItem(string key); + Task GetItemAsync(string key, CancellationToken token = default); + + void RefreshItem(string key); + Task RefreshItemAsync(string key, CancellationToken token = default); + + void RemoveItem(string key); + Task RemoveItemAsync(string key, CancellationToken token = default); + + void SetItem(string key, T value, TimeSpan? slidingExpiration = null); + Task SetItemAsync(string key, T value, TimeSpan? slidingExpiration = null, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/api/framework/Core/Domain/AuditableEntity.cs b/src/framework/Modules/Common/Modules.Common.Core/Domain/AuditableEntity.cs similarity index 90% rename from src/api/framework/Core/Domain/AuditableEntity.cs rename to src/framework/Modules/Common/Modules.Common.Core/Domain/AuditableEntity.cs index 6639a02156..4e1146e42b 100644 --- a/src/api/framework/Core/Domain/AuditableEntity.cs +++ b/src/framework/Modules/Common/Modules.Common.Core/Domain/AuditableEntity.cs @@ -1,4 +1,4 @@ -using FSH.Framework.Core.Domain.Contracts; +using FSH.Modules.Common.Core.Domain.Contracts; namespace FSH.Framework.Core.Domain; @@ -15,4 +15,4 @@ public class AuditableEntity : BaseEntity, IAuditable, ISoftDeletable public abstract class AuditableEntity : AuditableEntity { protected AuditableEntity() => Id = Guid.NewGuid(); -} +} \ No newline at end of file diff --git a/src/api/framework/Core/Domain/BaseEntity.cs b/src/framework/Modules/Common/Modules.Common.Core/Domain/BaseEntity.cs similarity index 60% rename from src/api/framework/Core/Domain/BaseEntity.cs rename to src/framework/Modules/Common/Modules.Common.Core/Domain/BaseEntity.cs index 1c2e98daaf..5c96547a40 100644 --- a/src/api/framework/Core/Domain/BaseEntity.cs +++ b/src/framework/Modules/Common/Modules.Common.Core/Domain/BaseEntity.cs @@ -1,7 +1,7 @@ -using System.Collections.ObjectModel; +using FSH.Framework.Core.Messaging.Events; +using FSH.Modules.Common.Core.Domain.Contracts; +using System.Collections.ObjectModel; using System.ComponentModel.DataAnnotations.Schema; -using FSH.Framework.Core.Domain.Contracts; -using FSH.Framework.Core.Domain.Events; namespace FSH.Framework.Core.Domain; @@ -9,8 +9,8 @@ public abstract class BaseEntity : IEntity { public TId Id { get; protected init; } = default!; [NotMapped] - public Collection DomainEvents { get; } = new Collection(); - public void QueueDomainEvent(DomainEvent @event) + public Collection DomainEvents { get; } = new Collection(); + public void QueueDomainEvent(AppEvent @event) { if (!DomainEvents.Contains(@event)) DomainEvents.Add(@event); @@ -20,4 +20,4 @@ public void QueueDomainEvent(DomainEvent @event) public abstract class BaseEntity : BaseEntity { protected BaseEntity() => Id = Guid.NewGuid(); -} +} \ No newline at end of file diff --git a/src/api/framework/Core/Domain/Contracts/IAggregateRoot.cs b/src/framework/Modules/Common/Modules.Common.Core/Domain/Contracts/IAggregateRoot.cs similarity index 76% rename from src/api/framework/Core/Domain/Contracts/IAggregateRoot.cs rename to src/framework/Modules/Common/Modules.Common.Core/Domain/Contracts/IAggregateRoot.cs index cc98c00dba..9d9d7a5f64 100644 --- a/src/api/framework/Core/Domain/Contracts/IAggregateRoot.cs +++ b/src/framework/Modules/Common/Modules.Common.Core/Domain/Contracts/IAggregateRoot.cs @@ -1,7 +1,7 @@ -namespace FSH.Framework.Core.Domain.Contracts; +namespace FSH.Modules.Common.Core.Domain.Contracts; // Apply this marker interface only to aggregate root entities // Repositories will only work with aggregate roots, not their children public interface IAggregateRoot : IEntity { -} +} \ No newline at end of file diff --git a/src/api/framework/Core/Domain/Contracts/IAuditable.cs b/src/framework/Modules/Common/Modules.Common.Core/Domain/Contracts/IAuditable.cs similarity index 75% rename from src/api/framework/Core/Domain/Contracts/IAuditable.cs rename to src/framework/Modules/Common/Modules.Common.Core/Domain/Contracts/IAuditable.cs index edfa8ab9f3..8e6590cb82 100644 --- a/src/api/framework/Core/Domain/Contracts/IAuditable.cs +++ b/src/framework/Modules/Common/Modules.Common.Core/Domain/Contracts/IAuditable.cs @@ -1,4 +1,4 @@ -namespace FSH.Framework.Core.Domain.Contracts; +namespace FSH.Modules.Common.Core.Domain.Contracts; public interface IAuditable { @@ -6,4 +6,4 @@ public interface IAuditable Guid CreatedBy { get; } DateTimeOffset LastModified { get; } Guid? LastModifiedBy { get; } -} +} \ No newline at end of file diff --git a/src/framework/Modules/Common/Modules.Common.Core/Domain/Contracts/IEntity.cs b/src/framework/Modules/Common/Modules.Common.Core/Domain/Contracts/IEntity.cs new file mode 100644 index 0000000000..f676924e9c --- /dev/null +++ b/src/framework/Modules/Common/Modules.Common.Core/Domain/Contracts/IEntity.cs @@ -0,0 +1,14 @@ +using FSH.Framework.Core.Messaging.Events; +using System.Collections.ObjectModel; + +namespace FSH.Modules.Common.Core.Domain.Contracts; + +public interface IEntity +{ + Collection DomainEvents { get; } +} + +public interface IEntity : IEntity +{ + TId Id { get; } +} \ No newline at end of file diff --git a/src/api/framework/Core/Domain/Contracts/ISoftDeletable.cs b/src/framework/Modules/Common/Modules.Common.Core/Domain/Contracts/ISoftDeletable.cs similarity index 66% rename from src/api/framework/Core/Domain/Contracts/ISoftDeletable.cs rename to src/framework/Modules/Common/Modules.Common.Core/Domain/Contracts/ISoftDeletable.cs index d129d02e4a..3c4b210c0a 100644 --- a/src/api/framework/Core/Domain/Contracts/ISoftDeletable.cs +++ b/src/framework/Modules/Common/Modules.Common.Core/Domain/Contracts/ISoftDeletable.cs @@ -1,7 +1,7 @@ -namespace FSH.Framework.Core.Domain.Contracts; +namespace FSH.Modules.Common.Core.Domain.Contracts; public interface ISoftDeletable { DateTimeOffset? Deleted { get; set; } Guid? DeletedBy { get; set; } -} +} \ No newline at end of file diff --git a/src/framework/Modules/Common/Modules.Common.Core/Exceptions/CustomException.cs b/src/framework/Modules/Common/Modules.Common.Core/Exceptions/CustomException.cs new file mode 100644 index 0000000000..3ed5a62ef3 --- /dev/null +++ b/src/framework/Modules/Common/Modules.Common.Core/Exceptions/CustomException.cs @@ -0,0 +1,41 @@ +using System.Net; + +namespace FSH.Modules.Common.Core.Exceptions; + +/// +/// FullStackHero exception used for consistent error handling across the stack. +/// Includes HTTP status codes and optional detailed error messages. +/// +public class CustomException : Exception +{ + /// + /// A list of error messages (e.g., validation errors, business rules). + /// + public IReadOnlyList ErrorMessages { get; } + + /// + /// The HTTP status code associated with this exception. + /// + public HttpStatusCode StatusCode { get; } + + public CustomException( + string message, + IEnumerable? errors = null, + HttpStatusCode statusCode = HttpStatusCode.InternalServerError) + : base(message) + { + ErrorMessages = errors?.ToList() ?? new List(); + StatusCode = statusCode; + } + + public CustomException( + string message, + Exception innerException, + IEnumerable? errors = null, + HttpStatusCode statusCode = HttpStatusCode.InternalServerError) + : base(message, innerException) + { + ErrorMessages = errors?.ToList() ?? new List(); + StatusCode = statusCode; + } +} \ No newline at end of file diff --git a/src/framework/Modules/Common/Modules.Common.Core/Exceptions/ForbiddenException.cs b/src/framework/Modules/Common/Modules.Common.Core/Exceptions/ForbiddenException.cs new file mode 100644 index 0000000000..bb47fd4dc4 --- /dev/null +++ b/src/framework/Modules/Common/Modules.Common.Core/Exceptions/ForbiddenException.cs @@ -0,0 +1,25 @@ +using FSH.Modules.Common.Core.Exceptions; +using System.Net; + +namespace FSH.Framework.Core.Exceptions; + +/// +/// Exception representing a 403 Forbidden error. +/// +public class ForbiddenException : CustomException +{ + public ForbiddenException() + : base("Unauthorized access.", Array.Empty(), HttpStatusCode.Forbidden) + { + } + + public ForbiddenException(string message) + : base(message, Array.Empty(), HttpStatusCode.Forbidden) + { + } + + public ForbiddenException(string message, IEnumerable errors) + : base(message, errors.ToList(), HttpStatusCode.Forbidden) + { + } +} \ No newline at end of file diff --git a/src/framework/Modules/Common/Modules.Common.Core/Exceptions/NotFoundException.cs b/src/framework/Modules/Common/Modules.Common.Core/Exceptions/NotFoundException.cs new file mode 100644 index 0000000000..3066beabaf --- /dev/null +++ b/src/framework/Modules/Common/Modules.Common.Core/Exceptions/NotFoundException.cs @@ -0,0 +1,20 @@ +using FSH.Modules.Common.Core.Exceptions; +using System.Net; + +namespace FSH.Framework.Core.Exceptions; + +/// +/// Exception representing a 404 Not Found error. +/// +public class NotFoundException : CustomException +{ + public NotFoundException(string message) + : base(message, Array.Empty(), HttpStatusCode.NotFound) + { + } + + public NotFoundException(string message, IEnumerable errors) + : base(message, errors.ToList(), HttpStatusCode.NotFound) + { + } +} \ No newline at end of file diff --git a/src/framework/Modules/Common/Modules.Common.Core/Exceptions/UnauthorizedException.cs b/src/framework/Modules/Common/Modules.Common.Core/Exceptions/UnauthorizedException.cs new file mode 100644 index 0000000000..50b04e36ef --- /dev/null +++ b/src/framework/Modules/Common/Modules.Common.Core/Exceptions/UnauthorizedException.cs @@ -0,0 +1,25 @@ +using FSH.Modules.Common.Core.Exceptions; +using System.Net; + +namespace FSH.Framework.Core.Exceptions; + +/// +/// Exception representing a 401 Unauthorized error (authentication failure). +/// +public class UnauthorizedException : CustomException +{ + public UnauthorizedException() + : base("Authentication failed.", Array.Empty(), HttpStatusCode.Unauthorized) + { + } + + public UnauthorizedException(string message) + : base(message, Array.Empty(), HttpStatusCode.Unauthorized) + { + } + + public UnauthorizedException(string message, IEnumerable errors) + : base(message, errors.ToList(), HttpStatusCode.Unauthorized) + { + } +} \ No newline at end of file diff --git a/src/api/framework/Core/Identity/Users/Abstractions/ICurrentUser.cs b/src/framework/Modules/Common/Modules.Common.Core/ExecutionContext/ICurrentUser.cs similarity index 82% rename from src/api/framework/Core/Identity/Users/Abstractions/ICurrentUser.cs rename to src/framework/Modules/Common/Modules.Common.Core/ExecutionContext/ICurrentUser.cs index aa5314b007..363df8f40d 100644 --- a/src/api/framework/Core/Identity/Users/Abstractions/ICurrentUser.cs +++ b/src/framework/Modules/Common/Modules.Common.Core/ExecutionContext/ICurrentUser.cs @@ -1,6 +1,6 @@ using System.Security.Claims; -namespace FSH.Framework.Core.Identity.Users.Abstractions; +namespace FSH.Framework.Core.ExecutionContext; public interface ICurrentUser { string? Name { get; } @@ -16,4 +16,4 @@ public interface ICurrentUser bool IsInRole(string role); IEnumerable? GetUserClaims(); -} +} \ No newline at end of file diff --git a/src/api/framework/Core/Identity/Users/Abstractions/ICurrentUserInitializer.cs b/src/framework/Modules/Common/Modules.Common.Core/ExecutionContext/ICurrentUserInitializer.cs similarity index 73% rename from src/api/framework/Core/Identity/Users/Abstractions/ICurrentUserInitializer.cs rename to src/framework/Modules/Common/Modules.Common.Core/ExecutionContext/ICurrentUserInitializer.cs index 2342d75b8d..93bf622e69 100644 --- a/src/api/framework/Core/Identity/Users/Abstractions/ICurrentUserInitializer.cs +++ b/src/framework/Modules/Common/Modules.Common.Core/ExecutionContext/ICurrentUserInitializer.cs @@ -1,9 +1,9 @@ using System.Security.Claims; -namespace FSH.Framework.Core.Identity.Users.Abstractions; +namespace FSH.Framework.Core.ExecutionContext; public interface ICurrentUserInitializer { void SetCurrentUser(ClaimsPrincipal user); void SetCurrentUserId(string userId); -} +} \ No newline at end of file diff --git a/src/api/framework/Core/FshCore.cs b/src/framework/Modules/Common/Modules.Common.Core/FshCore.cs similarity index 98% rename from src/api/framework/Core/FshCore.cs rename to src/framework/Modules/Common/Modules.Common.Core/FshCore.cs index 1891dc8d21..df778e0a8f 100644 --- a/src/api/framework/Core/FshCore.cs +++ b/src/framework/Modules/Common/Modules.Common.Core/FshCore.cs @@ -2,4 +2,4 @@ public static class FshCore { public static string Name { get; set; } = "FshCore"; -} +} \ No newline at end of file diff --git a/src/framework/Modules/Common/Modules.Common.Core/Helpers/JsonHelpers.cs b/src/framework/Modules/Common/Modules.Common.Core/Helpers/JsonHelpers.cs new file mode 100644 index 0000000000..4b99f4a6c7 --- /dev/null +++ b/src/framework/Modules/Common/Modules.Common.Core/Helpers/JsonHelpers.cs @@ -0,0 +1,13 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace FSH.Framework.Core.Helpers; +public static class JsonHelpers +{ + public static readonly JsonSerializerOptions DefaultJsonOptions = new(JsonSerializerDefaults.Web) + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; +} \ No newline at end of file diff --git a/src/api/framework/Core/Jobs/IJobService.cs b/src/framework/Modules/Common/Modules.Common.Core/Jobs/IJobService.cs similarity index 99% rename from src/api/framework/Core/Jobs/IJobService.cs rename to src/framework/Modules/Common/Modules.Common.Core/Jobs/IJobService.cs index 7016ae79d5..b7b7d15f0c 100644 --- a/src/api/framework/Core/Jobs/IJobService.cs +++ b/src/framework/Modules/Common/Modules.Common.Core/Jobs/IJobService.cs @@ -37,4 +37,4 @@ public interface IJobService string Schedule(Expression> methodCall, DateTimeOffset enqueueAt); string Schedule(Expression> methodCall, DateTimeOffset enqueueAt); -} +} \ No newline at end of file diff --git a/src/api/framework/Core/Mail/IMailService.cs b/src/framework/Modules/Common/Modules.Common.Core/Mail/IMailService.cs similarity index 98% rename from src/api/framework/Core/Mail/IMailService.cs rename to src/framework/Modules/Common/Modules.Common.Core/Mail/IMailService.cs index c5e000951b..f8ddc44c85 100644 --- a/src/api/framework/Core/Mail/IMailService.cs +++ b/src/framework/Modules/Common/Modules.Common.Core/Mail/IMailService.cs @@ -2,4 +2,4 @@ public interface IMailService { Task SendAsync(MailRequest request, CancellationToken ct); -} +} \ No newline at end of file diff --git a/src/api/framework/Core/Mail/MailOptions.cs b/src/framework/Modules/Common/Modules.Common.Core/Mail/MailOptions.cs similarity index 99% rename from src/api/framework/Core/Mail/MailOptions.cs rename to src/framework/Modules/Common/Modules.Common.Core/Mail/MailOptions.cs index 4b01169572..84bd4e9f31 100644 --- a/src/api/framework/Core/Mail/MailOptions.cs +++ b/src/framework/Modules/Common/Modules.Common.Core/Mail/MailOptions.cs @@ -12,4 +12,4 @@ public class MailOptions public string? Password { get; set; } public string? DisplayName { get; set; } -} +} \ No newline at end of file diff --git a/src/api/framework/Core/Mail/MailRequest.cs b/src/framework/Modules/Common/Modules.Common.Core/Mail/MailRequest.cs similarity index 99% rename from src/api/framework/Core/Mail/MailRequest.cs rename to src/framework/Modules/Common/Modules.Common.Core/Mail/MailRequest.cs index 662dcd4010..e01723e63a 100644 --- a/src/api/framework/Core/Mail/MailRequest.cs +++ b/src/framework/Modules/Common/Modules.Common.Core/Mail/MailRequest.cs @@ -24,4 +24,4 @@ public class MailRequest(Collection to, string subject, string? body = n public IDictionary AttachmentData { get; } = attachmentData ?? new Dictionary(); public IDictionary Headers { get; } = headers ?? new Dictionary(); -} +} \ No newline at end of file diff --git a/src/framework/Modules/Common/Modules.Common.Core/Messaging/CQRS/ICommand.cs b/src/framework/Modules/Common/Modules.Common.Core/Messaging/CQRS/ICommand.cs new file mode 100644 index 0000000000..07f340c5be --- /dev/null +++ b/src/framework/Modules/Common/Modules.Common.Core/Messaging/CQRS/ICommand.cs @@ -0,0 +1,4 @@ +namespace FSH.Modules.Common.Core.Messaging.CQRS; + +// Marker for command requests (intended to modify system state) +public interface ICommand : IRequest { } \ No newline at end of file diff --git a/src/framework/Modules/Common/Modules.Common.Core/Messaging/CQRS/ICommandDispatcher.cs b/src/framework/Modules/Common/Modules.Common.Core/Messaging/CQRS/ICommandDispatcher.cs new file mode 100644 index 0000000000..7b30f63078 --- /dev/null +++ b/src/framework/Modules/Common/Modules.Common.Core/Messaging/CQRS/ICommandDispatcher.cs @@ -0,0 +1,10 @@ +using FSH.Modules.Common.Core.Messaging.CQRS; + +namespace FSH.Framework.Core.Messaging.CQRS; +public interface ICommandDispatcher +{ + /// + /// Sends a command to its handler. + /// + Task SendAsync(ICommand command, CancellationToken ct = default); +} \ No newline at end of file diff --git a/src/framework/Modules/Common/Modules.Common.Core/Messaging/CQRS/ICommandHandler.cs b/src/framework/Modules/Common/Modules.Common.Core/Messaging/CQRS/ICommandHandler.cs new file mode 100644 index 0000000000..325cbf2405 --- /dev/null +++ b/src/framework/Modules/Common/Modules.Common.Core/Messaging/CQRS/ICommandHandler.cs @@ -0,0 +1,12 @@ +namespace FSH.Modules.Common.Core.Messaging.CQRS; + +/// +/// Handles a command and returns a result. +/// +/// Type of command +/// Type of response +public interface ICommandHandler + where TCommand : ICommand +{ + Task HandleAsync(TCommand command, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/framework/Modules/Common/Modules.Common.Core/Messaging/CQRS/IQuery.cs b/src/framework/Modules/Common/Modules.Common.Core/Messaging/CQRS/IQuery.cs new file mode 100644 index 0000000000..c1947494fd --- /dev/null +++ b/src/framework/Modules/Common/Modules.Common.Core/Messaging/CQRS/IQuery.cs @@ -0,0 +1,6 @@ +using FSH.Modules.Common.Core.Messaging.CQRS; + +namespace FSH.Framework.Core.Messaging.CQRS; + +// Marker for query requests (intended to return data without modifying state) +public interface IQuery : IRequest { } \ No newline at end of file diff --git a/src/framework/Modules/Common/Modules.Common.Core/Messaging/CQRS/IQueryDispatcher.cs b/src/framework/Modules/Common/Modules.Common.Core/Messaging/CQRS/IQueryDispatcher.cs new file mode 100644 index 0000000000..1e10aedb09 --- /dev/null +++ b/src/framework/Modules/Common/Modules.Common.Core/Messaging/CQRS/IQueryDispatcher.cs @@ -0,0 +1,5 @@ +namespace FSH.Framework.Core.Messaging.CQRS; +public interface IQueryDispatcher +{ + Task SendAsync(IQuery query, CancellationToken ct = default); +} \ No newline at end of file diff --git a/src/framework/Modules/Common/Modules.Common.Core/Messaging/CQRS/IQueryHandler.cs b/src/framework/Modules/Common/Modules.Common.Core/Messaging/CQRS/IQueryHandler.cs new file mode 100644 index 0000000000..f3ecce9e63 --- /dev/null +++ b/src/framework/Modules/Common/Modules.Common.Core/Messaging/CQRS/IQueryHandler.cs @@ -0,0 +1,12 @@ +namespace FSH.Framework.Core.Messaging.CQRS; + +/// +/// Handles a query and returns a result. +/// +/// Type of query +/// Type of response +public interface IQueryHandler + where TQuery : IQuery +{ + Task HandleAsync(TQuery query, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/framework/Modules/Common/Modules.Common.Core/Messaging/CQRS/IRequest.cs b/src/framework/Modules/Common/Modules.Common.Core/Messaging/CQRS/IRequest.cs new file mode 100644 index 0000000000..d4a7e45db3 --- /dev/null +++ b/src/framework/Modules/Common/Modules.Common.Core/Messaging/CQRS/IRequest.cs @@ -0,0 +1,4 @@ +namespace FSH.Modules.Common.Core.Messaging.CQRS; + +// Represents a generic request with a response +public interface IRequest { } \ No newline at end of file diff --git a/src/framework/Modules/Common/Modules.Common.Core/Messaging/Events/AppEvent.cs b/src/framework/Modules/Common/Modules.Common.Core/Messaging/Events/AppEvent.cs new file mode 100644 index 0000000000..c25be7212d --- /dev/null +++ b/src/framework/Modules/Common/Modules.Common.Core/Messaging/Events/AppEvent.cs @@ -0,0 +1,5 @@ +namespace FSH.Framework.Core.Messaging.Events; +public abstract record AppEvent : IEvent +{ + public DateTime RaisedOn { get; protected set; } = DateTime.UtcNow; +} \ No newline at end of file diff --git a/src/framework/Modules/Common/Modules.Common.Core/Messaging/Events/IEvent.cs b/src/framework/Modules/Common/Modules.Common.Core/Messaging/Events/IEvent.cs new file mode 100644 index 0000000000..1f929ce240 --- /dev/null +++ b/src/framework/Modules/Common/Modules.Common.Core/Messaging/Events/IEvent.cs @@ -0,0 +1,4 @@ +namespace FSH.Framework.Core.Messaging.Events; +public interface IEvent +{ +} \ No newline at end of file diff --git a/src/framework/Modules/Common/Modules.Common.Core/Messaging/Events/IEventHandler.cs b/src/framework/Modules/Common/Modules.Common.Core/Messaging/Events/IEventHandler.cs new file mode 100644 index 0000000000..01c2bbe2d1 --- /dev/null +++ b/src/framework/Modules/Common/Modules.Common.Core/Messaging/Events/IEventHandler.cs @@ -0,0 +1,5 @@ +namespace FSH.Framework.Core.Messaging.Events; +public interface IEventHandler where TEvent : IEvent +{ + Task HandleAsync(TEvent appEvent, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/framework/Modules/Common/Modules.Common.Core/Messaging/Events/IEventPublisher.cs b/src/framework/Modules/Common/Modules.Common.Core/Messaging/Events/IEventPublisher.cs new file mode 100644 index 0000000000..a50515af83 --- /dev/null +++ b/src/framework/Modules/Common/Modules.Common.Core/Messaging/Events/IEventPublisher.cs @@ -0,0 +1,5 @@ +namespace FSH.Framework.Core.Messaging.Events; +public interface IEventPublisher +{ + Task PublishAsync(IEvent appEvent, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/framework/Modules/Common/Modules.Common.Core/Messaging/Events/INotification.cs b/src/framework/Modules/Common/Modules.Common.Core/Messaging/Events/INotification.cs new file mode 100644 index 0000000000..eece5f53ed --- /dev/null +++ b/src/framework/Modules/Common/Modules.Common.Core/Messaging/Events/INotification.cs @@ -0,0 +1,4 @@ +namespace FSH.Framework.Core.Messaging.Events; +public interface INotification +{ +} \ No newline at end of file diff --git a/src/framework/Modules/Common/Modules.Common.Core/Messaging/Events/Notification.cs b/src/framework/Modules/Common/Modules.Common.Core/Messaging/Events/Notification.cs new file mode 100644 index 0000000000..88948d1476 --- /dev/null +++ b/src/framework/Modules/Common/Modules.Common.Core/Messaging/Events/Notification.cs @@ -0,0 +1,5 @@ +namespace FSH.Framework.Core.Messaging.Events; +public abstract record Notification : INotification +{ + public DateTime RaisedOn { get; protected set; } = DateTime.UtcNow; +} \ No newline at end of file diff --git a/src/api/framework/Core/Core.csproj b/src/framework/Modules/Common/Modules.Common.Core/Modules.Common.Core.csproj similarity index 50% rename from src/api/framework/Core/Core.csproj rename to src/framework/Modules/Common/Modules.Common.Core/Modules.Common.Core.csproj index 13d0f1dbc8..e713d44c23 100644 --- a/src/api/framework/Core/Core.csproj +++ b/src/framework/Modules/Common/Modules.Common.Core/Modules.Common.Core.csproj @@ -1,19 +1,17 @@  - FSH.Framework.Core - FSH.Framework.Core + FSH.Modules.Common.Core + FSH.Modules.Common.Core + + + + + - - + - - - - - - diff --git a/src/framework/Modules/Common/Modules.Common.Core/Modules/ICoreModule.cs b/src/framework/Modules/Common/Modules.Common.Core/Modules/ICoreModule.cs new file mode 100644 index 0000000000..04a33e9026 --- /dev/null +++ b/src/framework/Modules/Common/Modules.Common.Core/Modules/ICoreModule.cs @@ -0,0 +1,9 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace FSH.Modules.Common.Core.Modules; + +public interface ICoreModule +{ + void AddModule(IServiceCollection services, IConfiguration config); +} \ No newline at end of file diff --git a/src/api/framework/Core/Origin/OriginOptions.cs b/src/framework/Modules/Common/Modules.Common.Core/Origin/OriginOptions.cs similarity index 60% rename from src/api/framework/Core/Origin/OriginOptions.cs rename to src/framework/Modules/Common/Modules.Common.Core/Origin/OriginOptions.cs index 97e1c35423..6be3d4c6a5 100644 --- a/src/api/framework/Core/Origin/OriginOptions.cs +++ b/src/framework/Modules/Common/Modules.Common.Core/Origin/OriginOptions.cs @@ -1,6 +1,6 @@ -namespace FSH.Framework.Core.Origin; +namespace FSH.Modules.Common.Core.Origin; public class OriginOptions { public Uri? OriginUrl { get; set; } -} +} \ No newline at end of file diff --git a/src/api/framework/Core/Paging/BaseFilter.cs b/src/framework/Modules/Common/Modules.Common.Core/Paging/BaseFilter.cs similarity index 99% rename from src/api/framework/Core/Paging/BaseFilter.cs rename to src/framework/Modules/Common/Modules.Common.Core/Paging/BaseFilter.cs index 2bb5b099be..3a80bbf193 100644 --- a/src/api/framework/Core/Paging/BaseFilter.cs +++ b/src/framework/Modules/Common/Modules.Common.Core/Paging/BaseFilter.cs @@ -16,4 +16,4 @@ public class BaseFilter /// Advanced column filtering with logical operators and query operators is supported. /// public Filter? AdvancedFilter { get; set; } -} +} \ No newline at end of file diff --git a/src/framework/Modules/Common/Modules.Common.Core/Paging/Filter.cs b/src/framework/Modules/Common/Modules.Common.Core/Paging/Filter.cs new file mode 100644 index 0000000000..a447e6dfcc --- /dev/null +++ b/src/framework/Modules/Common/Modules.Common.Core/Paging/Filter.cs @@ -0,0 +1,17 @@ +namespace FSH.Framework.Core.Paging; + +/// +/// Represents a filter expression used for dynamic querying. +/// +public class Filter +{ + public FilterLogic? Logic { get; set; } + + public IEnumerable? Filters { get; set; } + + public string? Field { get; set; } + + public FilterOperator? Operator { get; set; } + + public object? Value { get; set; } +} \ No newline at end of file diff --git a/src/framework/Modules/Common/Modules.Common.Core/Paging/FilterLogic.cs b/src/framework/Modules/Common/Modules.Common.Core/Paging/FilterLogic.cs new file mode 100644 index 0000000000..3009824c0d --- /dev/null +++ b/src/framework/Modules/Common/Modules.Common.Core/Paging/FilterLogic.cs @@ -0,0 +1,8 @@ +namespace FSH.Framework.Core.Paging; + +public enum FilterLogic +{ + And, + Or, + Xor +} \ No newline at end of file diff --git a/src/framework/Modules/Common/Modules.Common.Core/Paging/FilterOperator.cs b/src/framework/Modules/Common/Modules.Common.Core/Paging/FilterOperator.cs new file mode 100644 index 0000000000..905af3b77a --- /dev/null +++ b/src/framework/Modules/Common/Modules.Common.Core/Paging/FilterOperator.cs @@ -0,0 +1,14 @@ +namespace FSH.Framework.Core.Paging; + +public enum FilterOperator +{ + Eq, + Neq, + Lt, + Lte, + Gt, + Gte, + StartsWith, + EndsWith, + Contains +} \ No newline at end of file diff --git a/src/framework/Modules/Common/Modules.Common.Core/Paging/IPageRequest.cs b/src/framework/Modules/Common/Modules.Common.Core/Paging/IPageRequest.cs new file mode 100644 index 0000000000..aaed1b2d9c --- /dev/null +++ b/src/framework/Modules/Common/Modules.Common.Core/Paging/IPageRequest.cs @@ -0,0 +1,19 @@ +namespace FSH.Framework.Core.Paging; + +/// +/// Represents a paginated query request with optional filtering and sorting. +/// +public interface IPageRequest +{ + /// Page number (1-based). + int PageNumber { get; set; } + + /// Number of items per page. + int PageSize { get; set; } + + /// Optional filter expression (raw or JSON). + string? Filters { get; set; } + + /// Optional sort order (e.g., "name asc", "created desc"). + string? SortOrder { get; set; } +} \ No newline at end of file diff --git a/src/framework/Modules/Common/Modules.Common.Core/Paging/IPagedList.cs b/src/framework/Modules/Common/Modules.Common.Core/Paging/IPagedList.cs new file mode 100644 index 0000000000..316ac4c6e4 --- /dev/null +++ b/src/framework/Modules/Common/Modules.Common.Core/Paging/IPagedList.cs @@ -0,0 +1,12 @@ +namespace FSH.Framework.Core.Paging; + +public interface IPagedList +{ + IReadOnlyList Items { get; } + int PageNumber { get; } + int PageSize { get; } + int TotalCount { get; } + int TotalPages { get; } + bool HasPrevious { get; } + bool HasNext { get; } +} \ No newline at end of file diff --git a/src/framework/Modules/Common/Modules.Common.Core/Paging/PagedList.cs b/src/framework/Modules/Common/Modules.Common.Core/Paging/PagedList.cs new file mode 100644 index 0000000000..639a9167eb --- /dev/null +++ b/src/framework/Modules/Common/Modules.Common.Core/Paging/PagedList.cs @@ -0,0 +1,22 @@ +namespace FSH.Framework.Core.Paging; + +public record PagedList( + IReadOnlyList Items, + int PageNumber, + int PageSize, + int TotalCount +) : IPagedList where T : class +{ + public int TotalPages => (int)Math.Ceiling(TotalCount / (double)PageSize); + + public bool HasPrevious => PageNumber > 1; + + public bool HasNext => PageNumber < TotalPages; + + /// + /// Maps the paged list to another paged list using a custom projection. + /// + public PagedList MapTo(Func mapper) + where TR : class => + new(Items.Select(mapper).ToList(), PageNumber, PageSize, TotalCount); +} \ No newline at end of file diff --git a/src/framework/Modules/Common/Modules.Common.Core/Paging/PaginationFilter.cs b/src/framework/Modules/Common/Modules.Common.Core/Paging/PaginationFilter.cs new file mode 100644 index 0000000000..d0532bf195 --- /dev/null +++ b/src/framework/Modules/Common/Modules.Common.Core/Paging/PaginationFilter.cs @@ -0,0 +1,11 @@ +namespace FSH.Framework.Core.Paging; + +public class PaginationFilter : BaseFilter, IPageRequest +{ + public int PageNumber { get; set; } = 1; + public int PageSize { get; set; } = 10; + public string[] OrderBy { get; set; } + + public string? Filters { get; set; } + public string? SortOrder { get; set; } +} \ No newline at end of file diff --git a/src/framework/Modules/Common/Modules.Common.Core/Paging/PaginationFilterExtensions.cs b/src/framework/Modules/Common/Modules.Common.Core/Paging/PaginationFilterExtensions.cs new file mode 100644 index 0000000000..8819b005f5 --- /dev/null +++ b/src/framework/Modules/Common/Modules.Common.Core/Paging/PaginationFilterExtensions.cs @@ -0,0 +1,10 @@ +namespace FSH.Framework.Core.Paging; + +public static class PaginationFilterExtensions +{ + /// + /// Returns true if the PaginationFilter contains any OrderBy fields. + /// + public static bool HasOrderBy(this PaginationFilter? filter) => + filter?.OrderBy?.Any() == true; +} \ No newline at end of file diff --git a/src/framework/Modules/Common/Modules.Common.Core/Paging/Search.cs b/src/framework/Modules/Common/Modules.Common.Core/Paging/Search.cs new file mode 100644 index 0000000000..0cb1a264ff --- /dev/null +++ b/src/framework/Modules/Common/Modules.Common.Core/Paging/Search.cs @@ -0,0 +1,23 @@ +namespace FSH.Framework.Core.Paging; + +/// +/// Represents a search filter with target fields and a keyword. +/// +public class Search +{ + /// + /// The list of field names to apply the search keyword against. + /// + public IReadOnlyList Fields { get; set; } = new List(); + + /// + /// The keyword to search for across the specified fields. + /// + public string? Keyword { get; set; } + + /// + /// Returns true if both fields and keyword are provided. + /// + public bool IsValid => + Fields?.Count > 0 && !string.IsNullOrWhiteSpace(Keyword); +} \ No newline at end of file diff --git a/src/api/framework/Core/Persistence/DatabaseOptions.cs b/src/framework/Modules/Common/Modules.Common.Core/Persistence/DatabaseOptions.cs similarity index 89% rename from src/api/framework/Core/Persistence/DatabaseOptions.cs rename to src/framework/Modules/Common/Modules.Common.Core/Persistence/DatabaseOptions.cs index 5be4fb9e02..0b6f126101 100644 --- a/src/api/framework/Core/Persistence/DatabaseOptions.cs +++ b/src/framework/Modules/Common/Modules.Common.Core/Persistence/DatabaseOptions.cs @@ -5,6 +5,7 @@ public class DatabaseOptions : IValidatableObject { public string Provider { get; set; } = "postgresql"; public string ConnectionString { get; set; } = string.Empty; + public string MigrationsAssembly { get; set; } = string.Empty; public IEnumerable Validate(ValidationContext validationContext) { @@ -13,4 +14,4 @@ public IEnumerable Validate(ValidationContext validationContex yield return new ValidationResult("connection string cannot be empty.", new[] { nameof(ConnectionString) }); } } -} +} \ No newline at end of file diff --git a/src/api/framework/Core/Persistence/IConnectionStringValidator.cs b/src/framework/Modules/Common/Modules.Common.Core/Persistence/IConnectionStringValidator.cs similarity index 98% rename from src/api/framework/Core/Persistence/IConnectionStringValidator.cs rename to src/framework/Modules/Common/Modules.Common.Core/Persistence/IConnectionStringValidator.cs index 413e4fc76c..69681ac5f0 100644 --- a/src/api/framework/Core/Persistence/IConnectionStringValidator.cs +++ b/src/framework/Modules/Common/Modules.Common.Core/Persistence/IConnectionStringValidator.cs @@ -2,4 +2,4 @@ public interface IConnectionStringValidator { bool TryValidate(string connectionString, string? dbProvider = null); -} +} \ No newline at end of file diff --git a/src/api/framework/Core/Persistence/IDbInitializer.cs b/src/framework/Modules/Common/Modules.Common.Core/Persistence/IDbInitializer.cs similarity index 98% rename from src/api/framework/Core/Persistence/IDbInitializer.cs rename to src/framework/Modules/Common/Modules.Common.Core/Persistence/IDbInitializer.cs index ed4a08c844..509895698c 100644 --- a/src/api/framework/Core/Persistence/IDbInitializer.cs +++ b/src/framework/Modules/Common/Modules.Common.Core/Persistence/IDbInitializer.cs @@ -3,4 +3,4 @@ public interface IDbInitializer { Task MigrateAsync(CancellationToken cancellationToken); Task SeedAsync(CancellationToken cancellationToken); -} +} \ No newline at end of file diff --git a/src/api/framework/Core/Persistence/IRepository.cs b/src/framework/Modules/Common/Modules.Common.Core/Persistence/IRepository.cs similarity index 84% rename from src/api/framework/Core/Persistence/IRepository.cs rename to src/framework/Modules/Common/Modules.Common.Core/Persistence/IRepository.cs index 3915bb3caa..46e2bd8f99 100644 --- a/src/api/framework/Core/Persistence/IRepository.cs +++ b/src/framework/Modules/Common/Modules.Common.Core/Persistence/IRepository.cs @@ -1,5 +1,5 @@ using Ardalis.Specification; -using FSH.Framework.Core.Domain.Contracts; +using FSH.Modules.Common.Core.Domain.Contracts; namespace FSH.Framework.Core.Persistence; public interface IRepository : IRepositoryBase @@ -10,4 +10,4 @@ public interface IRepository : IRepositoryBase public interface IReadRepository : IReadRepositoryBase where T : class, IAggregateRoot { -} +} \ No newline at end of file diff --git a/src/api/framework/Core/Specifications/EntitiesByBaseFilterSpec.cs b/src/framework/Modules/Common/Modules.Common.Core/Specifications/EntitiesByBaseFilterSpec.cs similarity index 77% rename from src/api/framework/Core/Specifications/EntitiesByBaseFilterSpec.cs rename to src/framework/Modules/Common/Modules.Common.Core/Specifications/EntitiesByBaseFilterSpec.cs index 643bcb675a..0ee0231b25 100644 --- a/src/api/framework/Core/Specifications/EntitiesByBaseFilterSpec.cs +++ b/src/framework/Modules/Common/Modules.Common.Core/Specifications/EntitiesByBaseFilterSpec.cs @@ -3,14 +3,14 @@ namespace FSH.Framework.Core.Specifications; -public class EntitiesByBaseFilterSpec : Specification +public class EntitiesByBaseFilterSpec : Specification where T : class { public EntitiesByBaseFilterSpec(BaseFilter filter) => Query.SearchBy(filter); } -public class EntitiesByBaseFilterSpec : Specification +public class EntitiesByBaseFilterSpec : Specification where T : class { public EntitiesByBaseFilterSpec(BaseFilter filter) => Query.SearchBy(filter); -} +} \ No newline at end of file diff --git a/src/api/framework/Core/Specifications/EntitiesByPaginationFilterSpec.cs b/src/framework/Modules/Common/Modules.Common.Core/Specifications/EntitiesByPaginationFilterSpec.cs similarity index 85% rename from src/api/framework/Core/Specifications/EntitiesByPaginationFilterSpec.cs rename to src/framework/Modules/Common/Modules.Common.Core/Specifications/EntitiesByPaginationFilterSpec.cs index abdf49eeb7..bdbb610911 100644 --- a/src/api/framework/Core/Specifications/EntitiesByPaginationFilterSpec.cs +++ b/src/framework/Modules/Common/Modules.Common.Core/Specifications/EntitiesByPaginationFilterSpec.cs @@ -2,16 +2,16 @@ namespace FSH.Framework.Core.Specifications; -public class EntitiesByPaginationFilterSpec : EntitiesByBaseFilterSpec +public class EntitiesByPaginationFilterSpec : EntitiesByBaseFilterSpec where T : class { public EntitiesByPaginationFilterSpec(PaginationFilter filter) : base(filter) => Query.PaginateBy(filter); } -public class EntitiesByPaginationFilterSpec : EntitiesByBaseFilterSpec +public class EntitiesByPaginationFilterSpec : EntitiesByBaseFilterSpec where T : class { public EntitiesByPaginationFilterSpec(PaginationFilter filter) : base(filter) => Query.PaginateBy(filter); -} +} \ No newline at end of file diff --git a/src/api/framework/Core/Specifications/SpecificationBuilderExtensions.cs b/src/framework/Modules/Common/Modules.Common.Core/Specifications/SpecificationBuilderExtensions.cs similarity index 70% rename from src/api/framework/Core/Specifications/SpecificationBuilderExtensions.cs rename to src/framework/Modules/Common/Modules.Common.Core/Specifications/SpecificationBuilderExtensions.cs index 81b352056d..a810aa5a3d 100644 --- a/src/api/framework/Core/Specifications/SpecificationBuilderExtensions.cs +++ b/src/framework/Modules/Common/Modules.Common.Core/Specifications/SpecificationBuilderExtensions.cs @@ -1,16 +1,15 @@ -using System.Linq.Expressions; +using Ardalis.Specification; +using FSH.Framework.Core.Paging; +using FSH.Modules.Common.Core.Exceptions; +using System.Linq.Expressions; using System.Reflection; using System.Text.Json; -using Ardalis.Specification; -using FSH.Framework.Core.Exceptions; -using FSH.Framework.Core.Paging; namespace FSH.Framework.Core.Specifications; -// See https://github.com/ardalis/Specification/issues/53 public static class SpecificationBuilderExtensions { - public static ISpecificationBuilder SearchBy(this ISpecificationBuilder query, BaseFilter filter) => + public static ISpecificationBuilder SearchBy(this ISpecificationBuilder query, BaseFilter filter) where T : class => query .SearchByKeyword(filter.Keyword) .AdvancedSearch(filter.AdvancedSearch) @@ -38,20 +37,20 @@ public static ISpecificationBuilder PaginateBy(this ISpecificationBuilder< .OrderBy(filter.OrderBy); } - public static IOrderedSpecificationBuilder SearchByKeyword( + public static ISpecificationBuilder SearchByKeyword( this ISpecificationBuilder specificationBuilder, - string? keyword) => + string? keyword) where T : class => specificationBuilder.AdvancedSearch(new Search { Keyword = keyword }); - public static IOrderedSpecificationBuilder AdvancedSearch( + public static ISpecificationBuilder AdvancedSearch( this ISpecificationBuilder specificationBuilder, - Search? search) + Search? search) where T : class { if (!string.IsNullOrEmpty(search?.Keyword)) { if (search.Fields?.Any() is true) { - // search seleted fields (can contain deeper nested fields) + // search selected fields (can contain deeper nested fields) foreach (string field in search.Fields) { var paramExpr = Expression.Parameter(typeof(T)); @@ -76,7 +75,7 @@ public static IOrderedSpecificationBuilder AdvancedSearch( } } - return new OrderedSpecificationBuilder(specificationBuilder.Specification); + return specificationBuilder; } private static void AddSearchPropertyByKeyword( @@ -84,7 +83,7 @@ private static void AddSearchPropertyByKeyword( Expression propertyExpr, ParameterExpression paramExpr, string keyword, - string operatorSearch = FilterOperator.CONTAINS) + FilterOperator operatorSearch = FilterOperator.Contains) where T : class { if (propertyExpr is not MemberExpression memberExpr || memberExpr.Member is not PropertyInfo property) { @@ -93,9 +92,9 @@ private static void AddSearchPropertyByKeyword( string searchTerm = operatorSearch switch { - FilterOperator.STARTSWITH => $"{keyword.ToLower()}%", - FilterOperator.ENDSWITH => $"%{keyword.ToLower()}", - FilterOperator.CONTAINS => $"%{keyword.ToLower()}%", + FilterOperator.StartsWith => $"{keyword.ToLower()}%", + FilterOperator.EndsWith => $"%{keyword.ToLower()}", + FilterOperator.Contains => $"%{keyword.ToLower()}%", _ => throw new ArgumentException("operatorSearch is not valid.", nameof(operatorSearch)) }; @@ -112,13 +111,12 @@ private static void AddSearchPropertyByKeyword( var toLowerMethod = typeof(string).GetMethod("ToLower", Type.EmptyTypes); Expression callToLowerMethod = Expression.Call(selectorExpr, toLowerMethod!); - var selector = Expression.Lambda>(callToLowerMethod, paramExpr); + var selector = Expression.Lambda>(callToLowerMethod, paramExpr); - ((List>)specificationBuilder.Specification.SearchCriterias) - .Add(new SearchExpressionInfo(selector, searchTerm, 1)); + specificationBuilder.Search(selector, searchTerm, 1); } - public static IOrderedSpecificationBuilder AdvancedFilter( + public static ISpecificationBuilder AdvancedFilter( this ISpecificationBuilder specificationBuilder, Filter? filter) { @@ -126,28 +124,28 @@ public static IOrderedSpecificationBuilder AdvancedFilter( { var parameter = Expression.Parameter(typeof(T)); - Expression binaryExpresioFilter; + Expression binaryExpressionFilter; - if (!string.IsNullOrEmpty(filter.Logic)) + if (filter.Logic.HasValue) { if (filter.Filters is null) throw new CustomException("The Filters attribute is required when declaring a logic"); - binaryExpresioFilter = CreateFilterExpression(filter.Logic, filter.Filters, parameter); + binaryExpressionFilter = CreateFilterExpression(filter.Logic, filter.Filters, parameter); } else { var filterValid = GetValidFilter(filter); - binaryExpresioFilter = CreateFilterExpression(filterValid.Field!, filterValid.Operator!, filterValid.Value, parameter); + binaryExpressionFilter = CreateFilterExpression(filterValid.Field!, filterValid.Operator!.Value, filterValid.Value, parameter); } - ((List>)specificationBuilder.Specification.WhereExpressions) - .Add(new WhereExpressionInfo(Expression.Lambda>(binaryExpresioFilter, parameter))); + var expr = Expression.Lambda>(binaryExpressionFilter, parameter); + specificationBuilder.Where(expr); } - return new OrderedSpecificationBuilder(specificationBuilder.Specification); + return specificationBuilder; } private static Expression CreateFilterExpression( - string logic, + FilterLogic? logic, IEnumerable filters, ParameterExpression parameter) { @@ -155,20 +153,20 @@ private static Expression CreateFilterExpression( foreach (var filter in filters) { - Expression bExpresionFilter; + Expression bExpressionFilter; - if (!string.IsNullOrEmpty(filter.Logic)) + if (filter.Logic.HasValue) { if (filter.Filters is null) throw new CustomException("The Filters attribute is required when declaring a logic"); - bExpresionFilter = CreateFilterExpression(filter.Logic, filter.Filters, parameter); + bExpressionFilter = CreateFilterExpression(filter.Logic, filter.Filters, parameter); } else { var filterValid = GetValidFilter(filter); - bExpresionFilter = CreateFilterExpression(filterValid.Field!, filterValid.Operator!, filterValid.Value, parameter); + bExpressionFilter = CreateFilterExpression(filterValid.Field!, filterValid.Operator!.Value, filterValid.Value, parameter); } - filterExpression = filterExpression is null ? bExpresionFilter : CombineFilter(logic, filterExpression, bExpresionFilter); + filterExpression = filterExpression is null ? bExpressionFilter : CombineFilter(logic, filterExpression, bExpressionFilter); } return filterExpression; @@ -176,19 +174,19 @@ private static Expression CreateFilterExpression( private static Expression CreateFilterExpression( string field, - string filterOperator, + FilterOperator filterOperator, object? value, ParameterExpression parameter) { - var propertyExpresion = GetPropertyExpression(field, parameter); - var valueExpresion = GeValuetExpression(field, value, propertyExpresion.Type); - return CreateFilterExpression(propertyExpresion, valueExpresion, filterOperator); + var propertyExpression = GetPropertyExpression(field, parameter); + var valueExpression = GeValueExpression(field, value, propertyExpression.Type); + return CreateFilterExpression(propertyExpression, valueExpression, filterOperator); } private static Expression CreateFilterExpression( Expression memberExpression, Expression constantExpression, - string filterOperator) + FilterOperator filterOperator) { if (memberExpression.Type == typeof(string)) { @@ -198,27 +196,27 @@ private static Expression CreateFilterExpression( return filterOperator switch { - FilterOperator.EQ => Expression.Equal(memberExpression, constantExpression), - FilterOperator.NEQ => Expression.NotEqual(memberExpression, constantExpression), - FilterOperator.LT => Expression.LessThan(memberExpression, constantExpression), - FilterOperator.LTE => Expression.LessThanOrEqual(memberExpression, constantExpression), - FilterOperator.GT => Expression.GreaterThan(memberExpression, constantExpression), - FilterOperator.GTE => Expression.GreaterThanOrEqual(memberExpression, constantExpression), - FilterOperator.CONTAINS => Expression.Call(memberExpression, "Contains", null, constantExpression), - FilterOperator.STARTSWITH => Expression.Call(memberExpression, "StartsWith", null, constantExpression), - FilterOperator.ENDSWITH => Expression.Call(memberExpression, "EndsWith", null, constantExpression), + FilterOperator.Eq => Expression.Equal(memberExpression, constantExpression), + FilterOperator.Neq => Expression.NotEqual(memberExpression, constantExpression), + FilterOperator.Lt => Expression.LessThan(memberExpression, constantExpression), + FilterOperator.Lte => Expression.LessThanOrEqual(memberExpression, constantExpression), + FilterOperator.Gt => Expression.GreaterThan(memberExpression, constantExpression), + FilterOperator.Gte => Expression.GreaterThanOrEqual(memberExpression, constantExpression), + FilterOperator.Contains => Expression.Call(memberExpression, "Contains", null, constantExpression), + FilterOperator.StartsWith => Expression.Call(memberExpression, "StartsWith", null, constantExpression), + FilterOperator.EndsWith => Expression.Call(memberExpression, "EndsWith", null, constantExpression), _ => throw new CustomException("Filter Operator is not valid."), }; } - private static Expression CombineFilter( - string filterOperator, - Expression bExpresionBase, - Expression bExpresion) => filterOperator switch + private static BinaryExpression CombineFilter( + FilterLogic? filterOperator, + Expression bExpressionBase, + Expression bExpression) => filterOperator switch { - FilterLogic.AND => Expression.And(bExpresionBase, bExpresion), - FilterLogic.OR => Expression.Or(bExpresionBase, bExpresion), - FilterLogic.XOR => Expression.ExclusiveOr(bExpresionBase, bExpresion), + FilterLogic.And => Expression.And(bExpressionBase, bExpression), + FilterLogic.Or => Expression.Or(bExpressionBase, bExpression), + FilterLogic.Xor => Expression.ExclusiveOr(bExpressionBase, bExpression), _ => throw new ArgumentException("FilterLogic is not valid."), }; @@ -238,7 +236,7 @@ private static MemberExpression GetPropertyExpression( private static string GetStringFromJsonElement(object value) => ((JsonElement)value).GetString()!; - private static ConstantExpression GeValuetExpression( + private static ConstantExpression GeValueExpression( string field, object? value, Type propertyType) @@ -299,14 +297,16 @@ private static ConstantExpression GeValuetExpression( private static Filter GetValidFilter(Filter filter) { if (string.IsNullOrEmpty(filter.Field)) throw new CustomException("The field attribute is required when declaring a filter"); - if (string.IsNullOrEmpty(filter.Operator)) throw new CustomException("The Operator attribute is required when declaring a filter"); + if (string.IsNullOrEmpty(filter.Operator.Value.ToString())) throw new CustomException("The Operator attribute is required when declaring a filter"); return filter; } - public static IOrderedSpecificationBuilder OrderBy( + public static ISpecificationBuilder OrderBy( this ISpecificationBuilder specificationBuilder, string[]? orderByFields) { + IOrderedSpecificationBuilder orderedBuilder = null!; + if (orderByFields is not null) { foreach (var field in ParseOrderBy(orderByFields)) @@ -323,12 +323,18 @@ public static IOrderedSpecificationBuilder OrderBy( Expression.Convert(propertyExpr, typeof(object)), paramExpr); - ((List>)specificationBuilder.Specification.OrderExpressions) - .Add(new OrderExpressionInfo(keySelector, field.Value)); + orderedBuilder = field.Value switch + { + OrderTypeEnum.OrderBy => specificationBuilder.OrderBy(keySelector), + OrderTypeEnum.OrderByDescending => specificationBuilder.OrderByDescending(keySelector), + OrderTypeEnum.ThenBy => orderedBuilder.ThenBy(keySelector), + OrderTypeEnum.ThenByDescending => orderedBuilder.ThenByDescending(keySelector), + _ => throw new CustomException("OrderTypeEnum is not valid."), + }; } } - return new OrderedSpecificationBuilder(specificationBuilder.Specification); + return specificationBuilder; } private static Dictionary ParseOrderBy(string[] orderByFields) => @@ -345,4 +351,4 @@ private static Dictionary ParseOrderBy(string[] orderByFi return new KeyValuePair(field, orderBy); })); -} +} \ No newline at end of file diff --git a/src/framework/Modules/Common/Modules.Common.Core/Storage/FileType.cs b/src/framework/Modules/Common/Modules.Common.Core/Storage/FileType.cs new file mode 100644 index 0000000000..31d82817d7 --- /dev/null +++ b/src/framework/Modules/Common/Modules.Common.Core/Storage/FileType.cs @@ -0,0 +1,24 @@ +namespace FSH.Framework.Core.Storage; +public enum FileType +{ + Image, + Document, + Pdf +} + +public class FileValidationRules +{ + public IReadOnlyList AllowedExtensions { get; init; } = Array.Empty(); + public int MaxSizeInMB { get; init; } = 5; +} + +public static class FileTypeMetadata +{ + public static FileValidationRules GetRules(FileType type) => + type switch + { + FileType.Image => new() { AllowedExtensions = [".jpg", ".jpeg", ".png"], MaxSizeInMB = 5 }, + FileType.Pdf => new() { AllowedExtensions = [".pdf"], MaxSizeInMB = 10 }, + _ => throw new NotSupportedException($"Unsupported file type: {type}") + }; +} \ No newline at end of file diff --git a/src/framework/Modules/Common/Modules.Common.Core/Storage/FileUploadRequest.cs b/src/framework/Modules/Common/Modules.Common.Core/Storage/FileUploadRequest.cs new file mode 100644 index 0000000000..6b7d529013 --- /dev/null +++ b/src/framework/Modules/Common/Modules.Common.Core/Storage/FileUploadRequest.cs @@ -0,0 +1,7 @@ +namespace FSH.Framework.Core.Storage; +public class FileUploadRequest +{ + public string FileName { get; init; } = default!; + public string ContentType { get; init; } = default!; + public IReadOnlyList Data { get; init; } = Array.Empty(); +} \ No newline at end of file diff --git a/src/framework/Modules/Common/Modules.Common.Core/Storage/IStorageService.cs b/src/framework/Modules/Common/Modules.Common.Core/Storage/IStorageService.cs new file mode 100644 index 0000000000..ce166d4c4d --- /dev/null +++ b/src/framework/Modules/Common/Modules.Common.Core/Storage/IStorageService.cs @@ -0,0 +1,10 @@ +namespace FSH.Framework.Core.Storage; +public interface IStorageService +{ + Task UploadAsync( + FileUploadRequest request, + FileType fileType, + CancellationToken cancellationToken = default) where T : class; + + Task RemoveAsync(string path, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/api/framework/Infrastructure/Caching/DistributedCacheService.cs b/src/framework/Modules/Common/Modules.Common.Infrastructure/Caching/DistributedCacheService.cs similarity index 84% rename from src/api/framework/Infrastructure/Caching/DistributedCacheService.cs rename to src/framework/Modules/Common/Modules.Common.Infrastructure/Caching/DistributedCacheService.cs index f4353d69a5..ce134cda8b 100644 --- a/src/api/framework/Infrastructure/Caching/DistributedCacheService.cs +++ b/src/framework/Modules/Common/Modules.Common.Infrastructure/Caching/DistributedCacheService.cs @@ -1,8 +1,8 @@ -using System.Text; -using System.Text.Json; -using FSH.Framework.Core.Caching; +using FSH.Modules.Common.Core.Caching; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Logging; +using System.Text; +using System.Text.Json; namespace FSH.Framework.Infrastructure.Caching; @@ -16,7 +16,7 @@ public DistributedCacheService(IDistributedCache cache, ILogger(string key) => + public T? GetItem(string key) => Get(key) is { } data ? Deserialize(data) : default; @@ -35,7 +35,7 @@ public DistributedCacheService(IDistributedCache cache, ILogger GetAsync(string key, CancellationToken token = default) => + public async Task GetItemAsync(string key, CancellationToken token = default) => await GetAsync(key, token) is { } data ? Deserialize(data) : default; @@ -53,7 +53,7 @@ await GetAsync(key, token) is { } data } } - public void Refresh(string key) + public void RefreshItem(string key) { try { @@ -65,7 +65,7 @@ public void Refresh(string key) } } - public async Task RefreshAsync(string key, CancellationToken token = default) + public async Task RefreshItemAsync(string key, CancellationToken token = default) { try { @@ -78,7 +78,7 @@ public async Task RefreshAsync(string key, CancellationToken token = default) } } - public void Remove(string key) + public void RemoveItem(string key) { try { @@ -90,7 +90,7 @@ public void Remove(string key) } } - public async Task RemoveAsync(string key, CancellationToken token = default) + public async Task RemoveItemAsync(string key, CancellationToken token = default) { try { @@ -102,7 +102,7 @@ public async Task RemoveAsync(string key, CancellationToken token = default) } } - public void Set(string key, T value, TimeSpan? slidingExpiration = null) => + public void SetItem(string key, T value, TimeSpan? slidingExpiration = null) => Set(key, Serialize(value), slidingExpiration); private void Set(string key, byte[] value, TimeSpan? slidingExpiration = null) @@ -118,7 +118,7 @@ private void Set(string key, byte[] value, TimeSpan? slidingExpiration = null) } } - public Task SetAsync(string key, T value, TimeSpan? slidingExpiration = null, CancellationToken cancellationToken = default) => + public Task SetItemAsync(string key, T value, TimeSpan? slidingExpiration = null, CancellationToken cancellationToken = default) => SetAsync(key, Serialize(value), slidingExpiration, cancellationToken); private async Task SetAsync(string key, byte[] value, TimeSpan? slidingExpiration = null, CancellationToken token = default) @@ -158,4 +158,4 @@ private static DistributedCacheEntryOptions GetOptions(TimeSpan? slidingExpirati options.SetAbsoluteExpiration(TimeSpan.FromMinutes(15)); return options; } -} +} \ No newline at end of file diff --git a/src/api/framework/Infrastructure/Caching/Extensions.cs b/src/framework/Modules/Common/Modules.Common.Infrastructure/Caching/Extensions.cs similarity index 87% rename from src/api/framework/Infrastructure/Caching/Extensions.cs rename to src/framework/Modules/Common/Modules.Common.Infrastructure/Caching/Extensions.cs index c389a52a1b..8c8eb2156e 100644 --- a/src/api/framework/Infrastructure/Caching/Extensions.cs +++ b/src/framework/Modules/Common/Modules.Common.Infrastructure/Caching/Extensions.cs @@ -1,4 +1,4 @@ -using FSH.Framework.Core.Caching; +using FSH.Modules.Common.Core.Caching; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Serilog; @@ -7,7 +7,7 @@ namespace FSH.Framework.Infrastructure.Caching; internal static class Extensions { private static readonly ILogger _logger = Log.ForContext(typeof(Extensions)); - internal static IServiceCollection ConfigureCaching(this IServiceCollection services, IConfiguration configuration) + internal static IServiceCollection AddFshCaching(this IServiceCollection services, IConfiguration configuration) { services.AddTransient(); var cacheOptions = configuration.GetSection(nameof(CacheOptions)).Get(); @@ -31,4 +31,4 @@ internal static IServiceCollection ConfigureCaching(this IServiceCollection serv return services; } -} +} \ No newline at end of file diff --git a/src/api/framework/Infrastructure/Common/Extensions/EnumExtensions.cs b/src/framework/Modules/Common/Modules.Common.Infrastructure/Common/Extensions/EnumExtensions.cs similarity index 56% rename from src/api/framework/Infrastructure/Common/Extensions/EnumExtensions.cs rename to src/framework/Modules/Common/Modules.Common.Infrastructure/Common/Extensions/EnumExtensions.cs index d1f3014aa3..fb8d8bf1b3 100644 --- a/src/api/framework/Infrastructure/Common/Extensions/EnumExtensions.cs +++ b/src/framework/Modules/Common/Modules.Common.Infrastructure/Common/Extensions/EnumExtensions.cs @@ -3,7 +3,7 @@ using System.Text.RegularExpressions; namespace FSH.Framework.Infrastructure.Common.Extensions; -public static class EnumExtensions +public static partial class EnumExtensions { public static string GetDescription(this Enum enumValue) { @@ -12,10 +12,10 @@ public static string GetDescription(this Enum enumValue) if (attr.Length > 0) return ((DescriptionAttribute)attr[0]).Description; string result = enumValue.ToString(); - result = Regex.Replace(result, "([a-z])([A-Z])", "$1 $2"); - result = Regex.Replace(result, "([A-Za-z])([0-9])", "$1 $2"); - result = Regex.Replace(result, "([0-9])([A-Za-z])", "$1 $2"); - result = Regex.Replace(result, "(? GetDescriptionList(this Enum enumValue) string result = enumValue.GetDescription(); return new ReadOnlyCollection(result.Split(',').ToList()); } -} + + [GeneratedRegex("([a-z])([A-Z])")] + private static partial Regex MyRegex(); + [GeneratedRegex("([A-Za-z])([0-9])")] + private static partial Regex MyRegex1(); + [GeneratedRegex("([0-9])([A-Za-z])")] + private static partial Regex MyRegex2(); + [GeneratedRegex("(? AllowedOrigins { get; } -} +} \ No newline at end of file diff --git a/src/api/framework/Infrastructure/Cors/Extensions.cs b/src/framework/Modules/Common/Modules.Common.Infrastructure/Cors/Extensions.cs similarity index 99% rename from src/api/framework/Infrastructure/Cors/Extensions.cs rename to src/framework/Modules/Common/Modules.Common.Infrastructure/Cors/Extensions.cs index d559de2067..0f5e84701a 100644 --- a/src/api/framework/Infrastructure/Cors/Extensions.cs +++ b/src/framework/Modules/Common/Modules.Common.Infrastructure/Cors/Extensions.cs @@ -22,4 +22,4 @@ internal static IApplicationBuilder UseCorsPolicy(this IApplicationBuilder app) { return app.UseCors(CorsPolicy); } -} +} \ No newline at end of file diff --git a/src/api/framework/Infrastructure/Exceptions/CustomExceptionHandler.cs b/src/framework/Modules/Common/Modules.Common.Infrastructure/Exceptions/CustomExceptionHandler.cs similarity index 92% rename from src/api/framework/Infrastructure/Exceptions/CustomExceptionHandler.cs rename to src/framework/Modules/Common/Modules.Common.Infrastructure/Exceptions/CustomExceptionHandler.cs index c2d19308f2..8bd6db7329 100644 --- a/src/api/framework/Infrastructure/Exceptions/CustomExceptionHandler.cs +++ b/src/framework/Modules/Common/Modules.Common.Infrastructure/Exceptions/CustomExceptionHandler.cs @@ -1,4 +1,4 @@ -using FSH.Framework.Core.Exceptions; +using FSH.Modules.Common.Core.Exceptions; using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -20,7 +20,7 @@ public async ValueTask TryHandleAsync(HttpContext httpContext, Exception e problemDetails.Detail = "one or more validation errors occurred"; problemDetails.Type = "https://tools.ietf.org/html/rfc7231#section-6.5.1"; httpContext.Response.StatusCode = StatusCodes.Status400BadRequest; - List validationErrors = new List(); + List validationErrors = new(); foreach (var error in fluentException.Errors) { validationErrors.Add(error.ErrorMessage); @@ -28,7 +28,7 @@ public async ValueTask TryHandleAsync(HttpContext httpContext, Exception e problemDetails.Extensions.Add("errors", validationErrors); } - else if (exception is FshException e) + else if (exception is CustomException e) { httpContext.Response.StatusCode = (int)e.StatusCode; problemDetails.Detail = e.Message; @@ -48,4 +48,4 @@ public async ValueTask TryHandleAsync(HttpContext httpContext, Exception e await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken).ConfigureAwait(false); return true; } -} +} \ No newline at end of file diff --git a/src/framework/Modules/Common/Modules.Common.Infrastructure/Extensions.cs b/src/framework/Modules/Common/Modules.Common.Infrastructure/Extensions.cs new file mode 100644 index 0000000000..968b57908b --- /dev/null +++ b/src/framework/Modules/Common/Modules.Common.Infrastructure/Extensions.cs @@ -0,0 +1,93 @@ +using FluentValidation; +using FSH.Framework.Core; +using FSH.Framework.Infrastructure; +using FSH.Framework.Infrastructure.Caching; +using FSH.Framework.Infrastructure.Cors; +using FSH.Framework.Infrastructure.Exceptions; +using FSH.Framework.Infrastructure.Jobs; +using FSH.Framework.Infrastructure.Logging.Serilog; +using FSH.Framework.Infrastructure.Mail; +using FSH.Framework.Infrastructure.Messaging.CQRS; +using FSH.Framework.Infrastructure.Messaging.Events; +using FSH.Framework.Infrastructure.OpenApi; +using FSH.Framework.Infrastructure.Persistence; +using FSH.Framework.Infrastructure.RateLimit; +using FSH.Framework.Infrastructure.SecurityHeaders; +using FSH.Framework.Infrastructure.Storage; +using FSH.Modules.Common.Core.Origin; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.FileProviders; +using System.Reflection; + +namespace FSH.Modules.Common.Infrastructure; + +public static class Extensions +{ + public static WebApplicationBuilder AddFshFramework(this WebApplicationBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + builder.Services.AddHttpContextAccessor(); + builder.AddFshSerilog(); + builder.AddDatabaseOption(); + builder.Services.AddCorsPolicy(builder.Configuration); + builder.Services.AddLocalFileStorage(); + builder.Services.AddFshOpenApi(); + builder.Services.AddFshJobs(); + builder.Services.AddFshMailing(); + builder.Services.AddFshCaching(builder.Configuration); + builder.Services.AddExceptionHandler(); + builder.Services.AddProblemDetails(); + builder.Services.AddHealthChecks(); + builder.Services.AddOptions().BindConfiguration(nameof(OriginOptions)); + + // Define framework assemblies + var assemblies = new Assembly[] + { + typeof(FshCore).Assembly, + typeof(FshInfrastructure).Assembly + }; + + // Register validators + builder.Services.AddValidatorsFromAssemblies(assemblies); + + // register messaging services + builder.Services.AddCommandAndQueryDispatchers(); + builder.Services.AddInMemoryEventBus(assemblies); + + builder.Services.AddRateLimiting(builder.Configuration); + builder.Services.AddSecurityHeaders(builder.Configuration); + + return builder; + } + + public static WebApplication ConfigureFshFramework(this WebApplication app) + { + app.UseRateLimit(); + app.UseSecurityHeaders(); + app.UseExceptionHandler(); + app.UseCorsPolicy(); + app.UseOpenApi(); + app.UseJobDashboard(app.Configuration); + app.UseRouting(); + app.UseStaticFiles(); + + app.MapHealthChecks("/health").AllowAnonymous(); + + var assetsPath = Path.Combine(Directory.GetCurrentDirectory(), "wwwroot"); + if (!Directory.Exists(assetsPath)) + { + Directory.CreateDirectory(assetsPath); + } + app.UseStaticFiles(new StaticFileOptions() + { + FileProvider = new PhysicalFileProvider(Path.Combine(Directory.GetCurrentDirectory(), "wwwroot")), + RequestPath = new PathString("/wwwroot"), + }); + + app.UseAuthentication(); + app.UseAuthorization(); + return app; + } +} \ No newline at end of file diff --git a/src/framework/Modules/Common/Modules.Common.Infrastructure/Extensions/PagedListExtensions.cs b/src/framework/Modules/Common/Modules.Common.Infrastructure/Extensions/PagedListExtensions.cs new file mode 100644 index 0000000000..c7e04ebe91 --- /dev/null +++ b/src/framework/Modules/Common/Modules.Common.Infrastructure/Extensions/PagedListExtensions.cs @@ -0,0 +1,12 @@ +using FSH.Framework.Core.Paging; +using Mapster; + +namespace FSH.Framework.Application.Extensions; + +public static class PagedListExtensions +{ + public static PagedList AdaptPagedList(this PagedList paged) + where T : class + where TR : class => + new(paged.Items.Adapt>(), paged.PageNumber, paged.PageSize, paged.TotalCount); +} \ No newline at end of file diff --git a/src/framework/Modules/Common/Modules.Common.Infrastructure/Extensions/RepositoryExtensions.cs b/src/framework/Modules/Common/Modules.Common.Infrastructure/Extensions/RepositoryExtensions.cs new file mode 100644 index 0000000000..540a29280a --- /dev/null +++ b/src/framework/Modules/Common/Modules.Common.Infrastructure/Extensions/RepositoryExtensions.cs @@ -0,0 +1,22 @@ +using Ardalis.Specification; +using FSH.Framework.Core.Paging; + +namespace FSH.Framework.Application.Extensions; +public static class RepositoryExtensions +{ + public static async Task> PaginatedListAsync( + this IReadRepositoryBase repository, + ISpecification spec, + PaginationFilter filter, + CancellationToken cancellationToken = default) + where T : class + where TDestination : class + { + ArgumentNullException.ThrowIfNull(repository); + + var items = await repository.ListAsync(spec, cancellationToken).ConfigureAwait(false); + var totalCount = await repository.CountAsync(spec, cancellationToken).ConfigureAwait(false); + + return new PagedList(items, filter.PageNumber, filter.PageSize, totalCount); + } +} \ No newline at end of file diff --git a/src/api/framework/Infrastructure/FshInfrastructure.cs b/src/framework/Modules/Common/Modules.Common.Infrastructure/FshInfrastructure.cs similarity index 74% rename from src/api/framework/Infrastructure/FshInfrastructure.cs rename to src/framework/Modules/Common/Modules.Common.Infrastructure/FshInfrastructure.cs index d6b702c584..cc7efdb9ed 100644 --- a/src/api/framework/Infrastructure/FshInfrastructure.cs +++ b/src/framework/Modules/Common/Modules.Common.Infrastructure/FshInfrastructure.cs @@ -1,5 +1,5 @@ namespace FSH.Framework.Infrastructure; -public class FshInfrastructure +public static class FshInfrastructure { public static string Name { get; set; } = "FshInfrastructure"; -} +} \ No newline at end of file diff --git a/src/api/framework/Infrastructure/HealthChecks/HealthCheckEndpoint.cs b/src/framework/Modules/Common/Modules.Common.Infrastructure/HealthChecks/HealthCheckEndpoint.cs similarity index 92% rename from src/api/framework/Infrastructure/HealthChecks/HealthCheckEndpoint.cs rename to src/framework/Modules/Common/Modules.Common.Infrastructure/HealthChecks/HealthCheckEndpoint.cs index 785aac77bb..cba3ee3446 100644 --- a/src/api/framework/Infrastructure/HealthChecks/HealthCheckEndpoint.cs +++ b/src/framework/Modules/Common/Modules.Common.Infrastructure/HealthChecks/HealthCheckEndpoint.cs @@ -1,9 +1,9 @@ -using System.Text.Json; -using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Diagnostics.HealthChecks; +using System.Text.Json; namespace FSH.Framework.Infrastructure.HealthChecks; public static class HealthCheckEndpoint @@ -14,7 +14,7 @@ internal static RouteHandlerBuilder MapCustomHealthCheckEndpoint(this IEndpointR { var healthCheckService = context.RequestServices.GetRequiredService(); var report = healthCheckService.CheckHealthAsync().Result; - + var response = new { status = report.Status.ToString(), @@ -24,10 +24,10 @@ internal static RouteHandlerBuilder MapCustomHealthCheckEndpoint(this IEndpointR status = entry.Value.Status.ToString(), description = entry.Value.Description }), - + duration = report.TotalDuration }; - + context.Response.ContentType = "application/json"; return JsonSerializer.Serialize(response); }) @@ -36,4 +36,4 @@ internal static RouteHandlerBuilder MapCustomHealthCheckEndpoint(this IEndpointR .WithDescription("Provides detailed health information about the application.") .AllowAnonymous(); } -} +} \ No newline at end of file diff --git a/src/api/framework/Infrastructure/HealthChecks/HealthCheckMiddleware.cs b/src/framework/Modules/Common/Modules.Common.Infrastructure/HealthChecks/HealthCheckMiddleware.cs similarity index 93% rename from src/api/framework/Infrastructure/HealthChecks/HealthCheckMiddleware.cs rename to src/framework/Modules/Common/Modules.Common.Infrastructure/HealthChecks/HealthCheckMiddleware.cs index 0311568702..e894698732 100644 --- a/src/api/framework/Infrastructure/HealthChecks/HealthCheckMiddleware.cs +++ b/src/framework/Modules/Common/Modules.Common.Infrastructure/HealthChecks/HealthCheckMiddleware.cs @@ -1,6 +1,6 @@ -using System.Text.Json; -using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Diagnostics.HealthChecks; +using System.Text.Json; namespace FSH.Framework.Infrastructure.HealthChecks; @@ -32,5 +32,4 @@ public async Task InvokeAsync(HttpContext context) context.Response.ContentType = "application/json"; await context.Response.WriteAsync(JsonSerializer.Serialize(response)); } -} - +} \ No newline at end of file diff --git a/src/api/framework/Infrastructure/Jobs/Extensions.cs b/src/framework/Modules/Common/Modules.Common.Infrastructure/Jobs/Extensions.cs similarity index 69% rename from src/api/framework/Infrastructure/Jobs/Extensions.cs rename to src/framework/Modules/Common/Modules.Common.Infrastructure/Jobs/Extensions.cs index 618d07d30d..f689d56bed 100644 --- a/src/api/framework/Infrastructure/Jobs/Extensions.cs +++ b/src/framework/Modules/Common/Modules.Common.Infrastructure/Jobs/Extensions.cs @@ -1,7 +1,7 @@ -using FSH.Framework.Core.Exceptions; -using FSH.Framework.Core.Jobs; +using FSH.Framework.Core.Jobs; using FSH.Framework.Core.Persistence; using FSH.Framework.Infrastructure.Persistence; +using FSH.Modules.Common.Core.Exceptions; using Hangfire; using Hangfire.PostgreSql; using Microsoft.AspNetCore.Builder; @@ -12,21 +12,23 @@ namespace FSH.Framework.Infrastructure.Jobs; internal static class Extensions { - internal static IServiceCollection ConfigureJobs(this IServiceCollection services, IConfiguration configuration) + internal static IServiceCollection AddFshJobs(this IServiceCollection services) { - var dbOptions = configuration.GetSection(nameof(DatabaseOptions)).Get() ?? - throw new FshException("database options cannot be null"); - - services.AddHangfireServer(o => + services.AddHangfireServer(options => { - o.HeartbeatInterval = TimeSpan.FromSeconds(30); - o.Queues = new string[] { "default", "email" }; - o.WorkerCount = 5; - o.SchedulePollingInterval = TimeSpan.FromSeconds(30); + options.HeartbeatInterval = TimeSpan.FromSeconds(30); + options.Queues = ["default", "email"]; + options.WorkerCount = 5; + options.SchedulePollingInterval = TimeSpan.FromSeconds(30); }); services.AddHangfire((provider, config) => { + var dbOptions = provider + .GetRequiredService() + .GetSection(nameof(DatabaseOptions)) + .Get() ?? throw new CustomException("Database options not found"); + switch (dbOptions.Provider.ToUpperInvariant()) { case DbProviders.PostgreSQL: @@ -41,7 +43,7 @@ internal static IServiceCollection ConfigureJobs(this IServiceCollection service break; default: - throw new FshException($"hangfire storage provider {dbOptions.Provider} is not supported"); + throw new CustomException($"Hangfire storage provider {dbOptions.Provider} is not supported"); } config.UseFilter(new FshJobFilter(provider)); @@ -49,9 +51,11 @@ internal static IServiceCollection ConfigureJobs(this IServiceCollection service }); services.AddTransient(); + return services; } + internal static IApplicationBuilder UseJobDashboard(this IApplicationBuilder app, IConfiguration config) { var hangfireOptions = config.GetSection(nameof(HangfireOptions)).Get() ?? new HangfireOptions(); @@ -68,4 +72,4 @@ internal static IApplicationBuilder UseJobDashboard(this IApplicationBuilder app return app.UseHangfireDashboard(hangfireOptions.Route, dashboardOptions); } -} +} \ No newline at end of file diff --git a/src/api/framework/Infrastructure/Jobs/FshJobActivator.cs b/src/framework/Modules/Common/Modules.Common.Infrastructure/Jobs/FshJobActivator.cs similarity index 92% rename from src/api/framework/Infrastructure/Jobs/FshJobActivator.cs rename to src/framework/Modules/Common/Modules.Common.Infrastructure/Jobs/FshJobActivator.cs index dc0eb2fd83..8400b54a80 100644 --- a/src/api/framework/Infrastructure/Jobs/FshJobActivator.cs +++ b/src/framework/Modules/Common/Modules.Common.Infrastructure/Jobs/FshJobActivator.cs @@ -1,9 +1,8 @@ using Finbuckle.MultiTenant; using Finbuckle.MultiTenant.Abstractions; -using FSH.Framework.Core.Identity.Users.Abstractions; +using FSH.Framework.Core.ExecutionContext; using FSH.Framework.Infrastructure.Constants; -using FSH.Framework.Infrastructure.Tenant; -using FSH.Starter.Shared.Authorization; +using FSH.Framework.Shared.Multitenancy; using Hangfire; using Hangfire.Server; using Microsoft.Extensions.DependencyInjection; @@ -35,7 +34,7 @@ public Scope(PerformContext context, IServiceScope scope) private void ReceiveParameters() { - var tenantInfo = _context.GetJobParameter(TenantConstants.Identifier); + var tenantInfo = _context.GetJobParameter(MultiTenancyConstants.Identifier); if (tenantInfo is not null) { _scope.ServiceProvider.GetRequiredService() @@ -61,4 +60,4 @@ public override object Resolve(Type type) => ? _context : _scope.ServiceProvider.GetService(serviceType); } -} +} \ No newline at end of file diff --git a/src/api/framework/Infrastructure/Jobs/FshJobFilter.cs b/src/framework/Modules/Common/Modules.Common.Infrastructure/Jobs/FshJobFilter.cs similarity index 90% rename from src/api/framework/Infrastructure/Jobs/FshJobFilter.cs rename to src/framework/Modules/Common/Modules.Common.Infrastructure/Jobs/FshJobFilter.cs index 54b83641b2..a9e40d5fa7 100644 --- a/src/api/framework/Infrastructure/Jobs/FshJobFilter.cs +++ b/src/framework/Modules/Common/Modules.Common.Infrastructure/Jobs/FshJobFilter.cs @@ -1,6 +1,7 @@ using Finbuckle.MultiTenant.Abstractions; using FSH.Framework.Infrastructure.Constants; -using FSH.Starter.Shared.Authorization; +using FSH.Framework.Shared.Extensions; +using FSH.Framework.Shared.Multitenancy; using Hangfire.Client; using Hangfire.Logging; using Microsoft.AspNetCore.Http; @@ -28,7 +29,7 @@ public void OnCreating(CreatingContext context) _ = httpContext ?? throw new InvalidOperationException("Can't create a TenantJob without HttpContext."); var tenantInfo = scope.ServiceProvider.GetRequiredService().MultiTenantContext.TenantInfo; - context.SetJobParameter(TenantConstants.Identifier, tenantInfo); + context.SetJobParameter(MultiTenancyConstants.Identifier, tenantInfo); string? userId = httpContext.User.GetUserId(); context.SetJobParameter(QueryStringKeys.UserId, userId); @@ -38,4 +39,4 @@ public void OnCreated(CreatedContext context) => Logger.InfoFormat( "Job created with parameters {0}", context.Parameters.Select(x => x.Key + "=" + x.Value).Aggregate((s1, s2) => s1 + ";" + s2)); -} +} \ No newline at end of file diff --git a/src/api/framework/Infrastructure/Jobs/HangfireCustomBasicAuthenticationFilter.cs b/src/framework/Modules/Common/Modules.Common.Infrastructure/Jobs/HangfireCustomBasicAuthenticationFilter.cs similarity index 98% rename from src/api/framework/Infrastructure/Jobs/HangfireCustomBasicAuthenticationFilter.cs rename to src/framework/Modules/Common/Modules.Common.Infrastructure/Jobs/HangfireCustomBasicAuthenticationFilter.cs index 2cc8980d4b..9d4215f46e 100644 --- a/src/api/framework/Infrastructure/Jobs/HangfireCustomBasicAuthenticationFilter.cs +++ b/src/framework/Modules/Common/Modules.Common.Infrastructure/Jobs/HangfireCustomBasicAuthenticationFilter.cs @@ -1,9 +1,9 @@ -using System.Net.Http.Headers; using Hangfire.Dashboard; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Primitives; +using System.Net.Http.Headers; namespace FSH.Framework.Infrastructure.Jobs; @@ -24,7 +24,7 @@ public HangfireCustomBasicAuthenticationFilter() public bool Authorize(DashboardContext context) { var httpContext = context.GetHttpContext(); - var header = httpContext.Request.Headers["Authorization"]!; + var header = httpContext.Request.Headers.Authorization!; if (MissingAuthorizationHeader(header)) { @@ -118,4 +118,4 @@ private bool ContainsTwoTokens() { return _tokens.Length == 2; } -} +} \ No newline at end of file diff --git a/src/api/framework/Infrastructure/Jobs/HangfireOptions.cs b/src/framework/Modules/Common/Modules.Common.Infrastructure/Jobs/HangfireOptions.cs similarity index 99% rename from src/api/framework/Infrastructure/Jobs/HangfireOptions.cs rename to src/framework/Modules/Common/Modules.Common.Infrastructure/Jobs/HangfireOptions.cs index 45f5ac0c63..e5b1283039 100644 --- a/src/api/framework/Infrastructure/Jobs/HangfireOptions.cs +++ b/src/framework/Modules/Common/Modules.Common.Infrastructure/Jobs/HangfireOptions.cs @@ -4,4 +4,4 @@ public class HangfireOptions public string UserName { get; set; } = "admin"; public string Password { get; set; } = "Secure1234!Me"; public string Route { get; set; } = "/jobs"; -} +} \ No newline at end of file diff --git a/src/api/framework/Infrastructure/Jobs/HangfireService.cs b/src/framework/Modules/Common/Modules.Common.Infrastructure/Jobs/HangfireService.cs similarity index 99% rename from src/api/framework/Infrastructure/Jobs/HangfireService.cs rename to src/framework/Modules/Common/Modules.Common.Infrastructure/Jobs/HangfireService.cs index 1c4785cd10..62454d869a 100644 --- a/src/api/framework/Infrastructure/Jobs/HangfireService.cs +++ b/src/framework/Modules/Common/Modules.Common.Infrastructure/Jobs/HangfireService.cs @@ -1,6 +1,6 @@ -using System.Linq.Expressions; using FSH.Framework.Core.Jobs; using Hangfire; +using System.Linq.Expressions; namespace FSH.Framework.Infrastructure.Jobs; @@ -56,4 +56,4 @@ public string Schedule(Expression> methodCall, DateTimeOffset enque public string Schedule(Expression> methodCall, DateTimeOffset enqueueAt) => BackgroundJob.Schedule(methodCall, enqueueAt); -} +} \ No newline at end of file diff --git a/src/api/framework/Infrastructure/Jobs/LogJobFilter.cs b/src/framework/Modules/Common/Modules.Common.Infrastructure/Jobs/LogJobFilter.cs similarity index 99% rename from src/api/framework/Infrastructure/Jobs/LogJobFilter.cs rename to src/framework/Modules/Common/Modules.Common.Infrastructure/Jobs/LogJobFilter.cs index 24f1c734ea..f495493ca1 100644 --- a/src/api/framework/Infrastructure/Jobs/LogJobFilter.cs +++ b/src/framework/Modules/Common/Modules.Common.Infrastructure/Jobs/LogJobFilter.cs @@ -48,4 +48,4 @@ public void OnStateUnapplied(ApplyStateContext context, IWriteOnlyTransaction tr "Job {0} state {1} was unapplied.", context.BackgroundJob.Id, context.OldStateName); -} +} \ No newline at end of file diff --git a/src/api/framework/Infrastructure/Logging/Serilog/Extensions.cs b/src/framework/Modules/Common/Modules.Common.Infrastructure/Logging/Serilog/Extensions.cs similarity index 94% rename from src/api/framework/Infrastructure/Logging/Serilog/Extensions.cs rename to src/framework/Modules/Common/Modules.Common.Infrastructure/Logging/Serilog/Extensions.cs index 25a8dba177..e617d827ce 100644 --- a/src/api/framework/Infrastructure/Logging/Serilog/Extensions.cs +++ b/src/framework/Modules/Common/Modules.Common.Infrastructure/Logging/Serilog/Extensions.cs @@ -7,7 +7,7 @@ namespace FSH.Framework.Infrastructure.Logging.Serilog; public static class Extensions { - public static WebApplicationBuilder ConfigureSerilog(this WebApplicationBuilder builder) + public static WebApplicationBuilder AddFshSerilog(this WebApplicationBuilder builder) { ArgumentNullException.ThrowIfNull(builder); builder.Host.UseSerilog((context, logger) => @@ -22,7 +22,7 @@ public static WebApplicationBuilder ConfigureSerilog(this WebApplicationBuilder { var (key, value) = header.Split('=') switch { - [string k, string v] => (k, v), + [string k, string v] => (k, v), var v => throw new Exception($"Invalid header format {v}") }; @@ -32,7 +32,7 @@ public static WebApplicationBuilder ConfigureSerilog(this WebApplicationBuilder //To remove the duplicate issue, we can use the below code to get the key and value from the configuration var (otelResourceAttribute, otelResourceAttributeValue) = builder.Configuration["OTEL_RESOURCE_ATTRIBUTES"]?.Split('=') switch { - [string k, string v] => (k, v), + [string k, string v] => (k, v), _ => throw new Exception($"Invalid header format {builder.Configuration["OTEL_RESOURCE_ATTRIBUTES"]}") }; options.ResourceAttributes.Add(otelResourceAttribute, otelResourceAttributeValue); @@ -55,4 +55,4 @@ public static WebApplicationBuilder ConfigureSerilog(this WebApplicationBuilder }); return builder; } -} +} \ No newline at end of file diff --git a/src/api/framework/Infrastructure/Logging/Serilog/StaticLogger.cs b/src/framework/Modules/Common/Modules.Common.Infrastructure/Logging/Serilog/StaticLogger.cs similarity index 99% rename from src/api/framework/Infrastructure/Logging/Serilog/StaticLogger.cs rename to src/framework/Modules/Common/Modules.Common.Infrastructure/Logging/Serilog/StaticLogger.cs index 1809893b53..98ec0ac905 100644 --- a/src/api/framework/Infrastructure/Logging/Serilog/StaticLogger.cs +++ b/src/framework/Modules/Common/Modules.Common.Infrastructure/Logging/Serilog/StaticLogger.cs @@ -16,4 +16,4 @@ public static void EnsureInitialized() .CreateLogger(); } } -} +} \ No newline at end of file diff --git a/src/api/framework/Infrastructure/Mail/Extensions.cs b/src/framework/Modules/Common/Modules.Common.Infrastructure/Mail/Extensions.cs similarity index 79% rename from src/api/framework/Infrastructure/Mail/Extensions.cs rename to src/framework/Modules/Common/Modules.Common.Infrastructure/Mail/Extensions.cs index 4c772f7731..27a659c434 100644 --- a/src/api/framework/Infrastructure/Mail/Extensions.cs +++ b/src/framework/Modules/Common/Modules.Common.Infrastructure/Mail/Extensions.cs @@ -4,10 +4,10 @@ namespace FSH.Framework.Infrastructure.Mail; internal static class Extensions { - internal static IServiceCollection ConfigureMailing(this IServiceCollection services) + internal static IServiceCollection AddFshMailing(this IServiceCollection services) { services.AddTransient(); services.AddOptions().BindConfiguration(nameof(MailOptions)); return services; } -} +} \ No newline at end of file diff --git a/src/api/framework/Infrastructure/Mail/SmtpMailService.cs b/src/framework/Modules/Common/Modules.Common.Infrastructure/Mail/SmtpMailService.cs similarity index 85% rename from src/api/framework/Infrastructure/Mail/SmtpMailService.cs rename to src/framework/Modules/Common/Modules.Common.Infrastructure/Mail/SmtpMailService.cs index aee5969491..4216eff2c3 100644 --- a/src/api/framework/Infrastructure/Mail/SmtpMailService.cs +++ b/src/framework/Modules/Common/Modules.Common.Infrastructure/Mail/SmtpMailService.cs @@ -1,9 +1,4 @@ -namespace FSH.Framework.Infrastructure.Mail; -using System; -using System.IO; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; + using FSH.Framework.Core.Mail; using MailKit.Net.Smtp; using MailKit.Security; @@ -11,6 +6,8 @@ using Microsoft.Extensions.Options; using MimeKit; +namespace FSH.Framework.Infrastructure.Mail; + public class SmtpMailService(IOptions settings, ILogger logger) : IMailService { private readonly MailOptions _settings = settings.Value; @@ -63,12 +60,10 @@ public async Task SendAsync(MailRequest request, CancellationToken ct) { foreach (var attachmentInfo in request.AttachmentData) { - using (var stream = new MemoryStream()) - { - await stream.WriteAsync(attachmentInfo.Value, ct); - stream.Position = 0; - await builder.Attachments.AddAsync(attachmentInfo.Key, stream, ct); - } + using var stream = new MemoryStream(); + await stream.WriteAsync(attachmentInfo.Value, ct); + stream.Position = 0; + await builder.Attachments.AddAsync(attachmentInfo.Key, stream, ct); } } @@ -90,4 +85,4 @@ public async Task SendAsync(MailRequest request, CancellationToken ct) await client.DisconnectAsync(true, ct); } } -} +} \ No newline at end of file diff --git a/src/framework/Modules/Common/Modules.Common.Infrastructure/Messaging/CQRS/CommandDispatcher.cs b/src/framework/Modules/Common/Modules.Common.Infrastructure/Messaging/CQRS/CommandDispatcher.cs new file mode 100644 index 0000000000..10f9eb5456 --- /dev/null +++ b/src/framework/Modules/Common/Modules.Common.Infrastructure/Messaging/CQRS/CommandDispatcher.cs @@ -0,0 +1,22 @@ +using FSH.Framework.Core.Messaging.CQRS; +using FSH.Modules.Common.Core.Messaging.CQRS; +using Microsoft.Extensions.DependencyInjection; + +namespace FSH.Framework.Infrastructure.Messaging.CQRS; +public class CommandDispatcher : ICommandDispatcher +{ + private readonly IServiceProvider _serviceProvider; + + public CommandDispatcher(IServiceProvider serviceProvider) => + _serviceProvider = serviceProvider; + + public Task SendAsync(ICommand command, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(command); + var handlerType = typeof(ICommandHandler<,>).MakeGenericType(command.GetType(), typeof(TResponse)); + dynamic handler = _serviceProvider.GetRequiredService(handlerType); + + // dynamic dispatch to call HandleAsync(command, ct) + return handler.HandleAsync((dynamic)command, ct); + } +} \ No newline at end of file diff --git a/src/framework/Modules/Common/Modules.Common.Infrastructure/Messaging/CQRS/Extensions.cs b/src/framework/Modules/Common/Modules.Common.Infrastructure/Messaging/CQRS/Extensions.cs new file mode 100644 index 0000000000..7bc0b8ebde --- /dev/null +++ b/src/framework/Modules/Common/Modules.Common.Infrastructure/Messaging/CQRS/Extensions.cs @@ -0,0 +1,42 @@ +using FSH.Framework.Core.Messaging.CQRS; +using FSH.Framework.Infrastructure.Messaging.CQRS.Validation; +using FSH.Modules.Common.Core.Messaging.CQRS; +using Microsoft.Extensions.DependencyInjection; +using System.Reflection; + +namespace FSH.Framework.Infrastructure.Messaging.CQRS; +public static class Extensions +{ + public static IServiceCollection AddCommandAndQueryDispatchers(this IServiceCollection services) + { + // Register dispatchers + services.AddScoped(); + services.AddScoped(); + + // Register decorators + services.Decorate(); + services.Decorate(); + + return services; + } + public static IServiceCollection RegisterCommandAndQueryHandlers(this IServiceCollection services, params Assembly[] assemblies) + { + // Deduplicate Assemblies + var distinctAssemblies = assemblies.Distinct().ToArray(); + + // Scan for handlers in provided assemblies + services.Scan(scan => scan + .FromAssemblies(distinctAssemblies) + .AddClasses(c => c.AssignableTo(typeof(ICommandHandler<,>))) + .AsImplementedInterfaces() + .WithScopedLifetime()); + + services.Scan(scan => scan + .FromAssemblies(distinctAssemblies) + .AddClasses(c => c.AssignableTo(typeof(IQueryHandler<,>))) + .AsImplementedInterfaces() + .WithScopedLifetime()); + + return services; + } +} \ No newline at end of file diff --git a/src/framework/Modules/Common/Modules.Common.Infrastructure/Messaging/CQRS/QueryDispatcher.cs b/src/framework/Modules/Common/Modules.Common.Infrastructure/Messaging/CQRS/QueryDispatcher.cs new file mode 100644 index 0000000000..37ec66fdbc --- /dev/null +++ b/src/framework/Modules/Common/Modules.Common.Infrastructure/Messaging/CQRS/QueryDispatcher.cs @@ -0,0 +1,21 @@ +using FSH.Framework.Core.Messaging.CQRS; +using Microsoft.Extensions.DependencyInjection; + +namespace FSH.Framework.Infrastructure.Messaging.CQRS; +public class QueryDispatcher : IQueryDispatcher +{ + private readonly IServiceProvider _serviceProvider; + + public QueryDispatcher(IServiceProvider serviceProvider) => + _serviceProvider = serviceProvider; + + public Task SendAsync(IQuery query, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(query); + + var handlerType = typeof(IQueryHandler<,>).MakeGenericType(query.GetType(), typeof(TResponse)); + dynamic handler = _serviceProvider.GetRequiredService(handlerType); + + return handler.HandleAsync((dynamic)query, ct); + } +} \ No newline at end of file diff --git a/src/framework/Modules/Common/Modules.Common.Infrastructure/Messaging/CQRS/Validation/CommandValidation.cs b/src/framework/Modules/Common/Modules.Common.Infrastructure/Messaging/CQRS/Validation/CommandValidation.cs new file mode 100644 index 0000000000..f2a73fa74e --- /dev/null +++ b/src/framework/Modules/Common/Modules.Common.Infrastructure/Messaging/CQRS/Validation/CommandValidation.cs @@ -0,0 +1,21 @@ +using FSH.Framework.Core.Messaging.CQRS; +using FSH.Modules.Common.Core.Messaging.CQRS; + +namespace FSH.Framework.Infrastructure.Messaging.CQRS.Validation; +public class CommandValidation : ICommandDispatcher +{ + private readonly ICommandDispatcher _inner; + private readonly IServiceProvider _serviceProvider; + + public CommandValidation(ICommandDispatcher inner, IServiceProvider serviceProvider) + { + _inner = inner; + _serviceProvider = serviceProvider; + } + + public async Task SendAsync(ICommand command, CancellationToken ct = default) + { + await ValidationHelper.ValidateAsync(command, _serviceProvider, ct); + return await _inner.SendAsync(command, ct); + } +} \ No newline at end of file diff --git a/src/framework/Modules/Common/Modules.Common.Infrastructure/Messaging/CQRS/Validation/QueryValidation.cs b/src/framework/Modules/Common/Modules.Common.Infrastructure/Messaging/CQRS/Validation/QueryValidation.cs new file mode 100644 index 0000000000..817579dc04 --- /dev/null +++ b/src/framework/Modules/Common/Modules.Common.Infrastructure/Messaging/CQRS/Validation/QueryValidation.cs @@ -0,0 +1,20 @@ +using FSH.Framework.Core.Messaging.CQRS; + +namespace FSH.Framework.Infrastructure.Messaging.CQRS.Validation; +internal sealed class QueryValidation : IQueryDispatcher +{ + private readonly IQueryDispatcher _inner; + private readonly IServiceProvider _serviceProvider; + + public QueryValidation(IQueryDispatcher inner, IServiceProvider serviceProvider) + { + _inner = inner; + _serviceProvider = serviceProvider; + } + + public async Task SendAsync(IQuery query, CancellationToken ct = default) + { + await ValidationHelper.ValidateAsync(query, _serviceProvider, ct); + return await _inner.SendAsync(query, ct); + } +} \ No newline at end of file diff --git a/src/framework/Modules/Common/Modules.Common.Infrastructure/Messaging/CQRS/Validation/ValidationHelper.cs b/src/framework/Modules/Common/Modules.Common.Infrastructure/Messaging/CQRS/Validation/ValidationHelper.cs new file mode 100644 index 0000000000..568aa5aff5 --- /dev/null +++ b/src/framework/Modules/Common/Modules.Common.Infrastructure/Messaging/CQRS/Validation/ValidationHelper.cs @@ -0,0 +1,36 @@ +using FluentValidation; +using Microsoft.Extensions.DependencyInjection; + +namespace FSH.Framework.Infrastructure.Messaging.CQRS.Validation; + +internal static class ValidationHelper +{ + public static async Task ValidateAsync(T request, IServiceProvider provider, CancellationToken ct = default) + { + var requestType = request.GetType(); + var validatorType = typeof(IValidator<>).MakeGenericType(requestType); + var validators = provider.GetServices(validatorType).Cast().ToList(); + + if (validators.Count == 0) return; + + var contextType = typeof(ValidationContext<>).MakeGenericType(requestType); + var context = Activator.CreateInstance(contextType, request)!; + + var failures = new List(); + + foreach (var validator in validators) + { + var validateAsyncMethod = validator.GetType() + .GetMethod("ValidateAsync", new[] { contextType, typeof(CancellationToken) })!; + + var task = (Task) + validateAsyncMethod.Invoke(validator, new[] { context, ct })!; + + var result = await task; + failures.AddRange(result.Errors.Where(f => f != null)); + } + + if (failures.Count > 0) + throw new ValidationException(failures); + } +} \ No newline at end of file diff --git a/src/framework/Modules/Common/Modules.Common.Infrastructure/Messaging/Events/Extensions.cs b/src/framework/Modules/Common/Modules.Common.Infrastructure/Messaging/Events/Extensions.cs new file mode 100644 index 0000000000..d649a01b23 --- /dev/null +++ b/src/framework/Modules/Common/Modules.Common.Infrastructure/Messaging/Events/Extensions.cs @@ -0,0 +1,20 @@ +using FSH.Framework.Core.Messaging.Events; +using Microsoft.Extensions.DependencyInjection; +using System.Reflection; + +namespace FSH.Framework.Infrastructure.Messaging.Events; +public static class Extensions +{ + public static IServiceCollection AddInMemoryEventBus(this IServiceCollection services, params Assembly[] assemblies) + { + services.AddScoped(); + + services.Scan(scan => scan + .FromAssemblies(assemblies) + .AddClasses(c => c.AssignableTo(typeof(IEventHandler<>))) + .AsImplementedInterfaces() + .WithScopedLifetime()); + + return services; + } +} \ No newline at end of file diff --git a/src/framework/Modules/Common/Modules.Common.Infrastructure/Messaging/Events/InMemoryEventPublisher.cs b/src/framework/Modules/Common/Modules.Common.Infrastructure/Messaging/Events/InMemoryEventPublisher.cs new file mode 100644 index 0000000000..4afd4f6b24 --- /dev/null +++ b/src/framework/Modules/Common/Modules.Common.Infrastructure/Messaging/Events/InMemoryEventPublisher.cs @@ -0,0 +1,57 @@ +using FSH.Framework.Core.Messaging.Events; +using Microsoft.Extensions.DependencyInjection; + +namespace FSH.Framework.Infrastructure.Messaging.Events; + +public class InMemoryEventPublisher : IEventPublisher +{ + private readonly IServiceProvider _serviceProvider; + + public InMemoryEventPublisher(IServiceProvider serviceProvider) => + _serviceProvider = serviceProvider; + + public async Task PublishAsync(IEvent appEvent, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(appEvent); + + using var scope = _serviceProvider.CreateScope(); + + // Get handler type dynamically based on the event's runtime type + var handlerType = typeof(IEventHandler<>).MakeGenericType(appEvent.GetType()); + var handlers = scope.ServiceProvider.GetServices(handlerType); + + foreach (var handler in handlers) + { + const int maxAttempts = 3; + int attempt = 0; + + while (attempt < maxAttempts) + { + try + { + attempt++; + var method = handlerType.GetMethod("HandleAsync"); + + if (method is not null) + { + await (Task)method.Invoke(handler, new object[] { appEvent, cancellationToken })!; + } + + break; // Success + } + catch (Exception ex) + { + if (attempt == maxAttempts) + { + Console.WriteLine($"Handler for {typeof(IEvent).Name} failed after {attempt} attempts: {ex.Message}"); + // Optionally: Add to dead-letter queue + } + else + { + await Task.Delay(100 * attempt, cancellationToken); // simple backoff + } + } + } + } + } +} \ No newline at end of file diff --git a/src/api/framework/Infrastructure/Infrastructure.csproj b/src/framework/Modules/Common/Modules.Common.Infrastructure/Modules.Common.Infrastructure.csproj similarity index 69% rename from src/api/framework/Infrastructure/Infrastructure.csproj rename to src/framework/Modules/Common/Modules.Common.Infrastructure/Modules.Common.Infrastructure.csproj index 389248b9c6..e218998fff 100644 --- a/src/api/framework/Infrastructure/Infrastructure.csproj +++ b/src/framework/Modules/Common/Modules.Common.Infrastructure/Modules.Common.Infrastructure.csproj @@ -1,21 +1,13 @@  - FSH.Framework.Infrastructure - FSH.Framework.Infrastructure - true + FSH.Modules.Common.Infrastructure + FSH.Modules.Common.Infrastructure - - - - - - - - + @@ -31,6 +23,7 @@ + @@ -61,22 +54,10 @@ - - - - - - - - - - - - - - - + + + diff --git a/src/framework/Modules/Common/Modules.Common.Infrastructure/Modules/IFrameworkModule.cs b/src/framework/Modules/Common/Modules.Common.Infrastructure/Modules/IFrameworkModule.cs new file mode 100644 index 0000000000..8405a2c006 --- /dev/null +++ b/src/framework/Modules/Common/Modules.Common.Infrastructure/Modules/IFrameworkModule.cs @@ -0,0 +1,6 @@ +using FSH.Modules.Common.Infrastructure.Modules; + +namespace FSH.Framework.Infrastructure.Modules; +public interface IFrameworkModule : IModule +{ +} \ No newline at end of file diff --git a/src/framework/Modules/Common/Modules.Common.Infrastructure/Modules/IModule.cs b/src/framework/Modules/Common/Modules.Common.Infrastructure/Modules/IModule.cs new file mode 100644 index 0000000000..bb531bc3ca --- /dev/null +++ b/src/framework/Modules/Common/Modules.Common.Infrastructure/Modules/IModule.cs @@ -0,0 +1,8 @@ +using FSH.Modules.Common.Core.Modules; +using Microsoft.AspNetCore.Builder; + +namespace FSH.Modules.Common.Infrastructure.Modules; +public interface IModule : ICoreModule +{ + void ConfigureModule(WebApplication app); +} \ No newline at end of file diff --git a/src/framework/Modules/Common/Modules.Common.Infrastructure/OpenApi/BearerSecuritySchemeTransformer.cs b/src/framework/Modules/Common/Modules.Common.Infrastructure/OpenApi/BearerSecuritySchemeTransformer.cs new file mode 100644 index 0000000000..1839b730ee --- /dev/null +++ b/src/framework/Modules/Common/Modules.Common.Infrastructure/OpenApi/BearerSecuritySchemeTransformer.cs @@ -0,0 +1,43 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.OpenApi.Models; + +namespace FSH.Framework.Infrastructure.OpenApi; +public sealed class BearerSecuritySchemeTransformer : IOpenApiDocumentTransformer +{ + private readonly IAuthenticationSchemeProvider _authenticationSchemeProvider; + + public BearerSecuritySchemeTransformer(IAuthenticationSchemeProvider authenticationSchemeProvider) + { + _authenticationSchemeProvider = authenticationSchemeProvider; + } + + public async Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerContext context, CancellationToken cancellationToken) + { + var authenticationSchemes = await _authenticationSchemeProvider.GetAllSchemesAsync(); + if (authenticationSchemes.Any(authScheme => authScheme.Name == "Bearer")) + { + var requirements = new Dictionary + { + ["Bearer"] = new OpenApiSecurityScheme + { + Type = SecuritySchemeType.Http, + Scheme = "bearer", + In = ParameterLocation.Header, + BearerFormat = "JWT" + } + }; + + document.Components ??= new OpenApiComponents(); + document.Components.SecuritySchemes = requirements; + + foreach (var operation in document.Paths.Values.SelectMany(path => path.Operations)) + { + operation.Value.Security.Add(new OpenApiSecurityRequirement + { + [new OpenApiSecurityScheme { Reference = new OpenApiReference { Id = "Bearer", Type = ReferenceType.SecurityScheme } }] = Array.Empty() + }); + } + } + } +} \ No newline at end of file diff --git a/src/api/framework/Infrastructure/OpenApi/ConfigureSwaggerOptions.cs b/src/framework/Modules/Common/Modules.Common.Infrastructure/OpenApi/ConfigureSwaggerOptions.cs similarity index 96% rename from src/api/framework/Infrastructure/OpenApi/ConfigureSwaggerOptions.cs rename to src/framework/Modules/Common/Modules.Common.Infrastructure/OpenApi/ConfigureSwaggerOptions.cs index 71eed3eb74..8044ddad0b 100644 --- a/src/api/framework/Infrastructure/OpenApi/ConfigureSwaggerOptions.cs +++ b/src/framework/Modules/Common/Modules.Common.Infrastructure/OpenApi/ConfigureSwaggerOptions.cs @@ -1,9 +1,9 @@ -using System.Text; -using Asp.Versioning.ApiExplorer; +using Asp.Versioning.ApiExplorer; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Microsoft.OpenApi.Models; using Swashbuckle.AspNetCore.SwaggerGen; +using System.Text; namespace FSH.Framework.Infrastructure.OpenApi; public class ConfigureSwaggerOptions : IConfigureOptions @@ -46,4 +46,4 @@ private static OpenApiInfo CreateInfoForApiVersion(ApiVersionDescription descrip return info; } -} +} \ No newline at end of file diff --git a/src/api/framework/Infrastructure/OpenApi/Extensions.cs b/src/framework/Modules/Common/Modules.Common.Infrastructure/OpenApi/Extensions.cs similarity index 93% rename from src/api/framework/Infrastructure/OpenApi/Extensions.cs rename to src/framework/Modules/Common/Modules.Common.Infrastructure/OpenApi/Extensions.cs index df32e185fe..cdadc29c99 100644 --- a/src/api/framework/Infrastructure/OpenApi/Extensions.cs +++ b/src/framework/Modules/Common/Modules.Common.Infrastructure/OpenApi/Extensions.cs @@ -11,7 +11,7 @@ namespace FSH.Framework.Infrastructure.OpenApi; public static class Extensions { - public static IServiceCollection ConfigureOpenApi(this IServiceCollection services) + public static IServiceCollection AddFshOpenApi(this IServiceCollection services) { ArgumentNullException.ThrowIfNull(services); services.AddEndpointsApiExplorer(); @@ -42,13 +42,14 @@ public static IServiceCollection ConfigureOpenApi(this IServiceCollection servic .AddApiVersioning(options => { options.ReportApiVersions = true; - options.DefaultApiVersion = new ApiVersion(1); + options.DefaultApiVersion = new ApiVersion(1, 0); options.AssumeDefaultVersionWhenUnspecified = true; options.ApiVersionReader = new UrlSegmentApiVersionReader(); }) .AddApiExplorer(options => { options.GroupNameFormat = "'v'VVV"; + options.SubstituteApiVersionInUrl = true; }) .EnableApiVersionBinding(); return services; @@ -79,4 +80,4 @@ public static WebApplication UseOpenApi(this WebApplication app) } return app; } -} +} \ No newline at end of file diff --git a/src/api/framework/Infrastructure/OpenApi/SwaggerDefaultValues.cs b/src/framework/Modules/Common/Modules.Common.Infrastructure/OpenApi/SwaggerDefaultValues.cs similarity index 97% rename from src/api/framework/Infrastructure/OpenApi/SwaggerDefaultValues.cs rename to src/framework/Modules/Common/Modules.Common.Infrastructure/OpenApi/SwaggerDefaultValues.cs index b872e69024..9ddca15307 100644 --- a/src/api/framework/Infrastructure/OpenApi/SwaggerDefaultValues.cs +++ b/src/framework/Modules/Common/Modules.Common.Infrastructure/OpenApi/SwaggerDefaultValues.cs @@ -1,8 +1,8 @@ -using System.Text.Json; -using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.AspNetCore.Mvc.ApiExplorer; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.OpenApi.Models; using Swashbuckle.AspNetCore.SwaggerGen; +using System.Text.Json; namespace FSH.Framework.Infrastructure.OpenApi; public class SwaggerDefaultValues : IOperationFilter @@ -59,4 +59,4 @@ description.DefaultValue is not DBNull && parameter.Required |= description.IsRequired; } } -} +} \ No newline at end of file diff --git a/src/api/framework/Infrastructure/Persistence/AppendGlobalQueryFilterExtension.cs b/src/framework/Modules/Common/Modules.Common.Infrastructure/Persistence/AppendGlobalQueryFilterExtension.cs similarity index 99% rename from src/api/framework/Infrastructure/Persistence/AppendGlobalQueryFilterExtension.cs rename to src/framework/Modules/Common/Modules.Common.Infrastructure/Persistence/AppendGlobalQueryFilterExtension.cs index dbd09831d1..f5323a2b8b 100644 --- a/src/api/framework/Infrastructure/Persistence/AppendGlobalQueryFilterExtension.cs +++ b/src/framework/Modules/Common/Modules.Common.Infrastructure/Persistence/AppendGlobalQueryFilterExtension.cs @@ -1,6 +1,6 @@ -using System.Linq.Expressions; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Query; +using System.Linq.Expressions; namespace FSH.Framework.Infrastructure.Persistence; @@ -33,4 +33,4 @@ public static ModelBuilder AppendGlobalQueryFilter(this ModelBuilder return modelBuilder; } -} +} \ No newline at end of file diff --git a/src/api/framework/Infrastructure/Persistence/DbProviders.cs b/src/framework/Modules/Common/Modules.Common.Infrastructure/Persistence/DbProviders.cs similarity index 98% rename from src/api/framework/Infrastructure/Persistence/DbProviders.cs rename to src/framework/Modules/Common/Modules.Common.Infrastructure/Persistence/DbProviders.cs index f330df5123..26d0e063e4 100644 --- a/src/api/framework/Infrastructure/Persistence/DbProviders.cs +++ b/src/framework/Modules/Common/Modules.Common.Infrastructure/Persistence/DbProviders.cs @@ -3,4 +3,4 @@ internal static class DbProviders { public const string PostgreSQL = "POSTGRESQL"; public const string MSSQL = "MSSQL"; -} +} \ No newline at end of file diff --git a/src/api/framework/Infrastructure/Persistence/Extensions.cs b/src/framework/Modules/Common/Modules.Common.Infrastructure/Persistence/Extensions.cs similarity index 70% rename from src/api/framework/Infrastructure/Persistence/Extensions.cs rename to src/framework/Modules/Common/Modules.Common.Infrastructure/Persistence/Extensions.cs index dce8cb5a64..cffedd772d 100644 --- a/src/api/framework/Infrastructure/Persistence/Extensions.cs +++ b/src/framework/Modules/Common/Modules.Common.Infrastructure/Persistence/Extensions.cs @@ -1,5 +1,4 @@ using FSH.Framework.Core.Persistence; -using FSH.Framework.Infrastructure.Persistence.Interceptors; using Microsoft.AspNetCore.Builder; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; @@ -11,20 +10,33 @@ namespace FSH.Framework.Infrastructure.Persistence; public static class Extensions { private static readonly ILogger Logger = Log.ForContext(typeof(Extensions)); - internal static DbContextOptionsBuilder ConfigureDatabase(this DbContextOptionsBuilder builder, string dbProvider, string connectionString) + public static DbContextOptionsBuilder ConfigureDatabase(this DbContextOptionsBuilder builder, + string dbProvider, + string connectionString, + string migrationsAssembly + ) { builder.ConfigureWarnings(warnings => warnings.Log(RelationalEventId.PendingModelChangesWarning)); return dbProvider.ToUpperInvariant() switch { - DbProviders.PostgreSQL => builder.UseNpgsql(connectionString, e => - e.MigrationsAssembly("FSH.Starter.WebApi.Migrations.PostgreSQL")).EnableSensitiveDataLogging(), - DbProviders.MSSQL => builder.UseSqlServer(connectionString, e => - e.MigrationsAssembly("FSH.Starter.WebApi.Migrations.MSSQL")), + DbProviders.PostgreSQL => + builder.UseNpgsql( + connectionString, e => + { + e.MigrationsAssembly(migrationsAssembly); + }) + .EnableSensitiveDataLogging(), + DbProviders.MSSQL => + builder.UseSqlServer( + connectionString, e => + { + e.MigrationsAssembly(migrationsAssembly); + }), _ => throw new InvalidOperationException($"DB Provider {dbProvider} is not supported."), }; } - public static WebApplicationBuilder ConfigureDatabase(this WebApplicationBuilder builder) + public static WebApplicationBuilder AddDatabaseOption(this WebApplicationBuilder builder) { ArgumentNullException.ThrowIfNull(builder); builder.Services.AddOptions() @@ -36,7 +48,6 @@ public static WebApplicationBuilder ConfigureDatabase(this WebApplicationBuilder Logger.Information("for documentations and guides, visit https://www.fullstackhero.net"); Logger.Information("to sponsor this project, visit https://opencollective.com/fullstackhero"); }); - builder.Services.AddScoped(); return builder; } @@ -48,9 +59,9 @@ public static IServiceCollection BindDbContext(this IServiceCollection services.AddDbContext((sp, options) => { var dbConfig = sp.GetRequiredService>().Value; - options.ConfigureDatabase(dbConfig.Provider, dbConfig.ConnectionString); + options.ConfigureDatabase(dbConfig.Provider, dbConfig.ConnectionString, dbConfig.MigrationsAssembly); options.AddInterceptors(sp.GetServices()); }); return services; } -} +} \ No newline at end of file diff --git a/src/api/framework/Infrastructure/Persistence/FshDbContext.cs b/src/framework/Modules/Common/Modules.Common.Infrastructure/Persistence/FshDbContext.cs similarity index 77% rename from src/api/framework/Infrastructure/Persistence/FshDbContext.cs rename to src/framework/Modules/Common/Modules.Common.Infrastructure/Persistence/FshDbContext.cs index 1f3186e3e5..2c675f2ff2 100644 --- a/src/api/framework/Infrastructure/Persistence/FshDbContext.cs +++ b/src/framework/Modules/Common/Modules.Common.Infrastructure/Persistence/FshDbContext.cs @@ -1,20 +1,20 @@ using Finbuckle.MultiTenant.Abstractions; using Finbuckle.MultiTenant.EntityFrameworkCore; -using FSH.Framework.Core.Domain.Contracts; +using FSH.Framework.Core.Messaging.Events; using FSH.Framework.Core.Persistence; -using FSH.Framework.Infrastructure.Tenant; -using MediatR; +using FSH.Framework.Shared.Multitenancy; +using FSH.Modules.Common.Core.Domain.Contracts; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; namespace FSH.Framework.Infrastructure.Persistence; public class FshDbContext(IMultiTenantContextAccessor multiTenantContextAccessor, DbContextOptions options, - IPublisher publisher, + IEventPublisher publisher, IOptions settings) : MultiTenantDbContext(multiTenantContextAccessor, options) { - private readonly IPublisher _publisher = publisher; + private readonly IEventPublisher _publisher = publisher; private readonly DatabaseOptions _settings = settings.Value; protected override void OnModelCreating(ModelBuilder modelBuilder) @@ -29,17 +29,19 @@ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) if (!string.IsNullOrWhiteSpace(multiTenantContextAccessor?.MultiTenantContext.TenantInfo?.ConnectionString)) { - optionsBuilder.ConfigureDatabase(_settings.Provider, multiTenantContextAccessor.MultiTenantContext.TenantInfo.ConnectionString!); + optionsBuilder.ConfigureDatabase(_settings.Provider, multiTenantContextAccessor.MultiTenantContext.TenantInfo.ConnectionString!, _settings.MigrationsAssembly); } } public override async Task SaveChangesAsync(CancellationToken cancellationToken = default) { this.TenantNotSetMode = TenantNotSetMode.Overwrite; int result = await base.SaveChangesAsync(cancellationToken).ConfigureAwait(false); - await PublishDomainEventsAsync().ConfigureAwait(false); + await PublishDomainEventsAsync(cancellationToken).ConfigureAwait(false); return result; } - private async Task PublishDomainEventsAsync() + + // todo: move this to interceptor + private async Task PublishDomainEventsAsync(CancellationToken cancellationToken = default) { var domainEvents = ChangeTracker.Entries() .Select(e => e.Entity) @@ -54,7 +56,7 @@ private async Task PublishDomainEventsAsync() foreach (var domainEvent in domainEvents) { - await _publisher.Publish(domainEvent).ConfigureAwait(false); + await _publisher.PublishAsync(domainEvent, cancellationToken).ConfigureAwait(false); } } -} +} \ No newline at end of file diff --git a/src/api/framework/Infrastructure/Persistence/Services/ConnectionStringValidator.cs b/src/framework/Modules/Common/Modules.Common.Infrastructure/Persistence/Services/ConnectionStringValidator.cs similarity index 90% rename from src/api/framework/Infrastructure/Persistence/Services/ConnectionStringValidator.cs rename to src/framework/Modules/Common/Modules.Common.Infrastructure/Persistence/Services/ConnectionStringValidator.cs index 2b39de48d8..b5e27eff85 100644 --- a/src/api/framework/Infrastructure/Persistence/Services/ConnectionStringValidator.cs +++ b/src/framework/Modules/Common/Modules.Common.Infrastructure/Persistence/Services/ConnectionStringValidator.cs @@ -5,7 +5,7 @@ using Npgsql; namespace FSH.Framework.Infrastructure.Persistence.Services; -internal sealed class ConnectionStringValidator(IOptions dbSettings, ILogger logger) : IConnectionStringValidator +public sealed class ConnectionStringValidator(IOptions dbSettings, ILogger logger) : IConnectionStringValidator { private readonly DatabaseOptions _dbSettings = dbSettings.Value; private readonly ILogger _logger = logger; @@ -41,4 +41,4 @@ public bool TryValidate(string connectionString, string? dbProvider = null) return false; } } -} +} \ No newline at end of file diff --git a/src/api/framework/Infrastructure/RateLimit/Extensions.cs b/src/framework/Modules/Common/Modules.Common.Infrastructure/RateLimit/Extensions.cs similarity index 91% rename from src/api/framework/Infrastructure/RateLimit/Extensions.cs rename to src/framework/Modules/Common/Modules.Common.Infrastructure/RateLimit/Extensions.cs index 3b00e7d85f..83900b52d3 100644 --- a/src/api/framework/Infrastructure/RateLimit/Extensions.cs +++ b/src/framework/Modules/Common/Modules.Common.Infrastructure/RateLimit/Extensions.cs @@ -1,16 +1,16 @@ -using System.Threading.RateLimiting; -using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.RateLimiting; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; +using System.Threading.RateLimiting; namespace FSH.Framework.Infrastructure.RateLimit; public static class Extensions { - internal static IServiceCollection ConfigureRateLimit(this IServiceCollection services, IConfiguration config) + internal static IServiceCollection AddRateLimiting(this IServiceCollection services, IConfiguration config) { services.Configure(config.GetSection(nameof(RateLimitOptions))); @@ -41,11 +41,11 @@ internal static IServiceCollection ConfigureRateLimit(this IServiceCollection se return services; } - + internal static IApplicationBuilder UseRateLimit(this IApplicationBuilder app) { var options = app.ApplicationServices.GetRequiredService>().Value; - + if (options.EnableRateLimiting) { app.UseRateLimiter(); @@ -60,4 +60,4 @@ private static string BuildRateLimitResponseMessage(OnRejectedContext onRejected return $"You have reached the maximum number of requests allowed for the address ({hostName})."; } -} +} \ No newline at end of file diff --git a/src/api/framework/Infrastructure/RateLimit/RateLimitOptions.cs b/src/framework/Modules/Common/Modules.Common.Infrastructure/RateLimit/RateLimitOptions.cs similarity index 99% rename from src/api/framework/Infrastructure/RateLimit/RateLimitOptions.cs rename to src/framework/Modules/Common/Modules.Common.Infrastructure/RateLimit/RateLimitOptions.cs index 6fd364c5d1..ed1eb39bb6 100644 --- a/src/api/framework/Infrastructure/RateLimit/RateLimitOptions.cs +++ b/src/framework/Modules/Common/Modules.Common.Infrastructure/RateLimit/RateLimitOptions.cs @@ -6,4 +6,4 @@ public class RateLimitOptions public int PermitLimit { get; init; } public int WindowInSeconds { get; init; } public int RejectionStatusCode { get; init; } -} +} \ No newline at end of file diff --git a/src/api/framework/Infrastructure/SecurityHeaders/Extensions.cs b/src/framework/Modules/Common/Modules.Common.Infrastructure/SecurityHeaders/Extensions.cs similarity index 95% rename from src/api/framework/Infrastructure/SecurityHeaders/Extensions.cs rename to src/framework/Modules/Common/Modules.Common.Infrastructure/SecurityHeaders/Extensions.cs index 7d8ea168c7..d13ab44b9f 100644 --- a/src/api/framework/Infrastructure/SecurityHeaders/Extensions.cs +++ b/src/framework/Modules/Common/Modules.Common.Infrastructure/SecurityHeaders/Extensions.cs @@ -7,7 +7,7 @@ namespace FSH.Framework.Infrastructure.SecurityHeaders; public static class Extensions { - internal static IServiceCollection ConfigureSecurityHeaders(this IServiceCollection services, IConfiguration config) + internal static IServiceCollection AddSecurityHeaders(this IServiceCollection services, IConfiguration config) { services.Configure(config.GetSection(nameof(SecurityHeaderOptions))); @@ -66,4 +66,4 @@ internal static IApplicationBuilder UseSecurityHeaders(this IApplicationBuilder return app; } -} +} \ No newline at end of file diff --git a/src/api/framework/Infrastructure/SecurityHeaders/SecurityHeaderOptions.cs b/src/framework/Modules/Common/Modules.Common.Infrastructure/SecurityHeaders/SecurityHeaderOptions.cs similarity index 98% rename from src/api/framework/Infrastructure/SecurityHeaders/SecurityHeaderOptions.cs rename to src/framework/Modules/Common/Modules.Common.Infrastructure/SecurityHeaders/SecurityHeaderOptions.cs index 4fac61a596..d867242d35 100644 --- a/src/api/framework/Infrastructure/SecurityHeaders/SecurityHeaderOptions.cs +++ b/src/framework/Modules/Common/Modules.Common.Infrastructure/SecurityHeaders/SecurityHeaderOptions.cs @@ -4,4 +4,4 @@ public class SecurityHeaderOptions { public bool Enable { get; set; } public SecurityHeaders Headers { get; set; } = default!; -} +} \ No newline at end of file diff --git a/src/api/framework/Infrastructure/SecurityHeaders/SecurityHeaders.cs b/src/framework/Modules/Common/Modules.Common.Infrastructure/SecurityHeaders/SecurityHeaders.cs similarity index 99% rename from src/api/framework/Infrastructure/SecurityHeaders/SecurityHeaders.cs rename to src/framework/Modules/Common/Modules.Common.Infrastructure/SecurityHeaders/SecurityHeaders.cs index 596d99a175..24528a9451 100644 --- a/src/api/framework/Infrastructure/SecurityHeaders/SecurityHeaders.cs +++ b/src/framework/Modules/Common/Modules.Common.Infrastructure/SecurityHeaders/SecurityHeaders.cs @@ -9,4 +9,4 @@ public class SecurityHeaders public string? ContentSecurityPolicy { get; set; } public string? PermissionsPolicy { get; set; } public string? StrictTransportSecurity { get; set; } -} +} \ No newline at end of file diff --git a/src/framework/Modules/Common/Modules.Common.Infrastructure/Storage/LocalStorageService.cs b/src/framework/Modules/Common/Modules.Common.Infrastructure/Storage/LocalStorageService.cs new file mode 100644 index 0000000000..03520df837 --- /dev/null +++ b/src/framework/Modules/Common/Modules.Common.Infrastructure/Storage/LocalStorageService.cs @@ -0,0 +1,58 @@ +using FSH.Framework.Core.Storage; +using System.Text.RegularExpressions; + +namespace FSH.Framework.Infrastructure.Storage; + +public class LocalStorageService : IStorageService +{ + private const string RootPath = "wwwroot"; + private const string UploadBasePath = "uploads"; + + public async Task UploadAsync(FileUploadRequest request, FileType fileType, CancellationToken cancellationToken = default) + where T : class + { + var rules = FileTypeMetadata.GetRules(fileType); + var extension = Path.GetExtension(request.FileName); + + if (string.IsNullOrWhiteSpace(extension) || + !rules.AllowedExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase)) + { + throw new InvalidOperationException($"File type '{extension}' is not allowed. Allowed: {string.Join(", ", rules.AllowedExtensions)}"); + } + + if (request.Data.Count > rules.MaxSizeInMB * 1024 * 1024) + { + throw new InvalidOperationException($"File exceeds max size of {rules.MaxSizeInMB} MB."); + } + + var folder = Regex.Replace(typeof(T).Name.ToLowerInvariant(), @"[^a-z0-9]", "_"); + var safeFileName = $"{Guid.NewGuid():N}_{SanitizeFileName(request.FileName)}"; + var relativePath = Path.Combine(UploadBasePath, folder, safeFileName); + var fullPath = Path.Combine(RootPath, relativePath); + + Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); + + await File.WriteAllBytesAsync(fullPath, request.Data.ToArray(), cancellationToken); + + return relativePath.Replace("\\", "/"); // Normalize for URLs + } + + public Task RemoveAsync(string path, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(path)) return Task.CompletedTask; + + var fullPath = Path.Combine(RootPath, path); + + if (File.Exists(fullPath)) + { + File.Delete(fullPath); + } + + return Task.CompletedTask; + } + + private static string SanitizeFileName(string fileName) + { + return Regex.Replace(fileName, @"[^a-zA-Z0-9_\.-]", "_"); + } +} \ No newline at end of file diff --git a/src/framework/Modules/Common/Modules.Common.Infrastructure/Storage/StorageServiceRegistration.cs b/src/framework/Modules/Common/Modules.Common.Infrastructure/Storage/StorageServiceRegistration.cs new file mode 100644 index 0000000000..b80b2bcd59 --- /dev/null +++ b/src/framework/Modules/Common/Modules.Common.Infrastructure/Storage/StorageServiceRegistration.cs @@ -0,0 +1,13 @@ +using FSH.Framework.Core.Storage; +using Microsoft.Extensions.DependencyInjection; + +namespace FSH.Framework.Infrastructure.Storage; +public static class StorageServiceRegistration +{ + public static IServiceCollection AddLocalFileStorage(this IServiceCollection services) + { + // You can later use config["Storage:Provider"] to swap between implementations + services.AddScoped(); + return services; + } +} \ No newline at end of file diff --git a/src/framework/Modules/Common/Modules.Common.Shared/Authorization/CustomClaims.cs b/src/framework/Modules/Common/Modules.Common.Shared/Authorization/CustomClaims.cs new file mode 100644 index 0000000000..2b3bee67f8 --- /dev/null +++ b/src/framework/Modules/Common/Modules.Common.Shared/Authorization/CustomClaims.cs @@ -0,0 +1,10 @@ +namespace FSH.Framework.Shared.Authorization; +public static class CustomClaims +{ + public const string Tenant = "tenant"; + public const string Fullname = "fullName"; + public const string Permission = "permission"; + public const string ImageUrl = "image_url"; + public const string IpAddress = "ipAddress"; + public const string Expiration = "exp"; +} \ No newline at end of file diff --git a/src/api/framework/Infrastructure/Auth/Policy/EndpointExtensions.cs b/src/framework/Modules/Common/Modules.Common.Shared/Authorization/EndpointExtensions.cs similarity index 89% rename from src/api/framework/Infrastructure/Auth/Policy/EndpointExtensions.cs rename to src/framework/Modules/Common/Modules.Common.Shared/Authorization/EndpointExtensions.cs index 7cd6ea7e1e..4e7dc2679e 100644 --- a/src/api/framework/Infrastructure/Auth/Policy/EndpointExtensions.cs +++ b/src/framework/Modules/Common/Modules.Common.Shared/Authorization/EndpointExtensions.cs @@ -1,6 +1,6 @@ using Microsoft.AspNetCore.Builder; -namespace FSH.Framework.Infrastructure.Auth.Policy; +namespace FSH.Framework.Shared.Authorization; public static class EndpointExtensions { public static TBuilder RequirePermission( @@ -9,4 +9,4 @@ public static TBuilder RequirePermission( { return endpointConventionBuilder.WithMetadata(new RequiredPermissionAttribute(requiredPermission, additionalRequiredPermissions)); } -} +} \ No newline at end of file diff --git a/src/api/framework/Infrastructure/Auth/Policy/RequiredPermissionAttribute.cs b/src/framework/Modules/Common/Modules.Common.Shared/Authorization/RequiredPermissionAttribute.cs similarity index 90% rename from src/api/framework/Infrastructure/Auth/Policy/RequiredPermissionAttribute.cs rename to src/framework/Modules/Common/Modules.Common.Shared/Authorization/RequiredPermissionAttribute.cs index cf7ea3d91e..4995537fa8 100644 --- a/src/api/framework/Infrastructure/Auth/Policy/RequiredPermissionAttribute.cs +++ b/src/framework/Modules/Common/Modules.Common.Shared/Authorization/RequiredPermissionAttribute.cs @@ -1,4 +1,4 @@ -namespace FSH.Framework.Infrastructure.Auth.Policy; +namespace FSH.Framework.Shared.Authorization; public interface IRequiredPermissionMetadata { HashSet RequiredPermissions { get; } @@ -11,4 +11,4 @@ public sealed class RequiredPermissionAttribute(string? requiredPermission, para public HashSet RequiredPermissions { get; } = [requiredPermission!, .. additionalRequiredPermissions]; public string? RequiredPermission { get; } public string[]? AdditionalRequiredPermissions { get; } -} +} \ No newline at end of file diff --git a/src/framework/Modules/Common/Modules.Common.Shared/Constants/AuthenticationConstants.cs b/src/framework/Modules/Common/Modules.Common.Shared/Constants/AuthenticationConstants.cs new file mode 100644 index 0000000000..5c20f76043 --- /dev/null +++ b/src/framework/Modules/Common/Modules.Common.Shared/Constants/AuthenticationConstants.cs @@ -0,0 +1,5 @@ +namespace FSH.Framework.Shared.Constants; +public static class AuthenticationConstants +{ + public const string AuthenticationScheme = "Bearer"; +} \ No newline at end of file diff --git a/src/framework/Modules/Common/Modules.Common.Shared/Constants/FshActions.cs b/src/framework/Modules/Common/Modules.Common.Shared/Constants/FshActions.cs new file mode 100644 index 0000000000..18a6426e0b --- /dev/null +++ b/src/framework/Modules/Common/Modules.Common.Shared/Constants/FshActions.cs @@ -0,0 +1,13 @@ +namespace FSH.Framework.Shared.Constants; +public static class FshActions +{ + public const string View = nameof(View); + public const string Search = nameof(Search); + public const string Create = nameof(Create); + public const string Update = nameof(Update); + public const string Delete = nameof(Delete); + public const string Export = nameof(Export); + public const string Generate = nameof(Generate); + public const string Clean = nameof(Clean); + public const string UpgradeSubscription = nameof(UpgradeSubscription); +} \ No newline at end of file diff --git a/src/framework/Modules/Common/Modules.Common.Shared/Constants/FshClaims.cs b/src/framework/Modules/Common/Modules.Common.Shared/Constants/FshClaims.cs new file mode 100644 index 0000000000..de21ecb254 --- /dev/null +++ b/src/framework/Modules/Common/Modules.Common.Shared/Constants/FshClaims.cs @@ -0,0 +1,10 @@ +namespace FSH.Framework.Shared.Constants; +public static class FshClaims +{ + public const string Tenant = "tenant"; + public const string Fullname = "fullName"; + public const string Permission = "permission"; + public const string ImageUrl = "image_url"; + public const string IpAddress = "ipAddress"; + public const string Expiration = "exp"; +} \ No newline at end of file diff --git a/src/framework/Modules/Common/Modules.Common.Shared/Constants/FshPermissions.cs b/src/framework/Modules/Common/Modules.Common.Shared/Constants/FshPermissions.cs new file mode 100644 index 0000000000..c00f3d9228 --- /dev/null +++ b/src/framework/Modules/Common/Modules.Common.Shared/Constants/FshPermissions.cs @@ -0,0 +1,64 @@ +namespace FSH.Framework.Shared.Constants; + +public static class FshPermissions +{ + private static readonly List _all = new() + { + // Built-in permissions + + // Tenants + new("View Tenants", FshActions.View, FshResources.Tenants, IsRoot: true), + new("Create Tenants", FshActions.Create, FshResources.Tenants, IsRoot: true), + new("Update Tenants", FshActions.Update, FshResources.Tenants, IsRoot: true), + new("Upgrade Tenant Subscription", FshActions.UpgradeSubscription, FshResources.Tenants, IsRoot: true), + + // Identity + new("View Users", FshActions.View, FshResources.Users), + new("Search Users", FshActions.Search, FshResources.Users), + new("Create Users", FshActions.Create, FshResources.Users), + new("Update Users", FshActions.Update, FshResources.Users), + new("Delete Users", FshActions.Delete, FshResources.Users), + new("Export Users", FshActions.Export, FshResources.Users), + new("View UserRoles", FshActions.View, FshResources.UserRoles), + new("Update UserRoles", FshActions.Update, FshResources.UserRoles), + new("View Roles", FshActions.View, FshResources.Roles), + new("Create Roles", FshActions.Create, FshResources.Roles), + new("Update Roles", FshActions.Update, FshResources.Roles), + new("Delete Roles", FshActions.Delete, FshResources.Roles), + new("View RoleClaims", FshActions.View, FshResources.RoleClaims), + new("Update RoleClaims", FshActions.Update, FshResources.RoleClaims), + + // Audit + new("View Audit Trails", FshActions.View, FshResources.AuditTrails), + + // Hangfire / Dashboard + new("View Hangfire", FshActions.View, FshResources.Hangfire), + new("View Dashboard", FshActions.View, FshResources.Dashboard), + }; + + /// + /// Register additional permissions from external projects/modules. + /// + public static void Register(IEnumerable additionalPermissions) + { + foreach (var permission in additionalPermissions) + { + if (!_all.Any(p => p.Name == permission.Name)) + _all.Add(permission); + } + } + + public static IReadOnlyList All => _all.AsReadOnly(); + public static IReadOnlyList Root => _all.Where(p => p.IsRoot).ToList(); + public static IReadOnlyList Admin => _all.Where(p => !p.IsRoot).ToList(); + public static IReadOnlyList Basic => _all.Where(p => p.IsBasic).ToList(); +} + +public record FshPermission(string Description, string Action, string Resource, bool IsBasic = false, bool IsRoot = false) +{ + public string Name => NameFor(Action, Resource); + public static string NameFor(string action, string resource) + { + return $"Permissions.{resource}.{action}"; + } +} \ No newline at end of file diff --git a/src/framework/Modules/Common/Modules.Common.Shared/Constants/FshResources.cs b/src/framework/Modules/Common/Modules.Common.Shared/Constants/FshResources.cs new file mode 100644 index 0000000000..ac8afa3acb --- /dev/null +++ b/src/framework/Modules/Common/Modules.Common.Shared/Constants/FshResources.cs @@ -0,0 +1,12 @@ +namespace FSH.Framework.Shared.Constants; +public static class FshResources +{ + public const string Tenants = nameof(Tenants); + public const string Dashboard = nameof(Dashboard); + public const string Hangfire = nameof(Hangfire); + public const string Users = nameof(Users); + public const string UserRoles = nameof(UserRoles); + public const string Roles = nameof(Roles); + public const string RoleClaims = nameof(RoleClaims); + public const string AuditTrails = nameof(AuditTrails); +} \ No newline at end of file diff --git a/src/framework/Modules/Common/Modules.Common.Shared/Constants/FshRoles.cs b/src/framework/Modules/Common/Modules.Common.Shared/Constants/FshRoles.cs new file mode 100644 index 0000000000..c32a857d81 --- /dev/null +++ b/src/framework/Modules/Common/Modules.Common.Shared/Constants/FshRoles.cs @@ -0,0 +1,23 @@ +using System.Collections.ObjectModel; + +namespace FSH.Framework.Shared.Constants; + +public static class FshRoles +{ + public const string Admin = nameof(Admin); + public const string Basic = nameof(Basic); + + /// + /// The base roles provided by the framework. + /// + public static IReadOnlyList DefaultRoles { get; } = new ReadOnlyCollection(new[] + { + Admin, + Basic + }); + + /// + /// Determines whether the role is a framework-defined default. + /// + public static bool IsDefault(string roleName) => DefaultRoles.Contains(roleName); +} \ No newline at end of file diff --git a/src/framework/Modules/Common/Modules.Common.Shared/Constants/MutiTenancyConstants.cs b/src/framework/Modules/Common/Modules.Common.Shared/Constants/MutiTenancyConstants.cs new file mode 100644 index 0000000000..37a17379cb --- /dev/null +++ b/src/framework/Modules/Common/Modules.Common.Shared/Constants/MutiTenancyConstants.cs @@ -0,0 +1,16 @@ +namespace FSH.Modules.Common.Shared.Constants; +public static class MutiTenancyConstants +{ + public static class Root + { + public const string Id = "root"; + public const string Name = "Root"; + public const string EmailAddress = "admin@root.com"; + public const string DefaultProfilePicture = "assets/defaults/profile-picture.webp"; + } + + public const string DefaultPassword = "123Pa$$word!"; + + public const string Identifier = "tenant"; + +} \ No newline at end of file diff --git a/src/framework/Modules/Common/Modules.Common.Shared/Extensions/ClaimsPrincipalExtensions.cs b/src/framework/Modules/Common/Modules.Common.Shared/Extensions/ClaimsPrincipalExtensions.cs new file mode 100644 index 0000000000..27344cbbbd --- /dev/null +++ b/src/framework/Modules/Common/Modules.Common.Shared/Extensions/ClaimsPrincipalExtensions.cs @@ -0,0 +1,55 @@ +using FSH.Framework.Shared.Authorization; +using System.Security.Claims; + +namespace FSH.Framework.Shared.Extensions; + +public static class ClaimsPrincipalExtensions +{ + // Retrieves the email claim + public static string? GetEmail(this ClaimsPrincipal principal) => + principal?.FindFirstValue(ClaimTypes.Email); + + // Retrieves the tenant claim + public static string? GetTenant(this ClaimsPrincipal principal) => + principal?.FindFirstValue(CustomClaims.Tenant); + + // Retrieves the user's full name + public static string? GetFullName(this ClaimsPrincipal principal) => + principal?.FindFirstValue(CustomClaims.Fullname); + + // Retrieves the user's first name + public static string? GetFirstName(this ClaimsPrincipal principal) => + principal?.FindFirstValue(ClaimTypes.Name); + + // Retrieves the user's surname + public static string? GetSurname(this ClaimsPrincipal principal) => + principal?.FindFirstValue(ClaimTypes.Surname); + + // Retrieves the user's phone number + public static string? GetPhoneNumber(this ClaimsPrincipal principal) => + principal?.FindFirstValue(ClaimTypes.MobilePhone); + + // Retrieves the user's ID + public static string? GetUserId(this ClaimsPrincipal principal) => + principal?.FindFirstValue(ClaimTypes.NameIdentifier); + + // Retrieves the user's image URL as Uri + public static Uri? GetImageUrl(this ClaimsPrincipal principal) + { + var imageUrl = principal?.FindFirstValue(CustomClaims.ImageUrl); + return Uri.TryCreate(imageUrl, UriKind.Absolute, out var uri) ? uri : null; + } + + // Retrieves the user's token expiration date + public static DateTimeOffset GetExpiration(this ClaimsPrincipal principal) + { + var expiration = principal?.FindFirstValue(CustomClaims.Expiration); + return expiration != null + ? DateTimeOffset.FromUnixTimeSeconds(Convert.ToInt64(expiration)) + : throw new InvalidOperationException("Expiration claim not found."); + } + + // Helper method to extract claim value + private static string? FindFirstValue(this ClaimsPrincipal principal, string claimType) => + principal?.FindFirst(claimType)?.Value; +} \ No newline at end of file diff --git a/src/api/framework/Infrastructure/Identity/Tokens/Endpoints/Extensions.cs b/src/framework/Modules/Common/Modules.Common.Shared/Extensions/HttpContextExtensions.cs similarity index 58% rename from src/api/framework/Infrastructure/Identity/Tokens/Endpoints/Extensions.cs rename to src/framework/Modules/Common/Modules.Common.Shared/Extensions/HttpContextExtensions.cs index 3bd70a42c4..783827c01b 100644 --- a/src/api/framework/Infrastructure/Identity/Tokens/Endpoints/Extensions.cs +++ b/src/framework/Modules/Common/Modules.Common.Shared/Extensions/HttpContextExtensions.cs @@ -1,16 +1,9 @@ using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; -namespace FSH.Framework.Infrastructure.Identity.Tokens.Endpoints; -internal static class Extensions -{ - public static IEndpointRouteBuilder MapTokenEndpoints(this IEndpointRouteBuilder app) - { - app.MapRefreshTokenEndpoint(); - app.MapTokenGenerationEndpoint(); - return app; - } +namespace FSH.Framework.Shared.Extensions; +public static class HttpContextExtensions +{ public static string GetIpAddress(this HttpContext context) { string ip = "N/A"; @@ -23,6 +16,5 @@ public static string GetIpAddress(this HttpContext context) ip = context.Connection.RemoteIpAddress.MapToIPv4().ToString(); } return ip; - } -} +} \ No newline at end of file diff --git a/src/framework/Modules/Common/Modules.Common.Shared/Modules.Common.Shared.csproj b/src/framework/Modules/Common/Modules.Common.Shared/Modules.Common.Shared.csproj new file mode 100644 index 0000000000..48e1551e0b --- /dev/null +++ b/src/framework/Modules/Common/Modules.Common.Shared/Modules.Common.Shared.csproj @@ -0,0 +1,12 @@ + + + FSH.Modules.Common.Shared + FSH.Modules.Common.Shared + + + + + + + + diff --git a/src/api/framework/Infrastructure/Tenant/FshTenantInfo.cs b/src/framework/Modules/Common/Modules.Common.Shared/Multitenancy/FshTenantInfo.cs similarity index 84% rename from src/api/framework/Infrastructure/Tenant/FshTenantInfo.cs rename to src/framework/Modules/Common/Modules.Common.Shared/Multitenancy/FshTenantInfo.cs index 7be7d62d3e..3698ab9b43 100644 --- a/src/api/framework/Infrastructure/Tenant/FshTenantInfo.cs +++ b/src/framework/Modules/Common/Modules.Common.Shared/Multitenancy/FshTenantInfo.cs @@ -1,10 +1,8 @@ using Finbuckle.MultiTenant.Abstractions; -using FSH.Framework.Core.Exceptions; -using FSH.Framework.Infrastructure.Tenant.Abstractions; -using FSH.Starter.Shared.Authorization; +using FSH.Modules.Common.Shared.Constants; -namespace FSH.Framework.Infrastructure.Tenant; -public sealed class FshTenantInfo : IFshTenantInfo +namespace FSH.Framework.Shared.Multitenancy; +public class FshTenantInfo : ITenantInfo, IFshTenantInfo { public FshTenantInfo() { @@ -40,11 +38,11 @@ public void AddValidity(int months) => public void SetValidity(in DateTime validTill) => ValidUpto = ValidUpto < validTill ? validTill - : throw new FshException("Subscription cannot be backdated."); + : throw new InvalidOperationException("Subscription cannot be backdated."); public void Activate() { - if (Id == TenantConstants.Root.Id) + if (Id == MutiTenancyConstants.Root.Id) { throw new InvalidOperationException("Invalid Tenant"); } @@ -54,7 +52,7 @@ public void Activate() public void Deactivate() { - if (Id == TenantConstants.Root.Id) + if (Id == MutiTenancyConstants.Root.Id) { throw new InvalidOperationException("Invalid Tenant"); } @@ -65,4 +63,4 @@ public void Deactivate() string? ITenantInfo.Identifier { get => Identifier; set => Identifier = value ?? throw new InvalidOperationException("Identifier can't be null."); } string? ITenantInfo.Name { get => Name; set => Name = value ?? throw new InvalidOperationException("Name can't be null."); } string? IFshTenantInfo.ConnectionString { get => ConnectionString; set => ConnectionString = value ?? throw new InvalidOperationException("ConnectionString can't be null."); } -} +} \ No newline at end of file diff --git a/src/framework/Modules/Common/Modules.Common.Shared/Multitenancy/IFshTenantInfo.cs b/src/framework/Modules/Common/Modules.Common.Shared/Multitenancy/IFshTenantInfo.cs new file mode 100644 index 0000000000..2242ae435a --- /dev/null +++ b/src/framework/Modules/Common/Modules.Common.Shared/Multitenancy/IFshTenantInfo.cs @@ -0,0 +1,5 @@ +namespace FSH.Framework.Shared.Multitenancy; +public interface IFshTenantInfo +{ + string? ConnectionString { get; set; } +} \ No newline at end of file diff --git a/src/framework/Modules/Common/Modules.Common.Shared/Multitenancy/MultiTenancyConstants.cs b/src/framework/Modules/Common/Modules.Common.Shared/Multitenancy/MultiTenancyConstants.cs new file mode 100644 index 0000000000..f130aa0500 --- /dev/null +++ b/src/framework/Modules/Common/Modules.Common.Shared/Multitenancy/MultiTenancyConstants.cs @@ -0,0 +1,15 @@ +namespace FSH.Framework.Shared.Multitenancy; +public static class MultiTenancyConstants +{ + public static class Root + { + public const string Id = "root"; + public const string Name = "Root"; + public const string EmailAddress = "admin@root.com"; + public const string DefaultProfilePicture = "assets/defaults/profile-picture.webp"; + } + + public const string DefaultPassword = "123Pa$$word!"; + + public const string Identifier = "tenant"; +} \ No newline at end of file diff --git a/src/api/framework/Core/Identity/Roles/RoleDto.cs b/src/framework/Modules/Identity/Modules.Identity.Contracts/Dtos/RoleDto.cs similarity index 59% rename from src/api/framework/Core/Identity/Roles/RoleDto.cs rename to src/framework/Modules/Identity/Modules.Identity.Contracts/Dtos/RoleDto.cs index 0a0fc7559b..e56ad41905 100644 --- a/src/api/framework/Core/Identity/Roles/RoleDto.cs +++ b/src/framework/Modules/Identity/Modules.Identity.Contracts/Dtos/RoleDto.cs @@ -1,9 +1,9 @@ -namespace FSH.Framework.Core.Identity.Roles; +namespace FSH.Framework.Identity.Core.Roles; public class RoleDto { public string Id { get; set; } = default!; public string Name { get; set; } = default!; public string? Description { get; set; } - public List? Permissions { get; set; } -} + public IReadOnlyCollection? Permissions { get; set; } +} \ No newline at end of file diff --git a/src/framework/Modules/Identity/Modules.Identity.Contracts/Dtos/TokenDto.cs b/src/framework/Modules/Identity/Modules.Identity.Contracts/Dtos/TokenDto.cs new file mode 100644 index 0000000000..e66d6eeb5f --- /dev/null +++ b/src/framework/Modules/Identity/Modules.Identity.Contracts/Dtos/TokenDto.cs @@ -0,0 +1,2 @@ +namespace FSH.Framework.Identity.Core.Tokens; +public record TokenDto(string Token, string RefreshToken, DateTime RefreshTokenExpiryTime); \ No newline at end of file diff --git a/src/api/framework/Core/Identity/Users/Dtos/UserDetail.cs b/src/framework/Modules/Identity/Modules.Identity.Contracts/Dtos/UserDto.cs similarity index 67% rename from src/api/framework/Core/Identity/Users/Dtos/UserDetail.cs rename to src/framework/Modules/Identity/Modules.Identity.Contracts/Dtos/UserDto.cs index 23941ad86c..dbf517c367 100644 --- a/src/api/framework/Core/Identity/Users/Dtos/UserDetail.cs +++ b/src/framework/Modules/Identity/Modules.Identity.Contracts/Dtos/UserDto.cs @@ -1,7 +1,7 @@ -namespace FSH.Framework.Core.Identity.Users.Dtos; -public class UserDetail +namespace FSH.Framework.Identity.Core.Users; +public class UserDto { - public Guid Id { get; set; } + public string? Id { get; set; } public string? UserName { get; set; } @@ -17,5 +17,5 @@ public class UserDetail public string? PhoneNumber { get; set; } - public Uri? ImageUrl { get; set; } -} + public string? ImageUrl { get; set; } +} \ No newline at end of file diff --git a/src/api/framework/Core/Identity/Users/Dtos/UserRoleDetail.cs b/src/framework/Modules/Identity/Modules.Identity.Contracts/Dtos/UserRoleDto.cs similarity index 66% rename from src/api/framework/Core/Identity/Users/Dtos/UserRoleDetail.cs rename to src/framework/Modules/Identity/Modules.Identity.Contracts/Dtos/UserRoleDto.cs index 935fd79b6e..3f045c8b0f 100644 --- a/src/api/framework/Core/Identity/Users/Dtos/UserRoleDetail.cs +++ b/src/framework/Modules/Identity/Modules.Identity.Contracts/Dtos/UserRoleDto.cs @@ -1,8 +1,8 @@ -namespace FSH.Framework.Core.Identity.Users.Dtos; -public class UserRoleDetail +namespace FSH.Framework.Identity.Core.Roles; +public class UserRoleDto { public string? RoleId { get; set; } public string? RoleName { get; set; } public string? Description { get; set; } public bool Enabled { get; set; } -} +} \ No newline at end of file diff --git a/src/framework/Modules/Identity/Modules.Identity.Contracts/IdentityModuleConstants.cs b/src/framework/Modules/Identity/Modules.Identity.Contracts/IdentityModuleConstants.cs new file mode 100644 index 0000000000..836897b298 --- /dev/null +++ b/src/framework/Modules/Identity/Modules.Identity.Contracts/IdentityModuleConstants.cs @@ -0,0 +1,6 @@ +namespace FSH.Framework.Modules.Identity.Contracts; +public static class IdentityModuleConstants +{ + public const string SchemaName = "identity"; + public const int PasswordLength = 10; +} \ No newline at end of file diff --git a/src/framework/Modules/Identity/Modules.Identity.Contracts/Modules.Identity.Contracts.csproj b/src/framework/Modules/Identity/Modules.Identity.Contracts/Modules.Identity.Contracts.csproj new file mode 100644 index 0000000000..968bf187eb --- /dev/null +++ b/src/framework/Modules/Identity/Modules.Identity.Contracts/Modules.Identity.Contracts.csproj @@ -0,0 +1,15 @@ + + + FSH.Modules.Identity.Contracts + FSH.Modules.Identity.Contracts + + + net9.0 + enable + enable + + + + + + diff --git a/src/framework/Modules/Identity/Modules.Identity.Contracts/v1/Roles/UpdatePermissions/UpdatePermissionsCommand.cs b/src/framework/Modules/Identity/Modules.Identity.Contracts/v1/Roles/UpdatePermissions/UpdatePermissionsCommand.cs new file mode 100644 index 0000000000..54293a3429 --- /dev/null +++ b/src/framework/Modules/Identity/Modules.Identity.Contracts/v1/Roles/UpdatePermissions/UpdatePermissionsCommand.cs @@ -0,0 +1,14 @@ +namespace FSH.Framework.Modules.Identity.Contracts.v1.Roles.UpdatePermissions; + +public class UpdatePermissionsCommand +{ + /// + /// The ID of the role to update. + /// + public string RoleId { get; init; } = default!; + + /// + /// The list of permissions to assign to the role. + /// + public List Permissions { get; init; } = []; +} \ No newline at end of file diff --git a/src/api/framework/Core/Identity/Roles/Features/CreateOrUpdateRole/CreateOrUpdateRoleCommand.cs b/src/framework/Modules/Identity/Modules.Identity.Contracts/v1/Roles/UpsertRole/UpsertRoleCommand.cs similarity index 55% rename from src/api/framework/Core/Identity/Roles/Features/CreateOrUpdateRole/CreateOrUpdateRoleCommand.cs rename to src/framework/Modules/Identity/Modules.Identity.Contracts/v1/Roles/UpsertRole/UpsertRoleCommand.cs index 5774c40ae9..40ea408789 100644 --- a/src/api/framework/Core/Identity/Roles/Features/CreateOrUpdateRole/CreateOrUpdateRoleCommand.cs +++ b/src/framework/Modules/Identity/Modules.Identity.Contracts/v1/Roles/UpsertRole/UpsertRoleCommand.cs @@ -1,8 +1,8 @@ -namespace FSH.Framework.Core.Identity.Roles.Features.CreateOrUpdateRole; +namespace FSH.Framework.Identity.Endpoints.v1.Roles.CreateOrUpdateRole; -public class CreateOrUpdateRoleCommand +public class UpsertRoleCommand { public string Id { get; set; } = default!; public string Name { get; set; } = default!; public string? Description { get; set; } -} +} \ No newline at end of file diff --git a/src/framework/Modules/Identity/Modules.Identity.Contracts/v1/Tokens/RefreshToken/RefreshTokenCommand.cs b/src/framework/Modules/Identity/Modules.Identity.Contracts/v1/Tokens/RefreshToken/RefreshTokenCommand.cs new file mode 100644 index 0000000000..ded9217e7b --- /dev/null +++ b/src/framework/Modules/Identity/Modules.Identity.Contracts/v1/Tokens/RefreshToken/RefreshTokenCommand.cs @@ -0,0 +1,6 @@ +using FSH.Framework.Identity.Contracts.v1.Tokens.RefreshToken; +using FSH.Modules.Common.Core.Messaging.CQRS; + +namespace FSH.Framework.Identity.Core.Tokens; +public record RefreshTokenCommand(string Token, string RefreshToken) + : ICommand; \ No newline at end of file diff --git a/src/framework/Modules/Identity/Modules.Identity.Contracts/v1/Tokens/RefreshToken/RefreshTokenCommandResponse.cs b/src/framework/Modules/Identity/Modules.Identity.Contracts/v1/Tokens/RefreshToken/RefreshTokenCommandResponse.cs new file mode 100644 index 0000000000..a1c961f43f --- /dev/null +++ b/src/framework/Modules/Identity/Modules.Identity.Contracts/v1/Tokens/RefreshToken/RefreshTokenCommandResponse.cs @@ -0,0 +1,5 @@ +namespace FSH.Framework.Identity.Contracts.v1.Tokens.RefreshToken; +public sealed record RefreshTokenCommandResponse( + string Token, + string RefreshToken, + DateTime RefreshTokenExpiryTime); \ No newline at end of file diff --git a/src/framework/Modules/Identity/Modules.Identity.Contracts/v1/Tokens/TokenGeneration/TokenGenerationCommand.cs b/src/framework/Modules/Identity/Modules.Identity.Contracts/v1/Tokens/TokenGeneration/TokenGenerationCommand.cs new file mode 100644 index 0000000000..3d6811cdc2 --- /dev/null +++ b/src/framework/Modules/Identity/Modules.Identity.Contracts/v1/Tokens/TokenGeneration/TokenGenerationCommand.cs @@ -0,0 +1,7 @@ +using FSH.Modules.Common.Core.Messaging.CQRS; + +namespace FSH.Framework.Identity.Contracts.v1.Tokens.TokenGeneration; +public record TokenGenerationCommand( + string Email, + string Password) + : ICommand; \ No newline at end of file diff --git a/src/framework/Modules/Identity/Modules.Identity.Contracts/v1/Tokens/TokenGeneration/TokenGenerationCommandResponse.cs b/src/framework/Modules/Identity/Modules.Identity.Contracts/v1/Tokens/TokenGeneration/TokenGenerationCommandResponse.cs new file mode 100644 index 0000000000..3dccebb7b0 --- /dev/null +++ b/src/framework/Modules/Identity/Modules.Identity.Contracts/v1/Tokens/TokenGeneration/TokenGenerationCommandResponse.cs @@ -0,0 +1,5 @@ +namespace FSH.Framework.Identity.Contracts.v1.Tokens.TokenGeneration; +public sealed record TokenGenerationCommandResponse( + string Token, + string RefreshToken, + DateTime RefreshTokenExpiryTime); \ No newline at end of file diff --git a/src/framework/Modules/Identity/Modules.Identity.Contracts/v1/Users/AssignUserRoles/AssignUserRolesCommand.cs b/src/framework/Modules/Identity/Modules.Identity.Contracts/v1/Users/AssignUserRoles/AssignUserRolesCommand.cs new file mode 100644 index 0000000000..7501df19b8 --- /dev/null +++ b/src/framework/Modules/Identity/Modules.Identity.Contracts/v1/Users/AssignUserRoles/AssignUserRolesCommand.cs @@ -0,0 +1,9 @@ +using FSH.Framework.Identity.Core.Roles; +using FSH.Modules.Common.Core.Messaging.CQRS; + +namespace FSH.Framework.Identity.Contracts.v1.Users.AssignUserRoles; +public sealed class AssignUserRolesCommand : ICommand +{ + public required string UserId { get; init; } + public List UserRoles { get; init; } = new(); +} \ No newline at end of file diff --git a/src/framework/Modules/Identity/Modules.Identity.Contracts/v1/Users/AssignUserRoles/AssignUserRolesCommandResponse.cs b/src/framework/Modules/Identity/Modules.Identity.Contracts/v1/Users/AssignUserRoles/AssignUserRolesCommandResponse.cs new file mode 100644 index 0000000000..5489ea1efc --- /dev/null +++ b/src/framework/Modules/Identity/Modules.Identity.Contracts/v1/Users/AssignUserRoles/AssignUserRolesCommandResponse.cs @@ -0,0 +1,2 @@ +namespace FSH.Framework.Identity.Contracts.v1.Users.AssignUserRoles; +public sealed record AssignUserRolesCommandResponse(string Result); \ No newline at end of file diff --git a/src/framework/Modules/Identity/Modules.Identity.Contracts/v1/Users/ChangePassword/ChangePasswordCommand.cs b/src/framework/Modules/Identity/Modules.Identity.Contracts/v1/Users/ChangePassword/ChangePasswordCommand.cs new file mode 100644 index 0000000000..e7b8a2cf64 --- /dev/null +++ b/src/framework/Modules/Identity/Modules.Identity.Contracts/v1/Users/ChangePassword/ChangePasswordCommand.cs @@ -0,0 +1,15 @@ +using FSH.Modules.Common.Core.Messaging.CQRS; + +namespace FSH.Framework.Identity.Contracts.v1.Users.ChangePassword; + +public class ChangePasswordCommand : ICommand +{ + /// The user's current password. + public string Password { get; init; } = default!; + + /// The new password the user wants to set. + public string NewPassword { get; init; } = default!; + + /// Confirmation of the new password. + public string ConfirmNewPassword { get; init; } = default!; +} \ No newline at end of file diff --git a/src/api/framework/Core/Identity/Users/Features/ForgotPassword/ForgotPasswordCommand.cs b/src/framework/Modules/Identity/Modules.Identity.Contracts/v1/Users/ForgotPassword/ForgotPasswordCommand.cs similarity index 54% rename from src/api/framework/Core/Identity/Users/Features/ForgotPassword/ForgotPasswordCommand.cs rename to src/framework/Modules/Identity/Modules.Identity.Contracts/v1/Users/ForgotPassword/ForgotPasswordCommand.cs index 5419a554fb..af8254f370 100644 --- a/src/api/framework/Core/Identity/Users/Features/ForgotPassword/ForgotPasswordCommand.cs +++ b/src/framework/Modules/Identity/Modules.Identity.Contracts/v1/Users/ForgotPassword/ForgotPasswordCommand.cs @@ -1,5 +1,5 @@ -namespace FSH.Framework.Core.Identity.Users.Features.ForgotPassword; +namespace FSH.Framework.Identity.Endpoints.v1.Users.ForgotPassword; public class ForgotPasswordCommand { public string Email { get; set; } = default!; -} +} \ No newline at end of file diff --git a/src/api/framework/Core/Identity/Users/Features/RegisterUser/RegisterUserCommand.cs b/src/framework/Modules/Identity/Modules.Identity.Contracts/v1/Users/RegisterUser/RegisterUserCommand.cs similarity index 66% rename from src/api/framework/Core/Identity/Users/Features/RegisterUser/RegisterUserCommand.cs rename to src/framework/Modules/Identity/Modules.Identity.Contracts/v1/Users/RegisterUser/RegisterUserCommand.cs index 34089d0470..4c412d0e22 100644 --- a/src/api/framework/Core/Identity/Users/Features/RegisterUser/RegisterUserCommand.cs +++ b/src/framework/Modules/Identity/Modules.Identity.Contracts/v1/Users/RegisterUser/RegisterUserCommand.cs @@ -1,8 +1,8 @@ -using System.Text.Json.Serialization; -using MediatR; +using FSH.Modules.Common.Core.Messaging.CQRS; +using System.Text.Json.Serialization; -namespace FSH.Framework.Core.Identity.Users.Features.RegisterUser; -public class RegisterUserCommand : IRequest +namespace FSH.Framework.Identity.Endpoints.v1.Users.RegisterUser; +public class RegisterUserCommand : ICommand { public string FirstName { get; set; } = default!; public string LastName { get; set; } = default!; @@ -14,4 +14,4 @@ public class RegisterUserCommand : IRequest [JsonIgnore] public string? Origin { get; set; } -} +} \ No newline at end of file diff --git a/src/framework/Modules/Identity/Modules.Identity.Contracts/v1/Users/RegisterUser/RegisterUserResponse.cs b/src/framework/Modules/Identity/Modules.Identity.Contracts/v1/Users/RegisterUser/RegisterUserResponse.cs new file mode 100644 index 0000000000..2631cca8c8 --- /dev/null +++ b/src/framework/Modules/Identity/Modules.Identity.Contracts/v1/Users/RegisterUser/RegisterUserResponse.cs @@ -0,0 +1,2 @@ +namespace FSH.Framework.Identity.Endpoints.v1.Users.RegisterUser; +public record RegisterUserResponse(string UserId); \ No newline at end of file diff --git a/src/api/framework/Core/Identity/Users/Features/ResetPassword/ResetPasswordCommand.cs b/src/framework/Modules/Identity/Modules.Identity.Contracts/v1/Users/ResetPassword/ResetPasswordCommand.cs similarity index 72% rename from src/api/framework/Core/Identity/Users/Features/ResetPassword/ResetPasswordCommand.cs rename to src/framework/Modules/Identity/Modules.Identity.Contracts/v1/Users/ResetPassword/ResetPasswordCommand.cs index 244aff2e93..080ecbb76e 100644 --- a/src/api/framework/Core/Identity/Users/Features/ResetPassword/ResetPasswordCommand.cs +++ b/src/framework/Modules/Identity/Modules.Identity.Contracts/v1/Users/ResetPassword/ResetPasswordCommand.cs @@ -1,4 +1,4 @@ -namespace FSH.Framework.Core.Identity.Users.Features.ResetPassword; +namespace FSH.Framework.Identity.Endpoints.v1.Users.ResetPassword; public class ResetPasswordCommand { public string Email { get; set; } = default!; @@ -6,4 +6,4 @@ public class ResetPasswordCommand public string Password { get; set; } = default!; public string Token { get; set; } = default!; -} +} \ No newline at end of file diff --git a/src/api/framework/Core/Identity/Users/Features/ToggleUserStatus/ToggleUserStatusCommand.cs b/src/framework/Modules/Identity/Modules.Identity.Contracts/v1/Users/ToggleUserStatus/ToggleUserStatusCommand.cs similarity index 61% rename from src/api/framework/Core/Identity/Users/Features/ToggleUserStatus/ToggleUserStatusCommand.cs rename to src/framework/Modules/Identity/Modules.Identity.Contracts/v1/Users/ToggleUserStatus/ToggleUserStatusCommand.cs index 8b3697293e..89bca2fc73 100644 --- a/src/api/framework/Core/Identity/Users/Features/ToggleUserStatus/ToggleUserStatusCommand.cs +++ b/src/framework/Modules/Identity/Modules.Identity.Contracts/v1/Users/ToggleUserStatus/ToggleUserStatusCommand.cs @@ -1,6 +1,6 @@ -namespace FSH.Framework.Core.Identity.Users.Features.ToggleUserStatus; +namespace FSH.Framework.Identity.Endpoints.v1.Users.ToggleUserStatus; public class ToggleUserStatusCommand { public bool ActivateUser { get; set; } public string? UserId { get; set; } -} +} \ No newline at end of file diff --git a/src/api/framework/Core/Identity/Users/Features/UpdateUser/UpdateUserCommand.cs b/src/framework/Modules/Identity/Modules.Identity.Contracts/v1/Users/UpdateUser/UpdateUserCommand.cs similarity index 54% rename from src/api/framework/Core/Identity/Users/Features/UpdateUser/UpdateUserCommand.cs rename to src/framework/Modules/Identity/Modules.Identity.Contracts/v1/Users/UpdateUser/UpdateUserCommand.cs index 470516218e..ad350a117b 100644 --- a/src/api/framework/Core/Identity/Users/Features/UpdateUser/UpdateUserCommand.cs +++ b/src/framework/Modules/Identity/Modules.Identity.Contracts/v1/Users/UpdateUser/UpdateUserCommand.cs @@ -1,14 +1,13 @@ -using FSH.Framework.Core.Storage.File.Features; -using MediatR; +using FSH.Framework.Core.Storage; -namespace FSH.Framework.Core.Identity.Users.Features.UpdateUser; -public class UpdateUserCommand : IRequest +namespace FSH.Framework.Identity.Contracts.v1.Users.UpdateUser; +public class UpdateUserCommand { public string Id { get; set; } = default!; public string? FirstName { get; set; } public string? LastName { get; set; } public string? PhoneNumber { get; set; } public string? Email { get; set; } - public FileUploadCommand? Image { get; set; } + public FileUploadRequest? Image { get; set; } public bool DeleteCurrentImage { get; set; } -} +} \ No newline at end of file diff --git a/src/api/framework/Infrastructure/Auth/CurrentUserMiddleware.cs b/src/framework/Modules/Identity/Modules.Identity/Authorization/CurrentUserMiddleware.cs similarity index 88% rename from src/api/framework/Infrastructure/Auth/CurrentUserMiddleware.cs rename to src/framework/Modules/Identity/Modules.Identity/Authorization/CurrentUserMiddleware.cs index b4deef0872..0b92b09fa0 100644 --- a/src/api/framework/Infrastructure/Auth/CurrentUserMiddleware.cs +++ b/src/framework/Modules/Identity/Modules.Identity/Authorization/CurrentUserMiddleware.cs @@ -1,4 +1,4 @@ -using FSH.Framework.Core.Identity.Users.Abstractions; +using FSH.Framework.Core.ExecutionContext; using Microsoft.AspNetCore.Http; namespace FSH.Framework.Infrastructure.Auth; @@ -11,4 +11,4 @@ public async Task InvokeAsync(HttpContext context, RequestDelegate next) _currentUserInitializer.SetCurrentUser(context.User); await next(context); } -} +} \ No newline at end of file diff --git a/src/api/framework/Infrastructure/Auth/Jwt/Extensions.cs b/src/framework/Modules/Identity/Modules.Identity/Authorization/Jwt/Extensions.cs similarity index 92% rename from src/api/framework/Infrastructure/Auth/Jwt/Extensions.cs rename to src/framework/Modules/Identity/Modules.Identity/Authorization/Jwt/Extensions.cs index 87a94d3ec1..1f2c2c0583 100644 --- a/src/api/framework/Infrastructure/Auth/Jwt/Extensions.cs +++ b/src/framework/Modules/Identity/Modules.Identity/Authorization/Jwt/Extensions.cs @@ -1,5 +1,5 @@ -using FSH.Framework.Core.Auth.Jwt; -using FSH.Framework.Infrastructure.Auth.Policy; +using FSH.Framework.Identity.Infrastructure.Authorization; +using FSH.Framework.Identity.Options; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; @@ -30,4 +30,4 @@ internal static IServiceCollection ConfigureJwtAuth(this IServiceCollection serv }); return services; } -} +} \ No newline at end of file diff --git a/src/framework/Modules/Identity/Modules.Identity/Authorization/PathAwareAuthorizationHandler.cs b/src/framework/Modules/Identity/Modules.Identity/Authorization/PathAwareAuthorizationHandler.cs new file mode 100644 index 0000000000..fe60047430 --- /dev/null +++ b/src/framework/Modules/Identity/Modules.Identity/Authorization/PathAwareAuthorizationHandler.cs @@ -0,0 +1,41 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Authorization.Policy; +using Microsoft.AspNetCore.Http; + +namespace FSH.Framework.Identity.Authorization; +public class PathAwareAuthorizationHandler : IAuthorizationMiddlewareResultHandler +{ + private readonly AuthorizationMiddlewareResultHandler _fallback = new(); + + public async Task HandleAsync( + RequestDelegate next, + HttpContext context, + AuthorizationPolicy policy, + PolicyAuthorizationResult authorizeResult) + { + var path = context.Request.Path; + var allowedPaths = new[] + { + new PathString("/scalar"), + new PathString("/openapi"), + new PathString("/favicon.ico") + }; + if (allowedPaths.Any(p => path.StartsWithSegments(p, StringComparison.OrdinalIgnoreCase))) + { + // ✅ Respect routing + continue the pipeline + var endpoint = context.GetEndpoint(); + if (endpoint != null) + { + await next(context); + return; + } + + // If no endpoint is found, return 404 explicitly + context.Response.StatusCode = StatusCodes.Status404NotFound; + await context.Response.WriteAsync("Endpoint not found."); + return; + } + + await _fallback.HandleAsync(next, context, policy, authorizeResult); + } +} \ No newline at end of file diff --git a/src/api/framework/Infrastructure/Auth/Policy/PermissionAuthorizationRequirement.cs b/src/framework/Modules/Identity/Modules.Identity/Authorization/PermissionAuthorizationRequirement.cs similarity index 59% rename from src/api/framework/Infrastructure/Auth/Policy/PermissionAuthorizationRequirement.cs rename to src/framework/Modules/Identity/Modules.Identity/Authorization/PermissionAuthorizationRequirement.cs index 1286921818..dc425c3bad 100644 --- a/src/api/framework/Infrastructure/Auth/Policy/PermissionAuthorizationRequirement.cs +++ b/src/framework/Modules/Identity/Modules.Identity/Authorization/PermissionAuthorizationRequirement.cs @@ -1,4 +1,4 @@ using Microsoft.AspNetCore.Authorization; -namespace FSH.Framework.Infrastructure.Auth.Policy; -public class PermissionAuthorizationRequirement : IAuthorizationRequirement; +namespace FSH.Framework.Identity.Infrastructure.Authorization; +public class PermissionAuthorizationRequirement : IAuthorizationRequirement; \ No newline at end of file diff --git a/src/api/framework/Infrastructure/Auth/Policy/RequiredPermissionAuthorizationExtensions.cs b/src/framework/Modules/Identity/Modules.Identity/Authorization/RequiredPermissionAuthorizationExtensions.cs similarity index 83% rename from src/api/framework/Infrastructure/Auth/Policy/RequiredPermissionAuthorizationExtensions.cs rename to src/framework/Modules/Identity/Modules.Identity/Authorization/RequiredPermissionAuthorizationExtensions.cs index e92cdb2e68..05612e9ca1 100644 --- a/src/api/framework/Infrastructure/Auth/Policy/RequiredPermissionAuthorizationExtensions.cs +++ b/src/framework/Modules/Identity/Modules.Identity/Authorization/RequiredPermissionAuthorizationExtensions.cs @@ -1,9 +1,9 @@ -using Microsoft.AspNetCore.Authentication.JwtBearer; +using FSH.Framework.Shared.Constants; using Microsoft.AspNetCore.Authorization; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; -namespace FSH.Framework.Infrastructure.Auth.Policy; +namespace FSH.Framework.Identity.Infrastructure.Authorization; public static class RequiredPermissionDefaults { public const string PolicyName = "RequiredPermission"; @@ -21,7 +21,7 @@ public static AuthorizationBuilder AddRequiredPermissionPolicy(this Authorizatio builder.AddPolicy(RequiredPermissionDefaults.PolicyName, policy => { policy.RequireAuthenticatedUser(); - policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme); + policy.AddAuthenticationSchemes(AuthenticationConstants.AuthenticationScheme); policy.RequireRequiredPermissions(); }); @@ -29,4 +29,4 @@ public static AuthorizationBuilder AddRequiredPermissionPolicy(this Authorizatio return builder; } -} +} \ No newline at end of file diff --git a/src/api/framework/Infrastructure/Auth/Policy/RequiredPermissionAuthorizationHandler.cs b/src/framework/Modules/Identity/Modules.Identity/Authorization/RequiredPermissionAuthorizationHandler.cs similarity index 85% rename from src/api/framework/Infrastructure/Auth/Policy/RequiredPermissionAuthorizationHandler.cs rename to src/framework/Modules/Identity/Modules.Identity/Authorization/RequiredPermissionAuthorizationHandler.cs index 8903b660e8..357a3926b2 100644 --- a/src/api/framework/Infrastructure/Auth/Policy/RequiredPermissionAuthorizationHandler.cs +++ b/src/framework/Modules/Identity/Modules.Identity/Authorization/RequiredPermissionAuthorizationHandler.cs @@ -1,9 +1,10 @@ -using FSH.Framework.Core.Identity.Users.Abstractions; -using FSH.Starter.Shared.Authorization; +using FSH.Framework.Identity.Core.Users; +using FSH.Framework.Shared.Authorization; +using FSH.Framework.Shared.Extensions; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; -namespace FSH.Framework.Infrastructure.Auth.Policy; +namespace FSH.Framework.Identity.Infrastructure.Authorization; public sealed class RequiredPermissionAuthorizationHandler(IUserService userService) : AuthorizationHandler { protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, PermissionAuthorizationRequirement requirement) @@ -28,4 +29,4 @@ protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context.Succeed(requirement); } } -} +} \ No newline at end of file diff --git a/src/api/framework/Infrastructure/Identity/Persistence/IdentityConfiguration.cs b/src/framework/Modules/Identity/Modules.Identity/Data/IdentityConfigurations.cs similarity index 62% rename from src/api/framework/Infrastructure/Identity/Persistence/IdentityConfiguration.cs rename to src/framework/Modules/Identity/Modules.Identity/Data/IdentityConfigurations.cs index 240b63fffc..b19a83f34a 100644 --- a/src/api/framework/Infrastructure/Identity/Persistence/IdentityConfiguration.cs +++ b/src/framework/Modules/Identity/Modules.Identity/Data/IdentityConfigurations.cs @@ -1,33 +1,20 @@ using Finbuckle.MultiTenant; -using FSH.Framework.Core.Audit; -using FSH.Framework.Infrastructure.Identity.RoleClaims; -using FSH.Framework.Infrastructure.Identity.Roles; -using FSH.Framework.Infrastructure.Identity.Users; +using FSH.Framework.Identity.Infrastructure.Roles; +using FSH.Framework.Identity.Infrastructure.Users; +using FSH.Framework.Identity.v1.RoleClaims; +using FSH.Framework.Modules.Identity.Contracts; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; -using IdentityConstants = FSH.Starter.Shared.Authorization.IdentityConstants; -namespace FSH.Framework.Infrastructure.Identity.Persistence; - -public class AuditTrailConfig : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder - .ToTable("AuditTrails", IdentityConstants.SchemaName) - .IsMultiTenant(); - - builder.HasKey(a => a.Id); - } -} +namespace FSH.Framework.Identity.Infrastructure.Data; public class ApplicationUserConfig : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder builder) { builder - .ToTable("Users", IdentityConstants.SchemaName) + .ToTable("Users", IdentityModuleConstants.SchemaName) .IsMultiTenant(); builder @@ -40,7 +27,7 @@ public class ApplicationRoleConfig : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder builder) => builder - .ToTable("Roles", IdentityConstants.SchemaName) + .ToTable("Roles", IdentityModuleConstants.SchemaName) .IsMultiTenant() .AdjustUniqueIndexes(); } @@ -49,7 +36,7 @@ public class ApplicationRoleClaimConfig : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder builder) => builder - .ToTable("RoleClaims", IdentityConstants.SchemaName) + .ToTable("RoleClaims", IdentityModuleConstants.SchemaName) .IsMultiTenant(); } @@ -57,7 +44,7 @@ public class IdentityUserRoleConfig : IEntityTypeConfiguration> builder) => builder - .ToTable("UserRoles", IdentityConstants.SchemaName) + .ToTable("UserRoles", IdentityModuleConstants.SchemaName) .IsMultiTenant(); } @@ -65,7 +52,7 @@ public class IdentityUserClaimConfig : IEntityTypeConfiguration> builder) => builder - .ToTable("UserClaims", IdentityConstants.SchemaName) + .ToTable("UserClaims", IdentityModuleConstants.SchemaName) .IsMultiTenant(); } @@ -73,7 +60,7 @@ public class IdentityUserLoginConfig : IEntityTypeConfiguration> builder) => builder - .ToTable("UserLogins", IdentityConstants.SchemaName) + .ToTable("UserLogins", IdentityModuleConstants.SchemaName) .IsMultiTenant(); } @@ -81,6 +68,6 @@ public class IdentityUserTokenConfig : IEntityTypeConfiguration> builder) => builder - .ToTable("UserTokens", IdentityConstants.SchemaName) + .ToTable("UserTokens", IdentityModuleConstants.SchemaName) .IsMultiTenant(); -} +} \ No newline at end of file diff --git a/src/api/framework/Infrastructure/Identity/Persistence/IdentityDbContext.cs b/src/framework/Modules/Identity/Modules.Identity/Data/IdentityDbContext.cs similarity index 78% rename from src/api/framework/Infrastructure/Identity/Persistence/IdentityDbContext.cs rename to src/framework/Modules/Identity/Modules.Identity/Data/IdentityDbContext.cs index 5945567c37..c11dfccdd3 100644 --- a/src/api/framework/Infrastructure/Identity/Persistence/IdentityDbContext.cs +++ b/src/framework/Modules/Identity/Modules.Identity/Data/IdentityDbContext.cs @@ -1,17 +1,16 @@ using Finbuckle.MultiTenant.Abstractions; using Finbuckle.MultiTenant.EntityFrameworkCore; -using FSH.Framework.Core.Audit; using FSH.Framework.Core.Persistence; -using FSH.Framework.Infrastructure.Identity.RoleClaims; -using FSH.Framework.Infrastructure.Identity.Roles; -using FSH.Framework.Infrastructure.Identity.Users; +using FSH.Framework.Identity.Infrastructure.Roles; +using FSH.Framework.Identity.Infrastructure.Users; +using FSH.Framework.Identity.v1.RoleClaims; using FSH.Framework.Infrastructure.Persistence; -using FSH.Framework.Infrastructure.Tenant; +using FSH.Framework.Shared.Multitenancy; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; -namespace FSH.Framework.Infrastructure.Identity.Persistence; +namespace FSH.Framework.Identity.Infrastructure.Data; public class IdentityDbContext : MultiTenantIdentityDbContext multiTenantC TenantInfo = multiTenantContextAccessor.MultiTenantContext.TenantInfo!; } - public DbSet AuditTrails { get; set; } - protected override void OnModelCreating(ModelBuilder builder) { base.OnModelCreating(builder); @@ -41,7 +38,7 @@ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { if (!string.IsNullOrWhiteSpace(TenantInfo?.ConnectionString)) { - optionsBuilder.ConfigureDatabase(_settings.Provider, TenantInfo.ConnectionString); + optionsBuilder.ConfigureDatabase(_settings.Provider, TenantInfo.ConnectionString, _settings.MigrationsAssembly); } } -} +} \ No newline at end of file diff --git a/src/api/framework/Infrastructure/Identity/Persistence/IdentityDbInitializer.cs b/src/framework/Modules/Identity/Modules.Identity/Data/IdentityDbInitializer.cs similarity index 90% rename from src/api/framework/Infrastructure/Identity/Persistence/IdentityDbInitializer.cs rename to src/framework/Modules/Identity/Modules.Identity/Data/IdentityDbInitializer.cs index f4759350cf..579486c09a 100644 --- a/src/api/framework/Infrastructure/Identity/Persistence/IdentityDbInitializer.cs +++ b/src/framework/Modules/Identity/Modules.Identity/Data/IdentityDbInitializer.cs @@ -1,18 +1,18 @@ using Finbuckle.MultiTenant.Abstractions; -using FSH.Framework.Core.Origin; using FSH.Framework.Core.Persistence; -using FSH.Framework.Infrastructure.Identity.RoleClaims; -using FSH.Framework.Infrastructure.Identity.Roles; -using FSH.Framework.Infrastructure.Identity.Users; -using FSH.Framework.Infrastructure.Tenant; -using FSH.Starter.Shared.Authorization; +using FSH.Framework.Identity.Infrastructure.Roles; +using FSH.Framework.Identity.Infrastructure.Users; +using FSH.Framework.Identity.v1.RoleClaims; +using FSH.Framework.Shared.Constants; +using FSH.Framework.Shared.Multitenancy; +using FSH.Modules.Common.Core.Origin; +using FSH.Modules.Common.Shared.Constants; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using IdentityConstants = FSH.Starter.Shared.Authorization.IdentityConstants; -namespace FSH.Framework.Infrastructure.Identity.Persistence; +namespace FSH.Framework.Identity.Infrastructure.Data; internal sealed class IdentityDbInitializer( ILogger logger, IdentityDbContext context, @@ -58,7 +58,7 @@ private async Task SeedRolesAsync() { await AssignPermissionsToRoleAsync(context, FshPermissions.Admin, role); - if (multiTenantContextAccessor.MultiTenantContext.TenantInfo?.Id == TenantConstants.Root.Id) + if (multiTenantContextAccessor.MultiTenantContext.TenantInfo?.Id == MutiTenancyConstants.Root.Id) { await AssignPermissionsToRoleAsync(context, FshPermissions.Root, role); } @@ -116,13 +116,13 @@ private async Task SeedAdminUserAsync() PhoneNumberConfirmed = true, NormalizedEmail = multiTenantContextAccessor.MultiTenantContext.TenantInfo?.AdminEmail!.ToUpperInvariant(), NormalizedUserName = adminUserName.ToUpperInvariant(), - ImageUrl = new Uri(originSettings.Value.OriginUrl! + TenantConstants.Root.DefaultProfilePicture), + ImageUrl = new Uri(originSettings.Value.OriginUrl! + MutiTenancyConstants.Root.DefaultProfilePicture), IsActive = true }; logger.LogInformation("Seeding Default Admin User for '{TenantId}' Tenant.", multiTenantContextAccessor.MultiTenantContext.TenantInfo?.Id); var password = new PasswordHasher(); - adminUser.PasswordHash = password.HashPassword(adminUser, TenantConstants.DefaultPassword); + adminUser.PasswordHash = password.HashPassword(adminUser, MutiTenancyConstants.DefaultPassword); await userManager.CreateAsync(adminUser); } @@ -133,4 +133,4 @@ private async Task SeedAdminUserAsync() await userManager.AddToRoleAsync(adminUser, FshRoles.Admin); } } -} +} \ No newline at end of file diff --git a/src/api/framework/Infrastructure/Identity/RoleClaims/FshRoleClaim.cs b/src/framework/Modules/Identity/Modules.Identity/Features/v1/RoleClaims/FshRoleClaim.cs similarity index 75% rename from src/api/framework/Infrastructure/Identity/RoleClaims/FshRoleClaim.cs rename to src/framework/Modules/Identity/Modules.Identity/Features/v1/RoleClaims/FshRoleClaim.cs index 210fa1bec6..dd1ddea479 100644 --- a/src/api/framework/Infrastructure/Identity/RoleClaims/FshRoleClaim.cs +++ b/src/framework/Modules/Identity/Modules.Identity/Features/v1/RoleClaims/FshRoleClaim.cs @@ -1,8 +1,8 @@ using Microsoft.AspNetCore.Identity; -namespace FSH.Framework.Infrastructure.Identity.RoleClaims; +namespace FSH.Framework.Identity.v1.RoleClaims; public class FshRoleClaim : IdentityRoleClaim { public string? CreatedBy { get; init; } public DateTimeOffset CreatedOn { get; init; } -} +} \ No newline at end of file diff --git a/src/api/framework/Infrastructure/Identity/Roles/Endpoints/DeleteRoleEndpoint.cs b/src/framework/Modules/Identity/Modules.Identity/Features/v1/Roles/DeleteRole/DeleteRoleEndpoint.cs similarity index 80% rename from src/api/framework/Infrastructure/Identity/Roles/Endpoints/DeleteRoleEndpoint.cs rename to src/framework/Modules/Identity/Modules.Identity/Features/v1/Roles/DeleteRole/DeleteRoleEndpoint.cs index 106ea082bf..b6b2c1fb7f 100644 --- a/src/api/framework/Infrastructure/Identity/Roles/Endpoints/DeleteRoleEndpoint.cs +++ b/src/framework/Modules/Identity/Modules.Identity/Features/v1/Roles/DeleteRole/DeleteRoleEndpoint.cs @@ -1,10 +1,10 @@ -using FSH.Framework.Core.Identity.Roles; -using FSH.Framework.Infrastructure.Auth.Policy; +using FSH.Framework.Identity.Core.Roles; +using FSH.Framework.Shared.Authorization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; -namespace FSH.Framework.Infrastructure.Identity.Roles.Endpoints; +namespace FSH.Framework.Identity.v1.Roles.DeleteRole; public static class DeleteRoleEndpoint { @@ -19,5 +19,4 @@ public static RouteHandlerBuilder MapDeleteRoleEndpoint(this IEndpointRouteBuild .RequirePermission("Permissions.Roles.Delete") .WithDescription("Remove a role from the system by its ID."); } -} - +} \ No newline at end of file diff --git a/src/api/framework/Infrastructure/Identity/Roles/FshRole.cs b/src/framework/Modules/Identity/Modules.Identity/Features/v1/Roles/FshRole.cs similarity index 84% rename from src/api/framework/Infrastructure/Identity/Roles/FshRole.cs rename to src/framework/Modules/Identity/Modules.Identity/Features/v1/Roles/FshRole.cs index fa8baf6bc6..850dbc26af 100644 --- a/src/api/framework/Infrastructure/Identity/Roles/FshRole.cs +++ b/src/framework/Modules/Identity/Modules.Identity/Features/v1/Roles/FshRole.cs @@ -1,6 +1,6 @@ using Microsoft.AspNetCore.Identity; -namespace FSH.Framework.Infrastructure.Identity.Roles; +namespace FSH.Framework.Identity.Infrastructure.Roles; public class FshRole : IdentityRole { public string? Description { get; set; } @@ -11,4 +11,4 @@ public FshRole(string name, string? description = null) Description = description; NormalizedName = name.ToUpperInvariant(); } -} +} \ No newline at end of file diff --git a/src/api/framework/Infrastructure/Identity/Roles/Endpoints/GetRoleEndpoint.cs b/src/framework/Modules/Identity/Modules.Identity/Features/v1/Roles/GetRole/GetRoleEndpoint.cs similarity index 88% rename from src/api/framework/Infrastructure/Identity/Roles/Endpoints/GetRoleEndpoint.cs rename to src/framework/Modules/Identity/Modules.Identity/Features/v1/Roles/GetRole/GetRoleEndpoint.cs index 6064a33866..33372598ef 100644 --- a/src/api/framework/Infrastructure/Identity/Roles/Endpoints/GetRoleEndpoint.cs +++ b/src/framework/Modules/Identity/Modules.Identity/Features/v1/Roles/GetRole/GetRoleEndpoint.cs @@ -1,5 +1,5 @@ -using FSH.Framework.Core.Identity.Roles; -using FSH.Framework.Infrastructure.Auth.Policy; +using FSH.Framework.Identity.Core.Roles; +using FSH.Framework.Shared.Authorization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; @@ -19,5 +19,4 @@ public static RouteHandlerBuilder MapGetRoleEndpoint(this IEndpointRouteBuilder .RequirePermission("Permissions.Roles.View") .WithDescription("Retrieve the details of a role by its ID."); } -} - +} \ No newline at end of file diff --git a/src/api/framework/Infrastructure/Identity/Roles/Endpoints/GetRolePermissionsEndpoint.cs b/src/framework/Modules/Identity/Modules.Identity/Features/v1/Roles/GetRoleWithPermissions/GetRolePermissionsEndpoint.cs similarity index 89% rename from src/api/framework/Infrastructure/Identity/Roles/Endpoints/GetRolePermissionsEndpoint.cs rename to src/framework/Modules/Identity/Modules.Identity/Features/v1/Roles/GetRoleWithPermissions/GetRolePermissionsEndpoint.cs index 42eb894263..71566502dc 100644 --- a/src/api/framework/Infrastructure/Identity/Roles/Endpoints/GetRolePermissionsEndpoint.cs +++ b/src/framework/Modules/Identity/Modules.Identity/Features/v1/Roles/GetRoleWithPermissions/GetRolePermissionsEndpoint.cs @@ -1,5 +1,5 @@ -using FSH.Framework.Core.Identity.Roles; -using FSH.Framework.Infrastructure.Auth.Policy; +using FSH.Framework.Identity.Core.Roles; +using FSH.Framework.Shared.Authorization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; @@ -18,4 +18,4 @@ public static RouteHandlerBuilder MapGetRolePermissionsEndpoint(this IEndpointRo .RequirePermission("Permissions.Roles.View") .WithDescription("get role permissions"); } -} +} \ No newline at end of file diff --git a/src/api/framework/Infrastructure/Identity/Roles/Endpoints/GetRolesEndpoint.cs b/src/framework/Modules/Identity/Modules.Identity/Features/v1/Roles/GetRoles/GetRolesEndpoint.cs similarity index 88% rename from src/api/framework/Infrastructure/Identity/Roles/Endpoints/GetRolesEndpoint.cs rename to src/framework/Modules/Identity/Modules.Identity/Features/v1/Roles/GetRoles/GetRolesEndpoint.cs index df3b91cff8..931ae21e9c 100644 --- a/src/api/framework/Infrastructure/Identity/Roles/Endpoints/GetRolesEndpoint.cs +++ b/src/framework/Modules/Identity/Modules.Identity/Features/v1/Roles/GetRoles/GetRolesEndpoint.cs @@ -1,5 +1,5 @@ -using FSH.Framework.Core.Identity.Roles; -using FSH.Framework.Infrastructure.Auth.Policy; +using FSH.Framework.Identity.Core.Roles; +using FSH.Framework.Shared.Authorization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; @@ -18,4 +18,4 @@ public static RouteHandlerBuilder MapGetRolesEndpoint(this IEndpointRouteBuilder .RequirePermission("Permissions.Roles.View") .WithDescription("Retrieve a list of all roles available in the system."); } -} +} \ No newline at end of file diff --git a/src/api/framework/Infrastructure/Identity/Roles/RoleService.cs b/src/framework/Modules/Identity/Modules.Identity/Features/v1/Roles/RoleService.cs similarity index 69% rename from src/api/framework/Infrastructure/Identity/Roles/RoleService.cs rename to src/framework/Modules/Identity/Modules.Identity/Features/v1/Roles/RoleService.cs index 6930ab0e16..6ffca551ee 100644 --- a/src/api/framework/Infrastructure/Identity/Roles/RoleService.cs +++ b/src/framework/Modules/Identity/Modules.Identity/Features/v1/Roles/RoleService.cs @@ -1,13 +1,14 @@ using Finbuckle.MultiTenant.Abstractions; using FSH.Framework.Core.Exceptions; -using FSH.Framework.Core.Identity.Roles; -using FSH.Framework.Core.Identity.Roles.Features.CreateOrUpdateRole; -using FSH.Framework.Core.Identity.Roles.Features.UpdatePermissions; -using FSH.Framework.Core.Identity.Users.Abstractions; -using FSH.Framework.Infrastructure.Identity.Persistence; -using FSH.Framework.Infrastructure.Identity.RoleClaims; -using FSH.Framework.Infrastructure.Tenant; -using FSH.Starter.Shared.Authorization; +using FSH.Framework.Core.ExecutionContext; +using FSH.Framework.Identity.Core.Roles; +using FSH.Framework.Identity.Infrastructure.Data; +using FSH.Framework.Identity.Infrastructure.Roles; +using FSH.Framework.Identity.v1.RoleClaims; +using FSH.Framework.Shared.Constants; +using FSH.Framework.Shared.Multitenancy; +using FSH.Modules.Common.Core.Exceptions; +using FSH.Modules.Common.Shared.Constants; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; @@ -36,19 +37,19 @@ public async Task> GetRolesAsync() return new RoleDto { Id = role.Id, Name = role.Name!, Description = role.Description }; } - public async Task CreateOrUpdateRoleAsync(CreateOrUpdateRoleCommand command) + public async Task CreateOrUpdateRoleAsync(string roleId, string name, string description) { - FshRole? role = await _roleManager.FindByIdAsync(command.Id); + FshRole? role = await _roleManager.FindByIdAsync(roleId); if (role != null) { - role.Name = command.Name; - role.Description = command.Description; + role.Name = name; + role.Description = description; await _roleManager.UpdateAsync(role); } else { - role = new FshRole(command.Name, command.Description); + role = new FshRole(name, description); await _roleManager.CreateAsync(role); } @@ -77,36 +78,36 @@ public async Task GetWithPermissionsAsync(string id, CancellationToken return role; } - public async Task UpdatePermissionsAsync(UpdatePermissionsCommand request) + public async Task UpdatePermissionsAsync(string roleId, List permissions) { - var role = await _roleManager.FindByIdAsync(request.RoleId); + var role = await _roleManager.FindByIdAsync(roleId); _ = role ?? throw new NotFoundException("role not found"); if (role.Name == FshRoles.Admin) { - throw new FshException("operation not permitted"); + throw new CustomException("operation not permitted"); } - if (multiTenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id != TenantConstants.Root.Id) + if (multiTenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id != MutiTenancyConstants.Root.Id) { // Remove Root Permissions if the Role is not created for Root Tenant. - request.Permissions.RemoveAll(u => u.StartsWith("Permissions.Root.", StringComparison.InvariantCultureIgnoreCase)); + permissions.RemoveAll(u => u.StartsWith("Permissions.Root.", StringComparison.InvariantCultureIgnoreCase)); } var currentClaims = await _roleManager.GetClaimsAsync(role); // Remove permissions that were previously selected - foreach (var claim in currentClaims.Where(c => !request.Permissions.Exists(p => p == c.Value))) + foreach (var claim in currentClaims.Where(c => !permissions.Exists(p => p == c.Value))) { var result = await _roleManager.RemoveClaimAsync(role, claim); if (!result.Succeeded) { var errors = result.Errors.Select(error => error.Description).ToList(); - throw new FshException("operation failed", errors); + throw new CustomException("operation failed", errors); } } // Add all permissions that were not previously selected - foreach (string permission in request.Permissions.Where(c => !currentClaims.Any(p => p.Value == c))) + foreach (string permission in permissions.Where(c => !currentClaims.Any(p => p.Value == c))) { if (!string.IsNullOrEmpty(permission)) { @@ -123,4 +124,4 @@ public async Task UpdatePermissionsAsync(UpdatePermissionsCommand reques return "permissions updated"; } -} +} \ No newline at end of file diff --git a/src/framework/Modules/Identity/Modules.Identity/Features/v1/Roles/UpdateRolePermissions/UpdatePermissionsCommandValidator.cs b/src/framework/Modules/Identity/Modules.Identity/Features/v1/Roles/UpdateRolePermissions/UpdatePermissionsCommandValidator.cs new file mode 100644 index 0000000000..d135d5e82d --- /dev/null +++ b/src/framework/Modules/Identity/Modules.Identity/Features/v1/Roles/UpdateRolePermissions/UpdatePermissionsCommandValidator.cs @@ -0,0 +1,14 @@ +using FluentValidation; +using FSH.Framework.Modules.Identity.Contracts.v1.Roles.UpdatePermissions; + +namespace FSH.Framework.Identity.Endpoints.v1.Roles.UpdatePermissions; +public class UpdatePermissionsCommandValidator : AbstractValidator +{ + public UpdatePermissionsCommandValidator() + { + RuleFor(r => r.RoleId) + .NotEmpty(); + RuleFor(r => r.Permissions) + .NotNull(); + } +} \ No newline at end of file diff --git a/src/api/framework/Infrastructure/Identity/Roles/Endpoints/UpdateRolePermissionsEndpoint.cs b/src/framework/Modules/Identity/Modules.Identity/Features/v1/Roles/UpdateRolePermissions/UpdateRolePermissionsEndpoint.cs similarity index 79% rename from src/api/framework/Infrastructure/Identity/Roles/Endpoints/UpdateRolePermissionsEndpoint.cs rename to src/framework/Modules/Identity/Modules.Identity/Features/v1/Roles/UpdateRolePermissions/UpdateRolePermissionsEndpoint.cs index 71cb44c611..c1dfe697b8 100644 --- a/src/api/framework/Infrastructure/Identity/Roles/Endpoints/UpdateRolePermissionsEndpoint.cs +++ b/src/framework/Modules/Identity/Modules.Identity/Features/v1/Roles/UpdateRolePermissions/UpdateRolePermissionsEndpoint.cs @@ -1,7 +1,7 @@ using FluentValidation; -using FSH.Framework.Core.Identity.Roles; -using FSH.Framework.Core.Identity.Roles.Features.UpdatePermissions; -using FSH.Framework.Infrastructure.Auth.Policy; +using FSH.Framework.Identity.Core.Roles; +using FSH.Framework.Modules.Identity.Contracts.v1.Roles.UpdatePermissions; +using FSH.Framework.Shared.Authorization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -13,13 +13,13 @@ public static class UpdateRolePermissionsEndpoint public static RouteHandlerBuilder MapUpdateRolePermissionsEndpoint(this IEndpointRouteBuilder endpoints) { return endpoints.MapPut("/{id}/permissions", async ( - UpdatePermissionsCommand request, + [FromBody] UpdatePermissionsCommand request, IRoleService roleService, string id, [FromServices] IValidator validator) => { if (id != request.RoleId) return Results.BadRequest(); - var response = await roleService.UpdatePermissionsAsync(request); + var response = await roleService.UpdatePermissionsAsync(request.RoleId, request.Permissions); return Results.Ok(response); }) .WithName(nameof(UpdateRolePermissionsEndpoint)) @@ -27,4 +27,4 @@ public static RouteHandlerBuilder MapUpdateRolePermissionsEndpoint(this IEndpoin .RequirePermission("Permissions.Roles.Create") .WithDescription("update role permissions"); } -} +} \ No newline at end of file diff --git a/src/api/framework/Infrastructure/Identity/Roles/Endpoints/CreateOrUpdateRoleEndpoint.cs b/src/framework/Modules/Identity/Modules.Identity/Features/v1/Roles/UpsertRole/CreateOrUpdateRoleEndpoint.cs similarity index 65% rename from src/api/framework/Infrastructure/Identity/Roles/Endpoints/CreateOrUpdateRoleEndpoint.cs rename to src/framework/Modules/Identity/Modules.Identity/Features/v1/Roles/UpsertRole/CreateOrUpdateRoleEndpoint.cs index 86234da7ec..943a6d55d0 100644 --- a/src/api/framework/Infrastructure/Identity/Roles/Endpoints/CreateOrUpdateRoleEndpoint.cs +++ b/src/framework/Modules/Identity/Modules.Identity/Features/v1/Roles/UpsertRole/CreateOrUpdateRoleEndpoint.cs @@ -1,8 +1,9 @@ -using FSH.Framework.Core.Identity.Roles; -using FSH.Framework.Core.Identity.Roles.Features.CreateOrUpdateRole; -using FSH.Framework.Infrastructure.Auth.Policy; +using FSH.Framework.Identity.Core.Roles; +using FSH.Framework.Identity.Endpoints.v1.Roles.CreateOrUpdateRole; +using FSH.Framework.Shared.Authorization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; namespace FSH.Framework.Infrastructure.Identity.Roles.Endpoints; @@ -11,13 +12,13 @@ public static class CreateOrUpdateRoleEndpoint { public static RouteHandlerBuilder MapCreateOrUpdateRoleEndpoint(this IEndpointRouteBuilder endpoints) { - return endpoints.MapPost("/", async (CreateOrUpdateRoleCommand request, IRoleService roleService) => + return endpoints.MapPost("/", async ([FromBody] UpsertRoleCommand request, IRoleService roleService) => { - return await roleService.CreateOrUpdateRoleAsync(request); + return await roleService.CreateOrUpdateRoleAsync(request.Id, request.Name, request.Description); }) .WithName(nameof(CreateOrUpdateRoleEndpoint)) .WithSummary("Create or update a role") .RequirePermission("Permissions.Roles.Create") .WithDescription("Create a new role or update an existing role."); } -} +} \ No newline at end of file diff --git a/src/framework/Modules/Identity/Modules.Identity/Features/v1/Roles/UpsertRole/UpsertRoleCommandValidator.cs b/src/framework/Modules/Identity/Modules.Identity/Features/v1/Roles/UpsertRole/UpsertRoleCommandValidator.cs new file mode 100644 index 0000000000..ca52ca5426 --- /dev/null +++ b/src/framework/Modules/Identity/Modules.Identity/Features/v1/Roles/UpsertRole/UpsertRoleCommandValidator.cs @@ -0,0 +1,11 @@ +using FluentValidation; + +namespace FSH.Framework.Identity.Endpoints.v1.Roles.CreateOrUpdateRole; + +public class UpsertRoleCommandValidator : AbstractValidator +{ + public UpsertRoleCommandValidator() + { + RuleFor(x => x.Name).NotEmpty().WithMessage("Role name is required."); + } +} \ No newline at end of file diff --git a/src/framework/Modules/Identity/Modules.Identity/Features/v1/Tokens/RefreshToken/RefreshTokenCommandHandler.cs b/src/framework/Modules/Identity/Modules.Identity/Features/v1/Tokens/RefreshToken/RefreshTokenCommandHandler.cs new file mode 100644 index 0000000000..921d44c750 --- /dev/null +++ b/src/framework/Modules/Identity/Modules.Identity/Features/v1/Tokens/RefreshToken/RefreshTokenCommandHandler.cs @@ -0,0 +1,19 @@ +using FSH.Framework.Identity.Contracts.v1.Tokens.RefreshToken; +using FSH.Framework.Identity.Core.Tokens; +using FSH.Framework.Shared.Extensions; +using FSH.Modules.Common.Core.Messaging.CQRS; +using Microsoft.AspNetCore.Http; + +namespace FSH.Framework.Identity.v1.Tokens.RefreshToken; +internal sealed class RefreshTokenCommandHandler( + ITokenService tokenService, + HttpContext context) + : ICommandHandler +{ + public async Task HandleAsync(RefreshTokenCommand command, CancellationToken cancellationToken = default) + { + string ip = context.GetIpAddress(); + var token = await tokenService.RefreshTokenAsync(command.Token, command.RefreshToken, ip, cancellationToken); + return new RefreshTokenCommandResponse(token.Token, token.RefreshToken, token.RefreshTokenExpiryTime); + } +} \ No newline at end of file diff --git a/src/framework/Modules/Identity/Modules.Identity/Features/v1/Tokens/RefreshToken/RefreshTokenCommandValidator.cs b/src/framework/Modules/Identity/Modules.Identity/Features/v1/Tokens/RefreshToken/RefreshTokenCommandValidator.cs new file mode 100644 index 0000000000..0d26593a2d --- /dev/null +++ b/src/framework/Modules/Identity/Modules.Identity/Features/v1/Tokens/RefreshToken/RefreshTokenCommandValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; +using FSH.Framework.Identity.Core.Tokens; + +namespace FSH.Framework.Identity.v1.Tokens.RefreshToken; +internal sealed class RefreshTokenCommandValidator : AbstractValidator +{ + public RefreshTokenCommandValidator() + { + RuleFor(p => p.Token).Cascade(CascadeMode.Stop).NotEmpty(); + RuleFor(p => p.RefreshToken).Cascade(CascadeMode.Stop).NotEmpty(); + } +} \ No newline at end of file diff --git a/src/framework/Modules/Identity/Modules.Identity/Features/v1/Tokens/RefreshToken/RefreshTokenEndpoint.cs b/src/framework/Modules/Identity/Modules.Identity/Features/v1/Tokens/RefreshToken/RefreshTokenEndpoint.cs new file mode 100644 index 0000000000..06705cdd5d --- /dev/null +++ b/src/framework/Modules/Identity/Modules.Identity/Features/v1/Tokens/RefreshToken/RefreshTokenEndpoint.cs @@ -0,0 +1,26 @@ +using FSH.Framework.Core.Messaging.CQRS; +using FSH.Framework.Identity.Core.Tokens; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace FSH.Framework.Identity.v1.Tokens.RefreshToken; +public static class RefreshTokenEndpoint +{ + internal static RouteHandlerBuilder MapEndpoint(this IEndpointRouteBuilder endpoints) + { + return endpoints.MapPost("/refresh", async (RefreshTokenCommand command, + string tenant, + ICommandDispatcher dispatcher, + HttpContext context, + CancellationToken cancellationToken) => + { + var result = await dispatcher.SendAsync(command, cancellationToken); + return TypedResults.Ok(result); + }) + .WithName(nameof(RefreshTokenEndpoint)) + .WithSummary("refresh JWTs") + .WithDescription("refresh JWTs") + .AllowAnonymous(); + } +} \ No newline at end of file diff --git a/src/framework/Modules/Identity/Modules.Identity/Features/v1/Tokens/TokenGeneration/TokenGenerationCommandHandler.cs b/src/framework/Modules/Identity/Modules.Identity/Features/v1/Tokens/TokenGeneration/TokenGenerationCommandHandler.cs new file mode 100644 index 0000000000..70eb9ca703 --- /dev/null +++ b/src/framework/Modules/Identity/Modules.Identity/Features/v1/Tokens/TokenGeneration/TokenGenerationCommandHandler.cs @@ -0,0 +1,19 @@ +using FSH.Framework.Identity.Contracts.v1.Tokens.TokenGeneration; +using FSH.Framework.Identity.Core.Tokens; +using FSH.Framework.Shared.Extensions; +using FSH.Modules.Common.Core.Messaging.CQRS; +using Microsoft.AspNetCore.Http; + +namespace FSH.Modules.Identity.Features.v1.Tokens.TokenGeneration; +public class TokenGenerationCommandHandler( + ITokenService tokenService, + IHttpContextAccessor contextAccessor) + : ICommandHandler +{ + public async Task HandleAsync(TokenGenerationCommand command, CancellationToken cancellationToken = default) + { + string? ip = contextAccessor.HttpContext?.GetIpAddress() ?? "unknown"; + var token = await tokenService.GenerateTokenAsync(command.Email, command.Password, ip, cancellationToken); + return new TokenGenerationCommandResponse(token.Token, token.RefreshToken, token.RefreshTokenExpiryTime); + } +} \ No newline at end of file diff --git a/src/framework/Modules/Identity/Modules.Identity/Features/v1/Tokens/TokenGeneration/TokenGenerationCommandValidator.cs b/src/framework/Modules/Identity/Modules.Identity/Features/v1/Tokens/TokenGeneration/TokenGenerationCommandValidator.cs new file mode 100644 index 0000000000..887503de1d --- /dev/null +++ b/src/framework/Modules/Identity/Modules.Identity/Features/v1/Tokens/TokenGeneration/TokenGenerationCommandValidator.cs @@ -0,0 +1,18 @@ +using FluentValidation; +using FSH.Framework.Identity.Contracts.v1.Tokens.TokenGeneration; + +namespace FSH.Framework.Identity.v1.Tokens.TokenGeneration; +public class TokenGenerationCommandValidator : AbstractValidator +{ + public TokenGenerationCommandValidator() + { + RuleFor(p => p.Email) + .Cascade(CascadeMode.Stop) + .NotEmpty() + .EmailAddress(); + + RuleFor(p => p.Password) + .Cascade(CascadeMode.Stop) + .NotEmpty(); + } +} \ No newline at end of file diff --git a/src/framework/Modules/Identity/Modules.Identity/Features/v1/Tokens/TokenGeneration/TokenGenerationEndpoint.cs b/src/framework/Modules/Identity/Modules.Identity/Features/v1/Tokens/TokenGeneration/TokenGenerationEndpoint.cs new file mode 100644 index 0000000000..9524e645bb --- /dev/null +++ b/src/framework/Modules/Identity/Modules.Identity/Features/v1/Tokens/TokenGeneration/TokenGenerationEndpoint.cs @@ -0,0 +1,29 @@ +using FSH.Framework.Core.Messaging.CQRS; +using FSH.Framework.Identity.Contracts.v1.Tokens.TokenGeneration; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; +using System.ComponentModel; + +namespace FSH.Framework.Identity.v1.Tokens.TokenGeneration; +public static class TokenGenerationEndpoint +{ + internal static RouteHandlerBuilder Map(this IEndpointRouteBuilder endpoints) + { + return endpoints.MapPost("/tokens", async ( + [FromBody] TokenGenerationCommand command, + [DefaultValue("root")] string tenant, + [FromServices] ICommandDispatcher dispatcher, + HttpContext context, + CancellationToken cancellationToken) => + { + var result = await dispatcher.SendAsync(command, cancellationToken); + return TypedResults.Ok(result); + }) + .WithName(nameof(TokenGenerationEndpoint)) + .WithSummary("Generate JWTs") + .WithDescription("Generates access and refresh tokens.") + .AllowAnonymous(); + } +} \ No newline at end of file diff --git a/src/framework/Modules/Identity/Modules.Identity/Features/v1/Users/AssignUserRoles/AssignUserRolesCommandHandler.cs b/src/framework/Modules/Identity/Modules.Identity/Features/v1/Users/AssignUserRoles/AssignUserRolesCommandHandler.cs new file mode 100644 index 0000000000..0e3ee5cab8 --- /dev/null +++ b/src/framework/Modules/Identity/Modules.Identity/Features/v1/Users/AssignUserRoles/AssignUserRolesCommandHandler.cs @@ -0,0 +1,13 @@ +using FSH.Framework.Identity.Contracts.v1.Users.AssignUserRoles; +using FSH.Framework.Identity.Core.Users; +using FSH.Modules.Common.Core.Messaging.CQRS; + +namespace FSH.Framework.Identity.v1.Users.AssignUserRoles; +internal sealed class AssignUserRolesCommandHandler(IUserService _userService) + : ICommandHandler +{ + public async Task HandleAsync( + AssignUserRolesCommand request, + CancellationToken cancellationToken = default) => + await _userService.AssignRolesAsync(request.UserId, request.UserRoles, cancellationToken); +} \ No newline at end of file diff --git a/src/framework/Modules/Identity/Modules.Identity/Features/v1/Users/AssignUserRoles/AssignUserRolesEndpoint.cs b/src/framework/Modules/Identity/Modules.Identity/Features/v1/Users/AssignUserRoles/AssignUserRolesEndpoint.cs new file mode 100644 index 0000000000..d3c686aa75 --- /dev/null +++ b/src/framework/Modules/Identity/Modules.Identity/Features/v1/Users/AssignUserRoles/AssignUserRolesEndpoint.cs @@ -0,0 +1,25 @@ +using FSH.Framework.Core.Messaging.CQRS; +using FSH.Framework.Identity.Contracts.v1.Users.AssignUserRoles; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace FSH.Framework.Identity.v1.Users.AssignUserRoles; +public static class AssignUserRolesEndpoint +{ + internal static RouteHandlerBuilder MapEndpoint(this IEndpointRouteBuilder endpoints) + { + return endpoints.MapPost("/{id:guid}/roles", async (AssignUserRolesCommand command, + HttpContext context, + string id, + ICommandDispatcher dispatcher, + CancellationToken cancellationToken) => + { + var result = await dispatcher.SendAsync(command, cancellationToken); + return Results.Ok(result); + }) + .WithName(nameof(AssignUserRolesEndpoint)) + .WithSummary("assign roles") + .WithDescription("assign roles"); + } +} \ No newline at end of file diff --git a/src/api/framework/Infrastructure/Identity/Users/Endpoints/ChangePasswordEndpoint.cs b/src/framework/Modules/Identity/Modules.Identity/Features/v1/Users/ChangePassword/ChangePasswordEndpoint.cs similarity index 81% rename from src/api/framework/Infrastructure/Identity/Users/Endpoints/ChangePasswordEndpoint.cs rename to src/framework/Modules/Identity/Modules.Identity/Features/v1/Users/ChangePassword/ChangePasswordEndpoint.cs index 7164ac5668..6950f1308c 100644 --- a/src/api/framework/Infrastructure/Identity/Users/Endpoints/ChangePasswordEndpoint.cs +++ b/src/framework/Modules/Identity/Modules.Identity/Features/v1/Users/ChangePassword/ChangePasswordEndpoint.cs @@ -1,9 +1,9 @@ using FluentValidation; using FluentValidation.Results; -using FSH.Framework.Core.Identity.Users.Abstractions; -using FSH.Framework.Core.Identity.Users.Features.ChangePassword; -using FSH.Framework.Core.Origin; -using FSH.Starter.Shared.Authorization; +using FSH.Framework.Identity.Contracts.v1.Users.ChangePassword; +using FSH.Framework.Identity.Core.Users; +using FSH.Framework.Shared.Extensions; +using FSH.Modules.Common.Core.Origin; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; @@ -32,7 +32,7 @@ internal static RouteHandlerBuilder MapChangePasswordEndpoint(this IEndpointRout return Results.BadRequest(); } - await userService.ChangePasswordAsync(command, userId); + await userService.ChangePasswordAsync(command.Password, command.NewPassword, command.ConfirmNewPassword, userId); return Results.Ok("password reset email sent"); }) .WithName(nameof(ChangePasswordEndpoint)) @@ -40,4 +40,4 @@ internal static RouteHandlerBuilder MapChangePasswordEndpoint(this IEndpointRout .WithDescription("Change password"); } -} +} \ No newline at end of file diff --git a/src/framework/Modules/Identity/Modules.Identity/Features/v1/Users/ChangePassword/ChangePasswordValidator.cs b/src/framework/Modules/Identity/Modules.Identity/Features/v1/Users/ChangePassword/ChangePasswordValidator.cs new file mode 100644 index 0000000000..fb0f694591 --- /dev/null +++ b/src/framework/Modules/Identity/Modules.Identity/Features/v1/Users/ChangePassword/ChangePasswordValidator.cs @@ -0,0 +1,24 @@ +using FluentValidation; +using FSH.Framework.Identity.Contracts.v1.Users.ChangePassword; + +namespace FSH.Framework.Identity.Endpoints.v1.Users.ChangePassword; + +public class ChangePasswordValidator : AbstractValidator +{ + public ChangePasswordValidator() + { + RuleFor(p => p.Password) + .NotEmpty() + .WithMessage("Current password is required."); + + RuleFor(p => p.NewPassword) + .NotEmpty() + .WithMessage("New password is required.") + .NotEqual(p => p.Password) + .WithMessage("New password must be different from the current password."); + + RuleFor(p => p.ConfirmNewPassword) + .Equal(p => p.NewPassword) + .WithMessage("Passwords do not match."); + } +} \ No newline at end of file diff --git a/src/api/framework/Infrastructure/Identity/Users/Endpoints/ConfirmEmailEndpoint.cs b/src/framework/Modules/Identity/Modules.Identity/Features/v1/Users/ConfirmEmail/ConfirmEmailEndpoint.cs similarity index 92% rename from src/api/framework/Infrastructure/Identity/Users/Endpoints/ConfirmEmailEndpoint.cs rename to src/framework/Modules/Identity/Modules.Identity/Features/v1/Users/ConfirmEmail/ConfirmEmailEndpoint.cs index c0800e3321..a8bc1bbf3a 100644 --- a/src/api/framework/Infrastructure/Identity/Users/Endpoints/ConfirmEmailEndpoint.cs +++ b/src/framework/Modules/Identity/Modules.Identity/Features/v1/Users/ConfirmEmail/ConfirmEmailEndpoint.cs @@ -1,4 +1,4 @@ -using FSH.Framework.Core.Identity.Users.Abstractions; +using FSH.Framework.Identity.Core.Users; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; @@ -17,4 +17,4 @@ internal static RouteHandlerBuilder MapConfirmEmailEndpoint(this IEndpointRouteB .WithDescription("confirm user email") .AllowAnonymous(); } -} +} \ No newline at end of file diff --git a/src/api/framework/Infrastructure/Identity/Users/Endpoints/DeleteUserEndpoint.cs b/src/framework/Modules/Identity/Modules.Identity/Features/v1/Users/DeleteUser/DeleteUserEndpoint.cs similarity index 86% rename from src/api/framework/Infrastructure/Identity/Users/Endpoints/DeleteUserEndpoint.cs rename to src/framework/Modules/Identity/Modules.Identity/Features/v1/Users/DeleteUser/DeleteUserEndpoint.cs index 6969b4e124..c24636ee7b 100644 --- a/src/api/framework/Infrastructure/Identity/Users/Endpoints/DeleteUserEndpoint.cs +++ b/src/framework/Modules/Identity/Modules.Identity/Features/v1/Users/DeleteUser/DeleteUserEndpoint.cs @@ -1,5 +1,5 @@ -using FSH.Framework.Core.Identity.Users.Abstractions; -using FSH.Framework.Infrastructure.Auth.Policy; +using FSH.Framework.Identity.Core.Users; +using FSH.Framework.Shared.Authorization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; @@ -18,4 +18,4 @@ internal static RouteHandlerBuilder MapDeleteUserEndpoint(this IEndpointRouteBui .RequirePermission("Permissions.Users.Delete") .WithDescription("delete user profile"); } -} +} \ No newline at end of file diff --git a/src/framework/Modules/Identity/Modules.Identity/Features/v1/Users/ForgotPassword/ForgotPasswordCommandValidator.cs b/src/framework/Modules/Identity/Modules.Identity/Features/v1/Users/ForgotPassword/ForgotPasswordCommandValidator.cs new file mode 100644 index 0000000000..4eac3fca4c --- /dev/null +++ b/src/framework/Modules/Identity/Modules.Identity/Features/v1/Users/ForgotPassword/ForgotPasswordCommandValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace FSH.Framework.Identity.Endpoints.v1.Users.ForgotPassword; +public class ForgotPasswordCommandValidator : AbstractValidator +{ + public ForgotPasswordCommandValidator() + { + RuleFor(p => p.Email).Cascade(CascadeMode.Stop) + .NotEmpty() + .EmailAddress(); + } +} \ No newline at end of file diff --git a/src/api/framework/Infrastructure/Identity/Users/Endpoints/ForgotPasswordEndpoint.cs b/src/framework/Modules/Identity/Modules.Identity/Features/v1/Users/ForgotPassword/ForgotPasswordEndpoint.cs similarity index 69% rename from src/api/framework/Infrastructure/Identity/Users/Endpoints/ForgotPasswordEndpoint.cs rename to src/framework/Modules/Identity/Modules.Identity/Features/v1/Users/ForgotPassword/ForgotPasswordEndpoint.cs index 9483571e7a..eb43d35150 100644 --- a/src/api/framework/Infrastructure/Identity/Users/Endpoints/ForgotPasswordEndpoint.cs +++ b/src/framework/Modules/Identity/Modules.Identity/Features/v1/Users/ForgotPassword/ForgotPasswordEndpoint.cs @@ -1,9 +1,9 @@ using FluentValidation; using FluentValidation.Results; -using FSH.Framework.Core.Identity.Users.Abstractions; -using FSH.Framework.Core.Identity.Users.Features.ForgotPassword; -using FSH.Framework.Core.Origin; -using FSH.Starter.Shared.Authorization; +using FSH.Framework.Identity.Core.Users; +using FSH.Framework.Identity.Endpoints.v1.Users.ForgotPassword; +using FSH.Modules.Common.Core.Origin; +using FSH.Modules.Common.Shared.Constants; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -16,7 +16,7 @@ public static class ForgotPasswordEndpoint { internal static RouteHandlerBuilder MapForgotPasswordEndpoint(this IEndpointRouteBuilder endpoints) { - return endpoints.MapPost("/forgot-password", async (HttpRequest request, [FromHeader(Name = TenantConstants.Identifier)] string tenant, ForgotPasswordCommand command, IOptions settings, IValidator validator, IUserService userService, CancellationToken cancellationToken) => + return endpoints.MapPost("/forgot-password", async (HttpRequest request, [FromHeader(Name = MutiTenancyConstants.Identifier)] string tenant, [FromBody] ForgotPasswordCommand command, IOptions settings, IValidator validator, IUserService userService, CancellationToken cancellationToken) => { ValidationResult result = await validator.ValidateAsync(command, cancellationToken); if (!result.IsValid) @@ -33,7 +33,7 @@ internal static RouteHandlerBuilder MapForgotPasswordEndpoint(this IEndpointRout return Results.BadRequest("Origin URL is not configured."); } - await userService.ForgotPasswordAsync(command, origin.OriginUrl.ToString(), cancellationToken); + await userService.ForgotPasswordAsync(command.Email, origin.OriginUrl.ToString(), cancellationToken); return Results.Ok("Password reset email sent."); }) .WithName(nameof(ForgotPasswordEndpoint)) @@ -42,4 +42,4 @@ internal static RouteHandlerBuilder MapForgotPasswordEndpoint(this IEndpointRout .AllowAnonymous(); } -} +} \ No newline at end of file diff --git a/src/api/framework/Infrastructure/Identity/Users/FshUser.cs b/src/framework/Modules/Identity/Modules.Identity/Features/v1/Users/FshUser.cs similarity index 87% rename from src/api/framework/Infrastructure/Identity/Users/FshUser.cs rename to src/framework/Modules/Identity/Modules.Identity/Features/v1/Users/FshUser.cs index 4d68e207f3..7eddbce8e9 100644 --- a/src/api/framework/Infrastructure/Identity/Users/FshUser.cs +++ b/src/framework/Modules/Identity/Modules.Identity/Features/v1/Users/FshUser.cs @@ -1,6 +1,6 @@ using Microsoft.AspNetCore.Identity; -namespace FSH.Framework.Infrastructure.Identity.Users; +namespace FSH.Framework.Identity.Infrastructure.Users; public class FshUser : IdentityUser { public string? FirstName { get; set; } @@ -11,4 +11,4 @@ public class FshUser : IdentityUser public DateTime RefreshTokenExpiryTime { get; set; } public string? ObjectId { get; set; } -} +} \ No newline at end of file diff --git a/src/api/framework/Infrastructure/Identity/Users/Endpoints/GetUserEndpoint.cs b/src/framework/Modules/Identity/Modules.Identity/Features/v1/Users/GetUser/GetUserEndpoint.cs similarity index 86% rename from src/api/framework/Infrastructure/Identity/Users/Endpoints/GetUserEndpoint.cs rename to src/framework/Modules/Identity/Modules.Identity/Features/v1/Users/GetUser/GetUserEndpoint.cs index c23a8f5f40..8f0be5dc00 100644 --- a/src/api/framework/Infrastructure/Identity/Users/Endpoints/GetUserEndpoint.cs +++ b/src/framework/Modules/Identity/Modules.Identity/Features/v1/Users/GetUser/GetUserEndpoint.cs @@ -1,5 +1,5 @@ -using FSH.Framework.Core.Identity.Users.Abstractions; -using FSH.Framework.Infrastructure.Auth.Policy; +using FSH.Framework.Identity.Core.Users; +using FSH.Framework.Shared.Authorization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; @@ -18,4 +18,4 @@ internal static RouteHandlerBuilder MapGetUserEndpoint(this IEndpointRouteBuilde .RequirePermission("Permissions.Users.View") .WithDescription("Get another user's profile details by user ID."); } -} +} \ No newline at end of file diff --git a/src/api/framework/Infrastructure/Identity/Users/Endpoints/GetUserPermissionsEndpoint.cs b/src/framework/Modules/Identity/Modules.Identity/Features/v1/Users/GetUserPermissions/GetUserPermissionsEndpoint.cs similarity index 84% rename from src/api/framework/Infrastructure/Identity/Users/Endpoints/GetUserPermissionsEndpoint.cs rename to src/framework/Modules/Identity/Modules.Identity/Features/v1/Users/GetUserPermissions/GetUserPermissionsEndpoint.cs index 6ee0f74eee..33ff76dd5d 100644 --- a/src/api/framework/Infrastructure/Identity/Users/Endpoints/GetUserPermissionsEndpoint.cs +++ b/src/framework/Modules/Identity/Modules.Identity/Features/v1/Users/GetUserPermissions/GetUserPermissionsEndpoint.cs @@ -1,10 +1,10 @@ -using System.Security.Claims; -using FSH.Framework.Core.Exceptions; -using FSH.Framework.Core.Identity.Users.Abstractions; -using FSH.Starter.Shared.Authorization; +using FSH.Framework.Core.Exceptions; +using FSH.Framework.Identity.Core.Users; +using FSH.Framework.Shared.Extensions; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; +using System.Security.Claims; namespace FSH.Framework.Infrastructure.Identity.Users.Endpoints; public static class GetUserPermissionsEndpoint @@ -24,4 +24,4 @@ internal static RouteHandlerBuilder MapGetCurrentUserPermissionsEndpoint(this IE .WithSummary("Get current user permissions") .WithDescription("Get current user permissions"); } -} +} \ No newline at end of file diff --git a/src/api/framework/Infrastructure/Identity/Users/Endpoints/GetUserProfileEndpoint.cs b/src/framework/Modules/Identity/Modules.Identity/Features/v1/Users/GetUserProfile/GetUserProfileEndpoint.cs similarity index 90% rename from src/api/framework/Infrastructure/Identity/Users/Endpoints/GetUserProfileEndpoint.cs rename to src/framework/Modules/Identity/Modules.Identity/Features/v1/Users/GetUserProfile/GetUserProfileEndpoint.cs index 9f3ea36ab4..018ce5992d 100644 --- a/src/api/framework/Infrastructure/Identity/Users/Endpoints/GetUserProfileEndpoint.cs +++ b/src/framework/Modules/Identity/Modules.Identity/Features/v1/Users/GetUserProfile/GetUserProfileEndpoint.cs @@ -1,10 +1,10 @@ using FSH.Framework.Core.Exceptions; -using System.Security.Claims; -using FSH.Framework.Core.Identity.Users.Abstractions; +using FSH.Framework.Identity.Core.Users; +using FSH.Framework.Shared.Extensions; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; -using FSH.Starter.Shared.Authorization; +using System.Security.Claims; namespace FSH.Framework.Infrastructure.Identity.Users.Endpoints; public static class GetUserProfileEndpoint @@ -24,4 +24,4 @@ internal static RouteHandlerBuilder MapGetMeEndpoint(this IEndpointRouteBuilder .WithSummary("Get current user information based on token") .WithDescription("Get current user information based on token"); } -} +} \ No newline at end of file diff --git a/src/api/framework/Infrastructure/Identity/Users/Endpoints/GetUserRolesEndpoint.cs b/src/framework/Modules/Identity/Modules.Identity/Features/v1/Users/GetUserRoles/GetUserRolesEndpoint.cs similarity index 86% rename from src/api/framework/Infrastructure/Identity/Users/Endpoints/GetUserRolesEndpoint.cs rename to src/framework/Modules/Identity/Modules.Identity/Features/v1/Users/GetUserRoles/GetUserRolesEndpoint.cs index 757f842926..08a0e77cdb 100644 --- a/src/api/framework/Infrastructure/Identity/Users/Endpoints/GetUserRolesEndpoint.cs +++ b/src/framework/Modules/Identity/Modules.Identity/Features/v1/Users/GetUserRoles/GetUserRolesEndpoint.cs @@ -1,5 +1,5 @@ -using FSH.Framework.Core.Identity.Users.Abstractions; -using FSH.Framework.Infrastructure.Auth.Policy; +using FSH.Framework.Identity.Core.Users; +using FSH.Framework.Shared.Authorization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; @@ -18,4 +18,4 @@ internal static RouteHandlerBuilder MapGetUserRolesEndpoint(this IEndpointRouteB .RequirePermission("Permissions.Users.View") .WithDescription("get user roles"); } -} +} \ No newline at end of file diff --git a/src/api/framework/Infrastructure/Identity/Users/Endpoints/GetUsersListEndpoint.cs b/src/framework/Modules/Identity/Modules.Identity/Features/v1/Users/GetUsers/GetUsersListEndpoint.cs similarity index 86% rename from src/api/framework/Infrastructure/Identity/Users/Endpoints/GetUsersListEndpoint.cs rename to src/framework/Modules/Identity/Modules.Identity/Features/v1/Users/GetUsers/GetUsersListEndpoint.cs index 0743634ca8..4662de6d13 100644 --- a/src/api/framework/Infrastructure/Identity/Users/Endpoints/GetUsersListEndpoint.cs +++ b/src/framework/Modules/Identity/Modules.Identity/Features/v1/Users/GetUsers/GetUsersListEndpoint.cs @@ -1,5 +1,5 @@ -using FSH.Framework.Core.Identity.Users.Abstractions; -using FSH.Framework.Infrastructure.Auth.Policy; +using FSH.Framework.Identity.Core.Users; +using FSH.Framework.Shared.Authorization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; @@ -18,4 +18,4 @@ internal static RouteHandlerBuilder MapGetUsersListEndpoint(this IEndpointRouteB .RequirePermission("Permissions.Users.View") .WithDescription("get users list"); } -} +} \ No newline at end of file diff --git a/src/api/framework/Infrastructure/Identity/Users/Endpoints/RegisterUserEndpoint.cs b/src/framework/Modules/Identity/Modules.Identity/Features/v1/Users/RegisterUser/RegisterUserEndpoint.cs similarity index 63% rename from src/api/framework/Infrastructure/Identity/Users/Endpoints/RegisterUserEndpoint.cs rename to src/framework/Modules/Identity/Modules.Identity/Features/v1/Users/RegisterUser/RegisterUserEndpoint.cs index 84b98a911f..39e5c03064 100644 --- a/src/api/framework/Infrastructure/Identity/Users/Endpoints/RegisterUserEndpoint.cs +++ b/src/framework/Modules/Identity/Modules.Identity/Features/v1/Users/RegisterUser/RegisterUserEndpoint.cs @@ -1,6 +1,6 @@ -using FSH.Framework.Core.Identity.Users.Abstractions; -using FSH.Framework.Core.Identity.Users.Features.RegisterUser; -using FSH.Framework.Infrastructure.Auth.Policy; +using FSH.Framework.Identity.Core.Users; +using FSH.Framework.Identity.Endpoints.v1.Users.RegisterUser; +using FSH.Framework.Shared.Authorization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; @@ -16,11 +16,19 @@ internal static RouteHandlerBuilder MapRegisterUserEndpoint(this IEndpointRouteB CancellationToken cancellationToken) => { var origin = $"{context.Request.Scheme}://{context.Request.Host.Value}{context.Request.PathBase.Value}"; - return service.RegisterAsync(request, origin, cancellationToken); + return service.RegisterAsync(request.FirstName, + request.LastName, + request.Email, + request.UserName, + request.Password, + request.ConfirmPassword, + request.PhoneNumber, + origin, + cancellationToken); }) .WithName(nameof(RegisterUserEndpoint)) .WithSummary("register user") .RequirePermission("Permissions.Users.Create") .WithDescription("register user"); } -} +} \ No newline at end of file diff --git a/src/framework/Modules/Identity/Modules.Identity/Features/v1/Users/ResetPassword/ResetPasswordCommandValidator.cs b/src/framework/Modules/Identity/Modules.Identity/Features/v1/Users/ResetPassword/ResetPasswordCommandValidator.cs new file mode 100644 index 0000000000..87451c9941 --- /dev/null +++ b/src/framework/Modules/Identity/Modules.Identity/Features/v1/Users/ResetPassword/ResetPasswordCommandValidator.cs @@ -0,0 +1,13 @@ +using FluentValidation; + +namespace FSH.Framework.Identity.Endpoints.v1.Users.ResetPassword; + +public class ResetPasswordCommandValidator : AbstractValidator +{ + public ResetPasswordCommandValidator() + { + RuleFor(x => x.Email).NotEmpty().EmailAddress(); + RuleFor(x => x.Password).NotEmpty(); + RuleFor(x => x.Token).NotEmpty(); + } +} \ No newline at end of file diff --git a/src/api/framework/Infrastructure/Identity/Users/Endpoints/ResetPasswordEndpoint.cs b/src/framework/Modules/Identity/Modules.Identity/Features/v1/Users/ResetPassword/ResetPasswordEndpoint.cs similarity index 61% rename from src/api/framework/Infrastructure/Identity/Users/Endpoints/ResetPasswordEndpoint.cs rename to src/framework/Modules/Identity/Modules.Identity/Features/v1/Users/ResetPassword/ResetPasswordEndpoint.cs index a1f7187208..20dec931b4 100644 --- a/src/api/framework/Infrastructure/Identity/Users/Endpoints/ResetPasswordEndpoint.cs +++ b/src/framework/Modules/Identity/Modules.Identity/Features/v1/Users/ResetPassword/ResetPasswordEndpoint.cs @@ -1,8 +1,8 @@ using FluentValidation; using FluentValidation.Results; -using FSH.Framework.Core.Identity.Users.Abstractions; -using FSH.Framework.Core.Identity.Users.Features.ResetPassword; -using FSH.Starter.Shared.Authorization; +using FSH.Framework.Identity.Core.Users; +using FSH.Framework.Identity.Endpoints.v1.Users.ResetPassword; +using FSH.Modules.Common.Shared.Constants; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -14,7 +14,11 @@ public static class ResetPasswordEndpoint { internal static RouteHandlerBuilder MapResetPasswordEndpoint(this IEndpointRouteBuilder endpoints) { - return endpoints.MapPost("/reset-password", async (ResetPasswordCommand command, [FromHeader(Name = TenantConstants.Identifier)] string tenant, IValidator validator, IUserService userService, CancellationToken cancellationToken) => + return endpoints.MapPost("/reset-password", + async ([FromBody] ResetPasswordCommand command, + [FromHeader(Name = MutiTenancyConstants.Identifier)] string tenant, + IValidator validator, + IUserService userService, CancellationToken cancellationToken) => { ValidationResult result = await validator.ValidateAsync(command, cancellationToken); if (!result.IsValid) @@ -22,7 +26,7 @@ internal static RouteHandlerBuilder MapResetPasswordEndpoint(this IEndpointRoute return Results.ValidationProblem(result.ToDictionary()); } - await userService.ResetPasswordAsync(command, cancellationToken); + await userService.ResetPasswordAsync(command.Email, command.Password, command.Token, cancellationToken); return Results.Ok("Password has been reset."); }) .WithName(nameof(ResetPasswordEndpoint)) @@ -31,4 +35,4 @@ internal static RouteHandlerBuilder MapResetPasswordEndpoint(this IEndpointRoute .AllowAnonymous(); } -} +} \ No newline at end of file diff --git a/src/api/framework/Infrastructure/Identity/Users/Endpoints/SelfRegisterUserEndpoint.cs b/src/framework/Modules/Identity/Modules.Identity/Features/v1/Users/SelfRegistration/SelfRegisterUserEndpoint.cs similarity index 60% rename from src/api/framework/Infrastructure/Identity/Users/Endpoints/SelfRegisterUserEndpoint.cs rename to src/framework/Modules/Identity/Modules.Identity/Features/v1/Users/SelfRegistration/SelfRegisterUserEndpoint.cs index 8af1fc52f0..f038bccc4d 100644 --- a/src/api/framework/Infrastructure/Identity/Users/Endpoints/SelfRegisterUserEndpoint.cs +++ b/src/framework/Modules/Identity/Modules.Identity/Features/v1/Users/SelfRegistration/SelfRegisterUserEndpoint.cs @@ -1,7 +1,7 @@ -using FSH.Framework.Core.Identity.Users.Abstractions; -using FSH.Framework.Core.Identity.Users.Features.RegisterUser; -using FSH.Framework.Infrastructure.Auth.Policy; -using FSH.Starter.Shared.Authorization; +using FSH.Framework.Identity.Core.Users; +using FSH.Framework.Identity.Endpoints.v1.Users.RegisterUser; +using FSH.Framework.Shared.Authorization; +using FSH.Modules.Common.Shared.Constants; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -13,13 +13,21 @@ public static class SelfRegisterUserEndpoint internal static RouteHandlerBuilder MapSelfRegisterUserEndpoint(this IEndpointRouteBuilder endpoints) { return endpoints.MapPost("/self-register", (RegisterUserCommand request, - [FromHeader(Name = TenantConstants.Identifier)] string tenant, + [FromHeader(Name = MutiTenancyConstants.Identifier)] string tenant, IUserService service, HttpContext context, CancellationToken cancellationToken) => { var origin = $"{context.Request.Scheme}://{context.Request.Host.Value}{context.Request.PathBase.Value}"; - return service.RegisterAsync(request, origin, cancellationToken); + return service.RegisterAsync(request.FirstName, + request.LastName, + request.Email, + request.UserName, + request.Password, + request.ConfirmPassword, + request.PhoneNumber, + origin, + cancellationToken); }) .WithName(nameof(SelfRegisterUserEndpoint)) .WithSummary("self register user") @@ -27,4 +35,4 @@ internal static RouteHandlerBuilder MapSelfRegisterUserEndpoint(this IEndpointRo .WithDescription("self register user") .AllowAnonymous(); } -} +} \ No newline at end of file diff --git a/src/api/framework/Infrastructure/Identity/Users/Endpoints/ToggleUserStatusEndpoint.cs b/src/framework/Modules/Identity/Modules.Identity/Features/v1/Users/ToggleUserStatus/ToggleUserStatusEndpoint.cs similarity index 75% rename from src/api/framework/Infrastructure/Identity/Users/Endpoints/ToggleUserStatusEndpoint.cs rename to src/framework/Modules/Identity/Modules.Identity/Features/v1/Users/ToggleUserStatus/ToggleUserStatusEndpoint.cs index 7e705e3294..b80dbb88e5 100644 --- a/src/api/framework/Infrastructure/Identity/Users/Endpoints/ToggleUserStatusEndpoint.cs +++ b/src/framework/Modules/Identity/Modules.Identity/Features/v1/Users/ToggleUserStatus/ToggleUserStatusEndpoint.cs @@ -1,6 +1,6 @@ using FluentValidation; -using FSH.Framework.Core.Identity.Users.Abstractions; -using FSH.Framework.Core.Identity.Users.Features.ToggleUserStatus; +using FSH.Framework.Identity.Core.Users; +using FSH.Framework.Identity.Endpoints.v1.Users.ToggleUserStatus; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -13,8 +13,8 @@ public static class ToggleUserStatusEndpoint internal static RouteHandlerBuilder ToggleUserStatusEndpointEndpoint(this IEndpointRouteBuilder endpoints) { return endpoints.MapPost("/{id:guid}/toggle-status", async ( - string id, - ToggleUserStatusCommand command, + [FromQuery] string id, + [FromBody] ToggleUserStatusCommand command, [FromServices] IUserService userService, CancellationToken cancellationToken) => { @@ -23,7 +23,7 @@ internal static RouteHandlerBuilder ToggleUserStatusEndpointEndpoint(this IEndpo return Results.BadRequest(); } - await userService.ToggleStatusAsync(command, cancellationToken); + await userService.ToggleStatusAsync(command.ActivateUser, command.UserId, cancellationToken); return Results.Ok(); }) .WithName(nameof(ToggleUserStatusEndpoint)) @@ -32,4 +32,4 @@ internal static RouteHandlerBuilder ToggleUserStatusEndpointEndpoint(this IEndpo .AllowAnonymous(); } -} +} \ No newline at end of file diff --git a/src/framework/Modules/Identity/Modules.Identity/Features/v1/Users/UpdateUser/UpdateUserCommandValidator.cs b/src/framework/Modules/Identity/Modules.Identity/Features/v1/Users/UpdateUser/UpdateUserCommandValidator.cs new file mode 100644 index 0000000000..a9f8404907 --- /dev/null +++ b/src/framework/Modules/Identity/Modules.Identity/Features/v1/Users/UpdateUser/UpdateUserCommandValidator.cs @@ -0,0 +1,41 @@ +using FluentValidation; +using FSH.Framework.Core.Storage; +using FSH.Framework.Identity.Contracts.v1.Users.UpdateUser; + +namespace FSH.Framework.Identity.v1.Users.UpdateUser; +public class UpdateUserCommandValidator : AbstractValidator +{ + public UpdateUserCommandValidator() + { + RuleFor(x => x.Id) + .NotEmpty() + .WithMessage("User ID is required."); + + RuleFor(x => x.FirstName) + .MaximumLength(50) + .When(x => !string.IsNullOrWhiteSpace(x.FirstName)); + + RuleFor(x => x.LastName) + .MaximumLength(50) + .When(x => !string.IsNullOrWhiteSpace(x.LastName)); + + RuleFor(x => x.PhoneNumber) + .MaximumLength(15) + .When(x => !string.IsNullOrWhiteSpace(x.PhoneNumber)); + + RuleFor(x => x.Email) + .EmailAddress() + .When(x => !string.IsNullOrWhiteSpace(x.Email)); + + When(x => x.Image is not null, () => + { + RuleFor(x => x.Image!) + .SetValidator(new UserImageValidator(FileType.Image)); + }); + + // Prevent deleting and uploading image at the same time + RuleFor(x => x) + .Must(x => !(x.DeleteCurrentImage && x.Image is not null)) + .WithMessage("You cannot upload a new image and delete the current one simultaneously."); + } +} \ No newline at end of file diff --git a/src/api/framework/Infrastructure/Identity/Users/Endpoints/UpdateUserEndpoint.cs b/src/framework/Modules/Identity/Modules.Identity/Features/v1/Users/UpdateUser/UpdateUserEndpoint.cs similarity index 52% rename from src/api/framework/Infrastructure/Identity/Users/Endpoints/UpdateUserEndpoint.cs rename to src/framework/Modules/Identity/Modules.Identity/Features/v1/Users/UpdateUser/UpdateUserEndpoint.cs index 6d137e8d99..74c99e3a5b 100644 --- a/src/api/framework/Infrastructure/Identity/Users/Endpoints/UpdateUserEndpoint.cs +++ b/src/framework/Modules/Identity/Modules.Identity/Features/v1/Users/UpdateUser/UpdateUserEndpoint.cs @@ -1,30 +1,35 @@ -using System.Security.Claims; -using FSH.Framework.Core.Exceptions; -using FSH.Framework.Core.Identity.Users.Abstractions; -using FSH.Framework.Core.Identity.Users.Features.UpdateUser; -using FSH.Framework.Infrastructure.Auth.Policy; -using FSH.Starter.Shared.Authorization; -using MediatR; +using FSH.Framework.Core.Exceptions; +using FSH.Framework.Identity.Contracts.v1.Users.UpdateUser; +using FSH.Framework.Identity.Core.Users; +using FSH.Framework.Shared.Authorization; +using FSH.Framework.Shared.Extensions; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; +using System.Security.Claims; namespace FSH.Framework.Infrastructure.Identity.Users.Endpoints; public static class UpdateUserEndpoint { internal static RouteHandlerBuilder MapUpdateUserEndpoint(this IEndpointRouteBuilder endpoints) { - return endpoints.MapPut("/profile", (UpdateUserCommand request, ISender mediator, ClaimsPrincipal user, IUserService service) => + return endpoints.MapPut("/profile", ([FromBody] UpdateUserCommand request, ClaimsPrincipal user, IUserService service) => { if (user.GetUserId() is not { } userId || string.IsNullOrEmpty(userId)) { throw new UnauthorizedException(); } - return service.UpdateAsync(request, userId); + return service.UpdateAsync(request.Id, + request.FirstName, + request.LastName, + request.PhoneNumber, + request.Image, + request.DeleteCurrentImage); }) .WithName(nameof(UpdateUserEndpoint)) .WithSummary("update user profile") .RequirePermission("Permissions.Users.Update") .WithDescription("update user profile"); } -} +} \ No newline at end of file diff --git a/src/framework/Modules/Identity/Modules.Identity/Features/v1/Users/UserImageValidator.cs b/src/framework/Modules/Identity/Modules.Identity/Features/v1/Users/UserImageValidator.cs new file mode 100644 index 0000000000..129f58b50a --- /dev/null +++ b/src/framework/Modules/Identity/Modules.Identity/Features/v1/Users/UserImageValidator.cs @@ -0,0 +1,21 @@ +using FluentValidation; +using FSH.Framework.Core.Storage; + +namespace FSH.Framework.Identity.v1.Users; +public class UserImageValidator : AbstractValidator +{ + public UserImageValidator(FileType fileType) + { + var rules = FileTypeMetadata.GetRules(fileType); + + RuleFor(x => x.FileName) + .NotEmpty() + .Must(file => rules.AllowedExtensions.Any(ext => file.EndsWith(ext, StringComparison.OrdinalIgnoreCase))) + .WithMessage($"Only these extensions are allowed: {string.Join(", ", rules.AllowedExtensions)}"); + + RuleFor(x => x.Data) + .NotEmpty() + .Must(data => data.Count <= rules.MaxSizeInMB * 1024 * 1024) + .WithMessage($"File must be <= {rules.MaxSizeInMB} MB."); + } +} \ No newline at end of file diff --git a/src/framework/Modules/Identity/Modules.Identity/Identity.Core/Identity.Core.csproj b/src/framework/Modules/Identity/Modules.Identity/Identity.Core/Identity.Core.csproj new file mode 100644 index 0000000000..60ee64c12c --- /dev/null +++ b/src/framework/Modules/Identity/Modules.Identity/Identity.Core/Identity.Core.csproj @@ -0,0 +1,9 @@ + + + FSH.Framework.Identity.Core + FSH.Framework.Identity.Core + + + + + diff --git a/src/framework/Modules/Identity/Modules.Identity/Identity.Endpoints/Identity.Endpoints.csproj b/src/framework/Modules/Identity/Modules.Identity/Identity.Endpoints/Identity.Endpoints.csproj new file mode 100644 index 0000000000..7fef105835 --- /dev/null +++ b/src/framework/Modules/Identity/Modules.Identity/Identity.Endpoints/Identity.Endpoints.csproj @@ -0,0 +1,17 @@ + + + FSH.Framework.Identity.Endpoints + FSH.Framework.Identity.Endpoints + + + + + + + + + + + + + diff --git a/src/framework/Modules/Identity/Modules.Identity/Identity.Infrastructure/Identity.Infrastructure.csproj b/src/framework/Modules/Identity/Modules.Identity/Identity.Infrastructure/Identity.Infrastructure.csproj new file mode 100644 index 0000000000..e3e60747c6 --- /dev/null +++ b/src/framework/Modules/Identity/Modules.Identity/Identity.Infrastructure/Identity.Infrastructure.csproj @@ -0,0 +1,20 @@ + + + FSH.Framework.Identity.Infrastructure + FSH.Framework.Identity.Infrastructure + + + + + + + + + + + + + + + + diff --git a/src/framework/Modules/Identity/Modules.Identity/IdentityModule.cs b/src/framework/Modules/Identity/Modules.Identity/IdentityModule.cs new file mode 100644 index 0000000000..9811996316 --- /dev/null +++ b/src/framework/Modules/Identity/Modules.Identity/IdentityModule.cs @@ -0,0 +1,93 @@ +using Asp.Versioning; +using FluentValidation; +using FSH.Framework.Core.ExecutionContext; +using FSH.Framework.Core.Persistence; +using FSH.Framework.Identity.Authorization; +using FSH.Framework.Identity.Core.Roles; +using FSH.Framework.Identity.Core.Tokens; +using FSH.Framework.Identity.Core.Users; +using FSH.Framework.Identity.Infrastructure.Data; +using FSH.Framework.Identity.Infrastructure.Roles; +using FSH.Framework.Identity.Infrastructure.Tokens; +using FSH.Framework.Identity.Infrastructure.Users; +using FSH.Framework.Identity.v1.Tokens.TokenGeneration; +using FSH.Framework.Identity.v1.Users; +using FSH.Framework.Infrastructure.Auth; +using FSH.Framework.Infrastructure.Auth.Jwt; +using FSH.Framework.Infrastructure.Identity.Roles; +using FSH.Framework.Infrastructure.Identity.Roles.Endpoints; +using FSH.Framework.Infrastructure.Identity.Users.Services; +using FSH.Framework.Infrastructure.Messaging.CQRS; +using FSH.Framework.Infrastructure.Persistence; +using FSH.Framework.Modules.Identity.Contracts; +using FSH.Modules.Common.Infrastructure.Modules; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using System.Reflection; + +namespace FSH.Modules.Identity; +public class IdentityModule : IModule +{ + public void AddModule(IServiceCollection services, IConfiguration config) + { + ArgumentNullException.ThrowIfNull(services); + + var assemblies = new Assembly[] + { + typeof(IdentityModule).Assembly + }; + services.RegisterCommandAndQueryHandlers(assemblies); + var scanResults = AssemblyScanner + .FindValidatorsInAssemblies(assemblies, true) + .Where(r => r.ValidatorType != typeof(UserImageValidator)) + .ToList(); + + foreach (var result in scanResults) + { + services.AddScoped(result.InterfaceType, result.ValidatorType); + } + services.AddScoped(); + services.AddSingleton(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(sp => (ICurrentUserInitializer)sp.GetRequiredService()); + services.AddTransient(); + services.AddTransient(); + services.BindDbContext(); + services.AddScoped(); + services.AddIdentity(options => + { + options.Password.RequiredLength = IdentityModuleConstants.PasswordLength; + options.Password.RequireDigit = false; + options.Password.RequireLowercase = false; + options.Password.RequireNonAlphanumeric = false; + options.Password.RequireUppercase = false; + options.User.RequireUniqueEmail = true; + }) + .AddEntityFrameworkStores() + .AddDefaultTokenProviders(); + services.ConfigureJwtAuth(); + } + + public void ConfigureModule(WebApplication app) + { + var apiVersionSet = app.NewApiVersionSet() + .HasApiVersion(new ApiVersion(1)) + .ReportApiVersions() + .Build(); + + var group = app + .MapGroup("api/v{version:apiVersion}/identity") + .WithTags("Identity") + .WithOpenApi() + .WithApiVersionSet(apiVersionSet); + + TokenGenerationEndpoint.Map(group).AllowAnonymous(); + GetRolesEndpoint.MapGetRolesEndpoint(group); + GetRoleByIdEndpoint.MapGetRoleEndpoint(group); + } +} \ No newline at end of file diff --git a/src/framework/Modules/Identity/Modules.Identity/Modules.Identity.csproj b/src/framework/Modules/Identity/Modules.Identity/Modules.Identity.csproj new file mode 100644 index 0000000000..6c5af73dfc --- /dev/null +++ b/src/framework/Modules/Identity/Modules.Identity/Modules.Identity.csproj @@ -0,0 +1,17 @@ + + + FSH.Modules.Identity + FSH.Modules.Identity + + + net9.0 + enable + enable + + + + + + + + diff --git a/src/api/framework/Infrastructure/Auth/Jwt/ConfigureJwtBearerOptions.cs b/src/framework/Modules/Identity/Modules.Identity/Options/ConfigureJwtBearerOptions.cs similarity index 80% rename from src/api/framework/Infrastructure/Auth/Jwt/ConfigureJwtBearerOptions.cs rename to src/framework/Modules/Identity/Modules.Identity/Options/ConfigureJwtBearerOptions.cs index 8e495d207b..fb7d35667e 100644 --- a/src/api/framework/Infrastructure/Auth/Jwt/ConfigureJwtBearerOptions.cs +++ b/src/framework/Modules/Identity/Modules.Identity/Options/ConfigureJwtBearerOptions.cs @@ -1,12 +1,11 @@ -using System.Security.Claims; -using System.Text; -using FSH.Framework.Core.Auth.Jwt; -using FSH.Framework.Core.Exceptions; +using FSH.Framework.Core.Exceptions; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; +using System.Security.Claims; +using System.Text; -namespace FSH.Framework.Infrastructure.Auth.Jwt; +namespace FSH.Framework.Identity.Options; public class ConfigureJwtBearerOptions : IConfigureNamedOptions { private readonly JwtOptions _options; @@ -36,10 +35,10 @@ public void Configure(string? name, JwtBearerOptions options) { ValidateIssuerSigningKey = true, IssuerSigningKey = new SymmetricSecurityKey(key), - ValidIssuer = JwtAuthConstants.Issuer, + ValidIssuer = _options.Issuer, ValidateIssuer = true, ValidateLifetime = true, - ValidAudience = JwtAuthConstants.Audience, + ValidAudience = _options.Audience, ValidateAudience = true, RoleClaimType = ClaimTypes.Role, ClockSkew = TimeSpan.Zero @@ -49,9 +48,14 @@ public void Configure(string? name, JwtBearerOptions options) OnChallenge = context => { context.HandleResponse(); + if (!context.Response.HasStarted) { - throw new UnauthorizedException(); + var path = context.HttpContext.Request.Path; + var method = context.HttpContext.Request.Method; + + // You can include more details if needed like headers, etc. + throw new UnauthorizedException($"Unauthorized access to {method} {path}"); } return Task.CompletedTask; @@ -72,4 +76,4 @@ public void Configure(string? name, JwtBearerOptions options) } }; } -} +} \ No newline at end of file diff --git a/src/framework/Modules/Identity/Modules.Identity/Options/JwtOptions.cs b/src/framework/Modules/Identity/Modules.Identity/Options/JwtOptions.cs new file mode 100644 index 0000000000..82e47ac812 --- /dev/null +++ b/src/framework/Modules/Identity/Modules.Identity/Options/JwtOptions.cs @@ -0,0 +1,31 @@ +using System.ComponentModel.DataAnnotations; + +namespace FSH.Framework.Identity.Options; +public class JwtOptions : IValidatableObject +{ + public string Key { get; set; } = string.Empty; + public string Issuer { get; set; } = string.Empty; + public string Audience { get; set; } = string.Empty; + + public int TokenExpirationInMinutes { get; set; } = 60; + + public int RefreshTokenExpirationInDays { get; set; } = 7; + + public IEnumerable Validate(ValidationContext validationContext) + { + if (string.IsNullOrEmpty(Key)) + { + yield return new ValidationResult("No Key defined in JwtOptions config", [nameof(Key)]); + } + + if (string.IsNullOrEmpty(Issuer)) + { + yield return new ValidationResult("No Issuer defined in JwtOptions config", [nameof(Key)]); + } + + if (string.IsNullOrEmpty(Audience)) + { + yield return new ValidationResult("No Audience defined in JwtOptions config", [nameof(Key)]); + } + } +} \ No newline at end of file diff --git a/src/api/framework/Infrastructure/Identity/Users/Services/CurrentUserService.cs b/src/framework/Modules/Identity/Modules.Identity/Services/CurrentUserService.cs similarity index 74% rename from src/api/framework/Infrastructure/Identity/Users/Services/CurrentUserService.cs rename to src/framework/Modules/Identity/Modules.Identity/Services/CurrentUserService.cs index 2fcfea6fb5..dd9d3fb126 100644 --- a/src/api/framework/Infrastructure/Identity/Users/Services/CurrentUserService.cs +++ b/src/framework/Modules/Identity/Modules.Identity/Services/CurrentUserService.cs @@ -1,9 +1,9 @@ -using System.Security.Claims; -using FSH.Framework.Core.Exceptions; -using FSH.Framework.Core.Identity.Users.Abstractions; -using FSH.Starter.Shared.Authorization; +using FSH.Framework.Core.ExecutionContext; +using FSH.Framework.Shared.Extensions; +using FSH.Modules.Common.Core.Exceptions; +using System.Security.Claims; -namespace FSH.Framework.Infrastructure.Identity.Users.Services; +namespace FSH.Framework.Identity.Infrastructure.Users; public class CurrentUser : ICurrentUser, ICurrentUserInitializer { private ClaimsPrincipal? _user; @@ -40,7 +40,7 @@ public void SetCurrentUser(ClaimsPrincipal user) { if (_user != null) { - throw new FshException("Method reserved for in-scope initialization"); + throw new CustomException("Method reserved for in-scope initialization"); } _user = user; @@ -50,7 +50,7 @@ public void SetCurrentUserId(string userId) { if (_userId != Guid.Empty) { - throw new FshException("Method reserved for in-scope initialization"); + throw new CustomException("Method reserved for in-scope initialization"); } if (!string.IsNullOrEmpty(userId)) @@ -58,4 +58,4 @@ public void SetCurrentUserId(string userId) _userId = Guid.Parse(userId); } } -} +} \ No newline at end of file diff --git a/src/framework/Modules/Identity/Modules.Identity/Services/IRoleService.cs b/src/framework/Modules/Identity/Modules.Identity/Services/IRoleService.cs new file mode 100644 index 0000000000..e940f3ab7e --- /dev/null +++ b/src/framework/Modules/Identity/Modules.Identity/Services/IRoleService.cs @@ -0,0 +1,12 @@ +namespace FSH.Framework.Identity.Core.Roles; + +public interface IRoleService +{ + Task> GetRolesAsync(); + Task GetRoleAsync(string id); + Task CreateOrUpdateRoleAsync(string roleId, string name, string description); + Task DeleteRoleAsync(string id); + Task GetWithPermissionsAsync(string id, CancellationToken cancellationToken); + + Task UpdatePermissionsAsync(string roleId, List permissions); +} \ No newline at end of file diff --git a/src/framework/Modules/Identity/Modules.Identity/Services/ITokenService.cs b/src/framework/Modules/Identity/Modules.Identity/Services/ITokenService.cs new file mode 100644 index 0000000000..bde1674a6b --- /dev/null +++ b/src/framework/Modules/Identity/Modules.Identity/Services/ITokenService.cs @@ -0,0 +1,6 @@ +namespace FSH.Framework.Identity.Core.Tokens; +public interface ITokenService +{ + Task GenerateTokenAsync(string email, string password, string ipAddress, CancellationToken cancellationToken); + Task RefreshTokenAsync(string token, string refreshToken, string ipAddress, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/framework/Modules/Identity/Modules.Identity/Services/IUserService.cs b/src/framework/Modules/Identity/Modules.Identity/Services/IUserService.cs new file mode 100644 index 0000000000..1bfa52b8f5 --- /dev/null +++ b/src/framework/Modules/Identity/Modules.Identity/Services/IUserService.cs @@ -0,0 +1,33 @@ +using FSH.Framework.Core.Storage; +using FSH.Framework.Identity.Core.Roles; +using System.Security.Claims; + +namespace FSH.Framework.Identity.Core.Users; +public interface IUserService +{ + Task ExistsWithNameAsync(string name); + Task ExistsWithEmailAsync(string email, string? exceptId = null); + Task ExistsWithPhoneNumberAsync(string phoneNumber, string? exceptId = null); + Task> GetListAsync(CancellationToken cancellationToken); + Task GetCountAsync(CancellationToken cancellationToken); + Task GetAsync(string userId, CancellationToken cancellationToken); + Task ToggleStatusAsync(bool activateUser, string userId, CancellationToken cancellationToken); + Task GetOrCreateFromPrincipalAsync(ClaimsPrincipal principal); + Task RegisterAsync(string firstName, string lastName, string email, string userName, string password, string confirmPassword, string phoneNumber, string origin, CancellationToken cancellationToken); + Task UpdateAsync(string userId, string firstName, string lastName, string phoneNumber, FileUploadRequest image, bool deleteCurrentImage); + Task DeleteAsync(string userId); + Task ConfirmEmailAsync(string userId, string code, string tenant, CancellationToken cancellationToken); + Task ConfirmPhoneNumberAsync(string userId, string code); + + // permisions + Task HasPermissionAsync(string userId, string permission, CancellationToken cancellationToken = default); + + // passwords + Task ForgotPasswordAsync(string email, string origin, CancellationToken cancellationToken); + Task ResetPasswordAsync(string email, string password, string token, CancellationToken cancellationToken); + Task?> GetPermissionsAsync(string userId, CancellationToken cancellationToken); + + Task ChangePasswordAsync(string password, string newPassword, string confirmNewPassword, string userId); + Task AssignRolesAsync(string userId, List userRoles, CancellationToken cancellationToken); + Task> GetUserRolesAsync(string userId, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/api/framework/Infrastructure/Identity/Tokens/TokenService.cs b/src/framework/Modules/Identity/Modules.Identity/Services/TokenService.cs similarity index 70% rename from src/api/framework/Infrastructure/Identity/Tokens/TokenService.cs rename to src/framework/Modules/Identity/Modules.Identity/Services/TokenService.cs index 4f0bc60145..aa7bf58611 100644 --- a/src/api/framework/Infrastructure/Identity/Tokens/TokenService.cs +++ b/src/framework/Modules/Identity/Modules.Identity/Services/TokenService.cs @@ -1,32 +1,35 @@ -using System.IdentityModel.Tokens.Jwt; -using System.Security.Claims; -using System.Security.Cryptography; -using System.Text; -using Finbuckle.MultiTenant.Abstractions; -using FSH.Framework.Core.Auth.Jwt; +using Finbuckle.MultiTenant.Abstractions; +using FSH.Framework.Auditing.Contracts.Dtos; +using FSH.Framework.Auditing.Contracts.Enums; +using FSH.Framework.Auditing.Contracts.Events.IntegrationEvents; using FSH.Framework.Core.Exceptions; -using FSH.Framework.Core.Identity.Tokens; -using FSH.Framework.Core.Identity.Tokens.Features.Generate; -using FSH.Framework.Core.Identity.Tokens.Features.Refresh; -using FSH.Framework.Core.Identity.Tokens.Models; -using FSH.Framework.Infrastructure.Auth.Jwt; -using FSH.Framework.Infrastructure.Identity.Audit; -using FSH.Framework.Infrastructure.Identity.Users; -using FSH.Framework.Infrastructure.Tenant; -using FSH.Starter.Shared.Authorization; -using MediatR; +using FSH.Framework.Core.Messaging.Events; +using FSH.Framework.Identity.Core.Tokens; +using FSH.Framework.Identity.Infrastructure.Users; +using FSH.Framework.Identity.Options; +using FSH.Framework.Shared.Constants; +using FSH.Framework.Shared.Multitenancy; +using FSH.Modules.Common.Shared.Constants; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; -namespace FSH.Framework.Infrastructure.Identity.Tokens; +namespace FSH.Framework.Identity.Infrastructure.Tokens; public sealed class TokenService : ITokenService { private readonly UserManager _userManager; private readonly IMultiTenantContextAccessor? _multiTenantContextAccessor; private readonly JwtOptions _jwtOptions; - private readonly IPublisher _publisher; - public TokenService(IOptions jwtOptions, UserManager userManager, IMultiTenantContextAccessor? multiTenantContextAccessor, IPublisher publisher) + private readonly IEventPublisher _publisher; + public TokenService( + IOptions jwtOptions, + UserManager userManager, + IMultiTenantContextAccessor? multiTenantContextAccessor, + IEventPublisher publisher) { _jwtOptions = jwtOptions.Value; _userManager = userManager ?? throw new ArgumentNullException(nameof(userManager)); @@ -34,13 +37,18 @@ public TokenService(IOptions jwtOptions, UserManager userMa _publisher = publisher; } - public async Task GenerateTokenAsync(TokenGenerationCommand request, string ipAddress, CancellationToken cancellationToken) + public async Task GenerateTokenAsync( + string email, + string password, + string ipAddress, + CancellationToken cancellationToken) { var currentTenant = _multiTenantContextAccessor!.MultiTenantContext.TenantInfo; if (currentTenant == null) throw new UnauthorizedException(); + if (string.IsNullOrWhiteSpace(currentTenant.Id) - || await _userManager.FindByEmailAsync(request.Email.Trim().Normalize()) is not { } user - || !await _userManager.CheckPasswordAsync(user, request.Password)) + || await _userManager.FindByEmailAsync(email.Trim().Normalize()) is not { } user + || !await _userManager.CheckPasswordAsync(user, password)) { throw new UnauthorizedException(); } @@ -55,7 +63,7 @@ public async Task GenerateTokenAsync(TokenGenerationCommand reque throw new UnauthorizedException("email not confirmed"); } - if (currentTenant.Id != TenantConstants.Root.Id) + if (currentTenant.Id != MutiTenancyConstants.Root.Id) { if (!currentTenant.IsActive) { @@ -72,9 +80,13 @@ public async Task GenerateTokenAsync(TokenGenerationCommand reque } - public async Task RefreshTokenAsync(RefreshTokenCommand request, string ipAddress, CancellationToken cancellationToken) + public async Task RefreshTokenAsync( + string token, + string refreshToken, + string ipAddress, + CancellationToken cancellationToken) { - var userPrincipal = GetPrincipalFromExpiredToken(request.Token); + var userPrincipal = GetPrincipalFromExpiredToken(token); var userId = _userManager.GetUserId(userPrincipal)!; var user = await _userManager.FindByIdAsync(userId); if (user is null) @@ -82,14 +94,16 @@ public async Task RefreshTokenAsync(RefreshTokenCommand request, throw new UnauthorizedException(); } - if (user.RefreshToken != request.RefreshToken || user.RefreshTokenExpiryTime <= DateTime.UtcNow) + if (user.RefreshToken != refreshToken || user.RefreshTokenExpiryTime <= DateTime.UtcNow) { throw new UnauthorizedException("Invalid Refresh Token"); } return await GenerateTokensAndUpdateUser(user, ipAddress); } - private async Task GenerateTokensAndUpdateUser(FshUser user, string ipAddress) + private async Task GenerateTokensAndUpdateUser( + FshUser user, + string ipAddress) { string token = GenerateJwt(user, ipAddress); @@ -98,19 +112,22 @@ private async Task GenerateTokensAndUpdateUser(FshUser user, stri await _userManager.UpdateAsync(user); - await _publisher.Publish(new AuditPublishedEvent(new() + var trailDtos = new List { - new() - { + new() { Id = Guid.NewGuid(), - Operation = "Token Generated", - Entity = "Identity", + DateTime = DateTimeOffset.UtcNow, UserId = new Guid(user.Id), - DateTime = DateTime.UtcNow, + Operation = AuditOperation.Create, + Description = "Token Generated", + EntityName = "Identity" } - })); + }; + + await _publisher.PublishAsync(new AuditPublishedEvent(trailDtos)); + - return new TokenResponse(token, user.RefreshToken, user.RefreshTokenExpiryTime); + return new TokenDto(token, user.RefreshToken, user.RefreshTokenExpiryTime); } private string GenerateJwt(FshUser user, string ipAddress) => @@ -128,15 +145,15 @@ private string GenerateEncryptedToken(SigningCredentials signingCredentials, IEn claims: claims, expires: DateTime.UtcNow.AddMinutes(_jwtOptions.TokenExpirationInMinutes), signingCredentials: signingCredentials, - issuer: JwtAuthConstants.Issuer, - audience: JwtAuthConstants.Audience + issuer: _jwtOptions.Issuer, + audience: _jwtOptions.Audience ); var tokenHandler = new JwtSecurityTokenHandler(); return tokenHandler.WriteToken(token); } private List GetClaims(FshUser user, string ipAddress) => - new List + new() { new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), new(ClaimTypes.NameIdentifier, user.Id), @@ -166,8 +183,8 @@ private ClaimsPrincipal GetPrincipalFromExpiredToken(string token) IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtOptions.Key)), ValidateIssuer = true, ValidateAudience = true, - ValidAudience = JwtAuthConstants.Audience, - ValidIssuer = JwtAuthConstants.Issuer, + ValidAudience = _jwtOptions.Audience, + ValidIssuer = _jwtOptions.Issuer, RoleClaimType = ClaimTypes.Role, ClockSkew = TimeSpan.Zero, ValidateLifetime = false @@ -185,4 +202,4 @@ private ClaimsPrincipal GetPrincipalFromExpiredToken(string token) return principal; } -} +} \ No newline at end of file diff --git a/src/api/framework/Infrastructure/Identity/Users/Services/UserService.Password.cs b/src/framework/Modules/Identity/Modules.Identity/Services/UserService.Password.cs similarity index 61% rename from src/api/framework/Infrastructure/Identity/Users/Services/UserService.Password.cs rename to src/framework/Modules/Identity/Modules.Identity/Services/UserService.Password.cs index 97b7af7979..4db24ae274 100644 --- a/src/api/framework/Infrastructure/Identity/Users/Services/UserService.Password.cs +++ b/src/framework/Modules/Identity/Modules.Identity/Services/UserService.Password.cs @@ -1,20 +1,18 @@ -using System.Collections.ObjectModel; -using System.Text; -using FSH.Framework.Core.Exceptions; -using FSH.Framework.Core.Identity.Users.Features.ChangePassword; -using FSH.Framework.Core.Identity.Users.Features.ForgotPassword; -using FSH.Framework.Core.Identity.Users.Features.ResetPassword; +using FSH.Framework.Core.Exceptions; using FSH.Framework.Core.Mail; +using FSH.Modules.Common.Core.Exceptions; using Microsoft.AspNetCore.WebUtilities; +using System.Collections.ObjectModel; +using System.Text; namespace FSH.Framework.Infrastructure.Identity.Users.Services; internal sealed partial class UserService { - public async Task ForgotPasswordAsync(ForgotPasswordCommand request, string origin, CancellationToken cancellationToken) + public async Task ForgotPasswordAsync(string email, string origin, CancellationToken cancellationToken) { EnsureValidTenant(); - var user = await userManager.FindByEmailAsync(request.Email); + var user = await userManager.FindByEmailAsync(email); if (user == null) { throw new NotFoundException("user not found"); @@ -28,7 +26,7 @@ public async Task ForgotPasswordAsync(ForgotPasswordCommand request, string orig var token = await userManager.GeneratePasswordResetTokenAsync(user); token = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(token)); - var resetPasswordUri = $"{origin}/reset-password?token={token}&email={request.Email}"; + var resetPasswordUri = $"{origin}/reset-password?token={token}&email={email}"; var mailRequest = new MailRequest( new Collection { user.Email }, "Reset Password", @@ -37,38 +35,38 @@ public async Task ForgotPasswordAsync(ForgotPasswordCommand request, string orig jobService.Enqueue(() => mailService.SendAsync(mailRequest, CancellationToken.None)); } - public async Task ResetPasswordAsync(ResetPasswordCommand request, CancellationToken cancellationToken) + public async Task ResetPasswordAsync(string email, string password, string token, CancellationToken cancellationToken) { EnsureValidTenant(); - var user = await userManager.FindByEmailAsync(request.Email); + var user = await userManager.FindByEmailAsync(email); if (user == null) { throw new NotFoundException("user not found"); } - request.Token = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(request.Token)); - var result = await userManager.ResetPasswordAsync(user, request.Token, request.Password); + token = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(token)); + var result = await userManager.ResetPasswordAsync(user, token, password); if (!result.Succeeded) { var errors = result.Errors.Select(e => e.Description).ToList(); - throw new FshException("error resetting password", errors); + throw new CustomException("error resetting password", errors); } } - public async Task ChangePasswordAsync(ChangePasswordCommand request, string userId) + public async Task ChangePasswordAsync(string password, string newPassword, string confirmNewPassword, string userId) { var user = await userManager.FindByIdAsync(userId); _ = user ?? throw new NotFoundException("user not found"); - var result = await userManager.ChangePasswordAsync(user, request.Password, request.NewPassword); + var result = await userManager.ChangePasswordAsync(user, password, newPassword); if (!result.Succeeded) { var errors = result.Errors.Select(e => e.Description).ToList(); - throw new FshException("failed to change password", errors); + throw new CustomException("failed to change password", errors); } } -} +} \ No newline at end of file diff --git a/src/api/framework/Infrastructure/Identity/Users/Services/UserService.Permissions.cs b/src/framework/Modules/Identity/Modules.Identity/Services/UserService.Permissions.cs similarity index 89% rename from src/api/framework/Infrastructure/Identity/Users/Services/UserService.Permissions.cs rename to src/framework/Modules/Identity/Modules.Identity/Services/UserService.Permissions.cs index 52f5698f78..10da55d630 100644 --- a/src/api/framework/Infrastructure/Identity/Users/Services/UserService.Permissions.cs +++ b/src/framework/Modules/Identity/Modules.Identity/Services/UserService.Permissions.cs @@ -1,6 +1,6 @@ -using FSH.Framework.Core.Caching; -using FSH.Framework.Core.Exceptions; -using FSH.Starter.Shared.Authorization; +using FSH.Framework.Core.Exceptions; +using FSH.Framework.Shared.Constants; +using FSH.Modules.Common.Core.Caching; using Microsoft.EntityFrameworkCore; namespace FSH.Framework.Infrastructure.Identity.Users.Services; @@ -48,6 +48,6 @@ public async Task HasPermissionAsync(string userId, string permission, Can public Task InvalidatePermissionCacheAsync(string userId, CancellationToken cancellationToken) { - return cache.RemoveAsync(GetPermissionCacheKey(userId), cancellationToken); + return cache.RemoveItemAsync(GetPermissionCacheKey(userId), cancellationToken); } -} +} \ No newline at end of file diff --git a/src/api/framework/Infrastructure/Identity/Users/Services/UserService.cs b/src/framework/Modules/Identity/Modules.Identity/Services/UserService.cs similarity index 65% rename from src/api/framework/Infrastructure/Identity/Users/Services/UserService.cs rename to src/framework/Modules/Identity/Modules.Identity/Services/UserService.cs index a714a154d2..b85fb48faf 100644 --- a/src/api/framework/Infrastructure/Identity/Users/Services/UserService.cs +++ b/src/framework/Modules/Identity/Modules.Identity/Services/UserService.cs @@ -1,28 +1,25 @@ -using System.Collections.ObjectModel; -using System.Security.Claims; -using System.Text; -using Finbuckle.MultiTenant.Abstractions; -using FSH.Framework.Core.Caching; +using Finbuckle.MultiTenant.Abstractions; using FSH.Framework.Core.Exceptions; -using FSH.Framework.Core.Identity.Users.Abstractions; -using FSH.Framework.Core.Identity.Users.Dtos; -using FSH.Framework.Core.Identity.Users.Features.AssignUserRole; -using FSH.Framework.Core.Identity.Users.Features.RegisterUser; -using FSH.Framework.Core.Identity.Users.Features.ToggleUserStatus; -using FSH.Framework.Core.Identity.Users.Features.UpdateUser; using FSH.Framework.Core.Jobs; using FSH.Framework.Core.Mail; using FSH.Framework.Core.Storage; -using FSH.Framework.Core.Storage.File; +using FSH.Framework.Identity.Core.Roles; +using FSH.Framework.Identity.Core.Users; +using FSH.Framework.Identity.Infrastructure.Data; +using FSH.Framework.Identity.Infrastructure.Roles; +using FSH.Framework.Identity.Infrastructure.Users; using FSH.Framework.Infrastructure.Constants; -using FSH.Framework.Infrastructure.Identity.Persistence; -using FSH.Framework.Infrastructure.Identity.Roles; -using FSH.Framework.Infrastructure.Tenant; -using FSH.Starter.Shared.Authorization; -using Mapster; +using FSH.Framework.Shared.Constants; +using FSH.Framework.Shared.Multitenancy; +using FSH.Modules.Common.Core.Caching; +using FSH.Modules.Common.Core.Exceptions; +using FSH.Modules.Common.Shared.Constants; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.WebUtilities; using Microsoft.EntityFrameworkCore; +using System.Collections.ObjectModel; +using System.Security.Claims; +using System.Text; namespace FSH.Framework.Infrastructure.Identity.Users.Services; @@ -54,14 +51,14 @@ public async Task ConfirmEmailAsync(string userId, string code, string t .Where(u => u.Id == userId && !u.EmailConfirmed) .FirstOrDefaultAsync(cancellationToken); - _ = user ?? throw new FshException("An error occurred while confirming E-Mail."); + _ = user ?? throw new CustomException("An error occurred while confirming E-Mail."); code = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(code)); var result = await userManager.ConfirmEmailAsync(user, code); return result.Succeeded ? string.Format("Account Confirmed for E-Mail {0}. You can now use the /api/tokens endpoint to generate JWT.", user.Email) - : throw new FshException(string.Format("An error occurred while confirming {0}", user.Email)); + : throw new CustomException(string.Format("An error occurred while confirming {0}", user.Email)); } public Task ConfirmPhoneNumberAsync(string userId, string code) @@ -87,7 +84,7 @@ public async Task ExistsWithPhoneNumberAsync(string phoneNumber, string? e return await userManager.Users.FirstOrDefaultAsync(x => x.PhoneNumber == phoneNumber) is FshUser user && user.Id != exceptId; } - public async Task GetAsync(string userId, CancellationToken cancellationToken) + public async Task GetAsync(string userId, CancellationToken cancellationToken) { var user = await userManager.Users .AsNoTracking() @@ -96,16 +93,40 @@ public async Task GetAsync(string userId, CancellationToken cancella _ = user ?? throw new NotFoundException("user not found"); - return user.Adapt(); + return new UserDto + { + Id = user.Id, + Email = user.Email, + UserName = user.UserName, + FirstName = user.FirstName, + LastName = user.LastName, + ImageUrl = user.ImageUrl?.ToString(), + IsActive = user.IsActive + }; } public Task GetCountAsync(CancellationToken cancellationToken) => userManager.Users.AsNoTracking().CountAsync(cancellationToken); - public async Task> GetListAsync(CancellationToken cancellationToken) + public async Task> GetListAsync(CancellationToken cancellationToken) { var users = await userManager.Users.AsNoTracking().ToListAsync(cancellationToken); - return users.Adapt>(); + var result = new List(users.Count); + foreach (var user in users) + { + result.Add(new UserDto + { + Id = user.Id, + Email = user.Email, + UserName = user.UserName, + FirstName = user.FirstName, + LastName = user.LastName, + ImageUrl = user.ImageUrl?.ToString(), + IsActive = user.IsActive + }); + } + + return result; } public Task GetOrCreateFromPrincipalAsync(ClaimsPrincipal principal) @@ -113,27 +134,29 @@ public Task GetOrCreateFromPrincipalAsync(ClaimsPrincipal principal) throw new NotImplementedException(); } - public async Task RegisterAsync(RegisterUserCommand request, string origin, CancellationToken cancellationToken) + public async Task RegisterAsync(string firstName, string lastName, string email, string userName, string password, string confirmPassword, string phoneNumber, string origin, CancellationToken cancellationToken) { + if (password != confirmPassword) throw new CustomException("password mismatch."); + // create user entity var user = new FshUser { - Email = request.Email, - FirstName = request.FirstName, - LastName = request.LastName, - UserName = request.UserName, - PhoneNumber = request.PhoneNumber, + Email = email, + FirstName = firstName, + LastName = lastName, + UserName = userName, + PhoneNumber = phoneNumber, IsActive = true, EmailConfirmed = false, PhoneNumberConfirmed = false, }; // register user - var result = await userManager.CreateAsync(user, request.Password); + var result = await userManager.CreateAsync(user, password); if (!result.Succeeded) { var errors = result.Errors.Select(error => error.Description).ToList(); - throw new FshException("error while registering a new user", errors); + throw new CustomException("error while registering a new user", errors); } // add basic role @@ -147,52 +170,52 @@ public async Task RegisterAsync(RegisterUserCommand reques new Collection { user.Email }, "Confirm Registration", emailVerificationUri); - jobService.Enqueue("email", () => mailService.SendAsync(mailRequest, CancellationToken.None)); + jobService.Enqueue("email", () => mailService.SendAsync(mailRequest, cancellationToken)); } - return new RegisterUserResponse(user.Id); + return user.Id; } - public async Task ToggleStatusAsync(ToggleUserStatusCommand request, CancellationToken cancellationToken) + public async Task ToggleStatusAsync(bool activateUser, string userId, CancellationToken cancellationToken) { - var user = await userManager.Users.Where(u => u.Id == request.UserId).FirstOrDefaultAsync(cancellationToken); + var user = await userManager.Users.Where(u => u.Id == userId).FirstOrDefaultAsync(cancellationToken); _ = user ?? throw new NotFoundException("User Not Found."); bool isAdmin = await userManager.IsInRoleAsync(user, FshRoles.Admin); if (isAdmin) { - throw new FshException("Administrators Profile's Status cannot be toggled"); + throw new CustomException("Administrators Profile's Status cannot be toggled"); } - user.IsActive = request.ActivateUser; + user.IsActive = activateUser; await userManager.UpdateAsync(user); } - public async Task UpdateAsync(UpdateUserCommand request, string userId) + public async Task UpdateAsync(string userId, string firstName, string lastName, string phoneNumber, FileUploadRequest image, bool deleteCurrentImage) { var user = await userManager.FindByIdAsync(userId); _ = user ?? throw new NotFoundException("user not found"); Uri imageUri = user.ImageUrl ?? null!; - if (request.Image != null || request.DeleteCurrentImage) + if (image.Data != null || deleteCurrentImage) { - user.ImageUrl = await storageService.UploadAsync(request.Image, FileType.Image); - if (request.DeleteCurrentImage && imageUri != null) + var imageString = await storageService.UploadAsync(image, FileType.Image); + user.ImageUrl = new Uri(imageString); + if (deleteCurrentImage && imageUri != null) { - storageService.Remove(imageUri); + await storageService.RemoveAsync(imageUri.ToString()); } } - user.FirstName = request.FirstName; - user.LastName = request.LastName; - user.PhoneNumber = request.PhoneNumber; - string? phoneNumber = await userManager.GetPhoneNumberAsync(user); - if (request.PhoneNumber != phoneNumber) + user.FirstName = firstName; + user.LastName = lastName; + string? currentPhoneNumber = await userManager.GetPhoneNumberAsync(user); + if (phoneNumber != currentPhoneNumber) { - await userManager.SetPhoneNumberAsync(user, request.PhoneNumber); + await userManager.SetPhoneNumberAsync(user, phoneNumber); } var result = await userManager.UpdateAsync(user); @@ -200,7 +223,7 @@ public async Task UpdateAsync(UpdateUserCommand request, string userId) if (!result.Succeeded) { - throw new FshException("Update profile failed"); + throw new CustomException("Update profile failed"); } } @@ -216,7 +239,7 @@ public async Task DeleteAsync(string userId) if (!result.Succeeded) { List errors = result.Errors.Select(error => error.Description).ToList(); - throw new FshException("Delete profile failed", errors); + throw new CustomException("Delete profile failed", errors); } } @@ -231,42 +254,40 @@ private async Task GetEmailVerificationUriAsync(FshUser user, string ori string verificationUri = QueryHelpers.AddQueryString(endpointUri.ToString(), QueryStringKeys.UserId, user.Id); verificationUri = QueryHelpers.AddQueryString(verificationUri, QueryStringKeys.Code, code); verificationUri = QueryHelpers.AddQueryString(verificationUri, - TenantConstants.Identifier, + MutiTenancyConstants.Identifier, multiTenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id!); return verificationUri; } - public async Task AssignRolesAsync(string userId, AssignUserRoleCommand request, CancellationToken cancellationToken) + public async Task AssignRolesAsync(string userId, List userRoles, CancellationToken cancellationToken) { - ArgumentNullException.ThrowIfNull(request); - var user = await userManager.Users.Where(u => u.Id == userId).FirstOrDefaultAsync(cancellationToken); _ = user ?? throw new NotFoundException("user not found"); // Check if the user is an admin for which the admin role is getting disabled if (await userManager.IsInRoleAsync(user, FshRoles.Admin) - && request.UserRoles.Exists(a => !a.Enabled && a.RoleName == FshRoles.Admin)) + && userRoles.Exists(a => !a.Enabled && a.RoleName == FshRoles.Admin)) { // Get count of users in Admin Role int adminCount = (await userManager.GetUsersInRoleAsync(FshRoles.Admin)).Count; // Check if user is not Root Tenant Admin // Edge Case : there are chances for other tenants to have users with the same email as that of Root Tenant Admin. Probably can add a check while User Registration - if (user.Email == TenantConstants.Root.EmailAddress) + if (user.Email == MutiTenancyConstants.Root.EmailAddress) { - if (multiTenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id == TenantConstants.Root.Id) + if (multiTenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id == MutiTenancyConstants.Root.Id) { - throw new FshException("action not permitted"); + throw new CustomException("action not permitted"); } } else if (adminCount <= 2) { - throw new FshException("tenant should have at least 2 admins."); + throw new CustomException("tenant should have at least 2 admins."); } } - foreach (var userRole in request.UserRoles) + foreach (var userRole in userRoles) { // Check if Role Exists if (await roleManager.FindByNameAsync(userRole.RoleName!) is not null) @@ -289,9 +310,9 @@ public async Task AssignRolesAsync(string userId, AssignUserRoleCommand } - public async Task> GetUserRolesAsync(string userId, CancellationToken cancellationToken) + public async Task> GetUserRolesAsync(string userId, CancellationToken cancellationToken) { - var userRoles = new List(); + var userRoles = new List(); var user = await userManager.FindByIdAsync(userId); if (user is null) throw new NotFoundException("user not found"); @@ -299,7 +320,7 @@ public async Task> GetUserRolesAsync(string userId, Cancell if (roles is null) throw new NotFoundException("roles not found"); foreach (var role in roles) { - userRoles.Add(new UserRoleDetail + userRoles.Add(new UserRoleDto { RoleId = role.Id, RoleName = role.Name, @@ -310,4 +331,4 @@ public async Task> GetUserRolesAsync(string userId, Cancell return userRoles; } -} +} \ No newline at end of file diff --git a/src/api/framework/Core/Tenant/Dtos/TenantDetail.cs b/src/framework/Modules/Tenant/Modules.Tenant.Contracts/Dtos/TenantDto.cs similarity index 80% rename from src/api/framework/Core/Tenant/Dtos/TenantDetail.cs rename to src/framework/Modules/Tenant/Modules.Tenant.Contracts/Dtos/TenantDto.cs index c9e44f8b7d..908f69d39b 100644 --- a/src/api/framework/Core/Tenant/Dtos/TenantDetail.cs +++ b/src/framework/Modules/Tenant/Modules.Tenant.Contracts/Dtos/TenantDto.cs @@ -1,5 +1,5 @@ -namespace FSH.Framework.Core.Tenant.Dtos; -public class TenantDetail +namespace FSH.Framework.Tenant.Contracts.Dtos; +public sealed class TenantDto { public string Id { get; set; } = default!; public string Name { get; set; } = default!; @@ -8,4 +8,4 @@ public class TenantDetail public bool IsActive { get; set; } public DateTime ValidUpto { get; set; } public string? Issuer { get; set; } -} +} \ No newline at end of file diff --git a/src/framework/Modules/Tenant/Modules.Tenant.Contracts/Modules.Tenant.Contracts.csproj b/src/framework/Modules/Tenant/Modules.Tenant.Contracts/Modules.Tenant.Contracts.csproj new file mode 100644 index 0000000000..017e54e1cb --- /dev/null +++ b/src/framework/Modules/Tenant/Modules.Tenant.Contracts/Modules.Tenant.Contracts.csproj @@ -0,0 +1,15 @@ + + + FSH.Modules.Tenant.Contracts + FSH.Modules.Tenant.Contracts + + + net9.0 + enable + enable + + + + + + diff --git a/src/framework/Modules/Tenant/Modules.Tenant.Contracts/TenantConstants.cs b/src/framework/Modules/Tenant/Modules.Tenant.Contracts/TenantConstants.cs new file mode 100644 index 0000000000..7556324706 --- /dev/null +++ b/src/framework/Modules/Tenant/Modules.Tenant.Contracts/TenantConstants.cs @@ -0,0 +1,8 @@ +namespace FSH.Modules.Tenant.Contracts; +public static class TenantConstants +{ + public static class Permissions + { + public const string View = "Permissions.Tenants.View"; + } +} \ No newline at end of file diff --git a/src/framework/Modules/Tenant/Modules.Tenant.Contracts/v1/ActivateTenant/ActivateTenantCommand.cs b/src/framework/Modules/Tenant/Modules.Tenant.Contracts/v1/ActivateTenant/ActivateTenantCommand.cs new file mode 100644 index 0000000000..26cfa55b85 --- /dev/null +++ b/src/framework/Modules/Tenant/Modules.Tenant.Contracts/v1/ActivateTenant/ActivateTenantCommand.cs @@ -0,0 +1,4 @@ +using FSH.Modules.Common.Core.Messaging.CQRS; + +namespace FSH.Framework.Tenant.Contracts.v1.ActivateTenant; +public sealed record ActivateTenantCommand(string TenantId) : ICommand; \ No newline at end of file diff --git a/src/framework/Modules/Tenant/Modules.Tenant.Contracts/v1/ActivateTenant/ActivateTenantCommandResponse.cs b/src/framework/Modules/Tenant/Modules.Tenant.Contracts/v1/ActivateTenant/ActivateTenantCommandResponse.cs new file mode 100644 index 0000000000..eb618c5efe --- /dev/null +++ b/src/framework/Modules/Tenant/Modules.Tenant.Contracts/v1/ActivateTenant/ActivateTenantCommandResponse.cs @@ -0,0 +1,2 @@ +namespace FSH.Framework.Tenant.Contracts.v1.ActivateTenant; +public sealed record ActivateTenantCommandResponse(string TenantId, string Status); \ No newline at end of file diff --git a/src/framework/Modules/Tenant/Modules.Tenant.Contracts/v1/CreateTenant/CreateTenantCommand.cs b/src/framework/Modules/Tenant/Modules.Tenant.Contracts/v1/CreateTenant/CreateTenantCommand.cs new file mode 100644 index 0000000000..810869db2b --- /dev/null +++ b/src/framework/Modules/Tenant/Modules.Tenant.Contracts/v1/CreateTenant/CreateTenantCommand.cs @@ -0,0 +1,9 @@ +using FSH.Modules.Common.Core.Messaging.CQRS; + +namespace FSH.Framework.Tenant.Contracts.v1.CreateTenant; +public sealed record CreateTenantCommand( + string Id, + string Name, + string? ConnectionString, + string AdminEmail, + string? Issuer) : ICommand; \ No newline at end of file diff --git a/src/framework/Modules/Tenant/Modules.Tenant.Contracts/v1/CreateTenant/CreateTenantCommandResponse.cs b/src/framework/Modules/Tenant/Modules.Tenant.Contracts/v1/CreateTenant/CreateTenantCommandResponse.cs new file mode 100644 index 0000000000..251ffe6988 --- /dev/null +++ b/src/framework/Modules/Tenant/Modules.Tenant.Contracts/v1/CreateTenant/CreateTenantCommandResponse.cs @@ -0,0 +1,2 @@ +namespace FSH.Framework.Tenant.Contracts.v1.CreateTenant; +public sealed record CreateTenantCommandResponse(string Id); \ No newline at end of file diff --git a/src/framework/Modules/Tenant/Modules.Tenant.Contracts/v1/DisableTenant/DisableTenantCommand.cs b/src/framework/Modules/Tenant/Modules.Tenant.Contracts/v1/DisableTenant/DisableTenantCommand.cs new file mode 100644 index 0000000000..531e883005 --- /dev/null +++ b/src/framework/Modules/Tenant/Modules.Tenant.Contracts/v1/DisableTenant/DisableTenantCommand.cs @@ -0,0 +1,4 @@ +using FSH.Modules.Common.Core.Messaging.CQRS; + +namespace FSH.Framework.Tenant.Contracts.v1.DisableTenant; +public sealed record DisableTenantCommand(string TenantId) : ICommand; \ No newline at end of file diff --git a/src/framework/Modules/Tenant/Modules.Tenant.Contracts/v1/DisableTenant/DisableTenantCommandResponse.cs b/src/framework/Modules/Tenant/Modules.Tenant.Contracts/v1/DisableTenant/DisableTenantCommandResponse.cs new file mode 100644 index 0000000000..0546390b3a --- /dev/null +++ b/src/framework/Modules/Tenant/Modules.Tenant.Contracts/v1/DisableTenant/DisableTenantCommandResponse.cs @@ -0,0 +1,2 @@ +namespace FSH.Framework.Tenant.Contracts.v1.DisableTenant; +public sealed record DisableTenantCommandResponse(string Status); \ No newline at end of file diff --git a/src/framework/Modules/Tenant/Modules.Tenant.Contracts/v1/GetTenantById/GetTenantByIdQuery.cs b/src/framework/Modules/Tenant/Modules.Tenant.Contracts/v1/GetTenantById/GetTenantByIdQuery.cs new file mode 100644 index 0000000000..6d664d9c0b --- /dev/null +++ b/src/framework/Modules/Tenant/Modules.Tenant.Contracts/v1/GetTenantById/GetTenantByIdQuery.cs @@ -0,0 +1,5 @@ +using FSH.Framework.Core.Messaging.CQRS; +using FSH.Framework.Tenant.Contracts.Dtos; + +namespace FSH.Framework.Tenant.Contracts.v1.GetTenantById; +public sealed record GetTenantByIdQuery(string TenantId) : IQuery; \ No newline at end of file diff --git a/src/framework/Modules/Tenant/Modules.Tenant.Contracts/v1/GetTenants/GetTenantsQuery.cs b/src/framework/Modules/Tenant/Modules.Tenant.Contracts/v1/GetTenants/GetTenantsQuery.cs new file mode 100644 index 0000000000..b4758ca817 --- /dev/null +++ b/src/framework/Modules/Tenant/Modules.Tenant.Contracts/v1/GetTenants/GetTenantsQuery.cs @@ -0,0 +1,5 @@ +using FSH.Framework.Core.Messaging.CQRS; +using FSH.Framework.Tenant.Contracts.Dtos; + +namespace FSH.Framework.Tenant.Contracts.v1.GetTenants; +public sealed record GetTenantsQuery : IQuery>; \ No newline at end of file diff --git a/src/framework/Modules/Tenant/Modules.Tenant.Contracts/v1/UpgradeTenant/UpgradeTenantCommand.cs b/src/framework/Modules/Tenant/Modules.Tenant.Contracts/v1/UpgradeTenant/UpgradeTenantCommand.cs new file mode 100644 index 0000000000..fa04352f01 --- /dev/null +++ b/src/framework/Modules/Tenant/Modules.Tenant.Contracts/v1/UpgradeTenant/UpgradeTenantCommand.cs @@ -0,0 +1,5 @@ +using FSH.Modules.Common.Core.Messaging.CQRS; + +namespace FSH.Framework.Tenant.Contracts.v1.UpgradeTenant; +public sealed record UpgradeTenantCommand(string Tenant, DateTime ExtendedExpiryDate) + : ICommand; \ No newline at end of file diff --git a/src/framework/Modules/Tenant/Modules.Tenant.Contracts/v1/UpgradeTenant/UpgradeTenantCommandResponse.cs b/src/framework/Modules/Tenant/Modules.Tenant.Contracts/v1/UpgradeTenant/UpgradeTenantCommandResponse.cs new file mode 100644 index 0000000000..7293d4dfcc --- /dev/null +++ b/src/framework/Modules/Tenant/Modules.Tenant.Contracts/v1/UpgradeTenant/UpgradeTenantCommandResponse.cs @@ -0,0 +1,2 @@ +namespace FSH.Framework.Tenant.Contracts.v1.UpgradeTenant; +public sealed record UpgradeTenantCommandResponse(DateTime NewValidity, string Tenant); \ No newline at end of file diff --git a/src/api/framework/Infrastructure/Tenant/Persistence/TenantDbContext.cs b/src/framework/Modules/Tenant/Modules.Tenant/Data/TenantDbContext.cs similarity index 89% rename from src/api/framework/Infrastructure/Tenant/Persistence/TenantDbContext.cs rename to src/framework/Modules/Tenant/Modules.Tenant/Data/TenantDbContext.cs index d778a7ce59..062f952ba0 100644 --- a/src/api/framework/Infrastructure/Tenant/Persistence/TenantDbContext.cs +++ b/src/framework/Modules/Tenant/Modules.Tenant/Data/TenantDbContext.cs @@ -1,7 +1,8 @@ using Finbuckle.MultiTenant.EntityFrameworkCore.Stores.EFCoreStore; +using FSH.Framework.Shared.Multitenancy; using Microsoft.EntityFrameworkCore; -namespace FSH.Framework.Infrastructure.Tenant.Persistence; +namespace FSH.Framework.Tenant.Data; public class TenantDbContext : EFCoreStoreDbContext { public const string Schema = "tenant"; @@ -19,4 +20,4 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.Entity().ToTable("Tenants", Schema); } -} +} \ No newline at end of file diff --git a/src/framework/Modules/Tenant/Modules.Tenant/Extensions.cs b/src/framework/Modules/Tenant/Modules.Tenant/Extensions.cs new file mode 100644 index 0000000000..b5d6a7f749 --- /dev/null +++ b/src/framework/Modules/Tenant/Modules.Tenant/Extensions.cs @@ -0,0 +1,89 @@ +using Finbuckle.MultiTenant; +using Finbuckle.MultiTenant.Abstractions; +using FSH.Framework.Core.Persistence; +using FSH.Framework.Shared.Multitenancy; +using FSH.Framework.Tenant.Data; +using FSH.Modules.Common.Shared.Constants; +using Microsoft.AspNetCore.Builder; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Serilog; + +namespace FSH.Framework.Tenant; +public static class Extensions +{ + private static IEnumerable TenantStoreSetup(IApplicationBuilder app) + { + var scope = app.ApplicationServices.CreateScope(); + + // tenant master schema migration + var tenantDbContext = scope.ServiceProvider.GetRequiredService(); + if (tenantDbContext.Database.GetPendingMigrations().Any()) + { + tenantDbContext.Database.Migrate(); + Log.Information("applied database migrations for tenant module"); + } + + // default tenant seeding + if (tenantDbContext.TenantInfo.Find(MutiTenancyConstants.Root.Id) is null) + { + var rootTenant = new FshTenantInfo( + MutiTenancyConstants.Root.Id, + MutiTenancyConstants.Root.Name, + string.Empty, + MutiTenancyConstants.Root.EmailAddress); + + rootTenant.SetValidity(DateTime.UtcNow.AddYears(1)); + tenantDbContext.TenantInfo.Add(rootTenant); + tenantDbContext.SaveChanges(); + Log.Information("configured default tenant data"); + } + + // get all tenants from store + var tenantStore = scope.ServiceProvider.GetRequiredService>(); + var tenants = tenantStore.GetAllAsync().Result; + + //dispose scope + scope.Dispose(); + + return tenants; + } + + public static WebApplication ConfigureMultiTenantDatabases(this WebApplication app) + { + ArgumentNullException.ThrowIfNull(app); + app.UseMultiTenant(); + + // set up tenant store + var tenants = TenantStoreSetup(app); + + // set up tenant databases + app.SetupTenantDatabases(tenants); + + return app; + } + private static IApplicationBuilder SetupTenantDatabases(this IApplicationBuilder app, IEnumerable tenants) + { + foreach (var tenant in tenants) + { + // create a scope for tenant + using var tenantScope = app.ApplicationServices.CreateScope(); + + //set current tenant so that the right connection string is used + tenantScope.ServiceProvider.GetRequiredService() + .MultiTenantContext = new MultiTenantContext() + { + TenantInfo = tenant + }; + + // using the scope, perform migrations / seeding + var initializers = tenantScope.ServiceProvider.GetServices(); + foreach (var initializer in initializers) + { + initializer.MigrateAsync(CancellationToken.None).Wait(); + initializer.SeedAsync(CancellationToken.None).Wait(); + } + } + return app; + } +} diff --git a/src/framework/Modules/Tenant/Modules.Tenant/Features/v1/ActivateTenant/ActivateTenantCommandHandler.cs b/src/framework/Modules/Tenant/Modules.Tenant/Features/v1/ActivateTenant/ActivateTenantCommandHandler.cs new file mode 100644 index 0000000000..2e8e7a98d1 --- /dev/null +++ b/src/framework/Modules/Tenant/Modules.Tenant/Features/v1/ActivateTenant/ActivateTenantCommandHandler.cs @@ -0,0 +1,16 @@ +using FSH.Framework.Tenant.Contracts.v1.ActivateTenant; +using FSH.Framework.Tenant.Services; +using FSH.Modules.Common.Core.Messaging.CQRS; + +namespace FSH.Modules.Tenant.Features.v1.ActivateTenant; +public sealed class ActivateTenantCommandHandler(ITenantService tenantService) + : ICommandHandler +{ + public async Task HandleAsync( + ActivateTenantCommand command, + CancellationToken cancellationToken = default) + { + var result = await tenantService.ActivateAsync(command.TenantId, cancellationToken); + return new ActivateTenantCommandResponse(result, command.TenantId); + } +} \ No newline at end of file diff --git a/src/framework/Modules/Tenant/Modules.Tenant/Features/v1/ActivateTenant/ActivateTenantCommandValidator.cs b/src/framework/Modules/Tenant/Modules.Tenant/Features/v1/ActivateTenant/ActivateTenantCommandValidator.cs new file mode 100644 index 0000000000..f1b645396d --- /dev/null +++ b/src/framework/Modules/Tenant/Modules.Tenant/Features/v1/ActivateTenant/ActivateTenantCommandValidator.cs @@ -0,0 +1,10 @@ +using FluentValidation; +using FSH.Framework.Tenant.Contracts.v1.ActivateTenant; + +namespace FSH.Framework.Tenant.Features.v1.ActivateTenant; +public sealed class ActivateTenantCommandValidator : AbstractValidator +{ + public ActivateTenantCommandValidator() => + RuleFor(t => t.TenantId) + .NotEmpty(); +} \ No newline at end of file diff --git a/src/framework/Modules/Tenant/Modules.Tenant/Features/v1/ActivateTenant/ActivateTenantEndpoint.cs b/src/framework/Modules/Tenant/Modules.Tenant/Features/v1/ActivateTenant/ActivateTenantEndpoint.cs new file mode 100644 index 0000000000..77d0450cc7 --- /dev/null +++ b/src/framework/Modules/Tenant/Modules.Tenant/Features/v1/ActivateTenant/ActivateTenantEndpoint.cs @@ -0,0 +1,21 @@ +using FSH.Framework.Core.Messaging.CQRS; +using FSH.Framework.Shared.Authorization; +using FSH.Framework.Tenant.Contracts.v1.ActivateTenant; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; + +namespace FSH.Modules.Tenant.Features.v1.ActivateTenant; +public static class ActivateTenantEndpoint +{ + public static RouteHandlerBuilder Map(this IEndpointRouteBuilder endpoints) + { + return endpoints.MapPost("/{id}/activate", async ([FromServices] ICommandDispatcher dispatcher, string id) + => await dispatcher.SendAsync(new ActivateTenantCommand(id))) + .WithName(nameof(ActivateTenantEndpoint)) + .WithSummary("Activate Tenant") + .RequirePermission("Permissions.Tenants.Update") + .WithDescription("Activate Tenant"); + } +} \ No newline at end of file diff --git a/src/framework/Modules/Tenant/Modules.Tenant/Features/v1/CreateTenant/CreateTenantCommandHandler.cs b/src/framework/Modules/Tenant/Modules.Tenant/Features/v1/CreateTenant/CreateTenantCommandHandler.cs new file mode 100644 index 0000000000..b529d4f4da --- /dev/null +++ b/src/framework/Modules/Tenant/Modules.Tenant/Features/v1/CreateTenant/CreateTenantCommandHandler.cs @@ -0,0 +1,21 @@ +using FSH.Framework.Tenant.Contracts.v1.CreateTenant; +using FSH.Framework.Tenant.Services; +using FSH.Modules.Common.Core.Messaging.CQRS; + +namespace FSH.Framework.Tenant.Features.v1.CreateTenant; +public class CreateTenantCommandHandler(ITenantService service) + : ICommandHandler +{ + public async Task HandleAsync( + CreateTenantCommand command, + CancellationToken cancellationToken = default) + { + var tenantId = await service.CreateAsync(command.Id, + command.Name, + command.ConnectionString, + command.AdminEmail, + command.Issuer, + cancellationToken); + return new CreateTenantCommandResponse(tenantId); + } +} \ No newline at end of file diff --git a/src/api/framework/Core/Tenant/Features/CreateTenant/CreateTenantValidator.cs b/src/framework/Modules/Tenant/Modules.Tenant/Features/v1/CreateTenant/CreateTenantCommandValidator.cs similarity index 72% rename from src/api/framework/Core/Tenant/Features/CreateTenant/CreateTenantValidator.cs rename to src/framework/Modules/Tenant/Modules.Tenant/Features/v1/CreateTenant/CreateTenantCommandValidator.cs index 16e9816afb..b4d097b0a7 100644 --- a/src/api/framework/Core/Tenant/Features/CreateTenant/CreateTenantValidator.cs +++ b/src/framework/Modules/Tenant/Modules.Tenant/Features/v1/CreateTenant/CreateTenantCommandValidator.cs @@ -1,13 +1,12 @@ using FluentValidation; using FSH.Framework.Core.Persistence; -using FSH.Framework.Core.Tenant.Abstractions; +using FSH.Framework.Tenant.Contracts.v1.CreateTenant; +using FSH.Framework.Tenant.Services; -namespace FSH.Framework.Core.Tenant.Features.CreateTenant; -public class CreateTenantValidator : AbstractValidator +namespace FSH.Framework.Tenant.Features.v1.CreateTenant; +public class CreateTenantCommandValidator : AbstractValidator { - public CreateTenantValidator( - ITenantService tenantService, - IConnectionStringValidator connectionStringValidator) + public CreateTenantCommandValidator(ITenantService tenantService, IConnectionStringValidator connectionStringValidator) { RuleFor(t => t.Id).Cascade(CascadeMode.Stop) .NotEmpty() @@ -27,4 +26,4 @@ public CreateTenantValidator( .NotEmpty() .EmailAddress(); } -} +} \ No newline at end of file diff --git a/src/framework/Modules/Tenant/Modules.Tenant/Features/v1/CreateTenant/CreateTenantEndpoint.cs b/src/framework/Modules/Tenant/Modules.Tenant/Features/v1/CreateTenant/CreateTenantEndpoint.cs new file mode 100644 index 0000000000..6d9cde477b --- /dev/null +++ b/src/framework/Modules/Tenant/Modules.Tenant/Features/v1/CreateTenant/CreateTenantEndpoint.cs @@ -0,0 +1,23 @@ +using FSH.Framework.Core.Messaging.CQRS; +using FSH.Framework.Shared.Authorization; +using FSH.Framework.Tenant.Contracts.v1.CreateTenant; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; + +namespace FSH.Framework.Tenant.Features.v1.CreateTenant; +public static class CreateTenantEndpoint +{ + public static RouteHandlerBuilder Map(this IEndpointRouteBuilder endpoints) + { + return endpoints.MapPost("/", async ( + [FromBody] CreateTenantCommand command, + [FromServices] ICommandDispatcher dispatcher) + => await dispatcher.SendAsync(command)) + .WithName(nameof(CreateTenantEndpoint)) + .WithSummary("activate tenant") + .RequirePermission("Permissions.Tenants.Create") + .WithDescription("activate tenant"); + } +} \ No newline at end of file diff --git a/src/framework/Modules/Tenant/Modules.Tenant/Features/v1/DisableTenant/DisableTenantCommandHandler.cs b/src/framework/Modules/Tenant/Modules.Tenant/Features/v1/DisableTenant/DisableTenantCommandHandler.cs new file mode 100644 index 0000000000..d7d1e79315 --- /dev/null +++ b/src/framework/Modules/Tenant/Modules.Tenant/Features/v1/DisableTenant/DisableTenantCommandHandler.cs @@ -0,0 +1,16 @@ +using FSH.Framework.Tenant.Contracts.v1.DisableTenant; +using FSH.Framework.Tenant.Services; +using FSH.Modules.Common.Core.Messaging.CQRS; + +namespace FSH.Framework.Tenant.Features.v1.DisableTenant; +public class DisableTenantCommandHandler(ITenantService service) + : ICommandHandler +{ + public async Task HandleAsync( + DisableTenantCommand request, + CancellationToken cancellationToken = default) + { + var status = await service.DeactivateAsync(request.TenantId); + return new DisableTenantCommandResponse(status); + } +} \ No newline at end of file diff --git a/src/framework/Modules/Tenant/Modules.Tenant/Features/v1/DisableTenant/DisableTenantCommandValidator.cs b/src/framework/Modules/Tenant/Modules.Tenant/Features/v1/DisableTenant/DisableTenantCommandValidator.cs new file mode 100644 index 0000000000..42d0006ccd --- /dev/null +++ b/src/framework/Modules/Tenant/Modules.Tenant/Features/v1/DisableTenant/DisableTenantCommandValidator.cs @@ -0,0 +1,10 @@ +using FluentValidation; +using FSH.Framework.Tenant.Contracts.v1.DisableTenant; + +namespace FSH.Framework.Tenant.Features.v1.DisableTenant; +internal sealed class DisableTenantCommandValidator : AbstractValidator +{ + public DisableTenantCommandValidator() => + RuleFor(t => t.TenantId) + .NotEmpty(); +} \ No newline at end of file diff --git a/src/api/framework/Infrastructure/Tenant/Endpoints/DisableTenantEndpoint.cs b/src/framework/Modules/Tenant/Modules.Tenant/Features/v1/DisableTenant/DisableTenantEndpoint.cs similarity index 50% rename from src/api/framework/Infrastructure/Tenant/Endpoints/DisableTenantEndpoint.cs rename to src/framework/Modules/Tenant/Modules.Tenant/Features/v1/DisableTenant/DisableTenantEndpoint.cs index 64ef613204..c53b588765 100644 --- a/src/api/framework/Infrastructure/Tenant/Endpoints/DisableTenantEndpoint.cs +++ b/src/framework/Modules/Tenant/Modules.Tenant/Features/v1/DisableTenant/DisableTenantEndpoint.cs @@ -1,19 +1,20 @@ -using FSH.Framework.Core.Tenant.Features.DisableTenant; -using FSH.Framework.Infrastructure.Auth.Policy; -using MediatR; +using FSH.Framework.Core.Messaging.CQRS; +using FSH.Framework.Shared.Authorization; +using FSH.Framework.Tenant.Contracts.v1.DisableTenant; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; -namespace FSH.Framework.Infrastructure.Tenant.Endpoints; +namespace FSH.Framework.Tenant.Features.v1.DisableTenant; public static class DisableTenantEndpoint { - internal static RouteHandlerBuilder MapDisableTenantEndpoint(this IEndpointRouteBuilder endpoints) + internal static RouteHandlerBuilder Map(this IEndpointRouteBuilder endpoints) { - return endpoints.MapPost("/{id}/deactivate", (ISender mediator, string id) => mediator.Send(new DisableTenantCommand(id))) + return endpoints.MapPost("/{id}/deactivate", (ICommandDispatcher dispatcher, string id) + => dispatcher.SendAsync(new DisableTenantCommand(id))) .WithName(nameof(DisableTenantEndpoint)) .WithSummary("activate tenant") .RequirePermission("Permissions.Tenants.Update") .WithDescription("activate tenant"); } -} +} \ No newline at end of file diff --git a/src/api/framework/Infrastructure/Tenant/Endpoints/GetTenantByIdEndpoint.cs b/src/framework/Modules/Tenant/Modules.Tenant/Features/v1/GetTenantById/GetTenantByIdEndpoint.cs similarity index 51% rename from src/api/framework/Infrastructure/Tenant/Endpoints/GetTenantByIdEndpoint.cs rename to src/framework/Modules/Tenant/Modules.Tenant/Features/v1/GetTenantById/GetTenantByIdEndpoint.cs index a429ac62e4..8bc4d63a45 100644 --- a/src/api/framework/Infrastructure/Tenant/Endpoints/GetTenantByIdEndpoint.cs +++ b/src/framework/Modules/Tenant/Modules.Tenant/Features/v1/GetTenantById/GetTenantByIdEndpoint.cs @@ -1,19 +1,20 @@ -using FSH.Framework.Core.Tenant.Features.GetTenantById; -using FSH.Framework.Infrastructure.Auth.Policy; -using MediatR; +using FSH.Framework.Core.Messaging.CQRS; +using FSH.Framework.Shared.Authorization; +using FSH.Framework.Tenant.Contracts.v1.GetTenantById; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; -namespace FSH.Framework.Infrastructure.Tenant.Endpoints; +namespace FSH.Framework.Tenant.Features.v1.GetTenantById; public static class GetTenantByIdEndpoint { - internal static RouteHandlerBuilder MapGetTenantByIdEndpoint(this IEndpointRouteBuilder endpoints) + internal static RouteHandlerBuilder Map(this IEndpointRouteBuilder endpoints) { - return endpoints.MapGet("/{id}", (ISender mediator, string id) => mediator.Send(new GetTenantByIdQuery(id))) + return endpoints.MapGet("/{id}", (IQueryDispatcher dispatcher, string id) + => dispatcher.SendAsync(new GetTenantByIdQuery(id))) .WithName(nameof(GetTenantByIdEndpoint)) .WithSummary("get tenant by id") .RequirePermission("Permissions.Tenants.View") .WithDescription("get tenant by id"); } -} +} \ No newline at end of file diff --git a/src/framework/Modules/Tenant/Modules.Tenant/Features/v1/GetTenantById/GetTenantByIdQueryHandler.cs b/src/framework/Modules/Tenant/Modules.Tenant/Features/v1/GetTenantById/GetTenantByIdQueryHandler.cs new file mode 100644 index 0000000000..8185c47a9b --- /dev/null +++ b/src/framework/Modules/Tenant/Modules.Tenant/Features/v1/GetTenantById/GetTenantByIdQueryHandler.cs @@ -0,0 +1,16 @@ +using FSH.Framework.Core.Messaging.CQRS; +using FSH.Framework.Tenant.Contracts.Dtos; +using FSH.Framework.Tenant.Contracts.v1.GetTenantById; +using FSH.Framework.Tenant.Services; +using Mapster; + +namespace FSH.Framework.Tenant.Features.v1.GetTenantById; +public sealed class GetTenantByIdQueryHandler(ITenantService service) + : IQueryHandler +{ + public async Task HandleAsync(GetTenantByIdQuery query, CancellationToken cancellationToken = default) + { + var tenant = await service.GetByIdAsync(query.TenantId); + return tenant.Adapt(); + } +} \ No newline at end of file diff --git a/src/framework/Modules/Tenant/Modules.Tenant/Features/v1/GetTenants/GetTenantsEndpoint.cs b/src/framework/Modules/Tenant/Modules.Tenant/Features/v1/GetTenants/GetTenantsEndpoint.cs new file mode 100644 index 0000000000..883f53e523 --- /dev/null +++ b/src/framework/Modules/Tenant/Modules.Tenant/Features/v1/GetTenants/GetTenantsEndpoint.cs @@ -0,0 +1,22 @@ +using FSH.Framework.Core.Messaging.CQRS; +using FSH.Framework.Shared.Authorization; +using FSH.Framework.Tenant.Contracts.v1.GetTenants; +using FSH.Modules.Tenant.Contracts; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace FSH.Framework.Tenant.Features.v1.GetTenants; +public static class GetTenantsEndpoint +{ + public static RouteHandlerBuilder Map(IEndpointRouteBuilder endpoints) + { + return endpoints.MapGet("/", (IQueryDispatcher dispatcher) + => dispatcher.SendAsync(new GetTenantsQuery())) + .WithName(nameof(GetTenantsEndpoint)) + .HasApiVersion(1) + .WithSummary("get tenants") + .RequirePermission(TenantConstants.Permissions.View) + .WithDescription("get tenants"); + } +} \ No newline at end of file diff --git a/src/framework/Modules/Tenant/Modules.Tenant/Features/v1/GetTenants/GetTenantsQueryHandler.cs b/src/framework/Modules/Tenant/Modules.Tenant/Features/v1/GetTenants/GetTenantsQueryHandler.cs new file mode 100644 index 0000000000..bca7b6616f --- /dev/null +++ b/src/framework/Modules/Tenant/Modules.Tenant/Features/v1/GetTenants/GetTenantsQueryHandler.cs @@ -0,0 +1,20 @@ +using FSH.Framework.Core.Messaging.CQRS; +using FSH.Framework.Tenant.Contracts.Dtos; +using FSH.Framework.Tenant.Contracts.v1.GetTenants; +using FSH.Framework.Tenant.Services; +using Mapster; + +namespace FSH.Framework.Tenant.Features.v1.GetTenants; + +public sealed class GetTenantsQueryHandler(ITenantService service) + : IQueryHandler> +{ + public async Task> HandleAsync( + GetTenantsQuery query, + CancellationToken cancellationToken = default + ) + { + var tenants = await service.GetAllAsync(); + return tenants.Adapt>(); + } +} \ No newline at end of file diff --git a/src/framework/Modules/Tenant/Modules.Tenant/Features/v1/UpgradeTenant/UpgradeTenantCommandHandler.cs b/src/framework/Modules/Tenant/Modules.Tenant/Features/v1/UpgradeTenant/UpgradeTenantCommandHandler.cs new file mode 100644 index 0000000000..5da08a22e8 --- /dev/null +++ b/src/framework/Modules/Tenant/Modules.Tenant/Features/v1/UpgradeTenant/UpgradeTenantCommandHandler.cs @@ -0,0 +1,16 @@ +using FSH.Framework.Tenant.Contracts.v1.UpgradeTenant; +using FSH.Framework.Tenant.Services; +using FSH.Modules.Common.Core.Messaging.CQRS; + +namespace FSH.Framework.Tenant.Features.v1.UpgradeTenant; +internal sealed class UpgradeTenantCommandHandler(ITenantService service) + : ICommandHandler +{ + public async Task HandleAsync( + UpgradeTenantCommand request, + CancellationToken cancellationToken = default) + { + var validUpto = await service.UpgradeSubscription(request.Tenant, request.ExtendedExpiryDate); + return new UpgradeTenantCommandResponse(validUpto, request.Tenant); + } +} \ No newline at end of file diff --git a/src/framework/Modules/Tenant/Modules.Tenant/Features/v1/UpgradeTenant/UpgradeTenantCommandValidator.cs b/src/framework/Modules/Tenant/Modules.Tenant/Features/v1/UpgradeTenant/UpgradeTenantCommandValidator.cs new file mode 100644 index 0000000000..3caa321e97 --- /dev/null +++ b/src/framework/Modules/Tenant/Modules.Tenant/Features/v1/UpgradeTenant/UpgradeTenantCommandValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; +using FSH.Framework.Tenant.Contracts.v1.UpgradeTenant; + +namespace FSH.Framework.Tenant.Features.v1.UpgradeTenant; +public sealed class UpgradeTenantCommandValidator : AbstractValidator +{ + public UpgradeTenantCommandValidator() + { + RuleFor(t => t.Tenant).NotEmpty(); + RuleFor(t => t.ExtendedExpiryDate).GreaterThan(DateTime.UtcNow); + } +} \ No newline at end of file diff --git a/src/framework/Modules/Tenant/Modules.Tenant/Features/v1/UpgradeTenant/UpgradeTenantEndpoint.cs b/src/framework/Modules/Tenant/Modules.Tenant/Features/v1/UpgradeTenant/UpgradeTenantEndpoint.cs new file mode 100644 index 0000000000..819623e5b3 --- /dev/null +++ b/src/framework/Modules/Tenant/Modules.Tenant/Features/v1/UpgradeTenant/UpgradeTenantEndpoint.cs @@ -0,0 +1,21 @@ +using FSH.Framework.Core.Messaging.CQRS; +using FSH.Framework.Shared.Authorization; +using FSH.Framework.Tenant.Contracts.v1.UpgradeTenant; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; + +namespace FSH.Framework.Tenant.Features.v1.UpgradeTenant; +public static class UpgradeTenantEndpoint +{ + internal static RouteHandlerBuilder Map(this IEndpointRouteBuilder endpoints) + { + return endpoints.MapPost("/upgrade", ([FromBody] UpgradeTenantCommand command, ICommandDispatcher dispatcher) + => dispatcher.SendAsync(command)) + .WithName(nameof(UpgradeTenantEndpoint)) + .WithSummary("upgrade tenant subscription") + .RequirePermission("Permissions.Tenants.Update") + .WithDescription("upgrade tenant subscription"); + } +} \ No newline at end of file diff --git a/src/framework/Modules/Tenant/Modules.Tenant/Modules.Tenant.csproj b/src/framework/Modules/Tenant/Modules.Tenant/Modules.Tenant.csproj new file mode 100644 index 0000000000..484155e390 --- /dev/null +++ b/src/framework/Modules/Tenant/Modules.Tenant/Modules.Tenant.csproj @@ -0,0 +1,22 @@ + + + FSH.Modules.Tenant + FSH.Modules.Tenant + + + net9.0 + enable + enable + + + + + + + + + + + + + diff --git a/src/api/framework/Core/Tenant/Abstractions/ITenantService.cs b/src/framework/Modules/Tenant/Modules.Tenant/Services/ITenantService.cs similarity index 50% rename from src/api/framework/Core/Tenant/Abstractions/ITenantService.cs rename to src/framework/Modules/Tenant/Modules.Tenant/Services/ITenantService.cs index d2540a2ff3..8da8f2f39f 100644 --- a/src/api/framework/Core/Tenant/Abstractions/ITenantService.cs +++ b/src/framework/Modules/Tenant/Modules.Tenant/Services/ITenantService.cs @@ -1,23 +1,22 @@ -using FSH.Framework.Core.Tenant.Dtos; -using FSH.Framework.Core.Tenant.Features.CreateTenant; +using FSH.Framework.Tenant.Contracts.Dtos; -namespace FSH.Framework.Core.Tenant.Abstractions; +namespace FSH.Framework.Tenant.Services; public interface ITenantService { - Task> GetAllAsync(); + Task> GetAllAsync(); Task ExistsWithIdAsync(string id); Task ExistsWithNameAsync(string name); - Task GetByIdAsync(string id); + Task GetByIdAsync(string id); - Task CreateAsync(CreateTenantCommand request, CancellationToken cancellationToken); + Task CreateAsync(string id, string name, string? connectionString, string adminEmail, string? issuer, CancellationToken cancellationToken); Task ActivateAsync(string id, CancellationToken cancellationToken); Task DeactivateAsync(string id); Task UpgradeSubscription(string id, DateTime extendedExpiryDate); -} +} \ No newline at end of file diff --git a/src/api/framework/Infrastructure/Tenant/Services/TenantService.cs b/src/framework/Modules/Tenant/Modules.Tenant/Services/TenantService.cs similarity index 79% rename from src/api/framework/Infrastructure/Tenant/Services/TenantService.cs rename to src/framework/Modules/Tenant/Modules.Tenant/Services/TenantService.cs index bfc8458cf4..b08f173617 100644 --- a/src/api/framework/Infrastructure/Tenant/Services/TenantService.cs +++ b/src/framework/Modules/Tenant/Modules.Tenant/Services/TenantService.cs @@ -2,14 +2,14 @@ using Finbuckle.MultiTenant.Abstractions; using FSH.Framework.Core.Exceptions; using FSH.Framework.Core.Persistence; -using FSH.Framework.Core.Tenant.Abstractions; -using FSH.Framework.Core.Tenant.Dtos; -using FSH.Framework.Core.Tenant.Features.CreateTenant; +using FSH.Framework.Shared.Multitenancy; +using FSH.Framework.Tenant.Contracts.Dtos; +using FSH.Modules.Common.Core.Exceptions; using Mapster; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; -namespace FSH.Framework.Infrastructure.Tenant.Services; +namespace FSH.Framework.Tenant.Services; public sealed class TenantService : ITenantService { @@ -30,7 +30,7 @@ public async Task ActivateAsync(string id, CancellationToken cancellatio if (tenant.IsActive) { - throw new FshException($"tenant {id} is already activated"); + throw new CustomException($"tenant {id} is already activated"); } tenant.Activate(); @@ -40,15 +40,17 @@ public async Task ActivateAsync(string id, CancellationToken cancellatio return $"tenant {id} is now activated"; } - public async Task CreateAsync(CreateTenantCommand request, CancellationToken cancellationToken) + public async Task CreateAsync(string id, + string name, + string? connectionString, + string adminEmail, string? issuer, CancellationToken cancellationToken) { - var connectionString = request.ConnectionString; - if (request.ConnectionString?.Trim() == _config.ConnectionString.Trim()) + if (connectionString?.Trim() == _config.ConnectionString.Trim()) { connectionString = string.Empty; } - FshTenantInfo tenant = new(request.Id, request.Name, connectionString, request.AdminEmail, request.Issuer); + FshTenantInfo tenant = new(id, name, connectionString, adminEmail, issuer); await _tenantStore.TryAddAsync(tenant).ConfigureAwait(false); await InitializeDatabase(tenant).ConfigureAwait(false); @@ -82,7 +84,7 @@ public async Task DeactivateAsync(string id) var tenant = await GetTenantInfoAsync(id).ConfigureAwait(false); if (!tenant.IsActive) { - throw new FshException($"tenant {id} is already deactivated"); + throw new CustomException($"tenant {id} is already deactivated"); } tenant.Deactivate(); @@ -96,15 +98,15 @@ public async Task ExistsWithIdAsync(string id) => public async Task ExistsWithNameAsync(string name) => (await _tenantStore.GetAllAsync().ConfigureAwait(false)).Any(t => t.Name == name); - public async Task> GetAllAsync() + public async Task> GetAllAsync() { - var tenants = (await _tenantStore.GetAllAsync().ConfigureAwait(false)).Adapt>(); + var tenants = (await _tenantStore.GetAllAsync().ConfigureAwait(false)).Adapt>(); return tenants; } - public async Task GetByIdAsync(string id) => + public async Task GetByIdAsync(string id) => (await GetTenantInfoAsync(id).ConfigureAwait(false)) - .Adapt(); + .Adapt(); public async Task UpgradeSubscription(string id, DateTime extendedExpiryDate) { @@ -117,4 +119,4 @@ public async Task UpgradeSubscription(string id, DateTime extendedExpi private async Task GetTenantInfoAsync(string id) => await _tenantStore.TryGetAsync(id).ConfigureAwait(false) ?? throw new NotFoundException($"{typeof(FshTenantInfo).Name} {id} Not Found."); -} +} \ No newline at end of file diff --git a/src/framework/Modules/Tenant/Modules.Tenant/TenantModule.cs b/src/framework/Modules/Tenant/Modules.Tenant/TenantModule.cs new file mode 100644 index 0000000000..25eaef2123 --- /dev/null +++ b/src/framework/Modules/Tenant/Modules.Tenant/TenantModule.cs @@ -0,0 +1,102 @@ +using Asp.Versioning; +using Finbuckle.MultiTenant; +using Finbuckle.MultiTenant.Abstractions; +using Finbuckle.MultiTenant.Stores.DistributedCacheStore; +using FluentValidation; +using FSH.Framework.Core.Persistence; +using FSH.Framework.Infrastructure.Messaging.CQRS; +using FSH.Framework.Infrastructure.Persistence; +using FSH.Framework.Infrastructure.Persistence.Services; +using FSH.Framework.Shared.Constants; +using FSH.Framework.Shared.Multitenancy; +using FSH.Framework.Tenant; +using FSH.Framework.Tenant.Data; +using FSH.Framework.Tenant.Features.v1.CreateTenant; +using FSH.Framework.Tenant.Features.v1.DisableTenant; +using FSH.Framework.Tenant.Features.v1.GetTenantById; +using FSH.Framework.Tenant.Features.v1.GetTenants; +using FSH.Framework.Tenant.Features.v1.UpgradeTenant; +using FSH.Framework.Tenant.Services; +using FSH.Modules.Common.Infrastructure.Modules; +using FSH.Modules.Common.Shared.Constants; +using FSH.Modules.Tenant.Features.v1.ActivateTenant; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using System.Reflection; + +namespace FSH.Modules.Tenant; + +public class TenantModule : IModule +{ + public void AddModule(IServiceCollection services, IConfiguration config) + { + ArgumentNullException.ThrowIfNull(services); + + services.RegisterCommandAndQueryHandlers(Assembly.GetExecutingAssembly()); + + services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly(), includeInternalTypes: true); + + services.AddTransient(); + + services.BindDbContext(); + + services + .AddMultiTenant(options => + { + options.Events.OnTenantResolveCompleted = async context => + { + if (context.MultiTenantContext.StoreInfo is null) return; + if (context.MultiTenantContext.StoreInfo.StoreType != typeof(DistributedCacheStore)) + { + var sp = ((HttpContext)context.Context!).RequestServices; + var distributedStore = sp + .GetRequiredService>>() + .FirstOrDefault(s => s.GetType() == typeof(DistributedCacheStore)); + + await distributedStore!.TryAddAsync(context.MultiTenantContext.TenantInfo!); + } + await Task.CompletedTask; + }; + }) + .WithClaimStrategy(FshClaims.Tenant) + .WithHeaderStrategy(MutiTenancyConstants.Identifier) + .WithDelegateStrategy(async context => + { + if (context is not HttpContext httpContext) return null; + + if (!httpContext.Request.Query.TryGetValue("tenant", out var tenantIdentifier) || + string.IsNullOrEmpty(tenantIdentifier)) + return null; + + return await Task.FromResult(tenantIdentifier.ToString()); + }) + .WithDistributedCacheStore(TimeSpan.FromMinutes(60)) + .WithEFCoreStore(); + + services.AddScoped(); + } + + public void ConfigureModule(WebApplication app) + { + app.ConfigureMultiTenantDatabases(); + + var versionSet = app.NewApiVersionSet() + .HasApiVersion(new ApiVersion(1)) + .ReportApiVersions() + .Build(); + + var group = app.MapGroup("api/v{version:apiVersion}/tenants") + .WithTags("Tenants") + .WithOpenApi() + .WithApiVersionSet(versionSet); + + DisableTenantEndpoint.Map(group); + GetTenantByIdEndpoint.Map(group); + GetTenantsEndpoint.Map(group); + UpgradeTenantEndpoint.Map(group); + ActivateTenantEndpoint.Map(group); + CreateTenantEndpoint.Map(group); + } +} diff --git a/src/framework/PlayGround/PlayGround.Api/Extensions/ModuleExtensions.cs b/src/framework/PlayGround/PlayGround.Api/Extensions/ModuleExtensions.cs new file mode 100644 index 0000000000..2ab67f5f7f --- /dev/null +++ b/src/framework/PlayGround/PlayGround.Api/Extensions/ModuleExtensions.cs @@ -0,0 +1,41 @@ +using FSH.Modules.Common.Infrastructure.Modules; +using System.Reflection; + +namespace FSH.PlayGround.Api.Extensions; + +public static class ModuleExtensions +{ + public static IServiceCollection AddModules(this IServiceCollection services, IConfiguration config) + { + var moduleTypes = typeof(ModuleExtensions).Assembly + .GetReferencedAssemblies() + .Select(Assembly.Load) + .SelectMany(a => a.GetTypes()) + .Where(t => typeof(IModule).IsAssignableFrom(t) && !t.IsInterface && !t.IsAbstract); + + foreach (var type in moduleTypes) + { + Console.WriteLine($"[AddModule] Registering module: {type.FullName}"); + var module = (IModule)Activator.CreateInstance(type)!; + module.AddModule(services, config); + } + + return services; + } + + public static WebApplication ConfigureModules(this WebApplication app) + { + var moduleTypes = AppDomain.CurrentDomain.GetAssemblies() + .SelectMany(a => a.GetTypes()) + .Where(t => typeof(IModule).IsAssignableFrom(t) && !t.IsInterface && !t.IsAbstract); + + foreach (var type in moduleTypes) + { + Console.WriteLine($"[ConfigureModule] Configuring module: {type.FullName}"); + var module = (IModule)Activator.CreateInstance(type)!; + module.ConfigureModule(app); + } + + return app; + } +} diff --git a/src/framework/Tests/Architecture.Tests/Architecture.Tests.csproj b/src/framework/Tests/Architecture.Tests/Architecture.Tests.csproj new file mode 100644 index 0000000000..6796c57b55 --- /dev/null +++ b/src/framework/Tests/Architecture.Tests/Architecture.Tests.csproj @@ -0,0 +1,32 @@ + + + + net9.0 + enable + enable + false + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + diff --git a/src/framework/Tests/Architecture.Tests/Messaging/CommandHandlerNamingTests.cs b/src/framework/Tests/Architecture.Tests/Messaging/CommandHandlerNamingTests.cs new file mode 100644 index 0000000000..77889ca4ef --- /dev/null +++ b/src/framework/Tests/Architecture.Tests/Messaging/CommandHandlerNamingTests.cs @@ -0,0 +1,28 @@ +using FSH.Modules.Common.Core.Messaging.CQRS; + +namespace Architecture.Tests.Messaging; + +public class CommandHandlerNamingTests +{ + [Fact] + public void All_ICommandHandler_Implementations_Should_End_With_CommandHandler() + { + var handlerInterfaceType = typeof(ICommandHandler<,>); + + var assemblies = ModuleAssemblyLoader.GetFshAssemblies(); + + var failures = assemblies + .SelectMany(a => a.GetTypes()) + .Where(t => t is { IsClass: true, IsAbstract: false }) + .Where(t => t.GetInterfaces() + .Any(i => + i.IsGenericType && + i.GetGenericTypeDefinition() == handlerInterfaceType)) + .Where(t => !t.Name.EndsWith("CommandHandler", StringComparison.Ordinal)) + .Select(t => t.FullName ?? t.Name) + .ToList(); + + Assert.True(failures.Count == 0, + $"The following classes do not end with 'CommandHandler': {string.Join(", ", failures)}"); + } +} \ No newline at end of file diff --git a/src/framework/Tests/Architecture.Tests/Messaging/QueryHandlerNamingTests.cs b/src/framework/Tests/Architecture.Tests/Messaging/QueryHandlerNamingTests.cs new file mode 100644 index 0000000000..d40306bf78 --- /dev/null +++ b/src/framework/Tests/Architecture.Tests/Messaging/QueryHandlerNamingTests.cs @@ -0,0 +1,27 @@ +using FSH.Framework.Core.Messaging.CQRS; + +namespace Architecture.Tests.Messaging; +public class QueryHandlerNamingTests +{ + [Fact] + public void All_IQueryHandler_Implementations_Should_End_With_QueryHandler() + { + var handlerInterfaceType = typeof(IQueryHandler<,>); + + var assemblies = ModuleAssemblyLoader.GetFshAssemblies(); + + var failures = assemblies + .SelectMany(a => a.GetTypes()) + .Where(t => t is { IsClass: true, IsAbstract: false }) + .Where(t => t.GetInterfaces() + .Any(i => + i.IsGenericType && + i.GetGenericTypeDefinition() == handlerInterfaceType)) + .Where(t => !t.Name.EndsWith("QueryHandler", StringComparison.Ordinal)) + .Select(t => t.FullName ?? t.Name) + .ToList(); + + Assert.True(failures.Count == 0, + $"The following classes do not end with 'QueryHandler': {string.Join(", ", failures)}"); + } +} \ No newline at end of file diff --git a/src/framework/Tests/Architecture.Tests/ModuleAssemblyLoader.cs b/src/framework/Tests/Architecture.Tests/ModuleAssemblyLoader.cs new file mode 100644 index 0000000000..9e2308b355 --- /dev/null +++ b/src/framework/Tests/Architecture.Tests/ModuleAssemblyLoader.cs @@ -0,0 +1,30 @@ +using System.Reflection; + +namespace Architecture.Tests; +public static class ModuleAssemblyLoader +{ + private static bool _loaded; + + public static void EnsureModulesLoaded() + { + if (_loaded) return; + + // ✅ Explicitly reference one or more types from each module to force load + _ = typeof(FSH.Modules.Identity.IdentityModule).Assembly; + _ = typeof(FSH.Modules.Tenant.TenantModule).Assembly; + _ = typeof(FSH.Modules.Auditing.AuditingModule).Assembly; + // Add more modules here... + + _loaded = true; + } + + public static IEnumerable GetFshAssemblies() + { + EnsureModulesLoaded(); + + return AppDomain.CurrentDomain.GetAssemblies() + .Where(a => + !a.IsDynamic && + a.FullName?.StartsWith("FSH", StringComparison.Ordinal) == true); + } +} \ No newline at end of file diff --git a/src/framework/playground/PlayGround.Api/PlayGround.Api.csproj b/src/framework/playground/PlayGround.Api/PlayGround.Api.csproj new file mode 100644 index 0000000000..fc96c817e9 --- /dev/null +++ b/src/framework/playground/PlayGround.Api/PlayGround.Api.csproj @@ -0,0 +1,31 @@ + + + FSH.PlayGround.Api + FSH.PlayGround.Api + + + net9.0 + enable + enable + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + diff --git a/src/framework/playground/PlayGround.Api/PlayGround.Api.http b/src/framework/playground/PlayGround.Api/PlayGround.Api.http new file mode 100644 index 0000000000..09a4f04b47 --- /dev/null +++ b/src/framework/playground/PlayGround.Api/PlayGround.Api.http @@ -0,0 +1,6 @@ +@PlayGround.Api_HostAddress = http://localhost:5153 + +GET {{PlayGround.Api_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/src/framework/playground/PlayGround.Api/Program.cs b/src/framework/playground/PlayGround.Api/Program.cs new file mode 100644 index 0000000000..e539870042 --- /dev/null +++ b/src/framework/playground/PlayGround.Api/Program.cs @@ -0,0 +1,43 @@ +using FSH.Framework.Infrastructure.Messaging.Events; +using FSH.Framework.Infrastructure.OpenApi; +using FSH.Framework.Tenant; +using FSH.Modules.Auditing; +using FSH.Modules.Common.Infrastructure; +using FSH.Modules.Identity; +using FSH.Modules.Tenant; +using FSH.PlayGround.Api.Extensions; +using Scalar.AspNetCore; +using System.Reflection; + +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddOpenApi("v1", options => +{ + options.AddDocumentTransformer(); +}); + +builder.AddFshFramework(); +builder.Services.AddModules(builder.Configuration); + +var assemblies = new Assembly[] + { + typeof(TenantModule).Assembly, + typeof(IdentityModule).Assembly, + typeof(AuditingModule).Assembly + }; +builder.Services.AddInMemoryEventBus(assemblies); + +var app = builder.Build(); +if (app.Environment.IsDevelopment()) +{ + app.MapOpenApi(); + string[] versions = ["v1", "v2"]; + app.MapScalarApiReference(options => options.AddDocuments(versions)); +} + + +app.ConfigureMultiTenantDatabases(); +app.ConfigureFshFramework(); +app.ConfigureModules(); + +app.UseHttpsRedirection(); +await app.RunAsync(); \ No newline at end of file diff --git a/src/framework/playground/PlayGround.Api/Properties/launchSettings.json b/src/framework/playground/PlayGround.Api/Properties/launchSettings.json new file mode 100644 index 0000000000..30930a61dc --- /dev/null +++ b/src/framework/playground/PlayGround.Api/Properties/launchSettings.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "scalar/v1", + "applicationUrl": "https://localhost:7044;http://localhost:5153", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/framework/playground/PlayGround.Api/appsettings.Development.json b/src/framework/playground/PlayGround.Api/appsettings.Development.json new file mode 100644 index 0000000000..0c208ae918 --- /dev/null +++ b/src/framework/playground/PlayGround.Api/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/framework/playground/PlayGround.Api/appsettings.json b/src/framework/playground/PlayGround.Api/appsettings.json new file mode 100644 index 0000000000..048148f522 --- /dev/null +++ b/src/framework/playground/PlayGround.Api/appsettings.json @@ -0,0 +1,71 @@ +{ + "DatabaseOptions": { + "Provider": "postgresql", + "ConnectionString": "Server=192.168.1.110;Database=phoenix;User Id=postgres;Password=password", + "MigrationsAssembly": "FSH.PlayGround.Migrations.PostgreSQL" + }, + "OriginOptions": { + "OriginUrl": "https://localhost:7000" + }, + "CacheOptions": { + "Redis": "" + }, + "HangfireOptions": { + "Username": "admin", + "Password": "Secure1234!Me", + "Route": "/jobs" + }, + "JwtOptions": { + "Key": "QsJbczCNysv/5SGh+U7sxedX8C07TPQPBdsnSDKZ/aE=", + "TokenExpirationInMinutes": 60, + "RefreshTokenExpirationInDays": 7, + "Audience": "playground.api", + "Issuer": "playground.api" + }, + "MailOptions": { + "From": "mukesh@fullstackhero.net", + "Host": "smtp.ethereal.email", + "Port": 587, + "UserName": "ruth.ruecker@ethereal.email", + "Password": "wygzuX6kpcK6AfDJcd", + "DisplayName": "Mukesh Murugan" + }, + "CorsOptions": { + "AllowedOrigins": [ + "https://localhost:7100", + "http://localhost:7100", + "http://localhost:5010" + ] + }, + "Serilog": { + "Using": [ + "Serilog.Sinks.Console" + ], + "MinimumLevel": { + "Default": "Debug" + }, + "WriteTo": [ + { + "Name": "Console" + } + ] + }, + "RateLimitOptions": { + "EnableRateLimiting": false, + "PermitLimit": 5, + "WindowInSeconds": 10, + "RejectionStatusCode": 429 + }, + "SecurityHeaderOptions": { + "Enable": true, + "Headers": { + "XContentTypeOptions": "nosniff", + "ReferrerPolicy": "no-referrer", + "XXSSProtection": "1; mode=block", + "XFrameOptions": "DENY", + "ContentSecurityPolicy": "block-all-mixed-content; style-src 'self' 'unsafe-inline'; font-src 'self'; form-action 'self'; frame-ancestors 'self'; img-src 'self' data:; script-src 'self' 'unsafe-inline'", + "PermissionsPolicy": "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()", + "StrictTransportSecurity": "max-age=31536000" + } + } +} \ No newline at end of file diff --git a/src/framework/playground/migrations/PostgreSQL/Auditing/20250417072044_Add Auditing Schema.Designer.cs b/src/framework/playground/migrations/PostgreSQL/Auditing/20250417072044_Add Auditing Schema.Designer.cs new file mode 100644 index 0000000000..9e61795ca5 --- /dev/null +++ b/src/framework/playground/migrations/PostgreSQL/Auditing/20250417072044_Add Auditing Schema.Designer.cs @@ -0,0 +1,80 @@ +// +using System; +using FSH.Framework.Auditing.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FSH.PlayGround.Migrations.PostgreSQL.Auditing +{ + [DbContext(typeof(AuditingDbContext))] + [Migration("20250417072044_Add Auditing Schema")] + partial class AddAuditingSchema + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.4") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("FSH.Framework.Auditing.Core.Entities.Trail", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DateTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("EntityName") + .HasColumnType("text"); + + b.Property("KeyValuesJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("ModifiedPropertiesJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("NewValuesJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("OldValuesJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("Operation") + .HasColumnType("integer"); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("Trails", "auditing"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/framework/playground/migrations/PostgreSQL/Auditing/20250417072044_Add Auditing Schema.cs b/src/framework/playground/migrations/PostgreSQL/Auditing/20250417072044_Add Auditing Schema.cs new file mode 100644 index 0000000000..d704aee8b4 --- /dev/null +++ b/src/framework/playground/migrations/PostgreSQL/Auditing/20250417072044_Add Auditing Schema.cs @@ -0,0 +1,46 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FSH.PlayGround.Migrations.PostgreSQL.Auditing; + +/// +public partial class AddAuditingSchema : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "auditing"); + + migrationBuilder.CreateTable( + name: "Trails", + schema: "auditing", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + DateTime = table.Column(type: "timestamp with time zone", nullable: false), + Operation = table.Column(type: "integer", nullable: false), + Description = table.Column(type: "text", nullable: false), + EntityName = table.Column(type: "text", nullable: true), + KeyValuesJson = table.Column(type: "text", nullable: false), + OldValuesJson = table.Column(type: "text", nullable: false), + NewValuesJson = table.Column(type: "text", nullable: false), + ModifiedPropertiesJson = table.Column(type: "text", nullable: false), + TenantId = table.Column(type: "character varying(64)", maxLength: 64, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Trails", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Trails", + schema: "auditing"); + } +} \ No newline at end of file diff --git a/src/framework/playground/migrations/PostgreSQL/Auditing/AuditingDbContextModelSnapshot.cs b/src/framework/playground/migrations/PostgreSQL/Auditing/AuditingDbContextModelSnapshot.cs new file mode 100644 index 0000000000..02107a941a --- /dev/null +++ b/src/framework/playground/migrations/PostgreSQL/Auditing/AuditingDbContextModelSnapshot.cs @@ -0,0 +1,77 @@ +// +using System; +using FSH.Framework.Auditing.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FSH.PlayGround.Migrations.PostgreSQL.Auditing +{ + [DbContext(typeof(AuditingDbContext))] + partial class AuditingDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.4") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("FSH.Framework.Auditing.Core.Entities.Trail", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DateTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("EntityName") + .HasColumnType("text"); + + b.Property("KeyValuesJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("ModifiedPropertiesJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("NewValuesJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("OldValuesJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("Operation") + .HasColumnType("integer"); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("Trails", "auditing"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/framework/playground/migrations/PostgreSQL/Identity/20250416110641_Add Identity Schema.Designer.cs b/src/framework/playground/migrations/PostgreSQL/Identity/20250416110641_Add Identity Schema.Designer.cs new file mode 100644 index 0000000000..85fb2de912 --- /dev/null +++ b/src/framework/playground/migrations/PostgreSQL/Identity/20250416110641_Add Identity Schema.Designer.cs @@ -0,0 +1,357 @@ +// +using System; +using FSH.Framework.Identity.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FSH.PlayGround.Migrations.PostgreSQL.Identity +{ + [DbContext(typeof(IdentityDbContext))] + [Migration("20250416110641_Add Identity Schema")] + partial class AddIdentitySchema + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.4") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("FSH.Framework.Identity.Infrastructure.Roles.FshRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName", "TenantId") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("Roles", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("FSH.Framework.Identity.Infrastructure.Users.FshUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("FirstName") + .HasColumnType("text"); + + b.Property("ImageUrl") + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LastName") + .HasColumnType("text"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ObjectId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("RefreshToken") + .HasColumnType("text"); + + b.Property("RefreshTokenExpiryTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("Users", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("FSH.Framework.Identity.v1.RoleClaims.FshRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("RoleClaims", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserClaims", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("UserLogins", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("UserRoles", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("UserTokens", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("FSH.Framework.Identity.v1.RoleClaims.FshRoleClaim", b => + { + b.HasOne("FSH.Framework.Identity.Infrastructure.Roles.FshRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("FSH.Framework.Identity.Infrastructure.Users.FshUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("FSH.Framework.Identity.Infrastructure.Users.FshUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("FSH.Framework.Identity.Infrastructure.Roles.FshRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FSH.Framework.Identity.Infrastructure.Users.FshUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("FSH.Framework.Identity.Infrastructure.Users.FshUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/framework/playground/migrations/PostgreSQL/Identity/20250416110641_Add Identity Schema.cs b/src/framework/playground/migrations/PostgreSQL/Identity/20250416110641_Add Identity Schema.cs new file mode 100644 index 0000000000..d24e0c9091 --- /dev/null +++ b/src/framework/playground/migrations/PostgreSQL/Identity/20250416110641_Add Identity Schema.cs @@ -0,0 +1,270 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FSH.PlayGround.Migrations.PostgreSQL.Identity; + +/// +public partial class AddIdentitySchema : Migration +{ + private static readonly string[] columns = new[] { "NormalizedName", "TenantId" }; + + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "identity"); + + migrationBuilder.CreateTable( + name: "Roles", + schema: "identity", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + Description = table.Column(type: "text", nullable: true), + TenantId = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + Name = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + NormalizedName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + ConcurrencyStamp = 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: "text", nullable: false), + FirstName = table.Column(type: "text", nullable: true), + LastName = table.Column(type: "text", nullable: true), + ImageUrl = table.Column(type: "text", nullable: true), + IsActive = table.Column(type: "boolean", nullable: false), + RefreshToken = table.Column(type: "text", nullable: true), + RefreshTokenExpiryTime = table.Column(type: "timestamp with time zone", nullable: false), + ObjectId = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + TenantId = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + UserName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + NormalizedUserName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + Email = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + NormalizedEmail = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + EmailConfirmed = table.Column(type: "boolean", nullable: false), + PasswordHash = table.Column(type: "text", nullable: true), + SecurityStamp = table.Column(type: "text", nullable: true), + ConcurrencyStamp = table.Column(type: "text", nullable: true), + PhoneNumber = table.Column(type: "text", nullable: true), + PhoneNumberConfirmed = table.Column(type: "boolean", nullable: false), + TwoFactorEnabled = table.Column(type: "boolean", nullable: false), + LockoutEnd = table.Column(type: "timestamp with time zone", nullable: true), + LockoutEnabled = table.Column(type: "boolean", nullable: false), + AccessFailedCount = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Users", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "RoleClaims", + schema: "identity", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + CreatedBy = table.Column(type: "text", nullable: true), + CreatedOn = table.Column(type: "timestamp with time zone", nullable: false), + TenantId = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + RoleId = table.Column(type: "text", nullable: false), + ClaimType = table.Column(type: "text", nullable: true), + ClaimValue = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_RoleClaims", x => x.Id); + table.ForeignKey( + name: "FK_RoleClaims_Roles_RoleId", + column: x => x.RoleId, + principalSchema: "identity", + principalTable: "Roles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "UserClaims", + schema: "identity", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + UserId = table.Column(type: "text", nullable: false), + ClaimType = table.Column(type: "text", nullable: true), + ClaimValue = table.Column(type: "text", nullable: true), + TenantId = table.Column(type: "character varying(64)", maxLength: 64, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UserClaims", x => x.Id); + table.ForeignKey( + name: "FK_UserClaims_Users_UserId", + column: x => x.UserId, + principalSchema: "identity", + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "UserLogins", + schema: "identity", + columns: table => new + { + LoginProvider = table.Column(type: "text", nullable: false), + ProviderKey = table.Column(type: "text", nullable: false), + ProviderDisplayName = table.Column(type: "text", nullable: true), + UserId = table.Column(type: "text", nullable: false), + TenantId = table.Column(type: "character varying(64)", maxLength: 64, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UserLogins", x => new { x.LoginProvider, x.ProviderKey }); + table.ForeignKey( + name: "FK_UserLogins_Users_UserId", + column: x => x.UserId, + principalSchema: "identity", + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "UserRoles", + schema: "identity", + columns: table => new + { + UserId = table.Column(type: "text", nullable: false), + RoleId = table.Column(type: "text", nullable: false), + TenantId = table.Column(type: "character varying(64)", maxLength: 64, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UserRoles", x => new { x.UserId, x.RoleId }); + table.ForeignKey( + name: "FK_UserRoles_Roles_RoleId", + column: x => x.RoleId, + principalSchema: "identity", + principalTable: "Roles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_UserRoles_Users_UserId", + column: x => x.UserId, + principalSchema: "identity", + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "UserTokens", + schema: "identity", + columns: table => new + { + UserId = table.Column(type: "text", nullable: false), + LoginProvider = table.Column(type: "text", nullable: false), + Name = table.Column(type: "text", nullable: false), + Value = table.Column(type: "text", nullable: true), + TenantId = table.Column(type: "character varying(64)", maxLength: 64, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UserTokens", x => new { x.UserId, x.LoginProvider, x.Name }); + table.ForeignKey( + name: "FK_UserTokens_Users_UserId", + column: x => x.UserId, + principalSchema: "identity", + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_RoleClaims_RoleId", + schema: "identity", + table: "RoleClaims", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "RoleNameIndex", + schema: "identity", + table: "Roles", + columns: columns, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_UserClaims_UserId", + schema: "identity", + table: "UserClaims", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_UserLogins_UserId", + schema: "identity", + table: "UserLogins", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_UserRoles_RoleId", + schema: "identity", + table: "UserRoles", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "EmailIndex", + schema: "identity", + table: "Users", + column: "NormalizedEmail"); + + migrationBuilder.CreateIndex( + name: "UserNameIndex", + schema: "identity", + table: "Users", + column: "NormalizedUserName", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "RoleClaims", + schema: "identity"); + + migrationBuilder.DropTable( + name: "UserClaims", + schema: "identity"); + + migrationBuilder.DropTable( + name: "UserLogins", + schema: "identity"); + + migrationBuilder.DropTable( + name: "UserRoles", + schema: "identity"); + + migrationBuilder.DropTable( + name: "UserTokens", + schema: "identity"); + + migrationBuilder.DropTable( + name: "Roles", + schema: "identity"); + + migrationBuilder.DropTable( + name: "Users", + schema: "identity"); + } +} \ No newline at end of file diff --git a/src/framework/playground/migrations/PostgreSQL/Identity/IdentityDbContextModelSnapshot.cs b/src/framework/playground/migrations/PostgreSQL/Identity/IdentityDbContextModelSnapshot.cs new file mode 100644 index 0000000000..e02243f200 --- /dev/null +++ b/src/framework/playground/migrations/PostgreSQL/Identity/IdentityDbContextModelSnapshot.cs @@ -0,0 +1,354 @@ +// +using System; +using FSH.Framework.Identity.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FSH.PlayGround.Migrations.PostgreSQL.Identity +{ + [DbContext(typeof(IdentityDbContext))] + partial class IdentityDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.4") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("FSH.Framework.Identity.Infrastructure.Roles.FshRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName", "TenantId") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("Roles", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("FSH.Framework.Identity.Infrastructure.Users.FshUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("FirstName") + .HasColumnType("text"); + + b.Property("ImageUrl") + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LastName") + .HasColumnType("text"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ObjectId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("RefreshToken") + .HasColumnType("text"); + + b.Property("RefreshTokenExpiryTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("Users", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("FSH.Framework.Identity.v1.RoleClaims.FshRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("RoleClaims", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserClaims", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("UserLogins", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("UserRoles", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("UserTokens", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("FSH.Framework.Identity.v1.RoleClaims.FshRoleClaim", b => + { + b.HasOne("FSH.Framework.Identity.Infrastructure.Roles.FshRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("FSH.Framework.Identity.Infrastructure.Users.FshUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("FSH.Framework.Identity.Infrastructure.Users.FshUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("FSH.Framework.Identity.Infrastructure.Roles.FshRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FSH.Framework.Identity.Infrastructure.Users.FshUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("FSH.Framework.Identity.Infrastructure.Users.FshUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/framework/playground/migrations/PostgreSQL/Migrations.PostgreSQL.csproj b/src/framework/playground/migrations/PostgreSQL/Migrations.PostgreSQL.csproj new file mode 100644 index 0000000000..fd54b3c66c --- /dev/null +++ b/src/framework/playground/migrations/PostgreSQL/Migrations.PostgreSQL.csproj @@ -0,0 +1,18 @@ + + + FSH.PlayGround.Migrations.PostgreSQL + FSH.PlayGround.Migrations.PostgreSQL + + + net9.0 + enable + enable + + + + + + + + + diff --git a/src/framework/playground/migrations/PostgreSQL/Tenant/20250416110812_Add Tenant Schema.Designer.cs b/src/framework/playground/migrations/PostgreSQL/Tenant/20250416110812_Add Tenant Schema.Designer.cs new file mode 100644 index 0000000000..8417128d09 --- /dev/null +++ b/src/framework/playground/migrations/PostgreSQL/Tenant/20250416110812_Add Tenant Schema.Designer.cs @@ -0,0 +1,69 @@ +// +using System; +using FSH.Framework.Tenant.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FSH.PlayGround.Migrations.PostgreSQL.Tenant +{ + [DbContext(typeof(TenantDbContext))] + [Migration("20250416110812_Add Tenant Schema")] + partial class AddTenantSchema + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.4") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("FSH.Framework.Shared.Multitenancy.FshTenantInfo", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("AdminEmail") + .IsRequired() + .HasColumnType("text"); + + b.Property("ConnectionString") + .IsRequired() + .HasColumnType("text"); + + b.Property("Identifier") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Issuer") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ValidUpto") + .HasColumnType("timestamp without time zone"); + + b.HasKey("Id"); + + b.HasIndex("Identifier") + .IsUnique(); + + b.ToTable("Tenants", "tenant"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/framework/playground/migrations/PostgreSQL/Tenant/20250416110812_Add Tenant Schema.cs b/src/framework/playground/migrations/PostgreSQL/Tenant/20250416110812_Add Tenant Schema.cs new file mode 100644 index 0000000000..25b831d4a7 --- /dev/null +++ b/src/framework/playground/migrations/PostgreSQL/Tenant/20250416110812_Add Tenant Schema.cs @@ -0,0 +1,50 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FSH.PlayGround.Migrations.PostgreSQL.Tenant; + +/// +public partial class AddTenantSchema : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "tenant"); + + migrationBuilder.CreateTable( + name: "Tenants", + schema: "tenant", + columns: table => new + { + Id = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + Identifier = table.Column(type: "text", nullable: false), + Name = table.Column(type: "text", nullable: false), + ConnectionString = table.Column(type: "text", nullable: false), + AdminEmail = table.Column(type: "text", nullable: false), + IsActive = table.Column(type: "boolean", nullable: false), + ValidUpto = table.Column(type: "timestamp without time zone", nullable: false), + Issuer = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Tenants", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_Tenants_Identifier", + schema: "tenant", + table: "Tenants", + column: "Identifier", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Tenants", + schema: "tenant"); + } +} \ No newline at end of file diff --git a/src/framework/playground/migrations/PostgreSQL/Tenant/TenantDbContextModelSnapshot.cs b/src/framework/playground/migrations/PostgreSQL/Tenant/TenantDbContextModelSnapshot.cs new file mode 100644 index 0000000000..d1ecf509bc --- /dev/null +++ b/src/framework/playground/migrations/PostgreSQL/Tenant/TenantDbContextModelSnapshot.cs @@ -0,0 +1,66 @@ +// +using System; +using FSH.Framework.Tenant.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FSH.PlayGround.Migrations.PostgreSQL.Tenant +{ + [DbContext(typeof(TenantDbContext))] + partial class TenantDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.4") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("FSH.Framework.Shared.Multitenancy.FshTenantInfo", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("AdminEmail") + .IsRequired() + .HasColumnType("text"); + + b.Property("ConnectionString") + .IsRequired() + .HasColumnType("text"); + + b.Property("Identifier") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Issuer") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ValidUpto") + .HasColumnType("timestamp without time zone"); + + b.HasKey("Id"); + + b.HasIndex("Identifier") + .IsUnique(); + + b.ToTable("Tenants", "tenant"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/framework/scripts/pack.ps1 b/src/framework/scripts/pack.ps1 new file mode 100644 index 0000000000..5f5a07c9ed --- /dev/null +++ b/src/framework/scripts/pack.ps1 @@ -0,0 +1,11 @@ +# Go to repo root from /scripts +Set-Location .. + +Write-Host "Packing local FSH modules..." + +$projects = Get-ChildItem -Recurse -Filter *.csproj ` + | Where-Object { $_.FullName -match "\\framework\\" -and $_.FullName } + +foreach ($proj in $projects) { + dotnet pack $proj.FullName -c Release -o ./nupkgs +} diff --git a/src/global.json b/src/global.json index d5bf446d0c..01fa100de6 100644 --- a/src/global.json +++ b/src/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "9.0.100", + "version": "9.0.203", "rollForward": "latestFeature" } } \ No newline at end of file diff --git a/src/pos/api/FSH.POS.sln b/src/pos/api/FSH.POS.sln new file mode 100644 index 0000000000..58ea566644 --- /dev/null +++ b/src/pos/api/FSH.POS.sln @@ -0,0 +1,14 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal