diff --git a/restaurant-reservations-clean-architecture/.editorconfig b/restaurant-reservations-clean-architecture/.editorconfig new file mode 100755 index 0000000..7d3bb5a --- /dev/null +++ b/restaurant-reservations-clean-architecture/.editorconfig @@ -0,0 +1,395 @@ +root = true + +# All files +[*] +indent_style = space + +# Xml files +[*.xml] +indent_size = 2 + +# Xml project files +[*.{csproj,fsproj,vbproj,proj,slnx}] +indent_size = 2 + +# Xml config files +[*.{props,targets,config,nuspec}] +indent_size = 2 + +[*.json] +indent_size = 2 + +# C# files +[*.cs] + +#### Core EditorConfig Options #### + +# Indentation and spacing +indent_size = 4 +tab_width = 4 + +# New line preferences +insert_final_newline = false + +#### .NET Coding Conventions #### +[*.{cs,vb}] + +# Organize usings +dotnet_separate_import_directive_groups = true +dotnet_sort_system_directives_first = true +file_header_template = unset + +# this. and Me. preferences +dotnet_style_qualification_for_event = false:silent +dotnet_style_qualification_for_field = false:silent +dotnet_style_qualification_for_method = false:silent +dotnet_style_qualification_for_property = false:silent + +# Language keywords vs BCL types preferences +dotnet_style_predefined_type_for_locals_parameters_members = true:silent +dotnet_style_predefined_type_for_member_access = true:silent + +# Parentheses preferences +dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent +dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent + +# Modifier preferences +dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent + +# Expression-level preferences +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_explicit_tuple_names = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_object_initializer = true:suggestion +dotnet_style_operator_placement_when_wrapping = beginning_of_line +dotnet_style_prefer_auto_properties = true:suggestion +dotnet_style_prefer_collection_expression = when_types_loosely_match:suggestion +dotnet_style_prefer_compound_assignment = true:suggestion +dotnet_style_prefer_conditional_expression_over_assignment = true:suggestion +dotnet_style_prefer_conditional_expression_over_return = true:suggestion +dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion +dotnet_style_prefer_inferred_tuple_names = true:suggestion +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion +dotnet_style_prefer_simplified_boolean_expressions = true:suggestion +dotnet_style_prefer_simplified_interpolation = true:suggestion + +# Field preferences +dotnet_style_readonly_field = true:warning + +# Parameter preferences +dotnet_code_quality_unused_parameters = all:suggestion + + +#### C# Coding Conventions #### +[*.cs] + +# var preferences +csharp_style_var_elsewhere = false:silent +csharp_style_var_for_built_in_types = false:silent +csharp_style_var_when_type_is_apparent = false:silent + +# Expression-bodied members +csharp_style_expression_bodied_accessors = true:silent +csharp_style_expression_bodied_constructors = false:silent +csharp_style_expression_bodied_indexers = true:silent +csharp_style_expression_bodied_lambdas = true:suggestion +csharp_style_expression_bodied_local_functions = false:silent +csharp_style_expression_bodied_methods = false:silent +csharp_style_expression_bodied_operators = false:silent +csharp_style_expression_bodied_properties = true:silent + +# Pattern matching preferences +csharp_style_pattern_matching_over_as_with_null_check = true:suggestion +csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion +csharp_style_prefer_extended_property_pattern = true:suggestion +csharp_style_prefer_not_pattern = true:suggestion +csharp_style_prefer_pattern_matching = true:silent +csharp_style_prefer_switch_expression = true:suggestion + +# Null-checking preferences +csharp_style_conditional_delegate_call = true:suggestion + +# Modifier preferences +csharp_prefer_static_anonymous_function = true:suggestion +csharp_prefer_static_local_function = true:warning +csharp_preferred_modifier_order = public,private,protected,internal,file,const,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,required,volatile,async:suggestion +csharp_style_prefer_readonly_struct = true:suggestion +csharp_style_prefer_readonly_struct_member = true:suggestion + +# Code-block preferences +csharp_prefer_braces = true:silent +csharp_prefer_simple_using_statement = true:suggestion +csharp_style_namespace_declarations = file_scoped:suggestion +csharp_style_prefer_method_group_conversion = true:silent +csharp_style_prefer_primary_constructors = true:suggestion +csharp_style_prefer_top_level_statements = true:silent + +# Expression-level preferences +csharp_prefer_simple_default_expression = true:suggestion +csharp_style_deconstructed_variable_declaration = true:suggestion +csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion +csharp_style_inlined_variable_declaration = true:suggestion +csharp_style_prefer_index_operator = true:suggestion +csharp_style_prefer_local_over_anonymous_function = true:suggestion +csharp_style_prefer_null_check_over_type_check = true:suggestion +csharp_style_prefer_range_operator = true:suggestion +csharp_style_prefer_tuple_swap = true:suggestion +csharp_style_prefer_utf8_string_literals = true:suggestion +csharp_style_throw_expression = true:suggestion +csharp_style_unused_value_assignment_preference = discard_variable:suggestion +csharp_style_unused_value_expression_statement_preference = discard_variable:silent + +# 'using' directive preferences +csharp_using_directive_placement = outside_namespace:silent + +#### C# Formatting Rules #### + +# New line preferences +csharp_new_line_before_catch = true +csharp_new_line_before_else = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_open_brace = all +csharp_new_line_between_query_expression_clauses = true + +# Indentation preferences +csharp_indent_block_contents = true +csharp_indent_braces = false +csharp_indent_case_contents = true +csharp_indent_case_contents_when_block = true +csharp_indent_labels = one_less_than_current +csharp_indent_switch_labels = true + +# Space preferences +csharp_space_after_cast = false +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_after_comma = true +csharp_space_after_dot = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_after_semicolon_in_for_statement = true +csharp_space_around_binary_operators = before_and_after +csharp_space_around_declaration_statements = false +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_before_comma = false +csharp_space_before_dot = false +csharp_space_before_open_square_brackets = false +csharp_space_before_semicolon_in_for_statement = false +csharp_space_between_empty_square_brackets = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_declaration_name_and_open_parenthesis = false +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_parentheses = false +csharp_space_between_square_brackets = false + +# Wrapping preferences +csharp_preserve_single_line_blocks = true +csharp_preserve_single_line_statements = true + +#### Naming styles #### + + +dotnet_diagnostic.CA1873.severity = none + + +# CS8618: Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. +dotnet_diagnostic.CS8618.severity = none + + +dotnet_diagnostic.CA2012.severity = none + + +# CS0219: Variable is assigned but its value is never used +dotnet_diagnostic.CS0219.severity = silent +[*.{cs,vb}] + +# Naming rules + +dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.symbols = types_and_namespaces +dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.interfaces_should_be_ipascalcase.severity = suggestion +dotnet_naming_rule.interfaces_should_be_ipascalcase.symbols = interfaces +dotnet_naming_rule.interfaces_should_be_ipascalcase.style = ipascalcase + +dotnet_naming_rule.type_parameters_should_be_tpascalcase.severity = suggestion +dotnet_naming_rule.type_parameters_should_be_tpascalcase.symbols = type_parameters +dotnet_naming_rule.type_parameters_should_be_tpascalcase.style = tpascalcase + +dotnet_naming_rule.methods_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.methods_should_be_pascalcase.symbols = methods +dotnet_naming_rule.methods_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.properties_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.properties_should_be_pascalcase.symbols = properties +dotnet_naming_rule.properties_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.events_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.events_should_be_pascalcase.symbols = events +dotnet_naming_rule.events_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.local_variables_should_be_camelcase.severity = suggestion +dotnet_naming_rule.local_variables_should_be_camelcase.symbols = local_variables +dotnet_naming_rule.local_variables_should_be_camelcase.style = camelcase + +dotnet_naming_rule.local_constants_should_be_camelcase.severity = suggestion +dotnet_naming_rule.local_constants_should_be_camelcase.symbols = local_constants +dotnet_naming_rule.local_constants_should_be_camelcase.style = camelcase + +dotnet_naming_rule.parameters_should_be_camelcase.severity = suggestion +dotnet_naming_rule.parameters_should_be_camelcase.symbols = parameters +dotnet_naming_rule.parameters_should_be_camelcase.style = camelcase + +dotnet_naming_rule.public_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.public_fields_should_be_pascalcase.symbols = public_fields +dotnet_naming_rule.public_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.private_static_fields_should_be_s_camelcase.severity = suggestion +dotnet_naming_rule.private_static_fields_should_be_s_camelcase.symbols = private_static_fields +dotnet_naming_rule.private_static_fields_should_be_s_camelcase.style = s_camelcase + +dotnet_naming_rule.public_constant_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.public_constant_fields_should_be_pascalcase.symbols = public_constant_fields +dotnet_naming_rule.public_constant_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.private_constant_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.private_constant_fields_should_be_pascalcase.symbols = private_constant_fields +dotnet_naming_rule.private_constant_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.symbols = public_static_readonly_fields +dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.symbols = private_static_readonly_fields +dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.enums_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.enums_should_be_pascalcase.symbols = enums +dotnet_naming_rule.enums_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.local_functions_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.local_functions_should_be_pascalcase.symbols = local_functions +dotnet_naming_rule.local_functions_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.non_field_members_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.non_field_members_should_be_pascalcase.symbols = non_field_members +dotnet_naming_rule.non_field_members_should_be_pascalcase.style = pascalcase + +# Symbol specifications + +dotnet_naming_symbols.interfaces.applicable_kinds = interface +dotnet_naming_symbols.interfaces.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.interfaces.required_modifiers = + +dotnet_naming_symbols.enums.applicable_kinds = enum +dotnet_naming_symbols.enums.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.enums.required_modifiers = + +dotnet_naming_symbols.events.applicable_kinds = event +dotnet_naming_symbols.events.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.events.required_modifiers = + +dotnet_naming_symbols.methods.applicable_kinds = method +dotnet_naming_symbols.methods.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.methods.required_modifiers = + +dotnet_naming_symbols.properties.applicable_kinds = property +dotnet_naming_symbols.properties.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.properties.required_modifiers = + +dotnet_naming_symbols.public_fields.applicable_kinds = field +dotnet_naming_symbols.public_fields.applicable_accessibilities = public, internal +dotnet_naming_symbols.public_fields.required_modifiers = + +dotnet_naming_symbols.private_fields.applicable_kinds = field +dotnet_naming_symbols.private_fields.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_fields.required_modifiers = + +dotnet_naming_symbols.private_static_fields.applicable_kinds = field +dotnet_naming_symbols.private_static_fields.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_static_fields.required_modifiers = static + +dotnet_naming_symbols.types_and_namespaces.applicable_kinds = namespace, class, struct, interface, enum +dotnet_naming_symbols.types_and_namespaces.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.types_and_namespaces.required_modifiers = + +dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method +dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.non_field_members.required_modifiers = + +dotnet_naming_symbols.type_parameters.applicable_kinds = namespace +dotnet_naming_symbols.type_parameters.applicable_accessibilities = * +dotnet_naming_symbols.type_parameters.required_modifiers = + +dotnet_naming_symbols.private_constant_fields.applicable_kinds = field +dotnet_naming_symbols.private_constant_fields.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_constant_fields.required_modifiers = const + +dotnet_naming_symbols.local_variables.applicable_kinds = local +dotnet_naming_symbols.local_variables.applicable_accessibilities = local +dotnet_naming_symbols.local_variables.required_modifiers = + +dotnet_naming_symbols.local_constants.applicable_kinds = local +dotnet_naming_symbols.local_constants.applicable_accessibilities = local +dotnet_naming_symbols.local_constants.required_modifiers = const + +dotnet_naming_symbols.parameters.applicable_kinds = parameter +dotnet_naming_symbols.parameters.applicable_accessibilities = * +dotnet_naming_symbols.parameters.required_modifiers = + +dotnet_naming_symbols.public_constant_fields.applicable_kinds = field +dotnet_naming_symbols.public_constant_fields.applicable_accessibilities = public, internal +dotnet_naming_symbols.public_constant_fields.required_modifiers = const + +dotnet_naming_symbols.public_static_readonly_fields.applicable_kinds = field +dotnet_naming_symbols.public_static_readonly_fields.applicable_accessibilities = public, internal +dotnet_naming_symbols.public_static_readonly_fields.required_modifiers = readonly, static + +dotnet_naming_symbols.private_static_readonly_fields.applicable_kinds = field +dotnet_naming_symbols.private_static_readonly_fields.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_static_readonly_fields.required_modifiers = readonly, static + +dotnet_naming_symbols.local_functions.applicable_kinds = local_function +dotnet_naming_symbols.local_functions.applicable_accessibilities = * +dotnet_naming_symbols.local_functions.required_modifiers = + +# Naming styles + +dotnet_naming_style.pascalcase.required_prefix = +dotnet_naming_style.pascalcase.required_suffix = +dotnet_naming_style.pascalcase.word_separator = +dotnet_naming_style.pascalcase.capitalization = pascal_case + +dotnet_naming_style.ipascalcase.required_prefix = I +dotnet_naming_style.ipascalcase.required_suffix = +dotnet_naming_style.ipascalcase.word_separator = +dotnet_naming_style.ipascalcase.capitalization = pascal_case + +dotnet_naming_style.tpascalcase.required_prefix = T +dotnet_naming_style.tpascalcase.required_suffix = +dotnet_naming_style.tpascalcase.word_separator = +dotnet_naming_style.tpascalcase.capitalization = pascal_case + +dotnet_naming_style._camelcase.required_prefix = _ +dotnet_naming_style._camelcase.required_suffix = +dotnet_naming_style._camelcase.word_separator = +dotnet_naming_style._camelcase.capitalization = camel_case + +dotnet_naming_style.camelcase.required_prefix = +dotnet_naming_style.camelcase.required_suffix = +dotnet_naming_style.camelcase.word_separator = +dotnet_naming_style.camelcase.capitalization = camel_case + +dotnet_naming_style.s_camelcase.required_prefix = s_ +dotnet_naming_style.s_camelcase.required_suffix = +dotnet_naming_style.s_camelcase.word_separator = +dotnet_naming_style.s_camelcase.capitalization = camel_case + diff --git a/restaurant-reservations-clean-architecture/.gitignore b/restaurant-reservations-clean-architecture/.gitignore new file mode 100755 index 0000000..0808c4a --- /dev/null +++ b/restaurant-reservations-clean-architecture/.gitignore @@ -0,0 +1,482 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from `dotnet new gitignore` + +# dotenv files +.env + +# 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 +project.lock.json +project.fragment.lock.json +artifacts/ + +# Tye +.tye/ + +# 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 +# but not Directory.Build.rsp, as it configures directory-level build defaults +!Directory.Build.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 +.idea/ + +## +## Visual studio for Mac +## + + +# globs +Makefile.in +*.userprefs +*.usertasks +config.make +config.status +aclocal.m4 +install-sh +autom4te.cache/ +*.tar.gz +tarballs/ +test-results/ + +# content below from: https://github.com/github/gitignore/blob/main/Global/macOS.gitignore +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +# content below from: https://github.com/github/gitignore/blob/main/Global/Windows.gitignore +# Windows thumbnail cache files +Thumbs.db +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# Vim temporary swap files +*.swp diff --git a/restaurant-reservations-clean-architecture/Directory.Build.props b/restaurant-reservations-clean-architecture/Directory.Build.props new file mode 100755 index 0000000..fca2783 --- /dev/null +++ b/restaurant-reservations-clean-architecture/Directory.Build.props @@ -0,0 +1,7 @@ + + + + + + + diff --git a/restaurant-reservations-clean-architecture/Directory.Packages.props b/restaurant-reservations-clean-architecture/Directory.Packages.props new file mode 100755 index 0000000..5919eb5 --- /dev/null +++ b/restaurant-reservations-clean-architecture/Directory.Packages.props @@ -0,0 +1,48 @@ + + + + true + + + + + + + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/Dockerfile b/restaurant-reservations-clean-architecture/Dockerfile new file mode 100644 index 0000000..9210a14 --- /dev/null +++ b/restaurant-reservations-clean-architecture/Dockerfile @@ -0,0 +1,23 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build-env +WORKDIR /src + +COPY *.slnx ./ +COPY Directory.Packages.props ./ + +COPY src/RestaurantReservation.Domain/*.csproj ./src/RestaurantReservation.Domain/ +COPY src/RestaurantReservation.Application/*.csproj ./src/RestaurantReservation.Application/ +COPY src/RestaurantReservation.Infrastructure/*.csproj ./src/RestaurantReservation.Infrastructure/ +COPY src/RestaurantReservation.Api/*.csproj ./src/RestaurantReservation.Api/ + +RUN dotnet restore src/RestaurantReservation.Api/RestaurantReservation.Api.csproj + +COPY . . + +RUN dotnet publish src/RestaurantReservation.Api/RestaurantReservation.Api.csproj -c Release -o /app/out + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final +WORKDIR /app +COPY --from=build-env /app/out . + +EXPOSE 8080 +ENTRYPOINT ["dotnet", "RestaurantReservation.Api.dll"] \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/README.md b/restaurant-reservations-clean-architecture/README.md new file mode 100644 index 0000000..d31cad1 --- /dev/null +++ b/restaurant-reservations-clean-architecture/README.md @@ -0,0 +1,110 @@ +# Restaurant Reservation API + +This is a backend API for managing restaurant reservations following the Clean Architecture Principles. This is a learning project from the [C# Academy Website](https://thecsharpacademy.com/project/100008/system-design-clean-architecture-reservations). It handles real-world business concerns such as seating parties across multiple tables if their party size exceeds the size of a single table, as well as preventing race-condition double bookings at the database level. + +## Technogies Used +- .NET10/ASP.NET Core +- PostgreSQL 17 - database/persistence +- Entity Framework Core - ORM, Code-First migrations, Fluent API +- MediatR 12 - CQRS-style command/query (request response) pipeline +- FluentValidation - command validation +- ASP.NET Core Identity - authentication, JWT bearer tokens +- Redis/Hybrid Cache - caching to improve API response times +- Seq - structured logging (via Serilog) +- MailKit/Mailpit - for sending email notifications after reservation scheduling/rescheduling/cancellation/completion /local SMTP testing +- Docker & Docker Compose - containerization +- xUnit, FluentAssertions, TestContainers - for architecture/integration testing + +**Getting Started** +You can run this project via Docker Compose, which spins up the API as well as PostgreSQL, Redis, Seq and Mailpit. +Clone the repo and navigate to the solution root directory and run the following command: + +```bash +docker compose up -d --build +``` + +***Once everything is up and running:*** + +| Service | URL | +|------------------------------------|---------------------------------| +| API (Scalar for testing endpoints) | http://localhost:8080/scalar/v1 | +| Seq (logs) | http://localhost:8081 | +| Mailpit (email) | http://localhost:8025 | + + +There is a default admin and customer user seeded into the database as well as restaurants/tables etc. + +***Default User Information*** + +| Username | Password | +|------------------------------------|------------------------| +| admin@example.com | Pa$$w0rd | +| customer@example.com | Pa$$w0rd | + +***Architecture Overview*** +This project follows Clean Architecture Principals (with some Domain-Driven-Design thrown in). This application is organized into four layers: +- ***Domain*** - Core business entiries and rules (Restaurant, Table, TableGroup, Reservation etc.). This layer has no dependencies on any other layers. Business invariants (e.g. A table can only be added to a table group if they belong to the same restaurant) are enforced on this layer. +- ***Application*** - Depends only on the Domain Layer. Use cases are implemented at this layer via MediatR commands and queries (CQRS). +- ***Infrastructure*** - Depends on the Application and Domain layers. This layer handles Database persistence via EF Core as well as Email, Identity/authentication. This layer implements interfaces defined at the Application layer. +- ***API*** - Depends on the Application and Infrastructure layers. This is the ASP.NET Core Web API layer. It is implemented using Minimal API endpoints, request/response mapping as well as mapping errors/failure responses to Problem Details. This layer also handles the main wiring of dependency injection. + +Dependencies flow inward: +```mermaid +graph TD + A[API Layer] --> B[Infrastructure Layer] + B --> C[Application Layer] + C --> D[Domain Layer] +``` +Dependencies are enforeced via a dedicated architecture test suite. + +## Key Design Decisions +***Multi-Table Reservations*** +The original design rejected any reservation if no single table could seat the full party — a party of 10 would be turned away even if two free 6-seat tables sat right next to each other. Real restaurants don't work this way: staff combine adjacent tables to seat larger parties. +Rather than computing table combinations dynamically, this project models combinability as pre-approved, restaurant-configured TableGroups — a known, finite set of tables that are physically allowed to be pushed together. Availability search checks single tables first, and only falls back to checking TableGroups (requiring all member tables to be free) if no single table fits. This keeps the search cheap and predictable — a simple loop over a small list, while still matching how restaurants actually operate. + +***Preventing Double-Bookings at the Database Level*** +Checking table availability in application code before inserting a reservation leaves a race-condition window: two near-simultaneous requests can both see a table as free and both succeed, double-booking it. Rather than relying solely on optimistic concurrency (which protects against conflicting updates, not conflicting inserts), this project uses a PostgreSQL EXCLUDE constraint on the reservation_tables table, enforced via a GiST index, to reject any overlapping reservation for the same table at the database level. This was done by creating a new migration and editing the migration file with raw PostgreSQL: +```postgresql +CREATE EXTENSION IF NOT EXISTS btree_gist; +ALTER TABLE reservation_tables + ADD CONSTRAINT no_overlapping_table_reservations + EXCLUDE USING gist ( + table_id WITH =, + tsrange( + scheduled_reservation_reservation_day + scheduled_reservation_reservation_start, + scheduled_reservation_reservation_day + scheduled_reservation_reservation_end + ) WITH &&); +``` +After which the migration was applied. +This is verified by an integration test that fires two concurrent reservation attempts for the same table and confirms exactly one succeeds. + +***PostgreSQL over SQL Server*** +Beyond PostgreSQL's technical merits, this choice was also deliberate for a couple of reasons First, gaining experience with more than one database provider is something that I feel is very important for a developer, even though EF Core abstracts much of the actual SQL, I still felt it was important to use a different database provider than previous projects. Second, C# and .NET still carry a lingering reputation as a "Windows-only" ecosystem, despite .NET Core being open-source and cross-platform. This entire project was built on a GNU/Linux distribution, using .NET Core/ASP.NET Core and an open-source database to demonstrate that modern .NET applications aren't tied to any single vendor or operating system. + +***Mailpit over a Real Email Provider*** +Since this is a learning project and not an actual restaurant reservation system, using a real email provider (e.g., Gmail via an app password) would add setup friction without adding real value — every reviewer would need to configure their own credentials just to see email notifications work. Using MailKit: reservation confirmations and updates are sent exactly as they would be in production, but anyone running the project can simply open Mailpit's web UI and see the emails arrive in real time, with zero configuration required. + +## Testing +This project uses both Architecture tests to ensure/enfore dependencies and Integration tests to test the entire application stack. The decision to exclude Unit Tests was deliberate for the reason being as I was initially writing them. I realized I was testing much of the same logic that was better tested via integration tests. + +# What Else Is Implemented +- Pagination +- Sorting +- Filtering +- Result Pattern + +# A Note About Scalar +For those endpoints that require authention/authorization there will be a drop-down that says ***Auth Type*** please be sure to select ***Bearer*** and then paste the JWT Token where it says ***Bearer Token***. + +## Things I Learned +- Clean Architecture - This was obviously the most important aspect of this project/application. I learned how dependencies should flow, and how separating different concerns (e.g. business logic, from database logic) makes for a much cleaner codebase. +- Docker - This was perhaps the second most important aspect of this project, or possibly it can be considered as tied for first with Clean Architecture. This is the first project from the C# Academy that I have personally done that required the use of docker. At first this was challenging because it was admittedly new to me, but once I got the hang of it and actually dockerized my own application and ran it entirely containerized I saw both the value and power of using Docker. It is also true as I read that Docker solved the age-old problem of "It works on my machine but not on others". +- Caching - Caching was not an explict require for this project but in my research I saw the value of caching and wanted to implement it in this project. I decided to use the Hybrid Cache approach available in .NET which allows you to combine local caching with an external caching service (Redis). +- Problem Details - Learning about problem details and structuring standardized, consistent and easily parseable error messages to inform the end user of any issues with the system. +- Domain Driven Design - In my study of Clean Architecture, I learned that it is at times combined with Domain Driven Design. I will not say that this project adhers strictly to DDD, but I tried to implement it as best as I could where I could. + +## Areas To Improve Upon +There is always room from improvement especially in software development. While I have learned a tremendous amount and have made many improvements in my own skillset while implementing this project. There is always so much more to learn. I know there are areas of this project that may not be optimal or could have been implemented better. So as with each project I will take what I learned and apply it further and keep learning and improving. + +## Helpful Resources Used +[codewithmukesh](https://codewithmukesh.com/courses/dotnet-webapi-zero-to-hero/lessons/) diff --git a/restaurant-reservations-clean-architecture/RestaurantReservation.slnx b/restaurant-reservations-clean-architecture/RestaurantReservation.slnx new file mode 100755 index 0000000..c8d05f7 --- /dev/null +++ b/restaurant-reservations-clean-architecture/RestaurantReservation.slnx @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/restaurant-reservations-clean-architecture/docker-compose.yml b/restaurant-reservations-clean-architecture/docker-compose.yml new file mode 100755 index 0000000..3fb7f6b --- /dev/null +++ b/restaurant-reservations-clean-architecture/docker-compose.yml @@ -0,0 +1,84 @@ +services: + api: + build: + context: . + dockerfile: Dockerfile + container_name: api + ports: + - "8080:8080" + environment: + ASPNETCORE_HTTP_PORTS: 8080 + ASPNETCORE_ENVIRONMENT: Docker + ConnectionStrings__DefaultConnection: "Host=postgres;Port=5432;Database=reservationDB;Username=postgres;Password=postgres" + ConnectionStrings__Redis: "redis:6379,password=redis" + Seq__ServerUrl: "http://seq:5341" + Smtp__Host: "mailpit" + Smtp__Port: "1025" + depends_on: + postgres: + condition: "service_healthy" + redis: + condition: "service_started" + seq: + condition: "service_started" + mailpit: + condition: "service_started" + postgres: + image: postgres:17-alpine + container_name: postgres + restart: always + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: reservationDB + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d reservationDB"] + interval: 10s + timeout: 5s + retries: 5 + + seq: + image: datalust/seq:latest + container_name: seq + environment: + ACCEPT_EULA: Y + SEQ_FIRSTRUN_NOAUTHENTICATION: "true" + ports: + - "5341:5341" + - "8081:80" + volumes: + - seq-data:/data + + mailpit: + image: axllent/mailpit + container_name: mailpit + restart: unless-stopped + environment: + MP_MAX_MESSAGES: 5000 + MP_DATABASE: /data/mailpit.db + MP_SMTP_AUTH_ACCEPT_ANY: 1 + MP_SMTP_AUTH_ALLOW_INSECURE: 1 + ports: + - "8025:8025" + - "1025:1025" + volumes: + - mailpit-data:/data + + redis: + image: redis:latest + container_name: redis + ports: + - "6379:6379" + command: redis-server --requirepass redis --appendonly yes + volumes: + - redis-data:/data + +volumes: + postgres_data: + seq-data: + mailpit-data: + redis-data: \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Api/DependencyInjection.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Api/DependencyInjection.cs new file mode 100755 index 0000000..8b76070 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Api/DependencyInjection.cs @@ -0,0 +1,95 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Hybrid; +using Microsoft.OpenApi; + +using RestaurantReservation.Api.Handlers; +using RestaurantReservation.Application.Abstractions; +using RestaurantReservation.Infrastructure.Persistence; + +using Serilog; + +namespace RestaurantReservation.Api; + +public static class DependencyInjection +{ + public static IServiceCollection AddPresentation(this IServiceCollection services, IConfiguration configuration) + { + services.AddProblemDetails(options => + { + options.CustomizeProblemDetails = context => + { + context.ProblemDetails.Extensions["traceId"] = context.HttpContext.TraceIdentifier; + context.ProblemDetails.Extensions["timestamp"] = DateTime.UtcNow; + context.ProblemDetails.Instance = + $"{context.HttpContext.Request.Method} {context.HttpContext.Request.Path}"; + }; + }); + + services.AddExceptionHandler(); + + services.AddSerilog((srvs, lc) => lc + .ReadFrom.Configuration(configuration) + .ReadFrom.Services(srvs)); + + services.AddDbContext(options => + { + options.UseNpgsql(configuration.GetConnectionString("DefaultConnection")) + .UseSnakeCaseNamingConvention(); + }); + + services.AddOpenApi(options => + { + options.AddDocumentTransformer((document, _, _) => + { + var info = document.Info; + info.Title = "Restaurant Reservation Api"; + info.Description = "An API for Creating, Cancelling, or Updating Restaurant Reservations"; + info.Contact = new OpenApiContact + { + Name = "Terrence L. Gee", + Email = "mrgee1978@proton.me", + Url = new Uri("https://github.com/TerrenceLGee?tab=repositories") + }; + document.Info = info; + + var components = document.Components ?? new OpenApiComponents(); + components.SecuritySchemes ??= new Dictionary(); + components.SecuritySchemes["Bearer"] = new OpenApiSecurityScheme + { + Type = SecuritySchemeType.Http, + Scheme = "bearer", + BearerFormat = "JWT", + Description = "Enter your JWT token" + }; + + document.Components = components; + + var schemeReference = new OpenApiSecuritySchemeReference("Bearer"); + var securityRequirement = new OpenApiSecurityRequirement { [schemeReference] = [] }; + + document.Security ??= []; + document.Security.Add(securityRequirement); + return Task.CompletedTask; + }); + }); + + services.AddStackExchangeRedisCache(options => + { + options.Configuration = configuration.GetConnectionString("Redis"); + options.InstanceName = "restaurantreservationapi:"; + }); + + services.AddHybridCache(options => + { + options.DefaultEntryOptions = new HybridCacheEntryOptions + { + LocalCacheExpiration = TimeSpan.FromMinutes(5), Expiration = TimeSpan.FromMinutes(30) + }; + options.MaximumPayloadBytes = 1024 * 1024; + }); + + services.Configure( + configuration.GetSection("Smtp")); + return services; + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Api/Endpoints/AuthEndpoints.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Api/Endpoints/AuthEndpoints.cs new file mode 100755 index 0000000..a3f7e6d --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Api/Endpoints/AuthEndpoints.cs @@ -0,0 +1,71 @@ +using MediatR; + +using RestaurantReservation.Api.Extensions; +using RestaurantReservation.Application.Features.Auth.Command.Login; +using RestaurantReservation.Application.Features.Auth.Command.Logout; +using RestaurantReservation.Application.Features.Auth.Command.Register; +using RestaurantReservation.Domain.Users.Events; + +namespace RestaurantReservation.Api.Endpoints; + +public static class AuthEndpoints +{ + public static void MapAuthEndpoints(this IEndpointRouteBuilder routes) + { + var api = routes.MapGroup(Constants.Auth.BaseUri) + .WithTags(Constants.Auth.Tag); + + api.MapPost(Constants.Auth.Register, Register) + .WithName("RegisterAccount") + .WithSummary("Register a new user account"); + + api.MapPost(Constants.Auth.Login, Login) + .WithName("Login") + .WithSummary("Login to your user account"); + + api.MapPost(Constants.Auth.Logout, Logout) + .WithName("Logout") + .WithSummary("Logout from the system") + .RequireAuthorization(); + } + + private static async Task Register( + RegisterUserCommand command, + IMediator sender, + CancellationToken cancellationToken) + { + var result = await sender.Send(command, cancellationToken); + + if (result.IsFailure) return result.ToProblemDetails(); + + await sender.Publish(new RegistrationSuccessfulEvent( + command.FirstName, + command.LastName, + command.EmailAddress, + DateOnly.FromDateTime(DateTime.Now)), cancellationToken); + + return TypedResults.Ok("Registration successful!"); + } + + private static async Task Login( + LoginCommand command, + ISender sender, + CancellationToken cancellationToken) + { + var result = await sender.Send(command, cancellationToken); + return result.IsSuccess + ? TypedResults.Ok(result.Value) + : result.ToProblemDetails(); + } + + private static async Task Logout( + LogoutCommand command, + ISender sender, + CancellationToken cancellationToken) + { + var result = await sender.Send(command, cancellationToken); + return result.IsSuccess + ? TypedResults.Ok("Logout successful") + : result.ToProblemDetails(); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Api/Endpoints/Constants/Auth.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Api/Endpoints/Constants/Auth.cs new file mode 100755 index 0000000..0fe934e --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Api/Endpoints/Constants/Auth.cs @@ -0,0 +1,10 @@ +namespace RestaurantReservation.Api.Endpoints.Constants; + +public static class Auth +{ + public static string BaseUri => "/api/auth"; + public static string Tag => "Auth"; + public static string Register => "/register"; + public static string Login => "/login"; + public static string Logout => "/logout"; +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Api/Endpoints/Constants/Reservation.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Api/Endpoints/Constants/Reservation.cs new file mode 100644 index 0000000..03e983e --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Api/Endpoints/Constants/Reservation.cs @@ -0,0 +1,14 @@ +namespace RestaurantReservation.Api.Endpoints.Constants; + +public static class Reservation +{ + public static string BaseUri => "/api/reservations"; + public static string Tag => "Reservations"; + public static string ScheduleReservation => "/schedulereservation"; + public static string RescheduleReservation => "/reschedulereservation"; + public static string CancelReservation => "/cancelreservation"; + public static string CompleteReservation => "/completereservation"; + public static string GetAll => "/"; + public static string GetAllByRestaurant => "/restaurants/{restaurantId:guid}/"; + public static string Get => "/{reservationId:guid}"; +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Api/Endpoints/Constants/Restaurant.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Api/Endpoints/Constants/Restaurant.cs new file mode 100755 index 0000000..571bec6 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Api/Endpoints/Constants/Restaurant.cs @@ -0,0 +1,23 @@ +namespace RestaurantReservation.Api.Endpoints.Constants; + +public static class Restaurant +{ + public static string BaseUri => "/api/restaurants"; + public static string Tag => "Restaurants"; + public static string GetAll => "/"; + public static string Get => "/{id:guid}"; + public static string Add => "/add"; + public static string Update => "/update/{id:guid}"; + public static string Delete => "/delete/{id:guid}"; + public static string AddTable => "/{id:guid}/tables/add"; + public static string UpdateTable => "/{restaurantId:guid}/tables/update/{tableId:guid}"; + public static string DeleteTable => "/{restaurantId:guid}/tables/delete/{tableId:guid}"; + public static string GetTable => "/{restaurantId:guid}/tables/{tableId:guid}"; + public static string GetTables => "/{restaurantId:guid}/tables"; + public static string GetTablesByGroupName => "/{restaurantId:guid}/tables/{tableGroupName}"; + public static string AddTableGroup => "/{restaurantId:guid}/tablegroups/add"; + public static string UpdateTableGroup => "/{restaurantId:guid}/tablegroups/update/{tableGroupId:guid}"; + public static string DeleteTableGroup => "/{restaurantId:guid}/tablegroups/delete/{tableGroupId:guid}"; + public static string GetTableGroup => "{restaurantId:guid}/tablegroups/{tableGroupId:guid}"; + public static string GetTableGroups => "{restaurantId:guid}/tablegroups"; +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Api/Endpoints/ReservationEndpoints.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Api/Endpoints/ReservationEndpoints.cs new file mode 100644 index 0000000..7ea9a9a --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Api/Endpoints/ReservationEndpoints.cs @@ -0,0 +1,212 @@ +using MediatR; + +using RestaurantReservation.Api.Extensions; +using RestaurantReservation.Application.Features.Reservations.Command.Cancel; +using RestaurantReservation.Application.Features.Reservations.Command.Complete; +using RestaurantReservation.Application.Features.Reservations.Command.Reschedule; +using RestaurantReservation.Application.Features.Reservations.Command.Schedule; +using RestaurantReservation.Application.Features.Reservations.Query.Get; +using RestaurantReservation.Application.Features.Reservations.Query.GetAll; +using RestaurantReservation.Domain.Reservations.Events; +using RestaurantReservation.Domain.Users; + +namespace RestaurantReservation.Api.Endpoints; + +public static class ReservationEndpoints +{ + public static void MapReservationEndpoints(this IEndpointRouteBuilder app) + { + var api = app.MapGroup(Constants.Reservation.BaseUri) + .WithTags(Constants.Reservation.Tag) + .WithDescription("Schedule, Reschedule, Complete or Cancel a Reservation") + .RequireAuthorization(); + + api.MapPost(Constants.Reservation.ScheduleReservation, ScheduleReservation) + .WithName("ScheduleReservation") + .WithSummary("Schedule a reservation with one of our restaurants"); + + api.MapPut(Constants.Reservation.RescheduleReservation, RescheduleReservation) + .WithName("RescheduleReservation") + .WithSummary("Reschedule a reservation"); + + api.MapPut(Constants.Reservation.CancelReservation, CancelReservation) + .WithName("CancelReservation") + .WithSummary("Cancel a reservation"); + + api.MapPut(Constants.Reservation.CompleteReservation, CompleteReservation) + .WithName("CompleteReservation") + .WithSummary("Complete your reservation"); + + api.MapGet(Constants.Reservation.GetAll, GetAll) + .WithName("GetAllReservations") + .WithSummary("Get all your reservations"); + + api.MapGet(Constants.Reservation.GetAllByRestaurant, GetAllByRestaurant) + .WithName("GetAllRestaurantReservations") + .WithSummary("Get all reservations tied to a restaurant in the system (Admins Only)") + .RequireAuthorization(policy => + policy.RequireRole(Roles.Admin)); + + api.MapGet(Constants.Reservation.Get, Get) + .WithName("GetReservation") + .WithSummary("Get one of your individual reservations"); + } + + private static async Task ScheduleReservation( + ScheduleReservationCommand command, + IMediator sender, + CancellationToken cancellationToken) + { + var result = await sender.Send(command, cancellationToken); + + if (result.IsFailure) return result.ToProblemDetails(); + + await sender.Publish(new ReservationScheduledEvent( + result.Value.ReservationId, + result.Value.CustomerFirstName, + result.Value.CustomerLastName, + result.Value.CustomerEmail, + result.Value.RestaurantName, + result.Value.ReservationDate, + result.Value.ReservationStartTime, + result.Value.ReservationEndTime, + result.Value.NumberOfGuests), + cancellationToken); + + return TypedResults.Created($"/api/reservations/{result.Value.ReservationId}", result.Value); + } + + private static async Task RescheduleReservation( + RescheduleReservationCommand command, + IMediator sender, + CancellationToken cancellationToken) + { + var result = await sender.Send(command, cancellationToken); + + if (result.IsFailure) return result.ToProblemDetails(); + + await sender.Publish(new ReservationRescheduledEvent( + result.Value.ReservationId, + result.Value.CustomerFirstName, + result.Value.CustomerLastName, + result.Value.CustomerEmail, + result.Value.RestaurantName, + result.Value.OriginalReservationDate, + result.Value.OriginalReservationStartTime, + result.Value.OriginalReservationEndTime, + result.Value.RescheduledReservationDate, + result.Value.RescheduledReservationStartTime, + result.Value.RescheduledReservationEndTime, + result.Value.OriginalNumberOfGuests), + cancellationToken); + + return TypedResults.Ok(result.Value); + } + + private static async Task CancelReservation( + CancelReservationCommand command, + IMediator sender, + CancellationToken cancellationToken) + { + var result = await sender.Send(command, cancellationToken); + + if (result.IsFailure) return result.ToProblemDetails(); + + await sender.Publish(new ReservationCanceledEvent( + result.Value.ReservationId, + result.Value.CustomerFirstName, + result.Value.CustomerLastName, + result.Value.CustomerEmail, + result.Value.RestaurantName, + result.Value.ReservationDate, + result.Value.ReservationStartTime, + result.Value.ReservationEndTime, + result.Value.ReservationCanceledAtUtc!.Value), + cancellationToken); + + return TypedResults.Ok(result.Value); + } + + private static async Task CompleteReservation( + CompleteReservationCommand command, + IMediator sender, + CancellationToken cancellationToken) + { + var result = await sender.Send(command, cancellationToken); + + if (result.IsFailure) return result.ToProblemDetails(); + + await sender.Publish(new ReservationCompletedEvent( + result.Value.ReservationId, + result.Value.CustomerFirstName, + result.Value.CustomerLastName, + result.Value.CustomerEmail, + result.Value.RestaurantName, + result.Value.ReservationDate, + result.Value.ReservationStartTime, + result.Value.ReservationEndTime, + result.Value.ReservationCompletedAtUtc!.Value), + cancellationToken); + + return TypedResults.Ok(result.Value); + } + + private static async Task GetAll( + int? page, + int? pageSize, + string? sortBy, + string? restaurantName, + string? status, + ISender sender, + CancellationToken cancellationToken) + { + var query = new GetAllReservationsQuery( + page ?? 1, + pageSize ?? 10, + sortBy, + restaurantName, + status); + + var result = await sender.Send(query, cancellationToken); + + return result.IsSuccess + ? TypedResults.Ok(result.Value) + : result.ToProblemDetails(); + } + + private static async Task GetAllByRestaurant( + Guid restaurantId, + int? page, + int? pageSize, + string? sortBy, + string? status, + ISender sender, + CancellationToken cancellationToken) + { + var query = new GetAllRestaurantReservationsQuery( + restaurantId, + page ?? 1, + pageSize ?? 10, + sortBy, + status); + + var result = await sender.Send(query, cancellationToken); + + return result.IsSuccess + ? TypedResults.Ok(result.Value) + : result.ToProblemDetails(); + } + + private static async Task Get( + Guid reservationId, + ISender sender, + CancellationToken cancellationToken) + { + var query = new GetReservationQuery(reservationId); + var result = await sender.Send(query, cancellationToken); + + return result.IsSuccess + ? TypedResults.Ok(result.Value) + : result.ToProblemDetails(); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Api/Endpoints/RestaurantEndpoints.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Api/Endpoints/RestaurantEndpoints.cs new file mode 100755 index 0000000..00b671c --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Api/Endpoints/RestaurantEndpoints.cs @@ -0,0 +1,412 @@ +using MediatR; + +using RestaurantReservation.Api.Extensions; +using RestaurantReservation.Application.Features.Restaurants.Command.Add; +using RestaurantReservation.Application.Features.Restaurants.Command.Delete; +using RestaurantReservation.Application.Features.Restaurants.Command.Update; +using RestaurantReservation.Application.Features.Restaurants.Query.Get; +using RestaurantReservation.Application.Features.Restaurants.Query.GetAll; +using RestaurantReservation.Application.Features.Restaurants.TableGroups.Command.Add; +using RestaurantReservation.Application.Features.Restaurants.TableGroups.Command.Delete; +using RestaurantReservation.Application.Features.Restaurants.TableGroups.Command.Update; +using RestaurantReservation.Application.Features.Restaurants.TableGroups.Query.Get; +using RestaurantReservation.Application.Features.Restaurants.TableGroups.Query.GetAll; +using RestaurantReservation.Application.Features.Restaurants.Tables.Command.Add; +using RestaurantReservation.Application.Features.Restaurants.Tables.Command.Delete; +using RestaurantReservation.Application.Features.Restaurants.Tables.Command.Update; +using RestaurantReservation.Application.Features.Restaurants.Tables.Query.Get; +using RestaurantReservation.Application.Features.Restaurants.Tables.Query.GetAll; +using RestaurantReservation.Domain.Users; + +namespace RestaurantReservation.Api.Endpoints; + +public static class RestaurantEndpoints +{ + public static void MapRestaurantEndpoints(this IEndpointRouteBuilder app) + { + var api = app.MapGroup(Constants.Restaurant.BaseUri) + .WithTags(Constants.Restaurant.Tag) + .WithDescription("Available Restaurants"); + + api.MapGet(Constants.Restaurant.GetAll, GetAll) + .WithName("GetAllRestaurants") + .WithSummary("Get a listing of all available restaurants"); + + api.MapGet(Constants.Restaurant.Get, Get) + .WithName("GetRestaurantById") + .WithSummary("Get a restaurant by id"); + + api.MapPost(Constants.Restaurant.Add, Add) + .WithName("AddRestaurant") + .WithSummary("Add a new restaurant to the system (Admins Only)") + .RequireAuthorization(policy => + policy.RequireRole(Roles.Admin)); + + api.MapPut(Constants.Restaurant.Update, Update) + .WithName("UpdateRestaurant") + .WithSummary("Update an existing restaurant in the system (Admins Only)") + .RequireAuthorization(policy => + policy.RequireRole(Roles.Admin)); + + api.MapDelete(Constants.Restaurant.Delete, Delete) + .WithName("DeleteRestaurant") + .WithSummary("Delete an existing restaurant that's in the system (Admins Only)") + .RequireAuthorization(policy => + policy.RequireRole(Roles.Admin)); + + api.MapPost(Constants.Restaurant.AddTable, AddTable) + .WithName("AddTable") + .WithSummary("Add a table to a restaurant in the system (Admins Only)") + .RequireAuthorization(policy => + policy.RequireRole(Roles.Admin)); + + api.MapPut(Constants.Restaurant.UpdateTable, UpdateTable) + .WithName("UpdateTable") + .WithSummary("Update a table in a restaurant in the system (Admins Only)") + .RequireAuthorization(policy => + policy.RequireRole(Roles.Admin)); + + api.MapDelete(Constants.Restaurant.DeleteTable, DeleteTable) + .WithName("DeleteTable") + .WithSummary("Delete a table that's in a restaurant in the system (Admins Only)") + .RequireAuthorization(policy => + policy.RequireRole(Roles.Admin)); + + api.MapGet(Constants.Restaurant.GetTable, GetTable) + .WithName("GetTable") + .WithSummary("Get a table by id that's in a restaurant in the system (Admins Only)") + .RequireAuthorization(policy => + policy.RequireRole(Roles.Admin)); + + api.MapGet(Constants.Restaurant.GetTables, GetTables) + .WithName("GetTables") + .WithSummary("Get all tables belonging to restaurant in the system (Admins Only)") + .RequireAuthorization(policy => + policy.RequireRole(Roles.Admin)); + + api.MapGet(Constants.Restaurant.GetTablesByGroupName, GetTablesByGroupName) + .WithName("GetTablesByGroupName") + .WithSummary("Get all tables belonging to a table group in a restaurant in the system (Admins Only)") + .RequireAuthorization(policy => + policy.RequireRole(Roles.Admin)); + + api.MapPost(Constants.Restaurant.AddTableGroup, AddTableGroup) + .WithName("AddTableGroup") + .WithSummary("Add a new table group to a restaurant in the system (Admins Only)") + .RequireAuthorization(policy => + policy.RequireRole(Roles.Admin)); + + api.MapPut(Constants.Restaurant.UpdateTableGroup, UpdateTableGroup) + .WithName("UpdateTableGroup") + .WithSummary("Update an existing table group in a restaurant in the system (Admins Only)") + .RequireAuthorization(policy => + policy.RequireRole(Roles.Admin)); + + api.MapDelete(Constants.Restaurant.DeleteTableGroup, DeleteTableGroup) + .WithName("DeleteTableGroup") + .WithSummary("Delete an existing table group in a restaurant in the system (Admins Only)") + .RequireAuthorization(policy => + policy.RequireRole(Roles.Admin)); + + api.MapGet(Constants.Restaurant.GetTableGroup, GetTableGroup) + .WithName("GetTableGroup") + .WithSummary("Get a table group that is in a restaurant in the system (Admins Only)") + .RequireAuthorization(policy => + policy.RequireRole(Roles.Admin)); + + api.MapGet(Constants.Restaurant.GetTableGroups, GetTableGroups) + .WithName("GetTableGroups") + .WithSummary("Get all table groups belonging to a restaurant in the system. (Admins Only)") + .RequireAuthorization(policy => + policy.RequireRole(Roles.Admin)); + } + + private static async Task GetAll( + int? page, + int? pageSize, + string? sortBy, + string? name, + ISender sender, + CancellationToken cancellationToken) + { + var query = new GetAllRestaurantsQuery( + page ?? 1, + pageSize ?? 10, + sortBy, + name); + var result = await sender.Send(query, cancellationToken); + + return result.IsSuccess + ? TypedResults.Ok(result.Value) + : result.ToProblemDetails(); + } + + private static async Task Get( + Guid id, + int? tablePage, + int? tablePageSize, + ISender sender, + CancellationToken cancellationToken) + { + var query = new GetRestaurantQuery( + id, + tablePage ?? 1, + tablePageSize ?? 10); + var result = await sender.Send(query, cancellationToken); + + return result.IsSuccess + ? TypedResults.Ok(result.Value) + : result.ToProblemDetails(); + } + + private static async Task Add( + AddRestaurantCommand command, + ISender sender, + CancellationToken cancellationToken) + { + var result = await sender.Send(command, cancellationToken); + + return result.IsSuccess + ? TypedResults.Created($"/restaurants/{result.Value.Id}", result.Value) + : result.ToProblemDetails(); + } + + private static async Task Update( + Guid id, + UpdateRestaurantQuery query, + ISender sender, + CancellationToken cancellationToken) + { + var command = new UpdateRestaurantCommand( + id, + query.Name, + query.Schedule); + + var result = await sender.Send(command, cancellationToken); + + return result.IsSuccess + ? TypedResults.NoContent() + : result.ToProblemDetails(); + } + + private static async Task Delete( + Guid id, + ISender sender, + CancellationToken cancellationToken) + { + var command = new DeleteRestaurantCommand(id); + + var result = await sender.Send(command, cancellationToken); + + return result.IsSuccess + ? TypedResults.NoContent() + : result.ToProblemDetails(); + } + + private static async Task AddTable( + Guid id, + AddTableQuery query, + ISender sender, + CancellationToken cancellationToken) + { + var command = new AddTableCommand( + id, + query.NumberOfSeats, + query.TableGroup); + + var result = await sender.Send(command, cancellationToken); + + return result.IsSuccess + ? TypedResults.Created($"/restaurants/{id}/tables/{result.Value.Id}", result.Value) + : result.ToProblemDetails(); + } + + private static async Task UpdateTable( + Guid restaurantId, + Guid tableId, + UpdateTableQuery query, + ISender sender, + CancellationToken cancellationToken) + { + var command = new UpdateTableCommand( + restaurantId, + tableId, + query.NumberOfSeats, + query.GroupName); + + var result = await sender.Send(command, cancellationToken); + + return result.IsSuccess + ? TypedResults.NoContent() + : result.ToProblemDetails(); + } + + private static async Task DeleteTable( + Guid restaurantId, + Guid tableId, + ISender sender, + CancellationToken cancellationToken) + { + var command = new DeleteTableCommand( + restaurantId, + tableId); + + var result = await sender.Send(command, cancellationToken); + + return result.IsSuccess + ? TypedResults.NoContent() + : result.ToProblemDetails(); + } + + private static async Task GetTable( + Guid restaurantId, + Guid tableId, + ISender sender, + CancellationToken cancellationToken) + { + var command = new GetTableQuery(restaurantId, tableId); + + var result = await sender.Send(command, cancellationToken); + + return result.IsSuccess + ? TypedResults.Ok(result.Value) + : result.ToProblemDetails(); + } + + private static async Task GetTables( + Guid restaurantId, + int? page, + int? pageSize, + string? sortBy, + ISender sender, + CancellationToken cancellationToken) + { + var query = new GetAllTablesByRestaurantQuery(restaurantId, page ?? 1, pageSize ?? 10, sortBy); + + var result = await sender.Send(query, cancellationToken); + + return result.IsSuccess + ? TypedResults.Ok(result.Value) + : result.ToProblemDetails(); + } + + private static async Task GetTablesByGroupName( + Guid restaurantId, + string tableGroupName, + int? page, + int? pageSize, + string? sortBy, + ISender sender, + CancellationToken cancellationToken) + { + var query = new GetAllTablesByTableGroupQuery( + restaurantId, + tableGroupName, + page ?? 1, + pageSize ?? 10, + sortBy); + + var result = await sender.Send(query, cancellationToken); + + return result.IsSuccess + ? TypedResults.Ok(result.Value) + : result.ToProblemDetails(); + } + + private static async Task AddTableGroup( + Guid restaurantId, + AddTableGroupQuery query, + ISender sender, + CancellationToken cancellationToken) + { + var command = new AddTableGroupCommand( + restaurantId, + query.Name, + query.NumberOfTables, + query.NumberOfSeats); + + var result = await sender.Send(command, cancellationToken); + + return result.IsSuccess + ? TypedResults.Created($"{restaurantId}/tablegroups/{result.Value.Id}", result.Value) + : result.ToProblemDetails(); + } + + private static async Task UpdateTableGroup( + Guid restaurantId, + Guid tableGroupId, + UpdateTableGroupQuery query, + ISender sender, + CancellationToken cancellationToken) + { + var command = new UpdateTableGroupCommand( + restaurantId, + tableGroupId, + query.GroupName, + query.NumberOfTablesToAdd, + query.NumberOfSeats); + + var result = await sender.Send(command, cancellationToken); + + return result.IsSuccess + ? TypedResults.NoContent() + : result.ToProblemDetails(); + } + + private static async Task DeleteTableGroup( + Guid restaurantId, + Guid tableGroupId, + ISender sender, + CancellationToken cancellationToken) + { + var command = new DeleteTableGroupCommand( + restaurantId, + tableGroupId); + + var result = await sender.Send(command, cancellationToken); + + return result.IsSuccess + ? TypedResults.NoContent() + : result.ToProblemDetails(); + } + + private static async Task GetTableGroup( + Guid restaurantId, + Guid tableGroupId, + ISender sender, + CancellationToken cancellationToken) + { + var command = new GetTableGroupQuery( + restaurantId, + tableGroupId); + + var result = await sender.Send(command, cancellationToken); + + return result.IsSuccess + ? TypedResults.Ok(result.Value) + : result.ToProblemDetails(); + } + + private static async Task GetTableGroups( + Guid restaurantId, + int? page, + int? pageSize, + string? sortBy, + string? name, + ISender sender, + CancellationToken cancellationToken) + { + var command = new GetAllTableGroupsQuery( + restaurantId, + page ?? 1, + pageSize ?? 10, + sortBy, + name); + + var result = await sender.Send(command, cancellationToken); + + return result.IsSuccess + ? TypedResults.Ok(result.Value) + : result.ToProblemDetails(); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Api/Extensions/ResultExtensions.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Api/Extensions/ResultExtensions.cs new file mode 100755 index 0000000..ef1b860 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Api/Extensions/ResultExtensions.cs @@ -0,0 +1,90 @@ +using RestaurantReservation.Domain.Common; + +namespace RestaurantReservation.Api.Extensions; + +public static class ResultExtensions +{ + public static IResult ToProblemDetails(this Result result) + { + result.CheckResult(); + + var errorType = ErrorType.None; + var description = string.Empty; + var errorCode = string.Empty; + + if (result.Errors is not null && result.Errors.Count > 0) + { + errorType = result.Errors[0].ErrorType; + description = result.Errors[0].Description; + errorCode = result.Errors[0].Code; + } + else + { + errorType = result.Error.ErrorType; + description = result.Error.Description; + errorCode = result.Error.Code; + } + + (int statusCode, string title) = MapErrorType(errorType); + + return Results.Problem( + statusCode: statusCode, + title: title, + detail: description, + extensions: new Dictionary + { + ["errorCode"] = errorCode + }); + } + + public static IResult ToProblemDetails(this Result result) + { + result.CheckResult(); + + var errorType = ErrorType.None; + var description = string.Empty; + var errorCode = string.Empty; + + if (result.Errors is not null && result.Errors.Count > 0) + { + errorType = result.Errors[0].ErrorType; + description = result.Errors[0].Description; + errorCode = result.Errors[0].Code; + } + else + { + errorType = result.Error.ErrorType; + description = result.Error.Description; + errorCode = result.Error.Code; + } + + (int statusCode, string title) = MapErrorType(errorType); + + return Results.Problem( + statusCode: statusCode, + title: title, + detail: description, + extensions: new Dictionary { ["errorCode"] = errorCode }); + } + + + private static (int StatusCode, string Title) MapErrorType(ErrorType type) => type switch + { + ErrorType.Validation => (StatusCodes.Status400BadRequest, "Validation Error"), + ErrorType.BadRequest => (StatusCodes.Status400BadRequest, "Bad Request"), + ErrorType.NotFound => (StatusCodes.Status404NotFound, "Not Found"), + ErrorType.Conflict => (StatusCodes.Status409Conflict, "Conflict"), + ErrorType.Unauthorized => (StatusCodes.Status401Unauthorized, "Unauthorized"), + ErrorType.Forbidden => (StatusCodes.Status403Forbidden, "Forbidden"), + ErrorType.CapacityExceeded => (StatusCodes.Status400BadRequest, "Bad Request: Capacity Exceeded"), + _ => (StatusCodes.Status500InternalServerError, "Internal Server Error") + }; + + private static void CheckResult(this Result result) + { + if (result.IsSuccess || result.Error == DomainError.None) + { + throw new InvalidOperationException("Cannot convert a successful result to a problem detail"); + } + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Api/Handlers/GlobalExceptionHandler.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Api/Handlers/GlobalExceptionHandler.cs new file mode 100755 index 0000000..100233e --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Api/Handlers/GlobalExceptionHandler.cs @@ -0,0 +1,66 @@ +using FluentValidation; + +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Mvc; + +namespace RestaurantReservation.Api.Handlers; + +public class GlobalExceptionHandler( + ILogger logger, + IProblemDetailsService problemDetailsService) : IExceptionHandler +{ + public async ValueTask TryHandleAsync( + HttpContext httpContext, + Exception exception, + CancellationToken cancellationToken) + { + logger.LogError( + exception, + "Unhandled exception occurred. TraceId: {TraceId}", + httpContext.TraceIdentifier); + + (int statusCode, string title) = MapException(exception); + + httpContext.Response.StatusCode = statusCode; + + var problemDetails = new ProblemDetails + { + Status = statusCode, + Type = GetProblemType(statusCode), + Title = title, + Detail = httpContext.RequestServices + .GetRequiredService() + .IsDevelopment() + ? exception.Message + : "An unexpected error has occurred", + Instance = httpContext.Request.Path, + Extensions = { ["traceId"] = httpContext.TraceIdentifier, ["timestamp"] = DateTime.UtcNow } + }; + + return await problemDetailsService.TryWriteAsync(new ProblemDetailsContext + { + HttpContext = httpContext, ProblemDetails = problemDetails + }); + } + + private static (int StatusCode, string Title) MapException(Exception exception) => exception switch + { + ValidationException => (StatusCodes.Status400BadRequest, "Validation error occurred"), + ArgumentNullException => (StatusCodes.Status400BadRequest, "Invalid argument provided"), + ArgumentException => (StatusCodes.Status400BadRequest, "Invalid argument provided"), + InvalidOperationException => (StatusCodes.Status400BadRequest, "Invalid operation attempted"), + UnauthorizedAccessException => (StatusCodes.Status401Unauthorized, "Unauthorized"), + BadHttpRequestException => (StatusCodes.Status400BadRequest, "Invalid Http Request"), + _ => (StatusCodes.Status500InternalServerError, "An unexpected error occurred") + }; + + private static string GetProblemType(int statusCode) => statusCode switch + { + 400 => "https://tools.ietf.org/html/rfc9110#section-15.5.1", + 401 => "https://tools.ietf.org/html/rfc9110#section-15.5.2", + 403 => "https://tools.ietf.org/html/rfc9110#section-15.5.4", + 404 => "https://tools.ietf.org/html/rfc9110#section-15.5.5", + 409 => "https://tools.ietf.org/html/rfc9110#section-15.5.10", + _ => "https://tools.ietf.org/html/rfc9110#section-15.6.1" + }; +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Api/Program.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Api/Program.cs new file mode 100755 index 0000000..4c53d4a --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Api/Program.cs @@ -0,0 +1,80 @@ +#pragma warning disable EXTEXP0018 + +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; + +using RestaurantReservation.Api; +using RestaurantReservation.Api.Endpoints; +using RestaurantReservation.Application; +using RestaurantReservation.Domain.Users; +using RestaurantReservation.Infrastructure; +using RestaurantReservation.Infrastructure.Persistence; +using RestaurantReservation.Infrastructure.Persistence.Seeding; + +using Scalar.AspNetCore; + +using Serilog; + +Log.Logger = new LoggerConfiguration() + .WriteTo.Console() + .CreateBootstrapLogger(); + +try +{ + var builder = WebApplication.CreateBuilder(args); + + builder.Services.AddPresentation(builder.Configuration) + .AddInfrastructure(builder.Configuration) + .AddApplication(); + + var app = builder.Build(); + + app.UseStatusCodePages(); + app.UseSerilogRequestLogging(options => + { + options.EnrichDiagnosticContext = (diagnosticContext, httpContext) => + { + diagnosticContext.Set("RequestHost", httpContext.Request.Host.Value); + diagnosticContext.Set("UserAgent", httpContext.Request.Headers.UserAgent.ToString()); + }; + }); + + app.UseExceptionHandler(); + app.UseHttpsRedirection(); + app.UseAuthentication(); + app.UseAuthorization(); + + if (app.Environment.IsDevelopment() || app.Environment.IsEnvironment("Docker")) + { + app.MapOpenApi(); + app.MapScalarApiReference(options => + { + options.WithTitle("Restaurant Reservation API"); + options.WithTheme(ScalarTheme.BluePlanet); + options.WithDefaultHttpClient(ScalarTarget.Shell, ScalarClient.Curl); + }); + } + + using (var scope = app.Services.CreateScope()) + { + var userManager = scope.ServiceProvider.GetRequiredService>(); + var roleManager = scope.ServiceProvider.GetRequiredService>(); + var context = scope.ServiceProvider.GetRequiredService(); + + await context.Database.MigrateAsync(); + await UserAndRoleSeeder.SeedUsersAndRolesAsync(userManager, roleManager); + } + + app.MapAuthEndpoints(); + app.MapRestaurantEndpoints(); + app.MapReservationEndpoints(); + app.Run(); +} +catch (Exception ex) +{ + Log.Fatal(ex, "Server terminated unexpectedly"); +} +finally +{ + Log.CloseAndFlush(); +} diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Api/Properties/launchSettings.json b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Api/Properties/launchSettings.json new file mode 100755 index 0000000..fd72725 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Api/Properties/launchSettings.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "scalar/v1", + "applicationUrl": "http://localhost:5001", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "scalar/v1", + "applicationUrl": "https://localhost:7001;http://localhost:5001", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Api/RestaurantReservation.Api.csproj b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Api/RestaurantReservation.Api.csproj new file mode 100755 index 0000000..41fcf25 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Api/RestaurantReservation.Api.csproj @@ -0,0 +1,31 @@ + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + net10.0 + enable + enable + + + diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Api/appsettings.Development.json b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Api/appsettings.Development.json new file mode 100755 index 0000000..0c208ae --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Api/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Api/appsettings.Docker.json b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Api/appsettings.Docker.json new file mode 100644 index 0000000..dd9cc68 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Api/appsettings.Docker.json @@ -0,0 +1,9 @@ +{ + "Seq": { + "ServerUrl" : "http://seq:5341" + }, + "Smtp": { + "Host": "mailpit", + "Port": 1025 + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Api/appsettings.json b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Api/appsettings.json new file mode 100755 index 0000000..e1148db --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Api/appsettings.json @@ -0,0 +1,64 @@ +{ + "Serilog": { + "Using": [ + "Serilog.Sinks.Console", + "Serilog.Sinks.File", + "Serilog.Sinks.Seq", + "Serilog.Enrichers.Environment", + "Serilog.Enrichers.Thread", + "Serilog.Enrichers.Process", + "Serilog.Exceptions" + ], + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft": "Warning", + "Microsoft.AspNetCore.Hosting.Diagnostics": "Error", + "Microsoft.Hosting.Lifetime": "Information", + "Microsoft.EntityFrameworkCore.Database.Command": "Information", + "System.Net.HttpClient": "Information" + } + }, + "WriteTo": [ + { "Name": "Console" }, + { + "Name": "File", + "Args": { + "path": "logs/log-.json", + "rollingInterval": "Day", + "rollOnFileSizeLimit": true, + "fileSizeLimitBytes": 104857600, + "retainedFileCountLimit": 14, + "formatter": "Serilog.Formatting.Compact.CompactJsonFormatter, Serilog.Formatting.Compact" + } + }, + { + "Name": "Seq", + "Args": { "serverUrl": "http://localhost:5341" } + } + ], + "Enrich": [ + "FromLogContext", + "WithMachineName", + "WithProcessId", + "WithThreadId", + "WithExceptionDetails" + ] + }, + "AllowedHosts": "*", + "ConnectionStrings": { + "DefaultConnection": "Host=localhost;Port=5432;Database=reservationDB;Username=postgres;Password=postgres", + "Redis": "localhost:6379,password=redis,abortConnect=false" + }, + "JwtSettings": { + "Key": "sjdasi872323232/-asd0adaskdia2nkcxozwlpp8761132192efmcoadsuoadnamaca76-034342432adas_109343432", + "Issuer": "https://my-restaurant.com", + "Audience": "https://localhost:7001", + "ExpiryInMinutes": 30, + "RefreshTokenExpirationInDays": 7 + }, + "Smtp": { + "Host": "localhost", + "Port": 1025 + } +} diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Abstractions/IApplicationDbContext.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Abstractions/IApplicationDbContext.cs new file mode 100755 index 0000000..a3b39d6 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Abstractions/IApplicationDbContext.cs @@ -0,0 +1,19 @@ +using Microsoft.EntityFrameworkCore; + +using RestaurantReservation.Domain.Reservations; +using RestaurantReservation.Domain.Restaurants; +using RestaurantReservation.Domain.Tables; +using RestaurantReservation.Domain.Users; + +namespace RestaurantReservation.Application.Abstractions; + +public interface IApplicationDbContext +{ + DbSet Restaurants { get; } + DbSet Tables { get; } + DbSet TableGroups { get; } + DbSet Reservations { get; } + DbSet RefreshTokens { get; } + DbSet ReservationTables { get; } + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Abstractions/ICurrentUser.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Abstractions/ICurrentUser.cs new file mode 100755 index 0000000..d1ecdeb --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Abstractions/ICurrentUser.cs @@ -0,0 +1,9 @@ +namespace RestaurantReservation.Application.Abstractions; + +public interface ICurrentUser +{ + string? UserId { get; } + string? Email { get; } + string? Name { get; } + bool IsAuthenticated { get; } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Abstractions/IEmailService.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Abstractions/IEmailService.cs new file mode 100755 index 0000000..fa0a043 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Abstractions/IEmailService.cs @@ -0,0 +1,8 @@ +using RestaurantReservation.Application.Features; + +namespace RestaurantReservation.Application.Abstractions; + +public interface IEmailService +{ + Task SendEmailAsync(EmailInfo emailInfo, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Abstractions/ISmtpClientFactory.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Abstractions/ISmtpClientFactory.cs new file mode 100755 index 0000000..86f8338 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Abstractions/ISmtpClientFactory.cs @@ -0,0 +1,8 @@ +using MailKit.Net.Smtp; + +namespace RestaurantReservation.Application.Abstractions; + +public interface ISmtpClientFactory +{ + ISmtpClient Create(); +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Abstractions/ITokenService.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Abstractions/ITokenService.cs new file mode 100755 index 0000000..51f5beb --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Abstractions/ITokenService.cs @@ -0,0 +1,17 @@ +using RestaurantReservation.Application.Features.Auth.Command.Login; +using RestaurantReservation.Domain.Common; +using RestaurantReservation.Domain.Users; + +namespace RestaurantReservation.Application.Abstractions; + +public interface ITokenService +{ + Task> CreateAuthenticationTokensAsync( + ApplicationUser user, + IEnumerable roles, + CancellationToken cancellationToken = default); + + Result CreateRefreshToken(); + + Task RevokeRefreshTokenAsync(string refreshToken, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Abstractions/SmtpOptions.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Abstractions/SmtpOptions.cs new file mode 100644 index 0000000..893f2e5 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Abstractions/SmtpOptions.cs @@ -0,0 +1,7 @@ +namespace RestaurantReservation.Application.Abstractions; + +public class SmtpOptions +{ + public string Host { get; set; } = string.Empty; + public int Port { get; set; } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Behaviors/ValidationBehavior.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Behaviors/ValidationBehavior.cs new file mode 100755 index 0000000..401801b --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Behaviors/ValidationBehavior.cs @@ -0,0 +1,36 @@ +using FluentValidation; + +using MediatR; + +using ValidationException = FluentValidation.ValidationException; + +namespace RestaurantReservation.Application.Behaviors; + +public class ValidationBehavior(IEnumerable> validators) + : IPipelineBehavior where TRequest : class +{ + public async Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken) + { + if (validators.Any()) + { + var context = new ValidationContext(request); + + var results = await Task.WhenAll(validators.Select(v => v.ValidateAsync(context, cancellationToken))); + + var failures = results + .SelectMany(r => r.Errors) + .Where(f => f is not null) + .ToList(); + + if (failures.Count > 0) + { + throw new ValidationException(failures); + } + } + + return await next(cancellationToken); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/DependencyInjection.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/DependencyInjection.cs new file mode 100755 index 0000000..1fa8ec2 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/DependencyInjection.cs @@ -0,0 +1,23 @@ +using FluentValidation; + +using Microsoft.Extensions.DependencyInjection; + +using RestaurantReservation.Application.Behaviors; + +namespace RestaurantReservation.Application; + +public static class DependencyInjection +{ + public static IServiceCollection AddApplication(this IServiceCollection services) + { + services.AddValidatorsFromAssembly(typeof(DependencyInjection).Assembly, includeInternalTypes: true); + + services.AddMediatR(config => + { + config.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly); + config.AddOpenBehavior(typeof(ValidationBehavior<,>)); + }); + + return services; + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Extensions/QueryableExtensions.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Extensions/QueryableExtensions.cs new file mode 100755 index 0000000..afff5be --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Extensions/QueryableExtensions.cs @@ -0,0 +1,114 @@ +using System.Linq.Dynamic.Core; +using System.Reflection; + +using RestaurantReservation.Domain.Reservations; + +namespace RestaurantReservation.Application.Extensions; + +public static class QueryableExtensions +{ + public static IQueryable ApplyPagination( + this IQueryable query, + int pageNumber, + int pageSize) + { + return query + .Skip((pageNumber - 1) * pageSize) + .Take(pageSize); + } + + public static IQueryable ApplySort(this IQueryable query, string? sortBy) where T : class + { + if (string.IsNullOrWhiteSpace(sortBy)) return query; + + var sortExpressions = new List(); + + foreach (var part in sortBy.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + var tokens = part.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (tokens.Length == 0) continue; + + var propertyPath = tokens[0]; + + if (!IsValidPropertyPath(typeof(T), propertyPath)) continue; + + var direction = tokens.Length > 1 && tokens[1].Equals("desc", StringComparison.OrdinalIgnoreCase) + ? "descending" + : "ascending"; + + sortExpressions.Add($"{propertyPath} {direction}"); + } + + return sortExpressions.Count > 0 + ? query.OrderBy(string.Join(", ", sortExpressions)) + : query; + } + + public static IQueryable ApplySearch(this IQueryable query, string? search) where T : class + { + if (string.IsNullOrWhiteSpace(search)) return query; + + var searchTrimmed = search.Trim(); + + var isDate = DateOnly.TryParse(searchTrimmed, out var parsedData); + var isInt = int.TryParse(searchTrimmed, out var parsedInt); + var isStatusEnum = Enum.TryParse(searchTrimmed, out var parsedStatusEnum); + + var allProperties = typeof(T) + .GetProperties(BindingFlags.Public | BindingFlags.Instance); + var searchExpressions = new List(); + var parameters = new List(); + + foreach (var property in allProperties) + { + if (property.PropertyType == typeof(string)) + { + var paramIndex = parameters.Count; + searchExpressions.Add($"{property.Name}.Contains(@{paramIndex})"); + parameters.Add(searchTrimmed); + } + else if (property.PropertyType == typeof(DateOnly) && isDate) + { + var paramIndex = parameters.Count; + searchExpressions.Add($"{property.Name} == @{paramIndex}"); + parameters.Add(parsedData); + } + else if (property.PropertyType == typeof(int) && isInt) + { + var paramIndex = parameters.Count; + searchExpressions.Add($"{property.Name} == @{paramIndex}"); + parameters.Add(parsedInt); + } + else if (property.PropertyType == typeof(ReservationStatus) && isStatusEnum) + { + var paramIndex = parameters.Count; + searchExpressions.Add($"{property.Name} == @{paramIndex}"); + parameters.Add(parsedStatusEnum); + } + } + + if (searchExpressions.Count == 0) return query; + + var dynamicWhereClause = string.Join(" || ", searchExpressions); + + return query.Where(dynamicWhereClause, [.. parameters]); + } + + private static bool IsValidPropertyPath(Type type, string path) + { + var parts = path.Split('.', StringSplitOptions.RemoveEmptyEntries); + var currentType = type; + + foreach (var part in parts) + { + var prop = currentType.GetProperties(BindingFlags.Public | BindingFlags.Instance) + .FirstOrDefault(p => p.Name.Equals(part, StringComparison.OrdinalIgnoreCase)); + + if (prop is null) return false; + + currentType = prop.PropertyType; + } + + return true; + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Auth/Command/Login/LoginCommand.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Auth/Command/Login/LoginCommand.cs new file mode 100755 index 0000000..46ef6e8 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Auth/Command/Login/LoginCommand.cs @@ -0,0 +1,7 @@ +using MediatR; + +using RestaurantReservation.Domain.Common; + +namespace RestaurantReservation.Application.Features.Auth.Command.Login; + +public record LoginCommand(string Email, string Password) : IRequest>; \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Auth/Command/Login/LoginCommandHandler.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Auth/Command/Login/LoginCommandHandler.cs new file mode 100755 index 0000000..7e696b9 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Auth/Command/Login/LoginCommandHandler.cs @@ -0,0 +1,55 @@ +using MediatR; + +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Logging; + +using RestaurantReservation.Application.Abstractions; +using RestaurantReservation.Domain.Common; +using RestaurantReservation.Domain.Users; +using RestaurantReservation.Domain.Users.Errors; + +namespace RestaurantReservation.Application.Features.Auth.Command.Login; + +public sealed class LoginCommandHandler( + UserManager userManager, + SignInManager signInManager, + ILogger logger, + ITokenService tokenService) : IRequestHandler> +{ + public async Task> Handle( + LoginCommand command, + CancellationToken cancellationToken) + { + var user = await userManager.FindByEmailAsync(command.Email); + + if (user is null) + { + logger.LogWarning("User {Email} not found", command.Email); + return Result.Failure(UserErrors.UserNotFound); + } + + var checkPassword = await signInManager.CheckPasswordSignInAsync( + user, + command.Password, + false); + + if (!checkPassword.Succeeded) + { + logger.LogWarning("User {Email} attempted to login with invalid credentials", command.Email); + return Result.Failure(LoginErrors.InvalidCredentials); + } + + var roles = await userManager.GetRolesAsync(user); + + var response = await tokenService.CreateAuthenticationTokensAsync( + user, + roles, + cancellationToken); + + logger.LogInformation("({Email}) has logged into the system at {Utc}", + command.Email, + DateTime.UtcNow); + + return Result.Success(response.Value); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Auth/Command/Login/LoginCommandValidator.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Auth/Command/Login/LoginCommandValidator.cs new file mode 100755 index 0000000..0211592 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Auth/Command/Login/LoginCommandValidator.cs @@ -0,0 +1,21 @@ +using FluentValidation; + +namespace RestaurantReservation.Application.Features.Auth.Command.Login; + +internal sealed class LoginCommandValidator : AbstractValidator +{ + public LoginCommandValidator() + { + RuleFor(x => x.Email) + .NotEmpty() + .WithMessage("{PropertyName} cannot be empty") + .EmailAddress() + .WithMessage("{PropertyName} is not a valid email address."); + + RuleFor(x => x.Password) + .NotEmpty() + .WithMessage("{PropertyName} cannot be empty") + .MinimumLength(8) + .WithMessage("Password must be at least 8 characters long."); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Auth/Command/Login/LoginResponse.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Auth/Command/Login/LoginResponse.cs new file mode 100755 index 0000000..74cc08f --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Auth/Command/Login/LoginResponse.cs @@ -0,0 +1,7 @@ +namespace RestaurantReservation.Application.Features.Auth.Command.Login; + +public record LoginResponse( + string AccessToken, + string RefreshToken, + DateTime AccessTokenExpiresAtUtc, + DateTime RefreshTokenExpiresAtUtc); \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Auth/Command/Logout/LogoutCommand.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Auth/Command/Logout/LogoutCommand.cs new file mode 100755 index 0000000..3b2d20a --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Auth/Command/Logout/LogoutCommand.cs @@ -0,0 +1,7 @@ +using MediatR; + +using RestaurantReservation.Domain.Common; + +namespace RestaurantReservation.Application.Features.Auth.Command.Logout; + +public record LogoutCommand(string RefreshToken) : IRequest; \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Auth/Command/Logout/LogoutCommandHandler.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Auth/Command/Logout/LogoutCommandHandler.cs new file mode 100755 index 0000000..ddec637 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Auth/Command/Logout/LogoutCommandHandler.cs @@ -0,0 +1,40 @@ +using MediatR; + +using Microsoft.Extensions.Logging; + +using RestaurantReservation.Application.Abstractions; +using RestaurantReservation.Domain.Common; + +namespace RestaurantReservation.Application.Features.Auth.Command.Logout; + +public sealed class LogoutCommandHandler( + ITokenService tokenService, + ICurrentUser currentUser, + ILogger logger) : IRequestHandler +{ + public async Task Handle( + LogoutCommand command, + CancellationToken cancellationToken) + { + var currentUserId = currentUser.UserId; + var currentUserEmail = currentUser.Email; + var currentUserName = currentUser.Name; + + var result = await tokenService.RevokeRefreshTokenAsync(command.RefreshToken, cancellationToken); + + if (result.IsFailure) + { + logger.LogWarning("{Name} ({Email}) with id {Id} attempted to logout with a revoked or fake refresh token", + currentUserName, + currentUserEmail, + currentUserId); + return result; + } + + logger.LogInformation("{Name} ({Email}) has logged out of the system at {Utc}", + currentUserName, + currentUserEmail, + DateTime.UtcNow); + return Result.Success(); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Auth/Command/Register/RegisterUserCommand.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Auth/Command/Register/RegisterUserCommand.cs new file mode 100755 index 0000000..1eee821 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Auth/Command/Register/RegisterUserCommand.cs @@ -0,0 +1,13 @@ +using MediatR; + +using RestaurantReservation.Domain.Common; + +namespace RestaurantReservation.Application.Features.Auth.Command.Register; + +public record RegisterUserCommand( + string FirstName, + string LastName, + string EmailAddress, + string PhoneNumber, + string Password, + string ConfirmPassword) : IRequest; \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Auth/Command/Register/RegisterUserCommandHandler.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Auth/Command/Register/RegisterUserCommandHandler.cs new file mode 100755 index 0000000..10091fb --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Auth/Command/Register/RegisterUserCommandHandler.cs @@ -0,0 +1,78 @@ +using MediatR; + +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Logging; + +using RestaurantReservation.Domain.Common; +using RestaurantReservation.Domain.Users; +using RestaurantReservation.Domain.Users.Errors; + +namespace RestaurantReservation.Application.Features.Auth.Command.Register; + +public sealed class RegisterUserCommandHandler( + UserManager userManager, + ILogger logger) : IRequestHandler +{ + public async Task Handle( + RegisterUserCommand command, + CancellationToken cancellationToken) + { + var existingUser = await userManager + .FindByEmailAsync(command.EmailAddress); + + if (existingUser is not null) + { + logger.LogWarning("An attempt was made to register a new account with email: {Email}. This email is already registered with an account in the system", + command.EmailAddress); + return Result.Failure(RegistrationErrors.EmailAlreadyInUse(command.EmailAddress)); + } + + var userToAdd = new ApplicationUser + { + FirstName = command.FirstName, + LastName = command.LastName, + PhoneNumber = command.PhoneNumber, + Email = command.EmailAddress, + UserName = command.EmailAddress, + RegistrationDate = new DateOnly( + DateTime.Now.Year, + DateTime.Now.Month, + DateTime.Now.Day) + }; + + var userAddedResult = await userManager.CreateAsync(userToAdd, command.Password); + + if (!userAddedResult.Succeeded) + { + var errors = userAddedResult.Errors.Select(e => new DomainError( + e.Code, + e.Description, + ErrorType.BadRequest)) + .ToList(); + logger.LogError("Unable to add user {Email}: {FirstError}", + command.EmailAddress, + errors[0].Description); + + return Result.Failure(errors); + } + + var userAddedAsCustomerResult = await userManager.AddToRoleAsync(userToAdd, Roles.Customer); + + if (!userAddedAsCustomerResult.Succeeded) + { + var errors = userAddedAsCustomerResult.Errors.Select(e => new DomainError( + e.Code, + e.Description, + ErrorType.BadRequest)) + .ToList(); + logger.LogError("Unable to add user {Email} to the {Role} role: {FirstError}", + command.EmailAddress, + Roles.Customer, + errors[0].Description); + + return Result.Failure(errors); + } + + return Result.Success(); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Auth/Command/Register/RegisterUserCommandValidator.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Auth/Command/Register/RegisterUserCommandValidator.cs new file mode 100755 index 0000000..39cf138 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Auth/Command/Register/RegisterUserCommandValidator.cs @@ -0,0 +1,73 @@ +using System.Text.RegularExpressions; + +using FluentValidation; + +namespace RestaurantReservation.Application.Features.Auth.Command.Register; + +internal sealed partial class RegisterUserCommandValidator : AbstractValidator +{ + public RegisterUserCommandValidator() + { + RuleFor(x => x.FirstName) + .NotEmpty() + .WithMessage("{PropertyName} cannot be empty") + .MaximumLength(50) + .WithMessage("{PropertyName} cannot exceed 50 characters"); + + RuleFor(x => x.LastName) + .NotEmpty() + .WithMessage("{PropertyName} cannot be empty") + .MaximumLength(50) + .WithMessage("{PropertyName} cannot exceed 50 characters"); + + RuleFor(x => x.EmailAddress) + .NotEmpty() + .WithMessage("{PropertyName} cannot be empty") + .MaximumLength(50) + .EmailAddress() + .WithMessage("{PropertyName} is not in a valid format"); + + RuleFor(x => x.PhoneNumber) + .NotEmpty() + .WithMessage("{PropertyName} cannot be empty") + .Must(IsValidTelephoneNumber) + .WithMessage("{PropertyName} is not in a valid telephone number format"); + + RuleFor(x => x.Password) + .NotEmpty() + .WithMessage("{PropertyName} cannot be empty") + .MinimumLength(8) + .Must(IsValidPassword) + .WithMessage("{PropertyName} is not a valid password"); + + RuleFor(x => x.ConfirmPassword) + .NotEmpty() + .WithMessage("{PropertyName} cannot be empty") + .Equal(x => x.Password) + .WithMessage("Passwords do not match"); + } + + private static bool IsValidTelephoneNumber(string phoneNumber) + { + return PhoneNumberRegex().IsMatch(phoneNumber); + } + + private static bool IsValidPassword(string password) + { + var hasUpper = false; + var hasLower = false; + var hasNumeric = false; + + foreach (var letter in password) + { + if (Char.IsUpper(letter)) hasUpper = true; + if (Char.IsLower(letter)) hasLower = true; + if (Char.IsNumber(letter)) hasNumeric = true; + } + + return hasUpper && hasLower && hasNumeric; + } + + [GeneratedRegex(@"^[\+]?[1-9]?[\d\s\-\(\)\.]{10,15}$")] + private static partial Regex PhoneNumberRegex(); +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Auth/Notifications/RegistrationSuccessfulEventHandler.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Auth/Notifications/RegistrationSuccessfulEventHandler.cs new file mode 100755 index 0000000..6c8b4c4 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Auth/Notifications/RegistrationSuccessfulEventHandler.cs @@ -0,0 +1,42 @@ +using MediatR; + +using Microsoft.Extensions.Logging; + +using RestaurantReservation.Application.Abstractions; +using RestaurantReservation.Application.Features.Constants; +using RestaurantReservation.Domain.Users.Events; + +namespace RestaurantReservation.Application.Features.Auth.Notifications; + +public sealed class RegistrationSuccessfulEventHandler( + IEmailService emailService, + ILogger logger) : INotificationHandler +{ + public async Task Handle( + RegistrationSuccessfulEvent notification, + CancellationToken cancellationToken) + { + logger.LogInformation("New customer {FName} {LName} ({Email}) registered in the system", + notification.FirstName, + notification.LastName, + notification.Email); + + var body = $"Your account has been successfully registered on {notification.RegistrationDate}. " + + $"Your username is {notification.Email}. " + + $"You are now able to schedule a reservation with any one of the restaurants in our system. " + + $"Thank you for joining our service. Have a great day!"; + var name = $"{notification.FirstName} {notification.LastName}"; + const string subject = "Registration Successful!"; + const string sender = "Restaurant Reservation Service"; + + var emailInfo = new EmailInfo( + sender, + name, + notification.Email, + NotificationConstants.EmailSender, + subject, + body); + + await emailService.SendEmailAsync(emailInfo, cancellationToken); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Constants/Keys.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Constants/Keys.cs new file mode 100755 index 0000000..6e12aec --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Constants/Keys.cs @@ -0,0 +1,9 @@ +namespace RestaurantReservation.Application.Features.Constants; + +public static class Keys +{ + public static string Restaurants => "restaurants"; + public static string Reservations => "reservations"; + public static string Tables => "tables"; + public static string TableGroups => "tableGroups"; +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Constants/NotificationConstants.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Constants/NotificationConstants.cs new file mode 100755 index 0000000..002be27 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Constants/NotificationConstants.cs @@ -0,0 +1,6 @@ +namespace RestaurantReservation.Application.Features.Constants; + +public static class NotificationConstants +{ + public static string EmailSender => "restaurant-reservations@example.com"; +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/EmailInfo.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/EmailInfo.cs new file mode 100755 index 0000000..b562d55 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/EmailInfo.cs @@ -0,0 +1,9 @@ +namespace RestaurantReservation.Application.Features; + +public record EmailInfo( + string SenderName, + string RecipientName, + string To, + string From, + string Subject, + string Body); \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Reservations/Command/Cancel/CancelReservationCommand.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Reservations/Command/Cancel/CancelReservationCommand.cs new file mode 100755 index 0000000..82bf2a9 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Reservations/Command/Cancel/CancelReservationCommand.cs @@ -0,0 +1,13 @@ +using MediatR; + +using RestaurantReservation.Application.Features.Reservations.Query.Responses; +using RestaurantReservation.Domain.Common; + +namespace RestaurantReservation.Application.Features.Reservations.Command.Cancel; + +public record CancelReservationCommand( + Guid ReservationId, + string RestaurantName, + DateOnly ReservationDate, + TimeOnly ReservationStartTime, + TimeOnly ReservationEndTime) : IRequest>; \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Reservations/Command/Cancel/CancelReservationCommandHandler.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Reservations/Command/Cancel/CancelReservationCommandHandler.cs new file mode 100755 index 0000000..dfffb42 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Reservations/Command/Cancel/CancelReservationCommandHandler.cs @@ -0,0 +1,97 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Hybrid; +using Microsoft.Extensions.Logging; + +using RestaurantReservation.Application.Abstractions; +using RestaurantReservation.Application.Features.Constants; +using RestaurantReservation.Application.Features.Reservations.Query.Mappings; +using RestaurantReservation.Application.Features.Reservations.Query.Responses; +using RestaurantReservation.Domain.Common; +using RestaurantReservation.Domain.Reservations.Errors; +using RestaurantReservation.Domain.Reservations.ValueObjects.ReservationValueObjects; +using RestaurantReservation.Domain.Tables.Errors; +using RestaurantReservation.Domain.Users.Errors; + +namespace RestaurantReservation.Application.Features.Reservations.Command.Cancel; + +public sealed class CancelReservationCommandHandler( + IApplicationDbContext context, + HybridCache cache, + ICurrentUser currentUser, + ILogger logger) : IRequestHandler> +{ + public async Task> Handle( + CancelReservationCommand command, + CancellationToken cancellationToken) + { + var customerId = currentUser.UserId; + var customerEmail = currentUser.Email ?? "email"; + + if (string.IsNullOrWhiteSpace(customerId)) + { + logger.LogWarning("Customer not found in the system, no reservation was canceled"); + return Result.Failure(UserErrors.UserNotFound); + } + + var date = new ReservationDate(command.ReservationDate); + var start = new ReservationStart(command.ReservationStartTime); + var end = new ReservationEnd(command.ReservationEndTime); + var restaurantName = await context.Restaurants + .Select(r => r.Name) + .FirstOrDefaultAsync(cancellationToken) ?? "restaurant"; + + var reservation = await context.Reservations + .FirstOrDefaultAsync(r => r.CustomerId == customerId + && r.Id == command.ReservationId + && r.ReservationInfo.Date == date + && r.ReservationInfo.StartTime == start + && r.ReservationInfo.EndTime == end, cancellationToken); + + if (reservation is null) + { + logger.LogWarning("Reservation with Id {Id} not found, no reservation was canceled", + command.ReservationId); + return Result.Failure( + ReservationErrors.ReservationNotFound(customerEmail, restaurantName)); + } + + var reservationCanceledResult = reservation.CancelReservation(); + + if (reservationCanceledResult.IsFailure) + { + logger.LogWarning("Unable to cancel reservation for {Email}: {Error}", + customerEmail, + reservationCanceledResult.Error.Description); + return Result.Failure(reservationCanceledResult.Error); + } + + var tablesFreed = await context.ReservationTables + .Where(rt => rt.ReservationId == command.ReservationId) + .ExecuteDeleteAsync(cancellationToken); + + if (tablesFreed <= 0) + { + logger.LogWarning("Unable to free the table(s) associated with reservation {Id} at restaurant {Name}", + reservation.Id, + restaurantName); + return Result.Failure(TableErrors.TableNotFreed); + } + + await context.SaveChangesAsync(cancellationToken); + + logger.LogInformation("Reservation for {Email} has been canceled. " + + "Invaliding cache for keys {Key} and {TKey}", + customerEmail, + Keys.Reservations, + Keys.Tables); + + await cache.RemoveByTagAsync(Keys.Reservations, cancellationToken); + await cache.RemoveByTagAsync(Keys.Tables, cancellationToken); + + var cancellationResponse = reservation.ToDetailResponse(restaurantName); + + return Result.Success(cancellationResponse); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Reservations/Command/Cancel/CancelReservationCommandValidator.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Reservations/Command/Cancel/CancelReservationCommandValidator.cs new file mode 100755 index 0000000..ac82f50 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Reservations/Command/Cancel/CancelReservationCommandValidator.cs @@ -0,0 +1,27 @@ +using FluentValidation; + +namespace RestaurantReservation.Application.Features.Reservations.Command.Cancel; + +internal sealed class CancelReservationCommandValidator : AbstractValidator +{ + public CancelReservationCommandValidator() + { + RuleFor(x => x.RestaurantName) + .NotEmpty() + .WithMessage("{PropertyName} cannot be empty") + .MaximumLength(50) + .WithMessage("{PropertyName} cannot exceed 50 characters"); + + RuleFor(x => x.ReservationDate) + .GreaterThanOrEqualTo(DateOnly.FromDateTime(DateTime.Today)) + .WithMessage("{PropertyName} cannot be in the past"); + + RuleFor(x => x.ReservationStartTime) + .LessThan(x => x.ReservationEndTime) + .WithMessage("{PropertyName} must come before the reservation end time"); + + RuleFor(x => x.ReservationEndTime) + .GreaterThan(x => x.ReservationStartTime) + .WithMessage("{PropertyName} must come after the reservation end time"); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Reservations/Command/Complete/CompleteReservationCommand.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Reservations/Command/Complete/CompleteReservationCommand.cs new file mode 100755 index 0000000..a043c64 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Reservations/Command/Complete/CompleteReservationCommand.cs @@ -0,0 +1,13 @@ +using MediatR; + +using RestaurantReservation.Application.Features.Reservations.Query.Responses; +using RestaurantReservation.Domain.Common; + +namespace RestaurantReservation.Application.Features.Reservations.Command.Complete; + +public record CompleteReservationCommand( + Guid ReservationId, + string RestaurantName, + DateOnly ReservationDate, + TimeOnly ReservationStartTime, + TimeOnly ReservationEndTime) : IRequest>; \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Reservations/Command/Complete/CompleteReservationCommandHandler.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Reservations/Command/Complete/CompleteReservationCommandHandler.cs new file mode 100755 index 0000000..b06ce50 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Reservations/Command/Complete/CompleteReservationCommandHandler.cs @@ -0,0 +1,97 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Hybrid; +using Microsoft.Extensions.Logging; + +using RestaurantReservation.Application.Abstractions; +using RestaurantReservation.Application.Features.Constants; +using RestaurantReservation.Application.Features.Reservations.Query.Mappings; +using RestaurantReservation.Application.Features.Reservations.Query.Responses; +using RestaurantReservation.Domain.Common; +using RestaurantReservation.Domain.Reservations.Errors; +using RestaurantReservation.Domain.Reservations.ValueObjects.ReservationValueObjects; +using RestaurantReservation.Domain.Tables.Errors; +using RestaurantReservation.Domain.Users.Errors; + +namespace RestaurantReservation.Application.Features.Reservations.Command.Complete; + +public sealed class CompleteReservationCommandHandler( + IApplicationDbContext context, + HybridCache cache, + ICurrentUser currentUser, + ILogger logger) : IRequestHandler> +{ + public async Task> Handle( + CompleteReservationCommand command, + CancellationToken cancellationToken) + { + var customerId = currentUser.UserId; + var customerEmail = currentUser.Email ?? "email"; + + if (string.IsNullOrWhiteSpace(customerId)) + { + logger.LogWarning("Customer not found in the system, no reservation was completed"); + return Result.Failure(UserErrors.UserNotFound); + } + + var date = new ReservationDate(command.ReservationDate); + var start = new ReservationStart(command.ReservationStartTime); + var end = new ReservationEnd(command.ReservationEndTime); + var restaurantName = await context.Restaurants + .Select(r => r.Name) + .FirstOrDefaultAsync(cancellationToken) ?? "restaurant"; + + var reservation = await context.Reservations + .FirstOrDefaultAsync(r => r.CustomerId == customerId + && r.Id == command.ReservationId + && r.ReservationInfo.Date == date + && r.ReservationInfo.StartTime == start + && r.ReservationInfo.EndTime == end, cancellationToken); + + if (reservation is null) + { + logger.LogWarning("Reservation with Id {Id} not found, no reservation was completed", + command.ReservationId); + return Result.Failure( + ReservationErrors.ReservationNotFound(customerEmail, restaurantName)); + } + + var reservationCompletedResult = reservation.CompleteReservation(); + + if (reservationCompletedResult.IsFailure) + { + logger.LogWarning("Unable to complete reservation for {Email}: {Error}", + customerEmail, + reservationCompletedResult.Error.Description); + return Result.Failure(reservationCompletedResult.Error); + } + + var tablesFreed = await context.ReservationTables + .Where(rt => rt.ReservationId == command.ReservationId) + .ExecuteDeleteAsync(cancellationToken); + + if (tablesFreed <= 0) + { + logger.LogWarning("Unable to free the table(s) associated with reservation {Id} at restaurant {Name}", + reservation.Id, + restaurantName); + return Result.Failure(TableErrors.TableNotFreed); + } + + await context.SaveChangesAsync(cancellationToken); + + logger.LogInformation("Reservation for {Email} has been completed. " + + "Invalidating cache for keys {Key} and {TKey}", + customerEmail, + Keys.Reservations, + Keys.Tables); + + await cache.RemoveByTagAsync(Keys.Reservations, cancellationToken); + await cache.RemoveByTagAsync(Keys.Tables, cancellationToken); + + var completionResponse = reservation.ToDetailResponse(restaurantName); + + return Result.Success(completionResponse); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Reservations/Command/Complete/CompleteReservationCommandValidator.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Reservations/Command/Complete/CompleteReservationCommandValidator.cs new file mode 100755 index 0000000..602f6c2 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Reservations/Command/Complete/CompleteReservationCommandValidator.cs @@ -0,0 +1,27 @@ +using FluentValidation; + +namespace RestaurantReservation.Application.Features.Reservations.Command.Complete; + +internal sealed class CompleteReservationCommandValidator : AbstractValidator +{ + public CompleteReservationCommandValidator() + { + RuleFor(x => x.RestaurantName) + .NotEmpty() + .WithMessage("{PropertyName} cannot be empty") + .MaximumLength(50) + .WithMessage("{PropertyName} cannot exceed 50 characters"); + + RuleFor(x => x.ReservationDate) + .GreaterThanOrEqualTo(DateOnly.FromDateTime(DateTime.Today)) + .WithMessage("{PropertyName} cannot be in the past"); + + RuleFor(x => x.ReservationStartTime) + .LessThan(x => x.ReservationEndTime) + .WithMessage("{PropertyName} must come before the reservation end time"); + + RuleFor(x => x.ReservationEndTime) + .GreaterThan(x => x.ReservationStartTime) + .WithMessage("{PropertyName} must come after the reservation start time"); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Reservations/Command/Reschedule/RescheduleReservationCommand.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Reservations/Command/Reschedule/RescheduleReservationCommand.cs new file mode 100755 index 0000000..2d6f39e --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Reservations/Command/Reschedule/RescheduleReservationCommand.cs @@ -0,0 +1,15 @@ +using MediatR; + +using RestaurantReservation.Application.Features.Reservations.Query.Responses; +using RestaurantReservation.Domain.Common; + +namespace RestaurantReservation.Application.Features.Reservations.Command.Reschedule; + +public record RescheduleReservationCommand( + Guid ReservationId, + string RestaurantName, + DateOnly RescheduleDate, + TimeOnly RescheduleStartTime, + TimeOnly RescheduleEndTime, + int RescheduleNumberOfGuests, + string? RescheduleTableGroup = null) : IRequest>; \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Reservations/Command/Reschedule/RescheduleReservationCommandHandler.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Reservations/Command/Reschedule/RescheduleReservationCommandHandler.cs new file mode 100755 index 0000000..1590c7b --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Reservations/Command/Reschedule/RescheduleReservationCommandHandler.cs @@ -0,0 +1,242 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Hybrid; +using Microsoft.Extensions.Logging; + +using RestaurantReservation.Application.Abstractions; +using RestaurantReservation.Application.Features.Constants; +using RestaurantReservation.Application.Features.Reservations.Query.Mappings; +using RestaurantReservation.Application.Features.Reservations.Query.Responses; +using RestaurantReservation.Domain.Common; +using RestaurantReservation.Domain.Reservations; +using RestaurantReservation.Domain.Reservations.Errors; +using RestaurantReservation.Domain.Restaurants.Errors; +using RestaurantReservation.Domain.Users.Errors; + +namespace RestaurantReservation.Application.Features.Reservations.Command.Reschedule; + +public sealed class RescheduleReservationCommandHandler( + IApplicationDbContext context, + HybridCache cache, + ICurrentUser currentUser, + ILogger logger) : IRequestHandler> +{ + public async Task> Handle( + RescheduleReservationCommand command, + CancellationToken cancellationToken) + { + var customerId = currentUser.UserId; + var customerEmail = currentUser.Email ?? "customer"; + List reservationTables = []; + + if (string.IsNullOrWhiteSpace(customerId)) + { + logger.LogError("Customer that is trying to reschedule a reservation in the system is not found"); + return Result.Failure(UserErrors.UserNotFound); + } + + var reservationToReschedule = await context.Reservations + .Include(r => r.Tables) + .FirstOrDefaultAsync(r => r.Id == command.ReservationId + && r.RestaurantName.ToLower().Equals(command.RestaurantName.ToLower()), cancellationToken); + + if (reservationToReschedule is null) + { + logger.LogWarning("Unable to retrieve reservation {Id}, reservation for {Email} not rescheduled", + command.ReservationId, + customerEmail); + return Result.Failure( + ReservationErrors.ReservationNotFound(customerEmail, command.RestaurantName)); + } + + var originalReservationDate = reservationToReschedule.ReservationInfo.Date.Value; + var originalReservationStartTime = reservationToReschedule.ReservationInfo.StartTime.Value; + var originalReservationEndTime = reservationToReschedule.ReservationInfo.EndTime.Value; + var originalReservationNumberOfGuests = reservationToReschedule.ReservationInfo.Guests.Value; + + var restaurant = await context.Restaurants + .AsNoTracking() + .Include(r => r.Schedule) + .FirstOrDefaultAsync(r => r.Name.ToLower().Equals(command.RestaurantName.ToLower()), cancellationToken); + + if (restaurant is null) + { + logger.LogWarning("Restaurant {Name} not found in the system, cannot reschedule reservation", + command.RestaurantName); + return Result.Failure(RestaurantErrors.NotFound(command.RestaurantName)); + } + + var isOpenResult = restaurant.RestaurantIsOpen( + command.RescheduleDate, + command.RescheduleStartTime, + command.RescheduleEndTime); + + if (isOpenResult.IsFailure) + { + logger.LogWarning("Restaurant {Name} is not opened at the time {Email} wishes to reschedule their reservation, so no reservation was rescheduled", + command.RestaurantName, + customerEmail); + return Result.Failure( + isOpenResult.Error); + } + + var tableToReserve = string.IsNullOrEmpty(command.RescheduleTableGroup) + ? await context.Tables + .Include(t => t.Reservations) + .Where(t => t.SeatsAtTable >= command.RescheduleNumberOfGuests && t.RestaurantId == restaurant.Id) + .OrderBy(t => t.SeatsAtTable) + .FirstOrDefaultAsync(t => !t.Reservations.Any(r => r.ReservationId != command.ReservationId + && r.ScheduledReservation.ReservationDay == + command.RescheduleDate + && r.ScheduledReservation.ReservationStart < + command.RescheduleEndTime + && command.RescheduleStartTime < + r.ScheduledReservation.ReservationEnd), + cancellationToken) + : await context.Tables + .Include(t => t.Reservations) + .Where(t => t.SeatsAtTable >= command.RescheduleNumberOfGuests && t.RestaurantId == restaurant.Id + && t.IsInTableGroup + && !string.IsNullOrEmpty(t.TableGroupName) + && t.TableGroupName.ToLower().Equals(command.RescheduleTableGroup.ToLower())) + .OrderBy(t => t.SeatsAtTable) + .FirstOrDefaultAsync(t => !t.Reservations.Any(r => r.ReservationId != command.ReservationId + && r.ScheduledReservation.ReservationDay == + command.RescheduleDate + && r.ScheduledReservation.ReservationStart < + command.RescheduleEndTime + && command.RescheduleStartTime < + r.ScheduledReservation.ReservationEnd), + cancellationToken); + + if (tableToReserve is null) + { + var tableGroupToReserve = string.IsNullOrEmpty(command.RescheduleTableGroup) + ? await context.TableGroups + .Include(tg => tg.Tables) + .ThenInclude(tbl => tbl.Reservations) + .Where(tg => tg.Tables.Sum(t => t.SeatsAtTable) >= command.RescheduleNumberOfGuests && tg.RestaurantId == restaurant.Id) + .OrderBy(tg => tg.Tables.Sum(t => t.SeatsAtTable)) + .FirstOrDefaultAsync(tg => tg.Tables.All(t => !t.Reservations.Any(r => + r.ReservationId != command.ReservationId + && r.ScheduledReservation.ReservationDay == + command.RescheduleDate + && r.ScheduledReservation.ReservationStart < + command.RescheduleEndTime + && command.RescheduleStartTime < + r.ScheduledReservation.ReservationEnd)), cancellationToken) + : await context.TableGroups + .Include(tg => tg.Tables) + .ThenInclude(tbl => tbl.Reservations) + .Where(tg => tg.Tables.Sum(t => t.SeatsAtTable) >= command.RescheduleNumberOfGuests && tg.RestaurantId == restaurant.Id) + .Where(tg => tg.Tables.All(t => t.IsInTableGroup + && !string.IsNullOrEmpty(t.TableGroupName) + && t.TableGroupName.ToLower().Equals(command.RescheduleTableGroup.ToLower()))) + .OrderBy(tg => tg.Tables.Sum(t => t.SeatsAtTable)) + .FirstOrDefaultAsync(tg => tg.Tables.All(t => !t.Reservations.Any(r => + r.ReservationId != command.ReservationId + && r.ScheduledReservation.ReservationDay == + command.RescheduleDate + && r.ScheduledReservation.ReservationStart < + command.RescheduleEndTime + && command.RescheduleStartTime < + r.ScheduledReservation.ReservationEnd)), cancellationToken); + + if (tableGroupToReserve is null) + { + logger.LogWarning("Unable to find any tables with enough capacity to accommodate the customer's party size of {Party}, " + + "unable to reschedule reservation", + command.RescheduleNumberOfGuests); + return Result.Failure(ReservationErrors.UnableToSecureTablesForReservation); + } + + var guests = command.RescheduleNumberOfGuests; + var index = 0; + + while (guests > 0) + { + var result = tableGroupToReserve.Tables.ElementAt(index++) + .ReserveTable( + command.RescheduleDate, + command.RescheduleStartTime, + command.RescheduleEndTime); + + if (result.IsFailure) + { + return Result.Failure(result.Error); + } + + reservationTables.Add(result.Value); + + guests -= result.Value.SeatsAtTable; + } + + reservationTables = [.. reservationTables.OrderByDescending(rt => rt.SeatsAtTable)]; + } + else + { + var result = tableToReserve.ReserveTable( + command.RescheduleDate, + command.RescheduleStartTime, + command.RescheduleEndTime); + + if (result.IsFailure) + { + return Result.Failure(result.Error); + } + + reservationTables.Add(result.Value); + } + + var rescheduledReservationResult = reservationToReschedule.RescheduleReservation( + command.RescheduleDate, + command.RescheduleStartTime, + command.RescheduleEndTime, + command.RescheduleNumberOfGuests); + + if (rescheduledReservationResult.IsFailure) + { + logger.LogError("Unable to reschedule reservation for {Email} at {RName}", + customerEmail, + restaurant.Name); + return Result.Failure(ReservationErrors.ReservationCannotBeRescheduled); + } + + var updateReservationTablesResult = reservationToReschedule.RemoveReservationTable(); + + if (updateReservationTablesResult.IsFailure) + { + logger.LogError("Unable to reschedule reservation for {Email} at {RName}", + customerEmail, + restaurant.Name); + return Result.Failure(updateReservationTablesResult.Error); + } + + foreach (var table in reservationTables) + { + reservationToReschedule.AddReservationTable(table); + } + + context.Reservations.Update(reservationToReschedule); + await context.SaveChangesAsync(cancellationToken); + + logger.LogInformation("{Email} just updated a reservation. Invaliding the cache for key: {Key} and {TKey} and {TGKey}", + customerEmail, + Keys.Reservations, + Keys.Tables, + Keys.TableGroups); + + await cache.RemoveByTagAsync(Keys.Reservations, cancellationToken); + await cache.RemoveByTagAsync(Keys.Tables, cancellationToken); + await cache.RemoveByTagAsync(Keys.TableGroups, cancellationToken); + + var response = reservationToReschedule.ToRescheduledDetailResponse( + originalReservationDate, + originalReservationStartTime, + originalReservationEndTime, + originalReservationNumberOfGuests); + + return Result.Success(response); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Reservations/Command/Reschedule/RescheduleReservationCommandValidator.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Reservations/Command/Reschedule/RescheduleReservationCommandValidator.cs new file mode 100755 index 0000000..a5b6f7c --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Reservations/Command/Reschedule/RescheduleReservationCommandValidator.cs @@ -0,0 +1,32 @@ +using FluentValidation; + +namespace RestaurantReservation.Application.Features.Reservations.Command.Reschedule; + +internal sealed class RescheduleReservationCommandValidator : AbstractValidator +{ + public RescheduleReservationCommandValidator() + { + RuleFor(x => x.RestaurantName) + .NotEmpty() + .WithMessage("{PropertyName} cannot be empty") + .MaximumLength(50) + .WithMessage("{PropertyName} cannot exceed 50 characters"); + + RuleFor(x => x.RescheduleDate) + .GreaterThanOrEqualTo(DateOnly.FromDateTime(DateTime.Today)) + .WithMessage("{PropertyName} cannot be in the past"); + + RuleFor(x => x.RescheduleStartTime) + .LessThan(x => x.RescheduleEndTime) + .WithMessage("{PropertyName} must come after the reservation start time"); + + RuleFor(x => x.RescheduleEndTime) + .GreaterThan(x => x.RescheduleStartTime) + .WithMessage("{PropertyName} must come after the reservation start time"); + + RuleFor(x => x.RescheduleNumberOfGuests) + .GreaterThan(0) + .WithMessage( + "{PropertyName} must be greater than zero. Even if you are the only person in your party that still counts as one guest"); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Reservations/Command/Schedule/ScheduleReservationCommand.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Reservations/Command/Schedule/ScheduleReservationCommand.cs new file mode 100755 index 0000000..31dc93d --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Reservations/Command/Schedule/ScheduleReservationCommand.cs @@ -0,0 +1,18 @@ +using MediatR; + +using RestaurantReservation.Application.Features.Reservations.Query.Responses; +using RestaurantReservation.Domain.Common; + +namespace RestaurantReservation.Application.Features.Reservations.Command.Schedule; + +public record ScheduleReservationCommand( + string RestaurantName, + string CustomerFirstName, + string CustomerLastName, + string CustomerEmail, + string CustomerPhone, + DateOnly ReservationDate, + TimeOnly ReservationStartTime, + TimeOnly ReservationEndTime, + int NumberOfGuests, + string? TableGroup = null) : IRequest>; \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Reservations/Command/Schedule/ScheduleReservationCommandHandler.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Reservations/Command/Schedule/ScheduleReservationCommandHandler.cs new file mode 100755 index 0000000..e8bc88a --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Reservations/Command/Schedule/ScheduleReservationCommandHandler.cs @@ -0,0 +1,213 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Hybrid; +using Microsoft.Extensions.Logging; + +using RestaurantReservation.Application.Abstractions; +using RestaurantReservation.Application.Features.Constants; +using RestaurantReservation.Application.Features.Reservations.Query.Mappings; +using RestaurantReservation.Application.Features.Reservations.Query.Responses; +using RestaurantReservation.Domain.Common; +using RestaurantReservation.Domain.Reservations; +using RestaurantReservation.Domain.Reservations.Errors; +using RestaurantReservation.Domain.Restaurants.Errors; +using RestaurantReservation.Domain.Users.Errors; + +namespace RestaurantReservation.Application.Features.Reservations.Command.Schedule; + +public sealed class ScheduleReservationCommandHandler( + IApplicationDbContext context, + HybridCache cache, + ICurrentUser currentUser, + ILogger logger) : IRequestHandler> +{ + public async Task> Handle( + ScheduleReservationCommand command, + CancellationToken cancellationToken) + { + var customerId = currentUser.UserId; + var customerName = currentUser.Name ?? "customer"; + ReservationTable? reservationTable = null; + List reservationTables = []; + + if (string.IsNullOrWhiteSpace(customerId)) + { + logger.LogError("Customer {Email} is not found in the system. Reservation not made.", command.CustomerEmail); + return Result.Failure(UserErrors.UserNotFound); + } + + var restaurant = await context.Restaurants + .AsNoTracking() + .Include(r => r.Schedule) + .FirstOrDefaultAsync(r => r.Name.ToLower().Equals(command.RestaurantName.ToLower()), cancellationToken); + + if (restaurant is null) + { + logger.LogWarning("Restaurant {Name} not found in the system, unable to make a reservation", + command.RestaurantName); + return Result.Failure(RestaurantErrors.NotFound(command.RestaurantName)); + } + + var isOpenResult = restaurant.RestaurantIsOpen( + command.ReservationDate, + command.ReservationStartTime, + command.ReservationEndTime); + + if (isOpenResult.IsFailure) + { + logger.LogWarning("Restaurant {Name} is not opened at the time {Email} wishes to schedule a reservation, so no reservation was made", + command.RestaurantName, + command.CustomerEmail); + return Result.Failure(isOpenResult.Error); + } + + var tableToReserve = string.IsNullOrEmpty(command.TableGroup) + ? await context.Tables + .Include(t => t.Reservations) + .Where(t => t.SeatsAtTable >= command.NumberOfGuests && t.RestaurantId == restaurant.Id) + .OrderBy(t => t.SeatsAtTable) + .FirstOrDefaultAsync(t => !t.Reservations.Any(r => + r.ScheduledReservation.ReservationDay == command.ReservationDate && + r.ScheduledReservation.ReservationStart < command.ReservationEndTime && + command.ReservationStartTime < r.ScheduledReservation.ReservationEnd), + cancellationToken) + : await context.Tables + .Include(t => t.Reservations) + .Where(t => t.SeatsAtTable >= command.NumberOfGuests && t.RestaurantId == restaurant.Id && + t.IsInTableGroup + && !string.IsNullOrEmpty(t.TableGroupName) + && t.TableGroupName.ToLower().Equals(command.TableGroup.ToLower())) + .OrderBy(t => t.SeatsAtTable) + .FirstOrDefaultAsync(t => !t.Reservations.Any(r => + r.ScheduledReservation.ReservationDay == command.ReservationDate && + r.ScheduledReservation.ReservationStart < command.ReservationEndTime && + command.ReservationStartTime < r.ScheduledReservation.ReservationEnd), + cancellationToken); + + if (tableToReserve is null) + { + var tableGroupToReserve = string.IsNullOrEmpty(command.TableGroup) + ? await context.TableGroups + .Include(tg => tg.Tables) + .ThenInclude(tbl => tbl.Reservations) + .Where(tg => tg.Tables.Sum(t => t.SeatsAtTable) >= command.NumberOfGuests && tg.RestaurantId == restaurant.Id) + .OrderBy(tg => tg.Tables.Sum(t => t.SeatsAtTable)) + .FirstOrDefaultAsync(tg => tg.Tables.All(t => !t.Reservations.Any(r => + r.ScheduledReservation.ReservationDay == command.ReservationDate && + r.ScheduledReservation.ReservationStart < command.ReservationEndTime && + command.ReservationStartTime < r.ScheduledReservation.ReservationEnd)), cancellationToken) + : await context.TableGroups + .Include(tg => tg.Tables) + .ThenInclude(tbl => tbl.Reservations) + .Where(tg => tg.Tables.Sum(t => t.SeatsAtTable) >= command.NumberOfGuests && tg.RestaurantId == restaurant.Id) + .Where(tg => tg.Tables.All(t => t.IsInTableGroup + && !string.IsNullOrEmpty(t.TableGroupName) + && t.TableGroupName.ToLower().Equals(command.TableGroup.ToLower()))) + .OrderBy(tg => tg.Tables.Sum(t => t.SeatsAtTable)) + .FirstOrDefaultAsync(tg => tg.Tables.All(t => !t.Reservations.Any(r => + r.ScheduledReservation.ReservationDay == command.ReservationDate && + r.ScheduledReservation.ReservationStart < command.ReservationEndTime && + command.ReservationStartTime < r.ScheduledReservation.ReservationEnd)), cancellationToken); + + if (tableGroupToReserve is null) + { + logger.LogWarning("Unable to find any tables with enough capacity to accommodate the customer's party size of {Party}", + command.NumberOfGuests); + return Result.Failure(ReservationErrors.UnableToSecureTablesForReservation); + } + + var guests = command.NumberOfGuests; + var index = 0; + + while (guests > 0) + { + var result = tableGroupToReserve.Tables.ElementAt(index++) + .ReserveTable( + command.ReservationDate, + command.ReservationStartTime, + command.ReservationEndTime); + + if (result.IsFailure) + { + return Result.Failure(result.Error); + } + + reservationTables.Add(result.Value); + + guests -= result.Value.SeatsAtTable; + } + + reservationTables = [.. reservationTables.OrderByDescending(rt => rt.SeatsAtTable)]; + } + else + { + var result = tableToReserve.ReserveTable( + command.ReservationDate, + command.ReservationStartTime, + command.ReservationEndTime); + + if (result.IsFailure) + { + return Result.Failure(result.Error); + } + + reservationTable = result.Value; + } + + var reservationResult = Reservation.MakeReservation( + restaurant.Id, + command.RestaurantName, + customerId, + command.CustomerFirstName, + command.CustomerLastName, + command.CustomerEmail, + command.CustomerPhone, + command.ReservationDate, + command.ReservationStartTime, + command.ReservationEndTime, + command.NumberOfGuests, + command.TableGroup); + + if (reservationResult.IsFailure) + { + logger.LogError("Unable to complete reservation for {CName} at {RName}", + customerName, + restaurant.Name); + return Result.Failure(reservationResult.Errors); + } + + if (reservationTable is not null) + { + reservationResult.Value.AddReservationTable(reservationTable); + await context.ReservationTables.AddAsync(reservationTable, cancellationToken); + } + else + { + foreach (var table in reservationTables) + { + reservationResult.Value.AddReservationTable(table); + } + + await context.ReservationTables.AddRangeAsync(reservationTables, cancellationToken); + } + + await context.Reservations.AddAsync(reservationResult.Value, cancellationToken); + await context.SaveChangesAsync(cancellationToken); + + logger.LogInformation("{Name} ({Email}) just made a reservation. Invalidating cache for key: {Key} and {TKey} and {TGKey}", + customerName, + command.CustomerEmail, + Keys.Reservations, + Keys.Tables, + Keys.TableGroups); + + await cache.RemoveByTagAsync(Keys.Reservations, cancellationToken); + await cache.RemoveByTagAsync(Keys.Tables, cancellationToken); + await cache.RemoveByTagAsync(Keys.TableGroups, cancellationToken); + + var response = reservationResult.Value.ToDetailResponse(restaurant.Name); + + return Result.Success(response); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Reservations/Command/Schedule/ScheduleReservationCommandValidator.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Reservations/Command/Schedule/ScheduleReservationCommandValidator.cs new file mode 100755 index 0000000..fc2e50a --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Reservations/Command/Schedule/ScheduleReservationCommandValidator.cs @@ -0,0 +1,62 @@ +using System.Text.RegularExpressions; + +using FluentValidation; + +namespace RestaurantReservation.Application.Features.Reservations.Command.Schedule; + +internal sealed partial class ScheduleReservationCommandValidator : AbstractValidator +{ + public ScheduleReservationCommandValidator() + { + RuleFor(x => x.CustomerFirstName) + .NotEmpty() + .WithMessage("{PropertyName} cannot be empty") + .MaximumLength(50) + .WithMessage("{PropertyName} cannot exceed 50 characters"); + + RuleFor(x => x.CustomerLastName) + .NotEmpty() + .WithMessage("{PropertyName} cannot be empty") + .MaximumLength(50) + .WithMessage("{PropertyName} cannot exceed 50 characters"); + + RuleFor(x => x.CustomerEmail) + .NotEmpty() + .WithMessage("{PropertyName} cannot be empty") + .MaximumLength(50) + .WithMessage("{PropertyName} cannot exceed 50 characters") + .EmailAddress() + .WithMessage("{PropertyName} is not a valid email address"); + + RuleFor(x => x.CustomerPhone) + .NotEmpty() + .WithMessage("{PropertyName} cannot be empty") + .Must(IsValidTelephoneNumber) + .WithMessage("{PropertyName} is not a valid telephone number"); + + RuleFor(x => x.ReservationDate) + .GreaterThanOrEqualTo(DateOnly.FromDateTime(DateTime.Today)) + .WithMessage("{PropertyName} cannot be in the past"); + + RuleFor(x => x.ReservationStartTime) + .LessThan(x => x.ReservationEndTime) + .WithMessage("{PropertyName} must come after the reservation start time"); + + RuleFor(x => x.ReservationEndTime) + .GreaterThan(x => x.ReservationStartTime) + .WithMessage("{PropertyName} must come after the reservation start time"); + + RuleFor(x => x.NumberOfGuests) + .GreaterThan(0) + .WithMessage("{PropertyName} must be greater than zero, even if you are the only person in your party " + + "that still counts as one guest"); + } + + private static bool IsValidTelephoneNumber(string phoneNumber) + { + return PhoneNumberRegex().IsMatch(phoneNumber); + } + + [GeneratedRegex(@"^[\+]?[1-9]?[\d\s\-\(\)\.]{10,15}$")] + private static partial Regex PhoneNumberRegex(); +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Reservations/Notification/ReservationCanceledEventHandler.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Reservations/Notification/ReservationCanceledEventHandler.cs new file mode 100755 index 0000000..2db3d53 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Reservations/Notification/ReservationCanceledEventHandler.cs @@ -0,0 +1,45 @@ +using MediatR; + +using Microsoft.Extensions.Logging; + +using RestaurantReservation.Application.Abstractions; +using RestaurantReservation.Application.Features.Constants; +using RestaurantReservation.Domain.Reservations.Events; + +namespace RestaurantReservation.Application.Features.Reservations.Notification; + +public sealed class ReservationCanceledEventHandler( + IEmailService emailService, + ILogger logger) : INotificationHandler +{ + public async Task Handle( + ReservationCanceledEvent notification, + CancellationToken cancellationToken) + { + logger.LogInformation("Reservation {Id} scheduled for Customer {Email} " + + "on {Date} starting at {Start} and ending at {End} has been canceled on {CancellationDate}", + notification.ReservationId, + notification.Email, + notification.ReservationDate, + notification.ReservationStartTime, + notification.ReservationEndTime, + notification.ReservationCanceledAtUtc); + + var body = $"Your reservation at {notification.RestaurantName} on {notification.ReservationDate} from " + + $"{notification.ReservationStartTime} to {notification.ReservationEndTime} has been canceled as per your request " + + $"on {notification.ReservationCanceledAtUtc}. No further action is necessary on your part."; + var name = $"{notification.FirstName} {notification.LastName}"; + const string subject = "Reservation Canceled"; + const string sender = "Restaurant Reservation Service"; + + var emailInfo = new EmailInfo( + sender, + name, + notification.Email, + NotificationConstants.EmailSender, + subject, + body); + + await emailService.SendEmailAsync(emailInfo, cancellationToken); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Reservations/Notification/ReservationCompletedEventHandler.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Reservations/Notification/ReservationCompletedEventHandler.cs new file mode 100755 index 0000000..05aa907 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Reservations/Notification/ReservationCompletedEventHandler.cs @@ -0,0 +1,46 @@ +using MediatR; + +using Microsoft.Extensions.Logging; + +using RestaurantReservation.Application.Abstractions; +using RestaurantReservation.Application.Features.Constants; +using RestaurantReservation.Domain.Reservations.Events; + +namespace RestaurantReservation.Application.Features.Reservations.Notification; + +public sealed class ReservationCompletedEventHandler( + IEmailService emailService, + ILogger logger) : INotificationHandler +{ + public async Task Handle( + ReservationCompletedEvent notification, + CancellationToken cancellationToken) + { + logger.LogInformation("Reservation {Id} scheduled for Customer {Email} " + + "on {Date} starting at {Start} and ending at {End} has been marked as completed on {CompletionDate}", + notification.ReservationId, + notification.Email, + notification.ReservationDate, + notification.ReservationStartTime, + notification.ReservationEndTime, + notification.ReservationCompletedAtUtc); + + var body = $"Your reservation at {notification.RestaurantName} on {notification.ReservationDate} from " + + $"{notification.ReservationStartTime} to {notification.ReservationEndTime} has been marked as" + + $" completed on {notification.ReservationCompletedAtUtc:F}. We hope that you enjoyed your " + + $"time at {notification.RestaurantName} and will visit again soon!"; + var name = $"{notification.FirstName} {notification.LastName}"; + const string subject = "Reservation Completed"; + const string sender = "Restaurant Reservation Service"; + + var emailInfo = new EmailInfo( + sender, + name, + notification.Email, + NotificationConstants.EmailSender, + subject, + body); + + await emailService.SendEmailAsync(emailInfo, cancellationToken); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Reservations/Notification/ReservationRescheduledEventHandler.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Reservations/Notification/ReservationRescheduledEventHandler.cs new file mode 100755 index 0000000..d5cb347 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Reservations/Notification/ReservationRescheduledEventHandler.cs @@ -0,0 +1,49 @@ +using MediatR; + +using Microsoft.Extensions.Logging; + +using RestaurantReservation.Application.Abstractions; +using RestaurantReservation.Application.Features.Constants; +using RestaurantReservation.Domain.Reservations.Events; + +namespace RestaurantReservation.Application.Features.Reservations.Notification; + +public sealed class ReservationRescheduledEventHandler( + IEmailService emailService, + ILogger logger) : INotificationHandler +{ + public async Task Handle( + ReservationRescheduledEvent notification, + CancellationToken cancellationToken) + { + logger.LogInformation("Reservation has been rescheduled for {Email} date has been changed from {OriginalDate} to " + + "{NewDate} and from start and end time of {OriginalStart} to {OriginalEnd} to new start and end time " + + "of {NewStart} to {NewEnd}", + notification.Email, + notification.OriginalReservationDate, + notification.ReservationDate, + notification.OriginalReservationStartTime, + notification.OriginalReservationEndTime, + notification.ReservationStartTime, + notification.ReservationEndTime); + + var body = $"Your reservation originally scheduled at {notification.RestaurantName} for {notification.OriginalReservationDate} from " + + $"{notification.OriginalReservationStartTime} to {notification.OriginalReservationEndTime} has been rescheduled for " + + $"{notification.ReservationDate} from {notification.ReservationStartTime} to {notification.ReservationEndTime} with a " + + $"party of {notification.NumberOfGuests} guests."; + + var name = $"{notification.FirstName} {notification.LastName}"; + const string subject = "Reservation Rescheduled"; + const string sender = "Restaurant Reservation Service"; + + var emailInfo = new EmailInfo( + sender, + name, + notification.Email, + NotificationConstants.EmailSender, + subject, + body); + + await emailService.SendEmailAsync(emailInfo, cancellationToken); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Reservations/Notification/ReservationScheduledEventHandler.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Reservations/Notification/ReservationScheduledEventHandler.cs new file mode 100755 index 0000000..cdd41a5 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Reservations/Notification/ReservationScheduledEventHandler.cs @@ -0,0 +1,44 @@ +using MediatR; + +using Microsoft.Extensions.Logging; + +using RestaurantReservation.Application.Abstractions; +using RestaurantReservation.Application.Features.Constants; +using RestaurantReservation.Domain.Reservations.Events; + +namespace RestaurantReservation.Application.Features.Reservations.Notification; + +public sealed class ReservationScheduledEventHandler( + IEmailService emailService, + ILogger logger) : INotificationHandler +{ + public async Task Handle( + ReservationScheduledEvent notification, + CancellationToken cancellationToken) + { + logger.LogInformation("Reservation has been scheduled for {Email} on {Date} starting at " + + "{Start} ending at {End}", + notification.Email, + notification.ReservationDate, + notification.ReservationStartTime, + notification.ReservationEndTime); + + var body = $"Your reservation has been scheduled at " + + $"{notification.RestaurantName} for {notification.ReservationDate} from {notification.ReservationStartTime} " + + $"to {notification.ReservationEndTime} with a party of {notification.NumberOfGuests} guests."; + + var name = $"{notification.FirstName} {notification.LastName}"; + const string subject = "Reservation Scheduled"; + const string sender = "Restaurant Reservation Service"; + + var emailInfo = new EmailInfo( + sender, + name, + notification.Email, + NotificationConstants.EmailSender, + subject, + body); + + await emailService.SendEmailAsync(emailInfo, cancellationToken); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Reservations/Query/Get/GetReservationQuery.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Reservations/Query/Get/GetReservationQuery.cs new file mode 100755 index 0000000..887a1f8 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Reservations/Query/Get/GetReservationQuery.cs @@ -0,0 +1,8 @@ +using MediatR; + +using RestaurantReservation.Application.Features.Reservations.Query.Responses; +using RestaurantReservation.Domain.Common; + +namespace RestaurantReservation.Application.Features.Reservations.Query.Get; + +public record GetReservationQuery(Guid ReservationId) : IRequest>; \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Reservations/Query/Get/GetReservationQueryHandler.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Reservations/Query/Get/GetReservationQueryHandler.cs new file mode 100755 index 0000000..0098506 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Reservations/Query/Get/GetReservationQueryHandler.cs @@ -0,0 +1,68 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Hybrid; +using Microsoft.Extensions.Logging; + +using RestaurantReservation.Application.Abstractions; +using RestaurantReservation.Application.Features.Constants; +using RestaurantReservation.Application.Features.Reservations.Query.Mappings; +using RestaurantReservation.Application.Features.Reservations.Query.Responses; +using RestaurantReservation.Domain.Common; +using RestaurantReservation.Domain.Reservations.Errors; +using RestaurantReservation.Domain.Users.Errors; + +namespace RestaurantReservation.Application.Features.Reservations.Query.Get; + +public sealed class GetReservationQueryHandler( + IApplicationDbContext context, + HybridCache cache, + ICurrentUser currentUser, + ILogger logger) : IRequestHandler> +{ + public async Task> Handle( + GetReservationQuery query, + CancellationToken cancellationToken) + { + var customerId = currentUser.UserId; + var customerEmail = currentUser.Email ?? "email"; + + if (string.IsNullOrWhiteSpace(customerId)) + { + logger.LogWarning("Customer not found, unable to retrieve reservation"); + return Result.Failure(UserErrors.UserNotFound); + } + + var reservationKey = $"reservation:{query.ReservationId}"; + + var reservation = await cache.GetOrCreateAsync( + reservationKey, + async ct => + { + logger.LogInformation("Cache miss for key: {Key}. Retrieving from database.", reservationKey); + var reservationFromDb = await context.Reservations + .Include(r => r.Restaurant) + .Include(r => r.Tables) + .FirstOrDefaultAsync(r => r.CustomerId == customerId && r.Id == query.ReservationId, ct); + return reservationFromDb?.ToDetailResponse(reservationFromDb.RestaurantName); + }, + new HybridCacheEntryOptions + { + LocalCacheExpiration = TimeSpan.FromMinutes(5), + Expiration = TimeSpan.FromMinutes(30) + }, + tags: [Keys.Reservations], + cancellationToken: cancellationToken); + + if (reservation is null) + { + logger.LogWarning("Reservation {Id} not found for customer with Id {Email}", + query.ReservationId, + customerEmail); + return Result.Failure( + ReservationErrors.ReservationNotFound(customerEmail)); + } + + return Result.Success(reservation); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Reservations/Query/GetAll/GetAllReservationsQuery.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Reservations/Query/GetAll/GetAllReservationsQuery.cs new file mode 100755 index 0000000..f9a2dd9 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Reservations/Query/GetAll/GetAllReservationsQuery.cs @@ -0,0 +1,13 @@ +using MediatR; + +using RestaurantReservation.Application.Features.Reservations.Query.Responses; +using RestaurantReservation.Domain.Common; + +namespace RestaurantReservation.Application.Features.Reservations.Query.GetAll; + +public record GetAllReservationsQuery( + int Page = 1, + int PageSize = 10, + string? SortBy = null, + string? RestaurantName = null, + string? Status = null) : IRequest>>; \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Reservations/Query/GetAll/GetAllReservationsQueryHandler.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Reservations/Query/GetAll/GetAllReservationsQueryHandler.cs new file mode 100755 index 0000000..b4b7e19 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Reservations/Query/GetAll/GetAllReservationsQueryHandler.cs @@ -0,0 +1,86 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Hybrid; +using Microsoft.Extensions.Logging; + +using RestaurantReservation.Application.Abstractions; +using RestaurantReservation.Application.Extensions; +using RestaurantReservation.Application.Features.Constants; +using RestaurantReservation.Application.Features.Reservations.Query.Mappings; +using RestaurantReservation.Application.Features.Reservations.Query.Responses; +using RestaurantReservation.Domain.Common; +using RestaurantReservation.Domain.Users.Errors; + +namespace RestaurantReservation.Application.Features.Reservations.Query.GetAll; + +public sealed class GetAllReservationsQueryHandler( + IApplicationDbContext context, + HybridCache cache, + ICurrentUser currentUser, + ILogger logger) : IRequestHandler>> +{ + public async Task>> Handle( + GetAllReservationsQuery query, + CancellationToken cancellationToken) + { + var customerId = currentUser.UserId; + + if (string.IsNullOrWhiteSpace(customerId)) + { + logger.LogWarning("User not found in the system, unable to retrieve reservations"); + return Result.Failure>(UserErrors.UserNotFound); + } + + var reservationsKey = $"reservations_page={query.Page}_pageSize={query.PageSize}"; + + (List items, int totalCount) = await cache.GetOrCreateAsync( + reservationsKey, + async ct => + { + logger.LogInformation("Cache miss for key: {Key}. Retrieving from database", + reservationsKey); + + var reservationsQuery = context.Reservations + .Where(r => r.CustomerId == customerId) + .AsNoTracking() + .AsQueryable(); + + if (!string.IsNullOrWhiteSpace(query.RestaurantName)) + { + reservationsQuery = reservationsQuery.ApplySearch(query.RestaurantName); + } + + if (!string.IsNullOrWhiteSpace(query.Status)) + { + reservationsQuery = reservationsQuery.ApplySearch(query.Status); + } + + var totalCount = await reservationsQuery + .AsNoTracking() + .CountAsync(r => r.CustomerId != null + && r.CustomerId.Equals(customerId), ct); + + reservationsQuery = reservationsQuery.ApplySort( + string.IsNullOrWhiteSpace(query.SortBy) ? "Id" : query.SortBy); + + var items = await reservationsQuery + .AsNoTracking() + .ApplyPagination(query.Page, query.PageSize) + .Select(r => r.ToResponse(r.RestaurantName)) + .ToListAsync(ct); + + return (items, totalCount); + }, + new HybridCacheEntryOptions + { + LocalCacheExpiration = TimeSpan.FromMinutes(5), + Expiration = TimeSpan.FromMinutes(30) + }, + tags: [Keys.Reservations], + cancellationToken: cancellationToken); + + var pagedResult = new PagedResult(items, totalCount, query.Page, query.PageSize); + return Result.Success(pagedResult); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Reservations/Query/GetAll/GetAllRestaurantReservationsQuery.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Reservations/Query/GetAll/GetAllRestaurantReservationsQuery.cs new file mode 100644 index 0000000..6f76b2c --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Reservations/Query/GetAll/GetAllRestaurantReservationsQuery.cs @@ -0,0 +1,13 @@ +using MediatR; + +using RestaurantReservation.Application.Features.Reservations.Query.Responses; +using RestaurantReservation.Domain.Common; + +namespace RestaurantReservation.Application.Features.Reservations.Query.GetAll; + +public record GetAllRestaurantReservationsQuery( + Guid RestaurantId, + int Page = 1, + int PageSize = 10, + string? SortBy = null, + string? Status = null) : IRequest>>; \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Reservations/Query/GetAll/GetAllRestaurantReservationsQueryHandler.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Reservations/Query/GetAll/GetAllRestaurantReservationsQueryHandler.cs new file mode 100644 index 0000000..71e0218 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Reservations/Query/GetAll/GetAllRestaurantReservationsQueryHandler.cs @@ -0,0 +1,69 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Hybrid; +using Microsoft.Extensions.Logging; + +using RestaurantReservation.Application.Abstractions; +using RestaurantReservation.Application.Extensions; +using RestaurantReservation.Application.Features.Constants; +using RestaurantReservation.Application.Features.Reservations.Query.Mappings; +using RestaurantReservation.Application.Features.Reservations.Query.Responses; +using RestaurantReservation.Domain.Common; + +namespace RestaurantReservation.Application.Features.Reservations.Query.GetAll; + +public sealed class GetAllRestaurantReservationsQueryHandler( + IApplicationDbContext context, + HybridCache cache, + ILogger logger) : IRequestHandler>> +{ + public async Task>> Handle( + GetAllRestaurantReservationsQuery query, + CancellationToken cancellationToken) + { + var restaurantReservationsKey = $"restaurant_reservations_page={query.Page}_pageSize={query.PageSize}"; + + (List items, int totalCount) = await cache.GetOrCreateAsync( + restaurantReservationsKey, + async ct => + { + logger.LogInformation("Cache miss for key: {Key}. Retrieving from database", + restaurantReservationsKey); + + var reservationsQuery = context.Reservations + .Where(r => r.RestaurantId == query.RestaurantId) + .AsNoTracking() + .AsQueryable(); + + if (!string.IsNullOrWhiteSpace(query.Status)) + { + reservationsQuery = reservationsQuery.ApplySearch(query.Status); + } + + var totalCount = await reservationsQuery + .AsNoTracking() + .CountAsync(r => r.RestaurantId == query.RestaurantId, ct); + + reservationsQuery = reservationsQuery.ApplySort( + string.IsNullOrWhiteSpace(query.SortBy) ? "Id" : query.SortBy); + + var items = await reservationsQuery + .AsNoTracking() + .ApplyPagination(query.Page, query.PageSize) + .Select(r => r.ToResponse(r.RestaurantName)) + .ToListAsync(ct); + + return (items, totalCount); + }, + new HybridCacheEntryOptions + { + LocalCacheExpiration = TimeSpan.FromMinutes(5), Expiration = TimeSpan.FromMinutes(30) + }, + tags: [Keys.Reservations], + cancellationToken: cancellationToken); + + var pagedResult = new PagedResult(items, totalCount, query.Page, query.PageSize); + return Result.Success(pagedResult); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Reservations/Query/Mappings/ReservationMappings.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Reservations/Query/Mappings/ReservationMappings.cs new file mode 100755 index 0000000..821f2ed --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Reservations/Query/Mappings/ReservationMappings.cs @@ -0,0 +1,86 @@ +using RestaurantReservation.Application.Features.Reservations.Query.Responses; +using RestaurantReservation.Domain.Reservations; + +namespace RestaurantReservation.Application.Features.Reservations.Query.Mappings; + +public static class ReservationMappings +{ + public static ReservationDetailResponse ToDetailResponse( + this Reservation reservation, + string? restaurantName = null) + { + return new ReservationDetailResponse( + reservation.Id, + reservation.RestaurantId, + restaurantName ?? "N/A", + reservation.CustomerContactInfo.FirstName.Value, + reservation.CustomerContactInfo.LastName.Value, + reservation.CustomerContactInfo.EmailAddress.Value, + reservation.CustomerContactInfo.TelephoneNumber.Value, + reservation.ReservationInfo.Date.Value, + reservation.ReservationInfo.StartTime.Value, + reservation.ReservationInfo.EndTime.Value, + reservation.ReservationCreatedAtUtc, + reservation.ReservationUpdatedAtUtc, + reservation.ReservationCanceledAtUtc, + reservation.ReservationCompletedAtUtc, + reservation.ReservationInfo.Guests.Value, + reservation.Tables.ToList().ToDetailResponse()); + } + + public static List ToDetailResponse(this List tables) + { + var reservationTables = new List(); + + foreach (var table in tables) + { + reservationTables.Add(new ReservationTableDetailResponse(table.ReservationId, table.SeatsAtTable)); + } + + return reservationTables; + } + + public static ReservationResponse ToResponse(this Reservation reservation, string? restaurantName = null) + { + return new ReservationResponse( + reservation.Id, + reservation.RestaurantId, + restaurantName ?? "N/A", + reservation.CustomerContactInfo.FirstName.Value, + reservation.CustomerContactInfo.LastName.Value, + reservation.CustomerContactInfo.EmailAddress.Value, + reservation.CustomerContactInfo.TelephoneNumber.Value, + reservation.ReservationInfo.Date.Value, + reservation.ReservationInfo.StartTime.Value, + reservation.ReservationInfo.EndTime.Value, + reservation.ReservationInfo.Guests.Value); + } + + public static RescheduledReservationDetailResponse ToRescheduledDetailResponse( + this Reservation reservation, + DateOnly? originalDate = null, + TimeOnly? originalStartTime = null, + TimeOnly? originalEndTime = null, + int? originalNumberOfGuests = null) + { + return new RescheduledReservationDetailResponse( + reservation.Id, + reservation.RestaurantId, + reservation.RestaurantName, + reservation.CustomerContactInfo.FirstName.Value, + reservation.CustomerContactInfo.LastName.Value, + reservation.CustomerContactInfo.EmailAddress.Value, + reservation.CustomerContactInfo.TelephoneNumber.Value, + originalDate ?? new DateOnly(), + originalStartTime ?? new TimeOnly(), + originalEndTime ?? new TimeOnly(), + reservation.ReservationInfo.Date.Value, + reservation.ReservationInfo.StartTime.Value, + reservation.ReservationInfo.EndTime.Value, + reservation.ReservationCreatedAtUtc, + reservation.ReservationUpdatedAtUtc, + originalNumberOfGuests ?? 0, + reservation.ReservationInfo.Guests.Value, + reservation.Tables.ToList().ToDetailResponse()); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Reservations/Query/Responses/RescheduledReservationDetailResponse.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Reservations/Query/Responses/RescheduledReservationDetailResponse.cs new file mode 100755 index 0000000..8d04a59 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Reservations/Query/Responses/RescheduledReservationDetailResponse.cs @@ -0,0 +1,21 @@ +namespace RestaurantReservation.Application.Features.Reservations.Query.Responses; + +public record RescheduledReservationDetailResponse( + Guid ReservationId, + Guid RestaurantId, + string RestaurantName, + string CustomerFirstName, + string CustomerLastName, + string CustomerEmail, + string CustomerPhoneNumber, + DateOnly OriginalReservationDate, + TimeOnly OriginalReservationStartTime, + TimeOnly OriginalReservationEndTime, + DateOnly RescheduledReservationDate, + TimeOnly RescheduledReservationStartTime, + TimeOnly RescheduledReservationEndTime, + DateTime ReservationCreatedAtUtc, + DateTime? ReservationUpdatedAtUtc, + int OriginalNumberOfGuests, + int RescheduledNumberOfGuests, + List TablesInReservation); \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Reservations/Query/Responses/ReservationDetailResponse.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Reservations/Query/Responses/ReservationDetailResponse.cs new file mode 100755 index 0000000..d4b06f9 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Reservations/Query/Responses/ReservationDetailResponse.cs @@ -0,0 +1,19 @@ +namespace RestaurantReservation.Application.Features.Reservations.Query.Responses; + +public record ReservationDetailResponse( + Guid ReservationId, + Guid RestaurantId, + string RestaurantName, + string CustomerFirstName, + string CustomerLastName, + string CustomerEmail, + string CustomerPhoneNumber, + DateOnly ReservationDate, + TimeOnly ReservationStartTime, + TimeOnly ReservationEndTime, + DateTime ReservationCreatedAtUtc, + DateTime? ReservationUpdatedAtUtc, + DateTime? ReservationCanceledAtUtc, + DateTime? ReservationCompletedAtUtc, + int NumberOfGuests, + List TablesInReservation); \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Reservations/Query/Responses/ReservationResponse.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Reservations/Query/Responses/ReservationResponse.cs new file mode 100755 index 0000000..b922f15 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Reservations/Query/Responses/ReservationResponse.cs @@ -0,0 +1,14 @@ +namespace RestaurantReservation.Application.Features.Reservations.Query.Responses; + +public record ReservationResponse( + Guid ReservationId, + Guid RestaurantId, + string RestaurantName, + string CustomerFirstName, + string CustomerLastName, + string CustomerEmail, + string CustomerPhoneNumber, + DateOnly ReservationDate, + TimeOnly ReservationStartTime, + TimeOnly ReservationEndTime, + int NumberOfGuests); \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Reservations/Query/Responses/ReservationTableDetailResponse.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Reservations/Query/Responses/ReservationTableDetailResponse.cs new file mode 100755 index 0000000..d4c49de --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Reservations/Query/Responses/ReservationTableDetailResponse.cs @@ -0,0 +1,3 @@ +namespace RestaurantReservation.Application.Features.Reservations.Query.Responses; + +public record ReservationTableDetailResponse(Guid Id, int SeatsAtTable); \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Command/Add/AddRestaurantCommand.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Command/Add/AddRestaurantCommand.cs new file mode 100755 index 0000000..9b68990 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Command/Add/AddRestaurantCommand.cs @@ -0,0 +1,9 @@ +using MediatR; + +using RestaurantReservation.Application.Features.Restaurants.Query.Response; +using RestaurantReservation.Domain.Common; +using RestaurantReservation.Domain.Restaurants; + +namespace RestaurantReservation.Application.Features.Restaurants.Command.Add; + +public record AddRestaurantCommand(string Name, RestaurantSchedule[] Schedule, TableInfo[] TableInfo) : IRequest>; \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Command/Add/AddRestaurantCommandHandler.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Command/Add/AddRestaurantCommandHandler.cs new file mode 100755 index 0000000..46fd366 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Command/Add/AddRestaurantCommandHandler.cs @@ -0,0 +1,66 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Hybrid; +using Microsoft.Extensions.Logging; + +using RestaurantReservation.Application.Abstractions; +using RestaurantReservation.Application.Features.Constants; +using RestaurantReservation.Application.Features.Restaurants.Query.Mappings; +using RestaurantReservation.Application.Features.Restaurants.Query.Response; +using RestaurantReservation.Domain.Common; +using RestaurantReservation.Domain.Restaurants; +using RestaurantReservation.Domain.Restaurants.Errors; +using RestaurantReservation.Domain.Tables; + +namespace RestaurantReservation.Application.Features.Restaurants.Command.Add; + +public sealed class AddRestaurantCommandHandler( + IApplicationDbContext context, + HybridCache cache, + ILogger logger) : IRequestHandler> +{ + public async Task> Handle( + AddRestaurantCommand command, + CancellationToken cancellationToken) + { + var alreadyExists = await context.Restaurants + .AnyAsync(r => r.Name.ToLower().Equals(command.Name.ToLower()), cancellationToken); + + if (alreadyExists) + { + logger.LogWarning("A restaurant named {Name} already exists in the system and cannot be added", + command.Name); + return Result.Failure(RestaurantErrors.AlreadyExists(command.Name)); + } + + var restaurant = Restaurant.Create(command.Name); + + restaurant.SetSchedule([.. command.Schedule]); + + foreach (var info in command.TableInfo) + { + var tables = new List
(); + for (int i = 0; i < info.NumberOfTables; i++) + { + tables.Add(restaurant.AddTable(info.NumberOfSeats)); + } + + if (!string.IsNullOrEmpty(info.GroupName)) + { + restaurant.AddTableGroup(info.GroupName, tables); + } + } + + await context.Restaurants.AddAsync(restaurant, cancellationToken); + await context.SaveChangesAsync(cancellationToken); + + logger.LogInformation("A new restaurant was added: '{Name}'. Invalidating all cache entries " + + "tagged with {Tag}", + command.Name, + Keys.Restaurants); + await cache.RemoveByTagAsync(Keys.Restaurants, cancellationToken); + + return Result.Success(restaurant.ToAddedDetailResponse()); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Command/Add/AddRestaurantCommandValidator.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Command/Add/AddRestaurantCommandValidator.cs new file mode 100755 index 0000000..fcfdc98 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Command/Add/AddRestaurantCommandValidator.cs @@ -0,0 +1,52 @@ +using FluentValidation; + +using RestaurantReservation.Domain.Restaurants; + +namespace RestaurantReservation.Application.Features.Restaurants.Command.Add; + +internal sealed class AddRestaurantCommandValidator : AbstractValidator +{ + public AddRestaurantCommandValidator() + { + RuleFor(x => x.Name) + .NotEmpty() + .WithMessage("{PropertyName} cannot be empty") + .MaximumLength(50) + .WithMessage("{PropertyName} cannot exceed 50 characters."); + + RuleFor(x => x.Schedule) + .Must(CheckForValidSchedule) + .WithMessage("{PropertyName] must have each day's opening hours begin before the days closing hours."); + + RuleFor(x => x.TableInfo) + .Must(CheckForValidTableInfo) + .WithMessage("{PropertyName} is invalid, please review your input and try again"); + } + + private static bool CheckForValidSchedule(RestaurantSchedule[] schedule) + { + if (schedule.Length != 7) return false; + + foreach (var day in schedule) + { + if (day.DailyHours[0] > day.DailyHours[1]) return false; + } + + return true; + } + + private static bool CheckForValidTableInfo(TableInfo[] tableInfo) + { + foreach (var table in tableInfo) + { + if (table.NumberOfSeats <= 0) return false; + if (table.NumberOfTables <= 0) return false; + if (!string.IsNullOrEmpty(table.GroupName)) + { + if (table.GroupName.Length > 50) return false; + } + } + + return true; + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Command/Add/TableInfo.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Command/Add/TableInfo.cs new file mode 100755 index 0000000..9deaab4 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Command/Add/TableInfo.cs @@ -0,0 +1,3 @@ +namespace RestaurantReservation.Application.Features.Restaurants.Command.Add; + +public record TableInfo(int NumberOfTables, int NumberOfSeats, string? GroupName = null); \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Command/Delete/DeleteRestaurantCommand.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Command/Delete/DeleteRestaurantCommand.cs new file mode 100755 index 0000000..c57be60 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Command/Delete/DeleteRestaurantCommand.cs @@ -0,0 +1,7 @@ +using MediatR; + +using RestaurantReservation.Domain.Common; + +namespace RestaurantReservation.Application.Features.Restaurants.Command.Delete; + +public record DeleteRestaurantCommand(Guid Id) : IRequest; \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Command/Delete/DeleteRestaurantCommandHandler.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Command/Delete/DeleteRestaurantCommandHandler.cs new file mode 100755 index 0000000..9b3e463 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Command/Delete/DeleteRestaurantCommandHandler.cs @@ -0,0 +1,43 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Hybrid; +using Microsoft.Extensions.Logging; + +using RestaurantReservation.Application.Abstractions; +using RestaurantReservation.Application.Features.Constants; +using RestaurantReservation.Domain.Common; +using RestaurantReservation.Domain.Restaurants.Errors; + +namespace RestaurantReservation.Application.Features.Restaurants.Command.Delete; + +public sealed class DeleteRestaurantCommandHandler( + IApplicationDbContext context, + HybridCache cache, + ILogger logger) : IRequestHandler +{ + public async Task Handle( + DeleteRestaurantCommand command, + CancellationToken cancellationToken) + { + var restaurant = await context.Restaurants + .FirstOrDefaultAsync(r => r.Id == command.Id, cancellationToken); + + if (restaurant is null) + { + logger.LogWarning("Restaurant with Id {Id} not found, nothing deleted", command.Id); + return Result.Failure(RestaurantErrors.NotFound()); + } + + context.Restaurants.Remove(restaurant); + await context.SaveChangesAsync(cancellationToken); + + logger.LogInformation("Restaurant {Name} was deleted from the system. Invalidating " + + "all cached entries tagged with {Tag}", + restaurant.Name, + Keys.Restaurants); + await cache.RemoveByTagAsync(Keys.Restaurants, cancellationToken); + + return Result.Success(); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Command/Update/UpdateRestaurantCommand.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Command/Update/UpdateRestaurantCommand.cs new file mode 100755 index 0000000..ac0faab --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Command/Update/UpdateRestaurantCommand.cs @@ -0,0 +1,8 @@ +using MediatR; + +using RestaurantReservation.Domain.Common; +using RestaurantReservation.Domain.Restaurants; + +namespace RestaurantReservation.Application.Features.Restaurants.Command.Update; + +public record UpdateRestaurantCommand(Guid Id, string? Name, RestaurantSchedule[]? Schedule) : IRequest; \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Command/Update/UpdateRestaurantCommandHandler.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Command/Update/UpdateRestaurantCommandHandler.cs new file mode 100755 index 0000000..bbde3b6 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Command/Update/UpdateRestaurantCommandHandler.cs @@ -0,0 +1,45 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Hybrid; +using Microsoft.Extensions.Logging; + +using RestaurantReservation.Application.Abstractions; +using RestaurantReservation.Application.Features.Constants; +using RestaurantReservation.Domain.Common; +using RestaurantReservation.Domain.Restaurants.Errors; + +namespace RestaurantReservation.Application.Features.Restaurants.Command.Update; + +public sealed class UpdateRestaurantCommandHandler( + IApplicationDbContext context, + HybridCache cache, + ILogger logger) : IRequestHandler +{ + public async Task Handle( + UpdateRestaurantCommand command, + CancellationToken cancellationToken) + { + var restaurant = await context.Restaurants + .FirstOrDefaultAsync(r => r.Id == command.Id, cancellationToken); + + if (restaurant is null) + { + logger.LogWarning("Restaurant with id {Id} is not found in the system, nothing updated", command.Id); + return Result.Failure(RestaurantErrors.NotFound()); + } + + restaurant.Update(command.Name, command.Schedule?.ToList()); + + await context.SaveChangesAsync(cancellationToken); + + logger.LogInformation("Restaurant {Name} was updated in the system, " + + "Invalidating all cache entries tagged with {Tag}", + restaurant.Name, + Keys.Restaurants); + + await cache.RemoveByTagAsync(Keys.Restaurants, cancellationToken); + + return Result.Success(); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Command/Update/UpdateRestaurantCommandValidator.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Command/Update/UpdateRestaurantCommandValidator.cs new file mode 100755 index 0000000..05f69e8 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Command/Update/UpdateRestaurantCommandValidator.cs @@ -0,0 +1,33 @@ +using FluentValidation; + +using RestaurantReservation.Domain.Restaurants; + +namespace RestaurantReservation.Application.Features.Restaurants.Command.Update; + +internal sealed class UpdateRestaurantCommandValidator : AbstractValidator +{ + public UpdateRestaurantCommandValidator() + { + RuleFor(x => x.Name) + .MaximumLength(50) + .WithMessage("{PropertyName} cannot exceed 50 characters."); + + RuleFor(x => x.Schedule) + .Must(CheckForValidSchedule) + .WithMessage("{PropertyName} must have each day's opening begin before the day's closing hours."); + } + + private static bool CheckForValidSchedule(RestaurantSchedule[]? schedule) + { + if (schedule is not null) + { + if (schedule.Length != 7) return false; + foreach (var day in schedule) + { + if (day.DailyHours[0] > day.DailyHours[1]) return false; + } + } + + return true; + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Command/Update/UpdateRestaurantQuery.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Command/Update/UpdateRestaurantQuery.cs new file mode 100755 index 0000000..ad46442 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Command/Update/UpdateRestaurantQuery.cs @@ -0,0 +1,5 @@ +using RestaurantReservation.Domain.Restaurants; + +namespace RestaurantReservation.Application.Features.Restaurants.Command.Update; + +public record UpdateRestaurantQuery(string? Name, RestaurantSchedule[]? Schedule); \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Query/Get/GetRestaurantQuery.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Query/Get/GetRestaurantQuery.cs new file mode 100755 index 0000000..6906ab7 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Query/Get/GetRestaurantQuery.cs @@ -0,0 +1,11 @@ +using MediatR; + +using RestaurantReservation.Application.Features.Restaurants.Query.Response; +using RestaurantReservation.Domain.Common; + +namespace RestaurantReservation.Application.Features.Restaurants.Query.Get; + +public record GetRestaurantQuery( + Guid Id, + int TablePage = 1, + int TablePageSize = 10) : IRequest>; \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Query/Get/GetRestaurantQueryHandler.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Query/Get/GetRestaurantQueryHandler.cs new file mode 100755 index 0000000..4209744 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Query/Get/GetRestaurantQueryHandler.cs @@ -0,0 +1,68 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Hybrid; +using Microsoft.Extensions.Logging; + +using RestaurantReservation.Application.Abstractions; +using RestaurantReservation.Application.Extensions; +using RestaurantReservation.Application.Features.Constants; +using RestaurantReservation.Application.Features.Restaurants.Query.Mappings; +using RestaurantReservation.Application.Features.Restaurants.Query.Response; +using RestaurantReservation.Domain.Common; +using RestaurantReservation.Domain.Restaurants.Errors; + +namespace RestaurantReservation.Application.Features.Restaurants.Query.Get; + +public sealed class GetRestaurantQueryHandler( + IApplicationDbContext context, + HybridCache cache, + ILogger logger) : IRequestHandler> +{ + public async Task> Handle( + GetRestaurantQuery query, + CancellationToken cancellationToken) + { + var cacheKey = $"restaurant:{query.Id}_tablePage={query.TablePage}_tablePageSize={query.TablePageSize}"; + + var restaurant = await cache.GetOrCreateAsync( + cacheKey, + async ct => + { + logger.LogInformation("Cache miss for key: {Key}. Retrieving from database.", cacheKey); + var restaurantFromDb = await context.Restaurants + .AsNoTracking() + .Include(r => r.TableGroups) + .AsSplitQuery() + .FirstOrDefaultAsync(r => r.Id == query.Id, ct); + + var totalTableCount = await context.Tables + .AsNoTracking() + .CountAsync(t => t.RestaurantId == query.Id, ct); + + var restaurantTablesFromDb = await context.Tables + .AsNoTracking() + .Where(t => t.RestaurantId == query.Id) + .ApplySort("TableGroupName asc") + .ApplyPagination(query.TablePage, query.TablePageSize) + .ToListAsync(ct); + + return restaurantFromDb?.ToDetailResponse( + restaurantTablesFromDb, + totalTableCount, + query.TablePage, + query.TablePageSize); + }, + new HybridCacheEntryOptions + { + LocalCacheExpiration = TimeSpan.FromMinutes(4), + Expiration = TimeSpan.FromMinutes(30) + }, + tags: [Keys.Restaurants], + cancellationToken: cancellationToken); + + return restaurant is null + ? Result.Failure(RestaurantErrors.NotFound()) + : Result.Success(restaurant); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Query/GetAll/GetAllRestaurantsQuery.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Query/GetAll/GetAllRestaurantsQuery.cs new file mode 100755 index 0000000..fb8eca9 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Query/GetAll/GetAllRestaurantsQuery.cs @@ -0,0 +1,12 @@ +using MediatR; + +using RestaurantReservation.Application.Features.Restaurants.Query.Response; +using RestaurantReservation.Domain.Common; + +namespace RestaurantReservation.Application.Features.Restaurants.Query.GetAll; + +public record GetAllRestaurantsQuery( + int Page = 1, + int PageSize = 10, + string? SortBy = null, + string? Name = null) : IRequest>>; \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Query/GetAll/GetAllRestaurantsQueryHandler.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Query/GetAll/GetAllRestaurantsQueryHandler.cs new file mode 100755 index 0000000..1255d92 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Query/GetAll/GetAllRestaurantsQueryHandler.cs @@ -0,0 +1,70 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Hybrid; +using Microsoft.Extensions.Logging; + +using RestaurantReservation.Application.Abstractions; +using RestaurantReservation.Application.Extensions; +using RestaurantReservation.Application.Features.Constants; +using RestaurantReservation.Application.Features.Restaurants.Query.Mappings; +using RestaurantReservation.Application.Features.Restaurants.Query.Response; +using RestaurantReservation.Domain.Common; + +namespace RestaurantReservation.Application.Features.Restaurants.Query.GetAll; + +public sealed class GetAllRestaurantsQueryHandler( + IApplicationDbContext context, + HybridCache cache, + ILogger logger) : IRequestHandler>> +{ + public async Task>> Handle( + GetAllRestaurantsQuery query, + CancellationToken cancellationToken) + { + var allRestaurantsCacheKey = $"restaurants_page={query.Page}_pageSize={query.PageSize}"; + + (List items, int totalCount) = await cache.GetOrCreateAsync( + allRestaurantsCacheKey, + async ct => + { + logger.LogInformation("Cache miss for key: {Key}, Retrieving from the database", allRestaurantsCacheKey); + var restaurantsQuery = context.Restaurants + .AsNoTracking() + .Include(r => r.Tables) + .AsSplitQuery() + .AsQueryable(); + + if (!string.IsNullOrEmpty(query.Name)) + { + restaurantsQuery = restaurantsQuery.ApplySearch(query.Name); + } + + var totalCount = await restaurantsQuery + .AsNoTracking() + .CountAsync(ct); + + restaurantsQuery = restaurantsQuery.ApplySort( + string.IsNullOrWhiteSpace(query.SortBy) ? "Id" : query.SortBy); + + var items = await restaurantsQuery + .AsNoTracking() + .Include(r => r.Tables) + .ApplyPagination(query.Page, query.PageSize) + .Select(r => r.ToResponse()) + .ToListAsync(ct); + + return (items, totalCount); + }, + new HybridCacheEntryOptions + { + LocalCacheExpiration = TimeSpan.FromMinutes(5), + Expiration = TimeSpan.FromMinutes(30) + }, + tags: [Keys.Restaurants], + cancellationToken: cancellationToken); + + var pagedResult = new PagedResult(items, totalCount, query.Page, query.PageSize); + return Result.Success(pagedResult); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Query/Mappings/ResponseMappings.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Query/Mappings/ResponseMappings.cs new file mode 100755 index 0000000..9efbfe2 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Query/Mappings/ResponseMappings.cs @@ -0,0 +1,99 @@ +using RestaurantReservation.Application.Features.Restaurants.Query.Response; +using RestaurantReservation.Domain.Common; +using RestaurantReservation.Domain.Restaurants; +using RestaurantReservation.Domain.Tables; + +namespace RestaurantReservation.Application.Features.Restaurants.Query.Mappings; + +public static class ResponseMappings +{ + public static RestaurantDetailResponse ToDetailResponse( + this Restaurant restaurant, + List
tables, + int totalTableCount, + int tablePage, + int tablePageSize) + { + return new RestaurantDetailResponse( + restaurant.Id, + restaurant.Name, + restaurant.TableGroups.ToTableGroupNameResponse(), + restaurant.Schedule.ToScheduleResponseCollection(), + tables.ToPagedTableResponseCollection( + totalTableCount, + tablePage, + tablePageSize)); + } + + public static RestaurantAddedResponse ToAddedDetailResponse(this Restaurant restaurant) + { + return new RestaurantAddedResponse( + restaurant.Id, + restaurant.Name, + restaurant.Tables.Count, + restaurant.TableGroups.ToTableGroupNameResponse(), + restaurant.Schedule.ToScheduleResponseCollection()); + } + + public static RestaurantResponse ToResponse(this Restaurant restaurant) + { + return new RestaurantResponse( + restaurant.Id, + restaurant.Name, + restaurant.Tables.Count); + } + + public static List ToResponseCollection(IEnumerable restaurants) + { + return [.. restaurants.Select(r => r.ToResponse())]; + } + + public static List ToScheduleResponseCollection( + this IEnumerable schedules) + { + return [.. schedules.Select(s => s.ToScheduleResponse())]; + } + + public static List ToTableResponseCollection(this IEnumerable
tables) + { + return [.. tables.Select(t => t.ToTableResponse()).OrderBy(t => t.TableGroupName)]; + } + + public static PagedResult ToPagedTableResponseCollection( + this List
tables, + int totalTableCount, + int tablePage, + int tablePageSize) + { + var tableItems = tables.Select(t => t.ToTableResponse()) + .ToList(); + + return new PagedResult( + tableItems, + totalTableCount, + tablePage, + tablePageSize); + } + + public static RestaurantScheduleResponse ToScheduleResponse(this RestaurantSchedule schedule) + { + return new RestaurantScheduleResponse( + schedule.Day.ToString(), + schedule.DailyHours); + } + + public static RestaurantTableResponse ToTableResponse(this Table table) + { + var groupName = table.TableGroupName ?? "None"; + + return new RestaurantTableResponse( + table.Id, + table.SeatsAtTable, + groupName); + } + + public static List ToTableGroupNameResponse(this IEnumerable tableGroups) + { + return [.. tableGroups.Select(tg => tg.Name).ToList()]; + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Query/Response/RestaurantAddedResponse.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Query/Response/RestaurantAddedResponse.cs new file mode 100644 index 0000000..4c2b357 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Query/Response/RestaurantAddedResponse.cs @@ -0,0 +1,8 @@ +namespace RestaurantReservation.Application.Features.Restaurants.Query.Response; + +public record RestaurantAddedResponse( + Guid Id, + string Name, + int TotalTables, + List TableGroups, + List Schedule); \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Query/Response/RestaurantDetailResponse.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Query/Response/RestaurantDetailResponse.cs new file mode 100755 index 0000000..77e4363 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Query/Response/RestaurantDetailResponse.cs @@ -0,0 +1,10 @@ +using RestaurantReservation.Domain.Common; + +namespace RestaurantReservation.Application.Features.Restaurants.Query.Response; + +public record RestaurantDetailResponse( + Guid Id, + string Name, + List TableGroups, + List Schedule, + PagedResult TableInfo); \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Query/Response/RestaurantResponse.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Query/Response/RestaurantResponse.cs new file mode 100755 index 0000000..a56b03a --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Query/Response/RestaurantResponse.cs @@ -0,0 +1,6 @@ +namespace RestaurantReservation.Application.Features.Restaurants.Query.Response; + +public record RestaurantResponse( + Guid Id, + string Name, + int NumberOfTables); \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Query/Response/RestaurantScheduleResponse.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Query/Response/RestaurantScheduleResponse.cs new file mode 100755 index 0000000..c5d4929 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Query/Response/RestaurantScheduleResponse.cs @@ -0,0 +1,3 @@ +namespace RestaurantReservation.Application.Features.Restaurants.Query.Response; + +public record RestaurantScheduleResponse(string Day, TimeOnly?[] DailyHours); \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Query/Response/RestaurantTableResponse.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Query/Response/RestaurantTableResponse.cs new file mode 100755 index 0000000..2bf17aa --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Query/Response/RestaurantTableResponse.cs @@ -0,0 +1,3 @@ +namespace RestaurantReservation.Application.Features.Restaurants.Query.Response; + +public record RestaurantTableResponse(Guid Id, int SeatsAtTable, string? TableGroupName = null); \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/TableGroups/Command/Add/AddTableGroupCommand.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/TableGroups/Command/Add/AddTableGroupCommand.cs new file mode 100755 index 0000000..30282e1 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/TableGroups/Command/Add/AddTableGroupCommand.cs @@ -0,0 +1,8 @@ +using MediatR; + +using RestaurantReservation.Application.Features.Restaurants.TableGroups.Query.Responses; +using RestaurantReservation.Domain.Common; + +namespace RestaurantReservation.Application.Features.Restaurants.TableGroups.Command.Add; + +public record AddTableGroupCommand(Guid RestaurantId, string Name, int? NumberOfTables = null, int? NumberOfSeats = null) : IRequest>; \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/TableGroups/Command/Add/AddTableGroupCommandHandler.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/TableGroups/Command/Add/AddTableGroupCommandHandler.cs new file mode 100755 index 0000000..6682fb2 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/TableGroups/Command/Add/AddTableGroupCommandHandler.cs @@ -0,0 +1,81 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Hybrid; +using Microsoft.Extensions.Logging; + +using RestaurantReservation.Application.Abstractions; +using RestaurantReservation.Application.Features.Constants; +using RestaurantReservation.Application.Features.Restaurants.TableGroups.Query.Mappings; +using RestaurantReservation.Application.Features.Restaurants.TableGroups.Query.Responses; +using RestaurantReservation.Domain.Common; +using RestaurantReservation.Domain.Restaurants.Errors; +using RestaurantReservation.Domain.Tables; +using RestaurantReservation.Domain.Tables.Errors; + +namespace RestaurantReservation.Application.Features.Restaurants.TableGroups.Command.Add; + +public sealed class AddTableGroupCommandHandler( + IApplicationDbContext context, + HybridCache cache, + ILogger logger) : IRequestHandler> +{ + public async Task> Handle( + AddTableGroupCommand command, + CancellationToken cancellationToken) + { + var restaurant = await context.Restaurants + .Include(r => r.Tables) + .Include(r => r.TableGroups) + .FirstOrDefaultAsync(r => r.Id == command.RestaurantId, cancellationToken); + + if (restaurant is null) + { + logger.LogWarning("Restaurant with id {Id} not found in the system unable to add table group", command.RestaurantId); + return Result.Failure(RestaurantErrors.NotFound()); + } + + var tableGroupAlreadyExists = await context.TableGroups + .AsNoTracking() + .AnyAsync( + tg => tg.RestaurantId == command.RestaurantId && tg.Name.ToLower().Equals(command.Name.ToLower()), + cancellationToken); + + if (tableGroupAlreadyExists) + { + logger.LogWarning("Table group {GName} already exists in {RName}, no table group was added", + command.Name, + restaurant.Name); + return Result.Failure(TableGroupErrors.TableGroupAlreadyExists(command.Name)); + } + + var tableGroup = TableGroup.Create(command.RestaurantId, command.Name); + + if (command is { NumberOfTables: not null, NumberOfSeats: not null }) + { + var tables = new List
(); + + for (int i = 0; i < command.NumberOfTables.Value; i++) + { + tables.Add(restaurant.AddTable(command.NumberOfSeats.Value)); + } + + tableGroup.AddTables(tables); + } + + await context.TableGroups.AddAsync(tableGroup, cancellationToken); + await context.SaveChangesAsync(cancellationToken); + + logger.LogInformation("A new table group was added to {RName}. Invalidating cache for keys: {RKey}, {TGKey}, {TKey}", + restaurant.Name, + Keys.Restaurants, + Keys.TableGroups, + Keys.Tables); + + await cache.RemoveByTagAsync(Keys.Restaurants, cancellationToken); + await cache.RemoveByTagAsync(Keys.TableGroups, cancellationToken); + await cache.RemoveByTagAsync(Keys.Tables, cancellationToken); + + return Result.Success(tableGroup.ToDetailResponse()); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/TableGroups/Command/Add/AddTableGroupCommandValidator.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/TableGroups/Command/Add/AddTableGroupCommandValidator.cs new file mode 100755 index 0000000..b18a1bd --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/TableGroups/Command/Add/AddTableGroupCommandValidator.cs @@ -0,0 +1,15 @@ +using FluentValidation; + +namespace RestaurantReservation.Application.Features.Restaurants.TableGroups.Command.Add; + +internal sealed class AddTableGroupCommandValidator : AbstractValidator +{ + public AddTableGroupCommandValidator() + { + RuleFor(x => x.Name) + .NotEmpty() + .WithMessage("{PropertyName} cannot be empty") + .MaximumLength(50) + .WithMessage("{PropertyName} cannot exceed 50 characters"); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/TableGroups/Command/Add/AddTableGroupQuery.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/TableGroups/Command/Add/AddTableGroupQuery.cs new file mode 100644 index 0000000..eb463f1 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/TableGroups/Command/Add/AddTableGroupQuery.cs @@ -0,0 +1,3 @@ +namespace RestaurantReservation.Application.Features.Restaurants.TableGroups.Command.Add; + +public record AddTableGroupQuery(string Name, int? NumberOfTables, int? NumberOfSeats); \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/TableGroups/Command/Delete/DeleteTableGroupCommand.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/TableGroups/Command/Delete/DeleteTableGroupCommand.cs new file mode 100755 index 0000000..4c0cc6c --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/TableGroups/Command/Delete/DeleteTableGroupCommand.cs @@ -0,0 +1,7 @@ +using MediatR; + +using RestaurantReservation.Domain.Common; + +namespace RestaurantReservation.Application.Features.Restaurants.TableGroups.Command.Delete; + +public record DeleteTableGroupCommand(Guid RestaurantId, Guid TableId) : IRequest; \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/TableGroups/Command/Delete/DeleteTableGroupCommandHandler.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/TableGroups/Command/Delete/DeleteTableGroupCommandHandler.cs new file mode 100755 index 0000000..636166d --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/TableGroups/Command/Delete/DeleteTableGroupCommandHandler.cs @@ -0,0 +1,67 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Hybrid; +using Microsoft.Extensions.Logging; + +using RestaurantReservation.Application.Abstractions; +using RestaurantReservation.Application.Features.Constants; +using RestaurantReservation.Domain.Common; +using RestaurantReservation.Domain.Restaurants.Errors; +using RestaurantReservation.Domain.Tables.Errors; + +namespace RestaurantReservation.Application.Features.Restaurants.TableGroups.Command.Delete; + +public sealed class DeleteTableGroupCommandHandler( + IApplicationDbContext context, + HybridCache cache, + ILogger logger) : IRequestHandler +{ + public async Task Handle( + DeleteTableGroupCommand command, + CancellationToken cancellationToken) + { + var restaurant = await context.Restaurants + .AsNoTracking() + .FirstOrDefaultAsync(r => r.Id == command.RestaurantId, cancellationToken); + + if (restaurant is null) + { + logger.LogWarning("Restaurant with id {Id} not found, unable to delete table group", + command.RestaurantId); + return Result.Failure(RestaurantErrors.NotFound()); + } + + var tableGroup = await context.TableGroups + .Include(tg => tg.Tables) + .FirstOrDefaultAsync(tg => tg.Id == command.TableId && tg.RestaurantId == command.RestaurantId, + cancellationToken); + + if (tableGroup is null) + { + logger.LogWarning("Table group with id {Id} not found in {RName}, unable to delete table group", + command.TableId, + restaurant.Name); + return Result.Failure(TableGroupErrors.TableGroupNotFound()); + } + + tableGroup.RemoveTables(); + + context.TableGroups.Remove(tableGroup); + await context.SaveChangesAsync(cancellationToken); + + logger.LogInformation("Table group {GName} has been removed from {RName}. Invalidating cache for keys: " + + "{RKey}, {TGKey} and {TKey}", + tableGroup.Name, + restaurant.Name, + Keys.Restaurants, + Keys.TableGroups, + Keys.Tables); + + await cache.RemoveByTagAsync(Keys.Restaurants, cancellationToken); + await cache.RemoveByTagAsync(Keys.TableGroups, cancellationToken); + await cache.RemoveByTagAsync(Keys.Tables, cancellationToken); + + return Result.Success(); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/TableGroups/Command/Update/UpdateTableGroupCommand.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/TableGroups/Command/Update/UpdateTableGroupCommand.cs new file mode 100755 index 0000000..7ef5f33 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/TableGroups/Command/Update/UpdateTableGroupCommand.cs @@ -0,0 +1,7 @@ +using MediatR; + +using RestaurantReservation.Domain.Common; + +namespace RestaurantReservation.Application.Features.Restaurants.TableGroups.Command.Update; + +public record UpdateTableGroupCommand(Guid RestaurantId, Guid TableGroupId, string? GroupName, int? NumberOfTablesToAdd, int? NumberOfSeats) : IRequest; \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/TableGroups/Command/Update/UpdateTableGroupCommandHandler.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/TableGroups/Command/Update/UpdateTableGroupCommandHandler.cs new file mode 100755 index 0000000..44a793c --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/TableGroups/Command/Update/UpdateTableGroupCommandHandler.cs @@ -0,0 +1,99 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Hybrid; +using Microsoft.Extensions.Logging; + +using RestaurantReservation.Application.Abstractions; +using RestaurantReservation.Application.Features.Constants; +using RestaurantReservation.Application.Features.Restaurants.TableGroups.Query.Responses; +using RestaurantReservation.Domain.Common; +using RestaurantReservation.Domain.Restaurants.Errors; +using RestaurantReservation.Domain.Tables; +using RestaurantReservation.Domain.Tables.Errors; + +namespace RestaurantReservation.Application.Features.Restaurants.TableGroups.Command.Update; + +public sealed class UpdateTableGroupCommandHandler( + IApplicationDbContext context, + HybridCache cache, + ILogger logger) : IRequestHandler +{ + public async Task Handle( + UpdateTableGroupCommand command, + CancellationToken cancellationToken) + { + var restaurant = await context.Restaurants + .AsNoTracking() + .FirstOrDefaultAsync(r => r.Id == command.RestaurantId, cancellationToken); + + if (restaurant is null) + { + logger.LogWarning("Restaurant with Id {Id} not found, unable to update table group", command.RestaurantId); + return Result.Failure(RestaurantErrors.NotFound()); + } + + if (!string.IsNullOrEmpty(command.GroupName)) + { + var tableGroupNameAlreadyTaken = await context.TableGroups + .AsNoTracking() + .AnyAsync(tg => tg.RestaurantId == command.RestaurantId + && tg.Name.ToLower().Equals(command.GroupName.ToLower()), cancellationToken); + + if (tableGroupNameAlreadyTaken) + { + logger.LogWarning("There is already a table group by the name {GName} in {RName}, cannot update this " + + "table group's name to already reserved table group name", + command.GroupName, + restaurant.Name); + return Result.Failure( + TableGroupErrors.TableGroupNameAlreadyTaken(command.GroupName)); + } + } + + var tableGroup = await context.TableGroups + .Include(tg => tg.Tables) + .FirstOrDefaultAsync(tg => tg.Id == command.TableGroupId && tg.RestaurantId == command.RestaurantId, + cancellationToken); + + if (tableGroup is null) + { + logger.LogInformation("Table group {GName} not found for {RName}, unable to update table group", + command.GroupName, + restaurant.Name); + return Result.Failure(TableGroupErrors.TableGroupNotFound()); + } + + List
? tables = null; + + if (command is { NumberOfTablesToAdd: not null, NumberOfSeats: not null }) + { + tables = []; + for (int i = 0; i < command.NumberOfTablesToAdd.Value; i++) + { + tables.Add(restaurant.AddTable(command.NumberOfSeats.Value)); + } + } + + tableGroup.Update(command.GroupName, tables); + + if (tables is not null) await context.Tables.AddRangeAsync(tables, cancellationToken); + + context.TableGroups.Update(tableGroup); + await context.SaveChangesAsync(cancellationToken); + + logger.LogInformation("Table group with Id {GId} was updated in {RName}, invalidating cache for " + + "{RKey}, {TGKey} and {TKey}", + command.TableGroupId, + restaurant.Name, + Keys.Restaurants, + Keys.TableGroups, + Keys.Tables); + + await cache.RemoveByTagAsync(Keys.Restaurants, cancellationToken); + await cache.RemoveByTagAsync(Keys.TableGroups, cancellationToken); + await cache.RemoveByTagAsync(Keys.Tables, cancellationToken); + + return Result.Success(); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/TableGroups/Command/Update/UpdateTableGroupCommandValidator.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/TableGroups/Command/Update/UpdateTableGroupCommandValidator.cs new file mode 100755 index 0000000..49b5ead --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/TableGroups/Command/Update/UpdateTableGroupCommandValidator.cs @@ -0,0 +1,13 @@ +using FluentValidation; + +namespace RestaurantReservation.Application.Features.Restaurants.TableGroups.Command.Update; + +internal sealed class UpdateTableGroupCommandValidator : AbstractValidator +{ + public UpdateTableGroupCommandValidator() + { + RuleFor(x => x.GroupName) + .MaximumLength(50) + .WithMessage("{PropertyName} cannot exceed 50 characters"); + } +} diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/TableGroups/Command/Update/UpdateTableGroupQuery.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/TableGroups/Command/Update/UpdateTableGroupQuery.cs new file mode 100644 index 0000000..e7caac3 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/TableGroups/Command/Update/UpdateTableGroupQuery.cs @@ -0,0 +1,3 @@ +namespace RestaurantReservation.Application.Features.Restaurants.TableGroups.Command.Update; + +public record UpdateTableGroupQuery(string? GroupName, int? NumberOfTablesToAdd, int? NumberOfSeats); \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/TableGroups/Query/Get/GetTableGroupQuery.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/TableGroups/Query/Get/GetTableGroupQuery.cs new file mode 100755 index 0000000..2c4adbc --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/TableGroups/Query/Get/GetTableGroupQuery.cs @@ -0,0 +1,8 @@ +using MediatR; + +using RestaurantReservation.Application.Features.Restaurants.TableGroups.Query.Responses; +using RestaurantReservation.Domain.Common; + +namespace RestaurantReservation.Application.Features.Restaurants.TableGroups.Query.Get; + +public record GetTableGroupQuery(Guid RestaurantId, Guid TableGroupId) : IRequest>; \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/TableGroups/Query/Get/GetTableGroupQueryHandler.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/TableGroups/Query/Get/GetTableGroupQueryHandler.cs new file mode 100755 index 0000000..56905b1 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/TableGroups/Query/Get/GetTableGroupQueryHandler.cs @@ -0,0 +1,52 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Hybrid; +using Microsoft.Extensions.Logging; + +using RestaurantReservation.Application.Abstractions; +using RestaurantReservation.Application.Features.Constants; +using RestaurantReservation.Application.Features.Restaurants.TableGroups.Query.Mappings; +using RestaurantReservation.Application.Features.Restaurants.TableGroups.Query.Responses; +using RestaurantReservation.Domain.Common; +using RestaurantReservation.Domain.Tables.Errors; + +namespace RestaurantReservation.Application.Features.Restaurants.TableGroups.Query.Get; + +public sealed class GetTableGroupQueryHandler( + IApplicationDbContext context, + HybridCache cache, + ILogger logger) : IRequestHandler> +{ + public async Task> Handle( + GetTableGroupQuery query, + CancellationToken cancellationToken) + { + var cacheKey = $"restaurant:{query.RestaurantId}_tableGroup:{query.TableGroupId}"; + + var tableGroup = await cache.GetOrCreateAsync( + cacheKey, + async ct => + { + logger.LogInformation("Cache miss for key: {Key}. Retrieving from database.", cacheKey); + var tableGroupFromDb = await context.TableGroups + .AsNoTracking() + .Include(tg => tg.Restaurant) + .Include(tg => tg.Tables) + .FirstOrDefaultAsync(tg => tg.Id == query.TableGroupId && tg.RestaurantId == query.RestaurantId, + ct); + return tableGroupFromDb?.ToDetailResponse(); + }, + new HybridCacheEntryOptions + { + LocalCacheExpiration = TimeSpan.FromMinutes(4), + Expiration = TimeSpan.FromMinutes(30) + }, + tags: [Keys.TableGroups], + cancellationToken: cancellationToken); + + return tableGroup is null + ? Result.Failure(TableGroupErrors.TableGroupNotFound()) + : Result.Success(tableGroup); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/TableGroups/Query/GetAll/GetAllTableGroupsQuery.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/TableGroups/Query/GetAll/GetAllTableGroupsQuery.cs new file mode 100755 index 0000000..9f57567 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/TableGroups/Query/GetAll/GetAllTableGroupsQuery.cs @@ -0,0 +1,13 @@ +using MediatR; + +using RestaurantReservation.Application.Features.Restaurants.TableGroups.Query.Responses; +using RestaurantReservation.Domain.Common; + +namespace RestaurantReservation.Application.Features.Restaurants.TableGroups.Query.GetAll; + +public record GetAllTableGroupsQuery( + Guid RestaurantId, + int Page = 1, + int PageSize = 10, + string? SortBy = null, + string? Name = null) : IRequest>>; diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/TableGroups/Query/GetAll/GetAllTableGroupsQueryHandler.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/TableGroups/Query/GetAll/GetAllTableGroupsQueryHandler.cs new file mode 100755 index 0000000..ff3f730 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/TableGroups/Query/GetAll/GetAllTableGroupsQueryHandler.cs @@ -0,0 +1,79 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Hybrid; +using Microsoft.Extensions.Logging; + +using RestaurantReservation.Application.Abstractions; +using RestaurantReservation.Application.Extensions; +using RestaurantReservation.Application.Features.Constants; +using RestaurantReservation.Application.Features.Restaurants.TableGroups.Query.Mappings; +using RestaurantReservation.Application.Features.Restaurants.TableGroups.Query.Responses; +using RestaurantReservation.Domain.Common; +using RestaurantReservation.Domain.Tables.Errors; + +namespace RestaurantReservation.Application.Features.Restaurants.TableGroups.Query.GetAll; + +public sealed class GetAllTableGroupsQueryHandler( + IApplicationDbContext context, + HybridCache cache, + ILogger logger) : IRequestHandler>> +{ + public async Task>> Handle( + GetAllTableGroupsQuery query, + CancellationToken cancellationToken) + { + var allTableGroupsQuery = + $"restaurant:{query.RestaurantId}_tableGroups_page={query.Page}_pageSize={query.PageSize}"; + + (List items, int totalCount) = await cache.GetOrCreateAsync( + allTableGroupsQuery, + async ct => + { + logger.LogInformation("Cache miss for key: {Key}. Retrieving from the database.", allTableGroupsQuery); + var tableGroupsQuery = context.TableGroups + .AsNoTracking() + .Where(tg => tg.RestaurantId == query.RestaurantId) + .Include(tg => tg.Restaurant) + .Include(tg => tg.Tables) + .AsQueryable(); + + if (!string.IsNullOrWhiteSpace(query.Name)) + { + tableGroupsQuery = tableGroupsQuery.ApplySearch(query.Name); + } + + var totalCount = await tableGroupsQuery + .AsNoTracking() + .CountAsync(ct); + + tableGroupsQuery = tableGroupsQuery.ApplySort( + string.IsNullOrWhiteSpace(query.SortBy) ? "Id" : query.SortBy); + + var items = await tableGroupsQuery + .AsNoTracking() + .Include(tg => tg.Tables) + .ApplyPagination(query.Page, query.PageSize) + .Select(tg => tg.ToResponse()) + .ToListAsync(ct); + + return (items, totalCount); + }, + new HybridCacheEntryOptions + { + LocalCacheExpiration = TimeSpan.FromMinutes(5), + Expiration = TimeSpan.FromMinutes(30) + }, + tags: [Keys.TableGroups], + cancellationToken: cancellationToken); + + if (items.Count == 0) + { + logger.LogWarning("There were no table groups found for a restaurant with Id {Id}", query.RestaurantId); + return Result.Failure>(TableGroupErrors.TableGroupsNotFound()); + } + + var pagedResult = new PagedResult(items, totalCount, query.Page, query.PageSize); + return Result.Success(pagedResult); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/TableGroups/Query/Mappings/ResponseMappings.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/TableGroups/Query/Mappings/ResponseMappings.cs new file mode 100755 index 0000000..6c52a7e --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/TableGroups/Query/Mappings/ResponseMappings.cs @@ -0,0 +1,39 @@ +using RestaurantReservation.Application.Features.Restaurants.Query.Mappings; +using RestaurantReservation.Application.Features.Restaurants.TableGroups.Query.Responses; +using RestaurantReservation.Domain.Tables; + +namespace RestaurantReservation.Application.Features.Restaurants.TableGroups.Query.Mappings; + +public static class ResponseMappings +{ + public static TableGroupResponse ToResponse(this TableGroup group) + { + return new TableGroupResponse( + group.Id, + group.RestaurantId, + group.Restaurant?.Name ?? "N/A", + group.Name, + group.Tables.Count); + } + + public static TableGroupDetailResponse ToDetailResponse(this TableGroup group) + { + return new TableGroupDetailResponse( + group.Id, + group.RestaurantId, + group.Restaurant?.Name ?? "N/A", + group.Name, + group.Tables.Count, + group.Tables.ToTableResponseCollection()); + } + + public static List ToResponseCollection(this IEnumerable groups) + { + return [.. groups.Select(g => g.ToResponse())]; + } + + public static List ToDetailResponseCollection(this IEnumerable groups) + { + return [.. groups.Select(g => g.ToDetailResponse())]; + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/TableGroups/Query/Responses/TableGroupDetailResponse.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/TableGroups/Query/Responses/TableGroupDetailResponse.cs new file mode 100755 index 0000000..b60d767 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/TableGroups/Query/Responses/TableGroupDetailResponse.cs @@ -0,0 +1,5 @@ +using RestaurantReservation.Application.Features.Restaurants.Query.Response; + +namespace RestaurantReservation.Application.Features.Restaurants.TableGroups.Query.Responses; + +public record TableGroupDetailResponse(Guid Id, Guid RestaurantId, string RestaurantName, string GroupName, int NumberOfTables, List Tables); \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/TableGroups/Query/Responses/TableGroupResponse.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/TableGroups/Query/Responses/TableGroupResponse.cs new file mode 100755 index 0000000..7ebf07f --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/TableGroups/Query/Responses/TableGroupResponse.cs @@ -0,0 +1,3 @@ +namespace RestaurantReservation.Application.Features.Restaurants.TableGroups.Query.Responses; + +public record TableGroupResponse(Guid Id, Guid RestaurantId, string RestaurantName, string GroupName, int NumberOfTables); \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Tables/Command/Add/AddTableCommand.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Tables/Command/Add/AddTableCommand.cs new file mode 100755 index 0000000..34ac0ae --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Tables/Command/Add/AddTableCommand.cs @@ -0,0 +1,8 @@ +using MediatR; + +using RestaurantReservation.Application.Features.Restaurants.Query.Response; +using RestaurantReservation.Domain.Common; + +namespace RestaurantReservation.Application.Features.Restaurants.Tables.Command.Add; + +public record AddTableCommand(Guid RestaurantId, int NumberOfSeats, string? TableGroup) : IRequest>; \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Tables/Command/Add/AddTableCommandHandler.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Tables/Command/Add/AddTableCommandHandler.cs new file mode 100755 index 0000000..130e7b0 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Tables/Command/Add/AddTableCommandHandler.cs @@ -0,0 +1,67 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Hybrid; +using Microsoft.Extensions.Logging; + +using RestaurantReservation.Application.Abstractions; +using RestaurantReservation.Application.Features.Constants; +using RestaurantReservation.Application.Features.Restaurants.Query.Mappings; +using RestaurantReservation.Application.Features.Restaurants.Query.Response; +using RestaurantReservation.Domain.Common; +using RestaurantReservation.Domain.Restaurants.Errors; +using RestaurantReservation.Domain.Tables.Errors; + +namespace RestaurantReservation.Application.Features.Restaurants.Tables.Command.Add; + +public sealed class AddTableCommandHandler( + IApplicationDbContext context, + HybridCache cache, + ILogger logger) : IRequestHandler> +{ + public async Task> Handle( + AddTableCommand command, + CancellationToken cancellationToken) + { + var restaurant = await context.Restaurants + .Include(r => r.Tables) + .Include(r => r.TableGroups) + .FirstOrDefaultAsync(r => r.Id == command.RestaurantId, cancellationToken); + + if (restaurant is null) + { + logger.LogWarning("Restaurant with Id {id} not found, unable to add a table to this restaurant", command.RestaurantId); + return Result.Failure(RestaurantErrors.NotFound()); + } + + var table = restaurant.AddTable(command.NumberOfSeats); + + if (!string.IsNullOrWhiteSpace(command.TableGroup)) + { + var isValidTableGroup = context.TableGroups.Any(tg => tg.RestaurantId == command.RestaurantId && + tg.Name.ToLower().Equals(command.TableGroup.ToLower())); + if (!isValidTableGroup) + { + logger.LogWarning("Table group with name: {Name} not found, unable to add table", command.TableGroup); + return Result.Failure(TableGroupErrors.TableGroupNotFound(command.TableGroup)); + } + + restaurant.AddTableToTableGroup(table, command.TableGroup); + } + + await context.Tables.AddAsync(table, cancellationToken); + await context.SaveChangesAsync(cancellationToken); + + logger.LogInformation("A Table was added to {Name}, invalidating cache for key: {RKey} and {TKey} and {TGKey}", + restaurant.Name, + Keys.Restaurants, + Keys.Tables, + Keys.TableGroups); + + await cache.RemoveByTagAsync(Keys.Restaurants, cancellationToken); + await cache.RemoveByTagAsync(Keys.Tables, cancellationToken); + await cache.RemoveByTagAsync(Keys.TableGroups, cancellationToken); + + return Result.Success(table.ToTableResponse()); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Tables/Command/Add/AddTableCommandValidator.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Tables/Command/Add/AddTableCommandValidator.cs new file mode 100755 index 0000000..133514c --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Tables/Command/Add/AddTableCommandValidator.cs @@ -0,0 +1,18 @@ +using FluentValidation; + +namespace RestaurantReservation.Application.Features.Restaurants.Tables.Command.Add; + +internal sealed class AddTableCommandValidator : AbstractValidator +{ + public AddTableCommandValidator() + { + RuleFor(x => x.NumberOfSeats) + .NotEmpty() + .GreaterThan(0) + .WithMessage("{PropertyName} must be greater than 0"); + + RuleFor(x => x.TableGroup) + .MaximumLength(50) + .WithMessage("{PropertyName} cannot be greater than 50 characters"); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Tables/Command/Add/AddTableQuery.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Tables/Command/Add/AddTableQuery.cs new file mode 100755 index 0000000..932c35a --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Tables/Command/Add/AddTableQuery.cs @@ -0,0 +1,3 @@ +namespace RestaurantReservation.Application.Features.Restaurants.Tables.Command.Add; + +public record AddTableQuery(int NumberOfSeats, string? TableGroup); \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Tables/Command/Delete/DeleteTableCommand.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Tables/Command/Delete/DeleteTableCommand.cs new file mode 100755 index 0000000..74875de --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Tables/Command/Delete/DeleteTableCommand.cs @@ -0,0 +1,7 @@ +using MediatR; + +using RestaurantReservation.Domain.Common; + +namespace RestaurantReservation.Application.Features.Restaurants.Tables.Command.Delete; + +public record DeleteTableCommand(Guid RestaurantId, Guid TableId) : IRequest; \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Tables/Command/Delete/DeleteTableCommandHandler.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Tables/Command/Delete/DeleteTableCommandHandler.cs new file mode 100755 index 0000000..fd59dc9 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Tables/Command/Delete/DeleteTableCommandHandler.cs @@ -0,0 +1,82 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Hybrid; +using Microsoft.Extensions.Logging; + +using RestaurantReservation.Application.Abstractions; +using RestaurantReservation.Application.Features.Constants; +using RestaurantReservation.Domain.Common; +using RestaurantReservation.Domain.Restaurants.Errors; +using RestaurantReservation.Domain.Tables.Errors; + +namespace RestaurantReservation.Application.Features.Restaurants.Tables.Command.Delete; + +public sealed class DeleteTableCommandHandler( + IApplicationDbContext context, + HybridCache cache, + ILogger logger) : IRequestHandler +{ + public async Task Handle( + DeleteTableCommand command, + CancellationToken cancellationToken) + { + var restaurant = await context.Restaurants + .AsNoTracking() + .FirstOrDefaultAsync(r => r.Id == command.RestaurantId, cancellationToken); + + if (restaurant is null) + { + logger.LogWarning("Restaurant with Id {Id} not found unable to delete table", command.RestaurantId); + return Result.Failure(RestaurantErrors.NotFound()); + } + + var table = await context.Tables + .FirstOrDefaultAsync(t => t.RestaurantId == command.RestaurantId + && t.Id == command.TableId, cancellationToken); + + if (table is null) + { + logger.LogWarning("Table with Id {Id} not found in {Name}, no table deleted", + command.TableId, + restaurant.Name); + return Result.Failure(TableErrors.TableNotFound); + } + + if (table.IsInTableGroup) + { + if (!string.IsNullOrEmpty(table.TableGroupName)) + { + var tableGroup = await context.TableGroups + .Include(tg => tg.Tables) + .FirstOrDefaultAsync(tg => tg.RestaurantId == command.RestaurantId + && tg.Name.ToLower().Equals(table.TableGroupName.ToLower()), cancellationToken); + + if (tableGroup is null) + { + logger.LogWarning("Table group {GName} not found in {RName} table not deleted", + table.TableGroupName, + restaurant.Name); + return Result.Failure(TableGroupErrors.TableGroupNotFound(table.TableGroupName)); + } + + tableGroup.RemoveTable(table); + } + } + + context.Tables.Remove(table); + await context.SaveChangesAsync(cancellationToken); + + logger.LogInformation("A Table in {Name} was deleted, invalidating cache for key: {RKey} and {TKey} and {TGKey}", + restaurant.Name, + Keys.Restaurants, + Keys.Tables, + Keys.TableGroups); + + await cache.RemoveByTagAsync(Keys.Restaurants, cancellationToken); + await cache.RemoveByTagAsync(Keys.Tables, cancellationToken); + await cache.RemoveByTagAsync(Keys.TableGroups, cancellationToken); + + return Result.Success(); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Tables/Command/Update/UpdateTableCommand.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Tables/Command/Update/UpdateTableCommand.cs new file mode 100755 index 0000000..a2f8c37 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Tables/Command/Update/UpdateTableCommand.cs @@ -0,0 +1,11 @@ +using MediatR; + +using RestaurantReservation.Domain.Common; + +namespace RestaurantReservation.Application.Features.Restaurants.Tables.Command.Update; + +public record UpdateTableCommand( + Guid RestaurantId, + Guid TableId, + int? NumberOfSeats, + string? GroupName) : IRequest; \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Tables/Command/Update/UpdateTableCommandHandler.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Tables/Command/Update/UpdateTableCommandHandler.cs new file mode 100755 index 0000000..7615786 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Tables/Command/Update/UpdateTableCommandHandler.cs @@ -0,0 +1,115 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Hybrid; +using Microsoft.Extensions.Logging; + +using RestaurantReservation.Application.Abstractions; +using RestaurantReservation.Application.Features.Constants; +using RestaurantReservation.Domain.Common; +using RestaurantReservation.Domain.Restaurants.Errors; +using RestaurantReservation.Domain.Tables.Errors; + +namespace RestaurantReservation.Application.Features.Restaurants.Tables.Command.Update; + +public sealed class UpdateTableCommandHandler( + IApplicationDbContext context, + HybridCache cache, + ILogger logger) : IRequestHandler +{ + public async Task Handle( + UpdateTableCommand command, + CancellationToken cancellationToken) + { + var restaurant = await context.Restaurants + .AsNoTracking() + .FirstOrDefaultAsync(r => r.Id == command.RestaurantId, cancellationToken); + + if (restaurant is null) + { + logger.LogWarning("Restaurant with Id {Id} not found, unable to update table", command.RestaurantId); + return Result.Failure(RestaurantErrors.NotFound()); + } + + var table = await context.Tables + .FirstOrDefaultAsync(t => t.RestaurantId == command.RestaurantId && t.Id == command.TableId, + cancellationToken); + + if (table is null) + { + logger.LogWarning("Table with Id {Id} not found in {Name}, unable to update table", + command.TableId, + restaurant.Name); + return Result.Failure(TableErrors.TableNotFound); + } + + if (command.NumberOfSeats.HasValue) + { + table.UpdateSeating(command.NumberOfSeats.Value); + } + + if (!string.IsNullOrEmpty(command.GroupName)) + { + var tableGroup = await context.TableGroups + .Include(tg => tg.Tables) + .FirstOrDefaultAsync( + tg => tg.RestaurantId == command.RestaurantId && + tg.Name.ToLower().Equals(command.GroupName.ToLower()), cancellationToken); + + if (tableGroup is null) + { + logger.LogWarning("Table group {Name} not found unable to finish updating table in {RName}", + command.GroupName, + restaurant.Name); + return Result.Failure(TableGroupErrors.TableGroupNotFound(command.GroupName)); + } + if (!table.IsInTableGroup) + { + tableGroup.AddTable(table); + } + else + { + if (!string.IsNullOrEmpty(table.TableGroupName)) + { + if (table.TableGroupName.ToLower().Equals(command.GroupName.ToLower())) + { + logger.LogWarning("Table is already in group {GName} in {RName} unable to finish updating table", + command.GroupName, + restaurant.Name); + return Result.Failure(TableErrors.TableAlreadyInTableGroup(command.GroupName)); + } + + var originalTableGroup = await context.TableGroups + .Include(tg => tg.Tables) + .FirstOrDefaultAsync(tg => tg.RestaurantId == command.RestaurantId && + tg.Name.ToLower().Equals(table.TableGroupName.ToLower()), cancellationToken); + + if (originalTableGroup is null) + { + logger.LogWarning("Table group {Name} not found unable to finish updating table in {RName}", + table.TableGroupName, + restaurant.Name); + return Result.Failure(TableGroupErrors.TableGroupNotFound(table.TableGroupName)); + } + + originalTableGroup.RemoveTable(table); + tableGroup.AddTable(table); + } + } + } + + await context.SaveChangesAsync(cancellationToken); + + logger.LogInformation("A Table in {Name} was updated, invalidating cache for key: {RKey} and {TKey} and {TGKey}", + restaurant.Name, + Keys.Restaurants, + Keys.Tables, + Keys.TableGroups); + + await cache.RemoveByTagAsync(Keys.Restaurants, cancellationToken); + await cache.RemoveByTagAsync(Keys.Tables, cancellationToken); + await cache.RemoveByTagAsync(Keys.TableGroups, cancellationToken); + + return Result.Success(); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Tables/Command/Update/UpdateTableCommandValidator.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Tables/Command/Update/UpdateTableCommandValidator.cs new file mode 100755 index 0000000..ccf91b0 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Tables/Command/Update/UpdateTableCommandValidator.cs @@ -0,0 +1,17 @@ +using FluentValidation; + +namespace RestaurantReservation.Application.Features.Restaurants.Tables.Command.Update; + +internal sealed class UpdateTableCommandValidator : AbstractValidator +{ + public UpdateTableCommandValidator() + { + RuleFor(x => x.NumberOfSeats) + .GreaterThan(0) + .WithMessage("{PropertyName} must be greater than 0"); + + RuleFor(x => x.GroupName) + .MaximumLength(50) + .WithMessage("{PropertyName} cannot exceed 50 characters"); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Tables/Command/Update/UpdateTableQuery.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Tables/Command/Update/UpdateTableQuery.cs new file mode 100644 index 0000000..300f358 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Tables/Command/Update/UpdateTableQuery.cs @@ -0,0 +1,3 @@ +namespace RestaurantReservation.Application.Features.Restaurants.Tables.Command.Update; + +public record UpdateTableQuery(int? NumberOfSeats, string? GroupName); \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Tables/Query/Get/GetTableQuery.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Tables/Query/Get/GetTableQuery.cs new file mode 100755 index 0000000..2f1cee1 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Tables/Query/Get/GetTableQuery.cs @@ -0,0 +1,8 @@ +using MediatR; + +using RestaurantReservation.Application.Features.Restaurants.Tables.Query.Responses; +using RestaurantReservation.Domain.Common; + +namespace RestaurantReservation.Application.Features.Restaurants.Tables.Query.Get; + +public record GetTableQuery(Guid RestaurantId, Guid TableId) : IRequest>; \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Tables/Query/Get/GetTableQueryHandler.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Tables/Query/Get/GetTableQueryHandler.cs new file mode 100755 index 0000000..ffa7451 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Tables/Query/Get/GetTableQueryHandler.cs @@ -0,0 +1,51 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Hybrid; +using Microsoft.Extensions.Logging; + +using RestaurantReservation.Application.Abstractions; +using RestaurantReservation.Application.Features.Constants; +using RestaurantReservation.Application.Features.Restaurants.Tables.Query.Mappings; +using RestaurantReservation.Application.Features.Restaurants.Tables.Query.Responses; +using RestaurantReservation.Domain.Common; +using RestaurantReservation.Domain.Tables.Errors; + +namespace RestaurantReservation.Application.Features.Restaurants.Tables.Query.Get; + +public sealed class GetTableQueryHandler( + IApplicationDbContext context, + HybridCache cache, + ILogger logger) : IRequestHandler> +{ + public async Task> Handle( + GetTableQuery query, + CancellationToken cancellationToken) + { + var cacheKey = $"restaurant{query.RestaurantId}_table:{query.TableId}"; + + var table = await cache.GetOrCreateAsync( + cacheKey, + async ct => + { + logger.LogInformation("Cache miss for key: {Key}. Retrieving from database.", cacheKey); + var tableFromDb = await context.Tables + .AsNoTracking() + .Include(t => t.Restaurant) + .FirstOrDefaultAsync(t => t.Id == query.TableId && t.RestaurantId == query.RestaurantId, + ct); + return tableFromDb?.ToDetailResponse(); + }, + new HybridCacheEntryOptions + { + LocalCacheExpiration = TimeSpan.FromMinutes(4), + Expiration = TimeSpan.FromMinutes(30) + }, + tags: [Keys.Tables], + cancellationToken: cancellationToken).ConfigureAwait(false); + + return table is null + ? Result.Failure(TableErrors.TableNotFound) + : Result.Success(table); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Tables/Query/GetAll/GetAllTablesByRestaurantQuery.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Tables/Query/GetAll/GetAllTablesByRestaurantQuery.cs new file mode 100755 index 0000000..77c2ca7 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Tables/Query/GetAll/GetAllTablesByRestaurantQuery.cs @@ -0,0 +1,13 @@ +using MediatR; + +using RestaurantReservation.Application.Features.Restaurants.Tables.Query.Responses; +using RestaurantReservation.Domain.Common; + +namespace RestaurantReservation.Application.Features.Restaurants.Tables.Query.GetAll; + +public record GetAllTablesByRestaurantQuery( + Guid RestaurantId, + int Page = 1, + int PageSize = 10, + string? SortBy = null, + string? GroupName = null) : IRequest>>; \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Tables/Query/GetAll/GetAllTablesByRestaurantQueryHandler.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Tables/Query/GetAll/GetAllTablesByRestaurantQueryHandler.cs new file mode 100755 index 0000000..027cb00 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Tables/Query/GetAll/GetAllTablesByRestaurantQueryHandler.cs @@ -0,0 +1,78 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Hybrid; +using Microsoft.Extensions.Logging; + +using RestaurantReservation.Application.Abstractions; +using RestaurantReservation.Application.Extensions; +using RestaurantReservation.Application.Features.Constants; +using RestaurantReservation.Application.Features.Restaurants.Tables.Query.Mappings; +using RestaurantReservation.Application.Features.Restaurants.Tables.Query.Responses; +using RestaurantReservation.Domain.Common; +using RestaurantReservation.Domain.Tables.Errors; + +namespace RestaurantReservation.Application.Features.Restaurants.Tables.Query.GetAll; + +public sealed class GetAllTablesByRestaurantQueryHandler( + IApplicationDbContext context, + HybridCache cache, + ILogger logger) + : IRequestHandler>> +{ + public async Task>> Handle( + GetAllTablesByRestaurantQuery query, + CancellationToken cancellationToken) + { + var allTablesCacheKey = $"restaurant:{query.RestaurantId}_tables_page={query.Page}_pageSize={query.PageSize}"; + + (List items, int totalCount) = await cache.GetOrCreateAsync( + allTablesCacheKey, + async ct => + { + logger.LogInformation("Cache miss for key: {Key}. Retrieving from the database", allTablesCacheKey); + var tablesQuery = context.Tables + .Include(r => r.Restaurant) + .Where(t => t.RestaurantId == query.RestaurantId) + .AsNoTracking() + .AsQueryable(); + + if (string.IsNullOrWhiteSpace(query.GroupName)) + { + tablesQuery = tablesQuery.ApplySearch(query.GroupName); + } + + var totalCount = await tablesQuery + .AsNoTracking() + .CountAsync(ct); + + tablesQuery = tablesQuery.ApplySort( + string.IsNullOrWhiteSpace(query.SortBy) ? "Id" : query.SortBy); + + var items = await tablesQuery + .AsNoTracking() + .ApplyPagination(query.Page, query.PageSize) + .Select(t => t.ToResponse()) + .ToListAsync(ct); + + return (items, totalCount); + }, + new HybridCacheEntryOptions + { + LocalCacheExpiration = TimeSpan.FromMinutes(5), + Expiration = TimeSpan.FromMinutes(30) + }, + tags: [Keys.Tables], + cancellationToken: cancellationToken); + + if (items.Count == 0) + { + logger.LogWarning("No tables found for restaurant with Id {Id}", + query.RestaurantId); + return Result.Failure>(TableErrors.TablesNotFound); + } + + var pagedResult = new PagedResult(items, totalCount, query.Page, query.PageSize); + return Result.Success(pagedResult); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Tables/Query/GetAll/GetAllTablesByTableGroupQuery.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Tables/Query/GetAll/GetAllTablesByTableGroupQuery.cs new file mode 100755 index 0000000..966223d --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Tables/Query/GetAll/GetAllTablesByTableGroupQuery.cs @@ -0,0 +1,14 @@ +using MediatR; + +using RestaurantReservation.Application.Features.Restaurants.Tables.Query.Responses; +using RestaurantReservation.Domain.Common; + +namespace RestaurantReservation.Application.Features.Restaurants.Tables.Query.GetAll; + +public record GetAllTablesByTableGroupQuery( + Guid RestaurantId, + string TableGroupName, + int Page = 1, + int PageSize = 10, + string? SortBy = null) + : IRequest>>; \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Tables/Query/GetAll/GetAllTablesByTableGroupQueryHandler.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Tables/Query/GetAll/GetAllTablesByTableGroupQueryHandler.cs new file mode 100755 index 0000000..4e0413f --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Tables/Query/GetAll/GetAllTablesByTableGroupQueryHandler.cs @@ -0,0 +1,76 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Hybrid; +using Microsoft.Extensions.Logging; + +using RestaurantReservation.Application.Abstractions; +using RestaurantReservation.Application.Extensions; +using RestaurantReservation.Application.Features.Constants; +using RestaurantReservation.Application.Features.Restaurants.Tables.Query.Mappings; +using RestaurantReservation.Application.Features.Restaurants.Tables.Query.Responses; +using RestaurantReservation.Domain.Common; +using RestaurantReservation.Domain.Tables.Errors; + +namespace RestaurantReservation.Application.Features.Restaurants.Tables.Query.GetAll; + +public sealed class GetAllTablesByTableGroupQueryHandler( + IApplicationDbContext context, + HybridCache cache, + ILogger logger) + : IRequestHandler>> +{ + public async Task>> Handle( + GetAllTablesByTableGroupQuery query, + CancellationToken cancellationToken) + { + var allTablesCacheKey = $"restaurant:{query.RestaurantId}_tableGroup:{query.TableGroupName}_tables_page={query.Page}_pageSize={query.PageSize}"; + + (List items, int totalCount) = await cache.GetOrCreateAsync( + allTablesCacheKey, + async ct => + { + logger.LogInformation("Cache miss for key: {Key}. Retrieving from the database", allTablesCacheKey); + var tablesQuery = context.Tables + .Where(t => t.RestaurantId == query.RestaurantId + && !string.IsNullOrEmpty(t.TableGroupName) + && t.TableGroupName.ToLower().Equals(query.TableGroupName.ToLower())) + .Include(t => t.Restaurant) + .AsNoTracking() + .AsQueryable(); + + var totalCount = await tablesQuery + .AsNoTracking() + .CountAsync(ct); + + tablesQuery = tablesQuery.ApplySort( + string.IsNullOrWhiteSpace(query.SortBy) ? "Id" : query.SortBy); + + var items = await tablesQuery + .AsNoTracking() + .ApplyPagination(query.Page, query.PageSize) + .Select(t => t.ToResponse()) + .ToListAsync(ct); + + return (items, totalCount); + }, + new HybridCacheEntryOptions + { + LocalCacheExpiration = TimeSpan.FromMinutes(5), + Expiration = TimeSpan.FromMinutes(30) + }, + tags: [Keys.Tables], + cancellationToken: cancellationToken); + + if (items.Count == 0) + { + logger.LogWarning("No tables found for table group {GName} in restaurant with Id {Id}", + query.TableGroupName, + query.RestaurantId); + return Result.Failure>(TableErrors.TablesNotFound); + } + + var pagedResult = new PagedResult(items, totalCount, query.Page, query.PageSize); + return Result.Success(pagedResult); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Tables/Query/Mappings/ResponseMappings.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Tables/Query/Mappings/ResponseMappings.cs new file mode 100755 index 0000000..3a7a4c6 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Tables/Query/Mappings/ResponseMappings.cs @@ -0,0 +1,32 @@ +using RestaurantReservation.Application.Features.Restaurants.Tables.Query.Responses; +using RestaurantReservation.Domain.Tables; + +namespace RestaurantReservation.Application.Features.Restaurants.Tables.Query.Mappings; + +public static class ResponseMappings +{ + public static TableDetailResponse ToDetailResponse(this Table table) + { + return new TableDetailResponse( + table.Id, + table.RestaurantId, + table.Restaurant?.Name ?? "N/A", + table.SeatsAtTable, + table.IsInTableGroup, + table.TableGroupName ?? "Not in a table group"); + } + + public static TableResponse ToResponse(this Table table) + { + return new TableResponse( + table.Id, + table.RestaurantId, + table.Restaurant?.Name ?? "N/A", + table.SeatsAtTable); + } + + public static List ToResponseCollection(this IEnumerable
tables) + { + return [.. tables.Select(t => t.ToResponse())]; + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Tables/Query/Responses/TableDetailResponse.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Tables/Query/Responses/TableDetailResponse.cs new file mode 100755 index 0000000..e61e96e --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Tables/Query/Responses/TableDetailResponse.cs @@ -0,0 +1,9 @@ +namespace RestaurantReservation.Application.Features.Restaurants.Tables.Query.Responses; + +public record TableDetailResponse( + Guid Id, + Guid RestaurantId, + string RestaurantName, + int SeatsAtTable, + bool IsInTableGroup, + string? TableGroupName = null); \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Tables/Query/Responses/TableResponse.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Tables/Query/Responses/TableResponse.cs new file mode 100755 index 0000000..1f0b93c --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/Features/Restaurants/Tables/Query/Responses/TableResponse.cs @@ -0,0 +1,7 @@ +namespace RestaurantReservation.Application.Features.Restaurants.Tables.Query.Responses; + +public record TableResponse( + Guid Id, + Guid RestaurantId, + string RestaurantName, + int SeatsAtTable); \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/RestaurantReservation.Application.csproj b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/RestaurantReservation.Application.csproj new file mode 100755 index 0000000..4a9efb5 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Application/RestaurantReservation.Application.csproj @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + net10.0 + enable + enable + + + diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Abstractions/BaseEntity.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Abstractions/BaseEntity.cs new file mode 100755 index 0000000..c36536d --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Abstractions/BaseEntity.cs @@ -0,0 +1,13 @@ +namespace RestaurantReservation.Domain.Abstractions; + +public abstract class BaseEntity +{ + public Guid Id { get; init; } + + protected BaseEntity(Guid id) + { + Id = id; + } + + protected BaseEntity() {} +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Common/DomainError.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Common/DomainError.cs new file mode 100755 index 0000000..bbc8327 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Common/DomainError.cs @@ -0,0 +1,10 @@ +namespace RestaurantReservation.Domain.Common; + +public record DomainError(string Code, string Description, ErrorType ErrorType) +{ + public static readonly DomainError None = new(string.Empty, string.Empty, ErrorType.None); + public static readonly DomainError NullValue = new( + "Error.NullValue", + "Null value was provided", + ErrorType.NullValue); +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Common/ErrorType.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Common/ErrorType.cs new file mode 100755 index 0000000..edb4c5c --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Common/ErrorType.cs @@ -0,0 +1,15 @@ +namespace RestaurantReservation.Domain.Common; + +public enum ErrorType +{ + BadRequest, + CapacityExceeded, + Conflict, + Forbidden, + InternalServerError, + None, + NotFound, + NullValue, + Unauthorized, + Validation +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Common/Helpers/ValidationHelper.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Common/Helpers/ValidationHelper.cs new file mode 100755 index 0000000..10db25b --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Common/Helpers/ValidationHelper.cs @@ -0,0 +1,133 @@ +using System.Text.RegularExpressions; + +using RestaurantReservation.Domain.Reservations.Errors; + +namespace RestaurantReservation.Domain.Common.Helpers; + +public static partial class ValidationHelper +{ + public static bool IsRestaurantScheduleStartAndEndTimeValid(TimeOnly? startTime, TimeOnly? endTime) + { + if (startTime.HasValue && endTime.HasValue) + { + return startTime < endTime; + } + + return true; + } + + public static Result EmailAddressValidator(string email) + { + if (string.IsNullOrWhiteSpace(email)) + { + return Result.Failure(EmailAddressErrors.EmailAddressEmpty); + } + + if (!IsValidEmail(email)) + { + return Result.Failure(EmailAddressErrors.EmailAddressInvalid); + } + + return email.Length > 50 + ? Result.Failure(EmailAddressErrors.EmailAddressMaxLengthExceeded) + : Result.Success(); + } + + public static Result TelephoneNumberValidator(string phoneNumber) + { + if (string.IsNullOrWhiteSpace(phoneNumber)) + { + return Result.Failure(TelephoneNumberErrors.TelephoneNumberEmpty); + } + + if (phoneNumber.Length > 15) + { + return Result.Failure(TelephoneNumberErrors.TelephoneNumberMaxLengthExceeded); + } + + return !IsValidPhoneNumber(phoneNumber) + ? Result.Failure(TelephoneNumberErrors.TelephoneNumberInvalid) + : Result.Success(); + } + + public static Result FirstNameValidator(string firstName) + { + if (string.IsNullOrWhiteSpace(firstName)) + { + return Result.Failure(FirstNameErrors.FirstNameEmpty); + } + + return firstName.Length > 50 + ? Result.Failure(FirstNameErrors.FirstNameMaxLengthExceeded) + : Result.Success(); + } + + public static Result LastNameValidator(string lastName) + { + if (string.IsNullOrWhiteSpace(lastName)) + { + return Result.Failure(LastNameErrors.LastNameEmpty); + } + + return lastName.Length > 50 + ? Result.Failure(LastNameErrors.LastNameMaxLengthExceeded) + : Result.Success(); + } + + public static Result ReservationDateValidator(DateOnly reservationDate) + { + return reservationDate < DateOnly.FromDateTime(DateTime.Today) + ? Result.Failure(ReservationDateErrors.ReservationDateInvalid) + : Result.Success(); + } + + public static Result ReservationTimeValidator( + DateOnly reservationDate, + TimeOnly startTime, + TimeOnly endTime) + { + if (reservationDate != DateOnly.FromDateTime(DateTime.Today)) + { + return startTime > endTime + ? Result.Failure(ReservationTimeErrors.ReservationTimeInvalid) + : Result.Success(); + } + + var currentTime = TimeOnly.FromDateTime(DateTime.Now); + + if (currentTime > startTime) + { + return Result.Failure(ReservationTimeErrors.ReservationStartTimeInPast( + reservationDate, + currentTime, + startTime)); + } + + if (currentTime > endTime) + { + return Result.Failure(ReservationTimeErrors.ReservationEndTimeInPast( + reservationDate, + currentTime, + endTime)); + } + + return startTime > endTime + ? Result.Failure(ReservationTimeErrors.ReservationTimeInvalid) + : Result.Success(); + } + + private static bool IsValidEmail(string emailAddress) + { + return EmailRegex().IsMatch(emailAddress); + } + + private static bool IsValidPhoneNumber(string phoneNumber) + { + return TelephoneRegex().IsMatch(phoneNumber); + } + + [GeneratedRegex(@"^[^@\s]+@[^@\s]+\.[^@\s]+$", RegexOptions.IgnoreCase, "en-US")] + private static partial Regex EmailRegex(); + [GeneratedRegex(@"^[\+]?[1-9]?[\d\s\-\(\)\.]{10,15}$")] + private static partial Regex TelephoneRegex(); +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Common/PagedResult.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Common/PagedResult.cs new file mode 100755 index 0000000..9c4c561 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Common/PagedResult.cs @@ -0,0 +1,12 @@ +namespace RestaurantReservation.Domain.Common; + +public sealed class PagedResult(List items, int totalCount, int page, int pageSize) +{ + public List Items { get; } = items; + public int TotalCount { get; } = totalCount; + public int Page { get; } = page; + public int PageSize { get; } = pageSize; + public int TotalPages => (int)Math.Ceiling(TotalCount / (double)PageSize); + public bool HasNextPage => Page < TotalPages; + public bool HasPreviousPage => Page > 1; +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Common/Result.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Common/Result.cs new file mode 100755 index 0000000..ea33dfd --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Common/Result.cs @@ -0,0 +1,72 @@ +using System.Diagnostics.CodeAnalysis; + +namespace RestaurantReservation.Domain.Common; + +public class Result +{ + public bool IsSuccess { get; } + public bool IsFailure => !IsSuccess; + public DomainError Error { get; } + public List Errors { get; } + + protected Result(bool isSuccess, DomainError error) + { + switch (isSuccess) + { + case true when error != DomainError.None: + throw new InvalidOperationException("Success result cannot have an error."); + case false when error == DomainError.None: + throw new InvalidOperationException("Failure result must have an error."); + default: + IsSuccess = isSuccess; + Error = error; + break; + } + } + + protected Result(bool isSuccess, List errors) + { + switch (isSuccess) + { + case true when errors.Count != 0: + throw new InvalidOperationException("Success result cannot have a collection of errors."); + case false when errors.Count == 0: + throw new InvalidOperationException("Failure must have at least one error in the collection."); + default: + IsSuccess = isSuccess; + Errors = errors; + break; + } + } + + public static Result Success() => new(true, DomainError.None); + public static Result Failure(DomainError error) => new(false, error); + public static Result Failure(List errors) => new(false, errors); + public static Result Success(T value) => new(value, true, DomainError.None); + public static Result Failure(DomainError error) => new(default, false, error); + public static Result Failure(List errors) => new(default, false, errors); + + protected static Result Create(T? value) => value is not null + ? Success(value) + : Failure(DomainError.NullValue); +} + +public class Result : Result +{ + [NotNull] + public T Value => IsSuccess + ? field! + : throw new InvalidOperationException("The value of a failure result can not be accessed."); + + protected internal Result(T? value, bool isSuccess, DomainError error) : base(isSuccess, error) + { + Value = value; + } + + protected internal Result(T? value, bool isSuccess, List errors) : base(isSuccess, errors) + { + Value = value; + } + + public static implicit operator Result(T? value) => Create(value); +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Reservations/Errors/EmailAddressErrors.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Reservations/Errors/EmailAddressErrors.cs new file mode 100755 index 0000000..f36be67 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Reservations/Errors/EmailAddressErrors.cs @@ -0,0 +1,21 @@ +using RestaurantReservation.Domain.Common; + +namespace RestaurantReservation.Domain.Reservations.Errors; + +public static class EmailAddressErrors +{ + public static readonly DomainError EmailAddressInvalid = new( + "EmailAddress.Invalid", + "Email address is not in a valid format", + ErrorType.Validation); + + public static readonly DomainError EmailAddressEmpty = new( + "EmailAddress.Empty", + "Email address cannot be empty", + ErrorType.Validation); + + public static readonly DomainError EmailAddressMaxLengthExceeded = new( + "EmailAddress.NaxLengthExceeded", + "Email address cannot exceed 50 characters", + ErrorType.Validation); +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Reservations/Errors/FirstNameErrors.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Reservations/Errors/FirstNameErrors.cs new file mode 100755 index 0000000..205721c --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Reservations/Errors/FirstNameErrors.cs @@ -0,0 +1,16 @@ +using RestaurantReservation.Domain.Common; + +namespace RestaurantReservation.Domain.Reservations.Errors; + +public static class FirstNameErrors +{ + public static readonly DomainError FirstNameEmpty = new( + "FirstName.Empty", + "First name cannot be empty", + ErrorType.Validation); + + public static readonly DomainError FirstNameMaxLengthExceeded = new( + "FirstName.MaxLengthExceeded", + "First name cannot exceed 50 characters", + ErrorType.Validation); +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Reservations/Errors/LastNameErrors.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Reservations/Errors/LastNameErrors.cs new file mode 100755 index 0000000..a7ca3c8 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Reservations/Errors/LastNameErrors.cs @@ -0,0 +1,16 @@ +using RestaurantReservation.Domain.Common; + +namespace RestaurantReservation.Domain.Reservations.Errors; + +public static class LastNameErrors +{ + public static readonly DomainError LastNameEmpty = new( + "LastName.Empty", + "Last name cannot be empty", + ErrorType.Validation); + + public static readonly DomainError LastNameMaxLengthExceeded = new( + "LastName.MaxLengthExceeded", + "Last name cannot exceed 50 characters", + ErrorType.Validation); +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Reservations/Errors/ReservationDateErrors.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Reservations/Errors/ReservationDateErrors.cs new file mode 100755 index 0000000..ae8c72b --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Reservations/Errors/ReservationDateErrors.cs @@ -0,0 +1,11 @@ +using RestaurantReservation.Domain.Common; + +namespace RestaurantReservation.Domain.Reservations.Errors; + +public static class ReservationDateErrors +{ + public static readonly DomainError ReservationDateInvalid = new( + "ReservationDate.Invalid", + "A reservation cannot be made for a date in the past", + ErrorType.Validation); +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Reservations/Errors/ReservationErrors.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Reservations/Errors/ReservationErrors.cs new file mode 100755 index 0000000..032fed5 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Reservations/Errors/ReservationErrors.cs @@ -0,0 +1,41 @@ +using RestaurantReservation.Domain.Common; + +namespace RestaurantReservation.Domain.Reservations.Errors; + +public static class ReservationErrors +{ + public static DomainError ReservationInvalidCancellationStatus(ReservationStatus status) => new( + "Cancellation.Invalid", + $"Cannot cancel reservation with status: {status.ToString()}", + ErrorType.Validation); + + public static DomainError ReservationInvalidCompletionStatus(ReservationStatus status) => new( + "Completion.Invalid", + $"Cannot complete reservation with status: {status.ToString()}", + ErrorType.Validation); + + public static DomainError ReservationNotFound(string customerEmail, string restaurantName) => new( + "Reservation.NotFound", + $"Reservation for {customerEmail} at {restaurantName} not found", + ErrorType.NotFound); + + public static DomainError ReservationNotFound(string customerEmail) => new( + "Reservation.NotFound", + $"Reservation for {customerEmail} not found", + ErrorType.NotFound); + + public static readonly DomainError ReservationOverlap = new( + "Reservation.Overlap", + "This reservation overlaps with an already existing reservation", + ErrorType.Conflict); + + public static readonly DomainError ReservationCannotBeRescheduled = new( + "Reservation.CannotBeRescheduled", + "Unable to reschedule your reservation", + ErrorType.BadRequest); + + public static readonly DomainError UnableToSecureTablesForReservation = new( + "Reservation.UnableToSecureTableForReservation", + "There were no tables available that would fit your party size", + ErrorType.NotFound); +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Reservations/Errors/ReservationTimeErrors.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Reservations/Errors/ReservationTimeErrors.cs new file mode 100755 index 0000000..858fb8b --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Reservations/Errors/ReservationTimeErrors.cs @@ -0,0 +1,31 @@ +using RestaurantReservation.Domain.Common; + +namespace RestaurantReservation.Domain.Reservations.Errors; + +public static class ReservationTimeErrors +{ + public static readonly DomainError ReservationTimeInvalid = new( + "ReservationTime.Invalid", + "Start time of reservation must come before end time of reservation", + ErrorType.Validation); + + public static DomainError ReservationStartTimeInPast( + DateOnly reservationDate, + TimeOnly currentTime, + TimeOnly startTime) => new( + "ReservationTime.StartTimeInPast", + $"You are trying to schedule a reservation for today {reservationDate}, the current time" + + $" is {currentTime}, and you are trying to make a reservation for {startTime} today " + + $"and it is not possible to schedule a reservation to start in the past", + ErrorType.BadRequest); + + public static DomainError ReservationEndTimeInPast( + DateOnly reservationDate, + TimeOnly currentTime, + TimeOnly endTime) => new( + "ReservationTime.EndTimeInPast", + $"You are trying to schedule a reservation for today {reservationDate}, the current time is " + + $"{currentTime}, and you are trying to schedule the reservation's end time to be {endTime}, " + + $"it is not possible to schedule a reservation to end in the past", + ErrorType.BadRequest); +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Reservations/Errors/TelephoneNumberErrors.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Reservations/Errors/TelephoneNumberErrors.cs new file mode 100755 index 0000000..9ed289d --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Reservations/Errors/TelephoneNumberErrors.cs @@ -0,0 +1,21 @@ +using RestaurantReservation.Domain.Common; + +namespace RestaurantReservation.Domain.Reservations.Errors; + +public static class TelephoneNumberErrors +{ + public static readonly DomainError TelephoneNumberInvalid = new( + "PhoneNumber.Invalid", + "The telephone number is not in a valid format", + ErrorType.Validation); + + public static readonly DomainError TelephoneNumberEmpty = new( + "PhoneNumber.Empty", + "The telephone number cannot be empty", + ErrorType.Validation); + + public static readonly DomainError TelephoneNumberMaxLengthExceeded = new( + "PhoneNumber.MaxLengthExceeded", + "The telephone number cannot exceed 15 characters", + ErrorType.Validation); +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Reservations/Events/ReservationCanceledEvent.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Reservations/Events/ReservationCanceledEvent.cs new file mode 100755 index 0000000..8825524 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Reservations/Events/ReservationCanceledEvent.cs @@ -0,0 +1,14 @@ +using MediatR; + +namespace RestaurantReservation.Domain.Reservations.Events; + +public record ReservationCanceledEvent( + Guid ReservationId, + string FirstName, + string LastName, + string Email, + string RestaurantName, + DateOnly ReservationDate, + TimeOnly ReservationStartTime, + TimeOnly ReservationEndTime, + DateTime ReservationCanceledAtUtc) : INotification; \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Reservations/Events/ReservationCompletedEvent.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Reservations/Events/ReservationCompletedEvent.cs new file mode 100755 index 0000000..23a085c --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Reservations/Events/ReservationCompletedEvent.cs @@ -0,0 +1,14 @@ +using MediatR; + +namespace RestaurantReservation.Domain.Reservations.Events; + +public record ReservationCompletedEvent( + Guid ReservationId, + string FirstName, + string LastName, + string Email, + string RestaurantName, + DateOnly ReservationDate, + TimeOnly ReservationStartTime, + TimeOnly ReservationEndTime, + DateTime ReservationCompletedAtUtc) : INotification; \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Reservations/Events/ReservationRescheduledEvent.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Reservations/Events/ReservationRescheduledEvent.cs new file mode 100755 index 0000000..1da53ca --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Reservations/Events/ReservationRescheduledEvent.cs @@ -0,0 +1,17 @@ +using MediatR; + +namespace RestaurantReservation.Domain.Reservations.Events; + +public record ReservationRescheduledEvent( + Guid ReservationId, + string FirstName, + string LastName, + string Email, + string RestaurantName, + DateOnly OriginalReservationDate, + TimeOnly OriginalReservationStartTime, + TimeOnly OriginalReservationEndTime, + DateOnly ReservationDate, + TimeOnly ReservationStartTime, + TimeOnly ReservationEndTime, + int NumberOfGuests) : INotification; \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Reservations/Events/ReservationScheduledEvent.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Reservations/Events/ReservationScheduledEvent.cs new file mode 100755 index 0000000..ed8d5f6 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Reservations/Events/ReservationScheduledEvent.cs @@ -0,0 +1,14 @@ +using MediatR; + +namespace RestaurantReservation.Domain.Reservations.Events; + +public record ReservationScheduledEvent( + Guid ReservationId, + string FirstName, + string LastName, + string Email, + string RestaurantName, + DateOnly ReservationDate, + TimeOnly ReservationStartTime, + TimeOnly ReservationEndTime, + int NumberOfGuests) : INotification; \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Reservations/Reservation.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Reservations/Reservation.cs new file mode 100755 index 0000000..5f033ff --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Reservations/Reservation.cs @@ -0,0 +1,219 @@ +using RestaurantReservation.Domain.Abstractions; +using RestaurantReservation.Domain.Common; +using RestaurantReservation.Domain.Common.Helpers; +using RestaurantReservation.Domain.Reservations.Errors; +using RestaurantReservation.Domain.Reservations.ValueObjects.CustomerValueObjects; +using RestaurantReservation.Domain.Reservations.ValueObjects.ReservationValueObjects; +using RestaurantReservation.Domain.Restaurants; +using RestaurantReservation.Domain.Users; + +namespace RestaurantReservation.Domain.Reservations; + +public class Reservation : BaseEntity +{ + public Guid RestaurantId { get; init; } + public Restaurant? Restaurant { get; set; } + public string? CustomerId { get; init; } + public ApplicationUser? Customer { get; set; } + public string RestaurantName { get; init; } + public string? TableGroup { get; private set; } + public CustomerContactInfo CustomerContactInfo { get; private set; } = null!; + public ReservationInfo ReservationInfo { get; private set; } = null!; + public ReservationStatus Status { get; private set; } + public DateTime ReservationCreatedAtUtc { get; private set; } + public DateTime? ReservationUpdatedAtUtc { get; set; } + public DateTime? ReservationCanceledAtUtc { get; set; } + public DateTime? ReservationCompletedAtUtc { get; set; } + public ICollection Tables { get; set; } = []; + + private Reservation() {} + + private Reservation( + Guid id, + Guid restaurantId, + string customerId, + string restaurantName, + string? tableGroup, + CustomerContactInfo customerInfo, + ReservationStatus status, + ReservationInfo reservationInfo) : base(id) + { + RestaurantId = restaurantId; + CustomerId = customerId; + RestaurantName = restaurantName; + TableGroup = tableGroup; + CustomerContactInfo = customerInfo; + Status = status; + ReservationInfo = reservationInfo; + ReservationCreatedAtUtc = DateTime.UtcNow; + } + + public static Result MakeReservation( + Guid restaurantId, + string restaurantName, + string customerId, + string customerFirstName, + string customerLastName, + string customerEmail, + string customerPhone, + DateOnly reservationDate, + TimeOnly reservationStartTime, + TimeOnly reservationEndTime, + int numberOfGuests, + string? tableGroup) + { + var firstResult = CheckCustomerContactInfo( + customerFirstName, + customerLastName, + customerEmail, + customerPhone); + + if (firstResult.IsFailure) return Result.Failure(firstResult.Errors); + + var secondResult = CheckReservationInfo( + reservationDate, + reservationStartTime, + reservationEndTime); + + if (secondResult.IsFailure) return Result.Failure(secondResult.Errors); + + var customerInfo = new CustomerContactInfo( + new FirstName(customerFirstName), + new LastName(customerLastName), + new EmailAddress(customerEmail), + new TelephoneNumber(customerPhone)); + + var reservationInfo = new ReservationInfo( + new ReservationDate(reservationDate), + new ReservationStart(reservationStartTime), + new ReservationEnd(reservationEndTime), + new GuestsInParty(numberOfGuests)); + + return Result.Success(new Reservation( + Guid.CreateVersion7(), + restaurantId, + customerId, + restaurantName, + tableGroup, + customerInfo, + ReservationStatus.Scheduled, + reservationInfo)); + } + + public Result RescheduleReservation( + DateOnly reservationDate, + TimeOnly reservationStartTime, + TimeOnly reservationEndTime, + int numberOfGuests) + { + var validationResult = CheckReservationInfo( + reservationDate, + reservationStartTime, + reservationEndTime); + + if (validationResult.IsFailure) return validationResult; + + ReservationInfo = new ReservationInfo( + new ReservationDate(reservationDate), + new ReservationStart(reservationStartTime), + new ReservationEnd(reservationEndTime), + new GuestsInParty(numberOfGuests)); + + ReservationUpdatedAtUtc = DateTime.UtcNow; + + return Result.Success(); + } + + public Result CancelReservation() + { + if (Status is ReservationStatus.Canceled or ReservationStatus.Completed) + return Result.Failure(ReservationErrors.ReservationInvalidCancellationStatus(Status)); + + ReservationCanceledAtUtc = DateTime.UtcNow; + ReservationUpdatedAtUtc = DateTime.UtcNow; + Status = ReservationStatus.Canceled; + + return Result.Success(); + } + + public Result CompleteReservation() + { + if (Status is ReservationStatus.Canceled or ReservationStatus.Completed) + return Result.Failure(ReservationErrors.ReservationInvalidCompletionStatus(Status)); + + ReservationCompletedAtUtc = DateTime.UtcNow; + ReservationUpdatedAtUtc = DateTime.UtcNow; + Status = ReservationStatus.Completed; + + return Result.Success(); + } + + private static Result CheckCustomerContactInfo( + string customerFirstName, + string customerLastName, + string customerEmail, + string customerPhone) + { + var errors = new List(); + + var firstNameResult = ValidationHelper.FirstNameValidator(customerFirstName); + if (firstNameResult.IsFailure) errors.Add(firstNameResult.Error); + + var lastNameResult = ValidationHelper.LastNameValidator(customerLastName); + if (lastNameResult.IsFailure) errors.Add(lastNameResult.Error); + + var emailResult = ValidationHelper.EmailAddressValidator(customerEmail); + if (emailResult.IsFailure) errors.Add(emailResult.Error); + + var phoneNumberResult = ValidationHelper.TelephoneNumberValidator(customerPhone); + if (phoneNumberResult.IsFailure) errors.Add(phoneNumberResult.Error); + + return errors.Count > 0 + ? Result.Failure(errors) + : Result.Success(); + } + + private static Result CheckReservationInfo( + DateOnly reservationDate, + TimeOnly reservationStartTime, + TimeOnly reservationEndTime) + { + var errors = new List(); + + var reservationDateResult = ValidationHelper.ReservationDateValidator(reservationDate); + if (reservationDateResult.IsFailure) errors.Add(reservationDateResult.Error); + + var reservationTimeResult = ValidationHelper.ReservationTimeValidator( + reservationDate, + reservationStartTime, + reservationEndTime); + if (reservationTimeResult.IsFailure) errors.Add(reservationTimeResult.Error); + + return errors.Count > 0 + ? Result.Failure(errors) + : Result.Success(); + } + + public void AddReservationTable(ReservationTable table) + { + Tables.Add(table); + } + + public Result RemoveReservationTable() + { + var tablesToRemove = Tables.Where(t => t.ReservationId == Id) + .ToList(); + + if (tablesToRemove.Count == 0) + { + return Result.Failure(ReservationErrors.ReservationCannotBeRescheduled); + } + + foreach (var table in tablesToRemove) + { + Tables.Remove(table); + } + + return Result.Success(); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Reservations/ReservationStatus.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Reservations/ReservationStatus.cs new file mode 100755 index 0000000..053695c --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Reservations/ReservationStatus.cs @@ -0,0 +1,8 @@ +namespace RestaurantReservation.Domain.Reservations; + +public enum ReservationStatus +{ + Scheduled, + Canceled, + Completed +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Reservations/ReservationTable.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Reservations/ReservationTable.cs new file mode 100755 index 0000000..569771a --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Reservations/ReservationTable.cs @@ -0,0 +1,14 @@ +using RestaurantReservation.Domain.Reservations.ValueObjects.ReservationValueObjects; +using RestaurantReservation.Domain.Tables; + +namespace RestaurantReservation.Domain.Reservations; + +public class ReservationTable +{ + public Guid ReservationId { get; set; } + public Reservation? Reservation { get; set; } + public Guid TableId { get; set; } + public Table? Table { get; set; } + public TableReservation ScheduledReservation { get; set; } + public int SeatsAtTable { get; set; } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Reservations/ValueObjects/CustomerValueObjects/CustomerContactInfo.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Reservations/ValueObjects/CustomerValueObjects/CustomerContactInfo.cs new file mode 100755 index 0000000..905536f --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Reservations/ValueObjects/CustomerValueObjects/CustomerContactInfo.cs @@ -0,0 +1,7 @@ +namespace RestaurantReservation.Domain.Reservations.ValueObjects.CustomerValueObjects; + +public record CustomerContactInfo( + FirstName FirstName, + LastName LastName, + EmailAddress EmailAddress, + TelephoneNumber TelephoneNumber); \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Reservations/ValueObjects/CustomerValueObjects/EmailAddress.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Reservations/ValueObjects/CustomerValueObjects/EmailAddress.cs new file mode 100755 index 0000000..9e2a006 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Reservations/ValueObjects/CustomerValueObjects/EmailAddress.cs @@ -0,0 +1,3 @@ +namespace RestaurantReservation.Domain.Reservations.ValueObjects.CustomerValueObjects; + +public record EmailAddress(string Value); \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Reservations/ValueObjects/CustomerValueObjects/FirstName.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Reservations/ValueObjects/CustomerValueObjects/FirstName.cs new file mode 100755 index 0000000..66ae4e8 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Reservations/ValueObjects/CustomerValueObjects/FirstName.cs @@ -0,0 +1,3 @@ +namespace RestaurantReservation.Domain.Reservations.ValueObjects.CustomerValueObjects; + +public record FirstName(string Value); \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Reservations/ValueObjects/CustomerValueObjects/LastName.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Reservations/ValueObjects/CustomerValueObjects/LastName.cs new file mode 100755 index 0000000..2027474 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Reservations/ValueObjects/CustomerValueObjects/LastName.cs @@ -0,0 +1,3 @@ +namespace RestaurantReservation.Domain.Reservations.ValueObjects.CustomerValueObjects; + +public record LastName(string Value); \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Reservations/ValueObjects/CustomerValueObjects/TelephoneNumber.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Reservations/ValueObjects/CustomerValueObjects/TelephoneNumber.cs new file mode 100755 index 0000000..e2c0576 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Reservations/ValueObjects/CustomerValueObjects/TelephoneNumber.cs @@ -0,0 +1,3 @@ +namespace RestaurantReservation.Domain.Reservations.ValueObjects.CustomerValueObjects; + +public record TelephoneNumber(string Value); \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Reservations/ValueObjects/ReservationValueObjects/GuestsInParty.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Reservations/ValueObjects/ReservationValueObjects/GuestsInParty.cs new file mode 100755 index 0000000..8378825 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Reservations/ValueObjects/ReservationValueObjects/GuestsInParty.cs @@ -0,0 +1,3 @@ +namespace RestaurantReservation.Domain.Reservations.ValueObjects.ReservationValueObjects; + +public record GuestsInParty(int Value); \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Reservations/ValueObjects/ReservationValueObjects/ReservationDate.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Reservations/ValueObjects/ReservationValueObjects/ReservationDate.cs new file mode 100755 index 0000000..b96b3e4 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Reservations/ValueObjects/ReservationValueObjects/ReservationDate.cs @@ -0,0 +1,3 @@ +namespace RestaurantReservation.Domain.Reservations.ValueObjects.ReservationValueObjects; + +public record ReservationDate(DateOnly Value); \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Reservations/ValueObjects/ReservationValueObjects/ReservationEnd.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Reservations/ValueObjects/ReservationValueObjects/ReservationEnd.cs new file mode 100755 index 0000000..a9f484a --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Reservations/ValueObjects/ReservationValueObjects/ReservationEnd.cs @@ -0,0 +1,3 @@ +namespace RestaurantReservation.Domain.Reservations.ValueObjects.ReservationValueObjects; + +public record ReservationEnd(TimeOnly Value); \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Reservations/ValueObjects/ReservationValueObjects/ReservationInfo.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Reservations/ValueObjects/ReservationValueObjects/ReservationInfo.cs new file mode 100755 index 0000000..eb6bb3c --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Reservations/ValueObjects/ReservationValueObjects/ReservationInfo.cs @@ -0,0 +1,7 @@ +namespace RestaurantReservation.Domain.Reservations.ValueObjects.ReservationValueObjects; + +public record ReservationInfo( + ReservationDate Date, + ReservationStart StartTime, + ReservationEnd EndTime, + GuestsInParty Guests); \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Reservations/ValueObjects/ReservationValueObjects/ReservationStart.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Reservations/ValueObjects/ReservationValueObjects/ReservationStart.cs new file mode 100755 index 0000000..c737e6d --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Reservations/ValueObjects/ReservationValueObjects/ReservationStart.cs @@ -0,0 +1,3 @@ +namespace RestaurantReservation.Domain.Reservations.ValueObjects.ReservationValueObjects; + +public record ReservationStart(TimeOnly Value); \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Reservations/ValueObjects/ReservationValueObjects/TableReservation.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Reservations/ValueObjects/ReservationValueObjects/TableReservation.cs new file mode 100755 index 0000000..ede76a2 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Reservations/ValueObjects/ReservationValueObjects/TableReservation.cs @@ -0,0 +1,3 @@ +namespace RestaurantReservation.Domain.Reservations.ValueObjects.ReservationValueObjects; + +public record TableReservation(DateOnly ReservationDay, TimeOnly ReservationStart, TimeOnly ReservationEnd); \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/RestaurantReservation.Domain.csproj b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/RestaurantReservation.Domain.csproj new file mode 100755 index 0000000..5b5cb0d --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/RestaurantReservation.Domain.csproj @@ -0,0 +1,14 @@ + + + + net10.0 + enable + enable + + + + + + + + diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Restaurants/Errors/RestaurantErrors.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Restaurants/Errors/RestaurantErrors.cs new file mode 100755 index 0000000..024a89e --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Restaurants/Errors/RestaurantErrors.cs @@ -0,0 +1,61 @@ +using RestaurantReservation.Domain.Common; + +namespace RestaurantReservation.Domain.Restaurants.Errors; + +public static class RestaurantErrors +{ + public static DomainError InvalidScheduleDay(string name) => new( + "Restaurant.InvalidScheduleDay", + "The day that you are trying to schedule is unknown", + ErrorType.Validation); + + public static DomainError ScheduleStartComesAfterEnd => new( + "Restaurant.InvalidScheduleTimes", + "When setting scheduling the opening time must always come before the closing time", + ErrorType.Validation); + + public static DomainError RestaurantIsAtFullCapacity(string name) => new( + "Restaurant.AtFullCapacity", + $"{name} is at full capacity at the time that you wish to schedule your reservation. Unable to reserve a table for this time", + ErrorType.CapacityExceeded); + + public static DomainError RestaurantScheduleIsIncomplete(string name) => new( + "Restaurant.ScheduleIsIncomplete", + $"The schedule for {name} is incomplete, even if you are not open 7 days a week, you must have a schedule for each day", + ErrorType.BadRequest); + + public static DomainError RestaurantScheduleIsInvalid => new( + "Restaurant.ScheduleIsInvalid", + "The schedule is invalid. Each day's opening time must come before the closing time", + ErrorType.BadRequest); + + public static DomainError RestaurantClosedToday(string name, DateOnly date) => new( + "Restaurant.Closed", + $"{name} is closed on {date}, unable to make a reservation", + ErrorType.BadRequest); + + public static DomainError RestaurantInvalidDay(string name, DateOnly date) => new( + "Restaurant.InvalidDay", + $"The date {date} is invalid to schedule a reservation at {name}", + ErrorType.BadRequest); + + public static DomainError HoursOutOfRange(string name, DateOnly date, TimeOnly start, TimeOnly end) => new( + "Restaurant.HoursOutOfRange", + $"The hours of {start:h:mm:ss tt} to {end:h:mm:ss tt} on {date} is an invalid time to schedule a reservation at {name}", + ErrorType.BadRequest); + + public static DomainError NotFound(string name) => new( + "Restaurant.NotFound", + $"{name} was not found", + ErrorType.NotFound); + + public static DomainError NotFound() => new( + "Restaurant.NotFound", + "Restaurant not found", + ErrorType.NotFound); + + public static DomainError AlreadyExists(string name) => new( + "Restaurant.AlreadyExistsInSystem", + $"{name} is already in the system and cannot be added", + ErrorType.Conflict); +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Restaurants/Restaurant.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Restaurants/Restaurant.cs new file mode 100755 index 0000000..6a4dbf3 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Restaurants/Restaurant.cs @@ -0,0 +1,143 @@ +using RestaurantReservation.Domain.Abstractions; +using RestaurantReservation.Domain.Common; +using RestaurantReservation.Domain.Common.Helpers; +using RestaurantReservation.Domain.Reservations; +using RestaurantReservation.Domain.Restaurants.Errors; +using RestaurantReservation.Domain.Tables; +using RestaurantReservation.Domain.Tables.Errors; + +namespace RestaurantReservation.Domain.Restaurants; + +public class Restaurant : BaseEntity +{ + public string Name { get; private set; } = string.Empty; + public ICollection Schedule { get; set; } = []; + public ICollection
Tables { get; set; } = []; + public ICollection TableGroups { get; set; } = []; + public ICollection Reservations { get; set; } = []; + + private Restaurant() {} + + private Restaurant(Guid id, string name) : base(id) + { + Name = name; + } + + public static Restaurant Create(string name) + { + return new Restaurant( + Guid.CreateVersion7(), + name); + } + + public void SetSchedule(List schedule) + { + foreach (var day in schedule) + { + Schedule.Add(day); + } + } + + public Result Update(string? name, List? schedule) + { + if (!string.IsNullOrWhiteSpace(name)) + { + Name = name; + } + + if (schedule is not null) + { + foreach (var day in schedule) + { + var result = UpdateScheduleDay(day); + if (result.IsFailure) return result; + } + } + + return Result.Success(); + } + + public Result UpdateScheduleDay(RestaurantSchedule scheduleDay) + { + if (!Enum.IsDefined(scheduleDay.Day)) + { + return Result.Failure(RestaurantErrors.InvalidScheduleDay(scheduleDay.Day.ToString())); + } + + if (!ValidationHelper.IsRestaurantScheduleStartAndEndTimeValid(scheduleDay.DailyHours[0], scheduleDay.DailyHours[1])) + { + return Result.Failure(RestaurantErrors.ScheduleStartComesAfterEnd); + } + + var scheduleToUpdate = Schedule.FirstOrDefault(s => s.Day == scheduleDay.Day); + + if (scheduleToUpdate is null) + { + throw new InvalidOperationException( + $"There was an unexpected error updating your schedule for {scheduleDay.Day}"); + } + + Schedule.Remove(scheduleToUpdate); + Schedule.Add(scheduleDay); + + return Result.Success(); + } + + public Table AddTable(int seats) + { + var table = Table.Create(Id, seats); + Tables.Add(table); + return table; + } + + public Result AddTableToTableGroup(Table table, string groupName) + { + var tableGroup = TableGroups.FirstOrDefault(tg => tg.Name.ToLower().Equals(groupName.ToLower())); + + if (tableGroup is null) + { + return Result.Failure(TableGroupErrors.TableGroupNotFound(groupName)); + } + + tableGroup.AddTables([table]); + return Result.Success(); + } + + public TableGroup AddTableGroup(string name, List
tables) + { + var tableGroup = TableGroup.Create(Id, name); + tableGroup.AddTables(tables); + TableGroups.Add(tableGroup); + return tableGroup; + } + + public Result RestaurantIsOpen( + DateOnly reservationDate, + TimeOnly reservationStartTime, + TimeOnly reservationEndTime) + { + var intValueForDay = (int)reservationDate.DayOfWeek; + var scheduleToCheck = Schedule.FirstOrDefault(s => (int)s.Day == intValueForDay); + + if (scheduleToCheck is null) + { + return Result.Failure(RestaurantErrors.InvalidScheduleDay(Name)); + } + + var opening = scheduleToCheck.DailyHours[0]; + var closing = scheduleToCheck.DailyHours[1]; + + if (!opening.HasValue || !closing.HasValue) + { + return Result.Failure(RestaurantErrors.RestaurantClosedToday(Name, reservationDate)); + } + + return reservationStartTime > opening && reservationEndTime < closing + ? Result.Success() + : Result.Failure(RestaurantErrors.HoursOutOfRange( + Name, + reservationDate, + reservationStartTime, + reservationEndTime)); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Restaurants/RestaurantSchedule.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Restaurants/RestaurantSchedule.cs new file mode 100755 index 0000000..aab2099 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Restaurants/RestaurantSchedule.cs @@ -0,0 +1,3 @@ +namespace RestaurantReservation.Domain.Restaurants; + +public record RestaurantSchedule(WeekDay Day, TimeOnly?[] DailyHours); \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Restaurants/WeekDay.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Restaurants/WeekDay.cs new file mode 100755 index 0000000..91afda2 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Restaurants/WeekDay.cs @@ -0,0 +1,12 @@ +namespace RestaurantReservation.Domain.Restaurants; + +public enum WeekDay +{ + Sunday = 0, + Monday = 1, + Tuesday = 2, + Wednesday = 3, + Thursday = 4, + Friday = 5, + Saturday = 6 +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Tables/Errors/TableErrors.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Tables/Errors/TableErrors.cs new file mode 100755 index 0000000..32a56da --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Tables/Errors/TableErrors.cs @@ -0,0 +1,41 @@ +using RestaurantReservation.Domain.Common; + +namespace RestaurantReservation.Domain.Tables.Errors; + +public static class TableErrors +{ + public static readonly DomainError TableCapacityExceeded = new( + "Table.CapacityExceeded", + "The number of guests in your party exceeds table capacity", + ErrorType.CapacityExceeded); + + public static readonly DomainError TableAlreadyReserved = new( + "Table.AlreadyReserved", + "This table is already reserved for this time slot", + ErrorType.Conflict); + + public static readonly DomainError TableCannotBeReserved = new( + "Table.CannotBeReserved", + "This table is not available for a reservation at this time", + ErrorType.NotFound); + + public static readonly DomainError TableNotFreed = new( + "Table.NotFreed", + "Unable to free table(s) associated with this reservation", + ErrorType.InternalServerError); + + public static readonly DomainError TableNotFound = new( + "Table.NotFound", + "Table not found", + ErrorType.NotFound); + + public static readonly DomainError TablesNotFound = new( + "Tables.NotFound", + "Tables not found", + ErrorType.NotFound); + + public static DomainError TableAlreadyInTableGroup(string groupName) => new( + "Table.AlreadyInTableGroup", + $"Table is already in table group {groupName}", + ErrorType.Conflict); +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Tables/Errors/TableGroupErrors.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Tables/Errors/TableGroupErrors.cs new file mode 100644 index 0000000..b36e296 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Tables/Errors/TableGroupErrors.cs @@ -0,0 +1,32 @@ +using RestaurantReservation.Domain.Common; + +namespace RestaurantReservation.Domain.Tables.Errors; + +public static class TableGroupErrors +{ + + public static DomainError TableGroupNotFound(string groupName) => new( + "TableGroup.NotFound", + $"There was no table group with the name {groupName} found, cannot add table", + ErrorType.NotFound); + + public static DomainError TableGroupNotFound() => new( + "TableGroup.NotFound", + "Table group not found", + ErrorType.NotFound); + + public static DomainError TableGroupsNotFound() => new( + "TableGroups.NotFound", + "Tables groups not found", + ErrorType.NotFound); + + public static DomainError TableGroupAlreadyExists(string groupName) => new( + "TableGroup.AlreadyExists", + $"Table group {groupName} already exists and cannot be added", + ErrorType.Conflict); + + public static DomainError TableGroupNameAlreadyTaken(string groupName) => new( + "TableGroup.NameAlreadyTaken", + $"The table group name {groupName} is already taken, by a table group in this restaurant, unable to update group name", + ErrorType.Conflict); +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Tables/Table.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Tables/Table.cs new file mode 100755 index 0000000..09c6b59 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Tables/Table.cs @@ -0,0 +1,60 @@ +using RestaurantReservation.Domain.Abstractions; +using RestaurantReservation.Domain.Common; +using RestaurantReservation.Domain.Reservations; +using RestaurantReservation.Domain.Reservations.ValueObjects.ReservationValueObjects; +using RestaurantReservation.Domain.Restaurants; +using RestaurantReservation.Domain.Tables.Errors; + +namespace RestaurantReservation.Domain.Tables; + +public class Table : BaseEntity +{ + public Guid RestaurantId { get; private set; } + public Restaurant? Restaurant { get; set; } + public int SeatsAtTable { get; internal set; } + public bool IsInTableGroup { get; set; } + public string? TableGroupName { get; set; } + public ICollection TableGroups { get; set; } = []; + public ICollection Reservations {get; set;} = []; + + private Table() {} + + private Table( + Guid id, + Guid restaurantId, + int seatsAtTable) : base(id) + { + RestaurantId = restaurantId; + SeatsAtTable = seatsAtTable; + } + + public static Table Create(Guid restaurantId, int seatsAtTable) + { + return new Table( + Guid.CreateVersion7(), + restaurantId, + seatsAtTable); + } + + public void UpdateSeating(int seatsAtTable) + { + SeatsAtTable = seatsAtTable; + } + + public Result ReserveTable( + DateOnly reservationDate, + TimeOnly startOfParty, + TimeOnly endOfParty) + { + var tableReservation = new TableReservation( + reservationDate, + startOfParty, + endOfParty); + + var reservationTable = new ReservationTable { TableId = Id, ScheduledReservation = tableReservation, SeatsAtTable = SeatsAtTable}; + + Reservations.Add(reservationTable); + + return Result.Success(reservationTable); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Tables/TableGroup.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Tables/TableGroup.cs new file mode 100755 index 0000000..201ead5 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Tables/TableGroup.cs @@ -0,0 +1,75 @@ +using RestaurantReservation.Domain.Abstractions; +using RestaurantReservation.Domain.Restaurants; + +namespace RestaurantReservation.Domain.Tables; + +public class TableGroup : BaseEntity +{ + public string Name { get; private set; } + public Guid RestaurantId { get; private set; } + public Restaurant? Restaurant { get; set; } + public ICollection
Tables { get; private set; } = []; + + private TableGroup() {} + + private TableGroup(Guid id, Guid restaurantId, string name) : base(id) + { + RestaurantId = restaurantId; + Name = name; + } + + public static TableGroup Create(Guid restaurantId, string name) + { + return new TableGroup( + Guid.CreateVersion7(), + restaurantId, + name); + } + + public void Update(string? name = null, List
? tables = null) + { + if (!string.IsNullOrEmpty(name)) Name = name; + + if (tables is not null) AddTables(tables); + } + + public void AddTables(List
tables) + { + foreach (var table in tables) + { + AddTable(table); + } + } + + public void RemoveTable(Table table) + { + Tables.Remove(table); + } + + public void RemoveTables() + { + foreach (var table in Tables) + { + table.IsInTableGroup = false; + table.TableGroupName = null; + } + + Tables.Clear(); + } + + public void AddTable(Table table) + { + if (table.RestaurantId != RestaurantId) + { + throw new InvalidOperationException( + "You cannot add a table to a group unless they are in the same restaurant"); + } + + if (Tables.All(t => t.Id != table.Id)) + { + table.IsInTableGroup = true; + table.TableGroupName = Name; + Tables.Add(table); + } + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Users/ApplicationUser.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Users/ApplicationUser.cs new file mode 100755 index 0000000..f175926 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Users/ApplicationUser.cs @@ -0,0 +1,14 @@ +using Microsoft.AspNetCore.Identity; + +using RestaurantReservation.Domain.Reservations; + +namespace RestaurantReservation.Domain.Users; + +public class ApplicationUser : IdentityUser +{ + public string FirstName { get; set; } = string.Empty; + public string LastName { get; set; } = string.Empty; + public DateOnly RegistrationDate { get; set; } + public ICollection RefreshTokens { get; set; } = []; + public ICollection Reservations { get; set; } = []; +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Users/Errors/EmailErrors.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Users/Errors/EmailErrors.cs new file mode 100755 index 0000000..ff23e91 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Users/Errors/EmailErrors.cs @@ -0,0 +1,11 @@ +using RestaurantReservation.Domain.Common; + +namespace RestaurantReservation.Domain.Users.Errors; + +public static class EmailErrors +{ + public static DomainError UnableToSendEmail(string address) => new( + "Email.SendingError", + $"Unable to send email to address: {address}", + ErrorType.BadRequest); +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Users/Errors/LoginErrors.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Users/Errors/LoginErrors.cs new file mode 100755 index 0000000..ae73812 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Users/Errors/LoginErrors.cs @@ -0,0 +1,11 @@ +using RestaurantReservation.Domain.Common; + +namespace RestaurantReservation.Domain.Users.Errors; + +public static class LoginErrors +{ + public static readonly DomainError InvalidCredentials = new( + "Invalid.Credentials", + "The entered credentials were invalid", + ErrorType.Unauthorized); +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Users/Errors/RefreshTokenErrors.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Users/Errors/RefreshTokenErrors.cs new file mode 100755 index 0000000..3172622 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Users/Errors/RefreshTokenErrors.cs @@ -0,0 +1,11 @@ +using RestaurantReservation.Domain.Common; + +namespace RestaurantReservation.Domain.Users.Errors; + +public static class RefreshTokenErrors +{ + public static readonly DomainError RefreshTokenInvalid = new( + "RefreshToken.Invalid", + "Unable to revoke refresh token because it is invalid", + ErrorType.Forbidden); +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Users/Errors/RegistrationErrors.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Users/Errors/RegistrationErrors.cs new file mode 100755 index 0000000..708df95 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Users/Errors/RegistrationErrors.cs @@ -0,0 +1,11 @@ +using RestaurantReservation.Domain.Common; + +namespace RestaurantReservation.Domain.Users.Errors; + +public static class RegistrationErrors +{ + public static DomainError EmailAlreadyInUse(string email) => new( + "Email.AlreadyInUse", + $"The email address '{email}' is already registered in the system", + ErrorType.Conflict); +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Users/Errors/UserErrors.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Users/Errors/UserErrors.cs new file mode 100755 index 0000000..57f5a3a --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Users/Errors/UserErrors.cs @@ -0,0 +1,11 @@ +using RestaurantReservation.Domain.Common; + +namespace RestaurantReservation.Domain.Users.Errors; + +public static class UserErrors +{ + public static readonly DomainError UserNotFound = new( + "User.NotFound", + "User not found in the system", + ErrorType.NotFound); +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Users/Events/RegistrationSuccessfulEvent.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Users/Events/RegistrationSuccessfulEvent.cs new file mode 100755 index 0000000..567b438 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Users/Events/RegistrationSuccessfulEvent.cs @@ -0,0 +1,9 @@ +using MediatR; + +namespace RestaurantReservation.Domain.Users.Events; + +public record RegistrationSuccessfulEvent( + string FirstName, + string LastName, + string Email, + DateOnly RegistrationDate) : INotification; \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Users/RefreshToken.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Users/RefreshToken.cs new file mode 100755 index 0000000..bb1b3c9 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Users/RefreshToken.cs @@ -0,0 +1,14 @@ +using RestaurantReservation.Domain.Abstractions; + +namespace RestaurantReservation.Domain.Users; + +public class RefreshToken : BaseEntity +{ + public string? UserId { get; set; } + public ApplicationUser? User { get; set; } + public string? Token { get; set; } + public DateTime? RefreshTokenExpiryTime { get; set; } + public DateTime? RevokedAt { get; set; } + public bool IsRevoked { get; set; } + public bool IsActive => RevokedAt is null && DateTime.UtcNow < RefreshTokenExpiryTime; +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Users/Roles.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Users/Roles.cs new file mode 100755 index 0000000..4668e45 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Domain/Users/Roles.cs @@ -0,0 +1,7 @@ +namespace RestaurantReservation.Domain.Users; + +public static class Roles +{ + public const string Admin = "Admin"; + public const string Customer = "Customer"; +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Auth/CurrentUser.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Auth/CurrentUser.cs new file mode 100755 index 0000000..ceed288 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Auth/CurrentUser.cs @@ -0,0 +1,16 @@ +using System.Security.Claims; + +using Microsoft.AspNetCore.Http; +using Microsoft.IdentityModel.JsonWebTokens; + +using RestaurantReservation.Application.Abstractions; + +namespace RestaurantReservation.Infrastructure.Auth; + +public class CurrentUser(IHttpContextAccessor httpContextAccessor) : ICurrentUser +{ + public string? UserId => httpContextAccessor.HttpContext?.User.FindFirstValue(JwtRegisteredClaimNames.Sub); + public string? Email => httpContextAccessor.HttpContext?.User.FindFirstValue(JwtRegisteredClaimNames.Email); + public string? Name => httpContextAccessor.HttpContext?.User.FindFirstValue(JwtRegisteredClaimNames.Name); + public bool IsAuthenticated => httpContextAccessor.HttpContext?.User.Identity?.IsAuthenticated ?? false; +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Auth/JwtSettings.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Auth/JwtSettings.cs new file mode 100755 index 0000000..1d3621d --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Auth/JwtSettings.cs @@ -0,0 +1,11 @@ +namespace RestaurantReservation.Infrastructure.Auth; + +public class JwtSettings +{ + public const string SectionName = "JwtSettings"; + public string Key { get; set; } = string.Empty; + public string Issuer { get; set; } = string.Empty; + public string Audience { get; set; } = string.Empty; + public int ExpiryInMinutes { get; set; } + public int RefreshTokenExpirationInDays { get; set; } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Auth/TokenService.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Auth/TokenService.cs new file mode 100755 index 0000000..cf5dbe7 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Auth/TokenService.cs @@ -0,0 +1,102 @@ +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; + +using RestaurantReservation.Application.Abstractions; +using RestaurantReservation.Application.Features.Auth.Command.Login; +using RestaurantReservation.Domain.Common; +using RestaurantReservation.Domain.Users; +using RestaurantReservation.Domain.Users.Errors; + +namespace RestaurantReservation.Infrastructure.Auth; + +public class TokenService( + IOptions jwtSettings, + IApplicationDbContext context) : ITokenService +{ + private readonly JwtSettings _settings = jwtSettings.Value; + + public async Task> CreateAuthenticationTokensAsync( + ApplicationUser user, + IEnumerable roles, + CancellationToken cancellationToken = default) + { + var accessTokenExpiresAt = DateTime.UtcNow.AddMinutes(_settings.ExpiryInMinutes); + + var claims = new List + { + new(JwtRegisteredClaimNames.Sub, user.Id), + new(JwtRegisteredClaimNames.Email, user.Email!), + new(JwtRegisteredClaimNames.Name, $"{user.FirstName} {user.LastName}"), + new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), + new(JwtRegisteredClaimNames.Iss, _settings.Issuer), + new(JwtRegisteredClaimNames.Aud, _settings.Audience) + }; + + claims.AddRange(roles.Select(role => new Claim("role", role))); + + var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_settings.Key)); + var credentials = new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256); + + var descriptor = new SecurityTokenDescriptor + { + Subject = new ClaimsIdentity(claims), + Expires = accessTokenExpiresAt, + Issuer = _settings.Issuer, + Audience = _settings.Audience, + SigningCredentials = credentials + }; + + var handler = new JsonWebTokenHandler(); + var accessToken = handler.CreateToken(descriptor); + var refreshToken = CreateRefreshToken(); + var refreshTokenExpiryTime = DateTime.UtcNow.AddDays(_settings.RefreshTokenExpirationInDays); + + var newRefreshToken = new RefreshToken + { + UserId = user.Id, + Token = refreshToken.Value, + RefreshTokenExpiryTime = refreshTokenExpiryTime, + IsRevoked = false + }; + + await context.RefreshTokens.AddAsync(newRefreshToken, cancellationToken); + await context.SaveChangesAsync(cancellationToken); + + return Result.Success( + new LoginResponse( + accessToken, + refreshToken.Value, + accessTokenExpiresAt, + refreshTokenExpiryTime)); + } + + public Result CreateRefreshToken() + { + var randomBytes = RandomNumberGenerator.GetBytes(64); + return Result.Success(Convert.ToBase64String(randomBytes)); + } + + public async Task RevokeRefreshTokenAsync( + string refreshToken, + CancellationToken cancellationToken = default) + { + var tokenToRevoke = await context.RefreshTokens + .FirstOrDefaultAsync(rt => rt.Token == refreshToken, cancellationToken); + + if (tokenToRevoke is null || !tokenToRevoke.IsActive) + { + return Result.Failure(RefreshTokenErrors.RefreshTokenInvalid); + } + + tokenToRevoke.IsRevoked = true; + tokenToRevoke.RevokedAt = DateTime.UtcNow; + await context.SaveChangesAsync(cancellationToken); + return Result.Success(); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/DependencyInjection.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/DependencyInjection.cs new file mode 100755 index 0000000..c71bc62 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/DependencyInjection.cs @@ -0,0 +1,91 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Text; + +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.IdentityModel.Tokens; + +using RestaurantReservation.Application.Abstractions; +using RestaurantReservation.Domain.Users; +using RestaurantReservation.Infrastructure.Auth; +using RestaurantReservation.Infrastructure.Email; +using RestaurantReservation.Infrastructure.Persistence; + +namespace RestaurantReservation.Infrastructure; + +public static class DependencyInjection +{ + public static IServiceCollection AddInfrastructure( + this IServiceCollection services, + IConfiguration configuration) + { + services.AddPersistence(); + services.AddAuth(configuration); + services.AddEmail(); + + return services; + } + + private static void AddPersistence(this IServiceCollection services) + { + services.AddScoped(sp => sp.GetRequiredService()); + } + + private static void AddAuth(this IServiceCollection services, IConfiguration configuration) + { + services.Configure(configuration.GetSection(JwtSettings.SectionName)); + var jwtSettings = configuration.GetSection(JwtSettings.SectionName) + .Get() + ?? throw new InvalidOperationException($"Unable to retrieve {JwtSettings.SectionName}"); + + services.AddIdentityCore(options => + { + options.Password.RequireDigit = true; + options.Password.RequireLowercase = true; + options.Password.RequireUppercase = true; + options.Password.RequireNonAlphanumeric = false; + options.Password.RequiredLength = 8; + options.User.RequireUniqueEmail = true; + }) + .AddRoles() + .AddSignInManager() + .AddEntityFrameworkStores() + .AddDefaultTokenProviders(); + + services.AddAuthentication(options => + { + options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; + }) + .AddJwtBearer(options => + { + options.MapInboundClaims = false; + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = jwtSettings.Issuer, + ValidAudience = jwtSettings.Audience, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSettings.Key)), + ClockSkew = TimeSpan.Zero, + NameClaimType = JwtRegisteredClaimNames.Name, + RoleClaimType = "role" + }; + }); + + services.AddAuthorization(); + services.AddHttpContextAccessor(); + services.AddScoped(); + services.AddScoped(); + } + + private static void AddEmail(this IServiceCollection services) + { + services.AddTransient(); + services.AddTransient(); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Email/EmailHelper.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Email/EmailHelper.cs new file mode 100755 index 0000000..d7b5431 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Email/EmailHelper.cs @@ -0,0 +1,49 @@ +namespace RestaurantReservation.Infrastructure.Email; + +public static class EmailHelper +{ + public static string GetFormattedEmailText(string recipientName, string body) + { + return $""" + + + + + + + +
+ + + +
+ + + + + + + + + + + + + +
+

👋 Greetings +

+

Hello {recipientName},

+ +

{body}

+ +

Sincerely yours,
Restaurant Reservations

+
+

This email was sent from the Restaurant Reservation App

+
+
+ + + """; + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Email/EmailService.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Email/EmailService.cs new file mode 100755 index 0000000..fe397ef --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Email/EmailService.cs @@ -0,0 +1,43 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +using MimeKit; + +using RestaurantReservation.Application.Abstractions; +using RestaurantReservation.Application.Features; + +namespace RestaurantReservation.Infrastructure.Email; + +public class EmailService( + ISmtpClientFactory smtpClientFactory, + IOptions smtpOptions, + ILogger logger) : IEmailService +{ + private readonly SmtpOptions _smtpOptions = smtpOptions.Value; + + public async Task SendEmailAsync(EmailInfo emailInfo, CancellationToken cancellationToken = default) + { + try + { + var msg = new MimeMessage(); + msg.From.Add(new MailboxAddress(emailInfo.SenderName, emailInfo.From)); + msg.To.Add(new MailboxAddress($"{emailInfo.RecipientName}", $"{emailInfo.To}")); + msg.Subject = $"{emailInfo.Subject}"; + var bodyBuilder = new BodyBuilder + { + HtmlBody = EmailHelper.GetFormattedEmailText(emailInfo.RecipientName, emailInfo.Body) + }; + msg.Body = bodyBuilder.ToMessageBody(); + + using var smtp = smtpClientFactory.Create(); + await smtp.ConnectAsync(_smtpOptions.Host, _smtpOptions.Port, false, cancellationToken); + await smtp.SendAsync(msg, cancellationToken); + await smtp.DisconnectAsync(true, cancellationToken); + } + catch (Exception ex) + { + logger.LogError("There was an unexpected error attempting to send an email via the email service class: {EX}", ex); + throw; + } + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Email/SmtpClientFactory.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Email/SmtpClientFactory.cs new file mode 100755 index 0000000..898159a --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Email/SmtpClientFactory.cs @@ -0,0 +1,13 @@ +using MailKit.Net.Smtp; + +using RestaurantReservation.Application.Abstractions; + +namespace RestaurantReservation.Infrastructure.Email; + +public class SmtpClientFactory : ISmtpClientFactory +{ + public ISmtpClient Create() + { + return new SmtpClient(); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Persistence/ApplicationDbContext.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Persistence/ApplicationDbContext.cs new file mode 100755 index 0000000..9f60f0e --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Persistence/ApplicationDbContext.cs @@ -0,0 +1,51 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; + +using RestaurantReservation.Application.Abstractions; +using RestaurantReservation.Domain.Reservations; +using RestaurantReservation.Domain.Restaurants; +using RestaurantReservation.Domain.Tables; +using RestaurantReservation.Domain.Users; +using RestaurantReservation.Infrastructure.Persistence.Seeding; + +namespace RestaurantReservation.Infrastructure.Persistence; + +public class ApplicationDbContext(DbContextOptions options) + : IdentityDbContext(options), IApplicationDbContext +{ + public DbSet Restaurants => Set(); + public DbSet Tables => Set
(); + public DbSet TableGroups => Set(); + public DbSet Reservations => Set(); + public DbSet RefreshTokens => Set(); + public DbSet ReservationTables => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.ApplyConfigurationsFromAssembly(typeof(ApplicationDbContext).Assembly); + base.OnModelCreating(modelBuilder); + } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + optionsBuilder.UseAsyncSeeding(async (context, _, cancellationToken) => + { + if (!await context.Set().AnyAsync(cancellationToken)) + { + var restaurants = SeedingResources.GetRestaurantsForSeeding(); + await context.Set().AddRangeAsync(restaurants, cancellationToken); + await context.SaveChangesAsync(cancellationToken); + } + }) + .UseSeeding((context, _) => + { + if (!context.Set().Any()) + { + var restaurants = SeedingResources.GetRestaurantsForSeeding(); + context.Set().AddRange(restaurants); + context.SaveChanges(); + } + }); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Persistence/Configuration/RefreshTokenConfiguration.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Persistence/Configuration/RefreshTokenConfiguration.cs new file mode 100755 index 0000000..f0b7c41 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Persistence/Configuration/RefreshTokenConfiguration.cs @@ -0,0 +1,20 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +using RestaurantReservation.Domain.Users; + +namespace RestaurantReservation.Infrastructure.Persistence.Configuration; + +public class RefreshTokenConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("refresh_tokens"); + builder.HasKey(rt => rt.Id); + + builder.HasOne(rt => rt.User) + .WithMany(u => u.RefreshTokens) + .HasForeignKey(rt => rt.UserId) + .OnDelete(DeleteBehavior.Cascade); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Persistence/Configuration/RegistrationConverters/EmailAddressConverter.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Persistence/Configuration/RegistrationConverters/EmailAddressConverter.cs new file mode 100755 index 0000000..b48f8c7 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Persistence/Configuration/RegistrationConverters/EmailAddressConverter.cs @@ -0,0 +1,8 @@ +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +using RestaurantReservation.Domain.Reservations.ValueObjects.CustomerValueObjects; + +namespace RestaurantReservation.Infrastructure.Persistence.Configuration.RegistrationConverters; + +public class EmailAddressConverter() + : ValueConverter(e => e.Value, value => new EmailAddress(value)); \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Persistence/Configuration/RegistrationConverters/FirstNameConverter.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Persistence/Configuration/RegistrationConverters/FirstNameConverter.cs new file mode 100755 index 0000000..d58ed77 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Persistence/Configuration/RegistrationConverters/FirstNameConverter.cs @@ -0,0 +1,8 @@ +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +using RestaurantReservation.Domain.Reservations.ValueObjects.CustomerValueObjects; + +namespace RestaurantReservation.Infrastructure.Persistence.Configuration.RegistrationConverters; + +public class FirstNameConverter() + : ValueConverter(fn => fn.Value, value => new FirstName(value)); \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Persistence/Configuration/RegistrationConverters/GuestsInPartyConverter.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Persistence/Configuration/RegistrationConverters/GuestsInPartyConverter.cs new file mode 100755 index 0000000..aaae171 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Persistence/Configuration/RegistrationConverters/GuestsInPartyConverter.cs @@ -0,0 +1,8 @@ +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +using RestaurantReservation.Domain.Reservations.ValueObjects.ReservationValueObjects; + +namespace RestaurantReservation.Infrastructure.Persistence.Configuration.RegistrationConverters; + +public class GuestsInPartyConverter() + : ValueConverter(g => g.Value, value => new GuestsInParty(value)); \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Persistence/Configuration/RegistrationConverters/LastNameConverter.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Persistence/Configuration/RegistrationConverters/LastNameConverter.cs new file mode 100755 index 0000000..c538923 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Persistence/Configuration/RegistrationConverters/LastNameConverter.cs @@ -0,0 +1,8 @@ +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +using RestaurantReservation.Domain.Reservations.ValueObjects.CustomerValueObjects; + +namespace RestaurantReservation.Infrastructure.Persistence.Configuration.RegistrationConverters; + +public class LastNameConverter() + : ValueConverter(ln => ln.Value, value => new LastName(value)); \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Persistence/Configuration/RegistrationConverters/ReservationConfiguration.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Persistence/Configuration/RegistrationConverters/ReservationConfiguration.cs new file mode 100755 index 0000000..02f72f0 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Persistence/Configuration/RegistrationConverters/ReservationConfiguration.cs @@ -0,0 +1,70 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +using RestaurantReservation.Domain.Reservations; +using RestaurantReservation.Domain.Reservations.ValueObjects.CustomerValueObjects; +using RestaurantReservation.Domain.Reservations.ValueObjects.ReservationValueObjects; + +namespace RestaurantReservation.Infrastructure.Persistence.Configuration.RegistrationConverters; + +public class ReservationConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("reservations"); + builder.HasKey(r => r.Id); + + builder.Property(r => r.Status) + .HasConversion(); + + builder.HasOne(rs => rs.Restaurant) + .WithMany(r => r.Reservations) + .HasForeignKey(rs => rs.RestaurantId); + + builder.HasOne(rs => rs.Customer) + .WithMany(c => c.Reservations) + .HasForeignKey(rs => rs.CustomerId); + + builder.ComplexProperty(r => r.CustomerContactInfo, ci => + { + ci.Property(i => i.FirstName) + .HasMaxLength(50) + .HasConversion(new FirstNameConverter()) + .HasColumnName("customer_first_name"); + + ci.Property(i => i.LastName) + .HasMaxLength(50) + .HasConversion(new LastNameConverter()) + .HasColumnName("customer_last_name"); + + ci.Property(i => i.EmailAddress) + .HasMaxLength(50) + .HasConversion(new EmailAddressConverter()) + .HasColumnName("customer_email_address"); + + ci.Property(i => i.TelephoneNumber) + .HasMaxLength(15) + .HasConversion(new TelephoneNumberConverter()) + .HasColumnName("customer_telephone_number"); + }); + + builder.ComplexProperty(r => r.ReservationInfo, ri => + { + ri.Property(i => i.Date) + .HasConversion(new ReservationDateConverter()) + .HasColumnName("reservation_date"); + + ri.Property(i => i.StartTime) + .HasConversion(new ReservationStartConverter()) + .HasColumnName("reservation_start_time"); + + ri.Property(i => i.EndTime) + .HasConversion(new ReservationEndConverter()) + .HasColumnName("reservation_end_time"); + + ri.Property(i => i.Guests) + .HasConversion(new GuestsInPartyConverter()) + .HasColumnName("number_of_guests"); + }); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Persistence/Configuration/RegistrationConverters/ReservationDateConverter.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Persistence/Configuration/RegistrationConverters/ReservationDateConverter.cs new file mode 100755 index 0000000..6c731f4 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Persistence/Configuration/RegistrationConverters/ReservationDateConverter.cs @@ -0,0 +1,8 @@ +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +using RestaurantReservation.Domain.Reservations.ValueObjects.ReservationValueObjects; + +namespace RestaurantReservation.Infrastructure.Persistence.Configuration.RegistrationConverters; + +public class ReservationDateConverter() + : ValueConverter(r => r.Value, value => new ReservationDate(value)); \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Persistence/Configuration/RegistrationConverters/ReservationEndConverter.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Persistence/Configuration/RegistrationConverters/ReservationEndConverter.cs new file mode 100755 index 0000000..d0cab58 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Persistence/Configuration/RegistrationConverters/ReservationEndConverter.cs @@ -0,0 +1,8 @@ +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +using RestaurantReservation.Domain.Reservations.ValueObjects.ReservationValueObjects; + +namespace RestaurantReservation.Infrastructure.Persistence.Configuration.RegistrationConverters; + +public class ReservationEndConverter() + : ValueConverter(r => r.Value, value => new ReservationEnd(value)); \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Persistence/Configuration/RegistrationConverters/ReservationStartConverter.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Persistence/Configuration/RegistrationConverters/ReservationStartConverter.cs new file mode 100755 index 0000000..341212e --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Persistence/Configuration/RegistrationConverters/ReservationStartConverter.cs @@ -0,0 +1,8 @@ +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +using RestaurantReservation.Domain.Reservations.ValueObjects.ReservationValueObjects; + +namespace RestaurantReservation.Infrastructure.Persistence.Configuration.RegistrationConverters; + +public class ReservationStartConverter() + : ValueConverter(r => r.Value, value => new ReservationStart(value)); \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Persistence/Configuration/RegistrationConverters/TelephoneNumberConverter.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Persistence/Configuration/RegistrationConverters/TelephoneNumberConverter.cs new file mode 100755 index 0000000..560d9bc --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Persistence/Configuration/RegistrationConverters/TelephoneNumberConverter.cs @@ -0,0 +1,8 @@ +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +using RestaurantReservation.Domain.Reservations.ValueObjects.CustomerValueObjects; + +namespace RestaurantReservation.Infrastructure.Persistence.Configuration.RegistrationConverters; + +public class TelephoneNumberConverter() + : ValueConverter(t => t.Value, value => new TelephoneNumber(value)); \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Persistence/Configuration/ReservationTableConfiguration.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Persistence/Configuration/ReservationTableConfiguration.cs new file mode 100755 index 0000000..9180da6 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Persistence/Configuration/ReservationTableConfiguration.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +using RestaurantReservation.Domain.Reservations; + +namespace RestaurantReservation.Infrastructure.Persistence.Configuration; + +public class ReservationTableConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("reservation_tables"); + builder.HasKey(rt => new { rt.ReservationId, rt.TableId }); + + builder.OwnsOne(rt => rt.ScheduledReservation); + + builder.HasOne(rt => rt.Reservation) + .WithMany(r => r.Tables) + .HasForeignKey(rt => rt.ReservationId); + + builder.HasOne(rt => rt.Table) + .WithMany(r => r.Reservations) + .HasForeignKey(rt => rt.TableId); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Persistence/Configuration/RestaurantConfiguration.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Persistence/Configuration/RestaurantConfiguration.cs new file mode 100755 index 0000000..08eff91 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Persistence/Configuration/RestaurantConfiguration.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +using RestaurantReservation.Domain.Restaurants; + +namespace RestaurantReservation.Infrastructure.Persistence.Configuration; + +public class RestaurantConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("restaurant"); + builder.HasKey(r => r.Id); + + builder.OwnsMany(r => r.Schedule, s => + { + s.ToJson(); + }); + + builder.HasIndex(r => r.Name); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Persistence/Configuration/TableConfiguration.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Persistence/Configuration/TableConfiguration.cs new file mode 100755 index 0000000..52dcbf3 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Persistence/Configuration/TableConfiguration.cs @@ -0,0 +1,20 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +using RestaurantReservation.Domain.Tables; + +namespace RestaurantReservation.Infrastructure.Persistence.Configuration; + +public class TableConfiguration : IEntityTypeConfiguration
+{ + public void Configure(EntityTypeBuilder
builder) + { + builder.ToTable("restaurant_tables"); + builder.HasKey(t => t.Id); + + builder.HasOne(t => t.Restaurant) + .WithMany(r => r.Tables) + .HasForeignKey(t => t.RestaurantId) + .OnDelete(DeleteBehavior.Cascade); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Persistence/Configuration/TableGroupConfiguration.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Persistence/Configuration/TableGroupConfiguration.cs new file mode 100755 index 0000000..d377048 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Persistence/Configuration/TableGroupConfiguration.cs @@ -0,0 +1,20 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +using RestaurantReservation.Domain.Tables; + +namespace RestaurantReservation.Infrastructure.Persistence.Configuration; + +public class TableGroupConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("table_groups"); + builder.HasKey(tg => tg.Id); + + builder.HasOne(tg => tg.Restaurant) + .WithMany(r => r.TableGroups) + .HasForeignKey(tg => tg.RestaurantId) + .OnDelete(DeleteBehavior.Cascade); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Persistence/DesignTimeContextFactory.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Persistence/DesignTimeContextFactory.cs new file mode 100755 index 0000000..cc73df9 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Persistence/DesignTimeContextFactory.cs @@ -0,0 +1,17 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace RestaurantReservation.Infrastructure.Persistence; + +public class DesignTimeContextFactory : IDesignTimeDbContextFactory +{ + public ApplicationDbContext CreateDbContext(string[] args) + { + var options = new DbContextOptionsBuilder() + .UseNpgsql("Host=localhost;Port=5432;Database=reservationDB;Username=postgres;Password=postgres") + .UseSnakeCaseNamingConvention() + .Options; + + return new ApplicationDbContext(options); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Persistence/Migrations/20260811225047_InitialMigration.Designer.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Persistence/Migrations/20260811225047_InitialMigration.Designer.cs new file mode 100644 index 0000000..89380a4 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Persistence/Migrations/20260811225047_InitialMigration.Designer.cs @@ -0,0 +1,791 @@ +// +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using RestaurantReservation.Infrastructure.Persistence; + +#nullable disable + +namespace RestaurantReservation.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260811225047_InitialMigration")] + partial class InitialMigration + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text") + .HasColumnName("concurrency_stamp"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("normalized_name"); + + b.HasKey("Id") + .HasName("pk_asp_net_roles"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text") + .HasColumnName("claim_type"); + + b.Property("ClaimValue") + .HasColumnType("text") + .HasColumnName("claim_value"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("role_id"); + + b.HasKey("Id") + .HasName("pk_asp_net_role_claims"); + + b.HasIndex("RoleId") + .HasDatabaseName("ix_asp_net_role_claims_role_id"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text") + .HasColumnName("claim_type"); + + b.Property("ClaimValue") + .HasColumnType("text") + .HasColumnName("claim_value"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_asp_net_user_claims"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_asp_net_user_claims_user_id"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text") + .HasColumnName("login_provider"); + + b.Property("ProviderKey") + .HasColumnType("text") + .HasColumnName("provider_key"); + + b.Property("ProviderDisplayName") + .HasColumnType("text") + .HasColumnName("provider_display_name"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("user_id"); + + b.HasKey("LoginProvider", "ProviderKey") + .HasName("pk_asp_net_user_logins"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_asp_net_user_logins_user_id"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text") + .HasColumnName("user_id"); + + b.Property("RoleId") + .HasColumnType("text") + .HasColumnName("role_id"); + + b.HasKey("UserId", "RoleId") + .HasName("pk_asp_net_user_roles"); + + b.HasIndex("RoleId") + .HasDatabaseName("ix_asp_net_user_roles_role_id"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text") + .HasColumnName("user_id"); + + b.Property("LoginProvider") + .HasColumnType("text") + .HasColumnName("login_provider"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Value") + .HasColumnType("text") + .HasColumnName("value"); + + b.HasKey("UserId", "LoginProvider", "Name") + .HasName("pk_asp_net_user_tokens"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("RestaurantReservation.Domain.Reservations.Reservation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CustomerId") + .HasColumnType("text") + .HasColumnName("customer_id"); + + b.Property("ReservationCanceledAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("reservation_canceled_at_utc"); + + b.Property("ReservationCompletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("reservation_completed_at_utc"); + + b.Property("ReservationCreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("reservation_created_at_utc"); + + b.Property("ReservationUpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("reservation_updated_at_utc"); + + b.Property("RestaurantId") + .HasColumnType("uuid") + .HasColumnName("restaurant_id"); + + b.Property("RestaurantName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("restaurant_name"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text") + .HasColumnName("status"); + + b.Property("TableGroup") + .HasColumnType("text") + .HasColumnName("table_group"); + + b.ComplexProperty(typeof(Dictionary), "CustomerContactInfo", "RestaurantReservation.Domain.Reservations.Reservation.CustomerContactInfo#CustomerContactInfo", b1 => + { + b1.IsRequired(); + + b1.Property("EmailAddress") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("customer_email_address"); + + b1.Property("FirstName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("customer_first_name"); + + b1.Property("LastName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("customer_last_name"); + + b1.Property("TelephoneNumber") + .IsRequired() + .HasMaxLength(15) + .HasColumnType("character varying(15)") + .HasColumnName("customer_telephone_number"); + }); + + b.ComplexProperty(typeof(Dictionary), "ReservationInfo", "RestaurantReservation.Domain.Reservations.Reservation.ReservationInfo#ReservationInfo", b1 => + { + b1.IsRequired(); + + b1.Property("Date") + .HasColumnType("date") + .HasColumnName("reservation_date"); + + b1.Property("EndTime") + .HasColumnType("time without time zone") + .HasColumnName("reservation_end_time"); + + b1.Property("Guests") + .HasColumnType("integer") + .HasColumnName("number_of_guests"); + + b1.Property("StartTime") + .HasColumnType("time without time zone") + .HasColumnName("reservation_start_time"); + }); + + b.HasKey("Id") + .HasName("pk_reservations"); + + b.HasIndex("CustomerId") + .HasDatabaseName("ix_reservations_customer_id"); + + b.HasIndex("RestaurantId") + .HasDatabaseName("ix_reservations_restaurant_id"); + + b.ToTable("reservations", (string)null); + }); + + modelBuilder.Entity("RestaurantReservation.Domain.Reservations.ReservationTable", b => + { + b.Property("ReservationId") + .HasColumnType("uuid") + .HasColumnName("reservation_id"); + + b.Property("TableId") + .HasColumnType("uuid") + .HasColumnName("table_id"); + + b.Property("SeatsAtTable") + .HasColumnType("integer") + .HasColumnName("seats_at_table"); + + b.HasKey("ReservationId", "TableId") + .HasName("pk_reservation_tables"); + + b.HasIndex("TableId") + .HasDatabaseName("ix_reservation_tables_table_id"); + + b.ToTable("reservation_tables", (string)null); + }); + + modelBuilder.Entity("RestaurantReservation.Domain.Restaurants.Restaurant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.HasKey("Id") + .HasName("pk_restaurant"); + + b.HasIndex("Name") + .HasDatabaseName("ix_restaurant_name"); + + b.ToTable("restaurant", (string)null); + }); + + modelBuilder.Entity("RestaurantReservation.Domain.Tables.Table", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("IsInTableGroup") + .HasColumnType("boolean") + .HasColumnName("is_in_table_group"); + + b.Property("RestaurantId") + .HasColumnType("uuid") + .HasColumnName("restaurant_id"); + + b.Property("SeatsAtTable") + .HasColumnType("integer") + .HasColumnName("seats_at_table"); + + b.Property("TableGroupName") + .HasColumnType("text") + .HasColumnName("table_group_name"); + + b.HasKey("Id") + .HasName("pk_restaurant_tables"); + + b.HasIndex("RestaurantId") + .HasDatabaseName("ix_restaurant_tables_restaurant_id"); + + b.ToTable("restaurant_tables", (string)null); + }); + + modelBuilder.Entity("RestaurantReservation.Domain.Tables.TableGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("RestaurantId") + .HasColumnType("uuid") + .HasColumnName("restaurant_id"); + + b.HasKey("Id") + .HasName("pk_table_groups"); + + b.HasIndex("RestaurantId") + .HasDatabaseName("ix_table_groups_restaurant_id"); + + b.ToTable("table_groups", (string)null); + }); + + modelBuilder.Entity("RestaurantReservation.Domain.Users.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("AccessFailedCount") + .HasColumnType("integer") + .HasColumnName("access_failed_count"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text") + .HasColumnName("concurrency_stamp"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("email"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean") + .HasColumnName("email_confirmed"); + + b.Property("FirstName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("first_name"); + + b.Property("LastName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("last_name"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean") + .HasColumnName("lockout_enabled"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone") + .HasColumnName("lockout_end"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("normalized_email"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("normalized_user_name"); + + b.Property("PasswordHash") + .HasColumnType("text") + .HasColumnName("password_hash"); + + b.Property("PhoneNumber") + .HasColumnType("text") + .HasColumnName("phone_number"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean") + .HasColumnName("phone_number_confirmed"); + + b.Property("RegistrationDate") + .HasColumnType("date") + .HasColumnName("registration_date"); + + b.Property("SecurityStamp") + .HasColumnType("text") + .HasColumnName("security_stamp"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean") + .HasColumnName("two_factor_enabled"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("user_name"); + + b.HasKey("Id") + .HasName("pk_asp_net_users"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("RestaurantReservation.Domain.Users.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("IsRevoked") + .HasColumnType("boolean") + .HasColumnName("is_revoked"); + + b.Property("RefreshTokenExpiryTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("refresh_token_expiry_time"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("revoked_at"); + + b.Property("Token") + .HasColumnType("text") + .HasColumnName("token"); + + b.Property("UserId") + .HasColumnType("text") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_refresh_tokens"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_refresh_tokens_user_id"); + + b.ToTable("refresh_tokens", (string)null); + }); + + modelBuilder.Entity("TableTableGroup", b => + { + b.Property("TableGroupsId") + .HasColumnType("uuid") + .HasColumnName("table_groups_id"); + + b.Property("TablesId") + .HasColumnType("uuid") + .HasColumnName("tables_id"); + + b.HasKey("TableGroupsId", "TablesId") + .HasName("pk_table_table_group"); + + b.HasIndex("TablesId") + .HasDatabaseName("ix_table_table_group_tables_id"); + + b.ToTable("table_table_group", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_asp_net_role_claims_asp_net_roles_role_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("RestaurantReservation.Domain.Users.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_asp_net_user_claims_asp_net_users_user_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("RestaurantReservation.Domain.Users.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_asp_net_user_logins_asp_net_users_user_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_asp_net_user_roles_asp_net_roles_role_id"); + + b.HasOne("RestaurantReservation.Domain.Users.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_asp_net_user_roles_asp_net_users_user_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("RestaurantReservation.Domain.Users.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_asp_net_user_tokens_asp_net_users_user_id"); + }); + + modelBuilder.Entity("RestaurantReservation.Domain.Reservations.Reservation", b => + { + b.HasOne("RestaurantReservation.Domain.Users.ApplicationUser", "Customer") + .WithMany("Reservations") + .HasForeignKey("CustomerId") + .HasConstraintName("fk_reservations_users_customer_id"); + + b.HasOne("RestaurantReservation.Domain.Restaurants.Restaurant", "Restaurant") + .WithMany("Reservations") + .HasForeignKey("RestaurantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_reservations_restaurants_restaurant_id"); + + b.Navigation("Customer"); + + b.Navigation("Restaurant"); + }); + + modelBuilder.Entity("RestaurantReservation.Domain.Reservations.ReservationTable", b => + { + b.HasOne("RestaurantReservation.Domain.Reservations.Reservation", "Reservation") + .WithMany("Tables") + .HasForeignKey("ReservationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_reservation_tables_reservations_reservation_id"); + + b.HasOne("RestaurantReservation.Domain.Tables.Table", "Table") + .WithMany("Reservations") + .HasForeignKey("TableId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_reservation_tables_tables_table_id"); + + b.OwnsOne("RestaurantReservation.Domain.Reservations.ValueObjects.ReservationValueObjects.TableReservation", "ScheduledReservation", b1 => + { + b1.Property("ReservationTableReservationId") + .HasColumnType("uuid") + .HasColumnName("reservation_id"); + + b1.Property("ReservationTableTableId") + .HasColumnType("uuid") + .HasColumnName("table_id"); + + b1.Property("ReservationDay") + .HasColumnType("date") + .HasColumnName("scheduled_reservation_reservation_day"); + + b1.Property("ReservationEnd") + .HasColumnType("time without time zone") + .HasColumnName("scheduled_reservation_reservation_end"); + + b1.Property("ReservationStart") + .HasColumnType("time without time zone") + .HasColumnName("scheduled_reservation_reservation_start"); + + b1.HasKey("ReservationTableReservationId", "ReservationTableTableId"); + + b1.ToTable("reservation_tables"); + + b1.WithOwner() + .HasForeignKey("ReservationTableReservationId", "ReservationTableTableId") + .HasConstraintName("fk_reservation_tables_reservation_tables_reservation_id_table_"); + }); + + b.Navigation("Reservation"); + + b.Navigation("ScheduledReservation") + .IsRequired(); + + b.Navigation("Table"); + }); + + modelBuilder.Entity("RestaurantReservation.Domain.Restaurants.Restaurant", b => + { + b.OwnsMany("RestaurantReservation.Domain.Restaurants.RestaurantSchedule", "Schedule", b1 => + { + b1.Property("RestaurantId"); + + b1.Property("__synthesizedOrdinal") + .ValueGeneratedOnAdd(); + + b1.PrimitiveCollection("DailyHours") + .IsRequired(); + + b1.Property("Day"); + + b1.HasKey("RestaurantId", "__synthesizedOrdinal"); + + b1.ToTable("restaurant"); + + b1 + .ToJson("schedule") + .HasColumnType("jsonb"); + + b1.WithOwner() + .HasForeignKey("RestaurantId") + .HasConstraintName("fk_restaurant_restaurant_restaurant_id"); + }); + + b.Navigation("Schedule"); + }); + + modelBuilder.Entity("RestaurantReservation.Domain.Tables.Table", b => + { + b.HasOne("RestaurantReservation.Domain.Restaurants.Restaurant", "Restaurant") + .WithMany("Tables") + .HasForeignKey("RestaurantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_restaurant_tables_restaurant_restaurant_id"); + + b.Navigation("Restaurant"); + }); + + modelBuilder.Entity("RestaurantReservation.Domain.Tables.TableGroup", b => + { + b.HasOne("RestaurantReservation.Domain.Restaurants.Restaurant", "Restaurant") + .WithMany("TableGroups") + .HasForeignKey("RestaurantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_table_groups_restaurants_restaurant_id"); + + b.Navigation("Restaurant"); + }); + + modelBuilder.Entity("RestaurantReservation.Domain.Users.RefreshToken", b => + { + b.HasOne("RestaurantReservation.Domain.Users.ApplicationUser", "User") + .WithMany("RefreshTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_refresh_tokens_users_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("TableTableGroup", b => + { + b.HasOne("RestaurantReservation.Domain.Tables.TableGroup", null) + .WithMany() + .HasForeignKey("TableGroupsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_table_table_group_table_groups_table_groups_id"); + + b.HasOne("RestaurantReservation.Domain.Tables.Table", null) + .WithMany() + .HasForeignKey("TablesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_table_table_group_tables_tables_id"); + }); + + modelBuilder.Entity("RestaurantReservation.Domain.Reservations.Reservation", b => + { + b.Navigation("Tables"); + }); + + modelBuilder.Entity("RestaurantReservation.Domain.Restaurants.Restaurant", b => + { + b.Navigation("Reservations"); + + b.Navigation("TableGroups"); + + b.Navigation("Tables"); + }); + + modelBuilder.Entity("RestaurantReservation.Domain.Tables.Table", b => + { + b.Navigation("Reservations"); + }); + + modelBuilder.Entity("RestaurantReservation.Domain.Users.ApplicationUser", b => + { + b.Navigation("RefreshTokens"); + + b.Navigation("Reservations"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Persistence/Migrations/20260811225047_InitialMigration.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Persistence/Migrations/20260811225047_InitialMigration.cs new file mode 100644 index 0000000..681942c --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Persistence/Migrations/20260811225047_InitialMigration.cs @@ -0,0 +1,453 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace RestaurantReservation.Infrastructure.Persistence.Migrations +{ + /// + public partial class InitialMigration : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AspNetRoles", + columns: table => new + { + id = table.Column(type: "text", nullable: false), + name = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + normalized_name = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + concurrency_stamp = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_asp_net_roles", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "AspNetUsers", + columns: table => new + { + id = table.Column(type: "text", nullable: false), + first_name = table.Column(type: "text", nullable: false), + last_name = table.Column(type: "text", nullable: false), + registration_date = table.Column(type: "date", nullable: false), + user_name = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + normalized_user_name = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + email = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + normalized_email = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + email_confirmed = table.Column(type: "boolean", nullable: false), + password_hash = table.Column(type: "text", nullable: true), + security_stamp = table.Column(type: "text", nullable: true), + concurrency_stamp = table.Column(type: "text", nullable: true), + phone_number = table.Column(type: "text", nullable: true), + phone_number_confirmed = table.Column(type: "boolean", nullable: false), + two_factor_enabled = table.Column(type: "boolean", nullable: false), + lockout_end = table.Column(type: "timestamp with time zone", nullable: true), + lockout_enabled = table.Column(type: "boolean", nullable: false), + access_failed_count = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_asp_net_users", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "restaurant", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + name = table.Column(type: "text", nullable: false), + schedule = table.Column(type: "jsonb", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_restaurant", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "AspNetRoleClaims", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + role_id = table.Column(type: "text", nullable: false), + claim_type = table.Column(type: "text", nullable: true), + claim_value = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_asp_net_role_claims", x => x.id); + table.ForeignKey( + name: "fk_asp_net_role_claims_asp_net_roles_role_id", + column: x => x.role_id, + principalTable: "AspNetRoles", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserClaims", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + user_id = table.Column(type: "text", nullable: false), + claim_type = table.Column(type: "text", nullable: true), + claim_value = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_asp_net_user_claims", x => x.id); + table.ForeignKey( + name: "fk_asp_net_user_claims_asp_net_users_user_id", + column: x => x.user_id, + principalTable: "AspNetUsers", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserLogins", + columns: table => new + { + login_provider = table.Column(type: "text", nullable: false), + provider_key = table.Column(type: "text", nullable: false), + provider_display_name = table.Column(type: "text", nullable: true), + user_id = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_asp_net_user_logins", x => new { x.login_provider, x.provider_key }); + table.ForeignKey( + name: "fk_asp_net_user_logins_asp_net_users_user_id", + column: x => x.user_id, + principalTable: "AspNetUsers", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserRoles", + columns: table => new + { + user_id = table.Column(type: "text", nullable: false), + role_id = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_asp_net_user_roles", x => new { x.user_id, x.role_id }); + table.ForeignKey( + name: "fk_asp_net_user_roles_asp_net_roles_role_id", + column: x => x.role_id, + principalTable: "AspNetRoles", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "fk_asp_net_user_roles_asp_net_users_user_id", + column: x => x.user_id, + principalTable: "AspNetUsers", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserTokens", + columns: table => new + { + user_id = table.Column(type: "text", nullable: false), + login_provider = table.Column(type: "text", nullable: false), + name = table.Column(type: "text", nullable: false), + value = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_asp_net_user_tokens", x => new { x.user_id, x.login_provider, x.name }); + table.ForeignKey( + name: "fk_asp_net_user_tokens_asp_net_users_user_id", + column: x => x.user_id, + principalTable: "AspNetUsers", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "refresh_tokens", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + user_id = table.Column(type: "text", nullable: true), + token = table.Column(type: "text", nullable: true), + refresh_token_expiry_time = table.Column(type: "timestamp with time zone", nullable: true), + revoked_at = table.Column(type: "timestamp with time zone", nullable: true), + is_revoked = table.Column(type: "boolean", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_refresh_tokens", x => x.id); + table.ForeignKey( + name: "fk_refresh_tokens_users_user_id", + column: x => x.user_id, + principalTable: "AspNetUsers", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "reservations", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + restaurant_id = table.Column(type: "uuid", nullable: false), + customer_id = table.Column(type: "text", nullable: true), + restaurant_name = table.Column(type: "text", nullable: false), + table_group = table.Column(type: "text", nullable: true), + status = table.Column(type: "text", nullable: false), + reservation_created_at_utc = table.Column(type: "timestamp with time zone", nullable: false), + reservation_updated_at_utc = table.Column(type: "timestamp with time zone", nullable: true), + reservation_canceled_at_utc = table.Column(type: "timestamp with time zone", nullable: true), + reservation_completed_at_utc = table.Column(type: "timestamp with time zone", nullable: true), + customer_email_address = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + customer_first_name = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + customer_last_name = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + customer_telephone_number = table.Column(type: "character varying(15)", maxLength: 15, nullable: false), + reservation_date = table.Column(type: "date", nullable: false), + reservation_end_time = table.Column(type: "time without time zone", nullable: false), + number_of_guests = table.Column(type: "integer", nullable: false), + reservation_start_time = table.Column(type: "time without time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_reservations", x => x.id); + table.ForeignKey( + name: "fk_reservations_restaurants_restaurant_id", + column: x => x.restaurant_id, + principalTable: "restaurant", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "fk_reservations_users_customer_id", + column: x => x.customer_id, + principalTable: "AspNetUsers", + principalColumn: "id"); + }); + + migrationBuilder.CreateTable( + name: "restaurant_tables", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + restaurant_id = table.Column(type: "uuid", nullable: false), + seats_at_table = table.Column(type: "integer", nullable: false), + is_in_table_group = table.Column(type: "boolean", nullable: false), + table_group_name = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_restaurant_tables", x => x.id); + table.ForeignKey( + name: "fk_restaurant_tables_restaurant_restaurant_id", + column: x => x.restaurant_id, + principalTable: "restaurant", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "table_groups", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + name = table.Column(type: "text", nullable: false), + restaurant_id = table.Column(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_table_groups", x => x.id); + table.ForeignKey( + name: "fk_table_groups_restaurants_restaurant_id", + column: x => x.restaurant_id, + principalTable: "restaurant", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "reservation_tables", + columns: table => new + { + reservation_id = table.Column(type: "uuid", nullable: false), + table_id = table.Column(type: "uuid", nullable: false), + scheduled_reservation_reservation_day = table.Column(type: "date", nullable: false), + scheduled_reservation_reservation_start = table.Column(type: "time without time zone", nullable: false), + scheduled_reservation_reservation_end = table.Column(type: "time without time zone", nullable: false), + seats_at_table = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_reservation_tables", x => new { x.reservation_id, x.table_id }); + table.ForeignKey( + name: "fk_reservation_tables_reservations_reservation_id", + column: x => x.reservation_id, + principalTable: "reservations", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "fk_reservation_tables_tables_table_id", + column: x => x.table_id, + principalTable: "restaurant_tables", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "table_table_group", + columns: table => new + { + table_groups_id = table.Column(type: "uuid", nullable: false), + tables_id = table.Column(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_table_table_group", x => new { x.table_groups_id, x.tables_id }); + table.ForeignKey( + name: "fk_table_table_group_table_groups_table_groups_id", + column: x => x.table_groups_id, + principalTable: "table_groups", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "fk_table_table_group_tables_tables_id", + column: x => x.tables_id, + principalTable: "restaurant_tables", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "ix_asp_net_role_claims_role_id", + table: "AspNetRoleClaims", + column: "role_id"); + + migrationBuilder.CreateIndex( + name: "RoleNameIndex", + table: "AspNetRoles", + column: "normalized_name", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_asp_net_user_claims_user_id", + table: "AspNetUserClaims", + column: "user_id"); + + migrationBuilder.CreateIndex( + name: "ix_asp_net_user_logins_user_id", + table: "AspNetUserLogins", + column: "user_id"); + + migrationBuilder.CreateIndex( + name: "ix_asp_net_user_roles_role_id", + table: "AspNetUserRoles", + column: "role_id"); + + migrationBuilder.CreateIndex( + name: "EmailIndex", + table: "AspNetUsers", + column: "normalized_email"); + + migrationBuilder.CreateIndex( + name: "UserNameIndex", + table: "AspNetUsers", + column: "normalized_user_name", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_refresh_tokens_user_id", + table: "refresh_tokens", + column: "user_id"); + + migrationBuilder.CreateIndex( + name: "ix_reservation_tables_table_id", + table: "reservation_tables", + column: "table_id"); + + migrationBuilder.CreateIndex( + name: "ix_reservations_customer_id", + table: "reservations", + column: "customer_id"); + + migrationBuilder.CreateIndex( + name: "ix_reservations_restaurant_id", + table: "reservations", + column: "restaurant_id"); + + migrationBuilder.CreateIndex( + name: "ix_restaurant_name", + table: "restaurant", + column: "name"); + + migrationBuilder.CreateIndex( + name: "ix_restaurant_tables_restaurant_id", + table: "restaurant_tables", + column: "restaurant_id"); + + migrationBuilder.CreateIndex( + name: "ix_table_groups_restaurant_id", + table: "table_groups", + column: "restaurant_id"); + + migrationBuilder.CreateIndex( + name: "ix_table_table_group_tables_id", + table: "table_table_group", + column: "tables_id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AspNetRoleClaims"); + + migrationBuilder.DropTable( + name: "AspNetUserClaims"); + + migrationBuilder.DropTable( + name: "AspNetUserLogins"); + + migrationBuilder.DropTable( + name: "AspNetUserRoles"); + + migrationBuilder.DropTable( + name: "AspNetUserTokens"); + + migrationBuilder.DropTable( + name: "refresh_tokens"); + + migrationBuilder.DropTable( + name: "reservation_tables"); + + migrationBuilder.DropTable( + name: "table_table_group"); + + migrationBuilder.DropTable( + name: "AspNetRoles"); + + migrationBuilder.DropTable( + name: "reservations"); + + migrationBuilder.DropTable( + name: "table_groups"); + + migrationBuilder.DropTable( + name: "restaurant_tables"); + + migrationBuilder.DropTable( + name: "AspNetUsers"); + + migrationBuilder.DropTable( + name: "restaurant"); + } + } +} diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Persistence/Migrations/20260811225627_AddReservationTableExclusion.Designer.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Persistence/Migrations/20260811225627_AddReservationTableExclusion.Designer.cs new file mode 100644 index 0000000..1496be6 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Persistence/Migrations/20260811225627_AddReservationTableExclusion.Designer.cs @@ -0,0 +1,791 @@ +// +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using RestaurantReservation.Infrastructure.Persistence; + +#nullable disable + +namespace RestaurantReservation.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260811225627_AddReservationTableExclusion")] + partial class AddReservationTableExclusion + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text") + .HasColumnName("concurrency_stamp"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("normalized_name"); + + b.HasKey("Id") + .HasName("pk_asp_net_roles"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text") + .HasColumnName("claim_type"); + + b.Property("ClaimValue") + .HasColumnType("text") + .HasColumnName("claim_value"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("role_id"); + + b.HasKey("Id") + .HasName("pk_asp_net_role_claims"); + + b.HasIndex("RoleId") + .HasDatabaseName("ix_asp_net_role_claims_role_id"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text") + .HasColumnName("claim_type"); + + b.Property("ClaimValue") + .HasColumnType("text") + .HasColumnName("claim_value"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_asp_net_user_claims"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_asp_net_user_claims_user_id"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text") + .HasColumnName("login_provider"); + + b.Property("ProviderKey") + .HasColumnType("text") + .HasColumnName("provider_key"); + + b.Property("ProviderDisplayName") + .HasColumnType("text") + .HasColumnName("provider_display_name"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("user_id"); + + b.HasKey("LoginProvider", "ProviderKey") + .HasName("pk_asp_net_user_logins"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_asp_net_user_logins_user_id"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text") + .HasColumnName("user_id"); + + b.Property("RoleId") + .HasColumnType("text") + .HasColumnName("role_id"); + + b.HasKey("UserId", "RoleId") + .HasName("pk_asp_net_user_roles"); + + b.HasIndex("RoleId") + .HasDatabaseName("ix_asp_net_user_roles_role_id"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text") + .HasColumnName("user_id"); + + b.Property("LoginProvider") + .HasColumnType("text") + .HasColumnName("login_provider"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Value") + .HasColumnType("text") + .HasColumnName("value"); + + b.HasKey("UserId", "LoginProvider", "Name") + .HasName("pk_asp_net_user_tokens"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("RestaurantReservation.Domain.Reservations.Reservation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CustomerId") + .HasColumnType("text") + .HasColumnName("customer_id"); + + b.Property("ReservationCanceledAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("reservation_canceled_at_utc"); + + b.Property("ReservationCompletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("reservation_completed_at_utc"); + + b.Property("ReservationCreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("reservation_created_at_utc"); + + b.Property("ReservationUpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("reservation_updated_at_utc"); + + b.Property("RestaurantId") + .HasColumnType("uuid") + .HasColumnName("restaurant_id"); + + b.Property("RestaurantName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("restaurant_name"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text") + .HasColumnName("status"); + + b.Property("TableGroup") + .HasColumnType("text") + .HasColumnName("table_group"); + + b.ComplexProperty(typeof(Dictionary), "CustomerContactInfo", "RestaurantReservation.Domain.Reservations.Reservation.CustomerContactInfo#CustomerContactInfo", b1 => + { + b1.IsRequired(); + + b1.Property("EmailAddress") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("customer_email_address"); + + b1.Property("FirstName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("customer_first_name"); + + b1.Property("LastName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("customer_last_name"); + + b1.Property("TelephoneNumber") + .IsRequired() + .HasMaxLength(15) + .HasColumnType("character varying(15)") + .HasColumnName("customer_telephone_number"); + }); + + b.ComplexProperty(typeof(Dictionary), "ReservationInfo", "RestaurantReservation.Domain.Reservations.Reservation.ReservationInfo#ReservationInfo", b1 => + { + b1.IsRequired(); + + b1.Property("Date") + .HasColumnType("date") + .HasColumnName("reservation_date"); + + b1.Property("EndTime") + .HasColumnType("time without time zone") + .HasColumnName("reservation_end_time"); + + b1.Property("Guests") + .HasColumnType("integer") + .HasColumnName("number_of_guests"); + + b1.Property("StartTime") + .HasColumnType("time without time zone") + .HasColumnName("reservation_start_time"); + }); + + b.HasKey("Id") + .HasName("pk_reservations"); + + b.HasIndex("CustomerId") + .HasDatabaseName("ix_reservations_customer_id"); + + b.HasIndex("RestaurantId") + .HasDatabaseName("ix_reservations_restaurant_id"); + + b.ToTable("reservations", (string)null); + }); + + modelBuilder.Entity("RestaurantReservation.Domain.Reservations.ReservationTable", b => + { + b.Property("ReservationId") + .HasColumnType("uuid") + .HasColumnName("reservation_id"); + + b.Property("TableId") + .HasColumnType("uuid") + .HasColumnName("table_id"); + + b.Property("SeatsAtTable") + .HasColumnType("integer") + .HasColumnName("seats_at_table"); + + b.HasKey("ReservationId", "TableId") + .HasName("pk_reservation_tables"); + + b.HasIndex("TableId") + .HasDatabaseName("ix_reservation_tables_table_id"); + + b.ToTable("reservation_tables", (string)null); + }); + + modelBuilder.Entity("RestaurantReservation.Domain.Restaurants.Restaurant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.HasKey("Id") + .HasName("pk_restaurant"); + + b.HasIndex("Name") + .HasDatabaseName("ix_restaurant_name"); + + b.ToTable("restaurant", (string)null); + }); + + modelBuilder.Entity("RestaurantReservation.Domain.Tables.Table", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("IsInTableGroup") + .HasColumnType("boolean") + .HasColumnName("is_in_table_group"); + + b.Property("RestaurantId") + .HasColumnType("uuid") + .HasColumnName("restaurant_id"); + + b.Property("SeatsAtTable") + .HasColumnType("integer") + .HasColumnName("seats_at_table"); + + b.Property("TableGroupName") + .HasColumnType("text") + .HasColumnName("table_group_name"); + + b.HasKey("Id") + .HasName("pk_restaurant_tables"); + + b.HasIndex("RestaurantId") + .HasDatabaseName("ix_restaurant_tables_restaurant_id"); + + b.ToTable("restaurant_tables", (string)null); + }); + + modelBuilder.Entity("RestaurantReservation.Domain.Tables.TableGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("RestaurantId") + .HasColumnType("uuid") + .HasColumnName("restaurant_id"); + + b.HasKey("Id") + .HasName("pk_table_groups"); + + b.HasIndex("RestaurantId") + .HasDatabaseName("ix_table_groups_restaurant_id"); + + b.ToTable("table_groups", (string)null); + }); + + modelBuilder.Entity("RestaurantReservation.Domain.Users.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("AccessFailedCount") + .HasColumnType("integer") + .HasColumnName("access_failed_count"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text") + .HasColumnName("concurrency_stamp"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("email"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean") + .HasColumnName("email_confirmed"); + + b.Property("FirstName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("first_name"); + + b.Property("LastName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("last_name"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean") + .HasColumnName("lockout_enabled"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone") + .HasColumnName("lockout_end"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("normalized_email"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("normalized_user_name"); + + b.Property("PasswordHash") + .HasColumnType("text") + .HasColumnName("password_hash"); + + b.Property("PhoneNumber") + .HasColumnType("text") + .HasColumnName("phone_number"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean") + .HasColumnName("phone_number_confirmed"); + + b.Property("RegistrationDate") + .HasColumnType("date") + .HasColumnName("registration_date"); + + b.Property("SecurityStamp") + .HasColumnType("text") + .HasColumnName("security_stamp"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean") + .HasColumnName("two_factor_enabled"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("user_name"); + + b.HasKey("Id") + .HasName("pk_asp_net_users"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("RestaurantReservation.Domain.Users.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("IsRevoked") + .HasColumnType("boolean") + .HasColumnName("is_revoked"); + + b.Property("RefreshTokenExpiryTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("refresh_token_expiry_time"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("revoked_at"); + + b.Property("Token") + .HasColumnType("text") + .HasColumnName("token"); + + b.Property("UserId") + .HasColumnType("text") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_refresh_tokens"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_refresh_tokens_user_id"); + + b.ToTable("refresh_tokens", (string)null); + }); + + modelBuilder.Entity("TableTableGroup", b => + { + b.Property("TableGroupsId") + .HasColumnType("uuid") + .HasColumnName("table_groups_id"); + + b.Property("TablesId") + .HasColumnType("uuid") + .HasColumnName("tables_id"); + + b.HasKey("TableGroupsId", "TablesId") + .HasName("pk_table_table_group"); + + b.HasIndex("TablesId") + .HasDatabaseName("ix_table_table_group_tables_id"); + + b.ToTable("table_table_group", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_asp_net_role_claims_asp_net_roles_role_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("RestaurantReservation.Domain.Users.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_asp_net_user_claims_asp_net_users_user_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("RestaurantReservation.Domain.Users.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_asp_net_user_logins_asp_net_users_user_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_asp_net_user_roles_asp_net_roles_role_id"); + + b.HasOne("RestaurantReservation.Domain.Users.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_asp_net_user_roles_asp_net_users_user_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("RestaurantReservation.Domain.Users.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_asp_net_user_tokens_asp_net_users_user_id"); + }); + + modelBuilder.Entity("RestaurantReservation.Domain.Reservations.Reservation", b => + { + b.HasOne("RestaurantReservation.Domain.Users.ApplicationUser", "Customer") + .WithMany("Reservations") + .HasForeignKey("CustomerId") + .HasConstraintName("fk_reservations_users_customer_id"); + + b.HasOne("RestaurantReservation.Domain.Restaurants.Restaurant", "Restaurant") + .WithMany("Reservations") + .HasForeignKey("RestaurantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_reservations_restaurants_restaurant_id"); + + b.Navigation("Customer"); + + b.Navigation("Restaurant"); + }); + + modelBuilder.Entity("RestaurantReservation.Domain.Reservations.ReservationTable", b => + { + b.HasOne("RestaurantReservation.Domain.Reservations.Reservation", "Reservation") + .WithMany("Tables") + .HasForeignKey("ReservationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_reservation_tables_reservations_reservation_id"); + + b.HasOne("RestaurantReservation.Domain.Tables.Table", "Table") + .WithMany("Reservations") + .HasForeignKey("TableId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_reservation_tables_tables_table_id"); + + b.OwnsOne("RestaurantReservation.Domain.Reservations.ValueObjects.ReservationValueObjects.TableReservation", "ScheduledReservation", b1 => + { + b1.Property("ReservationTableReservationId") + .HasColumnType("uuid") + .HasColumnName("reservation_id"); + + b1.Property("ReservationTableTableId") + .HasColumnType("uuid") + .HasColumnName("table_id"); + + b1.Property("ReservationDay") + .HasColumnType("date") + .HasColumnName("scheduled_reservation_reservation_day"); + + b1.Property("ReservationEnd") + .HasColumnType("time without time zone") + .HasColumnName("scheduled_reservation_reservation_end"); + + b1.Property("ReservationStart") + .HasColumnType("time without time zone") + .HasColumnName("scheduled_reservation_reservation_start"); + + b1.HasKey("ReservationTableReservationId", "ReservationTableTableId"); + + b1.ToTable("reservation_tables"); + + b1.WithOwner() + .HasForeignKey("ReservationTableReservationId", "ReservationTableTableId") + .HasConstraintName("fk_reservation_tables_reservation_tables_reservation_id_table_"); + }); + + b.Navigation("Reservation"); + + b.Navigation("ScheduledReservation") + .IsRequired(); + + b.Navigation("Table"); + }); + + modelBuilder.Entity("RestaurantReservation.Domain.Restaurants.Restaurant", b => + { + b.OwnsMany("RestaurantReservation.Domain.Restaurants.RestaurantSchedule", "Schedule", b1 => + { + b1.Property("RestaurantId"); + + b1.Property("__synthesizedOrdinal") + .ValueGeneratedOnAdd(); + + b1.PrimitiveCollection("DailyHours") + .IsRequired(); + + b1.Property("Day"); + + b1.HasKey("RestaurantId", "__synthesizedOrdinal"); + + b1.ToTable("restaurant"); + + b1 + .ToJson("schedule") + .HasColumnType("jsonb"); + + b1.WithOwner() + .HasForeignKey("RestaurantId") + .HasConstraintName("fk_restaurant_restaurant_restaurant_id"); + }); + + b.Navigation("Schedule"); + }); + + modelBuilder.Entity("RestaurantReservation.Domain.Tables.Table", b => + { + b.HasOne("RestaurantReservation.Domain.Restaurants.Restaurant", "Restaurant") + .WithMany("Tables") + .HasForeignKey("RestaurantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_restaurant_tables_restaurant_restaurant_id"); + + b.Navigation("Restaurant"); + }); + + modelBuilder.Entity("RestaurantReservation.Domain.Tables.TableGroup", b => + { + b.HasOne("RestaurantReservation.Domain.Restaurants.Restaurant", "Restaurant") + .WithMany("TableGroups") + .HasForeignKey("RestaurantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_table_groups_restaurants_restaurant_id"); + + b.Navigation("Restaurant"); + }); + + modelBuilder.Entity("RestaurantReservation.Domain.Users.RefreshToken", b => + { + b.HasOne("RestaurantReservation.Domain.Users.ApplicationUser", "User") + .WithMany("RefreshTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_refresh_tokens_users_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("TableTableGroup", b => + { + b.HasOne("RestaurantReservation.Domain.Tables.TableGroup", null) + .WithMany() + .HasForeignKey("TableGroupsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_table_table_group_table_groups_table_groups_id"); + + b.HasOne("RestaurantReservation.Domain.Tables.Table", null) + .WithMany() + .HasForeignKey("TablesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_table_table_group_tables_tables_id"); + }); + + modelBuilder.Entity("RestaurantReservation.Domain.Reservations.Reservation", b => + { + b.Navigation("Tables"); + }); + + modelBuilder.Entity("RestaurantReservation.Domain.Restaurants.Restaurant", b => + { + b.Navigation("Reservations"); + + b.Navigation("TableGroups"); + + b.Navigation("Tables"); + }); + + modelBuilder.Entity("RestaurantReservation.Domain.Tables.Table", b => + { + b.Navigation("Reservations"); + }); + + modelBuilder.Entity("RestaurantReservation.Domain.Users.ApplicationUser", b => + { + b.Navigation("RefreshTokens"); + + b.Navigation("Reservations"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Persistence/Migrations/20260811225627_AddReservationTableExclusion.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Persistence/Migrations/20260811225627_AddReservationTableExclusion.cs new file mode 100644 index 0000000..772a2f5 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Persistence/Migrations/20260811225627_AddReservationTableExclusion.cs @@ -0,0 +1,37 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace RestaurantReservation.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddReservationTableExclusion : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql("CREATE EXTENSION IF NOT EXISTS btree_gist"); + + migrationBuilder.Sql(@" + ALTER TABLE reservation_tables + ADD CONSTRAINT no_overlapping_table_reservations + EXCLUDE USING gist ( + table_id WITH =, + tsrange( + scheduled_reservation_reservation_day + scheduled_reservation_reservation_start, + scheduled_reservation_reservation_day + scheduled_reservation_reservation_end + ) WITH && + ); + "); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql(@" + ALTER TABLE reservation_tables + DROP CONSTRAINT no_overlapping_table_reservations; + "); + } + } +} diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Persistence/Migrations/ApplicationDbContextModelSnapshot.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Persistence/Migrations/ApplicationDbContextModelSnapshot.cs new file mode 100644 index 0000000..b6f6994 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Persistence/Migrations/ApplicationDbContextModelSnapshot.cs @@ -0,0 +1,788 @@ +// +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using RestaurantReservation.Infrastructure.Persistence; + +#nullable disable + +namespace RestaurantReservation.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + partial class ApplicationDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text") + .HasColumnName("concurrency_stamp"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("normalized_name"); + + b.HasKey("Id") + .HasName("pk_asp_net_roles"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text") + .HasColumnName("claim_type"); + + b.Property("ClaimValue") + .HasColumnType("text") + .HasColumnName("claim_value"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("role_id"); + + b.HasKey("Id") + .HasName("pk_asp_net_role_claims"); + + b.HasIndex("RoleId") + .HasDatabaseName("ix_asp_net_role_claims_role_id"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text") + .HasColumnName("claim_type"); + + b.Property("ClaimValue") + .HasColumnType("text") + .HasColumnName("claim_value"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_asp_net_user_claims"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_asp_net_user_claims_user_id"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text") + .HasColumnName("login_provider"); + + b.Property("ProviderKey") + .HasColumnType("text") + .HasColumnName("provider_key"); + + b.Property("ProviderDisplayName") + .HasColumnType("text") + .HasColumnName("provider_display_name"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("user_id"); + + b.HasKey("LoginProvider", "ProviderKey") + .HasName("pk_asp_net_user_logins"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_asp_net_user_logins_user_id"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text") + .HasColumnName("user_id"); + + b.Property("RoleId") + .HasColumnType("text") + .HasColumnName("role_id"); + + b.HasKey("UserId", "RoleId") + .HasName("pk_asp_net_user_roles"); + + b.HasIndex("RoleId") + .HasDatabaseName("ix_asp_net_user_roles_role_id"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text") + .HasColumnName("user_id"); + + b.Property("LoginProvider") + .HasColumnType("text") + .HasColumnName("login_provider"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Value") + .HasColumnType("text") + .HasColumnName("value"); + + b.HasKey("UserId", "LoginProvider", "Name") + .HasName("pk_asp_net_user_tokens"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("RestaurantReservation.Domain.Reservations.Reservation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CustomerId") + .HasColumnType("text") + .HasColumnName("customer_id"); + + b.Property("ReservationCanceledAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("reservation_canceled_at_utc"); + + b.Property("ReservationCompletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("reservation_completed_at_utc"); + + b.Property("ReservationCreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("reservation_created_at_utc"); + + b.Property("ReservationUpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("reservation_updated_at_utc"); + + b.Property("RestaurantId") + .HasColumnType("uuid") + .HasColumnName("restaurant_id"); + + b.Property("RestaurantName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("restaurant_name"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text") + .HasColumnName("status"); + + b.Property("TableGroup") + .HasColumnType("text") + .HasColumnName("table_group"); + + b.ComplexProperty(typeof(Dictionary), "CustomerContactInfo", "RestaurantReservation.Domain.Reservations.Reservation.CustomerContactInfo#CustomerContactInfo", b1 => + { + b1.IsRequired(); + + b1.Property("EmailAddress") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("customer_email_address"); + + b1.Property("FirstName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("customer_first_name"); + + b1.Property("LastName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("customer_last_name"); + + b1.Property("TelephoneNumber") + .IsRequired() + .HasMaxLength(15) + .HasColumnType("character varying(15)") + .HasColumnName("customer_telephone_number"); + }); + + b.ComplexProperty(typeof(Dictionary), "ReservationInfo", "RestaurantReservation.Domain.Reservations.Reservation.ReservationInfo#ReservationInfo", b1 => + { + b1.IsRequired(); + + b1.Property("Date") + .HasColumnType("date") + .HasColumnName("reservation_date"); + + b1.Property("EndTime") + .HasColumnType("time without time zone") + .HasColumnName("reservation_end_time"); + + b1.Property("Guests") + .HasColumnType("integer") + .HasColumnName("number_of_guests"); + + b1.Property("StartTime") + .HasColumnType("time without time zone") + .HasColumnName("reservation_start_time"); + }); + + b.HasKey("Id") + .HasName("pk_reservations"); + + b.HasIndex("CustomerId") + .HasDatabaseName("ix_reservations_customer_id"); + + b.HasIndex("RestaurantId") + .HasDatabaseName("ix_reservations_restaurant_id"); + + b.ToTable("reservations", (string)null); + }); + + modelBuilder.Entity("RestaurantReservation.Domain.Reservations.ReservationTable", b => + { + b.Property("ReservationId") + .HasColumnType("uuid") + .HasColumnName("reservation_id"); + + b.Property("TableId") + .HasColumnType("uuid") + .HasColumnName("table_id"); + + b.Property("SeatsAtTable") + .HasColumnType("integer") + .HasColumnName("seats_at_table"); + + b.HasKey("ReservationId", "TableId") + .HasName("pk_reservation_tables"); + + b.HasIndex("TableId") + .HasDatabaseName("ix_reservation_tables_table_id"); + + b.ToTable("reservation_tables", (string)null); + }); + + modelBuilder.Entity("RestaurantReservation.Domain.Restaurants.Restaurant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.HasKey("Id") + .HasName("pk_restaurant"); + + b.HasIndex("Name") + .HasDatabaseName("ix_restaurant_name"); + + b.ToTable("restaurant", (string)null); + }); + + modelBuilder.Entity("RestaurantReservation.Domain.Tables.Table", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("IsInTableGroup") + .HasColumnType("boolean") + .HasColumnName("is_in_table_group"); + + b.Property("RestaurantId") + .HasColumnType("uuid") + .HasColumnName("restaurant_id"); + + b.Property("SeatsAtTable") + .HasColumnType("integer") + .HasColumnName("seats_at_table"); + + b.Property("TableGroupName") + .HasColumnType("text") + .HasColumnName("table_group_name"); + + b.HasKey("Id") + .HasName("pk_restaurant_tables"); + + b.HasIndex("RestaurantId") + .HasDatabaseName("ix_restaurant_tables_restaurant_id"); + + b.ToTable("restaurant_tables", (string)null); + }); + + modelBuilder.Entity("RestaurantReservation.Domain.Tables.TableGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("RestaurantId") + .HasColumnType("uuid") + .HasColumnName("restaurant_id"); + + b.HasKey("Id") + .HasName("pk_table_groups"); + + b.HasIndex("RestaurantId") + .HasDatabaseName("ix_table_groups_restaurant_id"); + + b.ToTable("table_groups", (string)null); + }); + + modelBuilder.Entity("RestaurantReservation.Domain.Users.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("AccessFailedCount") + .HasColumnType("integer") + .HasColumnName("access_failed_count"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text") + .HasColumnName("concurrency_stamp"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("email"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean") + .HasColumnName("email_confirmed"); + + b.Property("FirstName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("first_name"); + + b.Property("LastName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("last_name"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean") + .HasColumnName("lockout_enabled"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone") + .HasColumnName("lockout_end"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("normalized_email"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("normalized_user_name"); + + b.Property("PasswordHash") + .HasColumnType("text") + .HasColumnName("password_hash"); + + b.Property("PhoneNumber") + .HasColumnType("text") + .HasColumnName("phone_number"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean") + .HasColumnName("phone_number_confirmed"); + + b.Property("RegistrationDate") + .HasColumnType("date") + .HasColumnName("registration_date"); + + b.Property("SecurityStamp") + .HasColumnType("text") + .HasColumnName("security_stamp"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean") + .HasColumnName("two_factor_enabled"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("user_name"); + + b.HasKey("Id") + .HasName("pk_asp_net_users"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("RestaurantReservation.Domain.Users.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("IsRevoked") + .HasColumnType("boolean") + .HasColumnName("is_revoked"); + + b.Property("RefreshTokenExpiryTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("refresh_token_expiry_time"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("revoked_at"); + + b.Property("Token") + .HasColumnType("text") + .HasColumnName("token"); + + b.Property("UserId") + .HasColumnType("text") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_refresh_tokens"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_refresh_tokens_user_id"); + + b.ToTable("refresh_tokens", (string)null); + }); + + modelBuilder.Entity("TableTableGroup", b => + { + b.Property("TableGroupsId") + .HasColumnType("uuid") + .HasColumnName("table_groups_id"); + + b.Property("TablesId") + .HasColumnType("uuid") + .HasColumnName("tables_id"); + + b.HasKey("TableGroupsId", "TablesId") + .HasName("pk_table_table_group"); + + b.HasIndex("TablesId") + .HasDatabaseName("ix_table_table_group_tables_id"); + + b.ToTable("table_table_group", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_asp_net_role_claims_asp_net_roles_role_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("RestaurantReservation.Domain.Users.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_asp_net_user_claims_asp_net_users_user_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("RestaurantReservation.Domain.Users.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_asp_net_user_logins_asp_net_users_user_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_asp_net_user_roles_asp_net_roles_role_id"); + + b.HasOne("RestaurantReservation.Domain.Users.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_asp_net_user_roles_asp_net_users_user_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("RestaurantReservation.Domain.Users.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_asp_net_user_tokens_asp_net_users_user_id"); + }); + + modelBuilder.Entity("RestaurantReservation.Domain.Reservations.Reservation", b => + { + b.HasOne("RestaurantReservation.Domain.Users.ApplicationUser", "Customer") + .WithMany("Reservations") + .HasForeignKey("CustomerId") + .HasConstraintName("fk_reservations_users_customer_id"); + + b.HasOne("RestaurantReservation.Domain.Restaurants.Restaurant", "Restaurant") + .WithMany("Reservations") + .HasForeignKey("RestaurantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_reservations_restaurants_restaurant_id"); + + b.Navigation("Customer"); + + b.Navigation("Restaurant"); + }); + + modelBuilder.Entity("RestaurantReservation.Domain.Reservations.ReservationTable", b => + { + b.HasOne("RestaurantReservation.Domain.Reservations.Reservation", "Reservation") + .WithMany("Tables") + .HasForeignKey("ReservationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_reservation_tables_reservations_reservation_id"); + + b.HasOne("RestaurantReservation.Domain.Tables.Table", "Table") + .WithMany("Reservations") + .HasForeignKey("TableId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_reservation_tables_tables_table_id"); + + b.OwnsOne("RestaurantReservation.Domain.Reservations.ValueObjects.ReservationValueObjects.TableReservation", "ScheduledReservation", b1 => + { + b1.Property("ReservationTableReservationId") + .HasColumnType("uuid") + .HasColumnName("reservation_id"); + + b1.Property("ReservationTableTableId") + .HasColumnType("uuid") + .HasColumnName("table_id"); + + b1.Property("ReservationDay") + .HasColumnType("date") + .HasColumnName("scheduled_reservation_reservation_day"); + + b1.Property("ReservationEnd") + .HasColumnType("time without time zone") + .HasColumnName("scheduled_reservation_reservation_end"); + + b1.Property("ReservationStart") + .HasColumnType("time without time zone") + .HasColumnName("scheduled_reservation_reservation_start"); + + b1.HasKey("ReservationTableReservationId", "ReservationTableTableId"); + + b1.ToTable("reservation_tables"); + + b1.WithOwner() + .HasForeignKey("ReservationTableReservationId", "ReservationTableTableId") + .HasConstraintName("fk_reservation_tables_reservation_tables_reservation_id_table_"); + }); + + b.Navigation("Reservation"); + + b.Navigation("ScheduledReservation") + .IsRequired(); + + b.Navigation("Table"); + }); + + modelBuilder.Entity("RestaurantReservation.Domain.Restaurants.Restaurant", b => + { + b.OwnsMany("RestaurantReservation.Domain.Restaurants.RestaurantSchedule", "Schedule", b1 => + { + b1.Property("RestaurantId"); + + b1.Property("__synthesizedOrdinal") + .ValueGeneratedOnAdd(); + + b1.PrimitiveCollection("DailyHours") + .IsRequired(); + + b1.Property("Day"); + + b1.HasKey("RestaurantId", "__synthesizedOrdinal"); + + b1.ToTable("restaurant"); + + b1 + .ToJson("schedule") + .HasColumnType("jsonb"); + + b1.WithOwner() + .HasForeignKey("RestaurantId") + .HasConstraintName("fk_restaurant_restaurant_restaurant_id"); + }); + + b.Navigation("Schedule"); + }); + + modelBuilder.Entity("RestaurantReservation.Domain.Tables.Table", b => + { + b.HasOne("RestaurantReservation.Domain.Restaurants.Restaurant", "Restaurant") + .WithMany("Tables") + .HasForeignKey("RestaurantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_restaurant_tables_restaurant_restaurant_id"); + + b.Navigation("Restaurant"); + }); + + modelBuilder.Entity("RestaurantReservation.Domain.Tables.TableGroup", b => + { + b.HasOne("RestaurantReservation.Domain.Restaurants.Restaurant", "Restaurant") + .WithMany("TableGroups") + .HasForeignKey("RestaurantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_table_groups_restaurants_restaurant_id"); + + b.Navigation("Restaurant"); + }); + + modelBuilder.Entity("RestaurantReservation.Domain.Users.RefreshToken", b => + { + b.HasOne("RestaurantReservation.Domain.Users.ApplicationUser", "User") + .WithMany("RefreshTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_refresh_tokens_users_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("TableTableGroup", b => + { + b.HasOne("RestaurantReservation.Domain.Tables.TableGroup", null) + .WithMany() + .HasForeignKey("TableGroupsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_table_table_group_table_groups_table_groups_id"); + + b.HasOne("RestaurantReservation.Domain.Tables.Table", null) + .WithMany() + .HasForeignKey("TablesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_table_table_group_tables_tables_id"); + }); + + modelBuilder.Entity("RestaurantReservation.Domain.Reservations.Reservation", b => + { + b.Navigation("Tables"); + }); + + modelBuilder.Entity("RestaurantReservation.Domain.Restaurants.Restaurant", b => + { + b.Navigation("Reservations"); + + b.Navigation("TableGroups"); + + b.Navigation("Tables"); + }); + + modelBuilder.Entity("RestaurantReservation.Domain.Tables.Table", b => + { + b.Navigation("Reservations"); + }); + + modelBuilder.Entity("RestaurantReservation.Domain.Users.ApplicationUser", b => + { + b.Navigation("RefreshTokens"); + + b.Navigation("Reservations"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Persistence/Seeding/SeedingResources.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Persistence/Seeding/SeedingResources.cs new file mode 100755 index 0000000..f6acaf4 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Persistence/Seeding/SeedingResources.cs @@ -0,0 +1,179 @@ +using RestaurantReservation.Domain.Restaurants; +using RestaurantReservation.Domain.Tables; + +namespace RestaurantReservation.Infrastructure.Persistence.Seeding; + +public static class SeedingResources +{ + public static List GetRestaurantsForSeeding() + { + var restaurants = new List(); + + var restaurant1 = Restaurant.Create("Red Lobster"); + restaurant1.SetSchedule([ + new RestaurantSchedule(WeekDay.Sunday, [new TimeOnly(11, 0), new TimeOnly(21, 00)]), + new RestaurantSchedule(WeekDay.Monday, [new TimeOnly(9, 0), new TimeOnly(21, 00)]), + new RestaurantSchedule(WeekDay.Tuesday, [new TimeOnly(9, 0), new TimeOnly(21, 00)]), + new RestaurantSchedule(WeekDay.Wednesday, [new TimeOnly(9, 0), new TimeOnly(21, 00)]), + new RestaurantSchedule(WeekDay.Thursday, [new TimeOnly(9, 0), new TimeOnly(22, 0)]), + new RestaurantSchedule(WeekDay.Friday, [new TimeOnly(9, 0), new TimeOnly(22, 30)]), + new RestaurantSchedule(WeekDay.Saturday, [new TimeOnly(9, 0), new TimeOnly(23, 00)]) + ]); + + var restaurant2 = Restaurant.Create("Longhorn's Steakhouse"); + restaurant2.SetSchedule([ + new RestaurantSchedule(WeekDay.Sunday, [new TimeOnly(12,0), new TimeOnly(20,0)]), + new RestaurantSchedule(WeekDay.Monday, [new TimeOnly(12,0), new TimeOnly(22,00)]), + new RestaurantSchedule(WeekDay.Tuesday, [new TimeOnly(12,0), new TimeOnly(22,00)]), + new RestaurantSchedule(WeekDay.Wednesday, [new TimeOnly(12,0), new TimeOnly(22,00)]), + new RestaurantSchedule(WeekDay.Thursday, [new TimeOnly(12,0), new TimeOnly(22,00)]), + new RestaurantSchedule(WeekDay.Friday, [new TimeOnly(12,0), new TimeOnly(23,00)]), + new RestaurantSchedule(WeekDay.Saturday, [new TimeOnly(12,0), new TimeOnly(23,00)])]); + + var restaurant3 = Restaurant.Create("O'Charley's"); + restaurant3.SetSchedule( [ + new RestaurantSchedule(WeekDay.Sunday, [new TimeOnly(12,0), new TimeOnly(21,0)]), + new RestaurantSchedule(WeekDay.Monday, [new TimeOnly(12,0), new TimeOnly(22,30)]), + new RestaurantSchedule(WeekDay.Tuesday, [new TimeOnly(11,0), new TimeOnly(22,30)]), + new RestaurantSchedule(WeekDay.Wednesday, [new TimeOnly(11,0), new TimeOnly(22,30)]), + new RestaurantSchedule(WeekDay.Thursday, [new TimeOnly(11,0), new TimeOnly(22,30)]), + new RestaurantSchedule(WeekDay.Friday, [new TimeOnly(11,0), new TimeOnly(22,30)]), + new RestaurantSchedule(WeekDay.Saturday, [new TimeOnly(11,0), new TimeOnly(23,0)])]); + + var restaurant4 = Restaurant.Create("Outback Steakhouse"); + restaurant4.SetSchedule([ + new RestaurantSchedule(WeekDay.Sunday, [new TimeOnly(11,0), new TimeOnly(21,30)]), + new RestaurantSchedule(WeekDay.Monday, [new TimeOnly(10,0), new TimeOnly(22,0)]), + new RestaurantSchedule(WeekDay.Tuesday, [new TimeOnly(10,0), new TimeOnly(22,0)]), + new RestaurantSchedule(WeekDay.Wednesday, [new TimeOnly(10,0), new TimeOnly(22,0)]), + new RestaurantSchedule(WeekDay.Thursday, [new TimeOnly(10,0), new TimeOnly(22,0)]), + new RestaurantSchedule(WeekDay.Friday, [new TimeOnly(10,0), new TimeOnly(22,0)]), + new RestaurantSchedule(WeekDay.Saturday, [new TimeOnly(10,0), new TimeOnly(22,0)]), + ]); + + var restaurant5 = Restaurant.Create("Miller's Ale House"); + restaurant5.SetSchedule([ + new RestaurantSchedule(WeekDay.Sunday, [null, null]), + new RestaurantSchedule(WeekDay.Monday, [new TimeOnly(13,0), new TimeOnly(22,0)]), + new RestaurantSchedule(WeekDay.Tuesday, [new TimeOnly(13,0), new TimeOnly(22,0)]), + new RestaurantSchedule(WeekDay.Wednesday, [new TimeOnly(13,0), new TimeOnly(22,0)]), + new RestaurantSchedule(WeekDay.Thursday, [new TimeOnly(13,0), new TimeOnly(22,0)]), + new RestaurantSchedule(WeekDay.Friday, [new TimeOnly(13,0), new TimeOnly(22,0)]), + new RestaurantSchedule(WeekDay.Saturday, [new TimeOnly(12,0), new TimeOnly(23,0)]), + ]); + + var restaurant6 = Restaurant.Create("Shoney's"); + restaurant6.SetSchedule([ + new RestaurantSchedule(WeekDay.Sunday, [null, null]), + new RestaurantSchedule(WeekDay.Monday, [new TimeOnly(7,0), new TimeOnly(22,0)]), + new RestaurantSchedule(WeekDay.Tuesday, [new TimeOnly(7,0), new TimeOnly(22,0)]), + new RestaurantSchedule(WeekDay.Wednesday, [new TimeOnly(7,0), new TimeOnly(22,0)]), + new RestaurantSchedule(WeekDay.Thursday, [new TimeOnly(7,0), new TimeOnly(22,0)]), + new RestaurantSchedule(WeekDay.Friday, [new TimeOnly(7,0), new TimeOnly(22,0)]), + new RestaurantSchedule(WeekDay.Saturday, [new TimeOnly(7,0), new TimeOnly(22,0)]), + ]); + + AddTablesToRestaurant(restaurant1, 45); + AddTablesToRestaurant(restaurant2, 40); + AddTablesToRestaurant(restaurant3, 36); + AddTablesToRestaurant(restaurant4, 28); + AddTablesToRestaurant(restaurant5, 57); + AddTablesToRestaurant(restaurant6, 30); + + var restaurant1TableGroupInfo = new Dictionary + { + { 20, null }, + { 15, "Main Dining Room" }, + { 10, null } + }; + AddTablesToTableGroup(restaurant1, restaurant1TableGroupInfo); + + var restaurant2TableGroupInfo = new Dictionary + { + { 15, "VIP Room" }, + { 20, "Main Dining Room" }, { 5, null } + }; + AddTablesToTableGroup(restaurant2, restaurant2TableGroupInfo); + + var restaurant3TableGroupInfo = new Dictionary + { + { 26, null }, + { 10, "Smoking Section" } + }; + AddTablesToTableGroup(restaurant3, restaurant3TableGroupInfo); + + var restaurant4TableGroupInfo = new Dictionary + { + { 15, null }, + { 13, "Non Smoking Section" } + }; + AddTablesToTableGroup(restaurant4, restaurant4TableGroupInfo); + + var restaurant5TableGroupInfo = new Dictionary + { + { 30, null }, + { 15, "VIP Room" }, + { 12, null } + }; + AddTablesToTableGroup(restaurant5, restaurant5TableGroupInfo); + + var restaurant6TableGroupInfo = new Dictionary + { + { 6, "Special Guests Area" }, + { 24, null } + }; + AddTablesToTableGroup(restaurant6, restaurant6TableGroupInfo); + + restaurants.Add(restaurant1); + restaurants.Add(restaurant2); + restaurants.Add(restaurant3); + restaurants.Add(restaurant4); + restaurants.Add(restaurant5); + restaurants.Add(restaurant6); + + + return restaurants; + } + + private static List
AddTablesToRestaurant(Restaurant restaurant, int numberOfTables) + { + var tables = new List
(); + for (int i = 0; i < numberOfTables; i++) + { + var seatsAtTable = Random.Shared.Next(1, 5) * 2; + tables.Add(restaurant.AddTable(seatsAtTable)); + } + + return tables; + } + + private static void AddTablesToTableGroup(Restaurant restaurant, Dictionary tableInfo) + { + var startingIndex = 0; + var runningCount = 0; + var stopIndex = restaurant.Tables.Count; + + foreach (var info in tableInfo) + { + var count = 0; + var tablesInGroup = info.Key; + var groupTitle = info.Value; + var tablesToAdd = new List
(); + if (string.IsNullOrEmpty(groupTitle)) + { + runningCount += tablesInGroup; + continue; + } + + while (count < tablesInGroup) + { + tablesToAdd.Add(restaurant.Tables.ElementAt(runningCount++)); + count++; + } + + restaurant.AddTableGroup(groupTitle, tablesToAdd); + + if (runningCount == stopIndex) break; + } + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Persistence/Seeding/UserAndRoleSeeder.cs b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Persistence/Seeding/UserAndRoleSeeder.cs new file mode 100755 index 0000000..7b72fb3 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/Persistence/Seeding/UserAndRoleSeeder.cs @@ -0,0 +1,93 @@ +using Microsoft.AspNetCore.Identity; + +using RestaurantReservation.Domain.Users; + +namespace RestaurantReservation.Infrastructure.Persistence.Seeding; + +public static class UserAndRoleSeeder +{ + public static async Task SeedUsersAndRolesAsync( + UserManager userManager, + RoleManager roleManager) + { + await SeedRolesAsync(roleManager); + await SeedUsersAsync(userManager); + } + + private static async Task SeedRolesAsync(RoleManager roleManager) + { + foreach (var role in new[] { Roles.Admin, Roles.Customer }) + { + if (!await roleManager.RoleExistsAsync(role)) + { + await roleManager.CreateAsync(new IdentityRole(role)); + } + } + } + + private static async Task SeedUsersAsync(UserManager userManager) + { + var adminUser = new + { + FirstName = "Gordon", + LastName = "Ramsay", + Email = "admin@example.com", + Password = "Pa$$w0rd", + Roles = new[] { Roles.Admin} + }; + + var customer = new + { + FirstName = "Dennis", + LastName = "Edwards", + Email = "customer@example.com", + Password = "Pa$$w0rd", + Roles = new[] { Roles.Customer } + }; + + await CreateUserAsync( + userManager, + adminUser.FirstName, + adminUser.LastName, + adminUser.Email, + adminUser.Password, + adminUser.Roles); + + await CreateUserAsync( + userManager, + customer.FirstName, + customer.LastName, + customer.Email, + customer.Password, + customer.Roles); + } + + private static async Task CreateUserAsync( + UserManager userManager, + string firstName, + string lastName, + string email, + string password, + string[] roles) + { + if (await userManager.FindByEmailAsync(email) is not null) return; + + var registrationDate = new DateOnly( + DateTime.Now.Year, + DateTime.Now.Month, + DateTime.Now.Day); + + var user = new ApplicationUser + { + FirstName = firstName, + LastName = lastName, + Email = email, + UserName = email, + EmailConfirmed = true, + RegistrationDate = registrationDate + }; + + await userManager.CreateAsync(user, password); + await userManager.AddToRolesAsync(user, roles); + } +} diff --git a/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/RestaurantReservation.Infrastructure.csproj b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/RestaurantReservation.Infrastructure.csproj new file mode 100755 index 0000000..d4a0b98 --- /dev/null +++ b/restaurant-reservations-clean-architecture/src/RestaurantReservation.Infrastructure/RestaurantReservation.Infrastructure.csproj @@ -0,0 +1,26 @@ + + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + net10.0 + enable + enable + + + diff --git a/restaurant-reservations-clean-architecture/tests/RestaurantReservation.ArchitectureTests/ArchitectureTests.cs b/restaurant-reservations-clean-architecture/tests/RestaurantReservation.ArchitectureTests/ArchitectureTests.cs new file mode 100644 index 0000000..588d2e9 --- /dev/null +++ b/restaurant-reservations-clean-architecture/tests/RestaurantReservation.ArchitectureTests/ArchitectureTests.cs @@ -0,0 +1,108 @@ +using System.Reflection; +using FluentAssertions; +using NetArchTest.Rules; + +namespace RestaurantReservation.ArchitectureTests; + +public sealed class ArchitectureTests +{ + private static readonly Assembly ApplicationAssembly = typeof(Application.DependencyInjection).Assembly; + private static readonly Assembly DomainAssembly = typeof(Domain.Abstractions.BaseEntity).Assembly; + private static readonly Assembly InfrastructureAssembly = typeof(Infrastructure.DependencyInjection).Assembly; + + private const string ApiNamespace = "RestaurantReservation.Api"; + private const string ApplicationNamespace = "RestaurantReservation.Application"; + private const string InfrastructureNamespace = "RestaurantReservation.Infrastructure"; + + [Fact] + public void Domain_Should_Not_Depend_On_Application() + { + var result = Types.InAssembly(DomainAssembly) + .ShouldNot() + .HaveDependencyOn(ApplicationNamespace) + .GetResult(); + + result.IsSuccessful.Should().BeTrue(); + } + + [Fact] + public void Domain_Should_Not_Depend_On_Infrastructure() + { + var result = Types.InAssembly(DomainAssembly) + .ShouldNot() + .HaveDependencyOn(InfrastructureNamespace) + .GetResult(); + + result.IsSuccessful.Should().BeTrue(); + } + + [Fact] + public void Domain_Should_Not_Depend_On_Api() + { + var result = Types.InAssembly(DomainAssembly) + .ShouldNot() + .HaveDependencyOn(ApiNamespace) + .GetResult(); + + result.IsSuccessful.Should().BeTrue(); + } + + [Fact] + public void Application_Should_Not_Depend_On_Infrastructure() + { + var result = Types.InAssembly(ApplicationAssembly) + .ShouldNot() + .HaveDependencyOn(InfrastructureNamespace) + .GetResult(); + + result.IsSuccessful.Should().BeTrue(); + } + + [Fact] + public void Application_Should_Not_Depend_On_Api() + { + var result = Types.InAssembly(ApplicationAssembly) + .ShouldNot() + .HaveDependencyOn(ApiNamespace) + .GetResult(); + + result.IsSuccessful.Should().BeTrue(); + } + + [Fact] + public void Infrastructure_Should_Not_Depend_On_Api() + { + var result = Types.InAssembly(InfrastructureAssembly) + .ShouldNot() + .HaveDependencyOn(ApiNamespace) + .GetResult(); + + result.IsSuccessful.Should().BeTrue(); + } + + [Fact] + public void Handlers_Should_Be_Sealed() + { + var result = Types.InAssembly(ApplicationAssembly) + .That() + .HaveNameEndingWith("Handler") + .Should() + .BeSealed() + .GetResult(); + + result.IsSuccessful.Should().BeTrue(); + } + + [Fact] + public void Validators_Should_Be_Sealed() + { + var result = Types.InAssembly(ApplicationAssembly) + .That() + .HaveNameEndingWith("Validator") + .Should() + .BeSealed() + .GetResult(); + + result.IsSuccessful.Should().BeTrue(); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/tests/RestaurantReservation.ArchitectureTests/RestaurantReservation.ArchitectureTests.csproj b/restaurant-reservations-clean-architecture/tests/RestaurantReservation.ArchitectureTests/RestaurantReservation.ArchitectureTests.csproj new file mode 100644 index 0000000..5c68953 --- /dev/null +++ b/restaurant-reservations-clean-architecture/tests/RestaurantReservation.ArchitectureTests/RestaurantReservation.ArchitectureTests.csproj @@ -0,0 +1,33 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/Auth/LoginTests.cs b/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/Auth/LoginTests.cs new file mode 100644 index 0000000..0c8b0a3 --- /dev/null +++ b/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/Auth/LoginTests.cs @@ -0,0 +1,58 @@ +using System.Net; +using System.Net.Http.Json; + +using FluentAssertions; + +using RestaurantReservation.Application.Features.Auth.Command.Login; + +namespace RestaurantReservation.IntegrationTests.Auth; + +public class LoginTests(IntegrationTestWebAppFactory factory) : BaseIntegrationTest(factory) +{ + [Fact] + public async Task Login_Returns_StatusCode_200OK_WhenSuccessful() + { + var loginResponse = await Client.PostAsJsonAsync("/api/auth/login", + new { Email = "customer@example.com", Password = "Pa$$w0rd" }, + TestContext.Current.CancellationToken); + + var loginResult = await loginResponse.Content.ReadFromJsonAsync( + TestContext.Current.CancellationToken); + + loginResponse.IsSuccessStatusCode.Should().BeTrue(); + loginResponse.StatusCode.Should().Be(HttpStatusCode.OK); + loginResult.Should().NotBeNull(); + loginResult.AccessToken.Should().BeOfType(); + loginResult.RefreshToken.Should().BeOfType(); + } + + [Fact] + public async Task Login_Returns_StatusCode_404NotFound_WhenUnregisteredUser_TriesToLogin() + { + var loginResponse = await Client.PostAsJsonAsync("/api/auth/login", + new { Email = "unknown@example.com", Password = "Unkn0wnPa$$w0rd" }, + TestContext.Current.CancellationToken); + + var loginResult = await loginResponse.Content.ReadFromJsonAsync( + TestContext.Current.CancellationToken); + + loginResponse.IsSuccessStatusCode.Should().BeFalse(); + loginResponse.StatusCode.Should().Be(HttpStatusCode.NotFound); + loginResult.Should().NotBeNull(); + } + + [Fact] + public async Task Login_Returns_StatusCode_401Unauthorized_WhenUser_EntersWrongPassword() + { + var loginResponse = await Client.PostAsJsonAsync("/api/auth/login", + new { Email = "customer@example.com", Password = "Passw0rd" }, + TestContext.Current.CancellationToken); + + var loginResult = await loginResponse.Content.ReadFromJsonAsync( + TestContext.Current.CancellationToken); + + loginResponse.IsSuccessStatusCode.Should().BeFalse(); + loginResponse.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + loginResult.Should().NotBeNull(); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/Auth/LogoutTests.cs b/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/Auth/LogoutTests.cs new file mode 100644 index 0000000..5469163 --- /dev/null +++ b/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/Auth/LogoutTests.cs @@ -0,0 +1,63 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; + +using FluentAssertions; + +using RestaurantReservation.Application.Features.Auth.Command.Login; + +namespace RestaurantReservation.IntegrationTests.Auth; + +public class LogoutTests(IntegrationTestWebAppFactory factory) : BaseIntegrationTest(factory) +{ + [Fact] + public async Task Logout_Returns_StatusCode_200OK_WhenSuccessful() + { + var refreshToken = await GetRefreshToken(); + + var logoutResponse = await Client.PostAsJsonAsync("/api/auth/logout", + new { RefreshToken = refreshToken }, + TestContext.Current.CancellationToken); + + var logoutMessage = await logoutResponse.Content.ReadFromJsonAsync( + TestContext.Current.CancellationToken); + + logoutResponse.IsSuccessStatusCode.Should().BeTrue(); + logoutResponse.StatusCode.Should().Be(HttpStatusCode.OK); + logoutMessage.Should().NotBeNull(); + logoutMessage.Should().Be("Logout successful"); + } + + [Fact] + public async Task Logout_Returns_StatusCode_403Forbidden_WhenTryingToRevoke_InvalidRefreshToken() + { + var loginToken = await GetRefreshToken(); + + var refreshToken = $"{loginToken}19238hksdnsdadcewzxse12034"; + + var logoutResponse = await Client.PostAsJsonAsync("/api/auth/logout", new { RefreshToken = refreshToken }, + TestContext.Current.CancellationToken); + + logoutResponse.IsSuccessStatusCode.Should().BeFalse(); + logoutResponse.StatusCode.Should().Be(HttpStatusCode.Forbidden); + } + + private async Task GetRefreshToken() + { + var loginResponse = await Client.PostAsJsonAsync("/api/auth/login", + new { Email = "customer@example.com", Password = "Pa$$w0rd" }, + TestContext.Current.CancellationToken); + + loginResponse.IsSuccessStatusCode.Should().BeTrue(); + + var loginResult = await loginResponse.Content.ReadFromJsonAsync( + TestContext.Current.CancellationToken); + + loginResult.Should().NotBeNull(); + + Client.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", loginResult.AccessToken); + + return loginResult.RefreshToken; + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/Auth/RegisterUserTests.cs b/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/Auth/RegisterUserTests.cs new file mode 100644 index 0000000..daa1a3b --- /dev/null +++ b/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/Auth/RegisterUserTests.cs @@ -0,0 +1,51 @@ +using System.Net; +using System.Net.Http.Json; + +using FluentAssertions; + +namespace RestaurantReservation.IntegrationTests.Auth; + +public class RegisterUserTests(IntegrationTestWebAppFactory factory) : BaseIntegrationTest(factory) +{ + [Fact] + public async Task RegisterUser_Returns_200OK_Upon_Successful_User_Registration() + { + var registrationResponse = await Client.PostAsJsonAsync("/api/auth/register", + new + { + FirstName = "Indiana", + LastName = "Jones", + EmailAddress = "indyJones@example.com", + PhoneNumber = "555-987-6543", + Password = "RaidersOfTheLostArk123$", + ConfirmPassword = "RaidersOfTheLostArk123$" + }, + TestContext.Current.CancellationToken); + + var successMessage = await registrationResponse.Content.ReadFromJsonAsync( + TestContext.Current.CancellationToken); + + registrationResponse.IsSuccessStatusCode.Should().BeTrue(); + registrationResponse.StatusCode.Should().Be(HttpStatusCode.OK); + successMessage.Should().Be("Registration successful!"); + } + + [Fact] + public async Task + RegisterUser_Returns_400BadRequest_WhenTryingRegistration_WherePassword_And_ConfirmationPassword_DoNotMatch() + { + var registrationResponse = await Client.PostAsJsonAsync("/api/auth/register", + new + { + FirstName = "Han", + LastName = "Solo", + EmailAddress = "hSolo@example.com", + Password = "TheEmpireStrikesBack123@", + ConfirmPassword = "TheEmpireStrikesBack123$" + }, + TestContext.Current.CancellationToken); + + registrationResponse.IsSuccessStatusCode.Should().BeFalse(); + registrationResponse.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/BaseIntegrationTest.cs b/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/BaseIntegrationTest.cs new file mode 100644 index 0000000..4a8a16e --- /dev/null +++ b/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/BaseIntegrationTest.cs @@ -0,0 +1,191 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; + +using FluentAssertions; + +using MediatR; + +using Microsoft.Extensions.DependencyInjection; + +using RestaurantReservation.Application.Features.Auth.Command.Login; +using RestaurantReservation.Application.Features.Reservations.Query.Responses; +using RestaurantReservation.Application.Features.Restaurants.Command.Add; +using RestaurantReservation.Application.Features.Restaurants.Query.Response; +using RestaurantReservation.Application.Features.Restaurants.TableGroups.Command.Add; +using RestaurantReservation.Application.Features.Restaurants.TableGroups.Query.Responses; +using RestaurantReservation.Application.Features.Restaurants.Tables.Command.Add; +using RestaurantReservation.Domain.Restaurants; +using RestaurantReservation.Infrastructure.Persistence; + +namespace RestaurantReservation.IntegrationTests; + +public class BaseIntegrationTest : IClassFixture +{ + protected readonly IMediator Sender; + protected readonly ApplicationDbContext Context; + protected readonly HttpClient Client; + protected IntegrationTestWebAppFactory Factory { get; } + + protected BaseIntegrationTest(IntegrationTestWebAppFactory factory) + { + Factory = factory; + IServiceScope scope = factory.Services.CreateScope(); + Client = factory.CreateClient(); + Sender = scope.ServiceProvider.GetRequiredService(); + Context = scope.ServiceProvider.GetRequiredService(); + } + + protected async Task LoginAndSetAuthenticationAsync(string email, string password) + { + var loginResponse = await Client + .PostAsJsonAsync("/api/auth/login", new { Email = email, Password = password }, + TestContext.Current.CancellationToken); + loginResponse.EnsureSuccessStatusCode(); + + var loginResult = await loginResponse.Content + .ReadFromJsonAsync(TestContext.Current.CancellationToken); + + Client.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", loginResult!.AccessToken); + } + + protected ApplicationDbContext CreateContext() + { + var scope = Factory.Services.CreateScope(); + return scope.ServiceProvider.GetRequiredService(); + } + + protected async Task ScheduleReservationForTesting() + { + await LoginAndSetAuthenticationAsync("customer@example.com", "Pa$$w0rd"); + + var reservationResponse = await Client.PostAsJsonAsync("/api/reservations/schedulereservation", + new + { + RestaurantName = "Red Lobster", + CustomerFirstName = "Dennis", + CustomerLastName = "Edwards", + CustomerEmail = "customer@example.com", + CustomerPhone = "555-123-4567", + ReservationDate = new DateOnly(2026, 9, 1), + ReservationStartTime = new TimeOnly(19, 00), + ReservationEndTime = new TimeOnly(20, 00), + NumberOfGuests = 3, + TableGroup = "Main Dining Room" + }, TestContext.Current.CancellationToken); + + reservationResponse.EnsureSuccessStatusCode(); + + var reservationResult = await + reservationResponse.Content.ReadFromJsonAsync( + TestContext.Current.CancellationToken); + + reservationResult.Should().NotBeNull(); + + return reservationResult.ReservationId; + } + + protected async Task ScheduleReservationsForTesting(string restaurantName) + { + await LoginAndSetAuthenticationAsync("customer@example.com", "Pa$$w0rd"); + + for (int i = 1; i <= 31; i++) + { + var reservationResponse = await Client.PostAsJsonAsync("/api/reservations/schedulereservation", new + { + RestaurantName = restaurantName, + CustomerFirstName = "Dennis", + CustomerLastName = "Edwards", + CustomerEmail = "customer@example.com", + CustomerPhone = "555-123-4567", + ReservationDate = new DateOnly(2026, 10, i), + ReservationStartTime = new TimeOnly(18, 00), + ReservationEndTime = new TimeOnly(19, 30), + NumberOfGuests = 3, + TableGroup = "Main Dining Room" + }, + TestContext.Current.CancellationToken); + + reservationResponse.EnsureSuccessStatusCode(); + } + } + + protected async Task CreateRestaurantForRetrieval(string name) + { + await LoginAndSetAuthenticationAsync("admin@example.com", "Pa$$w0rd"); + + var restaurantResponse = await Client.PostAsJsonAsync("/api/restaurants/add", + new + { + Name = name, + Schedule = new RestaurantSchedule[] + { + new(WeekDay.Sunday, [new TimeOnly(10, 00), new TimeOnly(21, 00)]), + new(WeekDay.Monday, [new TimeOnly(9, 00), new TimeOnly(21, 00)]), + new(WeekDay.Tuesday, [new TimeOnly(9, 0), new TimeOnly(21, 00)]), + new(WeekDay.Wednesday, [new TimeOnly(9, 0), new TimeOnly(21, 00)]), + new(WeekDay.Thursday, [new TimeOnly(9, 0), new TimeOnly(22, 0)]), + new(WeekDay.Friday, [new TimeOnly(9, 0), new TimeOnly(22, 30)]), + new(WeekDay.Saturday, [new TimeOnly(9, 0), new TimeOnly(23, 00)]) + }, + TableInfo = new TableInfo[] + { + new(45, 6, "Main Dining Room"), + new(30, 4, "Outside Patio"), + new(10, 3), + new(20, 8, "Family Dining Room") + } + }, TestContext.Current.CancellationToken); + + restaurantResponse.EnsureSuccessStatusCode(); + + var restaurantResult = await restaurantResponse.Content.ReadFromJsonAsync( + TestContext.Current.CancellationToken); + + restaurantResult.Should().NotBeNull(); + + return restaurantResult.Id; + } + + protected async Task CreateTableGroupForRetrieval(AddTableGroupCommand groupInfo) + { + await LoginAndSetAuthenticationAsync("admin@example.com", "Pa$$w0rd"); + + var tableGroupResponse = await Client.PostAsJsonAsync( + $"/api/restaurants/{groupInfo.RestaurantId}/tablegroups/add", + new + { + Name = groupInfo.Name, + NumberOfTables = groupInfo.NumberOfTables, + NumberOfSeats = groupInfo.NumberOfSeats + }, + TestContext.Current.CancellationToken); + + tableGroupResponse.EnsureSuccessStatusCode(); + + var tableGroupResult = await tableGroupResponse.Content.ReadFromJsonAsync( + TestContext.Current.CancellationToken); + + tableGroupResult.Should().NotBeNull(); + + return tableGroupResult.Id; + } + + public async Task CreateTableForRetrieval(AddTableCommand table) + { + await LoginAndSetAuthenticationAsync("admin@example.com", "Pa$$w0rd"); + + var tableResponse = await Client.PostAsJsonAsync($"/api/restaurants/{table.RestaurantId}/tables/add", + new { NumberOfSeats = table.NumberOfSeats, TableGroup = table.TableGroup }, + TestContext.Current.CancellationToken); + + tableResponse.EnsureSuccessStatusCode(); + + var tableResult = await tableResponse.Content.ReadFromJsonAsync( + TestContext.Current.CancellationToken); + + tableResult.Should().NotBeNull(); + + return tableResult.Id; + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/IntegrationTestWebAppFactory.cs b/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/IntegrationTestWebAppFactory.cs new file mode 100644 index 0000000..69395e4 --- /dev/null +++ b/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/IntegrationTestWebAppFactory.cs @@ -0,0 +1,54 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.StackExchangeRedis; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +using RestaurantReservation.Application.Abstractions; +using RestaurantReservation.Infrastructure.Persistence; + +using Testcontainers.PostgreSql; +using Testcontainers.Redis; + +namespace RestaurantReservation.IntegrationTests; + +public class IntegrationTestWebAppFactory : WebApplicationFactory, IAsyncLifetime +{ + private readonly PostgreSqlContainer _dbContainer = new PostgreSqlBuilder("postgres:17-alpine") + .WithDatabase("reservationDb") + .WithUsername("postgres") + .WithPassword("postgres") + .Build(); + + private readonly RedisContainer _redisContainer = new RedisBuilder("redis:latest") + .Build(); + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.ConfigureServices(services => + { + services.RemoveAll>(); + + services.AddScoped(sp => sp.GetRequiredService()); + + services.AddDbContext(options => + options.UseNpgsql(_dbContainer.GetConnectionString())); + + services.Configure(redisCacheOptions => + redisCacheOptions.Configuration = _redisContainer.GetConnectionString()); + }); + } + + public async ValueTask InitializeAsync() + { + await _dbContainer.StartAsync(); + await _redisContainer.StartAsync(); + } + + public new async Task DisposeAsync() + { + await _dbContainer.StopAsync(); + await _redisContainer.StopAsync(); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/Reservations/CancelReservationTests.cs b/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/Reservations/CancelReservationTests.cs new file mode 100644 index 0000000..17c7495 --- /dev/null +++ b/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/Reservations/CancelReservationTests.cs @@ -0,0 +1,61 @@ +using System.Net; +using System.Net.Http.Json; + +using FluentAssertions; + +using RestaurantReservation.Application.Features.Reservations.Query.Responses; + +namespace RestaurantReservation.IntegrationTests.Reservations; + +public class CancelReservationTests(IntegrationTestWebAppFactory factory) : BaseIntegrationTest(factory) +{ + [Fact] + public async Task CancelReservation_Returns_ReservationDetailResponse_And_StatusCode_200OK_OnSuccess() + { + var reservationId = await ScheduleReservationForTesting(); + const string restaurantName = "Red Lobster"; + + var reservationResponse = await Client.PutAsJsonAsync("/api/reservations/cancelreservation", + new + { + ReservationId = reservationId, + RestaurantName = restaurantName, + ReservationDate = new DateOnly(2026, 9, 1), + ReservationStartTime = new TimeOnly(19, 00), + ReservationEndTime = new TimeOnly(20, 00) + }, + TestContext.Current.CancellationToken); + + var reservationResult = await reservationResponse.Content.ReadFromJsonAsync( + TestContext.Current.CancellationToken); + + reservationResponse.IsSuccessStatusCode.Should().BeTrue(); + reservationResponse.StatusCode.Should().Be(HttpStatusCode.OK); + reservationResult.Should().NotBeNull(); + reservationResult.ReservationCanceledAtUtc.Should().NotBeNull(); + reservationResult.RestaurantName.Should().Be(restaurantName); + } + + [Fact] + public async Task CancelReservation_Returns_StatusCode_404NotFound_When_ReservationId_IsInvalid() + { + await LoginAndSetAuthenticationAsync("customer@example.com", "Pa$$w0rd"); + + var reservationId = Guid.CreateVersion7(); + const string restaurantName = "Red Lobster"; + + var reservationResponse = await Client.PutAsJsonAsync("/api/reservations/cancelreservation", + new + { + ReservationId = reservationId, + RestaurantName = restaurantName, + ReservationDate = new DateOnly(2026, 9, 1), + ReservationStartTime = new TimeOnly(19, 00), + ReservationEndTime = new TimeOnly(20, 00) + }, + TestContext.Current.CancellationToken); + + reservationResponse.IsSuccessStatusCode.Should().BeFalse(); + reservationResponse.StatusCode.Should().Be(HttpStatusCode.NotFound); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/Reservations/CompleteReservationTests.cs b/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/Reservations/CompleteReservationTests.cs new file mode 100644 index 0000000..cafe29e --- /dev/null +++ b/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/Reservations/CompleteReservationTests.cs @@ -0,0 +1,60 @@ +using System.Net; +using System.Net.Http.Json; + +using FluentAssertions; + +using RestaurantReservation.Application.Features.Reservations.Query.Responses; + +namespace RestaurantReservation.IntegrationTests.Reservations; + +public class CompleteReservationTests(IntegrationTestWebAppFactory factory) : BaseIntegrationTest(factory) +{ + [Fact] + public async Task CompleteReservation_Returns_ReservationDetailResult_And_StatusCode_200OK_OnSuccess() + { + var reservationId = await ScheduleReservationForTesting(); + const string restaurantName = "Red Lobster"; + + var reservationResponse = await Client.PutAsJsonAsync("/api/reservations/completereservation", + new + { + ReservationId = reservationId, + RestaurantName = restaurantName, + ReservationDate = new DateOnly(2026, 9, 1), + ReservationStartTime = new TimeOnly(19, 00), + ReservationEndTime = new TimeOnly(20, 00) + }, + TestContext.Current.CancellationToken); + + var reservationResult = await reservationResponse.Content.ReadFromJsonAsync( + TestContext.Current.CancellationToken); + + reservationResponse.IsSuccessStatusCode.Should().BeTrue(); + reservationResponse.StatusCode.Should().Be(HttpStatusCode.OK); + reservationResult.Should().NotBeNull(); + reservationResult.ReservationCompletedAtUtc.Should().NotBeNull(); + reservationResult.RestaurantName.Should().Be(restaurantName); + } + + [Fact] + public async Task CompleteReservation_Returns_StatusCode_404NotFound_When_ReservationId_Is_Invalid() + { + await LoginAndSetAuthenticationAsync("customer@example.com", "Pa$$w0rd"); + var reservationId = Guid.CreateVersion7(); + const string restaurantName = "Red Lobster"; + + var reservationResponse = await Client.PutAsJsonAsync("/api/reservations/completereservation", + new + { + ReservationId = reservationId, + RestaurantName = restaurantName, + ReservationDate = new DateOnly(2026, 9, 1), + ReservationStartTime = new TimeOnly(19, 00), + ReservationEndTime = new TimeOnly(20, 00) + }, + TestContext.Current.CancellationToken); + + reservationResponse.IsSuccessStatusCode.Should().BeFalse(); + reservationResponse.StatusCode.Should().Be(HttpStatusCode.NotFound); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/Reservations/GetAllReservationTests.cs b/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/Reservations/GetAllReservationTests.cs new file mode 100644 index 0000000..e21144d --- /dev/null +++ b/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/Reservations/GetAllReservationTests.cs @@ -0,0 +1,39 @@ +using System.Net; +using System.Net.Http.Json; + +using FluentAssertions; + +using RestaurantReservation.Application.Features.Reservations.Query.Responses; +using RestaurantReservation.Domain.Common; + +namespace RestaurantReservation.IntegrationTests.Reservations; + +public class GetAllReservationTests(IntegrationTestWebAppFactory factory) : BaseIntegrationTest(factory) +{ + [Fact] + public async Task GetAllReservations_Page_2_PageSize_10_Returns_PagedResult_Of_ReservationResponse_And_StatusCode_200OK_OnSuccess() + { + const string restaurantName = "Red Lobster"; + + await ScheduleReservationsForTesting(restaurantName); + + const string customerEmail = "customer@example.com"; + + var reservationsResponse = await Client.GetAsync("/api/reservations?page=2&pageSize=10", + TestContext.Current.CancellationToken); + + var reservationsResult = await reservationsResponse.Content.ReadFromJsonAsync>( + TestContext.Current.CancellationToken); + + reservationsResponse.IsSuccessStatusCode.Should().BeTrue(); + reservationsResponse.StatusCode.Should().Be(HttpStatusCode.OK); + reservationsResult.Should().NotBeNull(); + reservationsResult.HasPreviousPage.Should().BeTrue(); + reservationsResult.HasNextPage.Should().BeTrue(); + reservationsResult.Items.Count.Should().Be(10); + reservationsResult.TotalCount.Should().Be(31); + reservationsResult.TotalPages.Should().Be(4); + reservationsResult.Items[0].RestaurantName.Should().Be(restaurantName); + reservationsResult.Items[0].CustomerEmail.Should().Be(customerEmail); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/Reservations/GetAllRestaurantReservationsTests.cs b/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/Reservations/GetAllRestaurantReservationsTests.cs new file mode 100644 index 0000000..1be718f --- /dev/null +++ b/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/Reservations/GetAllRestaurantReservationsTests.cs @@ -0,0 +1,41 @@ +using System.Net; +using System.Net.Http.Json; + +using FluentAssertions; + +using RestaurantReservation.Application.Features.Reservations.Query.Responses; +using RestaurantReservation.Domain.Common; + +namespace RestaurantReservation.IntegrationTests.Reservations; + +public class GetAllRestaurantReservationsTests(IntegrationTestWebAppFactory factory) : BaseIntegrationTest(factory) +{ + [Fact] + public async Task + GetAllRestaurantReservations_Page_3_PageSize_5_Returns_PagedResult_Of_ReservationResponse_And_StatusCode_200OK_OnSuccess() + { + const string restaurantName = "Mr. Brown's Barbecue Pit"; + + var restaurantId = await CreateRestaurantForRetrieval(restaurantName); + + await ScheduleReservationsForTesting(restaurantName); + + await LoginAndSetAuthenticationAsync("admin@example.com", "Pa$$w0rd"); + + var reservationsResponse = await Client.GetAsync($"/api/reservations/restaurants/{restaurantId}?page=3&pageSize=5", + TestContext.Current.CancellationToken); + + var reservationsResult = await reservationsResponse.Content.ReadFromJsonAsync>( + TestContext.Current.CancellationToken); + + reservationsResponse.IsSuccessStatusCode.Should().BeTrue(); + reservationsResponse.StatusCode.Should().Be(HttpStatusCode.OK); + reservationsResult.Should().NotBeNull(); + reservationsResult.HasPreviousPage.Should().BeTrue(); + reservationsResult.HasNextPage.Should().BeTrue(); + reservationsResult.Items.Count.Should().Be(5); + reservationsResult.TotalCount.Should().Be(31); + reservationsResult.TotalPages.Should().Be(7); + reservationsResult.Items[0].RestaurantName.Should().Be(restaurantName); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/Reservations/GetReservationTests.cs b/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/Reservations/GetReservationTests.cs new file mode 100644 index 0000000..5156a40 --- /dev/null +++ b/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/Reservations/GetReservationTests.cs @@ -0,0 +1,46 @@ +using System.Net; +using System.Net.Http.Json; + +using FluentAssertions; + +using RestaurantReservation.Application.Features.Reservations.Query.Responses; + +namespace RestaurantReservation.IntegrationTests.Reservations; + +public class GetReservationTests(IntegrationTestWebAppFactory factory) : BaseIntegrationTest(factory) +{ + [Fact] + public async Task GetReservation_Returns_ReservationDetailResponse_And_StatusCode_200OK_OnSuccess() + { + var reservationId = await ScheduleReservationForTesting(); + + var reservationResponse = await Client.GetAsync($"/api/reservations/{reservationId}", + TestContext.Current.CancellationToken); + + var reservationResult = await reservationResponse.Content.ReadFromJsonAsync( + TestContext.Current.CancellationToken); + + reservationResponse.IsSuccessStatusCode.Should().BeTrue(); + reservationResponse.StatusCode.Should().Be(HttpStatusCode.OK); + reservationResult.Should().NotBeNull(); + reservationResult.NumberOfGuests.Should().Be(3); + reservationResult.ReservationCompletedAtUtc.Should().BeNull(); + reservationResult.ReservationDate.Should().Be(new DateOnly(2026, 9, 1)); + reservationResult.ReservationStartTime.Should().Be(new TimeOnly(19, 00)); + reservationResult.ReservationEndTime.Should().Be(new TimeOnly(20, 00)); + } + + [Fact] + public async Task GetReservation_Returns_StatusCode_404NotFound_When_ReservationId_Is_Invalid() + { + await LoginAndSetAuthenticationAsync("customer@example.com", "Pa$$w0rd"); + + var reservationId = Guid.CreateVersion7(); + + var reservationResponse = await Client.GetAsync($"/api/reservations/{reservationId}", + TestContext.Current.CancellationToken); + + reservationResponse.IsSuccessStatusCode.Should().BeFalse(); + reservationResponse.StatusCode.Should().Be(HttpStatusCode.NotFound); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/Reservations/RescheduleReservationTests.cs b/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/Reservations/RescheduleReservationTests.cs new file mode 100644 index 0000000..ecd5058 --- /dev/null +++ b/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/Reservations/RescheduleReservationTests.cs @@ -0,0 +1,71 @@ +using System.Net; +using System.Net.Http.Json; + +using FluentAssertions; + +using RestaurantReservation.Application.Features.Reservations.Query.Responses; + +namespace RestaurantReservation.IntegrationTests.Reservations; + +public class RescheduleReservationTests(IntegrationTestWebAppFactory factory) : BaseIntegrationTest(factory) +{ + [Fact] + public async Task RescheduleReservation_Returns_RescheduledReservationDetailResponse_And_StatusCode_200OK_OnSuccess() + { + var reservationId = await ScheduleReservationForTesting(); + const string restaurantName = "Red Lobster"; + const string tableGroup = "Main Dining Room"; + + var reservationResponse = await Client.PutAsJsonAsync("/api/reservations/reschedulereservation", new + { + ReservationId = reservationId, + RestaurantName = restaurantName, + RescheduleDate = new DateOnly(2026, 9, 2), + RescheduleStartTime = new TimeOnly(15,00), + RescheduleEndTime = new TimeOnly(17, 30), + RescheduleNumberOfGuests = 6, + RescheduleTableGroup = tableGroup + }, + TestContext.Current.CancellationToken); + + var reservationResult = + await reservationResponse.Content.ReadFromJsonAsync( + TestContext.Current.CancellationToken); + + reservationResponse.IsSuccessStatusCode.Should().BeTrue(); + reservationResponse.StatusCode.Should().Be(HttpStatusCode.OK); + reservationResult.Should().NotBeNull(); + reservationResult.OriginalNumberOfGuests.Should().Be(3); + reservationResult.RescheduledNumberOfGuests.Should().Be(6); + reservationResult.OriginalReservationDate.Should().Be(new DateOnly(2026, 9, 1)); + reservationResult.OriginalReservationStartTime.Should().Be(new TimeOnly(19, 00)); + reservationResult.OriginalReservationEndTime.Should().Be(new TimeOnly(20, 00)); + reservationResult.RescheduledReservationDate.Should().Be(new DateOnly(2026, 9, 2)); + reservationResult.RescheduledReservationStartTime.Should().Be(new TimeOnly(15,00)); + reservationResult.RescheduledReservationEndTime.Should().Be(new TimeOnly(17, 30)); + } + + [Fact] + public async Task + RescheduleReservation_Returns_StatusCode_400BadRequest_WhenTryingToReschedule_ReservationAtInvalidTime() + { + var reservationId = await ScheduleReservationForTesting(); + const string restaurantName = "Red Lobster"; + const string tableGroup = "Main Dining Room"; + + var reservationResponse = await Client.PutAsJsonAsync("/api/reservations/reschedulereservation", new + { + ReservationId = reservationId, + RestaurantName = restaurantName, + RescheduleDate = new DateOnly(2026, 9, 2), + RescheduleStartTime = new TimeOnly(7,00), + RescheduleEndTime = new TimeOnly(8, 30), + RescheduleNumberOfGuests = 6, + RescheduleTableGroup = tableGroup + }, + TestContext.Current.CancellationToken); + + reservationResponse.IsSuccessStatusCode.Should().BeFalse(); + reservationResponse.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/Reservations/ReservationTableConstraintTests.cs b/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/Reservations/ReservationTableConstraintTests.cs new file mode 100644 index 0000000..19027f4 --- /dev/null +++ b/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/Reservations/ReservationTableConstraintTests.cs @@ -0,0 +1,143 @@ +using FluentAssertions; + +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +using Npgsql; + +using RestaurantReservation.Domain.Reservations; +using RestaurantReservation.Domain.Restaurants; +using RestaurantReservation.Domain.Users; + +namespace RestaurantReservation.IntegrationTests.Reservations; + +public class ReservationTableConstraintTests(IntegrationTestWebAppFactory factory) : BaseIntegrationTest(factory) +{ + [Fact] + public async Task TwoReservationsFor_TheSameTable_Should_Have_One_Succeed_And_OneFail() + { + using var userScope = Factory.Services.CreateScope(); + var userManager = userScope.ServiceProvider.GetRequiredService>(); + const string password = "Pa$$w0rd"; + + var customer1 = new ApplicationUser + { + FirstName = "Dennis", + LastName = "Edwards", + Email = "customer1@example.com", + UserName = "customer1@example.com", + PhoneNumber = "555-111-2222", + EmailConfirmed = true, + RegistrationDate = DateOnly.FromDateTime(DateTime.Now) + }; + await userManager.CreateAsync(customer1, password); + + var customer2 = new ApplicationUser + { + FirstName = "John", + LastName = "Edwards", + Email = "customer2@example.com", + UserName = "customer2@example.com", + PhoneNumber = "555-333-6655", + EmailConfirmed = true, + RegistrationDate = DateOnly.FromDateTime(DateTime.Now) + }; + await userManager.CreateAsync(customer2, password); + + var restaurant = Restaurant.Create("Universal House Of Culinary Delights"); + const string groupName = "Outside Patio"; + + var originalContext = CreateContext(); + + var table = restaurant.AddTable(4); + + restaurant.AddTableGroup(groupName, [table]); + + await originalContext.Restaurants.AddAsync(restaurant, TestContext.Current.CancellationToken); + await originalContext.SaveChangesAsync(TestContext.Current.CancellationToken); + + var context1 = CreateContext(); + var context2 = CreateContext(); + + var reservation1 = Reservation.MakeReservation( + restaurant.Id, + restaurant.Name, + customer1.Id, + "Dennis", + "Edwards", + "customer1@example.com", + "555-111-2222", + new DateOnly(2026, 10, 1), + new TimeOnly(16, 30), + new TimeOnly(18, 30), + 3, + groupName); + + var reservation2 = Reservation.MakeReservation( + restaurant.Id, + restaurant.Name, + customer2.Id, + "John", + "Edwards", + "customer2@example.com", + "555-333-6655", + new DateOnly(2026, 10, 1), + new TimeOnly(16, 30), + new TimeOnly(18, 30), + 3, + groupName); + + reservation1.IsSuccess.Should().BeTrue(); + reservation2.IsSuccess.Should().BeTrue(); + + var table1 = + await context1.Tables.FirstOrDefaultAsync(t => t.Id == table.Id, TestContext.Current.CancellationToken); + var table2 = + await context2.Tables.FirstOrDefaultAsync(t => t.Id == table.Id, TestContext.Current.CancellationToken); + + table1.Should().NotBeNull(); + table2.Should().NotBeNull(); + + var reservationTable1 = table1.ReserveTable( + new DateOnly(2026, 10, 1), + new TimeOnly(16, 30), + new TimeOnly(18, 30)); + var reservationTable2 = table2.ReserveTable( + new DateOnly(2026, 10, 1), + new TimeOnly(16, 30), + new TimeOnly(18, 30)); + + reservationTable1.IsSuccess.Should().BeTrue(); + reservationTable2.IsSuccess.Should().BeTrue(); + + reservationTable1.Value.ReservationId = reservation1.Value.Id; + reservationTable2.Value.ReservationId = reservation2.Value.Id; + + reservation1.Value.AddReservationTable(reservationTable1.Value); + reservation2.Value.AddReservationTable(reservationTable2.Value); + + await context1.Reservations.AddAsync(reservation1.Value, TestContext.Current.CancellationToken); + await context2.Reservations.AddAsync(reservation2.Value, TestContext.Current.CancellationToken); + + var task1 = context1.SaveChangesAsync(TestContext.Current.CancellationToken); + var task2 = context2.SaveChangesAsync(TestContext.Current.CancellationToken); + + Exception? failure1 = null, failure2 = null; + + try { await task1; } catch (DbUpdateException ex) { failure1 = ex; } + + try { await task2; } catch (DbUpdateException ex) { failure2 = ex; } + + var actualFailure = failure1 ?? failure2; + + await using var verificationContext = CreateContext(); + var count = await verificationContext.Set() + .CountAsync(rt => rt.TableId == table.Id, TestContext.Current.CancellationToken); + + (failure1 is null ^ failure2 is null).Should().BeTrue(); + actualFailure!.InnerException.Should().BeOfType() + .Which.SqlState.Should().Be("23P01"); + count.Should().Be(1); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/Reservations/ScheduleReservationTests.cs b/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/Reservations/ScheduleReservationTests.cs new file mode 100644 index 0000000..bb19e9d --- /dev/null +++ b/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/Reservations/ScheduleReservationTests.cs @@ -0,0 +1,78 @@ +using System.Net; +using System.Net.Http.Json; + +using FluentAssertions; + +using RestaurantReservation.Application.Features.Reservations.Query.Responses; + +namespace RestaurantReservation.IntegrationTests.Reservations; + +public class ScheduleReservationTests(IntegrationTestWebAppFactory factory) : BaseIntegrationTest(factory) +{ + [Fact] + public async Task ScheduleReservation_Returns_ReservationDetailResponse_And_StatusCode_201Created_On_Success() + { + await LoginAndSetAuthenticationAsync("customer@example.com", "Pa$$w0rd"); + + const string restaurantName = "Red Lobster"; + const string tableGroup = "Main Dining Room"; + + var reservationResponse = await Client.PostAsJsonAsync("/api/reservations/schedulereservation", + new + { + RestaurantName = restaurantName, + CustomerFirstName = "Dennis", + CustomerLastName = "Edwards", + CustomerEmail = "customer@example.com", + CustomerPhone = "555-123-4567", + ReservationDate = new DateOnly(2026, 08, 29), + ReservationStartTime = new TimeOnly(15, 30), + ReservationEndTime = new TimeOnly(18, 30), + NumberOfGuests = 10, + TableGroup = tableGroup + }, + TestContext.Current.CancellationToken); + + var reservationResult = await reservationResponse.Content.ReadFromJsonAsync( + TestContext.Current.CancellationToken); + + reservationResponse.IsSuccessStatusCode.Should().BeTrue(); + reservationResponse.StatusCode.Should().Be(HttpStatusCode.Created); + reservationResult.Should().NotBeNull(); + reservationResult.NumberOfGuests.Should().Be(10); + reservationResult.ReservationDate.Should().Be(new DateOnly(2026, 08, 29)); + reservationResult.ReservationStartTime.Should().Be(new TimeOnly(15, 30)); + reservationResult.ReservationEndTime.Should().Be(new TimeOnly(18, 30)); + reservationResult.CustomerEmail.Should().Be("customer@example.com"); + reservationResult.RestaurantName.Should().Be(restaurantName); + } + + [Fact] + public async Task + ScheduleReservation_Returns_StatusCode_404NotFound_When_SchedulingReservation_At_Invalid_Restaurant() + { + await LoginAndSetAuthenticationAsync("customer@example.com", "Pa$$w0rd"); + + const string restaurantName = "Mario and Luigi's Italian Bar And Grill"; + const string tableGroup = "VIP Section"; + + var reservationResponse = await Client.PostAsJsonAsync("/api/reservations/schedulereservation", + new + { + RestaurantName = restaurantName, + CustomerFirstName = "Dennis", + CustomerLastName = "Edwards", + CustomerEmail = "customer@example.com", + CustomerPhone = "123-456-7890", + ReservationDate = new DateOnly(2026, 08, 29), + ReservationStartTime = new TimeOnly(15, 30), + ReservationEndTime = new TimeOnly(18, 30), + NumberOfGuests = 10, + TableGroup = tableGroup + }, + TestContext.Current.CancellationToken); + + reservationResponse.IsSuccessStatusCode.Should().BeFalse(); + reservationResponse.StatusCode.Should().Be(HttpStatusCode.NotFound); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/RestaurantReservation.IntegrationTests.csproj b/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/RestaurantReservation.IntegrationTests.csproj new file mode 100644 index 0000000..5fb1bd7 --- /dev/null +++ b/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/RestaurantReservation.IntegrationTests.csproj @@ -0,0 +1,31 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/Restaurants/AddRestaurantTests.cs b/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/Restaurants/AddRestaurantTests.cs new file mode 100644 index 0000000..2eeb727 --- /dev/null +++ b/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/Restaurants/AddRestaurantTests.cs @@ -0,0 +1,94 @@ +using System.Net; +using System.Net.Http.Json; + +using FluentAssertions; + +using RestaurantReservation.Application.Features.Restaurants.Command.Add; +using RestaurantReservation.Application.Features.Restaurants.Query.Response; +using RestaurantReservation.Domain.Restaurants; + +namespace RestaurantReservation.IntegrationTests.Restaurants; + +public class AddRestaurantTests(IntegrationTestWebAppFactory factory) : BaseIntegrationTest(factory) +{ + [Fact] + public async Task + AddRestaurant_Should_Return_RestaurantDetailResponse_And201Created_When_NewRestaurant_SuccessfullyAdded() + { + await LoginAndSetAuthenticationAsync("admin@example.com", "Pa$$w0rd"); + + const string restaurantName = "International House Of Culinary Delights"; + + var restaurantResponse = await Client.PostAsJsonAsync("/api/restaurants/add", new + { + Name = restaurantName, + Schedule = new RestaurantSchedule[] + { + new(WeekDay.Sunday, [new TimeOnly(11, 0), new TimeOnly(21, 00)]), + new(WeekDay.Monday, [new TimeOnly(9, 00), new TimeOnly(21, 00)]), + new(WeekDay.Tuesday, [new TimeOnly(9, 0), new TimeOnly(21, 00)]), + new(WeekDay.Wednesday, [new TimeOnly(9, 0), new TimeOnly(21, 00)]), + new(WeekDay.Thursday, [new TimeOnly(9, 0), new TimeOnly(22, 0)]), + new(WeekDay.Friday, [new TimeOnly(9, 0), new TimeOnly(22, 30)]), + new(WeekDay.Saturday, [new TimeOnly(9, 0), new TimeOnly(23, 00)]) + }, + TableInfo = new TableInfo[] + { + new(45, 6, "Main Dining Room"), + new(30, 4, "Outside Patio"), + new(10, 3), + new(20, 8, "Family Dining Room") + } + }, + TestContext.Current.CancellationToken); + + var restaurantResult = await + restaurantResponse.Content.ReadFromJsonAsync( + TestContext.Current.CancellationToken); + + restaurantResponse.IsSuccessStatusCode.Should().BeTrue(); + restaurantResponse.StatusCode.Should().Be(HttpStatusCode.Created); + restaurantResult.Should().NotBeNull(); + restaurantResult.Name.Should().Be(restaurantName); + restaurantResult.Schedule.Count.Should().Be(7); + restaurantResult.TableGroups.Count.Should().Be(3); + restaurantResult.TotalTables.Should().Be(105); + } + + [Fact] + public async Task AddRestaurant_Should_Return_401Unauthorized_WhenUnauthenticatedUserTries_ToAddRestaurant() + { + var restaurantName = "The Original International House Of Culinary Delights"; + + var restaurantResponse = await Client.PostAsJsonAsync("/api/restaurants/add", new + { + Name = restaurantName, + Schedule = new RestaurantSchedule[] + { + new(WeekDay.Sunday, [new TimeOnly(11, 0), new TimeOnly(21, 00)]), + new(WeekDay.Monday, [new TimeOnly(9, 00), new TimeOnly(21, 00)]), + new(WeekDay.Tuesday, [new TimeOnly(9, 0), new TimeOnly(21, 00)]), + new(WeekDay.Wednesday, [new TimeOnly(9, 0), new TimeOnly(21, 00)]), + new(WeekDay.Thursday, [new TimeOnly(9, 0), new TimeOnly(22, 0)]), + new(WeekDay.Friday, [new TimeOnly(9, 0), new TimeOnly(22, 30)]), + new(WeekDay.Saturday, [new TimeOnly(9, 0), new TimeOnly(23, 00)]) + }, + TableInfo = new TableInfo[] + { + new(45, 6, "Main Dining Room"), + new(30, 4, "Outside Patio"), + new(10, 3), + new(20, 8, "Family Dining Room") + } + }, + TestContext.Current.CancellationToken); + + var restaurantResult = await + restaurantResponse.Content.ReadFromJsonAsync( + TestContext.Current.CancellationToken); + + restaurantResponse.IsSuccessStatusCode.Should().BeFalse(); + restaurantResponse.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + restaurantResult.Should().NotBeNull(); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/Restaurants/DeleteRestaurantTests.cs b/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/Restaurants/DeleteRestaurantTests.cs new file mode 100644 index 0000000..0c359ad --- /dev/null +++ b/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/Restaurants/DeleteRestaurantTests.cs @@ -0,0 +1,34 @@ +using System.Net; + +using FluentAssertions; + +namespace RestaurantReservation.IntegrationTests.Restaurants; + +public class DeleteRestaurantTests(IntegrationTestWebAppFactory factory) : BaseIntegrationTest(factory) +{ + [Fact] + public async Task DeleteRestaurant_Should_Return_204NoContent_WhenSuccessful() + { + var restaurantId = await CreateRestaurantForRetrieval("George's Steakhouse"); + + var restaurantResponse = await Client.DeleteAsync($"/api/restaurants/delete/{restaurantId}", + TestContext.Current.CancellationToken); + + restaurantResponse.IsSuccessStatusCode.Should().BeTrue(); + restaurantResponse.StatusCode.Should().Be(HttpStatusCode.NoContent); + } + + [Fact] + public async Task DeleteRestaurant_Should_Return_404NotFound_WhenRestaurantDoesNotExist() + { + await LoginAndSetAuthenticationAsync("admin@example.com", "Pa$$w0rd"); + + var restaurantId = Guid.CreateVersion7(); + + var restaurantResponse = await Client.DeleteAsync($"/api/restaurants/delete/{restaurantId}", + TestContext.Current.CancellationToken); + + restaurantResponse.IsSuccessStatusCode.Should().BeFalse(); + restaurantResponse.StatusCode.Should().Be(HttpStatusCode.NotFound); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/Restaurants/GetAllRestaurantsTests.cs b/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/Restaurants/GetAllRestaurantsTests.cs new file mode 100644 index 0000000..e215c3f --- /dev/null +++ b/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/Restaurants/GetAllRestaurantsTests.cs @@ -0,0 +1,71 @@ +using System.Net; +using System.Net.Http.Json; + +using FluentAssertions; + +using RestaurantReservation.Application.Features.Restaurants.Query.Response; +using RestaurantReservation.Domain.Common; + +namespace RestaurantReservation.IntegrationTests.Restaurants; + +public class GetAllRestaurantsTests(IntegrationTestWebAppFactory factory) : BaseIntegrationTest(factory) +{ + [Fact] + public async Task GetAllRestaurants_ShouldReturnRestaurants_WhenSuccessful() + { + var restaurantResponse = await Client.GetAsync("/api/restaurants", + TestContext.Current.CancellationToken); + + var restaurantResult = await + restaurantResponse.Content.ReadFromJsonAsync>( + TestContext.Current.CancellationToken); + + restaurantResponse.IsSuccessStatusCode.Should().BeTrue(); + restaurantResponse.StatusCode.Should().Be(HttpStatusCode.OK); + restaurantResult.Should().NotBeNull(); + restaurantResult.TotalPages.Should().Be(1); + restaurantResult.TotalCount.Should().Be(6); + restaurantResult.HasNextPage.Should().BeFalse(); + restaurantResult.HasPreviousPage.Should().BeFalse(); + } + + [Fact] + public async Task GetAllRestaurants_WithPage_1_PageSize_3_ShouldReturnRestaurants_WhenSuccessful() + { + var restaurantResponse = await Client.GetAsync("/api/restaurants?page=1&pageSize=3", + TestContext.Current.CancellationToken); + + var restaurantResult = + await restaurantResponse.Content.ReadFromJsonAsync>( + TestContext.Current.CancellationToken); + + restaurantResponse.IsSuccessStatusCode.Should().BeTrue(); + restaurantResponse.StatusCode.Should().Be(HttpStatusCode.OK); + restaurantResult.Should().NotBeNull(); + restaurantResult.TotalPages.Should().Be(2); + restaurantResult.TotalCount.Should().Be(6); + restaurantResult.HasNextPage.Should().BeTrue(); + restaurantResult.HasPreviousPage.Should().BeFalse(); + restaurantResult.Items.Count.Should().Be(3); + } + + [Fact] + public async Task GetAllRestaurants_WithPageSize_10_And_Page_2_ShouldReturnEmpty_RestaurantsCollection() + { + var restaurantResponse = await Client.GetAsync("/api/restaurants?page=2&pageSize=10", + TestContext.Current.CancellationToken); + + var restaurantResult = + await restaurantResponse.Content.ReadFromJsonAsync>( + TestContext.Current.CancellationToken); + + restaurantResponse.IsSuccessStatusCode.Should().BeTrue(); + restaurantResponse.StatusCode.Should().Be(HttpStatusCode.OK); + restaurantResult.Should().NotBeNull(); + restaurantResult.HasPreviousPage.Should().BeTrue(); + restaurantResult.HasNextPage.Should().BeFalse(); + restaurantResult.TotalCount.Should().Be(6); + restaurantResult.Items.Count.Should().Be(0); + restaurantResult.TotalPages.Should().Be(1); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/Restaurants/GetRestaurantTests.cs b/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/Restaurants/GetRestaurantTests.cs new file mode 100644 index 0000000..badb416 --- /dev/null +++ b/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/Restaurants/GetRestaurantTests.cs @@ -0,0 +1,49 @@ +using System.Net; +using System.Net.Http.Json; + +using FluentAssertions; + +using RestaurantReservation.Application.Features.Restaurants.Query.Response; + +namespace RestaurantReservation.IntegrationTests.Restaurants; + +public class GetRestaurantTests(IntegrationTestWebAppFactory factory) : BaseIntegrationTest(factory) +{ + [Fact] + public async Task GetRestaurant_ShouldReturnRestaurant_WhenRestaurant_Is_Valid() + { + const string restaurantName = "International House Of Culinary Delights"; + var restaurantId = await CreateRestaurantForRetrieval(restaurantName); + + var restaurantResponse = await Client.GetAsync($"/api/restaurants/{restaurantId}", + TestContext.Current.CancellationToken); + + var restaurantResult = await restaurantResponse.Content.ReadFromJsonAsync( + TestContext.Current.CancellationToken); + + restaurantResponse.IsSuccessStatusCode.Should().BeTrue(); + restaurantResponse.StatusCode.Should().Be(HttpStatusCode.OK); + restaurantResult.Should().NotBeNull(); + restaurantResult.Id.Should().Be(restaurantId); + restaurantResult.Name.Should().Be(restaurantName); + restaurantResult.Schedule[0].DailyHours[0].Should().Be(new TimeOnly(10, 00)); + } + + [Fact] + public async Task GetRestaurant_ShouldFail_WhenRestaurantId_Is_Not_Valid() + { + var restaurantId = Guid.CreateVersion7(); + + var restaurantResponse = await Client.GetAsync($"/api/restaurants/{restaurantId}", + TestContext.Current.CancellationToken); + + var restaurantResult = await restaurantResponse.Content.ReadFromJsonAsync( + TestContext.Current.CancellationToken); + + restaurantResponse.IsSuccessStatusCode.Should().BeFalse(); + restaurantResponse.StatusCode.Should().Be(HttpStatusCode.NotFound); + restaurantResult.Should().NotBeNull(); + restaurantResult.Schedule.Should().BeNull(); + restaurantResult.TableInfo.Should().BeNull(); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/Restaurants/TableGroups/AddTableGroupTests.cs b/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/Restaurants/TableGroups/AddTableGroupTests.cs new file mode 100644 index 0000000..49b963c --- /dev/null +++ b/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/Restaurants/TableGroups/AddTableGroupTests.cs @@ -0,0 +1,57 @@ +using System.Net; +using System.Net.Http.Json; + +using FluentAssertions; + +using RestaurantReservation.Application.Features.Restaurants.TableGroups.Query.Responses; + +namespace RestaurantReservation.IntegrationTests.Restaurants.TableGroups; + +public class AddTableGroupTests(IntegrationTestWebAppFactory factory) : BaseIntegrationTest(factory) +{ + [Fact] + public async Task AddTableGroup_ShouldReturn_TableGroupDetailResponse_And_StatusCode_201Created_OnSuccess() + { + const string restaurantName = "Sal's Seafood Shack"; + const string groupName = "Non-Smoking"; + + var restaurantId = await CreateRestaurantForRetrieval(restaurantName); + + var tableGroupResponse = await Client.PostAsJsonAsync($"/api/restaurants/{restaurantId}/tablegroups/add", new + { + Name = groupName, + NumberOfTables = 45, + NumberOfSeats = 4 + }, + TestContext.Current.CancellationToken); + + var tableGroupResult = await tableGroupResponse.Content.ReadFromJsonAsync( + TestContext.Current.CancellationToken); + + tableGroupResponse.IsSuccessStatusCode.Should().BeTrue(); + tableGroupResponse.StatusCode.Should().Be(HttpStatusCode.Created); + tableGroupResult.Should().NotBeNull(); + tableGroupResult.GroupName.Should().Be(groupName); + tableGroupResult.RestaurantName.Should().Be(restaurantName); + tableGroupResult.RestaurantId.Should().Be(restaurantId); + tableGroupResult.NumberOfTables.Should().Be(45); + tableGroupResult.Tables[0].SeatsAtTable.Should().Be(4); + } + + [Fact] + public async Task AddTableGroup_Returns_StatusCode_404NotFound_When_RestaurantId_IsNotValid() + { + await LoginAndSetAuthenticationAsync("admin@example.com", "Pa$$w0rd"); + + const string groupName = "Non-Smoking"; + + var restaurantId = Guid.CreateVersion7(); + + var tableGroupResponse = await Client.PostAsJsonAsync($"/api/restaurants/{restaurantId}/tablegroups/add", + new { Name = groupName, NumberOfTables = 45, NumberOfSeats = 4 }, + TestContext.Current.CancellationToken); + + tableGroupResponse.IsSuccessStatusCode.Should().BeFalse(); + tableGroupResponse.StatusCode.Should().Be(HttpStatusCode.NotFound); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/Restaurants/TableGroups/DeleteTableGroupTests.cs b/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/Restaurants/TableGroups/DeleteTableGroupTests.cs new file mode 100644 index 0000000..ebec40d --- /dev/null +++ b/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/Restaurants/TableGroups/DeleteTableGroupTests.cs @@ -0,0 +1,45 @@ +using System.Net; + +using FluentAssertions; + +using RestaurantReservation.Application.Features.Restaurants.TableGroups.Command.Add; + +namespace RestaurantReservation.IntegrationTests.Restaurants.TableGroups; + +public class DeleteTableGroupTests(IntegrationTestWebAppFactory factory) : BaseIntegrationTest(factory) +{ + [Fact] + public async Task DeleteTable_Returns_StatusCode_204NoContent_OnSuccess() + { + var restaurantId = await CreateRestaurantForRetrieval("Steak Supreme"); + + const string groupName = "V.I.P"; + + var tableGroupId = await CreateTableGroupForRetrieval(new AddTableGroupCommand( + restaurantId, + groupName, + 30, + 6)); + + var tableGroupResponse = await Client.DeleteAsync( + $"/api/restaurants/{restaurantId}/tablegroups/delete/{tableGroupId}", + TestContext.Current.CancellationToken); + + tableGroupResponse.IsSuccessStatusCode.Should().BeTrue(); + tableGroupResponse.StatusCode.Should().Be(HttpStatusCode.NoContent); + } + + [Fact] + public async Task DeleteTableGroup_Returns_StatusCode_404NotFound_When_TableGroupId_IsInvalid() + { + var restaurantId = await CreateRestaurantForRetrieval("The New Steak Supreme"); + + var tableGroupId = Guid.CreateVersion7(); + + var tableGroupResponse = await Client.DeleteAsync($"/api/restaurants/{restaurantId}/tablegroups/delete/{tableGroupId}", + TestContext.Current.CancellationToken); + + tableGroupResponse.IsSuccessStatusCode.Should().BeFalse(); + tableGroupResponse.StatusCode.Should().Be(HttpStatusCode.NotFound); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/Restaurants/TableGroups/GetAllTableGroupsTests.cs b/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/Restaurants/TableGroups/GetAllTableGroupsTests.cs new file mode 100644 index 0000000..3ff14bd --- /dev/null +++ b/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/Restaurants/TableGroups/GetAllTableGroupsTests.cs @@ -0,0 +1,47 @@ +using System.Net; +using System.Net.Http.Json; + +using FluentAssertions; + +using RestaurantReservation.Application.Features.Restaurants.TableGroups.Query.Responses; +using RestaurantReservation.Domain.Common; + +namespace RestaurantReservation.IntegrationTests.Restaurants.TableGroups; + +public class GetAllTableGroupsTests(IntegrationTestWebAppFactory factory) : BaseIntegrationTest(factory) +{ + [Fact] + public async Task GetAllTableGroups_Returns_TableGroups_And_StatusCode_200OK_OnSuccess() + { + const string restaurantName = "Guy's Pancake Shack"; + + var restaurantId = await CreateRestaurantForRetrieval(restaurantName); + + var tableGroupsResponse = await Client.GetAsync($"/api/restaurants/{restaurantId}/tablegroups", + TestContext.Current.CancellationToken); + + var tableGroupsResult = await tableGroupsResponse.Content.ReadFromJsonAsync>( + TestContext.Current.CancellationToken); + + tableGroupsResponse.IsSuccessStatusCode.Should().BeTrue(); + tableGroupsResponse.StatusCode.Should().Be(HttpStatusCode.OK); + tableGroupsResult.Should().NotBeNull(); + tableGroupsResult.TotalCount.Should().Be(3); + tableGroupsResult.HasPreviousPage.Should().BeFalse(); + tableGroupsResult.HasNextPage.Should().BeFalse(); + } + + [Fact] + public async Task GetAllTableGroups_Returns_StatusCode_404NotFound_When_RestaurantId_IsInvalid() + { + await LoginAndSetAuthenticationAsync("admin@example.com", "Pa$$w0rd"); + + var restaurantId = Guid.CreateVersion7(); + + var tableGroupsResponse = await Client.GetAsync($"/api/restaurants/{restaurantId}/tablegroups", + TestContext.Current.CancellationToken); + + tableGroupsResponse.IsSuccessStatusCode.Should().BeFalse(); + tableGroupsResponse.StatusCode.Should().Be(HttpStatusCode.NotFound); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/Restaurants/TableGroups/GetTableGroupTests.cs b/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/Restaurants/TableGroups/GetTableGroupTests.cs new file mode 100644 index 0000000..8b7aedb --- /dev/null +++ b/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/Restaurants/TableGroups/GetTableGroupTests.cs @@ -0,0 +1,58 @@ +using System.Net; +using System.Net.Http.Json; + +using FluentAssertions; + +using RestaurantReservation.Application.Features.Restaurants.TableGroups.Command.Add; +using RestaurantReservation.Application.Features.Restaurants.TableGroups.Query.Responses; + +namespace RestaurantReservation.IntegrationTests.Restaurants.TableGroups; + +public class GetTableGroupTests(IntegrationTestWebAppFactory factory) : BaseIntegrationTest(factory) +{ + [Fact] + public async Task GetTableGroup_Returns_TableGroupDetailResponse_And_StatusCode_200OK_OnSuccess() + { + const string restaurantName = "Cameron's Desert Factory"; + var restaurantId = await CreateRestaurantForRetrieval(restaurantName); + + const string groupName = "Family Section"; + + var tableGroupId = await CreateTableGroupForRetrieval(new AddTableGroupCommand( + restaurantId, + groupName, + 20, + 6)); + + var tableGroupResponse = await Client.GetAsync($"/api/restaurants/{restaurantId}/tablegroups/{tableGroupId}", + TestContext.Current.CancellationToken); + + var tableGroupResult = await tableGroupResponse.Content.ReadFromJsonAsync( + TestContext.Current.CancellationToken); + + tableGroupResponse.IsSuccessStatusCode.Should().BeTrue(); + tableGroupResponse.StatusCode.Should().Be(HttpStatusCode.OK); + tableGroupResult.Should().NotBeNull(); + tableGroupResult.GroupName.Should().Be(groupName); + tableGroupResult.RestaurantName.Should().Be(restaurantName); + tableGroupResult.NumberOfTables.Should().Be(20); + tableGroupResult.Tables[0].SeatsAtTable.Should().Be(6); + tableGroupResult.Tables[0].TableGroupName.Should().Be(groupName); + } + + [Fact] + public async Task GetTableGroup_Returns_StatusCode_404NotFound_WhenTableGroupId_IsInvalid() + { + const string restaurantName = "Nathan's Desert Bar"; + + var restaurantId = await CreateRestaurantForRetrieval(restaurantName); + + var tableGroupId = Guid.CreateVersion7(); + + var tableGroupResponse = await Client.GetAsync($"/api/restaurants/{restaurantId}/tablegroups/{tableGroupId}", + TestContext.Current.CancellationToken); + + tableGroupResponse.IsSuccessStatusCode.Should().BeFalse(); + tableGroupResponse.StatusCode.Should().Be(HttpStatusCode.NotFound); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/Restaurants/TableGroups/UpdateTableGroupTests.cs b/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/Restaurants/TableGroups/UpdateTableGroupTests.cs new file mode 100644 index 0000000..8d03010 --- /dev/null +++ b/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/Restaurants/TableGroups/UpdateTableGroupTests.cs @@ -0,0 +1,85 @@ +using System.Net; +using System.Net.Http.Json; + +using FluentAssertions; + +using RestaurantReservation.Application.Features.Restaurants.TableGroups.Command.Add; +using RestaurantReservation.Application.Features.Restaurants.TableGroups.Query.Responses; + +namespace RestaurantReservation.IntegrationTests.Restaurants.TableGroups; + +public class UpdateTableGroupTests(IntegrationTestWebAppFactory factory) : BaseIntegrationTest(factory) +{ + [Fact] + public async Task UpdateTableGroup_Should_Return_StatusCode_204NoContent_OnSuccess() + { + var restaurantId = await CreateRestaurantForRetrieval("Charlie's House Of Brownies"); + + const string groupName = "Smoking Section"; + const string updatedGroupName = "V.I.P. Section"; + + var tableGroupId = await CreateTableGroupForRetrieval(new AddTableGroupCommand( + restaurantId, + groupName, + 30, + 4)); + + var tableGroupResponse = await Client.PutAsJsonAsync($"/api/restaurants/{restaurantId}/tablegroups/update/{tableGroupId}", new + { + GroupName = updatedGroupName, + NumberOfTablesToAdd = 30, + NumberOfSeats = 6 + }, + TestContext.Current.CancellationToken); + + var updatedTableGroup = await GetTableGroupToCheckProperties( + restaurantId, + tableGroupId); + + tableGroupResponse.IsSuccessStatusCode.Should().BeTrue(); + tableGroupResponse.StatusCode.Should().Be(HttpStatusCode.NoContent); + updatedTableGroup.GroupName.Should().Be(updatedGroupName); + updatedTableGroup.Id.Should().Be(tableGroupId); + updatedTableGroup.NumberOfTables.Should().Be(60); + } + + [Fact] + public async Task + UpdateTableGroup_Should_Return_StatusCode_409Conflict_WhenTryingToUpdateGroupName_ToNameAlreadyTaken() + { + var restaurantId = await CreateRestaurantForRetrieval("Ali's House Of Decadent Culinary Delights"); + + const string groupName = "Smoking Section"; + const string updatedGroupName = "Main Dining Room"; + + var tableGroupId = await CreateTableGroupForRetrieval(new AddTableGroupCommand( + restaurantId, + groupName, + 30, + 4)); + + var tableGroupResponse = await Client.PutAsJsonAsync( + $"/api/restaurants/{restaurantId}/tablegroups/update/{tableGroupId}", + new { GroupName = updatedGroupName, NumberOfTables = 30, NumberOfSeats = 6 }, + TestContext.Current.CancellationToken); + + tableGroupResponse.IsSuccessStatusCode.Should().BeFalse(); + tableGroupResponse.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + + + private async Task GetTableGroupToCheckProperties(Guid restaurantId, Guid tableGroupId) + { + var tableGroupResponse = await Client.GetAsync($"/api/restaurants/{restaurantId}/tablegroups/{tableGroupId}", + TestContext.Current.CancellationToken); + + tableGroupResponse.EnsureSuccessStatusCode(); + + var tableGroupResult = await tableGroupResponse.Content.ReadFromJsonAsync( + TestContext.Current.CancellationToken); + + tableGroupResult.Should().NotBeNull(); + + return tableGroupResult; + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/Restaurants/Tables/AddTableTests.cs b/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/Restaurants/Tables/AddTableTests.cs new file mode 100644 index 0000000..914df14 --- /dev/null +++ b/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/Restaurants/Tables/AddTableTests.cs @@ -0,0 +1,48 @@ +using System.Net; +using System.Net.Http.Json; + +using FluentAssertions; + +using RestaurantReservation.Application.Features.Restaurants.Query.Response; + +namespace RestaurantReservation.IntegrationTests.Restaurants.Tables; + +public class AddTableTests(IntegrationTestWebAppFactory factory) : BaseIntegrationTest(factory) +{ + [Fact] + public async Task AddTable_Returns_RestaurantTableResponse_And_StatusCode_201Created_OnSuccess() + { + const string restaurantName = "Alex's Italian Steakhouse"; + const string groupName = "Outside Patio"; + var restaurantId = await CreateRestaurantForRetrieval(restaurantName); + + var tableResponse = await Client.PostAsJsonAsync($"/api/restaurants/{restaurantId}/tables/add", + new { NumberOfSeats = 6, TableGroup = groupName }, + TestContext.Current.CancellationToken); + + var tableResult = await tableResponse.Content.ReadFromJsonAsync( + TestContext.Current.CancellationToken); + + tableResponse.IsSuccessStatusCode.Should().BeTrue(); + tableResponse.StatusCode.Should().Be(HttpStatusCode.Created); + tableResult.Should().NotBeNull(); + tableResult.SeatsAtTable.Should().Be(6); + tableResult.TableGroupName.Should().Be(groupName); + } + + [Fact] + public async Task AddTable_Returns_StatusCode_404NotFound_WhenTableGroup_IsNon_Existent() + { + const string restaurantName = "Alex's Steakhouse"; + const string groupName = "Balcony"; + + var restaurantId = await CreateRestaurantForRetrieval(restaurantName); + + var tableResponse = await Client.PostAsJsonAsync($"/api/restaurants/{restaurantId}/tables/add", + new { NumberOfSeats = 4, TableGroup = groupName }, + TestContext.Current.CancellationToken); + + tableResponse.IsSuccessStatusCode.Should().BeFalse(); + tableResponse.StatusCode.Should().Be(HttpStatusCode.NotFound); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/Restaurants/Tables/DeleteTableTests.cs b/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/Restaurants/Tables/DeleteTableTests.cs new file mode 100644 index 0000000..fc7e775 --- /dev/null +++ b/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/Restaurants/Tables/DeleteTableTests.cs @@ -0,0 +1,46 @@ +using System.Net; + +using FluentAssertions; + +using RestaurantReservation.Application.Features.Restaurants.Tables.Command.Add; + +namespace RestaurantReservation.IntegrationTests.Restaurants.Tables; + +public class DeleteTableTests(IntegrationTestWebAppFactory factory) : BaseIntegrationTest(factory) +{ + [Fact] + public async Task DeleteTable_Returns_StatusCode_204NoContent_OnSuccess() + { + const string restaurantName = "Bruce's All You Can Eat Burger Buffet"; + const string groupName = "Main Dining Room"; + + var restaurantId = await CreateRestaurantForRetrieval(restaurantName); + + var tableId = await CreateTableForRetrieval(new AddTableCommand( + restaurantId, + 4, + groupName)); + + var tableResponse = await Client.DeleteAsync($"/api/restaurants/{restaurantId}/tables/delete/{tableId}", + TestContext.Current.CancellationToken); + + tableResponse.IsSuccessStatusCode.Should().BeTrue(); + tableResponse.StatusCode.Should().Be(HttpStatusCode.NoContent); + } + + [Fact] + public async Task DeleteTable_Returns_StatusCode_404NotFound_When_TableId_IsInvalid() + { + const string restaurantName = "Bobby's Barbecue Pit"; + + var restaurantId = await CreateRestaurantForRetrieval(restaurantName); + + var tableId = Guid.CreateVersion7(); + + var tableResponse = await Client.DeleteAsync($"/api/restaurants/{restaurantId}/tables/delete/{tableId}", + TestContext.Current.CancellationToken); + + tableResponse.IsSuccessStatusCode.Should().BeFalse(); + tableResponse.StatusCode.Should().Be(HttpStatusCode.NotFound); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/Restaurants/Tables/GetAllTablesByRestaurantTests.cs b/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/Restaurants/Tables/GetAllTablesByRestaurantTests.cs new file mode 100644 index 0000000..9bcef23 --- /dev/null +++ b/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/Restaurants/Tables/GetAllTablesByRestaurantTests.cs @@ -0,0 +1,49 @@ +using System.Net; +using System.Net.Http.Json; + +using FluentAssertions; + +using RestaurantReservation.Application.Features.Restaurants.Tables.Query.Responses; +using RestaurantReservation.Domain.Common; + +namespace RestaurantReservation.IntegrationTests.Restaurants.Tables; + +public class GetAllTablesByRestaurantTests(IntegrationTestWebAppFactory factory) : BaseIntegrationTest(factory) +{ + [Fact] + public async Task GetAllTablesByRestaurant_Page2_PageSize15_Returns_PagedResult_Of_TableResponse_And_StatusCode_200OK_OnSuccess() + { + const string restaurantName = "Reginald's Crab Shack"; + + var restaurantId = await CreateRestaurantForRetrieval(restaurantName); + + var tablesResponse = await Client.GetAsync($"/api/restaurants/{restaurantId}/tables?page=2&pageSize=15", + TestContext.Current.CancellationToken); + + var tablesResult = await tablesResponse.Content.ReadFromJsonAsync>( + TestContext.Current.CancellationToken); + + tablesResponse.IsSuccessStatusCode.Should().BeTrue(); + tablesResponse.StatusCode.Should().Be(HttpStatusCode.OK); + tablesResult.Should().NotBeNull(); + tablesResult.Items.Count.Should().Be(15); + tablesResult.TotalCount.Should().Be(105); + tablesResult.TotalPages.Should().Be(7); + tablesResult.HasNextPage.Should().BeTrue(); + tablesResult.HasPreviousPage.Should().BeTrue(); + } + + [Fact] + public async Task GetAllTablesByRestaurant_Returns_StatusCode_404NotFound_When_RestaurantId_IsInvalid() + { + await LoginAndSetAuthenticationAsync("admin@example.com", "Pa$$w0rd"); + + var restaurantId = Guid.CreateVersion7(); + + var tablesResponse = await Client.GetAsync($"/api/restaurants/{restaurantId}/tables", + TestContext.Current.CancellationToken); + + tablesResponse.IsSuccessStatusCode.Should().BeFalse(); + tablesResponse.StatusCode.Should().Be(HttpStatusCode.NotFound); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/Restaurants/Tables/GetAllTablesByTableGroupTests.cs b/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/Restaurants/Tables/GetAllTablesByTableGroupTests.cs new file mode 100644 index 0000000..b980a51 --- /dev/null +++ b/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/Restaurants/Tables/GetAllTablesByTableGroupTests.cs @@ -0,0 +1,51 @@ +using System.Net; +using System.Net.Http.Json; + +using FluentAssertions; + +using RestaurantReservation.Application.Features.Restaurants.Tables.Query.Responses; +using RestaurantReservation.Domain.Common; + +namespace RestaurantReservation.IntegrationTests.Restaurants.Tables; + +public class GetAllTablesByTableGroupTests(IntegrationTestWebAppFactory factory) : BaseIntegrationTest(factory) +{ + [Fact] + public async Task GetAllTablesByTableGroup_Page1_PageSize_15_Returns_PagedResult_Of_TableGroupResponse_And_StatusCode_200OK_OnSuccess() + { + const string restaurantName = "Terry's Teriyaki Palace"; + const string tableGroupName = "Main Dining Room"; + + var restaurantId = await CreateRestaurantForRetrieval(restaurantName); + + var tablesResponse = await Client.GetAsync($"/api/restaurants/{restaurantId}/tables/{tableGroupName}?page=1&pageSize=15", + TestContext.Current.CancellationToken); + + var tablesResult = await tablesResponse.Content.ReadFromJsonAsync>( + TestContext.Current.CancellationToken); + + tablesResponse.IsSuccessStatusCode.Should().BeTrue(); + tablesResponse.StatusCode.Should().Be(HttpStatusCode.OK); + tablesResult.Should().NotBeNull(); + tablesResult.HasPreviousPage.Should().BeFalse(); + tablesResult.HasNextPage.Should().BeTrue(); + tablesResult.Items.Count.Should().Be(15); + tablesResult.TotalCount.Should().Be(45); + tablesResult.TotalPages.Should().Be(3); + } + + [Fact] + public async Task GetAllTablesByTableGroup_Should_Return_StatusCode_404_NotFound_When_TableGroupName_Is_Invalid() + { + const string restaurantName = "Terry's Steak And Lobster Buffet"; + const string tableGroupName = "Introverts"; + + var restaurantId = await CreateRestaurantForRetrieval(restaurantName); + + var tablesResponse = await Client.GetAsync($"/api/restaurants/{restaurantId}/tables/{tableGroupName}", + TestContext.Current.CancellationToken); + + tablesResponse.IsSuccessStatusCode.Should().BeFalse(); + tablesResponse.StatusCode.Should().Be(HttpStatusCode.NotFound); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/Restaurants/Tables/GetTableTests.cs b/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/Restaurants/Tables/GetTableTests.cs new file mode 100644 index 0000000..504d1b6 --- /dev/null +++ b/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/Restaurants/Tables/GetTableTests.cs @@ -0,0 +1,58 @@ +using System.Net; +using System.Net.Http.Json; + +using FluentAssertions; + +using RestaurantReservation.Application.Features.Restaurants.Tables.Command.Add; +using RestaurantReservation.Application.Features.Restaurants.Tables.Query.Responses; + +namespace RestaurantReservation.IntegrationTests.Restaurants.Tables; + +public class GetTableTests(IntegrationTestWebAppFactory factory) : BaseIntegrationTest(factory) +{ + [Fact] + public async Task GetTable_Returns_TableDetailResponse_And_StatusCode_200OK_OnSuccess() + { + const string restaurantName = "Martha's Seafood By The Sea"; + const string groupName = "Outside Patio"; + + var restaurantId = await CreateRestaurantForRetrieval(restaurantName); + + var tableId = await CreateTableForRetrieval(new AddTableCommand( + restaurantId, + 4, + groupName)); + + var tableResponse = await Client.GetAsync($"/api/restaurants/{restaurantId}/tables/{tableId}", + TestContext.Current.CancellationToken); + + var tableResult = await tableResponse.Content.ReadFromJsonAsync( + TestContext.Current.CancellationToken); + + tableResponse.IsSuccessStatusCode.Should().BeTrue(); + tableResponse.StatusCode.Should().Be(HttpStatusCode.OK); + tableResult.Should().NotBeNull(); + tableResult.Id.Should().Be(tableId); + tableResult.RestaurantId.Should().Be(restaurantId); + tableResult.RestaurantName.Should().Be(restaurantName); + tableResult.IsInTableGroup.Should().BeTrue(); + tableResult.TableGroupName.Should().Be(groupName); + tableResult.SeatsAtTable.Should().Be(4); + } + + [Fact] + public async Task GetTable_Should_Return_StatusCode_404NotFound_When_TableId_Is_Invalid() + { + const string restaurantName = "Smith's Famous Desert House"; + + var restaurantId = await CreateRestaurantForRetrieval(restaurantName); + + var tableId = Guid.CreateVersion7(); + + var tableResponse = await Client.GetAsync($"/api/restaurants/{restaurantId}/tables/{tableId}", + TestContext.Current.CancellationToken); + + tableResponse.IsSuccessStatusCode.Should().BeFalse(); + tableResponse.StatusCode.Should().Be(HttpStatusCode.NotFound); + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/Restaurants/Tables/UpdateTableTests.cs b/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/Restaurants/Tables/UpdateTableTests.cs new file mode 100644 index 0000000..4a109ac --- /dev/null +++ b/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/Restaurants/Tables/UpdateTableTests.cs @@ -0,0 +1,71 @@ +using System.Net; +using System.Net.Http.Json; + +using FluentAssertions; + +using RestaurantReservation.Application.Features.Restaurants.Tables.Command.Add; +using RestaurantReservation.Application.Features.Restaurants.Tables.Query.Responses; + +namespace RestaurantReservation.IntegrationTests.Restaurants.Tables; + +public class UpdateTableTests(IntegrationTestWebAppFactory factory) : BaseIntegrationTest(factory) +{ + [Fact] + public async Task UpdateTable_Returns_StatusCode_204NoContent_OnSuccess() + { + const string restaurantName = "Gia's Desert Shop"; + const string groupName = "Main Dining Room"; + const string updatedGroupName = "Family Dining Room"; + + var restaurantId = await CreateRestaurantForRetrieval(restaurantName); + + var tableId = await CreateTableForRetrieval(new AddTableCommand( + restaurantId, + 4, + groupName)); + + var tableResponse = await Client.PutAsJsonAsync($"/api/restaurants/{restaurantId}/tables/update/{tableId}", + new { NumberOfSeats = 6, GroupName = updatedGroupName }, + TestContext.Current.CancellationToken); + + var updatedTable = await GetTableToCheckProperties(restaurantId, tableId); + + tableResponse.IsSuccessStatusCode.Should().BeTrue(); + tableResponse.StatusCode.Should().Be(HttpStatusCode.NoContent); + updatedTable.SeatsAtTable.Should().Be(6); + updatedTable.TableGroupName.Should().Be(updatedGroupName); + } + + [Fact] + public async Task UpdateTableGroup_Returns_StatusCode_404NotFound_When_TableId_IsInvalid() + { + const string restaurantName = "Foxy's Bar And Grill"; + const string updatedGroupName = "Family Dining Room"; + + var restaurantId = await CreateRestaurantForRetrieval(restaurantName); + + var tableId = Guid.CreateVersion7(); + + var tableResponse = await Client.PutAsJsonAsync($"/api/restaurants/{restaurantId}/tables/update/{tableId}", + new { NumberOfSeats = 6, GroupName = updatedGroupName }, + TestContext.Current.CancellationToken); + + tableResponse.IsSuccessStatusCode.Should().BeFalse(); + tableResponse.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + private async Task GetTableToCheckProperties(Guid restaurantId, Guid tableId) + { + var tableResponse = await Client.GetAsync($"/api/restaurants/{restaurantId}/tables/{tableId}", + TestContext.Current.CancellationToken); + + tableResponse.EnsureSuccessStatusCode(); + + var tableResult = await tableResponse.Content.ReadFromJsonAsync( + TestContext.Current.CancellationToken); + + tableResult.Should().NotBeNull(); + + return tableResult; + } +} \ No newline at end of file diff --git a/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/Restaurants/UpdateRestaurantTests.cs b/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/Restaurants/UpdateRestaurantTests.cs new file mode 100644 index 0000000..b66608d --- /dev/null +++ b/restaurant-reservations-clean-architecture/tests/RestaurantReservation.IntegrationTests/Restaurants/UpdateRestaurantTests.cs @@ -0,0 +1,67 @@ +using System.Net; +using System.Net.Http.Json; + +using FluentAssertions; + +using RestaurantReservation.Domain.Restaurants; + +namespace RestaurantReservation.IntegrationTests.Restaurants; + +public class UpdateRestaurantTests(IntegrationTestWebAppFactory factory) : BaseIntegrationTest(factory) +{ + [Fact] + public async Task UpdateRestaurant_Should_Be_Successful_WhenId_IsValid() + { + const string originalName = "Susan's Vegan Health Shack"; + const string updatedName = "Susan's Healthy Vegan Meals And Snacks"; + + var restaurantId = await CreateRestaurantForRetrieval(originalName); + + var restaurantResponse = await Client.PutAsJsonAsync($"/api/restaurants/update/{restaurantId}", new + { + Name = updatedName, + Schedule = new RestaurantSchedule[] + { + new(WeekDay.Sunday, [new TimeOnly(9, 00), new TimeOnly(21,00)]), + new(WeekDay.Monday, [new TimeOnly(8, 00), new TimeOnly(21, 00)]), + new(WeekDay.Tuesday, [new TimeOnly(8, 0), new TimeOnly(21, 00)]), + new(WeekDay.Wednesday, [new TimeOnly(8, 0), new TimeOnly(21, 00)]), + new(WeekDay.Thursday, [new TimeOnly(8, 0), new TimeOnly(22, 0)]), + new(WeekDay.Friday, [new TimeOnly(8, 0), new TimeOnly(22, 30)]), + new(WeekDay.Saturday, [new TimeOnly(9, 0), new TimeOnly(23, 00)]) + } + }, + TestContext.Current.CancellationToken); + + restaurantResponse.IsSuccessStatusCode.Should().BeTrue(); + restaurantResponse.StatusCode.Should().Be(HttpStatusCode.NoContent); + } + + [Fact] + public async Task UpdateRestaurant_Should_Fail_WhenRestaurantNotFound() + { + await LoginAndSetAuthenticationAsync("admin@example.com", "Pa$$w0rd"); + + const string updatedName = "Susan's Healthy Vegan Meals And Snacks"; + var restaurantId = Guid.CreateVersion7(); + + var restaurantResponse = await Client.PutAsJsonAsync($"/api/restaurants/update/{restaurantId}", new + { + Name = updatedName, + Schedule = new RestaurantSchedule[] + { + new(WeekDay.Sunday, [new TimeOnly(9, 00), new TimeOnly(21,00)]), + new(WeekDay.Monday, [new TimeOnly(8, 00), new TimeOnly(21, 00)]), + new(WeekDay.Tuesday, [new TimeOnly(8, 0), new TimeOnly(21, 00)]), + new(WeekDay.Wednesday, [new TimeOnly(8, 0), new TimeOnly(21, 00)]), + new(WeekDay.Thursday, [new TimeOnly(8, 0), new TimeOnly(22, 0)]), + new(WeekDay.Friday, [new TimeOnly(8, 0), new TimeOnly(22, 30)]), + new(WeekDay.Saturday, [new TimeOnly(9, 0), new TimeOnly(23, 00)]) + } + }, + TestContext.Current.CancellationToken); + + restaurantResponse.IsSuccessStatusCode.Should().BeFalse(); + restaurantResponse.StatusCode.Should().Be(HttpStatusCode.NotFound); + } +} \ No newline at end of file