diff --git a/.cursorrules b/.cursorrules new file mode 100644 index 0000000..77511bb --- /dev/null +++ b/.cursorrules @@ -0,0 +1,31 @@ +# ASP.NET Core 8 Project Rules + +- Use C# 12 language features where appropriate +- Follow SOLID principles in class and interface design +- Implement dependency injection for loose coupling +- Use primary constructors for dependency injection in services, use cases, etc. +- Use async/await for I/O-bound operations +- Prefer record types for immutable data structures +- Prefer controller endpoints over minimal APIs + - Utilize minimal APIs for simple endpoints (when explicitly stated or when it makes sense) +- Implement proper exception handling and logging +- Use strongly-typed configuration with IOptions pattern +- Implement proper authentication and authorization +- Use Entity Framework Core for database operations +- Implement unit tests for business logic +- Use integration tests for API endpoints +- Implement proper versioning for APIs +- Implement proper caching strategies +- Use middleware for cross-cutting concerns +- Implement health checks for the application +- Use environment-specific configuration files +- Implement proper CORS policies +- Use secure communication with HTTPS +- Implement proper model validation +- Use Swagger/OpenAPI for API documentation +- Implement proper logging with structured logging +- Use background services for long-running tasks +- Favor explicit typing (this is very important). Only use var when evident. +- Make types internal and sealed by default unless otherwise specified +- Prefer Guid for identifiers unless otherwise specified +- Use `is null` checks instead of `== null`. The same goes for `is not null` and `!= null`. diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3729ff0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,25 @@ +**/.classpath +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/azds.yaml +**/bin +**/charts +**/docker-compose* +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +LICENSE +README.md \ No newline at end of file diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..f773573 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,413 @@ +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 = true + +#### .NET Coding Conventions #### + +# Organize usings +dotnet_separate_import_directive_groups = false +dotnet_sort_system_directives_first = true + +# this. and Me. preferences +dotnet_style_qualification_for_event = false:error +dotnet_style_qualification_for_field = false:error +dotnet_style_qualification_for_method = false:error +dotnet_style_qualification_for_property = false:error + +# Language keywords vs BCL types preferences +dotnet_style_predefined_type_for_locals_parameters_members = true:error +dotnet_style_predefined_type_for_member_access = true:error + +# Parentheses preferences +dotnet_style_parentheses_in_arithmetic_binary_operators = never_if_unnecessary:error +dotnet_style_parentheses_in_other_binary_operators = never_if_unnecessary:error +dotnet_style_parentheses_in_other_operators = never_if_unnecessary:error +dotnet_style_parentheses_in_relational_binary_operators = never_if_unnecessary:error + +# Modifier preferences +dotnet_style_require_accessibility_modifiers = for_non_interface_members:error + +# Expression-level preferences +dotnet_style_coalesce_expression = true:error +dotnet_style_collection_initializer = true:error +dotnet_style_explicit_tuple_names = true:error +dotnet_style_null_propagation = true:error +dotnet_style_object_initializer = true:error +dotnet_style_prefer_auto_properties = true:warning +dotnet_style_prefer_compound_assignment = true:error +dotnet_style_prefer_conditional_expression_over_assignment = true:error +dotnet_style_prefer_conditional_expression_over_return = true:none +dotnet_style_prefer_inferred_anonymous_type_member_names = true:error +dotnet_style_prefer_inferred_tuple_names = true:error +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:error +csharp_indent_labels = one_less_than_current +csharp_using_directive_placement = outside_namespace:error +csharp_prefer_simple_using_statement = true:error +csharp_prefer_braces = true:error +csharp_style_namespace_declarations = file_scoped:error +csharp_style_prefer_method_group_conversion = true:silent +csharp_style_prefer_top_level_statements = true:silent +csharp_style_prefer_primary_constructors = true:none +csharp_style_expression_bodied_methods = false:silent +csharp_style_expression_bodied_constructors = false:silent +csharp_style_expression_bodied_operators = true:error +csharp_style_expression_bodied_properties = true:error +csharp_style_expression_bodied_indexers = true:error +csharp_style_expression_bodied_accessors = true:error +csharp_style_expression_bodied_lambdas = true:error +csharp_style_expression_bodied_local_functions = true:error + +[*.{cs,vb}] +dotnet_style_prefer_simplified_boolean_expressions = true:suggestion +dotnet_style_prefer_simplified_interpolation = true:suggestion +dotnet_style_namespace_match_folder = true:suggestion + +# Field preferences +dotnet_style_readonly_field = true:error + +# Parameter preferences +dotnet_code_quality_unused_parameters = all:none + +#### C# Coding Conventions #### + +# Namespace preferences +csharp_style_namespace_declarations= file_scoped:error + +# var preferences +csharp_style_var_elsewhere = false:warning +csharp_style_var_for_built_in_types = true:warning +csharp_style_var_when_type_is_apparent = true:warning + +# Expression-bodied members +csharp_style_expression_bodied_accessors = true:error +csharp_style_expression_bodied_constructors = false:silent +csharp_style_expression_bodied_indexers = true:error +csharp_style_expression_bodied_lambdas = true:error +csharp_style_expression_bodied_local_functions = true:error +csharp_style_expression_bodied_methods = false:silent +csharp_style_expression_bodied_operators = true:error +csharp_style_expression_bodied_properties = true:error + +# Pattern matching preferences +csharp_style_pattern_matching_over_as_with_null_check = true:error +csharp_style_pattern_matching_over_is_with_cast_check = true:error +csharp_style_prefer_switch_expression = true:error + +# Null-checking preferences +csharp_style_conditional_delegate_call = true:error + +# Modifier preferences +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 + +# Code-block preferences +csharp_prefer_braces = true:error +csharp_prefer_simple_using_statement = true:error + +# Expression-level preferences +csharp_prefer_simple_default_expression = true:error +csharp_style_deconstructed_variable_declaration = true:suggestion +csharp_style_inlined_variable_declaration = true:error +csharp_style_pattern_local_over_anonymous_function = true:error +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:silent +csharp_style_unused_value_expression_statement_preference = discard_variable:none +csharp_style_prefer_method_group_conversion = true:silent +csharp_style_prefer_top_level_statements = true:silent + +# 'using' directive preferences +csharp_using_directive_placement = outside_namespace:error + +#### 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 = false, +csharp_new_line_before_members_in_object_initializers = false, +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 = false + +#### 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 + +# Custom Rules - configure these as required + +# .NET Code Analyzers rules + +# CA1000: Do not declare static members on generic types +dotnet_diagnostic.CA1000.severity = none + +# CA1002: Do not expose generic lists +dotnet_diagnostic.CA1002.severity = none + +# CA1008: Enums should have zero value +dotnet_diagnostic.CA1008.severity = none + +# CA1019: Define accessors for attribute arguments +dotnet_diagnostic.CA1019.severity = none + +# CA1024: Use properties where appropriate +dotnet_diagnostic.CA1024.severity = none + +# CA1030: Use events where appropriate +dotnet_diagnostic.CA1030.severity = none + +# CA1031: Do not catch general exception types +dotnet_diagnostic.CA1031.severity = none + +# CA1032: Implement standard exception constructors +dotnet_diagnostic.CA1032.severity = none + +# CA1034: Nested types should not be visible +dotnet_diagnostic.CA1034.severity = none + +# CA1040: Avoid empty interfaces +dotnet_diagnostic.CA1040.severity = none + +# CA1051: Do not declare visible instance fields +dotnet_diagnostic.CA1051.severity = none + +# CA1056: URI-like properties should not be strings +dotnet_diagnostic.CA1056.severity = none + +# CA1062: Validate arguments of public methods +dotnet_diagnostic.CA1062.severity = none + +# CA1063: Implement IDisposable Correctly +dotnet_diagnostic.CA1063.severity = none + +# CA1307: Specify StringComparison for clarity +dotnet_diagnostic.CA1307.severity = none + +# CA1515: Consider making public types internal +dotnet_diagnostic.CA1515.severity = none + +# CA1700: Do not name enum values 'Reserved' +dotnet_diagnostic.CA1700.severity = none + +# CA1707: Identifiers should not contain underscores +dotnet_diagnostic.CA1707.severity = none + +# CA1711: Identifiers should not have incorrect suffix +dotnet_diagnostic.CA1711.severity = none + +# CA1716: Identifiers should not match keywords +dotnet_diagnostic.CA1716.severity = none + +# CA1724: Type names should not match namespaces +dotnet_diagnostic.CA1724.severity = none + +# CA1725: Parameter names should match base declaration +dotnet_diagnostic.CA1725.severity = none + +# CA1812: Avoid uninstantiated internal classes +dotnet_diagnostic.CA1812.severity = none + +# CA1816: Dispose methods should call SuppressFinalize +dotnet_diagnostic.CA1816.severity = none + +# CA1819: Properties should not return arrays +dotnet_diagnostic.CA1819.severity = none + +# CA1822: Mark members as static +dotnet_diagnostic.CA1822.severity = none + +# CA1848: Use the LoggerMessage delegates +dotnet_diagnostic.CA1848.severity = none + +# CA1860: Avoid using 'Enumerable.Any()' extension method +dotnet_diagnostic.CA1860.severity = none + +# CA2007: Consider calling ConfigureAwait on the awaited task +dotnet_diagnostic.CA2007.severity = none + +# CA2201: Do not raise reserved exception types +dotnet_diagnostic.CA2201.severity = none + +# CA2211: Non-constant fields should not be visible +dotnet_diagnostic.CA2211.severity = none + +# CA2213: Disposable fields should be disposed +dotnet_diagnostic.CA2213.severity = none + +# CA2225: Operator overloads have named alternates +dotnet_diagnostic.CA2225.severity = none + +# CA2227: Collection properties should be read only +dotnet_diagnostic.CA2227.severity = none + +# CA2234: Pass system uri objects instead of strings +dotnet_diagnostic.CA2234.severity = none + +# CA2326: Do not use TypeNameHandling values other than None +dotnet_diagnostic.CA2326.severity = none + +# CA2326: Do not use insecure JsonSerializerSettings +dotnet_diagnostic.CA2327.severity = none + +# CS8600: Converting null literal or possible null value to non-nullable type. +dotnet_diagnostic.CS8600.severity = none + +# CS8603: Possible null reference return. +dotnet_diagnostic.CS8603.severity = none + +# CS8618: Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. +dotnet_diagnostic.CS8618.severity = none + +# IDE Code Analyzers rules + +# IDE0005 +dotnet_diagnostic.IDE0005.severity = none + +# IDE0008: Use explicit type instead of 'var' +dotnet_diagnostic.IDE0008.severity = none + +# IDE0046: Convert to conditional expression +dotnet_diagnostic.IDE0046.severity = none + +# IDE0058: Expression value is never used +dotnet_diagnostic.IDE0058.severity = none + +# IDE0060: Remove unused parameter +dotnet_diagnostic.IDE0060.severity = none + +# IDE0072: Add missing cases +dotnet_diagnostic.IDE0072.severity = none + +# IDE0290: Use primary constructor +dotnet_diagnostic.IDE0290.severity = none + +# SonarAnalyzer.CSharp rules + +# S112: General or reserved exceptions should never be thrown +dotnet_diagnostic.S112.severity = none + +# S125: Sections of code should not be commented out +dotnet_diagnostic.S125.severity = none + +# S1135: Track uses of "TODO" tags +dotnet_diagnostic.S1135.severity = none + +# S2094: Utility classes should not have public constructors +dotnet_diagnostic.S1118.severity = none + +# S2094: Classes should not be empty +dotnet_diagnostic.S2094.severity = none + +# S2325: Methods and properties that don't access instance data should be static +dotnet_diagnostic.S2325.severity = none + +# S2326: Generic type parameter is not used in interface +dotnet_diagnostic.S2326.severity = none + +# S2365: Properties should not make collection or array copies +dotnet_diagnostic.S2365.severity = none + +# S3267: Loops should be simplified with "LINQ" expressions +dotnet_diagnostic.S3267.severity = none + +# S3881: "IDisposable" should be implemented correctly +dotnet_diagnostic.S3881.severity = none + +# S4136: Method overloads should be grouped together +dotnet_diagnostic.S4136.severity = none + +# S4158: Empty collections should not be accessed or iterated +dotnet_diagnostic.S4158.severity = none + +# S6605: Collection-specific "Exists" method should be used instead of the "Any" extension +dotnet_diagnostic.S6605.severity = none + +# S6781: JWT secret keys should not be disclosed +dotnet_diagnostic.S6781.severity = none diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6b9ae06 --- /dev/null +++ b/.gitignore @@ -0,0 +1,400 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 auto-generated project file (contains which files were open etc.) +*.vbp + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# JetBrains Rider +*.sln.iml + +.containers/ diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..f588c79 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,23 @@ + + + + net8.0 + + + enable + enable + + latest + All + true + true + true + + + \ No newline at end of file diff --git a/LegalAssistant.AppService.sln b/LegalAssistant.AppService.sln new file mode 100644 index 0000000..98e4dbb --- /dev/null +++ b/LegalAssistant.AppService.sln @@ -0,0 +1,48 @@ + +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}") = "src", "src", "{EDE5F5E1-80F9-4DCE-80B0-BFDBF36DC680}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Application", "src\Application\Application.csproj", "{44042BE8-3AF1-45B5-8719-2AFFDA30CF41}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Infrastructure", "src\Infrastructure\Infrastructure.csproj", "{55A6D295-C238-464F-9729-3E9E9B4972B5}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Web.Api", "src\Web.Api\Web.Api.csproj", "{BB7A6132-5274-490D-A502-FCA2CF51BA94}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Domain", "src\Domain\Domain.csproj", "{0DA30A4D-944B-49F4-AEE5-E89DF7B7EC9A}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {44042BE8-3AF1-45B5-8719-2AFFDA30CF41}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {44042BE8-3AF1-45B5-8719-2AFFDA30CF41}.Debug|Any CPU.Build.0 = Debug|Any CPU + {44042BE8-3AF1-45B5-8719-2AFFDA30CF41}.Release|Any CPU.ActiveCfg = Release|Any CPU + {44042BE8-3AF1-45B5-8719-2AFFDA30CF41}.Release|Any CPU.Build.0 = Release|Any CPU + {55A6D295-C238-464F-9729-3E9E9B4972B5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {55A6D295-C238-464F-9729-3E9E9B4972B5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {55A6D295-C238-464F-9729-3E9E9B4972B5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {55A6D295-C238-464F-9729-3E9E9B4972B5}.Release|Any CPU.Build.0 = Release|Any CPU + {BB7A6132-5274-490D-A502-FCA2CF51BA94}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BB7A6132-5274-490D-A502-FCA2CF51BA94}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BB7A6132-5274-490D-A502-FCA2CF51BA94}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BB7A6132-5274-490D-A502-FCA2CF51BA94}.Release|Any CPU.Build.0 = Release|Any CPU + {0DA30A4D-944B-49F4-AEE5-E89DF7B7EC9A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0DA30A4D-944B-49F4-AEE5-E89DF7B7EC9A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0DA30A4D-944B-49F4-AEE5-E89DF7B7EC9A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0DA30A4D-944B-49F4-AEE5-E89DF7B7EC9A}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {44042BE8-3AF1-45B5-8719-2AFFDA30CF41} = {EDE5F5E1-80F9-4DCE-80B0-BFDBF36DC680} + {55A6D295-C238-464F-9729-3E9E9B4972B5} = {EDE5F5E1-80F9-4DCE-80B0-BFDBF36DC680} + {BB7A6132-5274-490D-A502-FCA2CF51BA94} = {EDE5F5E1-80F9-4DCE-80B0-BFDBF36DC680} + {0DA30A4D-944B-49F4-AEE5-E89DF7B7EC9A} = {EDE5F5E1-80F9-4DCE-80B0-BFDBF36DC680} + EndGlobalSection +EndGlobal diff --git a/README.md b/README.md index 62009a5..5908f17 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,10 @@ dotnet run --project src/Web.Api ## 📖 Detailed Documentation +- 📚 **[Documentation Folder](docs/)** - Tài liệu chi tiết về architecture và patterns +- 🏗️ **[Architecture Overview](docs/ARCHITECTURE_OVERVIEW.md)** - Hiểu tổng quan kiến trúc +- 🔄 **[Event Handler Flow](docs/EVENT_HANDLER_FLOW.md)** - Cách Domain Events hoạt động + Xem README trong từng folder để hiểu chi tiết: - [Domain Layer](./src/Domain/README.md) diff --git a/docs/ARCHITECTURE_OVERVIEW.md b/docs/ARCHITECTURE_OVERVIEW.md new file mode 100644 index 0000000..54c8a9a --- /dev/null +++ b/docs/ARCHITECTURE_OVERVIEW.md @@ -0,0 +1,147 @@ +# 🎯 KIẾN TRÚC DỄ HIỂU - Legal Assistant + +## 🔴 **MỤC ĐÍCH CỦA TỪNG LAYER** + +``` +🌐 Web.Api/ ← API endpoints (controllers) + ↓ gọi +🔧 Application/ ← Use cases (login, create user, etc.) + ↓ sử dụng +🏛️ Domain/ ← Business rules (User, Conversation) + ↑ implement +🗄️ Infrastructure/ ← Database, email, external services +``` + +## 🔥 **LUỒNG HOẠT ĐỘNG CƠ BẢN** + +### 1️⃣ **User muốn đăng ký tài khoản:** +``` +📱 Client gửi POST /api/users + ↓ +🌐 UsersController.CreateUser() + ↓ +🔧 CreateUserCommandHandler.Handle() + ↓ +🏛️ UserAggregate.Create() → tạo User entity + UserCreatedEvent + ↓ +🗄️ UserRepository.SaveAsync() → lưu vào database + ↓ +📧 UserCreatedEventHandler → gửi email welcome +``` + +### 2️⃣ **User muốn tạo conversation:** +``` +📱 Client gửi POST /api/conversations + ↓ +🌐 ConversationsController.Create() + ↓ +🔧 CreateConversationCommandHandler.Handle() + ↓ +🏛️ ConversationAggregate.Create() → tạo Conversation entity + ↓ +🗄️ ConversationRepository.SaveAsync() +``` + +## 🧩 **CÁC PATTERN ĐƯỢC SỬ DỤNG** + +### 🔵 **1. CQRS (Command Query Responsibility Segregation)** +- **Commands** = Thay đổi data (Create, Update, Delete) +- **Queries** = Đọc data (Get, List, Search) + +```csharp +// Command - thay đổi data +public record CreateUserCommand(string Email, string FullName, string Password); + +// Query - đọc data +public record GetUserByIdQuery(Guid UserId); +``` + +### 🔵 **2. Domain Events** +- Khi có sự kiện quan trọng → raise event → trigger side effects + +```csharp +// Khi user được tạo +UserAggregate.Create() → raises UserCreatedEvent + ↓ +UserCreatedEventHandler → gửi email welcome +``` + +### 🔵 **3. MediatR Pipeline Behaviors** +- **Middleware** cho tất cả requests +- Validation, Logging, Authorization, etc. + +```csharp +Request → ValidationBehavior → AuthorizationBehavior → Handler → Response +``` + +## 📂 **FOLDERS QUAN TRỌNG NHẤT** + +### 🏛️ **Domain/** - Business Logic +``` +Entities/ ← Data objects (User, Conversation, Message) +Aggregates/ ← Business operations (UserAggregate, ConversationAggregate) +Events/ ← Domain events (UserCreatedEvent, etc.) +ValueObjects/ ← Value objects (Email, Password) +``` + +### 🔧 **Application/** - Use Cases +``` +Features/ ← Organized by business feature +├── Auth/ ← Login, logout, register +├── User/ ← Create, update, get user +└── Conversation/ ← Create, get conversations + +Common/ +├── Behaviors/ ← Pipeline middleware +├── Exceptions/ ← Custom exceptions +└── Models/ ← Shared models +``` + +### 🗄️ **Infrastructure/** - External Services +``` +Data/ ← Database (Entity Framework) +Services/ ← Email, external APIs +Repositories/ ← Data access implementations +``` + +### 🌐 **Web.Api/** - HTTP Layer +``` +Controllers/ ← API endpoints +Middleware/ ← HTTP middleware +Models/ ← Request/response models +``` + +## 🎯 **QUY TẮC DEPENDENCY** + +``` +Web.Api → Application → Domain + ↓ ↓ +Infrastructure ←――→ +``` + +- **Domain** không depend vào ai cả +- **Application** chỉ depend Domain +- **Infrastructure** depend Domain + Application +- **Web.Api** depend Application + Infrastructure + +## 🚀 **BƯỚC ĐẦU TIÊN ĐỂ HIỂU** + +1. 🏛️ Xem **Domain/Entities/** → hiểu data structure +2. 🏛️ Xem **Domain/Aggregates/** → hiểu business logic +3. 🔧 Xem **Application/Features/User/** → hiểu use cases +4. 🌐 Xem **Web.Api/Controllers/** → hiểu API endpoints + +## 💡 **TIPS ĐỂ KHÔNG BỊ CONFUSED** + +✅ **Bắt đầu từ Domain Entities** → hiểu data trước +✅ **Theo dõi 1 luồng từ đầu đến cuối** (VD: CreateUser) +✅ **Bỏ qua các pattern phức tạp** lúc đầu (Events, Behaviors) +✅ **Focus vào business logic** trong Aggregates + +❌ **Đừng cố hiểu tất cả pattern cùng lúc** +❌ **Đừng bắt đầu từ Infrastructure** +❌ **Đừng quan tâm Behaviors/Events lúc đầu** + +--- + +**🎯 Mục tiêu: Hiểu được luồng tạo User từ API → Database** diff --git a/docs/EVENT_HANDLER_FLOW.md b/docs/EVENT_HANDLER_FLOW.md new file mode 100644 index 0000000..8b25537 --- /dev/null +++ b/docs/EVENT_HANDLER_FLOW.md @@ -0,0 +1,213 @@ +# 🔄 Event Handler - Luồng hoạt động chi tiết + +## 📍 **1. TỔNG QUAN LUỒNG EVENT** + +``` +UserAggregate.Create() → Raises Event → MediatR Dispatch → Event Handler → Side Effects +``` + +## 🎯 **2. VÍ DỤ CỤ THỂ: User Registration** + +### **Step 1: Aggregate raises Event** +```csharp +// Trong UserAggregate.Create() +public static UserAggregate Create(string email, string fullName, string passwordHash, UserRole[]? roles = null) +{ + // Tạo user entity + var user = new Entities.User + { + Email = email, + FullName = fullName, + PasswordHash = passwordHash, + Roles = roles ?? [UserRole.User] + }; + + var aggregate = new UserAggregate(user); + + // 🔥 RAISE EVENT - đây là điểm quan trọng! + aggregate.AddDomainEvent(new UserCreatedEvent( + user.Id, + user.Email, + user.FullName, + user.Roles.Select(r => r.ToString()).ToArray())); + + return aggregate; +} +``` + +### **Step 2: Event được lưu trong Aggregate** +```csharp +// Trong BaseAggregateRoot +public abstract class BaseAggregateRoot : BaseEntity +{ + private readonly List _domainEvents = []; + + protected void AddDomainEvent(IDomainEvent domainEvent) + { + _domainEvents.Add(domainEvent); // 📦 Event được lưu ở đây + } + + public IReadOnlyCollection DomainEvents => _domainEvents.AsReadOnly(); +} +``` + +### **Step 3: Command Handler save Aggregate** +```csharp +// Trong CreateUserCommandHandler +public async Task> Handle(CreateUserCommand request, CancellationToken cancellationToken) +{ + // Tạo aggregate (với events) + var userAggregate = UserAggregate.Create(request.Email, request.FullName, hashedPassword); + + // 💾 Save aggregate - events vẫn nằm trong aggregate + await _userRepository.CreateAsync(userAggregate.GetUser(), cancellationToken); + + return Result.Success(response); +} // ← Command hoàn thành, giờ events sẽ được dispatch +``` + +### **Step 4: DomainEventBehavior dispatch Events** +```csharp +// Trong DomainEventBehavior (MediatR Pipeline) +public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) +{ + // 1. Execute command first + var response = await next(); // ← CreateUserCommandHandler chạy xong + + // 2. Dispatch events after command success + if (request is ICommand) + { + // 🔍 Lấy tất cả entities có events từ DbContext + var entitiesWithEvents = await GetEntitiesWithDomainEventsAsync(); + + foreach (var entity in entitiesWithEvents) + { + foreach (var domainEvent in entity.DomainEvents) + { + // 📡 Dispatch từng event qua MediatR + await _mediator.Publish(domainEvent, cancellationToken); + } + + // 🧹 Clear events sau khi dispatch + entity.ClearDomainEvents(); + } + } + + return response; +} +``` + +### **Step 5: MediatR route Event đến Handler** +```csharp +// MediatR tự động tìm handler cho UserCreatedEvent +await _mediator.Publish(userCreatedEvent, cancellationToken); + ↓ +// Route đến UserCreatedEventHandler.Handle() +``` + +### **Step 6: Event Handler thực hiện Side Effects** +```csharp +public async Task Handle(UserCreatedEvent notification, CancellationToken cancellationToken) +{ + logger.LogInformation("User created: {UserId} - {Email}", + notification.UserId, notification.Email); + + // 🔥 SIDE EFFECTS - Những việc xảy ra khi user được tạo: + + // 1. 📧 Gửi welcome email + await emailService.SendWelcomeEmailAsync(notification.Email, notification.FullName); + + // 2. 👤 Tạo user profile mặc định + await profileService.CreateDefaultProfileAsync(notification.UserId); + + // 3. ⚙️ Khởi tạo settings mặc định + await settingsService.CreateDefaultSettingsAsync(notification.UserId); + + // 4. 📊 Track analytics + await analyticsService.TrackUserRegistrationAsync(notification.UserId); + + // 5. 🔔 Gửi notification cho admin + await notificationService.NotifyAdminNewUserAsync(notification.UserId); +} +``` + +## 🎯 **3. TẠI SAO SỬ DỤNG EVENT HANDLER?** + +### ✅ **ADVANTAGES:** + +| **Vấn đề** | **Không có Events** | **Có Events** | +|------------|--------------------|--------------------| +| **🔗 Coupling** | Handler phải biết tất cả side effects | Handler chỉ lo business logic | +| **🧪 Testing** | Khó test vì nhiều dependencies | Dễ test từng handler riêng | +| **📈 Scalability** | Thêm feature = sửa handler cũ | Thêm feature = thêm handler mới | +| **🔄 Consistency** | Có thể quên side effects | Tự động trigger tất cả handlers | + +### **VD: Không có Events** ❌ +```csharp +public async Task Handle(CreateUserCommand request, CancellationToken cancellationToken) +{ + // Tạo user + var user = UserAggregate.Create(...); + await _userRepository.CreateAsync(user); + + // 😰 Phải nhớ làm tất cả side effects trong 1 handler + await _emailService.SendWelcomeEmailAsync(...); // Email + await _profileService.CreateDefaultProfileAsync(...); // Profile + await _settingsService.CreateDefaultSettingsAsync(...); // Settings + await _analyticsService.TrackUserRegistrationAsync(...); // Analytics + await _notificationService.NotifyAdminNewUserAsync(...); // Notification + + // 🤢 Handler trở nên phức tạp và khó maintain + return Result.Success(); +} +``` + +### **VD: Có Events** ✅ +```csharp +// CreateUserCommandHandler - CHỈ LO BUSINESS LOGIC +public async Task Handle(CreateUserCommand request, CancellationToken cancellationToken) +{ + var user = UserAggregate.Create(...); // ← Automatically raises UserCreatedEvent + await _userRepository.CreateAsync(user); + return Result.Success(); +} // ← Events tự động được dispatch + +// UserCreatedEventHandler - LO SIDE EFFECTS +public async Task Handle(UserCreatedEvent notification, CancellationToken cancellationToken) +{ + await _emailService.SendWelcomeEmailAsync(...); +} + +// UserProfileEventHandler - LO PROFILE +public async Task Handle(UserCreatedEvent notification, CancellationToken cancellationToken) +{ + await _profileService.CreateDefaultProfileAsync(...); +} +``` + +## 🚀 **4. MULTIPLE EVENT HANDLERS** + +**1 Event có thể có nhiều handlers:** + +```csharp +// UserCreatedEvent sẽ trigger TẤT CẢ handlers này: + +UserCreatedEventHandler → Gửi email welcome +UserProfileEventHandler → Tạo profile +UserSettingsEventHandler → Tạo settings +UserAnalyticsEventHandler → Track analytics +UserNotificationEventHandler → Notify admin +``` + +**MediatR tự động gọi TẤT CẢ handlers song song!** + +## 💡 **5. KẾT LUẬN** + +Event Handler = **"Khi X xảy ra, hãy làm Y"** + +- **Decoupling**: Business logic tách biệt khỏi side effects +- **Extensible**: Dễ thêm features mới +- **Testable**: Test từng handler độc lập +- **Reliable**: Không bao giờ quên side effects + +**🎯 Đây là pattern rất mạnh trong microservices và domain-driven design!** diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..1f8ab2e --- /dev/null +++ b/docs/README.md @@ -0,0 +1,57 @@ +# 📚 Documentation - Legal Assistant + +**Tài liệu hướng dẫn hiểu và phát triển Legal Assistant Application** + +## 📖 **Danh sách tài liệu** + +### 🏗️ **1. ARCHITECTURE_OVERVIEW.md** +- **🎯 Mục đích**: Hiểu tổng quan kiến trúc Clean Architecture +- **👥 Đối tượng**: Developer mới, người muốn hiểu project structure +- **📋 Nội dung**: + - Dependency flow giữa các layers + - Mục đích từng folder + - Patterns được sử dụng (CQRS, DDD, Events) + - Quy tắc coding và best practices + +### 🔄 **2. EVENT_HANDLER_FLOW.md** +- **🎯 Mục đích**: Hiểu cách Domain Events hoạt động +- **👥 Đối tượng**: Developer cần hiểu Event-Driven Architecture +- **📋 Nội dung**: + - Luồng hoạt động từ Event creation → Handler execution + - So sánh có/không có Events + - Multiple Event Handlers + - Side effects và decoupling + +## 🚀 **Cách sử dụng tài liệu** + +### **🆕 Người mới:** +1. 📖 Đọc **ARCHITECTURE_OVERVIEW.md** trước +2. 🔍 Trace 1 luồng CreateUser từ đầu đến cuối +3. 📚 Đọc **EVENT_HANDLER_FLOW.md** để hiểu Events + +### **🔧 Developer có kinh nghiệm:** +1. 🏗️ Review **ARCHITECTURE_OVERVIEW.md** để hiểu patterns +2. 🔄 Đọc **EVENT_HANDLER_FLOW.md** để implement Events + +### **👨‍💼 Tech Lead/Architect:** +1. 📊 Review cả 2 docs để hiểu design decisions +2. 🎯 Sử dụng làm onboarding material cho team + +## 🧭 **Navigation Map** + +``` +📁 docs/ +├── 📖 README.md ← Bạn đang ở đây +├── 🏗️ ARCHITECTURE_OVERVIEW.md ← Bắt đầu từ đây +└── 🔄 EVENT_HANDLER_FLOW.md ← Sau khi hiểu kiến trúc +``` + +## 📞 **Hỗ trợ** + +- **❓ Có thắc mắc về architecture**: Xem ARCHITECTURE_OVERVIEW.md +- **🔄 Không hiểu Events**: Xem EVENT_HANDLER_FLOW.md +- **🆘 Vẫn chưa rõ**: Hỏi team hoặc tạo issue + +--- + +**🎯 Mục tiêu: Giúp mọi người hiểu và contribute vào project một cách hiệu quả!** diff --git a/src/Application/Application.csproj b/src/Application/Application.csproj new file mode 100644 index 0000000..d650da5 --- /dev/null +++ b/src/Application/Application.csproj @@ -0,0 +1,20 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + + diff --git a/src/Application/Common/Behaviors/AuthorizationBehavior.cs b/src/Application/Common/Behaviors/AuthorizationBehavior.cs new file mode 100644 index 0000000..90ce6f0 --- /dev/null +++ b/src/Application/Common/Behaviors/AuthorizationBehavior.cs @@ -0,0 +1,126 @@ +using Application.Common.Exceptions; +using MediatR; +using Microsoft.Extensions.Logging; + +namespace Application.Common.Behaviors; + +/// +/// Authorization requirement +/// +public sealed record AuthorizationRequirement +{ + /// + /// Required roles (user must have at least one) + /// + public string[] Roles { get; init; } = []; + + /// + /// Required permissions (user must have all) + /// + public string[] Permissions { get; init; } = []; + + /// + /// Require authentication (default true) + /// + public bool RequireAuthentication { get; init; } = true; +} + +/// +/// Marker interface for requests that require authorization +/// +public interface IAuthorizedRequest +{ + /// + /// Authorization requirements + /// + AuthorizationRequirement AuthorizationRequirement { get; } +} + +/// +/// Current user information (injected from HTTP context) +/// +public interface ICurrentUser +{ + /// + /// Current user ID + /// + Guid? UserId { get; } + + /// + /// Is user authenticated + /// + bool IsAuthenticated { get; } + + /// + /// User roles + /// + string[] Roles { get; } + + /// + /// User permissions + /// + string[] Permissions { get; } +} + +/// +/// Authorization behavior for MediatR pipeline +/// +/// Request type +/// Response type +public sealed class AuthorizationBehavior( + ICurrentUser currentUser, + ILogger> logger) + : IPipelineBehavior + where TRequest : notnull +{ + public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) + { + if (request is not IAuthorizedRequest authorizedRequest) + { + return await next(); + } + + var requirement = authorizedRequest.AuthorizationRequirement; + + // Check authentication + if (requirement.RequireAuthentication && !currentUser.IsAuthenticated) + { + logger.LogWarning("Unauthorized access attempt for {RequestName}", typeof(TRequest).Name); + throw new UnauthorizedException("Authentication required"); + } + + // Check roles + if (requirement.Roles.Length > 0) + { + var hasRequiredRole = requirement.Roles.Any(role => + currentUser.Roles.Contains(role, StringComparer.OrdinalIgnoreCase)); + + if (!hasRequiredRole) + { + logger.LogWarning("Access denied for user {UserId} to {RequestName}. Required roles: {RequiredRoles}, User roles: {UserRoles}", + currentUser.UserId, typeof(TRequest).Name, requirement.Roles, currentUser.Roles); + throw new ForbiddenException($"Required roles: {string.Join(", ", requirement.Roles)}"); + } + } + + // Check permissions + if (requirement.Permissions.Length > 0) + { + var hasAllPermissions = requirement.Permissions.All(permission => + currentUser.Permissions.Contains(permission, StringComparer.OrdinalIgnoreCase)); + + if (!hasAllPermissions) + { + var missingPermissions = requirement.Permissions.Except(currentUser.Permissions).ToArray(); + logger.LogWarning("Access denied for user {UserId} to {RequestName}. Missing permissions: {MissingPermissions}", + currentUser.UserId, typeof(TRequest).Name, missingPermissions); + throw new ForbiddenException($"Missing permissions: {string.Join(", ", missingPermissions)}"); + } + } + + logger.LogDebug("Authorization passed for user {UserId} to {RequestName}", + currentUser.UserId, typeof(TRequest).Name); + + return await next(); + } +} diff --git a/src/Application/Common/Behaviors/CachingBehavior.cs b/src/Application/Common/Behaviors/CachingBehavior.cs new file mode 100644 index 0000000..c756cb1 --- /dev/null +++ b/src/Application/Common/Behaviors/CachingBehavior.cs @@ -0,0 +1,73 @@ +using MediatR; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using System.Text.Json; + +namespace Application.Common.Behaviors; + +/// +/// Marker interface for cacheable queries +/// +public interface ICacheableQuery +{ + /// + /// Cache key for the query + /// + string CacheKey { get; } + + /// + /// Cache expiration time + /// + TimeSpan? CacheExpiration { get; } +} + +/// +/// Caching behavior for MediatR pipeline +/// +/// Request type +/// Response type +public sealed class CachingBehavior( + IMemoryCache cache, + ILogger> logger) + : IPipelineBehavior + where TRequest : notnull +{ + public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) + { + if (request is not ICacheableQuery cacheableQuery) + { + return await next(); + } + + var cacheKey = cacheableQuery.CacheKey; + + // Try to get from cache + if (cache.TryGetValue(cacheKey, out TResponse? cachedResponse)) + { + logger.LogDebug("Cache hit for key: {CacheKey}", cacheKey); + return cachedResponse!; + } + + logger.LogDebug("Cache miss for key: {CacheKey}", cacheKey); + + // Execute the request + var response = await next(); + + // Cache the response + var cacheOptions = new MemoryCacheEntryOptions(); + + if (cacheableQuery.CacheExpiration.HasValue) + { + cacheOptions.SetAbsoluteExpiration(cacheableQuery.CacheExpiration.Value); + } + else + { + cacheOptions.SetAbsoluteExpiration(TimeSpan.FromMinutes(5)); // Default 5 minutes + } + + cache.Set(cacheKey, response, cacheOptions); + logger.LogDebug("Cached response for key: {CacheKey}", cacheKey); + + return response; + } +} diff --git a/src/Application/Common/Behaviors/DomainEventBehavior.cs b/src/Application/Common/Behaviors/DomainEventBehavior.cs new file mode 100644 index 0000000..b06a551 --- /dev/null +++ b/src/Application/Common/Behaviors/DomainEventBehavior.cs @@ -0,0 +1,35 @@ +using Application.Common; +using MediatR; +using Microsoft.Extensions.Logging; + +namespace Application.Common.Behaviors; + +/// +/// Pipeline behavior to dispatch domain events after command execution +/// +/// Request type +/// Response type +public sealed class DomainEventBehavior( + ILogger> logger) + : IPipelineBehavior + where TRequest : notnull +{ + public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) + { + // Execute the command first + var response = await next(); + + // Only dispatch events for commands, not queries + if (request is ICommand) + { + logger.LogDebug("Command completed, checking for domain events to dispatch"); + + // TODO: Get entities with domain events from current context/unit of work + // This would typically come from your DbContext or Repository pattern + // var entitiesWithEvents = await GetEntitiesWithDomainEventsAsync(); + // await domainEventDispatcher.DispatchEventsAsync(entitiesWithEvents, cancellationToken); + } + + return response; + } +} diff --git a/src/Application/Common/Behaviors/LoggingBehavior.cs b/src/Application/Common/Behaviors/LoggingBehavior.cs new file mode 100644 index 0000000..8f8f3fa --- /dev/null +++ b/src/Application/Common/Behaviors/LoggingBehavior.cs @@ -0,0 +1,41 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using System.Diagnostics; + +namespace Application.Common.Behaviors; + +/// +/// Logging behavior for MediatR pipeline +/// +/// Request type +/// Response type +public sealed class LoggingBehavior(ILogger> logger) + : IPipelineBehavior + where TRequest : notnull +{ + public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) + { + var requestName = typeof(TRequest).Name; + var stopwatch = Stopwatch.StartNew(); + + logger.LogInformation("Starting request {RequestName}", requestName); + + try + { + var response = await next(); + + stopwatch.Stop(); + logger.LogInformation("Completed request {RequestName} in {ElapsedMilliseconds}ms", + requestName, stopwatch.ElapsedMilliseconds); + + return response; + } + catch (Exception ex) + { + stopwatch.Stop(); + logger.LogError(ex, "Request {RequestName} failed after {ElapsedMilliseconds}ms", + requestName, stopwatch.ElapsedMilliseconds); + throw; + } + } +} diff --git a/src/Application/Common/Behaviors/PerformanceBehavior.cs b/src/Application/Common/Behaviors/PerformanceBehavior.cs new file mode 100644 index 0000000..d768e07 --- /dev/null +++ b/src/Application/Common/Behaviors/PerformanceBehavior.cs @@ -0,0 +1,38 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using System.Diagnostics; + +namespace Application.Common.Behaviors; + +/// +/// Performance monitoring behavior for MediatR pipeline +/// +/// Request type +/// Response type +public sealed class PerformanceBehavior(ILogger> logger) + : IPipelineBehavior + where TRequest : notnull +{ + private const int SlowRequestThresholdMs = 5000; // 5 seconds + + public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) + { + var stopwatch = Stopwatch.StartNew(); + + var response = await next(); + + stopwatch.Stop(); + + var elapsedMilliseconds = stopwatch.ElapsedMilliseconds; + + if (elapsedMilliseconds > SlowRequestThresholdMs) + { + var requestName = typeof(TRequest).Name; + + logger.LogWarning("Slow request detected: {RequestName} took {ElapsedMilliseconds}ms", + requestName, elapsedMilliseconds); + } + + return response; + } +} diff --git a/src/Application/Common/Behaviors/TransactionBehavior.cs b/src/Application/Common/Behaviors/TransactionBehavior.cs new file mode 100644 index 0000000..2af9883 --- /dev/null +++ b/src/Application/Common/Behaviors/TransactionBehavior.cs @@ -0,0 +1,54 @@ +using MediatR; +using Microsoft.Extensions.Logging; + +namespace Application.Common.Behaviors; + +/// +/// Marker interface for commands that require transaction +/// +public interface ITransactionalCommand +{ +} + +/// +/// Transaction behavior for MediatR pipeline +/// +/// Request type +/// Response type +public sealed class TransactionBehavior( + ILogger> logger) + : IPipelineBehavior + where TRequest : notnull +{ + public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) + { + if (request is not ITransactionalCommand) + { + return await next(); + } + + logger.LogDebug("Starting transaction for {RequestName}", typeof(TRequest).Name); + + // TODO: Implement actual transaction logic when database context is available + // using var transaction = await context.Database.BeginTransactionAsync(cancellationToken); + + try + { + var response = await next(); + + // TODO: Commit transaction + // await transaction.CommitAsync(cancellationToken); + + logger.LogDebug("Transaction committed for {RequestName}", typeof(TRequest).Name); + return response; + } + catch (Exception ex) + { + // TODO: Rollback transaction + // await transaction.RollbackAsync(cancellationToken); + + logger.LogError(ex, "Transaction rolled back for {RequestName}", typeof(TRequest).Name); + throw; + } + } +} diff --git a/src/Application/Common/Behaviors/ValidationBehavior.cs b/src/Application/Common/Behaviors/ValidationBehavior.cs new file mode 100644 index 0000000..bc604aa --- /dev/null +++ b/src/Application/Common/Behaviors/ValidationBehavior.cs @@ -0,0 +1,40 @@ +using Application.Common.Exceptions; +using FluentValidation; +using MediatR; + +namespace Application.Common.Behaviors; + +/// +/// Validation behavior for MediatR pipeline +/// +/// Request type +/// Response type +public sealed class ValidationBehavior(IEnumerable> validators) + : IPipelineBehavior + where TRequest : notnull +{ + public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) + { + if (!validators.Any()) + { + return await next(); + } + + var context = new ValidationContext(request); + + var validationResults = await Task.WhenAll( + validators.Select(v => v.ValidateAsync(context, cancellationToken))); + + var failures = validationResults + .Where(r => r.Errors.Count != 0) + .SelectMany(r => r.Errors) + .ToList(); + + if (failures.Count != 0) + { + throw new Exceptions.ValidationException(failures); + } + + return await next(); + } +} diff --git a/src/Application/Common/Exceptions/ApplicationException.cs b/src/Application/Common/Exceptions/ApplicationException.cs new file mode 100644 index 0000000..b98b823 --- /dev/null +++ b/src/Application/Common/Exceptions/ApplicationException.cs @@ -0,0 +1,15 @@ +namespace Application.Common.Exceptions; + +/// +/// Base application exception +/// +public abstract class ApplicationException : Exception +{ + protected ApplicationException(string message) : base(message) + { + } + + protected ApplicationException(string message, Exception innerException) : base(message, innerException) + { + } +} diff --git a/src/Application/Common/Exceptions/BusinessRuleException.cs b/src/Application/Common/Exceptions/BusinessRuleException.cs new file mode 100644 index 0000000..3e08ef4 --- /dev/null +++ b/src/Application/Common/Exceptions/BusinessRuleException.cs @@ -0,0 +1,21 @@ +namespace Application.Common.Exceptions; + +/// +/// Exception thrown when a business rule is violated +/// +public sealed class BusinessRuleException : ApplicationException +{ + /// + /// Business rule code for identification + /// + public string? RuleCode { get; } + + public BusinessRuleException(string message) : base(message) + { + } + + public BusinessRuleException(string ruleCode, string message) : base(message) + { + RuleCode = ruleCode; + } +} diff --git a/src/Application/Common/Exceptions/ConflictException.cs b/src/Application/Common/Exceptions/ConflictException.cs new file mode 100644 index 0000000..c3530b6 --- /dev/null +++ b/src/Application/Common/Exceptions/ConflictException.cs @@ -0,0 +1,16 @@ +namespace Application.Common.Exceptions; + +/// +/// Exception thrown when there is a conflict (e.g., duplicate data) +/// +public sealed class ConflictException : ApplicationException +{ + public ConflictException(string message) : base(message) + { + } + + public ConflictException(string name, object value) + : base($"Entity \"{name}\" with value \"{value}\" already exists.") + { + } +} diff --git a/src/Application/Common/Exceptions/ExternalServiceException.cs b/src/Application/Common/Exceptions/ExternalServiceException.cs new file mode 100644 index 0000000..a842e66 --- /dev/null +++ b/src/Application/Common/Exceptions/ExternalServiceException.cs @@ -0,0 +1,34 @@ +namespace Application.Common.Exceptions; + +/// +/// Exception thrown when external service call fails +/// +public sealed class ExternalServiceException : ApplicationException +{ + /// + /// Service name that failed + /// + public string ServiceName { get; } + + /// + /// HTTP status code (if applicable) + /// + public int? StatusCode { get; } + + public ExternalServiceException(string serviceName, string message) : base(message) + { + ServiceName = serviceName; + } + + public ExternalServiceException(string serviceName, string message, int statusCode) : base(message) + { + ServiceName = serviceName; + StatusCode = statusCode; + } + + public ExternalServiceException(string serviceName, string message, Exception innerException) + : base(message, innerException) + { + ServiceName = serviceName; + } +} diff --git a/src/Application/Common/Exceptions/ForbiddenException.cs b/src/Application/Common/Exceptions/ForbiddenException.cs new file mode 100644 index 0000000..a2d3209 --- /dev/null +++ b/src/Application/Common/Exceptions/ForbiddenException.cs @@ -0,0 +1,15 @@ +namespace Application.Common.Exceptions; + +/// +/// Exception thrown when user is authenticated but lacks permission +/// +public sealed class ForbiddenException : ApplicationException +{ + public ForbiddenException() : base("User does not have permission to access this resource.") + { + } + + public ForbiddenException(string message) : base(message) + { + } +} diff --git a/src/Application/Common/Exceptions/NotFoundException.cs b/src/Application/Common/Exceptions/NotFoundException.cs new file mode 100644 index 0000000..dc587b9 --- /dev/null +++ b/src/Application/Common/Exceptions/NotFoundException.cs @@ -0,0 +1,16 @@ +namespace Application.Common.Exceptions; + +/// +/// Exception thrown when a requested entity is not found +/// +public sealed class NotFoundException : ApplicationException +{ + public NotFoundException(string name, object key) + : base($"Entity \"{name}\" ({key}) was not found.") + { + } + + public NotFoundException(string message) : base(message) + { + } +} diff --git a/src/Application/Common/Exceptions/UnauthorizedException.cs b/src/Application/Common/Exceptions/UnauthorizedException.cs new file mode 100644 index 0000000..aaaa604 --- /dev/null +++ b/src/Application/Common/Exceptions/UnauthorizedException.cs @@ -0,0 +1,15 @@ +namespace Application.Common.Exceptions; + +/// +/// Exception thrown when user is not authorized to perform an action +/// +public sealed class UnauthorizedException : ApplicationException +{ + public UnauthorizedException() : base("User is not authorized to perform this action.") + { + } + + public UnauthorizedException(string message) : base(message) + { + } +} diff --git a/src/Application/Common/Exceptions/ValidationException.cs b/src/Application/Common/Exceptions/ValidationException.cs new file mode 100644 index 0000000..54cf0a7 --- /dev/null +++ b/src/Application/Common/Exceptions/ValidationException.cs @@ -0,0 +1,39 @@ +using FluentValidation.Results; + +namespace Application.Common.Exceptions; + +/// +/// Exception thrown when validation fails +/// +public sealed class ValidationException : ApplicationException +{ + /// + /// Validation errors + /// + public IReadOnlyDictionary Errors { get; } + + public ValidationException() : base("One or more validation failures have occurred.") + { + Errors = new Dictionary(); + } + + public ValidationException(IEnumerable failures) : this() + { + Errors = failures + .GroupBy(e => e.PropertyName, e => e.ErrorMessage) + .ToDictionary(failureGroup => failureGroup.Key, failureGroup => failureGroup.ToArray()); + } + + public ValidationException(string propertyName, string errorMessage) : this() + { + Errors = new Dictionary + { + [propertyName] = [errorMessage] + }; + } + + public ValidationException(Dictionary errors) : this() + { + Errors = errors; + } +} diff --git a/src/Application/Common/ICommand.cs b/src/Application/Common/ICommand.cs new file mode 100644 index 0000000..985141e --- /dev/null +++ b/src/Application/Common/ICommand.cs @@ -0,0 +1,18 @@ +using MediatR; + +namespace Application.Common; + +/// +/// Marker interface for commands +/// +public interface ICommand : IRequest +{ +} + +/// +/// Marker interface for commands with response +/// +/// Response type +public interface ICommand : IRequest +{ +} diff --git a/src/Application/Common/ICommandHandler.cs b/src/Application/Common/ICommandHandler.cs new file mode 100644 index 0000000..2e575e9 --- /dev/null +++ b/src/Application/Common/ICommandHandler.cs @@ -0,0 +1,22 @@ +using MediatR; + +namespace Application.Common; + +/// +/// Command handler interface +/// +/// Command type +public interface ICommandHandler : IRequestHandler + where TCommand : ICommand +{ +} + +/// +/// Command handler interface with response +/// +/// Command type +/// Response type +public interface ICommandHandler : IRequestHandler + where TCommand : ICommand +{ +} diff --git a/src/Application/Common/IDomainEventDispatcher.cs b/src/Application/Common/IDomainEventDispatcher.cs new file mode 100644 index 0000000..9c4bcfa --- /dev/null +++ b/src/Application/Common/IDomainEventDispatcher.cs @@ -0,0 +1,19 @@ +using Domain.Common; + +namespace Application.Common; + +/// +/// Interface for dispatching domain events +/// +public interface IDomainEventDispatcher +{ + /// + /// Dispatch all domain events from entities + /// + Task DispatchEventsAsync(IEnumerable entities, CancellationToken cancellationToken = default); + + /// + /// Dispatch a single domain event + /// + Task DispatchEventAsync(IDomainEvent domainEvent, CancellationToken cancellationToken = default); +} diff --git a/src/Application/Common/IDomainEventHandler.cs b/src/Application/Common/IDomainEventHandler.cs new file mode 100644 index 0000000..b10af39 --- /dev/null +++ b/src/Application/Common/IDomainEventHandler.cs @@ -0,0 +1,13 @@ +using Domain.Common; +using MediatR; + +namespace Application.Common; + +/// +/// Domain event handler interface +/// +/// Domain event type +public interface IDomainEventHandler : INotificationHandler + where TDomainEvent : IDomainEvent +{ +} diff --git a/src/Application/Common/IQuery.cs b/src/Application/Common/IQuery.cs new file mode 100644 index 0000000..4aa3156 --- /dev/null +++ b/src/Application/Common/IQuery.cs @@ -0,0 +1,11 @@ +using MediatR; + +namespace Application.Common; + +/// +/// Marker interface for queries +/// +/// Response type +public interface IQuery : IRequest +{ +} diff --git a/src/Application/Common/IQueryHandler.cs b/src/Application/Common/IQueryHandler.cs new file mode 100644 index 0000000..84c3d4c --- /dev/null +++ b/src/Application/Common/IQueryHandler.cs @@ -0,0 +1,13 @@ +using MediatR; + +namespace Application.Common; + +/// +/// Query handler interface +/// +/// Query type +/// Response type +public interface IQueryHandler : IRequestHandler + where TQuery : IQuery +{ +} diff --git a/src/Application/Common/Models/AuditableEntity.cs b/src/Application/Common/Models/AuditableEntity.cs new file mode 100644 index 0000000..a28f526 --- /dev/null +++ b/src/Application/Common/Models/AuditableEntity.cs @@ -0,0 +1,47 @@ +namespace Application.Common.Models; + +/// +/// Base auditable entity with common audit fields +/// +public abstract record AuditableEntity +{ + /// + /// Entity ID + /// + public required Guid Id { get; init; } + + /// + /// When the entity was created + /// + public required DateTime CreatedAt { get; init; } + + /// + /// Who created the entity + /// + public required Guid CreatedBy { get; init; } + + /// + /// When the entity was last updated + /// + public DateTime? UpdatedAt { get; init; } + + /// + /// Who last updated the entity + /// + public Guid? UpdatedBy { get; init; } + + /// + /// Soft delete flag + /// + public bool IsDeleted { get; init; } + + /// + /// When the entity was deleted (soft delete) + /// + public DateTime? DeletedAt { get; init; } + + /// + /// Who deleted the entity (soft delete) + /// + public Guid? DeletedBy { get; init; } +} diff --git a/src/Application/Common/Models/PaginatedResult.cs b/src/Application/Common/Models/PaginatedResult.cs new file mode 100644 index 0000000..72ab220 --- /dev/null +++ b/src/Application/Common/Models/PaginatedResult.cs @@ -0,0 +1,54 @@ +namespace Application.Common.Models; + +/// +/// Paginated result wrapper for queries that return lists +/// +/// Type of items in the result +public sealed record PaginatedResult +{ + /// + /// List of items for current page + /// + public required IReadOnlyList Items { get; init; } + + /// + /// Current page number (1-based) + /// + public required int PageNumber { get; init; } + + /// + /// Number of items per page + /// + public required int PageSize { get; init; } + + /// + /// Total number of items across all pages + /// + public required int TotalCount { get; init; } + + /// + /// Total number of pages + /// + public int TotalPages => (int)Math.Ceiling((double)TotalCount / PageSize); + + /// + /// Indicates if there is a previous page + /// + public bool HasPreviousPage => PageNumber > 1; + + /// + /// Indicates if there is a next page + /// + public bool HasNextPage => PageNumber < TotalPages; + + /// + /// Create empty paginated result + /// + public static PaginatedResult Empty() => new() + { + Items = [], + PageNumber = 1, + PageSize = 10, + TotalCount = 0 + }; +} diff --git a/src/Application/Common/Models/PaginationRequest.cs b/src/Application/Common/Models/PaginationRequest.cs new file mode 100644 index 0000000..4ebb9c0 --- /dev/null +++ b/src/Application/Common/Models/PaginationRequest.cs @@ -0,0 +1,43 @@ +namespace Application.Common.Models; + +/// +/// Base pagination request for queries +/// +public abstract record PaginationRequest +{ + private int _pageNumber = 1; + private int _pageSize = 10; + + /// + /// Page number (1-based, minimum 1) + /// + public int PageNumber + { + get => _pageNumber; + init => _pageNumber = value < 1 ? 1 : value; + } + + /// + /// Page size (minimum 1, maximum 100) + /// + public int PageSize + { + get => _pageSize; + init => _pageSize = value switch + { + < 1 => 1, + > 100 => 100, + _ => value + }; + } + + /// + /// Calculate skip count for database queries + /// + public int Skip => (PageNumber - 1) * PageSize; + + /// + /// Get take count (same as PageSize) + /// + public int Take => PageSize; +} diff --git a/src/Application/Common/Models/SortOrder.cs b/src/Application/Common/Models/SortOrder.cs new file mode 100644 index 0000000..3cdf4c6 --- /dev/null +++ b/src/Application/Common/Models/SortOrder.cs @@ -0,0 +1,51 @@ +namespace Application.Common.Models; + +/// +/// Sort order enumeration +/// +public enum SortOrder +{ + /// + /// Ascending order + /// + Ascending = 0, + + /// + /// Descending order + /// + Descending = 1 +} + +/// +/// Sort specification +/// +public sealed record SortSpecification +{ + /// + /// Field name to sort by + /// + public required string FieldName { get; init; } + + /// + /// Sort order + /// + public SortOrder Order { get; init; } = SortOrder.Ascending; + + /// + /// Create ascending sort + /// + public static SortSpecification Ascending(string fieldName) => new() + { + FieldName = fieldName, + Order = SortOrder.Ascending + }; + + /// + /// Create descending sort + /// + public static SortSpecification Descending(string fieldName) => new() + { + FieldName = fieldName, + Order = SortOrder.Descending + }; +} diff --git a/src/Application/EventHandlers/UserCreatedEventHandler.cs b/src/Application/EventHandlers/UserCreatedEventHandler.cs new file mode 100644 index 0000000..c73eb32 --- /dev/null +++ b/src/Application/EventHandlers/UserCreatedEventHandler.cs @@ -0,0 +1,34 @@ +using Application.Common; +using Domain.Events.User; +using Microsoft.Extensions.Logging; + +namespace Application.EventHandlers; + +/// +/// Handler for UserCreatedEvent +/// +public sealed class UserCreatedEventHandler( + ILogger logger) : IDomainEventHandler +{ + public async Task Handle(UserCreatedEvent notification, CancellationToken cancellationToken) + { + logger.LogInformation("User created: {UserId} - {Email}", + notification.UserId, notification.Email); + + // Side effects when user is created: + + // 1. Send welcome email + // await emailService.SendWelcomeEmailAsync(notification.Email, notification.FullName); + + // 2. Create user profile + // await profileService.CreateDefaultProfileAsync(notification.UserId); + + // 3. Initialize user settings + // await settingsService.CreateDefaultSettingsAsync(notification.UserId); + + // 4. Add to analytics + // await analyticsService.TrackUserRegistrationAsync(notification.UserId, notification.Roles); + + await Task.CompletedTask; // Remove this when implementing actual services + } +} diff --git a/src/Application/EventHandlers/UserDeactivatedEventHandler.cs b/src/Application/EventHandlers/UserDeactivatedEventHandler.cs new file mode 100644 index 0000000..0a01851 --- /dev/null +++ b/src/Application/EventHandlers/UserDeactivatedEventHandler.cs @@ -0,0 +1,36 @@ +using Application.Common; +using Domain.Events.User; +using Microsoft.Extensions.Logging; + +namespace Application.EventHandlers; + +/// +/// Handler for UserDeactivatedEvent +/// +public sealed class UserDeactivatedEventHandler( + ILogger logger) : IDomainEventHandler +{ + public async Task Handle(UserDeactivatedEvent notification, CancellationToken cancellationToken) + { + logger.LogInformation("User deactivated: {UserId}", notification.UserId); + + // Side effects when user is deactivated: + + // 1. Revoke all user sessions + // await sessionService.RevokeAllUserSessionsAsync(notification.UserId); + + // 2. Cancel pending subscriptions + // await subscriptionService.CancelUserSubscriptionsAsync(notification.UserId); + + // 3. Archive user data + // await dataArchiveService.ArchiveUserDataAsync(notification.UserId); + + // 4. Send deactivation notification + // await emailService.SendAccountDeactivatedEmailAsync(notification.UserId); + + // 5. Update analytics + // await analyticsService.TrackUserDeactivationAsync(notification.UserId); + + await Task.CompletedTask; // Remove this when implementing actual services + } +} diff --git a/src/Application/EventHandlers/UserUpdatedEventHandler.cs b/src/Application/EventHandlers/UserUpdatedEventHandler.cs new file mode 100644 index 0000000..8c3653b --- /dev/null +++ b/src/Application/EventHandlers/UserUpdatedEventHandler.cs @@ -0,0 +1,34 @@ +using Application.Common; +using Domain.Events.User; +using Microsoft.Extensions.Logging; + +namespace Application.EventHandlers; + +/// +/// Handler for UserUpdatedEvent +/// +public sealed class UserUpdatedEventHandler( + ILogger logger) : IDomainEventHandler +{ + public async Task Handle(UserUpdatedEvent notification, CancellationToken cancellationToken) + { + logger.LogInformation("User updated: {UserId} - {FullName}", + notification.UserId, notification.FullName); + + // Side effects when user is updated: + + // 1. Update search index + // await searchService.UpdateUserIndexAsync(notification.UserId, notification.FullName); + + // 2. Sync with external systems + // await externalSyncService.SyncUserDataAsync(notification.UserId); + + // 3. Update cached user data + // await cacheService.InvalidateUserCacheAsync(notification.UserId); + + // 4. Send profile update notification + // await notificationService.SendProfileUpdatedNotificationAsync(notification.UserId); + + await Task.CompletedTask; // Remove this when implementing actual services + } +} diff --git a/src/Application/Features/Auth/GetProfile/GetProfileQuery.cs b/src/Application/Features/Auth/GetProfile/GetProfileQuery.cs new file mode 100644 index 0000000..3c3ab21 --- /dev/null +++ b/src/Application/Features/Auth/GetProfile/GetProfileQuery.cs @@ -0,0 +1,61 @@ +using Application.Common; +using Application.Common.Behaviors; +using Domain.Common; + +namespace Application.Features.Auth.GetProfile; + +/// +/// Get profile query with caching +/// +public sealed record GetProfileQuery : IQuery>, ICacheableQuery +{ + /// + /// User ID + /// + public required Guid UserId { get; init; } + + // ICacheableQuery implementation + public string CacheKey => $"user-profile:{UserId}"; + public TimeSpan? CacheExpiration => TimeSpan.FromMinutes(15); +} + +/// +/// Get profile response +/// +public sealed record GetProfileResponse +{ + /// + /// User ID + /// + public required Guid Id { get; init; } + + /// + /// Email address + /// + public required string Email { get; init; } + + /// + /// Full name + /// + public required string FullName { get; init; } + + /// + /// Phone number + /// + public string? PhoneNumber { get; init; } + + /// + /// User roles + /// + public required string[] Roles { get; init; } + + /// + /// Created date + /// + public required DateTime CreatedAt { get; init; } + + /// + /// Last updated date + /// + public DateTime? UpdatedAt { get; init; } +} diff --git a/src/Application/Features/Auth/GetProfile/GetProfileQueryHandler.cs b/src/Application/Features/Auth/GetProfile/GetProfileQueryHandler.cs new file mode 100644 index 0000000..f758e06 --- /dev/null +++ b/src/Application/Features/Auth/GetProfile/GetProfileQueryHandler.cs @@ -0,0 +1,25 @@ +using Application.Common; +using Application.Interfaces; +using Domain.Common; + +namespace Application.Features.Auth.GetProfile; + +/// +/// Get profile query handler +/// +#pragma warning disable CS9113 // Parameter is unread. +public sealed class GetProfileQueryHandler(IUserRepository userRepository) +#pragma warning restore CS9113 // Parameter is unread. + : IQueryHandler> +{ + public async Task> Handle(GetProfileQuery request, CancellationToken cancellationToken) + { + // TODO: Implement get profile logic + // 1. Retrieve user by ID + // 2. Map to GetProfileResponse + // 3. Return result + + await Task.Delay(1, cancellationToken); + throw new NotImplementedException("Get profile logic not implemented yet"); + } +} diff --git a/src/Application/Features/Auth/Login/LoginCommand.cs b/src/Application/Features/Auth/Login/LoginCommand.cs new file mode 100644 index 0000000..0af1b7c --- /dev/null +++ b/src/Application/Features/Auth/Login/LoginCommand.cs @@ -0,0 +1,77 @@ +using Application.Common; +using Domain.Common; + +namespace Application.Features.Auth.Login; + +/// +/// Login command +/// +public sealed record LoginCommand : ICommand> +{ + /// + /// Email address + /// + public required string Email { get; init; } + + /// + /// Password + /// + public required string Password { get; init; } + + /// + /// Remember me flag + /// + public bool RememberMe { get; init; } +} + +/// +/// Login response +/// +public sealed record LoginResponse +{ + /// + /// Access token + /// + public required string AccessToken { get; init; } + + /// + /// Refresh token + /// + public required string RefreshToken { get; init; } + + /// + /// Token expiration time + /// + public required DateTime ExpiresAt { get; init; } + + /// + /// User information + /// + public required UserInfo User { get; init; } +} + +/// +/// User information +/// +public sealed record UserInfo +{ + /// + /// User ID + /// + public required Guid Id { get; init; } + + /// + /// Email address + /// + public required string Email { get; init; } + + /// + /// Full name + /// + public required string FullName { get; init; } + + /// + /// User roles + /// + public required string[] Roles { get; init; } +} diff --git a/src/Application/Features/Auth/Login/LoginCommandHandler.cs b/src/Application/Features/Auth/Login/LoginCommandHandler.cs new file mode 100644 index 0000000..831711d --- /dev/null +++ b/src/Application/Features/Auth/Login/LoginCommandHandler.cs @@ -0,0 +1,56 @@ +using Application.Common; +using Application.Common.Exceptions; +using Application.Interfaces; +using Domain.Common; + +namespace Application.Features.Auth.Login; + +/// +/// Login command handler +/// +public sealed class LoginCommandHandler( + IUserRepository userRepository, + IPasswordHasher passwordHasher, + ITokenService tokenService) : ICommandHandler> +{ + public async Task> Handle(LoginCommand request, CancellationToken cancellationToken) + { + // Find user by email + var user = await userRepository.GetByEmailAsync(request.Email, cancellationToken) + ?? throw new UnauthorizedException("Invalid email or password."); + + // Verify password + if (!passwordHasher.VerifyPassword(request.Password, user.PasswordHash)) + { + throw new UnauthorizedException("Invalid email or password."); + } + + // Check if user is active + if (user.IsDeleted) + { + throw new ForbiddenException("User account has been deactivated."); + } + + // Generate tokens + var userInfo = new UserInfo + { + Id = user.Id, + Email = user.Email, + FullName = user.FullName, + Roles = user.Roles + }; + + var accessToken = tokenService.GenerateAccessToken(userInfo); + var refreshToken = tokenService.GenerateRefreshToken(); + + var response = new LoginResponse + { + AccessToken = accessToken, + RefreshToken = refreshToken, + ExpiresAt = DateTime.UtcNow.AddHours(1), // TODO: Get from configuration + User = userInfo + }; + + return Result.Success(response); + } +} diff --git a/src/Application/Features/Auth/Login/LoginCommandValidator.cs b/src/Application/Features/Auth/Login/LoginCommandValidator.cs new file mode 100644 index 0000000..624bce3 --- /dev/null +++ b/src/Application/Features/Auth/Login/LoginCommandValidator.cs @@ -0,0 +1,24 @@ +using FluentValidation; + +namespace Application.Features.Auth.Login; + +/// +/// Login command validator +/// +public sealed class LoginCommandValidator : AbstractValidator +{ + public LoginCommandValidator() + { + RuleFor(x => x.Email) + .NotEmpty() + .WithMessage("Email is required") + .EmailAddress() + .WithMessage("Email must be a valid email address"); + + RuleFor(x => x.Password) + .NotEmpty() + .WithMessage("Password is required") + .MinimumLength(6) + .WithMessage("Password must be at least 6 characters long"); + } +} diff --git a/src/Application/Features/Auth/Logout/LogoutCommand.cs b/src/Application/Features/Auth/Logout/LogoutCommand.cs new file mode 100644 index 0000000..50340ea --- /dev/null +++ b/src/Application/Features/Auth/Logout/LogoutCommand.cs @@ -0,0 +1,15 @@ +using Application.Common; +using Domain.Common; + +namespace Application.Features.Auth.Logout; + +/// +/// Logout command +/// +public sealed record LogoutCommand : ICommand +{ + /// + /// Refresh token to invalidate + /// + public required string RefreshToken { get; init; } +} diff --git a/src/Application/Features/Auth/Logout/LogoutCommandHandler.cs b/src/Application/Features/Auth/Logout/LogoutCommandHandler.cs new file mode 100644 index 0000000..a3a9e68 --- /dev/null +++ b/src/Application/Features/Auth/Logout/LogoutCommandHandler.cs @@ -0,0 +1,25 @@ +using Application.Common; +using Domain.Common; + +namespace Application.Features.Auth.Logout; + +/// +/// Logout command handler +/// +public sealed class LogoutCommandHandler : ICommandHandler +{ + public LogoutCommandHandler() + { + // TODO: Inject dependencies (ITokenService, IRefreshTokenRepository, etc.) + } + + public async Task Handle(LogoutCommand request, CancellationToken cancellationToken) + { + // TODO: Implement logout logic + // 1. Invalidate refresh token + // 2. Add access token to blacklist (optional) + + await Task.Delay(1, cancellationToken); + throw new NotImplementedException("Logout logic not implemented yet"); + } +} diff --git a/src/Application/Features/Auth/Logout/LogoutCommandValidator.cs b/src/Application/Features/Auth/Logout/LogoutCommandValidator.cs new file mode 100644 index 0000000..4ab0723 --- /dev/null +++ b/src/Application/Features/Auth/Logout/LogoutCommandValidator.cs @@ -0,0 +1,16 @@ +using FluentValidation; + +namespace Application.Features.Auth.Logout; + +/// +/// Logout command validator +/// +public sealed class LogoutCommandValidator : AbstractValidator +{ + public LogoutCommandValidator() + { + RuleFor(x => x.RefreshToken) + .NotEmpty() + .WithMessage("Refresh token is required"); + } +} diff --git a/src/Application/Features/Conversation/CreateConversation/CreateConversationCommand.cs b/src/Application/Features/Conversation/CreateConversation/CreateConversationCommand.cs new file mode 100644 index 0000000..3be97a3 --- /dev/null +++ b/src/Application/Features/Conversation/CreateConversation/CreateConversationCommand.cs @@ -0,0 +1,46 @@ +using Application.Common; +using Domain.Common; + +namespace Application.Features.Conversation.CreateConversation; + +/// +/// Create conversation command +/// +public sealed record CreateConversationCommand : ICommand> +{ + /// + /// User ID who creates the conversation + /// + public required Guid UserId { get; init; } + + /// + /// Conversation title + /// + public required string Title { get; init; } + + /// + /// Initial message (optional) + /// + public string? InitialMessage { get; init; } +} + +/// +/// Create conversation response +/// +public sealed record CreateConversationResponse +{ + /// + /// Created conversation ID + /// + public required Guid Id { get; init; } + + /// + /// Conversation title + /// + public required string Title { get; init; } + + /// + /// Created date + /// + public required DateTime CreatedAt { get; init; } +} diff --git a/src/Application/Features/User/CreateUser/CreateUserCommand.cs b/src/Application/Features/User/CreateUser/CreateUserCommand.cs new file mode 100644 index 0000000..c479804 --- /dev/null +++ b/src/Application/Features/User/CreateUser/CreateUserCommand.cs @@ -0,0 +1,66 @@ +using Application.Common; +using Application.Common.Behaviors; +using Domain.Common; + +namespace Application.Features.User.CreateUser; + +/// +/// Create user command with transaction and authorization +/// +public sealed record CreateUserCommand : ICommand>, + ITransactionalCommand, IAuthorizedRequest +{ + /// + /// Email address + /// + public required string Email { get; init; } + + /// + /// Full name + /// + public required string FullName { get; init; } + + /// + /// Password + /// + public required string Password { get; init; } + + /// + /// Phone number + /// + public string? PhoneNumber { get; init; } + + /// + /// User roles + /// + public string[] Roles { get; init; } = ["User"]; + + // IAuthorizedRequest implementation + public AuthorizationRequirement AuthorizationRequirement => new() + { + Roles = ["Admin"], + Permissions = ["users.create"], + RequireAuthentication = true + }; +} + +/// +/// Create user response +/// +public sealed record CreateUserResponse +{ + /// + /// Created user ID + /// + public required Guid Id { get; init; } + + /// + /// Email address + /// + public required string Email { get; init; } + + /// + /// Full name + /// + public required string FullName { get; init; } +} diff --git a/src/Application/Features/User/CreateUser/CreateUserCommandHandler.cs b/src/Application/Features/User/CreateUser/CreateUserCommandHandler.cs new file mode 100644 index 0000000..d6166b8 --- /dev/null +++ b/src/Application/Features/User/CreateUser/CreateUserCommandHandler.cs @@ -0,0 +1,50 @@ +using Application.Common; +using Application.Common.Exceptions; +using Application.Interfaces; +using Domain.Common; +using Domain.Aggregates.User; + +namespace Application.Features.User.CreateUser; + +/// +/// Create user command handler +/// +public sealed class CreateUserCommandHandler( + IUserRepository userRepository, + IPasswordHasher passwordHasher) : ICommandHandler> +{ + public async Task> Handle(CreateUserCommand request, CancellationToken cancellationToken) + { + // Check if user already exists + var existingUser = await userRepository.GetByEmailAsync(request.Email, cancellationToken); + if (existingUser is not null) + { + throw new ConflictException("User", request.Email); + } + + // Create new user + var user = Domain.Aggregates.User.User.Create( + request.Email, + request.FullName, + passwordHasher.HashPassword(request.Password), + request.Roles); + + try + { + var createdUser = await userRepository.CreateAsync(user, cancellationToken); + + var response = new CreateUserResponse + { + Id = createdUser.Id, + Email = createdUser.Email, + FullName = createdUser.FullName + }; + + return Result.Success(response); + } + catch (Exception ex) + { + throw new ExternalServiceException("Database", "Failed to create user", ex); + } + } +} diff --git a/src/Application/Features/User/CreateUser/CreateUserCommandValidator.cs b/src/Application/Features/User/CreateUser/CreateUserCommandValidator.cs new file mode 100644 index 0000000..c1f9a9b --- /dev/null +++ b/src/Application/Features/User/CreateUser/CreateUserCommandValidator.cs @@ -0,0 +1,45 @@ +using FluentValidation; + +namespace Application.Features.User.CreateUser; + +/// +/// Create user command validator +/// +public sealed class CreateUserCommandValidator : AbstractValidator +{ + public CreateUserCommandValidator() + { + RuleFor(x => x.Email) + .NotEmpty() + .WithMessage("Email is required") + .EmailAddress() + .WithMessage("Email must be a valid email address") + .MaximumLength(255) + .WithMessage("Email cannot exceed 255 characters"); + + RuleFor(x => x.FullName) + .NotEmpty() + .WithMessage("Full name is required") + .MaximumLength(100) + .WithMessage("Full name cannot exceed 100 characters"); + + RuleFor(x => x.Password) + .NotEmpty() + .WithMessage("Password is required") + .MinimumLength(8) + .WithMessage("Password must be at least 8 characters long") + .Matches(@"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)") + .WithMessage("Password must contain at least one lowercase, one uppercase letter and one digit"); + + RuleFor(x => x.PhoneNumber) + .Matches(@"^\+?[1-9]\d{1,14}$") + .When(x => !string.IsNullOrEmpty(x.PhoneNumber)) + .WithMessage("Phone number format is invalid"); + + RuleFor(x => x.Roles) + .NotEmpty() + .WithMessage("At least one role is required") + .Must(roles => roles.All(role => !string.IsNullOrWhiteSpace(role))) + .WithMessage("Role names cannot be empty"); + } +} diff --git a/src/Application/Features/User/GetUsers/GetUsersQuery.cs b/src/Application/Features/User/GetUsers/GetUsersQuery.cs new file mode 100644 index 0000000..d8aafec --- /dev/null +++ b/src/Application/Features/User/GetUsers/GetUsersQuery.cs @@ -0,0 +1,75 @@ +using Application.Common; +using Application.Common.Behaviors; +using Application.Common.Models; +using Domain.Common; + +namespace Application.Features.User.GetUsers; + +/// +/// Get users query with pagination, sorting, and caching +/// +public sealed record GetUsersQuery : PaginationRequest, IQuery>>, + ICacheableQuery, IAuthorizedRequest +{ + /// + /// Search term to filter users + /// + public string? SearchTerm { get; init; } + + /// + /// Role filter + /// + public string? Role { get; init; } + + /// + /// Sort specification + /// + public SortSpecification? Sort { get; init; } + + /// + /// Include deleted users + /// + public bool IncludeDeleted { get; init; } + + // ICacheableQuery implementation + public string CacheKey => $"users:{PageNumber}:{PageSize}:{SearchTerm}:{Role}:{Sort?.FieldName}:{Sort?.Order}:{IncludeDeleted}"; + public TimeSpan? CacheExpiration => TimeSpan.FromMinutes(10); + + // IAuthorizedRequest implementation + public AuthorizationRequirement AuthorizationRequirement => new() + { + Roles = ["Admin", "Manager"], + RequireAuthentication = true + }; +} + +/// +/// User summary for list display +/// +public sealed record UserSummary : AuditableEntity +{ + /// + /// Email address + /// + public required string Email { get; init; } + + /// + /// Full name + /// + public required string FullName { get; init; } + + /// + /// User roles + /// + public required string[] Roles { get; init; } + + /// + /// Is user active + /// + public required bool IsActive { get; init; } + + /// + /// Last login date + /// + public DateTime? LastLoginAt { get; init; } +} diff --git a/src/Application/Interfaces/IPasswordHasher.cs b/src/Application/Interfaces/IPasswordHasher.cs new file mode 100644 index 0000000..7024e61 --- /dev/null +++ b/src/Application/Interfaces/IPasswordHasher.cs @@ -0,0 +1,17 @@ +namespace Application.Interfaces; + +/// +/// Password hasher interface +/// +public interface IPasswordHasher +{ + /// + /// Hash password + /// + string HashPassword(string password); + + /// + /// Verify password + /// + bool VerifyPassword(string password, string hash); +} diff --git a/src/Application/Interfaces/ITokenService.cs b/src/Application/Interfaces/ITokenService.cs new file mode 100644 index 0000000..eacf984 --- /dev/null +++ b/src/Application/Interfaces/ITokenService.cs @@ -0,0 +1,29 @@ +using Application.Features.Auth.Login; + +namespace Application.Interfaces; + +/// +/// Token service interface +/// +public interface ITokenService +{ + /// + /// Generate access token + /// + string GenerateAccessToken(UserInfo user); + + /// + /// Generate refresh token + /// + string GenerateRefreshToken(); + + /// + /// Validate access token + /// + Task ValidateAccessTokenAsync(string token); + + /// + /// Get user claims from token + /// + Task?> GetClaimsFromTokenAsync(string token); +} diff --git a/src/Application/Interfaces/IUserRepository.cs b/src/Application/Interfaces/IUserRepository.cs new file mode 100644 index 0000000..c233c15 --- /dev/null +++ b/src/Application/Interfaces/IUserRepository.cs @@ -0,0 +1,29 @@ +using Domain.Aggregates.User; + +namespace Application.Interfaces; + +/// +/// User repository interface +/// +public interface IUserRepository +{ + /// + /// Get user by email + /// + Task GetByEmailAsync(string email, CancellationToken cancellationToken = default); + + /// + /// Get user by ID + /// + Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default); + + /// + /// Update user + /// + Task UpdateAsync(User user, CancellationToken cancellationToken = default); + + /// + /// Create user + /// + Task CreateAsync(User user, CancellationToken cancellationToken = default); +} diff --git a/src/Application/README.md b/src/Application/README.md new file mode 100644 index 0000000..1a9d334 --- /dev/null +++ b/src/Application/README.md @@ -0,0 +1,188 @@ +# 🔧 Application Layer + +**Orchestrates use cases và business workflows. Định nghĩa "what" the system does.** + +## 📁 Cấu trúc Folders + +``` +Application/ +├── Common/ # 🔗 Shared Components +│ ├── ICommand.cs # Command interfaces (CQRS) +│ ├── ICommandHandler.cs # Command handler interfaces +│ ├── IQuery.cs # Query interfaces (CQRS) +│ ├── IQueryHandler.cs # Query handler interfaces +│ ├── IDomainEventHandler.cs # Domain event handler interface +│ ├── IDomainEventDispatcher.cs # Domain event dispatcher +│ │ +│ ├── Behaviors/ # 🔄 MediatR Pipeline Behaviors +│ │ ├── ValidationBehavior.cs # Validate requests +│ │ ├── LoggingBehavior.cs # Log all requests/responses +│ │ ├── PerformanceBehavior.cs # Monitor performance +│ │ ├── AuthorizationBehavior.cs # Check permissions +│ │ ├── CachingBehavior.cs # Cache responses +│ │ ├── TransactionBehavior.cs # Database transactions +│ │ └── DomainEventBehavior.cs # Dispatch domain events +│ │ +│ ├── Exceptions/ # 🚨 Custom Exceptions +│ │ ├── ApplicationException.cs # Base application exception +│ │ ├── ValidationException.cs # Validation errors +│ │ ├── NotFoundException.cs # Entity not found +│ │ ├── UnauthorizedException.cs # Auth failures +│ │ ├── ForbiddenException.cs # Permission denied +│ │ ├── ConflictException.cs # Business conflicts +│ │ ├── BusinessRuleException.cs # Business rule violations +│ │ └── ExternalServiceException.cs # External service errors +│ │ +│ └── Models/ # 📋 Shared Models +│ ├── PaginatedResult.cs # Paginated responses +│ ├── PaginationRequest.cs # Pagination parameters +│ ├── AuditableEntity.cs # Base auditable entity +│ └── SortOrder.cs # Sorting directions +│ +├── Features/ # 🎯 Feature-based Organization (Vertical Slices) +│ ├── Auth/ # Authentication features +│ │ ├── Login/ +│ │ │ ├── LoginCommand.cs # Login command +│ │ │ ├── LoginCommandHandler.cs # Login logic +│ │ │ └── LoginCommandValidator.cs # Login validation +│ │ ├── Logout/ +│ │ ├── GetProfile/ +│ │ ├── ChangePassword/ +│ │ └── RefreshToken/ +│ │ +│ ├── User/ # User management features +│ │ ├── CreateUser/ +│ │ │ ├── CreateUserCommand.cs +│ │ │ ├── CreateUserCommandHandler.cs +│ │ │ └── CreateUserCommandValidator.cs +│ │ ├── GetUsers/ +│ │ ├── UpdateUserProfile/ +│ │ └── DeleteUser/ +│ │ +│ └── Conversation/ # Conversation features +│ ├── CreateConversation/ +│ ├── GetConversations/ +│ ├── SendMessage/ +│ ├── GetMessages/ +│ └── DeleteConversation/ +│ +├── EventHandlers/ # 📢 Domain Event Handlers +│ ├── UserCreatedEventHandler.cs # Handle user creation +│ ├── UserUpdatedEventHandler.cs # Handle user updates +│ └── UserDeactivatedEventHandler.cs # Handle user deactivation +│ +└── Interfaces/ # 🔌 External Service Interfaces (DIP) + ├── IUserRepository.cs # User data access + ├── IPasswordHasher.cs # Password hashing service + └── ITokenService.cs # JWT token service +``` + +## 🎯 Mục đích từng folder + +### 🎯 **Features/** - Vertical Slices +Tổ chức theo **features** thay vì **technical layers** + +**Ưu điểm:** +- ✅ **High Cohesion** - Mọi thứ liên quan đến 1 feature ở cùng chỗ +- ✅ **Easy to Find** - Dễ tìm code liên quan đến use case +- ✅ **Team Productivity** - Team có thể work parallel trên các features +- ✅ **Maintainable** - Thay đổi feature không ảnh hưởng features khác + +**Structure mỗi feature:** +``` +FeatureName/ +├── Command.cs # Input model +├── CommandHandler.cs # Business logic +├── CommandValidator.cs # Input validation +└── Response.cs # Output model +``` + +### 🔄 **Behaviors/** - Cross-cutting Concerns +**Pipeline behaviors** chạy trước/sau mọi request + +**Thứ tự quan trọng:** +1. **Logging** - Log request đầu vào +2. **Performance** - Đo thời gian execution +3. **Authorization** - Check permissions +4. **Validation** - Validate input +5. **Caching** - Cache responses (cho queries) +6. **Transaction** - Database transactions (cho commands) +7. **DomainEvent** - Dispatch domain events + +### 🚨 **Exceptions/** - Error Handling +**Structured exception hierarchy** cho error handling + +- **ApplicationException** - Base cho tất cả app exceptions +- **ValidationException** - Input validation errors +- **BusinessRuleException** - Business logic violations +- **External exceptions** - Integration failures + +### 📢 **EventHandlers/** - Side Effects +**Handle domain events** để thực hiện side effects + +**Ví dụ UserCreatedEvent:** +- Send welcome email +- Create default user profile +- Initialize user settings +- Track analytics + +### 🔌 **Interfaces/** - Dependency Inversion +**Define contracts** cho external services + +**Implemented by Infrastructure layer:** +- Database repositories +- External APIs +- File storage +- Email services +- Caching services + +## 🔄 CQRS Pattern + +### Commands (Write Operations) +```csharp +public record CreateUserCommand : ICommand> +{ + public string Email { get; init; } + public string FullName { get; init; } + public string Password { get; init; } +} +``` + +### Queries (Read Operations) +```csharp +public record GetUsersQuery : IQuery> +{ + public int Page { get; init; } = 1; + public int PageSize { get; init; } = 10; +} +``` + +## 🚫 Điều KHÔNG ĐƯỢC làm + +- ❌ **No Infrastructure concerns** - Không có EF, SQL, HTTP +- ❌ **No UI logic** - Không có presentation logic +- ❌ **No external dependencies** - Chỉ abstractions/interfaces +- ❌ **No business rules** - Business logic thuộc về Domain + +## ✅ Điều NÊN làm + +- ✅ **Orchestrate use cases** - Coordinate business workflows +- ✅ **Define interfaces** - Contracts cho external services +- ✅ **Handle domain events** - Side effects từ domain events +- ✅ **Validate inputs** - Input validation với FluentValidation +- ✅ **Transform data** - Map giữa domain objects và DTOs + +## 🔗 Dependencies + +- **Domain Layer** - Core business logic +- **MediatR** - CQRS và mediator pattern +- **FluentValidation** - Input validation +- **Microsoft.Extensions.*** - Abstractions (Logging, Caching) + +## 💡 Best Practices + +1. **Thin Handlers** - Logic minimal, delegate to domain +2. **Validation First** - Validate inputs trước khi process +3. **Error Handling** - Consistent exception handling +4. **Event-Driven** - Use domain events cho side effects +5. **Testable** - Easy to unit test handlers diff --git a/src/Domain/Aggregates/Conversation/Conversation.cs b/src/Domain/Aggregates/Conversation/Conversation.cs new file mode 100644 index 0000000..7da105b --- /dev/null +++ b/src/Domain/Aggregates/Conversation/Conversation.cs @@ -0,0 +1,221 @@ +using Domain.Common; +using Domain.Entities; +using Domain.Events.Conversation; +using Domain.Enums; + +namespace Domain.Aggregates.Conversation; + +/// +/// Conversation aggregate root - Manages conversation business operations +/// +public sealed class ConversationAggregate : BaseAggregateRoot +{ + private readonly Entities.Conversation _conversation; + private readonly List _messages = []; + + public ConversationAggregate(Entities.Conversation conversation) + { + _conversation = conversation ?? throw new ArgumentNullException(nameof(conversation)); + Id = conversation.Id; + } + + // Expose conversation properties + public Guid OwnerId => _conversation.OwnerId; + public string Title => _conversation.Title; + public bool IsPrivate => _conversation.IsPrivate; + public string[] Tags => _conversation.Tags; + public int Priority => _conversation.Priority; + public ConversationStatus Status => _conversation.Status; + public bool IsActive => _conversation.IsActive; + public IReadOnlyList Messages => _messages.AsReadOnly(); + + // Get underlying entities + public Entities.Conversation GetConversation() => _conversation; + + /// + /// Create a new conversation aggregate + /// + public static ConversationAggregate Create(Guid ownerId, string title, bool isPrivate = false) + { + // Validate business rules + if (ownerId == Guid.Empty) + { + throw new ArgumentException("Owner ID is required", nameof(ownerId)); + } + + if (string.IsNullOrWhiteSpace(title)) + { + throw new ArgumentException("Title is required", nameof(title)); + } + + // Create conversation entity + var conversation = new Entities.Conversation + { + OwnerId = ownerId, + Title = title, + IsPrivate = isPrivate, + Priority = CalculateInitialPriority(isPrivate), + Status = ConversationStatus.Active + }; + + var aggregate = new ConversationAggregate(conversation); + + // Raise domain event + aggregate.AddDomainEvent(new ConversationCreatedEvent( + conversation.Id, + ownerId, + title, + isPrivate)); + + return aggregate; + } + + /// + /// Add message to conversation + /// + public void AddMessage(Guid senderId, string content, MessageType type = MessageType.Text, bool isFromBot = false) + { + // Business rules validation + if (_conversation.Status != ConversationStatus.Active) + { + throw new InvalidOperationException("Cannot add messages to inactive conversations"); + } + + if (string.IsNullOrWhiteSpace(content)) + { + throw new ArgumentException("Message content is required", nameof(content)); + } + + var message = new Entities.Message + { + ConversationId = _conversation.Id, + SenderId = senderId, + Content = content, + Type = type, + IsFromBot = isFromBot + }; + + _messages.Add(message); + _conversation.UpdatedAt = DateTime.UtcNow; + UpdatedAt = DateTime.UtcNow; + + // Update priority based on activity + UpdatePriorityBasedOnActivity(); + } + + /// + /// Close conversation + /// + public void Close() + { + if (_conversation.Status == ConversationStatus.Closed) + { + return; + } + + _conversation.Status = ConversationStatus.Closed; + _conversation.UpdatedAt = DateTime.UtcNow; + UpdatedAt = DateTime.UtcNow; + + // Raise domain event + AddDomainEvent(new ConversationClosedEvent(_conversation.Id, _conversation.OwnerId)); + } + + /// + /// Update conversation title + /// + public void UpdateTitle(string newTitle) + { + if (string.IsNullOrWhiteSpace(newTitle)) + { + throw new ArgumentException("Title cannot be empty", nameof(newTitle)); + } + + // Business rule: Only owner or admins can update title + _conversation.Title = newTitle; + _conversation.UpdatedAt = DateTime.UtcNow; + UpdatedAt = DateTime.UtcNow; + } + + /// + /// Change privacy settings + /// + public void SetPrivacy(bool isPrivate, UserRole userRole) + { + // Business rule: Only owner or admins can change privacy + if (userRole != UserRole.Admin && _conversation.OwnerId != Id) + { + throw new UnauthorizedAccessException("Only owner or admin can change privacy settings"); + } + + _conversation.IsPrivate = isPrivate; + _conversation.UpdatedAt = DateTime.UtcNow; + UpdatedAt = DateTime.UtcNow; + } + + /// + /// Add tags to conversation + /// + public void AddTags(params string[] newTags) + { + if (newTags is null || newTags.Length == 0) + { + return; + } + + var existingTags = _conversation.Tags.ToList(); + var validNewTags = newTags + .Where(tag => !string.IsNullOrWhiteSpace(tag)) + .Where(tag => !existingTags.Contains(tag, StringComparer.OrdinalIgnoreCase)) + .ToList(); + + if (validNewTags.Count == 0) + { + return; + } + + existingTags.AddRange(validNewTags); + _conversation.Tags = existingTags.ToArray(); + _conversation.UpdatedAt = DateTime.UtcNow; + UpdatedAt = DateTime.UtcNow; + } + + /// + /// Check if user can access this conversation + /// + public bool CanBeAccessedBy(Guid userId, UserRole[] userRoles) + { + return _conversation.IsAccessibleBy(userId, userRoles.Select(r => r.ToString()).ToArray()); + } + + // Private helper methods + private static int CalculateInitialPriority(bool isPrivate) + { + // Business rule: Private conversations get higher initial priority + return isPrivate ? 5 : 1; + } + + private void UpdatePriorityBasedOnActivity() + { + // Business rule: Recent activity increases priority + var recentMessages = _messages.Count(m => m.IsRecent); + _conversation.Priority = Math.Min(10, _conversation.Priority + recentMessages); + } +} + +/// +/// Conversation closed event +/// +public sealed record ConversationClosedEvent : IDomainEvent +{ + public Guid ConversationId { get; } + public Guid OwnerId { get; } + public DateTime OccurredOn { get; } + + public ConversationClosedEvent(Guid conversationId, Guid ownerId) + { + ConversationId = conversationId; + OwnerId = ownerId; + OccurredOn = DateTime.UtcNow; + } +} diff --git a/src/Domain/Aggregates/User/User.cs b/src/Domain/Aggregates/User/User.cs new file mode 100644 index 0000000..a660dd5 --- /dev/null +++ b/src/Domain/Aggregates/User/User.cs @@ -0,0 +1,170 @@ +using Domain.Common; +using Domain.Entities; +using Domain.Events.User; +using Domain.Enums; +using Domain.ValueObjects; + +namespace Domain.Aggregates.User; + +/// +/// User aggregate root - Manages user business operations +/// +public sealed class UserAggregate : BaseAggregateRoot +{ + private readonly Entities.User _user; + + public UserAggregate(Entities.User user) + { + _user = user ?? throw new ArgumentNullException(nameof(user)); + Id = user.Id; + } + + // Expose user properties + public string Email => _user.Email; + public string FullName => _user.FullName; + public string PasswordHash => _user.PasswordHash; + public string? PhoneNumber => _user.PhoneNumber; + public UserRole[] Roles => _user.Roles; + public bool IsActive => _user.IsActive; + + // Get the underlying entity + public Entities.User GetUser() => _user; + + /// + /// Create a new user aggregate + /// + public static UserAggregate Create(string email, string fullName, string passwordHash, UserRole[]? roles = null) + { + // Validate business rules + if (string.IsNullOrWhiteSpace(email)) + { + throw new ArgumentException("Email is required", nameof(email)); + } + + if (string.IsNullOrWhiteSpace(fullName)) + { + throw new ArgumentException("Full name is required", nameof(fullName)); + } + + if (string.IsNullOrWhiteSpace(passwordHash)) + { + throw new ArgumentException("Password hash is required", nameof(passwordHash)); + } + + // Create user entity + var user = new Entities.User + { + Email = email, + FullName = fullName, + PasswordHash = passwordHash, + Roles = roles ?? [UserRole.User] + }; + + var aggregate = new UserAggregate(user); + + // Raise domain event + aggregate.AddDomainEvent(new UserCreatedEvent(user.Id, user.Email, user.FullName, user.Roles.Select(r => r.ToString()).ToArray())); + + return aggregate; + } + + /// + /// Deactivate user (soft delete) + /// + public void Deactivate() + { + // Business rule: Cannot deactivate if user has active conversations + if (HasActiveConversations()) + { + throw new InvalidOperationException("Cannot deactivate user with active conversations"); + } + + _user.IsDeleted = true; + _user.DeletedAt = DateTime.UtcNow; + UpdatedAt = DateTime.UtcNow; + + // Raise domain event + AddDomainEvent(new UserDeactivatedEvent(_user.Id)); + } + + /// + /// Update user information + /// + public void UpdateInfo(string fullName, string? phoneNumber = null) + { + if (string.IsNullOrWhiteSpace(fullName)) + { + throw new ArgumentException("Full name is required", nameof(fullName)); + } + + _user.FullName = fullName; + _user.PhoneNumber = phoneNumber; + _user.UpdatedAt = DateTime.UtcNow; + UpdatedAt = DateTime.UtcNow; + + // Raise domain event + AddDomainEvent(new UserUpdatedEvent(_user.Id, fullName, phoneNumber)); + } + + /// + /// Add role to user + /// + public void AddRole(UserRole role) + { + if (_user.Roles.Contains(role)) + { + return; + } + + var rolesList = _user.Roles.ToList(); + rolesList.Add(role); + _user.Roles = rolesList.ToArray(); + _user.UpdatedAt = DateTime.UtcNow; + UpdatedAt = DateTime.UtcNow; + } + + /// + /// Remove role from user + /// + public void RemoveRole(UserRole role) + { + if (!_user.Roles.Contains(role)) + { + return; + } + + // Business rule: User must have at least one role + if (_user.Roles.Length <= 1) + { + throw new InvalidOperationException("User must have at least one role"); + } + + _user.Roles = _user.Roles.Where(r => r != role).ToArray(); + _user.UpdatedAt = DateTime.UtcNow; + UpdatedAt = DateTime.UtcNow; + } + + /// + /// Check if user can perform action + /// + public bool CanPerformAction(string action) + { + // Business logic for permissions + return action switch + { + "CreateConversation" => IsActive, + "EditProfile" => IsActive, + "ManageUsers" => _user.HasRole(UserRole.Admin), + "AccessLegalDatabase" => _user.HasAnyRole(UserRole.Admin, UserRole.LegalExpert), + _ => false + }; + } + + // Private helper methods + private bool HasActiveConversations() + { + // This would typically be checked via a domain service + // For now, assume false + return false; + } +} diff --git a/src/Domain/Common/BaseAggregateRoot.cs b/src/Domain/Common/BaseAggregateRoot.cs new file mode 100644 index 0000000..9dc7963 --- /dev/null +++ b/src/Domain/Common/BaseAggregateRoot.cs @@ -0,0 +1,38 @@ +namespace Domain.Common; + +/// +/// Base aggregate root with domain events +/// +public abstract class BaseAggregateRoot : BaseEntity +{ + private readonly List _domainEvents = []; + + /// + /// Domain events raised by this aggregate + /// + public IReadOnlyCollection DomainEvents => _domainEvents.AsReadOnly(); + + /// + /// Add a domain event + /// + protected void AddDomainEvent(IDomainEvent domainEvent) + { + _domainEvents.Add(domainEvent); + } + + /// + /// Remove a domain event + /// + protected void RemoveDomainEvent(IDomainEvent domainEvent) + { + _domainEvents.Remove(domainEvent); + } + + /// + /// Clear all domain events + /// + public void ClearDomainEvents() + { + _domainEvents.Clear(); + } +} diff --git a/src/Domain/Common/BaseEntity.cs b/src/Domain/Common/BaseEntity.cs new file mode 100644 index 0000000..404b7fa --- /dev/null +++ b/src/Domain/Common/BaseEntity.cs @@ -0,0 +1,38 @@ +namespace Domain.Common; + +/// +/// Base entity with common properties +/// +public abstract class BaseEntity +{ + /// + /// Entity identifier + /// + public Guid Id { get; set; } + + /// + /// Creation timestamp + /// + public DateTime CreatedAt { get; set; } + + /// + /// Last update timestamp + /// + public DateTime? UpdatedAt { get; set; } + + /// + /// Soft delete flag + /// + public bool IsDeleted { get; set; } + + /// + /// When entity was deleted + /// + public DateTime? DeletedAt { get; set; } + + protected BaseEntity() + { + Id = Guid.NewGuid(); + CreatedAt = DateTime.UtcNow; + } +} diff --git a/src/Domain/Common/Error.cs b/src/Domain/Common/Error.cs new file mode 100644 index 0000000..5bbdf08 --- /dev/null +++ b/src/Domain/Common/Error.cs @@ -0,0 +1,35 @@ +namespace Domain.Common; + +public record Error +{ + public static readonly Error None = new(string.Empty, string.Empty, ErrorType.Failure); + public static readonly Error NullValue = new( + "General.Null", + "Null value was provided", + ErrorType.Failure); + + public Error(string code, string description, ErrorType type) + { + Code = code; + Description = description; + Type = type; + } + + public string Code { get; } + + public string Description { get; } + + public ErrorType Type { get; } + + public static Error Failure(string code, string description) => + new(code, description, ErrorType.Failure); + + public static Error NotFound(string code, string description) => + new(code, description, ErrorType.NotFound); + + public static Error Problem(string code, string description) => + new(code, description, ErrorType.Problem); + + public static Error Conflict(string code, string description) => + new(code, description, ErrorType.Conflict); +} diff --git a/src/Domain/Common/ErrorType.cs b/src/Domain/Common/ErrorType.cs new file mode 100644 index 0000000..ff8f68b --- /dev/null +++ b/src/Domain/Common/ErrorType.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace Domain.Common; + +public enum ErrorType +{ + Failure = 0, + Validation = 1, + Problem = 2, + NotFound = 3, + Conflict = 4 +} diff --git a/src/Domain/Common/IDomainEvent.cs b/src/Domain/Common/IDomainEvent.cs new file mode 100644 index 0000000..f47633e --- /dev/null +++ b/src/Domain/Common/IDomainEvent.cs @@ -0,0 +1,14 @@ +using MediatR; + +namespace Domain.Common; + +/// +/// Marker interface for domain events +/// +public interface IDomainEvent : INotification +{ + /// + /// When the event occurred + /// + DateTime OccurredOn { get; } +} diff --git a/src/Domain/Common/Result.cs b/src/Domain/Common/Result.cs new file mode 100644 index 0000000..69e0c50 --- /dev/null +++ b/src/Domain/Common/Result.cs @@ -0,0 +1,56 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Domain.Common; + +public class Result +{ + public Result(bool isSuccess, Error error) + { + if (isSuccess && error != Error.None || + !isSuccess && error == Error.None) + { + throw new ArgumentException("Invalid error", nameof(error)); + } + + IsSuccess = isSuccess; + Error = error; + } + + public bool IsSuccess { get; } + + public bool IsFailure => !IsSuccess; + + public Error Error { get; } + + public static Result Success() => new(true, Error.None); + + public static Result Success(TValue value) => + new(value, true, Error.None); + + public static Result Failure(Error error) => new(false, error); + + public static Result Failure(Error error) => + new(default, false, error); +} + +public class Result : Result +{ + private readonly TValue? _value; + + public Result(TValue? value, bool isSuccess, Error error) + : base(isSuccess, error) + { + _value = value; + } + + [NotNull] + public TValue Value => IsSuccess + ? _value! + : throw new InvalidOperationException("The value of a failure result can't be accessed."); + + public static implicit operator Result(TValue? value) => + value is not null ? Success(value) : Failure(Error.NullValue); + + public static Result ValidationFailure(Error error) => + new(default, false, error); +} diff --git a/src/Domain/Common/ValidationError.cs b/src/Domain/Common/ValidationError.cs new file mode 100644 index 0000000..486e6e0 --- /dev/null +++ b/src/Domain/Common/ValidationError.cs @@ -0,0 +1,18 @@ +namespace Domain.Common; + +public sealed record ValidationError : Error +{ + public ValidationError(Error[] errors) + : base( + "Validation.General", + "One or more validation errors occurred", + ErrorType.Validation) + { + Errors = errors; + } + + public Error[] Errors { get; } + + public static ValidationError FromResults(IEnumerable results) => + new(results.Where(r => r.IsFailure).Select(r => r.Error).ToArray()); +} diff --git a/src/Domain/Domain.csproj b/src/Domain/Domain.csproj new file mode 100644 index 0000000..a19c932 --- /dev/null +++ b/src/Domain/Domain.csproj @@ -0,0 +1,13 @@ + + + + net8.0 + enable + enable + + + + + + + diff --git a/src/Domain/Entities/Conversation.cs b/src/Domain/Entities/Conversation.cs new file mode 100644 index 0000000..2bfd9d6 --- /dev/null +++ b/src/Domain/Entities/Conversation.cs @@ -0,0 +1,67 @@ +using Domain.Common; + +namespace Domain.Entities; + +/// +/// Conversation entity - Pure domain entity +/// +public sealed class Conversation : BaseEntity +{ + public required Guid OwnerId { get; set; } + public required string Title { get; set; } + public bool IsPrivate { get; set; } + public string[] Tags { get; set; } = []; + public int Priority { get; set; } + public ConversationStatus Status { get; set; } = ConversationStatus.Active; + + // Navigation properties + public User Owner { get; set; } = null!; + public ICollection Messages { get; set; } = []; + + /// + /// Check if conversation is accessible by user + /// + public bool IsAccessibleBy(Guid userId, string[] userRoles) + { + // Owner can always access + if (OwnerId == userId) + { + return true; + } + + // Admin can access all + if (userRoles.Contains("Admin")) + { + return true; + } + + // Legal experts can access non-private conversations + if (userRoles.Contains("LegalExpert") && !IsPrivate) + { + return true; + } + + return false; + } + + /// + /// Check if conversation is active + /// + public bool IsActive => Status == ConversationStatus.Active && !IsDeleted; + + /// + /// Get message count + /// + public int MessageCount => Messages?.Count ?? 0; +} + +/// +/// Conversation status enumeration +/// +public enum ConversationStatus +{ + Active = 0, + Closed = 1, + Archived = 2, + Suspended = 3 +} diff --git a/src/Domain/Entities/Message.cs b/src/Domain/Entities/Message.cs new file mode 100644 index 0000000..8368fdd --- /dev/null +++ b/src/Domain/Entities/Message.cs @@ -0,0 +1,81 @@ +using Domain.Common; + +namespace Domain.Entities; + +/// +/// Message entity - Pure domain entity +/// +public sealed class Message : BaseEntity +{ + public required Guid ConversationId { get; set; } + public required Guid SenderId { get; set; } + public required string Content { get; set; } + public MessageType Type { get; set; } = MessageType.Text; + public string? Metadata { get; set; } // JSON metadata for attachments, etc. + public bool IsFromBot { get; set; } + public bool IsEdited { get; set; } + public DateTime? EditedAt { get; set; } + + // Navigation properties + public Conversation Conversation { get; set; } = null!; + public User Sender { get; set; } = null!; + + /// + /// Check if message can be edited + /// + public bool CanBeEditedBy(Guid userId) + { + // Only sender can edit + if (SenderId != userId) + { + return false; + } + + // Bot messages cannot be edited + if (IsFromBot) + { + return false; + } + + // Can only edit within 5 minutes + if (DateTime.UtcNow - CreatedAt > TimeSpan.FromMinutes(5)) + { + return false; + } + + return true; + } + + /// + /// Check if message is recent (within 1 hour) + /// + public bool IsRecent => DateTime.UtcNow - CreatedAt <= TimeSpan.FromHours(1); + + /// + /// Get content preview (first 100 characters) + /// + public string GetPreview(int maxLength = 100) + { + if (string.IsNullOrWhiteSpace(Content)) + { + return string.Empty; + } + + return Content.Length <= maxLength + ? Content + : Content[..maxLength] + "..."; + } +} + +/// +/// Message types enumeration +/// +public enum MessageType +{ + Text = 0, + Image = 1, + Document = 2, + Audio = 3, + System = 4, + LegalAdvice = 5 +} diff --git a/src/Domain/Entities/User.cs b/src/Domain/Entities/User.cs new file mode 100644 index 0000000..6273d73 --- /dev/null +++ b/src/Domain/Entities/User.cs @@ -0,0 +1,40 @@ +using Domain.Common; +using Domain.Enums; + +namespace Domain.Entities; + +/// +/// User entity - Pure domain entity +/// +public sealed class User : BaseEntity +{ + public required string Email { get; set; } + public required string FullName { get; set; } + public required string PasswordHash { get; set; } + public string? PhoneNumber { get; set; } + public UserRole[] Roles { get; set; } = []; + + // Navigation properties for related entities + public ICollection OwnedConversations { get; set; } = []; + public ICollection Messages { get; set; } = []; + + /// + /// Check if user has specific role + /// + public bool HasRole(UserRole role) => Roles.Contains(role); + + /// + /// Check if user has any of the specified roles + /// + public bool HasAnyRole(params UserRole[] roles) => roles.Any(role => Roles.Contains(role)); + + /// + /// Validate email format (basic validation in entity) + /// + public bool IsEmailValid() => !string.IsNullOrWhiteSpace(Email) && Email.Contains('@'); + + /// + /// Check if user is active + /// + public bool IsActive => !IsDeleted; +} diff --git a/src/Domain/Enums/UserRole.cs b/src/Domain/Enums/UserRole.cs new file mode 100644 index 0000000..5553aec --- /dev/null +++ b/src/Domain/Enums/UserRole.cs @@ -0,0 +1,62 @@ +namespace Domain.Enums; + +/// +/// User roles enumeration +/// +public enum UserRole +{ + /// + /// Regular user + /// + User = 0, + + /// + /// Administrator + /// + Admin = 1, + + /// + /// Manager + /// + Manager = 2, + + /// + /// Legal expert + /// + LegalExpert = 3, + + /// + /// Premium user with extended features + /// + Premium = 4, + + /// + /// System user (for automated processes) + /// + System = 5 +} + +/// +/// Extension methods for UserRole enum +/// +public static class UserRoleExtensions +{ + /// + /// Get display name for role + /// + public static string GetDisplayName(this UserRole role) => role switch + { + UserRole.User => "User", + UserRole.Admin => "Administrator", + UserRole.Manager => "Manager", + UserRole.LegalExpert => "Legal Expert", + UserRole.Premium => "Premium User", + UserRole.System => "System", + _ => "Unknown" + }; + + /// + /// Check if role has elevated privileges + /// + public static bool IsElevated(this UserRole role) => role is UserRole.LegalExpert or UserRole.Manager or UserRole.Admin or UserRole.System; +} diff --git a/src/Domain/Events/Conversation/ConversationCreatedEvent.cs b/src/Domain/Events/Conversation/ConversationCreatedEvent.cs new file mode 100644 index 0000000..6e8e0a4 --- /dev/null +++ b/src/Domain/Events/Conversation/ConversationCreatedEvent.cs @@ -0,0 +1,24 @@ +using Domain.Common; + +namespace Domain.Events.Conversation; + +/// +/// Domain event raised when a new conversation is created +/// +public sealed record ConversationCreatedEvent : IDomainEvent +{ + public Guid ConversationId { get; } + public Guid OwnerId { get; } + public string Title { get; } + public bool IsPrivate { get; } + public DateTime OccurredOn { get; } + + public ConversationCreatedEvent(Guid conversationId, Guid ownerId, string title, bool isPrivate) + { + ConversationId = conversationId; + OwnerId = ownerId; + Title = title; + IsPrivate = isPrivate; + OccurredOn = DateTime.UtcNow; + } +} diff --git a/src/Domain/Events/Examples/DomainEventsUsageExamples.cs b/src/Domain/Events/Examples/DomainEventsUsageExamples.cs new file mode 100644 index 0000000..20b4482 --- /dev/null +++ b/src/Domain/Events/Examples/DomainEventsUsageExamples.cs @@ -0,0 +1,173 @@ +using Domain.Aggregates.User; +using Domain.Events.User; +using Domain.Common; + +namespace Domain.Events.Examples; + +/// +/// Examples demonstrating when and how to use Domain Events +/// +public static class DomainEventsUsageExamples +{ + /// + /// Example 1: User Registration Flow + /// Khi user đăng ký, cần trigger nhiều side effects + /// + public static void UserRegistrationExample() + { + // 1. Tạo user aggregate + var user = Domain.Aggregates.User.User.Create("john@example.com", "John Doe", "hashedPassword", ["User"]); + + // 2. Domain event sẽ được raise automatically trong User.Create() + // UserCreatedEvent sẽ trigger: + // - Send welcome email + // - Create user profile + // - Initialize settings + // - Track analytics + + // 3. Save user to database (events sẽ được dispatch sau khi save) + // await userRepository.CreateAsync(user); + } + + /// + /// Example 2: User Deactivation Flow + /// Khi deactivate user, cần clean up tất cả related data + /// + public static void UserDeactivationExample() + { + // 1. Load user from repository + // var user = await userRepository.GetByIdAsync(userId); + + // 2. Deactivate user + // user.Deactivate(); + + // 3. Domain event sẽ được raise automatically + // UserDeactivatedEvent sẽ trigger: + // - Revoke all sessions + // - Cancel subscriptions + // - Archive data + // - Send notification + + // 4. Save changes (events sẽ được dispatch) + // await userRepository.UpdateAsync(user); + } + + /// + /// Example 3: Legal Consultation Started + /// Complex business flow with multiple domain events + /// + public static void LegalConsultationExample() + { + // Scenario: User starts a legal consultation + + // 1. ConversationCreatedEvent triggers: + // - Assign legal expert + // - Create initial assessment + // - Setup billing + // - Send notifications + + // 2. UserConsultationStartedEvent triggers: + // - Update user subscription usage + // - Track analytics + // - Create case file + + // 3. ExpertAssignedEvent triggers: + // - Notify expert + // - Update expert workload + // - Schedule initial meeting + } + + /// + /// Example 4: Payment Processing + /// Financial domain events for legal services + /// + public static void PaymentProcessingExample() + { + // Scenario: User pays for legal consultation + + // 1. PaymentInitiatedEvent + // 2. PaymentProcessedEvent triggers: + // - Update user account balance + // - Grant access to premium features + // - Send receipt + // - Update billing history + + // 3. ServiceAccessGrantedEvent triggers: + // - Enable premium features + // - Send confirmation + // - Update user tier + } +} + +/// +/// Best practices for Domain Events +/// +public static class DomainEventsBestPractices +{ + /// + /// 1. Events should be IMMUTABLE (sử dụng record) + /// 2. Events should contain enough data to handle side effects + /// 3. Events should be named in past tense (UserCreated, not CreateUser) + /// 4. Keep events focused on single responsibility + /// 5. Don't put business logic in events - use handlers + /// + public static void BestPracticesExample() + { + // ✅ GOOD: Immutable, descriptive, past tense + var userCreated = new UserCreatedEvent( + userId: Guid.NewGuid(), + email: "user@example.com", + fullName: "John Doe", + roles: ["User"] + ); + + // ❌ BAD: Mutable, confusing name + // var createUserEvent = new CreateUserEvent { ... }; + } +} + +/// +/// Common Domain Events in Legal Assistant System +/// +public static class LegalAssistantDomainEvents +{ + /// + /// User Domain Events: + /// - UserCreatedEvent + /// - UserDeactivatedEvent + /// - UserUpdatedEvent + /// - UserSubscriptionChangedEvent + /// - UserTierUpgradedEvent + /// + public static void UserEvents() { } + + /// + /// Conversation Domain Events: + /// - ConversationCreatedEvent + /// - ConversationClosedEvent + /// - MessageSentEvent + /// - ExpertAssignedEvent + /// - ConversationRatedEvent + /// + public static void ConversationEvents() { } + + /// + /// Legal Service Domain Events: + /// - ConsultationStartedEvent + /// - ConsultationCompletedEvent + /// - DocumentGeneratedEvent + /// - LegalAdviceProvidedEvent + /// - CaseFileCreatedEvent + /// + public static void LegalServiceEvents() { } + + /// + /// Payment Domain Events: + /// - PaymentInitiatedEvent + /// - PaymentCompletedEvent + /// - PaymentFailedEvent + /// - RefundProcessedEvent + /// - InvoiceGeneratedEvent + /// + public static void PaymentEvents() { } +} diff --git a/src/Domain/Events/User/UserCreatedEvent.cs b/src/Domain/Events/User/UserCreatedEvent.cs new file mode 100644 index 0000000..cca2511 --- /dev/null +++ b/src/Domain/Events/User/UserCreatedEvent.cs @@ -0,0 +1,24 @@ +using Domain.Common; + +namespace Domain.Events.User; + +/// +/// Domain event raised when a new user is created +/// +public sealed record UserCreatedEvent : IDomainEvent +{ + public Guid UserId { get; } + public string Email { get; } + public string FullName { get; } + public string[] Roles { get; } + public DateTime OccurredOn { get; } + + public UserCreatedEvent(Guid userId, string email, string fullName, string[] roles) + { + UserId = userId; + Email = email; + FullName = fullName; + Roles = roles; + OccurredOn = DateTime.UtcNow; + } +} diff --git a/src/Domain/Events/User/UserDeactivatedEvent.cs b/src/Domain/Events/User/UserDeactivatedEvent.cs new file mode 100644 index 0000000..c75f62d --- /dev/null +++ b/src/Domain/Events/User/UserDeactivatedEvent.cs @@ -0,0 +1,18 @@ +using Domain.Common; + +namespace Domain.Events.User; + +/// +/// Domain event raised when a user is deactivated +/// +public sealed record UserDeactivatedEvent : IDomainEvent +{ + public Guid UserId { get; } + public DateTime OccurredOn { get; } + + public UserDeactivatedEvent(Guid userId) + { + UserId = userId; + OccurredOn = DateTime.UtcNow; + } +} diff --git a/src/Domain/Events/User/UserUpdatedEvent.cs b/src/Domain/Events/User/UserUpdatedEvent.cs new file mode 100644 index 0000000..c391460 --- /dev/null +++ b/src/Domain/Events/User/UserUpdatedEvent.cs @@ -0,0 +1,22 @@ +using Domain.Common; + +namespace Domain.Events.User; + +/// +/// Domain event raised when user information is updated +/// +public sealed record UserUpdatedEvent : IDomainEvent +{ + public Guid UserId { get; } + public string FullName { get; } + public string? PhoneNumber { get; } + public DateTime OccurredOn { get; } + + public UserUpdatedEvent(Guid userId, string fullName, string? phoneNumber) + { + UserId = userId; + FullName = fullName; + PhoneNumber = phoneNumber; + OccurredOn = DateTime.UtcNow; + } +} diff --git a/src/Domain/README.md b/src/Domain/README.md new file mode 100644 index 0000000..272c336 --- /dev/null +++ b/src/Domain/README.md @@ -0,0 +1,129 @@ +# 🏛️ Domain Layer + +**Đây là lõi của ứng dụng - chứa business logic thuần túy, không phụ thuộc vào bất kỳ layer nào khác.** + +## 📁 Cấu trúc Folders + +``` +Domain/ +├── Entities/ # 🏛️ Pure Domain Entities +│ ├── User.cs # User entity (data + basic behavior) +│ ├── Conversation.cs # Conversation entity +│ └── Message.cs # Message entity +│ +├── Aggregates/ # 🏗️ Aggregate Roots (Business Logic) +│ ├── User/ +│ │ └── UserAggregate.cs # User business operations +│ └── Conversation/ +│ └── ConversationAggregate.cs # Conversation business operations +│ +├── Common/ # 🔗 Base Classes & Shared Types +│ ├── BaseEntity.cs # Base entity với Id, timestamps +│ ├── BaseAggregateRoot.cs # Base cho aggregates + domain events +│ ├── IDomainEvent.cs # Interface cho domain events +│ ├── Result.cs # Result pattern cho error handling +│ └── Error.cs # Error types và codes +│ +├── Events/ # 📢 Domain Events +│ ├── User/ +│ │ ├── UserCreatedEvent.cs # Khi user được tạo +│ │ ├── UserUpdatedEvent.cs # Khi user được cập nhật +│ │ └── UserDeactivatedEvent.cs # Khi user bị vô hiệu hóa +│ ├── Conversation/ +│ └── Examples/ # Ví dụ cách sử dụng domain events +│ +├── Enums/ # 📋 Domain Enumerations +│ └── UserRole.cs # Roles: Admin, User, LegalExpert, etc. +│ +├── Services/ # 🛠️ Domain Services +│ ├── UserDomainService.cs # Business logic không thuộc 1 entity +│ └── ConversationDomainService.cs # Logic liên quan conversation +│ +├── Specifications/ # 🔍 Specification Pattern +│ ├── ISpecification.cs # Base interface +│ ├── CompositeSpecifications.cs # And, Or, Not operations +│ ├── UserSpecifications.cs # User-specific queries +│ └── ConversationSpecifications.cs +│ +└── ValueObjects/ # 💎 Value Objects + ├── Email.cs # Email với validation + └── Password.cs # Password với hashing rules +``` + +## 🎯 Mục đích từng folder + +### 🏛️ **Entities/** - Pure Domain Objects +- **Domain Entities** - Objects với identity và basic behavior +- **Data + Basic Logic** - Properties và simple methods +- **Navigation Properties** - Relationships với other entities +- **Validation** - Basic domain validation + +**Ví dụ:** `User.HasRole()`, `Conversation.IsAccessibleBy()`, `Message.CanBeEditedBy()` + +### 🏗️ **Aggregates/** - Business Logic Orchestrators +- **Aggregate Roots** - Manage business operations và consistency +- **Complex Business Logic** - Operations spanning multiple entities +- **Domain Events** - Raise events khi business actions occur +- **Invariants** - Enforce complex business rules + +**Ví dụ:** `UserAggregate.Create()`, `ConversationAggregate.AddMessage()`, `UserAggregate.Deactivate()` + +### 📢 **Events/** +- **Domain Events** - Thông báo khi có sự kiện quan trọng xảy ra +- **Immutable** - Events không thể thay đổi sau khi tạo +- **Past Tense** - Named theo sự kiện đã xảy ra + +**Khi nào dùng:** Khi cần trigger side effects hoặc notify other contexts + +### 🛠️ **Services/** +- **Domain Services** - Logic không thuộc về 1 entity cụ thể +- **Coordination** - Phối hợp giữa nhiều aggregates +- **Complex Business Rules** - Rules phức tạp cần nhiều inputs + +**Ví dụ:** Check permissions, calculate complex business values + +### 🔍 **Specifications/** +- **Query Logic** - Encapsulate complex query conditions +- **Reusable** - Có thể combine và reuse +- **Testable** - Dễ test business query logic + +**Ví dụ:** `ActiveUserSpecification`, `AdminUserSpecification` + +### 💎 **ValueObjects/** +- **Immutable** - Không thể thay đổi sau khi tạo +- **Validation** - Built-in validation rules +- **Equality** - So sánh theo value, không theo reference + +**Ví dụ:** Email, Money, Address, Phone Number + +## 🚫 Điều KHÔNG ĐƯỢC làm trong Domain + +- ❌ **Không reference** Infrastructure hoặc UI layers +- ❌ **Không có** database concerns (EF, SQL, etc.) +- ❌ **Không có** HTTP, JSON, hoặc external service calls +- ❌ **Không có** logging frameworks (chỉ có abstractions) +- ❌ **Không có** dependency injection containers + +## ✅ Điều NÊN làm trong Domain + +- ✅ **Pure business logic** - Logic nghiệp vụ thuần túy +- ✅ **Domain events** - Thông báo business events +- ✅ **Validation rules** - Business validation +- ✅ **Invariants** - Business constraints +- ✅ **Factory methods** - Tạo objects đúng cách + +## 🔗 Dependencies + +**Domain layer KHÔNG phụ thuộc vào layer nào khác!** + +Chỉ có thể reference: +- .NET BCL (Base Class Library) +- MediatR (chỉ cho INotification interface) + +## 💡 Best Practices + +1. **Rich Domain Models** - Entities có behavior, không chỉ data +2. **Tell, Don't Ask** - Gọi methods thay vì manipulate properties +3. **Aggregate Boundaries** - Giữ aggregates nhỏ và focused +4. **Domain Events** - Communicate giữa aggregates qua events +5. **Ubiquitous Language** - Dùng ngôn ngữ của business domain diff --git a/src/Domain/Services/ConversationDomainService.cs b/src/Domain/Services/ConversationDomainService.cs new file mode 100644 index 0000000..00eed73 --- /dev/null +++ b/src/Domain/Services/ConversationDomainService.cs @@ -0,0 +1,71 @@ +namespace Domain.Services; + +/// +/// Domain service for conversation-related business logic +/// +public sealed class ConversationDomainService +{ + /// + /// Check if user can access conversation + /// + public bool CanUserAccessConversation( + Guid userId, + string[] userRoles, + Guid conversationOwnerId, + bool isConversationPrivate) + { + // Business rule: Owner can always access + if (userId == conversationOwnerId) + { + return true; + } + + // Business rule: Admin can access all conversations + if (userRoles.Contains("Admin")) + { + return true; + } + + // Business rule: Legal experts can access non-private conversations + if (userRoles.Contains("LegalExpert") && !isConversationPrivate) + { + return true; + } + + // Business rule: Regular users can't access others' private conversations + return false; + } + + /// + /// Calculate conversation priority based on business rules + /// + public int CalculateConversationPriority( + string[] userRoles, + string conversationTopic, + DateTime createdAt) + { + var priority = 0; + + // Business rule: Premium users get higher priority + if (userRoles.Contains("Premium")) + { + priority += 10; + } + + // Business rule: Legal emergencies get highest priority + if (conversationTopic.Contains("urgent", StringComparison.OrdinalIgnoreCase) || + conversationTopic.Contains("emergency", StringComparison.OrdinalIgnoreCase)) + { + priority += 20; + } + + // Business rule: Older conversations get higher priority + var daysSinceCreated = (DateTime.UtcNow - createdAt).Days; + if (daysSinceCreated > 7) + { + priority += 5; + } + + return priority; + } +} diff --git a/src/Domain/Services/UserDomainService.cs b/src/Domain/Services/UserDomainService.cs new file mode 100644 index 0000000..5c3edfb --- /dev/null +++ b/src/Domain/Services/UserDomainService.cs @@ -0,0 +1,52 @@ +namespace Domain.Services; + +/// +/// Domain service for user-related business logic +/// +public sealed class UserDomainService +{ + /// + /// Check if user can be assigned to specific role + /// + public bool CanAssignRole(Domain.Aggregates.User.User user, string newRole) + { + // Business rule: Admin can't be demoted if they're the last admin + if (user.Roles.Contains("Admin") && newRole != "Admin") + { + // This would require checking other admins - complex business logic + // that doesn't belong to User entity alone + return true; // Simplified for example + } + + // Business rule: System role can't be assigned manually + if (newRole == "System") + { + return false; + } + + return true; + } + + /// + /// Calculate user's effective permissions based on roles + /// + public string[] CalculateEffectivePermissions(string[] roles) + { + var permissions = new List(); + + foreach (var role in roles) + { + permissions.AddRange(role switch + { + "Admin" => ["users.create", "users.read", "users.update", "users.delete", + "conversations.read", "conversations.delete", "system.admin"], + "Manager" => ["users.read", "conversations.read", "conversations.manage"], + "LegalExpert" => ["conversations.read", "legal.advice", "documents.review"], + "User" => ["conversations.create", "conversations.read.own"], + _ => [] + }); + } + + return [.. permissions.Distinct()]; + } +} diff --git a/src/Domain/Specifications/CompositeSpecifications.cs b/src/Domain/Specifications/CompositeSpecifications.cs new file mode 100644 index 0000000..8a202df --- /dev/null +++ b/src/Domain/Specifications/CompositeSpecifications.cs @@ -0,0 +1,71 @@ +using System.Linq.Expressions; + +namespace Domain.Specifications; + +/// +/// AND specification +/// +internal sealed class AndSpecification(ISpecification left, ISpecification right) : Specification +{ + public override Expression> ToExpression() + { + var leftExpression = left.ToExpression(); + var rightExpression = right.ToExpression(); + + var parameter = Expression.Parameter(typeof(T)); + var leftVisitor = new ReplaceExpressionVisitor(leftExpression.Parameters[0], parameter); + var rightVisitor = new ReplaceExpressionVisitor(rightExpression.Parameters[0], parameter); + + var newLeft = leftVisitor.Visit(leftExpression.Body); + var newRight = rightVisitor.Visit(rightExpression.Body); + + return Expression.Lambda>(Expression.AndAlso(newLeft!, newRight!), parameter); + } +} + +/// +/// OR specification +/// +internal sealed class OrSpecification(ISpecification left, ISpecification right) : Specification +{ + public override Expression> ToExpression() + { + var leftExpression = left.ToExpression(); + var rightExpression = right.ToExpression(); + + var parameter = Expression.Parameter(typeof(T)); + var leftVisitor = new ReplaceExpressionVisitor(leftExpression.Parameters[0], parameter); + var rightVisitor = new ReplaceExpressionVisitor(rightExpression.Parameters[0], parameter); + + var newLeft = leftVisitor.Visit(leftExpression.Body); + var newRight = rightVisitor.Visit(rightExpression.Body); + + return Expression.Lambda>(Expression.OrElse(newLeft!, newRight!), parameter); + } +} + +/// +/// NOT specification +/// +internal sealed class NotSpecification(ISpecification specification) : Specification +{ + public override Expression> ToExpression() + { + var expression = specification.ToExpression(); + var parameter = expression.Parameters[0]; + var body = Expression.Not(expression.Body); + + return Expression.Lambda>(body, parameter); + } +} + +/// +/// Helper class to replace expression parameters +/// +internal sealed class ReplaceExpressionVisitor(Expression oldValue, Expression newValue) : ExpressionVisitor +{ + public override Expression? Visit(Expression? node) + { + return node == oldValue ? newValue : base.Visit(node); + } +} diff --git a/src/Domain/Specifications/ConversationSpecifications.cs b/src/Domain/Specifications/ConversationSpecifications.cs new file mode 100644 index 0000000..c67867c --- /dev/null +++ b/src/Domain/Specifications/ConversationSpecifications.cs @@ -0,0 +1,86 @@ +using System.Linq.Expressions; + +namespace Domain.Specifications; + +/// +/// Placeholder conversation entity for specifications +/// +public class Conversation +{ + public Guid Id { get; set; } + public Guid OwnerId { get; set; } + public string Title { get; set; } = string.Empty; + public bool IsPrivate { get; set; } + public DateTime CreatedAt { get; set; } + public bool IsDeleted { get; set; } + public string[] Tags { get; set; } = []; + public int Priority { get; set; } +} + +/// +/// Specification for active conversations +/// +public sealed class ActiveConversationSpecification : Specification +{ + public override Expression> ToExpression() + { + return conv => !conv.IsDeleted; + } +} + +/// +/// Specification for conversations owned by user +/// +public sealed class ConversationOwnedBySpecification(Guid userId) : Specification +{ + public override Expression> ToExpression() + { + return conv => conv.OwnerId == userId; + } +} + +/// +/// Specification for public conversations +/// +public sealed class PublicConversationSpecification : Specification +{ + public override Expression> ToExpression() + { + return conv => !conv.IsPrivate; + } +} + +/// +/// Specification for high priority conversations +/// +public sealed class HighPriorityConversationSpecification : Specification +{ + public override Expression> ToExpression() + { + return conv => conv.Priority >= 10; + } +} + +/// +/// Specification for conversations with specific tag +/// +public sealed class ConversationWithTagSpecification(string tag) : Specification +{ + public override Expression> ToExpression() + { + return conv => conv.Tags.Contains(tag); + } +} + +/// +/// Factory class for common conversation specifications +/// +public static class ConversationSpecs +{ + public static ActiveConversationSpecification Active => new(); + public static PublicConversationSpecification Public => new(); + public static HighPriorityConversationSpecification HighPriority => new(); + + public static ConversationOwnedBySpecification OwnedBy(Guid userId) => new ConversationOwnedBySpecification(userId); + public static ConversationWithTagSpecification WithTag(string tag) => new ConversationWithTagSpecification(tag); +} diff --git a/src/Domain/Specifications/Examples/SpecificationUsageExamples.cs b/src/Domain/Specifications/Examples/SpecificationUsageExamples.cs new file mode 100644 index 0000000..6f8f820 --- /dev/null +++ b/src/Domain/Specifications/Examples/SpecificationUsageExamples.cs @@ -0,0 +1,119 @@ +using Domain.Aggregates.User; + +namespace Domain.Specifications.Examples; + +/// +/// Examples of how to use specifications +/// +public static class SpecificationUsageExamples +{ + /// + /// Example: Simple specification usage + /// + public static void SimpleUsage() + { + var users = GetSampleUsers(); + + // Find active users + var activeUsers = users.Where(UserSpecs.Active.ToExpression().Compile()); + + // Find admin users + var adminUsers = users.Where(UserSpecs.Admin.ToExpression().Compile()); + } + + /// + /// Example: Combining specifications with operators + /// + public static void CombinedSpecifications() + { + var users = GetSampleUsers(); + + // Active AND Admin users + var activeAdmins = UserSpecs.Active & UserSpecs.Admin; + var result1 = users.Where(activeAdmins.ToExpression().Compile()); + + // Users with legal access OR admin role + var legalOrAdmin = UserSpecs.LegalAccess | UserSpecs.Admin; + var result2 = users.Where(legalOrAdmin.ToExpression().Compile()); + + // NOT deleted users (same as active) + var notDeleted = !new UserHasRoleSpecification("Deleted"); + var result3 = users.Where(notDeleted.ToExpression().Compile()); + } + + /// + /// Example: Complex business rules + /// + public static void ComplexBusinessRules() + { + var users = GetSampleUsers(); + + // Business rule: "Recent active legal experts" + var recentActiveLegalExperts = UserSpecs.Active & + UserSpecs.HasRole("LegalExpert") & + UserSpecs.CreatedAfter(DateTime.UtcNow.AddMonths(-6)); + + var result = users.Where(recentActiveLegalExperts.ToExpression().Compile()); + + // Business rule: "Company users excluding admins" + var companyNonAdmins = UserSpecs.EmailDomain("company.com") & + UserSpecs.Active & + !UserSpecs.Admin; + + var result2 = users.Where(companyNonAdmins.ToExpression().Compile()); + } + + /// + /// Example: Validation using specifications + /// + public static void ValidationExample() + { + var user = GetSampleUser(); + + // Validate business rules + if (!UserSpecs.Active.IsSatisfiedBy(user)) + { + throw new InvalidOperationException("User must be active"); + } + + if (!UserSpecs.LegalAccess.IsSatisfiedBy(user)) + { + throw new UnauthorizedAccessException("User doesn't have legal access"); + } + } + + /// + /// Example: Dynamic query building + /// + public static void DynamicQueryBuilding() + { + var users = GetSampleUsers(); + var querySpec = UserSpecs.Active; // Start with base requirement + + // Add conditions based on user input + var requireLegalAccess = true; + var excludeAdmins = false; + var emailDomain = "company.com"; + + if (requireLegalAccess) + { + querySpec = (ActiveUserSpecification)(querySpec & UserSpecs.LegalAccess); + } + + if (excludeAdmins) + { + querySpec = (ActiveUserSpecification)(querySpec & !UserSpecs.Admin); + } + + if (!string.IsNullOrEmpty(emailDomain)) + { + querySpec = (ActiveUserSpecification)(querySpec & UserSpecs.EmailDomain(emailDomain)); + } + + var result = users.Where(querySpec.ToExpression().Compile()); + } + + // Helper methods + private static List GetSampleUsers() => []; + private static User GetSampleUser() => new() { Email = "test@test.com", FullName = "Test", PasswordHash = "hash", Roles = ["User"] }; +} diff --git a/src/Domain/Specifications/ISpecification.cs b/src/Domain/Specifications/ISpecification.cs new file mode 100644 index 0000000..44bc76a --- /dev/null +++ b/src/Domain/Specifications/ISpecification.cs @@ -0,0 +1,50 @@ +using System.Linq.Expressions; + +namespace Domain.Specifications; + +/// +/// Base specification interface +/// +/// Entity type +public interface ISpecification +{ + /// + /// Expression that represents the specification + /// + Expression> ToExpression(); + + /// + /// Check if entity satisfies the specification + /// + bool IsSatisfiedBy(T entity); +} + +/// +/// Base specification implementation +/// +/// Entity type +public abstract class Specification : ISpecification +{ + public abstract Expression> ToExpression(); + + public bool IsSatisfiedBy(T entity) + { + var predicate = ToExpression().Compile(); + return predicate(entity); + } + + /// + /// AND operator + /// + public static Specification operator &(Specification left, Specification right) => new AndSpecification(left, right); + + /// + /// OR operator + /// + public static Specification operator |(Specification left, Specification right) => new OrSpecification(left, right); + + /// + /// NOT operator + /// + public static Specification operator !(Specification specification) => new NotSpecification(specification); +} diff --git a/src/Domain/Specifications/UserSpecifications.cs b/src/Domain/Specifications/UserSpecifications.cs new file mode 100644 index 0000000..8f0d924 --- /dev/null +++ b/src/Domain/Specifications/UserSpecifications.cs @@ -0,0 +1,86 @@ +using System.Linq.Expressions; +using Domain.Aggregates.User; + +namespace Domain.Specifications; + +/// +/// Specification for active users +/// +public sealed class ActiveUserSpecification : Specification +{ + public override Expression> ToExpression() + { + return user => !user.IsDeleted; + } +} + +/// +/// Specification for users with specific role +/// +public sealed class UserHasRoleSpecification(string role) : Specification +{ + public override Expression> ToExpression() + { + return user => user.Roles.Contains(role); + } +} + +/// +/// Specification for users created after specific date +/// +public sealed class UserCreatedAfterSpecification(DateTime date) : Specification +{ + public override Expression> ToExpression() + { + return user => user.CreatedAt > date; + } +} + +/// +/// Specification for users with email domain +/// +public sealed class UserEmailDomainSpecification(string domain) : Specification +{ + public override Expression> ToExpression() + { + return user => user.Email.EndsWith($"@{domain}"); + } +} + +/// +/// Specification for admin users +/// +public sealed class AdminUserSpecification : Specification +{ + public override Expression> ToExpression() + { + return user => user.Roles.Contains("Admin"); + } +} + +/// +/// Specification for users that can access legal features +/// +public sealed class LegalAccessUserSpecification : Specification +{ + public override Expression> ToExpression() + { + return user => user.Roles.Contains("Admin") || + user.Roles.Contains("LegalExpert") || + user.Roles.Contains("Manager"); + } +} + +/// +/// Factory class for common user specifications +/// +public static class UserSpecs +{ + public static ActiveUserSpecification Active => new(); + public static AdminUserSpecification Admin => new(); + public static LegalAccessUserSpecification LegalAccess => new(); + + public static UserHasRoleSpecification HasRole(string role) => new(role); + public static UserCreatedAfterSpecification CreatedAfter(DateTime date) => new(date); + public static UserEmailDomainSpecification EmailDomain(string domain) => new(domain); +} diff --git a/src/Domain/ValueObjects/Email.cs b/src/Domain/ValueObjects/Email.cs new file mode 100644 index 0000000..ef17006 --- /dev/null +++ b/src/Domain/ValueObjects/Email.cs @@ -0,0 +1,35 @@ +using System.Text.RegularExpressions; + +namespace Domain.ValueObjects; + +/// +/// Email value object +/// +public sealed record Email +{ + private static readonly Regex EmailRegex = new( + @"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + + public string Value { get; } + + public Email(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new ArgumentException("Email cannot be empty", nameof(value)); + } + + if (!EmailRegex.IsMatch(value)) + { + throw new ArgumentException("Invalid email format", nameof(value)); + } + + Value = value.ToUpperInvariant().Trim(); + } + + public static implicit operator string(Email email) => email.Value; + public static implicit operator Email(string email) => new(email); + + public override string ToString() => Value; +} diff --git a/src/Domain/ValueObjects/Password.cs b/src/Domain/ValueObjects/Password.cs new file mode 100644 index 0000000..af7f7ec --- /dev/null +++ b/src/Domain/ValueObjects/Password.cs @@ -0,0 +1,53 @@ +namespace Domain.ValueObjects; + +/// +/// Password value object +/// +public sealed record Password +{ + public string Hash { get; } + + public Password(string hash) + { + if (string.IsNullOrWhiteSpace(hash)) + { + throw new ArgumentException("Password hash cannot be empty", nameof(hash)); + } + + Hash = hash; + } + + /// + /// Create password from plain text (for validation purposes) + /// + public static void ValidatePlainText(string plainText) + { + if (string.IsNullOrWhiteSpace(plainText)) + { + throw new ArgumentException("Password cannot be empty"); + } + + if (plainText.Length < 8) + { + throw new ArgumentException("Password must be at least 8 characters long"); + } + + if (!plainText.Any(char.IsUpper)) + { + throw new ArgumentException("Password must contain at least one uppercase letter"); + } + + if (!plainText.Any(char.IsLower)) + { + throw new ArgumentException("Password must contain at least one lowercase letter"); + } + + if (!plainText.Any(char.IsDigit)) + { + throw new ArgumentException("Password must contain at least one digit"); + } + } + + public static implicit operator string(Password password) => password.Hash; + public static implicit operator Password(string hash) => new(hash); +} diff --git a/src/Infrastructure/Data/Configurations/BaseEntityConfiguration.cs b/src/Infrastructure/Data/Configurations/BaseEntityConfiguration.cs new file mode 100644 index 0000000..f324a2f --- /dev/null +++ b/src/Infrastructure/Data/Configurations/BaseEntityConfiguration.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Infrastructure.Data.Configurations; + +/// +/// Base configuration cho tất cả entities +/// +/// Entity type +public abstract class BaseEntityConfiguration : IEntityTypeConfiguration + where TEntity : class +{ + public virtual void Configure(EntityTypeBuilder builder) + { + // Cấu hình chung cho tất cả entities + // Ví dụ: CreatedAt, UpdatedAt fields nếu có base entity + + ConfigureEntity(builder); + } + + /// + /// Cấu hình cụ thể cho từng entity + /// + /// Entity type builder + protected abstract void ConfigureEntity(EntityTypeBuilder builder); +} + diff --git a/src/Infrastructure/Data/Contexts/DataContext.cs b/src/Infrastructure/Data/Contexts/DataContext.cs new file mode 100644 index 0000000..5725473 --- /dev/null +++ b/src/Infrastructure/Data/Contexts/DataContext.cs @@ -0,0 +1,35 @@ +using Microsoft.EntityFrameworkCore; + +namespace Infrastructure.Data.Contexts; + +/// +/// Database context cho Legal Assistant application +/// +public sealed class DataContext : DbContext, IDataContext +{ + public DataContext(DbContextOptions options) : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + // Áp dụng tất cả configurations từ assembly hiện tại + modelBuilder.ApplyConfigurationsFromAssembly(typeof(DataContext).Assembly); + + // Cấu hình schema mặc định + modelBuilder.HasDefaultSchema(Schemas.Default); + } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + base.OnConfiguring(optionsBuilder); + + // Cấu hình logging cho Entity Framework (chỉ trong Development) +#if DEBUG + optionsBuilder.EnableSensitiveDataLogging(); + optionsBuilder.EnableDetailedErrors(); +#endif + } +} diff --git a/src/Infrastructure/Data/Contexts/IDataContext.cs b/src/Infrastructure/Data/Contexts/IDataContext.cs new file mode 100644 index 0000000..1285cfc --- /dev/null +++ b/src/Infrastructure/Data/Contexts/IDataContext.cs @@ -0,0 +1,24 @@ +using Microsoft.EntityFrameworkCore; + +namespace Infrastructure.Data.Contexts; + +/// +/// Interface cho Legal Assistant Database Context +/// +public interface IDataContext +{ + /// + /// Lưu các thay đổi vào database + /// + Task SaveChangesAsync(CancellationToken cancellationToken = default); + + /// + /// Lưu các thay đổi vào database (synchronous) + /// + int SaveChanges(); + + /// + /// Dispose context + /// + ValueTask DisposeAsync(); +} diff --git a/src/Infrastructure/Data/Contexts/Schemas.cs b/src/Infrastructure/Data/Contexts/Schemas.cs new file mode 100644 index 0000000..e15eba3 --- /dev/null +++ b/src/Infrastructure/Data/Contexts/Schemas.cs @@ -0,0 +1,6 @@ +namespace Infrastructure.Data.Contexts; + +internal static class Schemas +{ + public const string Default = "public"; +} diff --git a/src/Infrastructure/Infrastructure.csproj b/src/Infrastructure/Infrastructure.csproj new file mode 100644 index 0000000..663d7cb --- /dev/null +++ b/src/Infrastructure/Infrastructure.csproj @@ -0,0 +1,27 @@ + + + + net8.0 + enable + enable + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + diff --git a/src/Infrastructure/README.md b/src/Infrastructure/README.md new file mode 100644 index 0000000..5ba85db --- /dev/null +++ b/src/Infrastructure/README.md @@ -0,0 +1,218 @@ +# 🗄️ Infrastructure Layer + +**Implements external concerns và technical details. Provides concrete implementations cho Application interfaces.** + +## 📁 Cấu trúc Folders + +``` +Infrastructure/ +├── Data/ # 🗃️ Database & Data Access +│ ├── Contexts/ +│ │ ├── DataContext.cs # Main EF DbContext +│ │ ├── IDataContext.cs # DbContext interface +│ │ └── Schemas.cs # Database schema constants +│ │ +│ └── Configurations/ # EF Entity Configurations +│ └── BaseEntityConfiguration.cs # Base entity config +│ +├── Repositories/ # 📚 Repository Implementations +│ ├── UserRepository.cs # IUserRepository implementation +│ └── BaseRepository.cs # Base repository với common operations +│ +├── Services/ # 🛠️ External Service Implementations +│ ├── Email/ +│ │ ├── EmailService.cs # IEmailService implementation +│ │ └── EmailOptions.cs # Email configuration +│ │ +│ ├── FileStorage/ +│ │ ├── FileStorageService.cs # File upload/download +│ │ └── AzureBlobStorage.cs # Azure Blob implementation +│ │ +│ ├── PasswordHasher.cs # IPasswordHasher implementation +│ ├── TokenService.cs # ITokenService implementation +│ └── DomainEventDispatcher.cs # Domain event dispatching +│ +├── Authentication/ # 🔐 Authentication & Authorization +│ ├── JwtTokenService.cs # JWT token generation/validation +│ ├── PasswordHasher.cs # Password hashing với BCrypt +│ └── AuthenticationOptions.cs # Auth configuration +│ +├── Caching/ # 💾 Caching Implementations +│ ├── MemoryCacheService.cs # In-memory caching +│ ├── RedisCacheService.cs # Redis distributed cache +│ └── CacheOptions.cs # Cache configuration +│ +├── External/ # 🌐 External API Integrations +│ ├── PaymentGateway/ +│ │ ├── StripePaymentService.cs +│ │ └── PaymentOptions.cs +│ │ +│ └── LegalDatabase/ +│ ├── LegalApiClient.cs +│ └── LegalApiOptions.cs +│ +├── Messaging/ # 📨 Message Queues & Events +│ ├── ServiceBus/ +│ │ ├── ServiceBusPublisher.cs +│ │ └── ServiceBusOptions.cs +│ │ +│ └── EventBus/ +│ ├── InMemoryEventBus.cs +│ └── RabbitMQEventBus.cs +│ +└── Persistence/ # 📊 Database Specific Concerns + ├── Migrations/ # EF Database migrations + │ └── 20240101_InitialMigration.cs + │ + └── Seeders/ # Database seed data + ├── UserSeeder.cs + └── RoleSeeder.cs +``` + +## 🎯 Mục đích từng folder + +### 🗃️ **Data/** - Database Layer +**Entity Framework Core** setup và configuration + +**DataContext:** +- DbContext chính của application +- Entity configurations +- Database connection management + +**Configurations:** +- EF entity mappings +- Relationship configurations +- Index definitions +- Constraints + +### 📚 **Repositories/** - Data Access Pattern +**Implement Application interfaces** cho data access + +```csharp +public class UserRepository : IUserRepository +{ + private readonly DataContext _context; + + public async Task GetByEmailAsync(string email) + { + return await _context.Users + .FirstOrDefaultAsync(u => u.Email == email); + } +} +``` + +### 🛠️ **Services/** - External Service Implementations +**Concrete implementations** của Application interfaces + +**Categories:** +- **Authentication** - JWT, OAuth, etc. +- **Communication** - Email, SMS, Push notifications +- **Storage** - File upload, cloud storage +- **Integration** - External APIs, webhooks + +### 🔐 **Authentication/** - Security Implementation +**Authentication và Authorization** mechanics + +- **JWT Token Service** - Create/validate tokens +- **Password Hashing** - Secure password storage +- **Claims Management** - User permissions +- **Session Management** - User sessions + +### 💾 **Caching/** - Performance Optimization +**Caching strategies** implementation + +- **Memory Cache** - In-process caching +- **Distributed Cache** - Redis, SQL Server cache +- **Cache Policies** - TTL, eviction strategies + +### 🌐 **External/** - Third-party Integrations +**External service integrations** + +**Examples:** +- Payment gateways (Stripe, PayPal) +- Legal databases +- Document services +- Analytics services +- Cloud services (AWS, Azure) + +### 📨 **Messaging/** - Event & Message Processing +**Asynchronous communication** + +- **Service Bus** - Azure Service Bus, RabbitMQ +- **Event Sourcing** - Event store implementation +- **Background Jobs** - Hangfire, Quartz.NET +- **Real-time** - SignalR + +### 📊 **Persistence/** - Database Operations +**Database-specific operations** + +**Migrations:** +- Schema changes +- Data migrations +- Version control + +**Seeders:** +- Initial data setup +- Test data creation +- Reference data + +## 🔌 Dependency Injection Setup + +Infrastructure layer cung cấp **service registration extensions**: + +```csharp +public static class InfrastructureServiceExtensions +{ + public static IServiceCollection AddInfrastructure( + this IServiceCollection services, + IConfiguration configuration) + { + // Database + services.AddDbContext(options => + options.UseSqlServer(connectionString)); + + // Repositories + services.AddScoped(); + + // Services + services.AddScoped(); + services.AddScoped(); + + // External Services + services.AddScoped(); + + return services; + } +} +``` + +## 🚫 Điều KHÔNG ĐƯỢC làm + +- ❌ **No business logic** - Business rules thuộc Domain +- ❌ **No UI concerns** - Presentation logic thuộc Web layer +- ❌ **Direct domain manipulation** - Always qua Application layer + +## ✅ Điều NÊN làm + +- ✅ **Implement interfaces** - Từ Application layer +- ✅ **Handle technical concerns** - Database, external APIs +- ✅ **Configuration management** - Options pattern +- ✅ **Error handling** - Technical error wrapping +- ✅ **Performance optimization** - Caching, connection pooling + +## 🔗 Dependencies + +- **Application Layer** - Interfaces to implement +- **Domain Layer** - Domain entities và aggregates +- **Entity Framework Core** - ORM +- **External packages** - Specific to implementation + +## 💡 Best Practices + +1. **Configuration** - Use IOptions pattern +2. **Connection Management** - Proper disposal +3. **Error Handling** - Wrap external exceptions +4. **Logging** - Log all external calls +5. **Resilience** - Retry policies, circuit breakers +6. **Testing** - Integration tests cho repositories +7. **Security** - Secure external communications diff --git a/src/Infrastructure/Services/DomainEventDispatcher.cs b/src/Infrastructure/Services/DomainEventDispatcher.cs new file mode 100644 index 0000000..2e4a9ac --- /dev/null +++ b/src/Infrastructure/Services/DomainEventDispatcher.cs @@ -0,0 +1,51 @@ +using Application.Common; +using Domain.Common; +using MediatR; +using Microsoft.Extensions.Logging; + +namespace Infrastructure.Services; + +/// +/// Implementation of domain event dispatcher using MediatR +/// +public sealed class DomainEventDispatcher( + IMediator mediator, + ILogger logger) : IDomainEventDispatcher +{ + public async Task DispatchEventsAsync(IEnumerable entities, CancellationToken cancellationToken = default) + { + var entitiesList = entities.ToList(); + var domainEvents = entitiesList + .SelectMany(entity => entity.DomainEvents) + .ToList(); + + // Clear events from entities before dispatching + foreach (var entity in entitiesList) + { + entity.ClearDomainEvents(); + } + + // Dispatch all events + foreach (var domainEvent in domainEvents) + { + await DispatchEventAsync(domainEvent, cancellationToken); + } + } + + public async Task DispatchEventAsync(IDomainEvent domainEvent, CancellationToken cancellationToken = default) + { + try + { + logger.LogDebug("Dispatching domain event: {EventType}", domainEvent.GetType().Name); + + await mediator.Publish(domainEvent, cancellationToken); + + logger.LogDebug("Successfully dispatched domain event: {EventType}", domainEvent.GetType().Name); + } + catch (Exception ex) + { + logger.LogError(ex, "Error dispatching domain event: {EventType}", domainEvent.GetType().Name); + throw; + } + } +} diff --git a/src/Infrastructure/Services/Email/.gitkeep b/src/Infrastructure/Services/Email/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/Web.Api/Configurations/Options/JwtOptions.cs b/src/Web.Api/Configurations/Options/JwtOptions.cs new file mode 100644 index 0000000..07b2c24 --- /dev/null +++ b/src/Web.Api/Configurations/Options/JwtOptions.cs @@ -0,0 +1,37 @@ +namespace Web.Api.Configurations.Options; + +/// +/// JWT configuration options +/// +public sealed class JwtOptions +{ + /// + /// Configuration section name + /// + public const string SectionName = "Jwt"; + + /// + /// JWT secret key + /// + public string SecretKey { get; init; } = string.Empty; + + /// + /// JWT issuer + /// + public string Issuer { get; init; } = string.Empty; + + /// + /// JWT audience + /// + public string Audience { get; init; } = string.Empty; + + /// + /// Access token expiration time in minutes + /// + public int AccessTokenExpirationMinutes { get; init; } = 60; + + /// + /// Refresh token expiration time in days + /// + public int RefreshTokenExpirationDays { get; init; } = 7; +} diff --git a/src/Web.Api/Controllers/V1/AuthController.cs b/src/Web.Api/Controllers/V1/AuthController.cs new file mode 100644 index 0000000..ee26040 --- /dev/null +++ b/src/Web.Api/Controllers/V1/AuthController.cs @@ -0,0 +1,34 @@ +using Application.Features.Auth.Login; +using MediatR; +using Microsoft.AspNetCore.Mvc; +using Web.Api.Models.Responses; + +namespace Web.Api.Controllers.V1; + +/// +/// Authentication controller +/// +[Route("api/v{version:apiVersion}/[controller]")] +public sealed class AuthController(IMediator mediator) : BaseController +{ + /// + /// User login + /// + /// Login credentials + /// Login result with tokens + [HttpPost("login")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status401Unauthorized)] + public async Task Login([FromBody] LoginCommand command) + { + var result = await mediator.Send(command); + + if (result.IsSuccess) + { + return Ok(ApiResponse.CreateSuccess(result.Value!, "Login successful")); + } + + return BadRequest(ApiResponse.CreateFailure(result.Error!.Message)); + } +} diff --git a/src/Web.Api/Controllers/V1/BaseController.cs b/src/Web.Api/Controllers/V1/BaseController.cs new file mode 100644 index 0000000..93f9a97 --- /dev/null +++ b/src/Web.Api/Controllers/V1/BaseController.cs @@ -0,0 +1,49 @@ +using Microsoft.AspNetCore.Mvc; + +namespace Web.Api.Controllers.V1; + +/// +/// Base controller cho tất cả API controllers version 1 +/// +[ApiController] +[ApiVersion("1.0")] +[Route("api/v{version:apiVersion}/[controller]")] +[Produces("application/json")] +public abstract class BaseController : ControllerBase +{ + protected BaseController() + { + } + + /// + /// Tạo response cho success result + /// + /// Type of data + /// Data to return + /// Success response + protected IActionResult Ok(T data) + { + return base.Ok(new + { + Success = true, + Data = data, + Message = "Request completed successfully" + }); + } + + /// + /// Tạo response cho error result + /// + /// Error message + /// HTTP status code + /// Error response + protected IActionResult Error(string message, int statusCode = 400) + { + return StatusCode(statusCode, new + { + Success = false, + Message = message, + Data = (object?)null + }); + } +} diff --git a/src/Web.Api/Controllers/V1/UsersController.cs b/src/Web.Api/Controllers/V1/UsersController.cs new file mode 100644 index 0000000..4fd5566 --- /dev/null +++ b/src/Web.Api/Controllers/V1/UsersController.cs @@ -0,0 +1,60 @@ +using Application.Common.Models; +using Application.Features.User.CreateUser; +using Application.Features.User.GetUsers; +using MediatR; +using Microsoft.AspNetCore.Mvc; +using Web.Api.Models.Responses; + +namespace Web.Api.Controllers.V1; + +/// +/// Users management controller +/// +[Route("api/v{version:apiVersion}/[controller]")] +public sealed class UsersController(IMediator mediator) : BaseController +{ + /// + /// Get users with pagination + /// + /// Query parameters + /// Paginated list of users + [HttpGet] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status403Forbidden)] + public async Task GetUsers([FromQuery] GetUsersQuery query) + { + var result = await mediator.Send(query); + + if (result.IsSuccess) + { + return Ok(ApiResponse>.CreateSuccess(result.Value!, "Users retrieved successfully")); + } + + return BadRequest(ApiResponse.CreateFailure(result.Error!.Message)); + } + + /// + /// Create new user + /// + /// User creation data + /// Created user information + [HttpPost] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status403Forbidden)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status409Conflict)] + public async Task CreateUser([FromBody] CreateUserCommand command) + { + var result = await mediator.Send(command); + + if (result.IsSuccess) + { + return CreatedAtAction( + nameof(GetUsers), + new { id = result.Value!.Id }, + ApiResponse.CreateSuccess(result.Value, "User created successfully")); + } + + return BadRequest(ApiResponse.CreateFailure(result.Error!.Message)); + } +} diff --git a/src/Web.Api/Extensions/ApplicationServiceExtensions.cs b/src/Web.Api/Extensions/ApplicationServiceExtensions.cs new file mode 100644 index 0000000..62b9b7d --- /dev/null +++ b/src/Web.Api/Extensions/ApplicationServiceExtensions.cs @@ -0,0 +1,44 @@ +using Application.Common; +using Application.Common.Behaviors; +using FluentValidation; +using MediatR; +using System.Reflection; +using Web.Api.Services; + +namespace Web.Api.Extensions; + +/// +/// Extension methods for Application services configuration +/// +public static class ApplicationServiceExtensions +{ + /// + /// Add Application layer services + /// + public static IServiceCollection AddApplicationServices(this IServiceCollection services) + { + var applicationAssembly = Assembly.GetAssembly(typeof(ICommand)); + + // Add MediatR + services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(applicationAssembly!)); + + // Add FluentValidation + services.AddValidatorsFromAssembly(applicationAssembly); + + // Add MediatR Pipeline Behaviors (order matters!) + services.AddScoped(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>)); + services.AddScoped(typeof(IPipelineBehavior<,>), typeof(PerformanceBehavior<,>)); + services.AddScoped(typeof(IPipelineBehavior<,>), typeof(AuthorizationBehavior<,>)); + services.AddScoped(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>)); + services.AddScoped(typeof(IPipelineBehavior<,>), typeof(CachingBehavior<,>)); + services.AddScoped(typeof(IPipelineBehavior<,>), typeof(TransactionBehavior<,>)); + services.AddScoped(typeof(IPipelineBehavior<,>), typeof(DomainEventBehavior<,>)); + + // Add Application services + services.AddHttpContextAccessor(); + services.AddScoped(); + services.AddMemoryCache(); + + return services; + } +} diff --git a/src/Web.Api/Extensions/ServiceCollectionExtensions.cs b/src/Web.Api/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..734be77 --- /dev/null +++ b/src/Web.Api/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,116 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Versioning; +using Microsoft.OpenApi.Models; + +namespace Web.Api.Extensions; + +/// +/// Extension methods cho IServiceCollection +/// +public static class ServiceCollectionExtensions +{ + /// + /// Cấu hình API versioning + /// + public static IServiceCollection AddApiVersioningConfiguration(this IServiceCollection services) + { + services.AddApiVersioning(options => + { + options.DefaultApiVersion = new ApiVersion(1, 0); + options.AssumeDefaultVersionWhenUnspecified = true; + options.ApiVersionReader = ApiVersionReader.Combine( + new UrlSegmentApiVersionReader(), + new HeaderApiVersionReader("X-Version"), + new QueryStringApiVersionReader("version") + ); + }); + + services.AddVersionedApiExplorer(options => + { + options.GroupNameFormat = "'v'VVV"; + options.SubstituteApiVersionInUrl = true; + }); + + return services; + } + + /// + /// Cấu hình Swagger/OpenAPI + /// + public static IServiceCollection AddSwaggerDocumentation(this IServiceCollection services) + { + services.AddSwaggerGen(options => + { + options.SwaggerDoc("v1", new OpenApiInfo + { + Title = "Legal Assistant API", + Version = "v1", + Description = "API for Legal Assistant Application", + Contact = new OpenApiContact + { + Name = "Legal Assistant Team", + Email = "support@legalassistant.com" + } + }); + + // Include XML comments + var xmlFile = $"{System.Reflection.Assembly.GetExecutingAssembly().GetName().Name}.xml"; + var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile); + if (File.Exists(xmlPath)) + { + options.IncludeXmlComments(xmlPath); + } + + // Add JWT Bearer authentication + options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme + { + Description = "JWT Authorization header using the Bearer scheme. Example: \"Authorization: Bearer {token}\"", + Name = "Authorization", + In = ParameterLocation.Header, + Type = SecuritySchemeType.ApiKey, + Scheme = "Bearer" + }); + + options.AddSecurityRequirement(new OpenApiSecurityRequirement + { + { + new OpenApiSecurityScheme + { + Reference = new OpenApiReference + { + Type = ReferenceType.SecurityScheme, + Id = "Bearer" + } + }, + Array.Empty() + } + }); + }); + + return services; + } + + /// + /// Cấu hình CORS + /// + public static IServiceCollection AddCorsPolicies(this IServiceCollection services) + { + services.AddCors(options => + { + options.AddPolicy("DefaultPolicy", builder => + builder + .AllowAnyOrigin() + .AllowAnyMethod() + .AllowAnyHeader()); + + options.AddPolicy("ProductionPolicy", builder => + builder + .WithOrigins("https://legalassistant.com") + .AllowAnyMethod() + .AllowAnyHeader() + .AllowCredentials()); + }); + + return services; + } +} diff --git a/src/Web.Api/Filters/ValidateModelFilter.cs b/src/Web.Api/Filters/ValidateModelFilter.cs new file mode 100644 index 0000000..337b15a --- /dev/null +++ b/src/Web.Api/Filters/ValidateModelFilter.cs @@ -0,0 +1,37 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Web.Api.Models.Responses; + +namespace Web.Api.Filters; + +/// +/// Action filter để validate model state +/// +public sealed class ValidateModelFilter : ActionFilterAttribute +{ + public override void OnActionExecuting(ActionExecutingContext context) + { + if (!context.ModelState.IsValid) + { + var errors = context.ModelState + .Where(x => x.Value?.Errors.Count > 0) + .ToDictionary( + kvp => kvp.Key, + kvp => kvp.Value?.Errors.Select(e => e.ErrorMessage).ToArray() ?? Array.Empty() + ); + + var errorInfo = new ErrorInfo + { + Type = "ValidationError", + Details = "One or more validation errors occurred", + ValidationErrors = errors + }; + + var response = ApiResponse.CreateFailure("Validation failed", errorInfo); + + context.Result = new BadRequestObjectResult(response); + } + + base.OnActionExecuting(context); + } +} diff --git a/src/Web.Api/Middleware/GlobalExceptionMiddleware.cs b/src/Web.Api/Middleware/GlobalExceptionMiddleware.cs new file mode 100644 index 0000000..082b3b2 --- /dev/null +++ b/src/Web.Api/Middleware/GlobalExceptionMiddleware.cs @@ -0,0 +1,83 @@ +using System.Net; +using System.Text.Json; + +namespace Web.Api.Middleware; + +/// +/// Global exception handling middleware +/// +public sealed class GlobalExceptionMiddleware +{ + private readonly RequestDelegate _next; + private readonly ILogger _logger; + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + public GlobalExceptionMiddleware(RequestDelegate next, ILogger logger) + { + _next = next; + _logger = logger; + } + + public async Task InvokeAsync(HttpContext context) + { + try + { + await _next(context); + } + catch (Exception exception) + { + _logger.LogError(exception, "An unhandled exception occurred: {Message}", exception.Message); + await HandleExceptionAsync(context, exception); + } + } + + private static async Task HandleExceptionAsync(HttpContext context, Exception exception) + { + context.Response.ContentType = "application/json"; + + var response = new + { + Success = false, + Message = GetErrorMessage(exception), + Data = (object?)null, + Error = new + { + Type = exception.GetType().Name, + Details = exception.Message + } + }; + + context.Response.StatusCode = GetStatusCode(exception); + + var jsonResponse = JsonSerializer.Serialize(response, JsonOptions); + + await context.Response.WriteAsync(jsonResponse); + } + + private static string GetErrorMessage(Exception exception) + { + return exception switch + { + ArgumentException => "Invalid request parameters", + UnauthorizedAccessException => "Unauthorized access", + FileNotFoundException => "Resource not found", + TimeoutException => "Request timeout", + _ => "An error occurred while processing your request" + }; + } + + private static int GetStatusCode(Exception exception) + { + return exception switch + { + ArgumentException => (int)HttpStatusCode.BadRequest, + UnauthorizedAccessException => (int)HttpStatusCode.Unauthorized, + FileNotFoundException => (int)HttpStatusCode.NotFound, + TimeoutException => (int)HttpStatusCode.RequestTimeout, + _ => (int)HttpStatusCode.InternalServerError + }; + } +} diff --git a/src/Web.Api/Models/Responses/ApiResponse.cs b/src/Web.Api/Models/Responses/ApiResponse.cs new file mode 100644 index 0000000..2a1135f --- /dev/null +++ b/src/Web.Api/Models/Responses/ApiResponse.cs @@ -0,0 +1,67 @@ +namespace Web.Api.Models.Responses; + +/// +/// Standard API response wrapper +/// +/// Type of data +public sealed record ApiResponse +{ + /// + /// Indicates if the request was successful + /// + public bool Success { get; init; } + + /// + /// Response message + /// + public string Message { get; init; } = string.Empty; + + /// + /// Response data + /// + public T? Data { get; init; } + + /// + /// Error information (if any) + /// + public ErrorInfo? Error { get; init; } + + /// + /// Creates a successful response + /// + public static ApiResponse CreateSuccess(T data, string message = "Request completed successfully") + => new ApiResponse { Success = true, Data = data, Message = message }; + + /// + /// Creates a successful response without data + /// + public static ApiResponse CreateSuccess(string message = "Request completed successfully") + => new ApiResponse { Success = true, Message = message }; + + /// + /// Creates an error response + /// + public static ApiResponse CreateFailure(string message, ErrorInfo? error = null) + => new ApiResponse { Success = false, Message = message, Error = error }; +} + +/// +/// Error information +/// +public sealed record ErrorInfo +{ + /// + /// Error type + /// + public string Type { get; init; } = string.Empty; + + /// + /// Error details + /// + public string Details { get; init; } = string.Empty; + + /// + /// Validation errors (if applicable) + /// + public Dictionary? ValidationErrors { get; init; } +} diff --git a/src/Web.Api/Program.cs b/src/Web.Api/Program.cs new file mode 100644 index 0000000..ee9b7bf --- /dev/null +++ b/src/Web.Api/Program.cs @@ -0,0 +1,65 @@ +using Web.Api.Extensions; + +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. +builder.Services.AddControllers(); + +// Add Application services (MediatR, Behaviors, Validation) +builder.Services.AddApplicationServices(); + +// Add API services +builder.Services.AddApiVersioningConfiguration(); +builder.Services.AddSwaggerDocumentation(); +builder.Services.AddCorsPolicies(); +builder.Services.AddEndpointsApiExplorer(); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseHttpsRedirection(); + +// Add CORS +app.UseCors("DefaultPolicy"); + +// Add Authentication & Authorization (when implemented) +// app.UseAuthentication(); +// app.UseAuthorization(); + +// Map controllers +app.MapControllers(); + +var summaries = new[] +{ + "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" +}; + +app.MapGet("/weatherforecast", () => +{ +#pragma warning disable CA5394 // Do not use insecure randomness + var forecast = Enumerable.Range(1, 5).Select(index => + new WeatherForecast + ( + DateOnly.FromDateTime(DateTime.Now.AddDays(index)), + Random.Shared.Next(-20, 55), + summaries[Random.Shared.Next(summaries.Length)] + )) + .ToArray(); +#pragma warning restore CA5394 + return forecast; +}) +.WithName("GetWeatherForecast") +.WithOpenApi(); + +app.Run(); + +internal record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary) +{ + public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); +} diff --git a/src/Web.Api/Properties/launchSettings.json b/src/Web.Api/Properties/launchSettings.json new file mode 100644 index 0000000..1626fea --- /dev/null +++ b/src/Web.Api/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:25608", + "sslPort": 44381 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5221", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7260;http://localhost:5221", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Web.Api/README.md b/src/Web.Api/README.md new file mode 100644 index 0000000..d4cbe5d --- /dev/null +++ b/src/Web.Api/README.md @@ -0,0 +1,264 @@ +# 🌐 Web.Api Layer (Presentation) + +**HTTP API endpoints và web concerns. Entry point cho external clients.** + +## 📁 Cấu trúc Folders + +``` +Web.Api/ +├── Controllers/ # 🎮 API Controllers +│ ├── V1/ # Version 1 APIs +│ │ ├── BaseController.cs # Base controller với common logic +│ │ ├── AuthController.cs # Authentication endpoints +│ │ └── UsersController.cs # User management endpoints +│ │ +│ └── V2/ # Version 2 APIs (future) +│ +├── Middleware/ # 🔗 HTTP Middleware +│ └── GlobalExceptionMiddleware.cs # Global error handling +│ +├── Filters/ # 🔍 Action Filters +│ └── ValidateModelFilter.cs # Model validation filter +│ +├── Extensions/ # 🔧 Service Extensions +│ ├── ServiceCollectionExtensions.cs # API services (Swagger, CORS, etc.) +│ └── ApplicationServiceExtensions.cs # Application layer registration +│ +├── Models/ # 📋 API Models (DTOs) +│ ├── Requests/ # Request models +│ │ ├── CreateUserRequest.cs +│ │ └── LoginRequest.cs +│ │ +│ ├── Responses/ # Response models +│ │ ├── ApiResponse.cs # Standard response wrapper +│ │ ├── UserResponse.cs +│ │ └── LoginResponse.cs +│ │ +│ └── DTOs/ # Data Transfer Objects +│ ├── UserDto.cs +│ └── ConversationDto.cs +│ +├── Services/ # 🛠️ Web-specific Services +│ └── CurrentUserService.cs # Extract current user from HTTP context +│ +├── Configurations/ # ⚙️ Configuration Classes +│ └── Options/ +│ └── JwtOptions.cs # JWT configuration options +│ +├── Validators/ # ✅ Request Validators +│ ├── CreateUserRequestValidator.cs +│ └── LoginRequestValidator.cs +│ +├── Properties/ # 🔧 Launch Settings +│ └── launchSettings.json # Development launch configuration +│ +├── Program.cs # 🚀 Application Entry Point +├── appsettings.json # ⚙️ Configuration +├── appsettings.Development.json # 🛠️ Development config +└── Web.Api.http # 🧪 HTTP test requests +``` + +## 🎯 Mục đích từng folder + +### 🎮 **Controllers/** - API Endpoints +**HTTP endpoints** organized by version + +**Versioning Strategy:** +- `/api/v1/users` - Version 1 +- `/api/v2/users` - Version 2 +- Header-based: `X-Version: 1.0` +- Query string: `?version=1.0` + +**Controller Responsibilities:** +- Route requests +- Model binding +- Authentication/Authorization +- Return appropriate HTTP status codes + +```csharp +[ApiController] +[ApiVersion("1.0")] +[Route("api/v{version:apiVersion}/[controller]")] +public class UsersController : BaseController +{ + [HttpGet] + public async Task GetUsers([FromQuery] GetUsersQuery query) + { + var result = await Mediator.Send(query); + return Ok(result); + } +} +``` + +### 🔗 **Middleware/** - Request Pipeline +**HTTP request processing pipeline** + +**Common Middleware:** +- **Exception Handling** - Catch và format exceptions +- **Logging** - Request/response logging +- **Authentication** - JWT token validation +- **CORS** - Cross-origin requests +- **Rate Limiting** - API throttling + +**Order matters:** +1. Exception Handling (first) +2. HTTPS Redirection +3. CORS +4. Authentication +5. Authorization +6. Controllers (last) + +### 🔍 **Filters/** - Cross-cutting Concerns +**Action filters** cho common functionality + +- **Model Validation** - Automatic model validation +- **Caching** - Response caching +- **Logging** - Action-level logging +- **Authorization** - Custom authorization logic + +### 📋 **Models/** - API Contracts +**Input/Output models** cho API + +**Structure:** +- **Requests** - Input models từ clients +- **Responses** - Output models to clients +- **DTOs** - Data transfer objects + +**Best Practices:** +- Separate từ Domain entities +- Include validation attributes +- Use appropriate HTTP status codes +- Document với XML comments cho Swagger + +### 🛠️ **Services/** - Web-specific Logic +**Services** specific to web layer + +**Examples:** +- **CurrentUserService** - Extract user từ HTTP context +- **FileUploadService** - Handle file uploads +- **ResponseFactory** - Create standardized responses + +### ⚙️ **Configurations/** - Settings Management +**Configuration classes** theo Options pattern + +```csharp +public class JwtOptions +{ + public string SecretKey { get; set; } + public string Issuer { get; set; } + public string Audience { get; set; } + public TimeSpan TokenLifetime { get; set; } +} + +// Usage +services.Configure(configuration.GetSection("Jwt")); +``` + +## 🚀 Startup Configuration (Program.cs) + +```csharp +var builder = WebApplication.CreateBuilder(args); + +// Add services +builder.Services.AddControllers(); +builder.Services.AddApplicationServices(); // Application layer +builder.Services.AddInfrastructureServices(); // Infrastructure layer +builder.Services.AddApiVersioning(); +builder.Services.AddSwaggerDocumentation(); +builder.Services.AddAuthentication(); +builder.Services.AddCors(); + +var app = builder.Build(); + +// Configure pipeline +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseHttpsRedirection(); +app.UseCors(); +app.UseAuthentication(); +app.UseAuthorization(); +app.MapControllers(); + +app.Run(); +``` + +## 📖 API Documentation + +### Swagger/OpenAPI +- **Automatic documentation** từ code +- **Interactive testing** interface +- **Schema generation** từ models +- **Authentication** support + +### Versioning +- **URL versioning** - `/api/v1/users` +- **Header versioning** - `X-Version: 1.0` +- **Backward compatibility** - Support multiple versions + +## 🔒 Security + +### Authentication +```csharp +[Authorize] +[HttpGet("profile")] +public async Task GetProfile() +{ + var userId = User.GetUserId(); + var query = new GetUserProfileQuery(userId); + var result = await Mediator.Send(query); + return Ok(result); +} +``` + +### CORS Policy +```csharp +services.AddCors(options => +{ + options.AddPolicy("AllowedOrigins", builder => + builder + .WithOrigins("https://app.legalassistant.com") + .AllowAnyMethod() + .AllowAnyHeader() + .AllowCredentials()); +}); +``` + +## 🚫 Điều KHÔNG ĐƯỢC làm + +- ❌ **No business logic** - Chỉ orchestration +- ❌ **No data access** - Không direct database calls +- ❌ **No domain objects exposure** - Use DTOs +- ❌ **Heavy processing** - Keep controllers thin + +## ✅ Điều NÊN làm + +- ✅ **Thin controllers** - Delegate to Application layer +- ✅ **Proper HTTP status codes** - RESTful responses +- ✅ **Input validation** - Validate all inputs +- ✅ **Error handling** - Consistent error responses +- ✅ **API documentation** - Swagger/OpenAPI +- ✅ **Security** - Authentication/Authorization +- ✅ **Versioning** - API evolution strategy + +## 🔗 Dependencies + +- **Application Layer** - Use cases và commands/queries +- **Infrastructure Layer** - Service implementations +- **ASP.NET Core** - Web framework +- **Swagger** - API documentation +- **FluentValidation** - Input validation + +## 💡 Best Practices + +1. **RESTful Design** - Follow REST conventions +2. **Status Codes** - Use appropriate HTTP codes +3. **Error Handling** - Consistent error responses +4. **Validation** - Validate all inputs +5. **Documentation** - Keep API docs updated +6. **Security** - Always authenticate/authorize +7. **Performance** - Use caching, pagination +8. **Monitoring** - Log và monitor API usage diff --git a/src/Web.Api/Services/CurrentUserService.cs b/src/Web.Api/Services/CurrentUserService.cs new file mode 100644 index 0000000..6e2f9da --- /dev/null +++ b/src/Web.Api/Services/CurrentUserService.cs @@ -0,0 +1,39 @@ +using Application.Common.Behaviors; +using System.Security.Claims; + +namespace Web.Api.Services; + +/// +/// Current user service implementation +/// +public sealed class CurrentUserService(IHttpContextAccessor httpContextAccessor) : ICurrentUser +{ + public Guid? UserId + { + get + { + var userIdClaim = httpContextAccessor.HttpContext?.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value; + return Guid.TryParse(userIdClaim, out var userId) ? userId : null; + } + } + + public bool IsAuthenticated => httpContextAccessor.HttpContext?.User?.Identity?.IsAuthenticated ?? false; + + public string[] Roles + { + get + { + var rolesClaims = httpContextAccessor.HttpContext?.User?.FindAll(ClaimTypes.Role); + return rolesClaims?.Select(c => c.Value).ToArray() ?? []; + } + } + + public string[] Permissions + { + get + { + var permissionsClaims = httpContextAccessor.HttpContext?.User?.FindAll("permission"); + return permissionsClaims?.Select(c => c.Value).ToArray() ?? []; + } + } +} diff --git a/src/Web.Api/Web.Api.csproj b/src/Web.Api/Web.Api.csproj new file mode 100644 index 0000000..2b667f5 --- /dev/null +++ b/src/Web.Api/Web.Api.csproj @@ -0,0 +1,24 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + + + + + + diff --git a/src/Web.Api/Web.Api.http b/src/Web.Api/Web.Api.http new file mode 100644 index 0000000..7e5f41a --- /dev/null +++ b/src/Web.Api/Web.Api.http @@ -0,0 +1,6 @@ +@Web.Api_HostAddress = http://localhost:5221 + +GET {{Web.Api_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/src/Web.Api/appsettings.Development.json b/src/Web.Api/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/src/Web.Api/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/Web.Api/appsettings.json b/src/Web.Api/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/src/Web.Api/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +}