diff --git a/.editorconfig b/.editorconfig deleted file mode 100644 index 2ae896e..0000000 --- a/.editorconfig +++ /dev/null @@ -1,355 +0,0 @@ -# Remove the line below if you want to inherit .editorconfig settings from higher directories -root = true - -# C# files -[*.cs] - -#### Core EditorConfig Options #### - -# Indentation and spacing -indent_size = 4 -indent_style = tab -tab_width = 4 - -# New line preferences -end_of_line = crlf -insert_final_newline = false - -#### .NET Coding Conventions #### - -# Organize usings -dotnet_separate_import_directive_groups = false -dotnet_sort_system_directives_first = false -file_header_template = unset - -# this. and Me. preferences -dotnet_style_qualification_for_event = false -dotnet_style_qualification_for_field = false -dotnet_style_qualification_for_method = false -dotnet_style_qualification_for_property = false - -# Language keywords vs BCL types preferences -dotnet_style_predefined_type_for_locals_parameters_members = true -dotnet_style_predefined_type_for_member_access = true - -# Parentheses preferences -dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity -dotnet_style_parentheses_in_other_binary_operators = always_for_clarity -dotnet_style_parentheses_in_other_operators = never_if_unnecessary -dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity - -# Modifier preferences -dotnet_style_require_accessibility_modifiers = for_non_interface_members - -# Expression-level preferences -dotnet_style_coalesce_expression = true -dotnet_style_collection_initializer = true -dotnet_style_explicit_tuple_names = true -dotnet_style_namespace_match_folder = true -dotnet_style_null_propagation = true -dotnet_style_object_initializer = true -dotnet_style_operator_placement_when_wrapping = beginning_of_line -dotnet_style_prefer_auto_properties = true -dotnet_style_prefer_collection_expression = when_types_loosely_match -dotnet_style_prefer_compound_assignment = true -dotnet_style_prefer_conditional_expression_over_assignment = true -dotnet_style_prefer_conditional_expression_over_return = true -dotnet_style_prefer_foreach_explicit_cast_in_source = when_strongly_typed -dotnet_style_prefer_inferred_anonymous_type_member_names = true -dotnet_style_prefer_inferred_tuple_names = true -dotnet_style_prefer_is_null_check_over_reference_equality_method = true -dotnet_style_prefer_simplified_boolean_expressions = true -dotnet_style_prefer_simplified_interpolation = true - -# Seal internal types -dotnet_diagnostic.CA1852.severity = warning - -# Field preferences -dotnet_style_readonly_field = true - -# Parameter preferences -dotnet_code_quality_unused_parameters = all - -# Suppression preferences -dotnet_remove_unnecessary_suppression_exclusions = none - -# New line preferences -dotnet_style_allow_multiple_blank_lines_experimental = true -dotnet_style_allow_statement_immediately_after_block_experimental = true - -#### C# Coding Conventions #### - -# var preferences -csharp_style_var_elsewhere = false:silent -csharp_style_var_for_built_in_types = false:suggestion -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:silent -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:suggestion -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:suggestion -csharp_preferred_modifier_order = public, private, protected, internal, file, static, extern, new, virtual, abstract, sealed, override, readonly, unsafe, required, volatile, async -csharp_style_prefer_readonly_struct = true:suggestion -csharp_style_prefer_readonly_struct_member = true:suggestion - -# Code-block preferences -csharp_prefer_braces = true:suggestion -csharp_prefer_simple_using_statement = true:suggestion -csharp_style_namespace_declarations = file_scoped:warning -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:suggestion - -# New line preferences -csharp_style_allow_blank_line_after_colon_in_constructor_initializer_experimental = true:silent -csharp_style_allow_blank_line_after_token_in_arrow_expression_clause_experimental = true:silent -csharp_style_allow_blank_line_after_token_in_conditional_expression_experimental = true:silent -csharp_style_allow_blank_lines_between_consecutive_braces_experimental = true:silent -csharp_style_allow_embedded_statements_on_same_line_experimental = true:silent - -#### C# Formatting Rules #### - -# New line preferences -csharp_new_line_before_catch = false -csharp_new_line_before_else = false -csharp_new_line_before_finally = false -csharp_new_line_before_members_in_anonymous_types = false -csharp_new_line_before_members_in_object_initializers = false -csharp_new_line_before_open_brace = none -csharp_new_line_between_query_expression_clauses = false - -# Indentation preferences -csharp_indent_block_contents = true -csharp_indent_braces = false -csharp_indent_case_contents = true -csharp_indent_case_contents_when_block = true -csharp_indent_labels = one_less_than_current -csharp_indent_switch_labels = true - -# Space preferences -csharp_space_after_cast = false -csharp_space_after_colon_in_inheritance_clause = true -csharp_space_after_comma = true -csharp_space_after_dot = false -csharp_space_after_keywords_in_control_flow_statements = true -csharp_space_after_semicolon_in_for_statement = true -csharp_space_around_binary_operators = before_and_after -csharp_space_around_declaration_statements = false -csharp_space_before_colon_in_inheritance_clause = true -csharp_space_before_comma = false -csharp_space_before_dot = false -csharp_space_before_open_square_brackets = false -csharp_space_before_semicolon_in_for_statement = false -csharp_space_between_empty_square_brackets = false -csharp_space_between_method_call_empty_parameter_list_parentheses = false -csharp_space_between_method_call_name_and_opening_parenthesis = false -csharp_space_between_method_call_parameter_list_parentheses = false -csharp_space_between_method_declaration_empty_parameter_list_parentheses = false -csharp_space_between_method_declaration_name_and_open_parenthesis = false -csharp_space_between_method_declaration_parameter_list_parentheses = false -csharp_space_between_parentheses = false -csharp_space_between_square_brackets = false - -# Wrapping preferences -csharp_preserve_single_line_blocks = true -csharp_preserve_single_line_statements = true - -#### Naming styles #### - -# Naming rules - -dotnet_naming_rule.interface_should_be_begins_with_i.severity = suggestion -dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface -dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i - -dotnet_naming_rule.types_should_be_pascal_case.severity = suggestion -dotnet_naming_rule.types_should_be_pascal_case.symbols = types -dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case - -dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = suggestion -dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members -dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case - -# Symbol specifications - -dotnet_naming_symbols.interface.applicable_kinds = interface -dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected -dotnet_naming_symbols.interface.required_modifiers = - -dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum -dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected -dotnet_naming_symbols.types.required_modifiers = - -dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method -dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected -dotnet_naming_symbols.non_field_members.required_modifiers = - -# Naming styles - -dotnet_naming_style.pascal_case.required_prefix = -dotnet_naming_style.pascal_case.required_suffix = -dotnet_naming_style.pascal_case.word_separator = -dotnet_naming_style.pascal_case.capitalization = pascal_case - -dotnet_naming_style.begins_with_i.required_prefix = I -dotnet_naming_style.begins_with_i.required_suffix = -dotnet_naming_style.begins_with_i.word_separator = -dotnet_naming_style.begins_with_i.capitalization = pascal_case -csharp_prefer_system_threading_lock = true:suggestion - -[*.{cs,vb}] -dotnet_style_operator_placement_when_wrapping = beginning_of_line -tab_width = 4 -indent_size = 4 -end_of_line = crlf -dotnet_style_coalesce_expression = true:suggestion -dotnet_style_null_propagation = true:suggestion -dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion -dotnet_style_prefer_auto_properties = true:silent -dotnet_style_object_initializer = true:suggestion -dotnet_style_collection_initializer = true:suggestion -dotnet_style_prefer_conditional_expression_over_assignment = true:silent -dotnet_style_prefer_simplified_boolean_expressions = true:suggestion -dotnet_style_prefer_conditional_expression_over_return = true:silent -dotnet_style_explicit_tuple_names = true:suggestion -dotnet_style_prefer_inferred_tuple_names = true:suggestion -dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion -dotnet_style_prefer_compound_assignment = true:suggestion -dotnet_style_prefer_simplified_interpolation = true:suggestion -dotnet_style_prefer_collection_expression = when_types_loosely_match:suggestion -dotnet_style_namespace_match_folder = true:suggestion -dotnet_style_readonly_field = true:suggestion -dotnet_style_predefined_type_for_locals_parameters_members = true:silent -dotnet_style_predefined_type_for_member_access = true:silent -dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent -dotnet_style_allow_multiple_blank_lines_experimental = true:silent -dotnet_style_allow_statement_immediately_after_block_experimental = true:silent -dotnet_code_quality_unused_parameters = all:suggestion -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_relational_binary_operators = always_for_clarity:silent -dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent -dotnet_style_qualification_for_field = false:silent -dotnet_style_qualification_for_property = true:silent -dotnet_style_qualification_for_method = false:silent -dotnet_style_qualification_for_event = false:silent -dotnet_style_lambda_parameter_parentheses = always - -[*.{c++,cc,cpp,cppm,cxx,h,h++,hh,hpp,hxx,inl,ipp,ixx,tlh,tli}] - -# Visual C++ Code Style settings - -cpp_generate_documentation_comments = xml - -# Visual C++ Formatting settings - -cpp_indent_braces = false -cpp_indent_multi_line_relative_to = innermost_parenthesis -cpp_indent_within_parentheses = indent -cpp_indent_preserve_within_parentheses = true -cpp_indent_case_contents = true -cpp_indent_case_labels = false -cpp_indent_case_contents_when_block = false -cpp_indent_lambda_braces_when_parameter = true -cpp_indent_goto_labels = one_left -cpp_indent_preprocessor = leftmost_column -cpp_indent_access_specifiers = false -cpp_indent_namespace_contents = true -cpp_indent_preserve_comments = false -cpp_new_line_before_open_brace_namespace = ignore -cpp_new_line_before_open_brace_type = ignore -cpp_new_line_before_open_brace_function = ignore -cpp_new_line_before_open_brace_block = ignore -cpp_new_line_before_open_brace_lambda = ignore -cpp_new_line_scope_braces_on_separate_lines = false -cpp_new_line_close_brace_same_line_empty_type = false -cpp_new_line_close_brace_same_line_empty_function = false -cpp_new_line_before_catch = true -cpp_new_line_before_else = true -cpp_new_line_before_while_in_do_while = false -cpp_space_before_function_open_parenthesis = remove -cpp_space_within_parameter_list_parentheses = false -cpp_space_between_empty_parameter_list_parentheses = false -cpp_space_after_keywords_in_control_flow_statements = true -cpp_space_within_control_flow_statement_parentheses = false -cpp_space_before_lambda_open_parenthesis = false -cpp_space_within_cast_parentheses = false -cpp_space_after_cast_close_parenthesis = false -cpp_space_within_expression_parentheses = false -cpp_space_before_block_open_brace = true -cpp_space_between_empty_braces = false -cpp_space_before_initializer_list_open_brace = false -cpp_space_within_initializer_list_braces = true -cpp_space_preserve_in_initializer_list = true -cpp_space_before_open_square_bracket = false -cpp_space_within_square_brackets = false -cpp_space_before_empty_square_brackets = false -cpp_space_between_empty_square_brackets = false -cpp_space_group_square_brackets = true -cpp_space_within_lambda_brackets = false -cpp_space_between_empty_lambda_brackets = false -cpp_space_before_comma = false -cpp_space_after_comma = true -cpp_space_remove_around_member_operators = true -cpp_space_before_inheritance_colon = true -cpp_space_before_constructor_colon = true -cpp_space_remove_before_semicolon = true -cpp_space_after_semicolon = true -cpp_space_remove_around_unary_operator = true -cpp_space_around_binary_operator = insert -cpp_space_around_assignment_operator = insert -cpp_space_pointer_reference_alignment = left -cpp_space_around_ternary_operator = insert -cpp_use_unreal_engine_macro_formatting = true -cpp_wrap_preserve_blocks = one_liners - -# Visual C++ Inlcude Cleanup settings - -cpp_include_cleanup_add_missing_error_tag_type = suggestion -cpp_include_cleanup_remove_unused_error_tag_type = dimmed -cpp_include_cleanup_optimize_unused_error_tag_type = suggestion -cpp_include_cleanup_sort_after_edits = false -cpp_sort_includes_error_tag_type = none -cpp_sort_includes_priority_case_sensitive = false -cpp_sort_includes_priority_style = quoted -cpp_includes_style = default -cpp_includes_use_forward_slash = true - diff --git a/Evently.slnx b/Evently.slnx index 438e79d..32d0618 100644 --- a/Evently.slnx +++ b/Evently.slnx @@ -1,22 +1,22 @@ - + - - - - - - + + + + + - + + - + - + \ No newline at end of file diff --git a/Makefile b/Makefile index f142e41..bd18157 100644 --- a/Makefile +++ b/Makefile @@ -15,7 +15,7 @@ remove-migration: fmt: dotnet tool restore - jb cleanupcode ./src/Evently.Server/**/* + cd src/Evently.Server && dotnet csharpier format . cd src/evently.client && npm run fmt docker-build-no-cache: diff --git a/dotnet-tools.json b/dotnet-tools.json new file mode 100644 index 0000000..03df232 --- /dev/null +++ b/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "csharpier": { + "version": "1.2.1", + "commands": [ + "csharpier" + ], + "rollForward": false + } + } +} \ No newline at end of file diff --git a/src/Evently.Server/.config/dotnet-tools.json b/src/Evently.Server/.config/dotnet-tools.json index c69ef0a..be20a98 100644 --- a/src/Evently.Server/.config/dotnet-tools.json +++ b/src/Evently.Server/.config/dotnet-tools.json @@ -8,6 +8,13 @@ "jb" ], "rollForward": false + }, + "csharpier": { + "version": "1.2.1", + "commands": [ + "csharpier" + ], + "rollForward": false } } } \ No newline at end of file diff --git a/src/Evently.Server/Common/Adapters/Data/AppDbContext.cs b/src/Evently.Server/Common/Adapters/Data/AppDbContext.cs deleted file mode 100644 index cbf9340..0000000 --- a/src/Evently.Server/Common/Adapters/Data/AppDbContext.cs +++ /dev/null @@ -1,290 +0,0 @@ -using Evently.Server.Common.Domains.Entities; -using Microsoft.AspNetCore.Identity.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using System.Reflection; - -namespace Evently.Server.Common.Adapters.Data; - -public class AppDbContext(DbContextOptions options) : IdentityDbContext(options) { - public DbSet Accounts { get; set; } - public DbSet Bookings { get; set; } - public DbSet Gatherings { get; set; } - public DbSet GatheringCategoryDetails { get; set; } - public DbSet Categories { get; set; } - - protected override void OnModelCreating(ModelBuilder modelBuilder) { - base.OnModelCreating(modelBuilder); - - // for unit testing, sqlite is used - if (Database.ProviderName == "Microsoft.EntityFrameworkCore.Sqlite") { - ConfigureSqlite(modelBuilder); - } - - // Postgres identity configuration - modelBuilder.Entity().Property(g => g.GatheringId) - .HasIdentityOptions(startValue: 20); - - modelBuilder.Entity().Property(c => c.CategoryId) - .HasIdentityOptions(startValue: 20); - - SeedData(modelBuilder); - } - - private static void SeedData(ModelBuilder modelBuilder) { - // Fixed Account IDs for referencing - const string hostUserId = "empty-user-12345"; - const string guestUserId = "guest-user-22222"; - - // Seed Accounts (without proper password hashes - they won't be able to login) - modelBuilder.Entity().HasData( - new Account { - Id = hostUserId, // Fixed constant ID - UserName = "empty_user", - NormalizedUserName = "EMPTY_USER", - Email = "empty@example.com", - NormalizedEmail = "EMPTY@EXAMPLE.COM", - Name = "Empty User", - EmailConfirmed = false, - PasswordHash = null, // No password - unusable account - SecurityStamp = "EMPTY-SECURITY-STAMP-12345", // Fixed constant - ConcurrencyStamp = "EMPTY-CONCURRENCY-STAMP-12345", // Fixed constant - PhoneNumber = null, - PhoneNumberConfirmed = false, - TwoFactorEnabled = false, - LockoutEnabled = true, - AccessFailedCount = 0, - }, - new Account { - Id = guestUserId, // Fixed constant ID - UserName = "guest_user2", - NormalizedUserName = "GUEST_USER_2", - Email = "guest@example.com", - NormalizedEmail = "GUEST@EXAMPLE.COM", - Name = "Guest User", - EmailConfirmed = false, - PasswordHash = null, // No password - unusable account - SecurityStamp = "EMPTY-SECURITY-STAMP-12345", // Fixed constant - ConcurrencyStamp = "EMPTY-CONCURRENCY-STAMP-12345", // Fixed constant - PhoneNumber = null, - PhoneNumberConfirmed = false, - TwoFactorEnabled = false, - LockoutEnabled = true, - AccessFailedCount = 0, - } - ); - - // Seed Categories - modelBuilder.Entity().HasData( - new Category { CategoryId = 1, CategoryName = "Information Technology" }, - new Category { CategoryId = 2, CategoryName = "Business & Networking" }, - new Category { CategoryId = 3, CategoryName = "Arts & Culture" } - ); - - // Singapore timezone offset - TimeSpan singaporeOffset = TimeSpan.Zero; - - // Seed Gatherings - modelBuilder.Entity().HasData( - new Gathering { - GatheringId = 1, - Name = "Tech Innovation Summit", - Description = "A comprehensive summit exploring the latest in AI and machine learning", - Location = "Marina Bay Sands Convention Centre, Singapore", - Start = new DateTimeOffset(year: 2026, month: 12, day: 5, hour: 9, minute: 0, second: 0, singaporeOffset), - End = new DateTimeOffset(year: 2026, month: 12, day: 5, hour: 17, minute: 0, second: 0, singaporeOffset), - OrganiserId = hostUserId, - }, - new Gathering { - GatheringId = 2, - Name = "Startup Networking Night", - Description = "Connect with fellow entrepreneurs and investors", - Location = "Clarke Quay Central, Singapore", - Start = new DateTimeOffset(year: 2026, month: 12, day: 10, hour: 18, minute: 30, second: 0, singaporeOffset), - End = new DateTimeOffset(year: 2026, month: 12, day: 10, hour: 22, minute: 0, second: 0, singaporeOffset), - OrganiserId = hostUserId, - }, - new Gathering { - GatheringId = 3, - Name = "Digital Art Exhibition", - Description = "Showcasing contemporary digital art from emerging artists", - Location = "National Gallery Singapore", - Start = new DateTimeOffset(year: 2026, month: 12, day: 15, hour: 10, minute: 0, second: 0, singaporeOffset), - End = new DateTimeOffset(year: 2026, month: 12, day: 15, hour: 18, minute: 0, second: 0, singaporeOffset), - OrganiserId = hostUserId, - }, - new Gathering { - GatheringId = 4, - Name = "Web Development Workshop", - Description = "Learn modern web development techniques and best practices", - Location = "Singapore Science Centre", - Start = new DateTimeOffset(year: 2026, month: 12, day: 8, hour: 13, minute: 0, second: 0, singaporeOffset), - End = new DateTimeOffset(year: 2026, month: 12, day: 8, hour: 17, minute: 0, second: 0, singaporeOffset), - OrganiserId = guestUserId, - }, - new Gathering { - GatheringId = 5, - Name = "Business Strategy Seminar", - Description = "Advanced strategies for scaling your business", - Location = "Raffles City Convention Centre, Singapore", - Start = new DateTimeOffset(year: 2026, month: 12, day: 20, hour: 14, minute: 0, second: 0, singaporeOffset), - End = new DateTimeOffset(year: 2026, month: 12, day: 20, hour: 16, minute: 30, second: 0, singaporeOffset), - OrganiserId = guestUserId, - }, - new Gathering { - GatheringId = 6, - Name = "Photography Masterclass", - Description = "Professional photography techniques and portfolio building", - Location = "Gardens by the Bay, Singapore", - Start = new DateTimeOffset(year: 2026, month: 12, day: 22, hour: 8, minute: 0, second: 0, singaporeOffset), - End = new DateTimeOffset(year: 2026, month: 12, day: 22, hour: 12, minute: 0, second: 0, singaporeOffset), - OrganiserId = guestUserId, - }, - new Gathering { - GatheringId = 7, - Name = "Mobile App Development Bootcamp", - Description = "Intensive bootcamp covering iOS and Android development", - Location = "NUS School of Computing, Singapore", - Start = new DateTimeOffset(year: 2026, month: 12, day: 12, hour: 9, minute: 0, second: 0, singaporeOffset), - End = new DateTimeOffset(year: 2026, month: 12, day: 12, hour: 18, minute: 0, second: 0, singaporeOffset), - OrganiserId = hostUserId, - }, - new Gathering { - GatheringId = 8, - Name = "Investment & Finance Forum", - Description = "Learn about personal finance and investment strategies", - Location = "Suntec Singapore Convention Centre", - Start = new DateTimeOffset(year: 2026, month: 12, day: 25, hour: 14, minute: 0, second: 0, singaporeOffset), - End = new DateTimeOffset(year: 2026, month: 12, day: 25, hour: 17, minute: 30, second: 0, singaporeOffset), - OrganiserId = hostUserId, - }, - new Gathering { - GatheringId = 9, - Name = "Creative Writing Workshop", - Description = "Explore storytelling techniques and creative expression", - Location = "Esplanade Theatres, Singapore", - Start = new DateTimeOffset(year: 2026, month: 12, day: 28, hour: 10, minute: 0, second: 0, singaporeOffset), - End = new DateTimeOffset(year: 2026, month: 12, day: 28, hour: 15, minute: 0, second: 0, singaporeOffset), - OrganiserId = guestUserId, - }, - new Gathering { - GatheringId = 10, - Name = "Cloud Computing Conference", - Description = "Latest trends in cloud architecture and DevOps", - Location = "Singapore EXPO", - Start = new DateTimeOffset(year: 2026, month: 12, day: 30, hour: 9, minute: 30, second: 0, singaporeOffset), - End = new DateTimeOffset(year: 2026, month: 12, day: 30, hour: 17, minute: 30, second: 0, singaporeOffset), - OrganiserId = hostUserId, - }, - new Gathering { - GatheringId = 11, - Name = "E-commerce Mastery", - Description = "Build and scale your online business effectively", - Location = "Marina Bay Financial Centre, Singapore", - Start = new DateTimeOffset(year: 2027, month: 1, day: 3, hour: 13, minute: 0, second: 0, singaporeOffset), - End = new DateTimeOffset(year: 2027, month: 1, day: 3, hour: 18, minute: 0, second: 0, singaporeOffset), - OrganiserId = guestUserId, - }, - new Gathering { - GatheringId = 12, - Name = "Contemporary Dance Performance", - Description = "An evening of modern dance and artistic expression", - Location = "Victoria Theatre, Singapore", - Start = new DateTimeOffset(year: 2027, month: 1, day: 5, hour: 19, minute: 30, second: 0, singaporeOffset), - End = new DateTimeOffset(year: 2027, month: 1, day: 5, hour: 22, minute: 0, second: 0, singaporeOffset), - OrganiserId = hostUserId, - }, - new Gathering { - GatheringId = 13, - Name = "Cybersecurity Awareness Training", - Description = "Essential cybersecurity practices for businesses", - Location = "Singapore Management University", - Start = new DateTimeOffset(year: 2027, month: 1, day: 8, hour: 10, minute: 0, second: 0, singaporeOffset), - End = new DateTimeOffset(year: 2027, month: 1, day: 8, hour: 16, minute: 0, second: 0, singaporeOffset), - OrganiserId = guestUserId, - }, - new Gathering { - GatheringId = 14, - Name = "Leadership Excellence Workshop", - Description = "Develop essential leadership skills for modern managers", - Location = "Orchard Hotel Singapore", - Start = new DateTimeOffset(year: 2027, month: 1, day: 10, hour: 9, minute: 0, second: 0, singaporeOffset), - End = new DateTimeOffset(year: 2027, month: 1, day: 10, hour: 17, minute: 0, second: 0, singaporeOffset), - OrganiserId = hostUserId, - }, - new Gathering { - GatheringId = 15, - Name = "Film & Media Production Showcase", - Description = "Independent filmmakers present their latest works", - Location = "Singapore International Film Festival Venue", - Start = new DateTimeOffset(year: 2027, month: 1, day: 12, hour: 18, minute: 0, second: 0, singaporeOffset), - End = new DateTimeOffset(year: 2027, month: 1, day: 12, hour: 23, minute: 0, second: 0, singaporeOffset), - OrganiserId = guestUserId, - } - ); - - // Seed GatheringCategoryDetails - modelBuilder.Entity().HasData( - new GatheringCategoryDetail { GatheringId = 1, CategoryId = 1 }, // Tech Innovation Summit -> IT - new GatheringCategoryDetail { GatheringId = 2, CategoryId = 2 }, // Startup Networking Night -> Business - new GatheringCategoryDetail { GatheringId = 3, CategoryId = 3 }, // Digital Art Exhibition -> Arts - new GatheringCategoryDetail { GatheringId = 4, CategoryId = 1 }, // Web Development Workshop -> IT - new GatheringCategoryDetail { GatheringId = 5, CategoryId = 2 }, // Business Strategy Seminar -> Business - new GatheringCategoryDetail { GatheringId = 6, CategoryId = 3 }, // Photography Masterclass -> Arts - new GatheringCategoryDetail { GatheringId = 7, CategoryId = 1 }, // Mobile App Development Bootcamp -> IT - new GatheringCategoryDetail { GatheringId = 8, CategoryId = 2 }, // Investment & Finance Forum -> Business - new GatheringCategoryDetail { GatheringId = 9, CategoryId = 3 }, // Creative Writing Workshop -> Arts - new GatheringCategoryDetail { GatheringId = 10, CategoryId = 1 }, // Cloud Computing Conference -> IT - new GatheringCategoryDetail { GatheringId = 11, CategoryId = 2 }, // E-commerce Mastery -> Business - new GatheringCategoryDetail { GatheringId = 12, CategoryId = 3 }, // Contemporary Dance Performance -> Arts - new GatheringCategoryDetail { GatheringId = 13, CategoryId = 1 }, // Cybersecurity Awareness Training -> IT - new GatheringCategoryDetail { GatheringId = 14, CategoryId = 2 }, // Leadership Excellence Workshop -> Business - new GatheringCategoryDetail { GatheringId = 15, CategoryId = 3 } // Film & Media Production Showcase -> Arts - ); - - // Seed Bookings with fixed DateTimeOffset values - // Fixed DateTimeOffset for seeding (static value) - DateTimeOffset fixedCreationTime = new(year: 2025, month: 1, day: 1, hour: 0, minute: 0, second: 0, TimeSpan.Zero); - - modelBuilder.Entity().HasData( - new Booking { - BookingId = "book_abc123456", - GatheringId = 1, - AttendeeId = guestUserId, - CreationDateTime = fixedCreationTime, - CheckInDateTime = null, - CheckoutDateTime = null, - CancellationDateTime = null, - }, - new Booking { - BookingId = "book_def789012", - GatheringId = 2, - AttendeeId = hostUserId, - CreationDateTime = fixedCreationTime.AddHours(1), // Slightly different time - CheckInDateTime = null, - CheckoutDateTime = null, - CancellationDateTime = null, - } - ); - } - - // https://stackoverflow.com/a/76152994/6514532 - private static void ConfigureSqlite(ModelBuilder modelBuilder) { - // SQLite does not have proper support for DateTimeOffset via Entity Framework Core, - // see the limitations here: https://docs.microsoft.com/en-us/ef/core/providers/sqlite/limitations#query-limitations. - // Based on: https://github.com/aspnet/EntityFrameworkCore/issues/10784#issuecomment-415769754. - foreach (IMutableEntityType entityType in modelBuilder.Model.GetEntityTypes()) { - IEnumerable properties = entityType.ClrType - .GetProperties() - .Where(p => p.PropertyType == typeof(DateTimeOffset) - || p.PropertyType == typeof(DateTimeOffset?)); - foreach (PropertyInfo property in properties) { - modelBuilder - .Entity(entityType.Name) - .Property(property.Name) - .HasConversion(new DateTimeOffsetToBinaryConverter()); - } - } - } -} \ No newline at end of file diff --git a/src/Evently.Server/Common/Adapters/Data/Migrations/20250913035915_SQLServer.cs b/src/Evently.Server/Common/Adapters/Data/Migrations/20250913035915_SQLServer.cs deleted file mode 100644 index 710a20a..0000000 --- a/src/Evently.Server/Common/Adapters/Data/Migrations/20250913035915_SQLServer.cs +++ /dev/null @@ -1,413 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional - -namespace Evently.Server.Common.Adapters.Data.Migrations -{ - /// - public partial class SQLServer : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "AspNetRoles", - columns: table => new - { - Id = table.Column(type: "nvarchar(450)", nullable: false), - Name = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), - NormalizedName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), - ConcurrencyStamp = table.Column(type: "nvarchar(max)", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_AspNetRoles", x => x.Id); - }); - - migrationBuilder.CreateTable( - name: "AspNetUsers", - columns: table => new - { - Id = table.Column(type: "nvarchar(450)", nullable: false), - Name = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), - UserName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), - NormalizedUserName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), - Email = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), - NormalizedEmail = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), - EmailConfirmed = table.Column(type: "bit", nullable: false), - PasswordHash = table.Column(type: "nvarchar(max)", nullable: true), - SecurityStamp = table.Column(type: "nvarchar(max)", nullable: true), - ConcurrencyStamp = table.Column(type: "nvarchar(max)", nullable: true), - PhoneNumber = table.Column(type: "nvarchar(max)", nullable: true), - PhoneNumberConfirmed = table.Column(type: "bit", nullable: false), - TwoFactorEnabled = table.Column(type: "bit", nullable: false), - LockoutEnd = table.Column(type: "datetimeoffset", nullable: true), - LockoutEnabled = table.Column(type: "bit", nullable: false), - AccessFailedCount = table.Column(type: "int", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_AspNetUsers", x => x.Id); - }); - - migrationBuilder.CreateTable( - name: "Categories", - columns: table => new - { - CategoryId = table.Column(type: "bigint", nullable: false) - .Annotation("SqlServer:Identity", "1, 1"), - CategoryName = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), - Approved = table.Column(type: "bit", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_Categories", x => x.CategoryId); - }); - - migrationBuilder.CreateTable( - name: "Gatherings", - columns: table => new - { - GatheringId = table.Column(type: "bigint", nullable: false) - .Annotation("SqlServer:Identity", "1, 1"), - Name = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), - Description = table.Column(type: "nvarchar(max)", maxLength: 10000, nullable: false), - Start = table.Column(type: "datetimeoffset", nullable: false), - End = table.Column(type: "datetimeoffset", nullable: false), - Location = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), - CoverSrc = table.Column(type: "nvarchar(1000)", maxLength: 1000, nullable: true), - OrganiserId = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), - CancellationDateTime = table.Column(type: "datetimeoffset", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_Gatherings", x => x.GatheringId); - }); - - migrationBuilder.CreateTable( - name: "AspNetRoleClaims", - columns: table => new - { - Id = table.Column(type: "int", nullable: false) - .Annotation("SqlServer:Identity", "1, 1"), - RoleId = table.Column(type: "nvarchar(450)", nullable: false), - ClaimType = table.Column(type: "nvarchar(max)", nullable: true), - ClaimValue = table.Column(type: "nvarchar(max)", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id); - table.ForeignKey( - name: "FK_AspNetRoleClaims_AspNetRoles_RoleId", - column: x => x.RoleId, - principalTable: "AspNetRoles", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "AspNetUserClaims", - columns: table => new - { - Id = table.Column(type: "int", nullable: false) - .Annotation("SqlServer:Identity", "1, 1"), - UserId = table.Column(type: "nvarchar(450)", nullable: false), - ClaimType = table.Column(type: "nvarchar(max)", nullable: true), - ClaimValue = table.Column(type: "nvarchar(max)", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_AspNetUserClaims", x => x.Id); - table.ForeignKey( - name: "FK_AspNetUserClaims_AspNetUsers_UserId", - column: x => x.UserId, - principalTable: "AspNetUsers", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "AspNetUserLogins", - columns: table => new - { - LoginProvider = table.Column(type: "nvarchar(450)", nullable: false), - ProviderKey = table.Column(type: "nvarchar(450)", nullable: false), - ProviderDisplayName = table.Column(type: "nvarchar(max)", nullable: true), - UserId = table.Column(type: "nvarchar(450)", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey }); - table.ForeignKey( - name: "FK_AspNetUserLogins_AspNetUsers_UserId", - column: x => x.UserId, - principalTable: "AspNetUsers", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "AspNetUserRoles", - columns: table => new - { - UserId = table.Column(type: "nvarchar(450)", nullable: false), - RoleId = table.Column(type: "nvarchar(450)", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId }); - table.ForeignKey( - name: "FK_AspNetUserRoles_AspNetRoles_RoleId", - column: x => x.RoleId, - principalTable: "AspNetRoles", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - table.ForeignKey( - name: "FK_AspNetUserRoles_AspNetUsers_UserId", - column: x => x.UserId, - principalTable: "AspNetUsers", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "AspNetUserTokens", - columns: table => new - { - UserId = table.Column(type: "nvarchar(450)", nullable: false), - LoginProvider = table.Column(type: "nvarchar(450)", nullable: false), - Name = table.Column(type: "nvarchar(450)", nullable: false), - Value = table.Column(type: "nvarchar(max)", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name }); - table.ForeignKey( - name: "FK_AspNetUserTokens_AspNetUsers_UserId", - column: x => x.UserId, - principalTable: "AspNetUsers", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "Bookings", - columns: table => new - { - BookingId = table.Column(type: "nvarchar(50)", maxLength: 50, nullable: false), - AttendeeId = table.Column(type: "nvarchar(450)", nullable: false), - GatheringId = table.Column(type: "bigint", nullable: false), - CreationDateTime = table.Column(type: "datetimeoffset", nullable: false), - CheckInDateTime = table.Column(type: "datetimeoffset", nullable: true), - CheckoutDateTime = table.Column(type: "datetimeoffset", nullable: true), - CancellationDateTime = table.Column(type: "datetimeoffset", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_Bookings", x => x.BookingId); - table.ForeignKey( - name: "FK_Bookings_AspNetUsers_AttendeeId", - column: x => x.AttendeeId, - principalTable: "AspNetUsers", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - table.ForeignKey( - name: "FK_Bookings_Gatherings_GatheringId", - column: x => x.GatheringId, - principalTable: "Gatherings", - principalColumn: "GatheringId", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "GatheringCategoryDetails", - columns: table => new - { - GatheringId = table.Column(type: "bigint", nullable: false), - CategoryId = table.Column(type: "bigint", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_GatheringCategoryDetails", x => new { x.GatheringId, x.CategoryId }); - table.ForeignKey( - name: "FK_GatheringCategoryDetails_Categories_CategoryId", - column: x => x.CategoryId, - principalTable: "Categories", - principalColumn: "CategoryId", - onDelete: ReferentialAction.Cascade); - table.ForeignKey( - name: "FK_GatheringCategoryDetails_Gatherings_GatheringId", - column: x => x.GatheringId, - principalTable: "Gatherings", - principalColumn: "GatheringId", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.InsertData( - table: "AspNetUsers", - columns: new[] { "Id", "AccessFailedCount", "ConcurrencyStamp", "Email", "EmailConfirmed", "LockoutEnabled", "LockoutEnd", "Name", "NormalizedEmail", "NormalizedUserName", "PasswordHash", "PhoneNumber", "PhoneNumberConfirmed", "SecurityStamp", "TwoFactorEnabled", "UserName" }, - values: new object[,] - { - { "empty-user-12345", 0, "EMPTY-CONCURRENCY-STAMP-12345", "empty@example.com", false, true, null, "Empty User", "EMPTY@EXAMPLE.COM", "EMPTY_USER", null, null, false, "EMPTY-SECURITY-STAMP-12345", false, "empty_user" }, - { "guest-user-22222", 0, "EMPTY-CONCURRENCY-STAMP-12345", "guest@example.com", false, true, null, "Guest User", "GUEST@EXAMPLE.COM", "GUEST_USER_2", null, null, false, "EMPTY-SECURITY-STAMP-12345", false, "guest_user2" } - }); - - migrationBuilder.InsertData( - table: "Categories", - columns: new[] { "CategoryId", "Approved", "CategoryName" }, - values: new object[,] - { - { 1L, false, "Information Technology" }, - { 2L, false, "Business & Networking" }, - { 3L, false, "Arts & Culture" } - }); - - migrationBuilder.InsertData( - table: "Gatherings", - columns: new[] { "GatheringId", "CancellationDateTime", "CoverSrc", "Description", "End", "Location", "Name", "OrganiserId", "Start" }, - values: new object[,] - { - { 1L, null, "", "A comprehensive summit exploring the latest in AI and machine learning", new DateTimeOffset(new DateTime(2025, 12, 5, 17, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), "Marina Bay Sands Convention Centre, Singapore", "Tech Innovation Summit", "empty-user-12345", new DateTimeOffset(new DateTime(2025, 12, 5, 9, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) }, - { 2L, null, "", "Connect with fellow entrepreneurs and investors", new DateTimeOffset(new DateTime(2025, 12, 10, 22, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), "Clarke Quay Central, Singapore", "Startup Networking Night", "empty-user-12345", new DateTimeOffset(new DateTime(2025, 12, 10, 18, 30, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) }, - { 3L, null, "", "Showcasing contemporary digital art from emerging artists", new DateTimeOffset(new DateTime(2025, 12, 15, 18, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), "National Gallery Singapore", "Digital Art Exhibition", "empty-user-12345", new DateTimeOffset(new DateTime(2025, 12, 15, 10, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) }, - { 4L, null, "", "Learn modern web development techniques and best practices", new DateTimeOffset(new DateTime(2025, 12, 8, 17, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), "Singapore Science Centre", "Web Development Workshop", "guest-user-22222", new DateTimeOffset(new DateTime(2025, 12, 8, 13, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) }, - { 5L, null, "", "Advanced strategies for scaling your business", new DateTimeOffset(new DateTime(2025, 12, 20, 16, 30, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), "Raffles City Convention Centre, Singapore", "Business Strategy Seminar", "guest-user-22222", new DateTimeOffset(new DateTime(2025, 12, 20, 14, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) }, - { 6L, null, "", "Professional photography techniques and portfolio building", new DateTimeOffset(new DateTime(2025, 12, 22, 12, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), "Gardens by the Bay, Singapore", "Photography Masterclass", "guest-user-22222", new DateTimeOffset(new DateTime(2025, 12, 22, 8, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) }, - { 7L, null, "", "Intensive bootcamp covering iOS and Android development", new DateTimeOffset(new DateTime(2025, 12, 12, 18, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), "NUS School of Computing, Singapore", "Mobile App Development Bootcamp", "empty-user-12345", new DateTimeOffset(new DateTime(2025, 12, 12, 9, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) }, - { 8L, null, "", "Learn about personal finance and investment strategies", new DateTimeOffset(new DateTime(2025, 12, 25, 17, 30, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), "Suntec Singapore Convention Centre", "Investment & Finance Forum", "empty-user-12345", new DateTimeOffset(new DateTime(2025, 12, 25, 14, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) }, - { 9L, null, "", "Explore storytelling techniques and creative expression", new DateTimeOffset(new DateTime(2025, 12, 28, 15, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), "Esplanade Theatres, Singapore", "Creative Writing Workshop", "guest-user-22222", new DateTimeOffset(new DateTime(2025, 12, 28, 10, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) }, - { 10L, null, "", "Latest trends in cloud architecture and DevOps", new DateTimeOffset(new DateTime(2025, 12, 30, 17, 30, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), "Singapore EXPO", "Cloud Computing Conference", "empty-user-12345", new DateTimeOffset(new DateTime(2025, 12, 30, 9, 30, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) }, - { 11L, null, "", "Build and scale your online business effectively", new DateTimeOffset(new DateTime(2026, 1, 3, 18, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), "Marina Bay Financial Centre, Singapore", "E-commerce Mastery", "guest-user-22222", new DateTimeOffset(new DateTime(2026, 1, 3, 13, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) }, - { 12L, null, "", "An evening of modern dance and artistic expression", new DateTimeOffset(new DateTime(2026, 1, 5, 22, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), "Victoria Theatre, Singapore", "Contemporary Dance Performance", "empty-user-12345", new DateTimeOffset(new DateTime(2026, 1, 5, 19, 30, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) }, - { 13L, null, "", "Essential cybersecurity practices for businesses", new DateTimeOffset(new DateTime(2026, 1, 8, 16, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), "Singapore Management University", "Cybersecurity Awareness Training", "guest-user-22222", new DateTimeOffset(new DateTime(2026, 1, 8, 10, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) }, - { 14L, null, "", "Develop essential leadership skills for modern managers", new DateTimeOffset(new DateTime(2026, 1, 10, 17, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), "Orchard Hotel Singapore", "Leadership Excellence Workshop", "empty-user-12345", new DateTimeOffset(new DateTime(2026, 1, 10, 9, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) }, - { 15L, null, "", "Independent filmmakers present their latest works", new DateTimeOffset(new DateTime(2026, 1, 12, 23, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), "Singapore International Film Festival Venue", "Film & Media Production Showcase", "guest-user-22222", new DateTimeOffset(new DateTime(2026, 1, 12, 18, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) } - }); - - migrationBuilder.InsertData( - table: "Bookings", - columns: new[] { "BookingId", "AttendeeId", "CancellationDateTime", "CheckInDateTime", "CheckoutDateTime", "CreationDateTime", "GatheringId" }, - values: new object[,] - { - { "book_abc123456", "guest-user-22222", null, null, null, new DateTimeOffset(new DateTime(2024, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), 1L }, - { "book_def789012", "empty-user-12345", null, null, null, new DateTimeOffset(new DateTime(2024, 1, 1, 1, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), 2L } - }); - - migrationBuilder.InsertData( - table: "GatheringCategoryDetails", - columns: new[] { "CategoryId", "GatheringId" }, - values: new object[,] - { - { 1L, 1L }, - { 2L, 2L }, - { 3L, 3L }, - { 1L, 4L }, - { 2L, 5L }, - { 3L, 6L }, - { 1L, 7L }, - { 2L, 8L }, - { 3L, 9L }, - { 1L, 10L }, - { 2L, 11L }, - { 3L, 12L }, - { 1L, 13L }, - { 2L, 14L }, - { 3L, 15L } - }); - - migrationBuilder.CreateIndex( - name: "IX_AspNetRoleClaims_RoleId", - table: "AspNetRoleClaims", - column: "RoleId"); - - migrationBuilder.CreateIndex( - name: "RoleNameIndex", - table: "AspNetRoles", - column: "NormalizedName", - unique: true, - filter: "[NormalizedName] IS NOT NULL"); - - migrationBuilder.CreateIndex( - name: "IX_AspNetUserClaims_UserId", - table: "AspNetUserClaims", - column: "UserId"); - - migrationBuilder.CreateIndex( - name: "IX_AspNetUserLogins_UserId", - table: "AspNetUserLogins", - column: "UserId"); - - migrationBuilder.CreateIndex( - name: "IX_AspNetUserRoles_RoleId", - table: "AspNetUserRoles", - column: "RoleId"); - - migrationBuilder.CreateIndex( - name: "EmailIndex", - table: "AspNetUsers", - column: "NormalizedEmail"); - - migrationBuilder.CreateIndex( - name: "UserNameIndex", - table: "AspNetUsers", - column: "NormalizedUserName", - unique: true, - filter: "[NormalizedUserName] IS NOT NULL"); - - migrationBuilder.CreateIndex( - name: "IX_Bookings_AttendeeId", - table: "Bookings", - column: "AttendeeId"); - - migrationBuilder.CreateIndex( - name: "IX_Bookings_GatheringId", - table: "Bookings", - column: "GatheringId"); - - migrationBuilder.CreateIndex( - name: "IX_GatheringCategoryDetails_CategoryId", - table: "GatheringCategoryDetails", - column: "CategoryId"); - } - - /// - 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: "Bookings"); - - migrationBuilder.DropTable( - name: "GatheringCategoryDetails"); - - migrationBuilder.DropTable( - name: "AspNetRoles"); - - migrationBuilder.DropTable( - name: "AspNetUsers"); - - migrationBuilder.DropTable( - name: "Categories"); - - migrationBuilder.DropTable( - name: "Gatherings"); - } - } -} diff --git a/src/Evently.Server/Common/Adapters/Data/Migrations/20250927053802_UpdateSeededDates.cs b/src/Evently.Server/Common/Adapters/Data/Migrations/20250927053802_UpdateSeededDates.cs deleted file mode 100644 index ddaadcc..0000000 --- a/src/Evently.Server/Common/Adapters/Data/Migrations/20250927053802_UpdateSeededDates.cs +++ /dev/null @@ -1,257 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Evently.Server.Common.Adapters.Data.Migrations -{ - /// - public partial class UpdateSeededDates : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.UpdateData( - table: "Bookings", - keyColumn: "BookingId", - keyValue: "book_abc123456", - column: "CreationDateTime", - value: new DateTimeOffset(new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0))); - - migrationBuilder.UpdateData( - table: "Bookings", - keyColumn: "BookingId", - keyValue: "book_def789012", - column: "CreationDateTime", - value: new DateTimeOffset(new DateTime(2025, 1, 1, 1, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0))); - - migrationBuilder.UpdateData( - table: "Gatherings", - keyColumn: "GatheringId", - keyValue: 1L, - columns: new[] { "End", "Start" }, - values: new object[] { new DateTimeOffset(new DateTime(2026, 12, 5, 17, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), new DateTimeOffset(new DateTime(2026, 12, 5, 9, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) }); - - migrationBuilder.UpdateData( - table: "Gatherings", - keyColumn: "GatheringId", - keyValue: 2L, - columns: new[] { "End", "Start" }, - values: new object[] { new DateTimeOffset(new DateTime(2026, 12, 10, 22, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), new DateTimeOffset(new DateTime(2026, 12, 10, 18, 30, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) }); - - migrationBuilder.UpdateData( - table: "Gatherings", - keyColumn: "GatheringId", - keyValue: 3L, - columns: new[] { "End", "Start" }, - values: new object[] { new DateTimeOffset(new DateTime(2026, 12, 15, 18, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), new DateTimeOffset(new DateTime(2026, 12, 15, 10, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) }); - - migrationBuilder.UpdateData( - table: "Gatherings", - keyColumn: "GatheringId", - keyValue: 4L, - columns: new[] { "End", "Start" }, - values: new object[] { new DateTimeOffset(new DateTime(2026, 12, 8, 17, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), new DateTimeOffset(new DateTime(2026, 12, 8, 13, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) }); - - migrationBuilder.UpdateData( - table: "Gatherings", - keyColumn: "GatheringId", - keyValue: 5L, - columns: new[] { "End", "Start" }, - values: new object[] { new DateTimeOffset(new DateTime(2026, 12, 20, 16, 30, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), new DateTimeOffset(new DateTime(2026, 12, 20, 14, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) }); - - migrationBuilder.UpdateData( - table: "Gatherings", - keyColumn: "GatheringId", - keyValue: 6L, - columns: new[] { "End", "Start" }, - values: new object[] { new DateTimeOffset(new DateTime(2026, 12, 22, 12, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), new DateTimeOffset(new DateTime(2026, 12, 22, 8, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) }); - - migrationBuilder.UpdateData( - table: "Gatherings", - keyColumn: "GatheringId", - keyValue: 7L, - columns: new[] { "End", "Start" }, - values: new object[] { new DateTimeOffset(new DateTime(2026, 12, 12, 18, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), new DateTimeOffset(new DateTime(2026, 12, 12, 9, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) }); - - migrationBuilder.UpdateData( - table: "Gatherings", - keyColumn: "GatheringId", - keyValue: 8L, - columns: new[] { "End", "Start" }, - values: new object[] { new DateTimeOffset(new DateTime(2026, 12, 25, 17, 30, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), new DateTimeOffset(new DateTime(2026, 12, 25, 14, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) }); - - migrationBuilder.UpdateData( - table: "Gatherings", - keyColumn: "GatheringId", - keyValue: 9L, - columns: new[] { "End", "Start" }, - values: new object[] { new DateTimeOffset(new DateTime(2026, 12, 28, 15, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), new DateTimeOffset(new DateTime(2026, 12, 28, 10, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) }); - - migrationBuilder.UpdateData( - table: "Gatherings", - keyColumn: "GatheringId", - keyValue: 10L, - columns: new[] { "End", "Start" }, - values: new object[] { new DateTimeOffset(new DateTime(2026, 12, 30, 17, 30, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), new DateTimeOffset(new DateTime(2026, 12, 30, 9, 30, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) }); - - migrationBuilder.UpdateData( - table: "Gatherings", - keyColumn: "GatheringId", - keyValue: 11L, - columns: new[] { "End", "Start" }, - values: new object[] { new DateTimeOffset(new DateTime(2027, 1, 3, 18, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), new DateTimeOffset(new DateTime(2027, 1, 3, 13, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) }); - - migrationBuilder.UpdateData( - table: "Gatherings", - keyColumn: "GatheringId", - keyValue: 12L, - columns: new[] { "End", "Start" }, - values: new object[] { new DateTimeOffset(new DateTime(2027, 1, 5, 22, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), new DateTimeOffset(new DateTime(2027, 1, 5, 19, 30, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) }); - - migrationBuilder.UpdateData( - table: "Gatherings", - keyColumn: "GatheringId", - keyValue: 13L, - columns: new[] { "End", "Start" }, - values: new object[] { new DateTimeOffset(new DateTime(2027, 1, 8, 16, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), new DateTimeOffset(new DateTime(2027, 1, 8, 10, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) }); - - migrationBuilder.UpdateData( - table: "Gatherings", - keyColumn: "GatheringId", - keyValue: 14L, - columns: new[] { "End", "Start" }, - values: new object[] { new DateTimeOffset(new DateTime(2027, 1, 10, 17, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), new DateTimeOffset(new DateTime(2027, 1, 10, 9, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) }); - - migrationBuilder.UpdateData( - table: "Gatherings", - keyColumn: "GatheringId", - keyValue: 15L, - columns: new[] { "End", "Start" }, - values: new object[] { new DateTimeOffset(new DateTime(2027, 1, 12, 23, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), new DateTimeOffset(new DateTime(2027, 1, 12, 18, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) }); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.UpdateData( - table: "Bookings", - keyColumn: "BookingId", - keyValue: "book_abc123456", - column: "CreationDateTime", - value: new DateTimeOffset(new DateTime(2024, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0))); - - migrationBuilder.UpdateData( - table: "Bookings", - keyColumn: "BookingId", - keyValue: "book_def789012", - column: "CreationDateTime", - value: new DateTimeOffset(new DateTime(2024, 1, 1, 1, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0))); - - migrationBuilder.UpdateData( - table: "Gatherings", - keyColumn: "GatheringId", - keyValue: 1L, - columns: new[] { "End", "Start" }, - values: new object[] { new DateTimeOffset(new DateTime(2025, 12, 5, 17, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), new DateTimeOffset(new DateTime(2025, 12, 5, 9, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) }); - - migrationBuilder.UpdateData( - table: "Gatherings", - keyColumn: "GatheringId", - keyValue: 2L, - columns: new[] { "End", "Start" }, - values: new object[] { new DateTimeOffset(new DateTime(2025, 12, 10, 22, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), new DateTimeOffset(new DateTime(2025, 12, 10, 18, 30, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) }); - - migrationBuilder.UpdateData( - table: "Gatherings", - keyColumn: "GatheringId", - keyValue: 3L, - columns: new[] { "End", "Start" }, - values: new object[] { new DateTimeOffset(new DateTime(2025, 12, 15, 18, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), new DateTimeOffset(new DateTime(2025, 12, 15, 10, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) }); - - migrationBuilder.UpdateData( - table: "Gatherings", - keyColumn: "GatheringId", - keyValue: 4L, - columns: new[] { "End", "Start" }, - values: new object[] { new DateTimeOffset(new DateTime(2025, 12, 8, 17, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), new DateTimeOffset(new DateTime(2025, 12, 8, 13, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) }); - - migrationBuilder.UpdateData( - table: "Gatherings", - keyColumn: "GatheringId", - keyValue: 5L, - columns: new[] { "End", "Start" }, - values: new object[] { new DateTimeOffset(new DateTime(2025, 12, 20, 16, 30, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), new DateTimeOffset(new DateTime(2025, 12, 20, 14, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) }); - - migrationBuilder.UpdateData( - table: "Gatherings", - keyColumn: "GatheringId", - keyValue: 6L, - columns: new[] { "End", "Start" }, - values: new object[] { new DateTimeOffset(new DateTime(2025, 12, 22, 12, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), new DateTimeOffset(new DateTime(2025, 12, 22, 8, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) }); - - migrationBuilder.UpdateData( - table: "Gatherings", - keyColumn: "GatheringId", - keyValue: 7L, - columns: new[] { "End", "Start" }, - values: new object[] { new DateTimeOffset(new DateTime(2025, 12, 12, 18, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), new DateTimeOffset(new DateTime(2025, 12, 12, 9, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) }); - - migrationBuilder.UpdateData( - table: "Gatherings", - keyColumn: "GatheringId", - keyValue: 8L, - columns: new[] { "End", "Start" }, - values: new object[] { new DateTimeOffset(new DateTime(2025, 12, 25, 17, 30, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), new DateTimeOffset(new DateTime(2025, 12, 25, 14, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) }); - - migrationBuilder.UpdateData( - table: "Gatherings", - keyColumn: "GatheringId", - keyValue: 9L, - columns: new[] { "End", "Start" }, - values: new object[] { new DateTimeOffset(new DateTime(2025, 12, 28, 15, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), new DateTimeOffset(new DateTime(2025, 12, 28, 10, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) }); - - migrationBuilder.UpdateData( - table: "Gatherings", - keyColumn: "GatheringId", - keyValue: 10L, - columns: new[] { "End", "Start" }, - values: new object[] { new DateTimeOffset(new DateTime(2025, 12, 30, 17, 30, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), new DateTimeOffset(new DateTime(2025, 12, 30, 9, 30, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) }); - - migrationBuilder.UpdateData( - table: "Gatherings", - keyColumn: "GatheringId", - keyValue: 11L, - columns: new[] { "End", "Start" }, - values: new object[] { new DateTimeOffset(new DateTime(2026, 1, 3, 18, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), new DateTimeOffset(new DateTime(2026, 1, 3, 13, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) }); - - migrationBuilder.UpdateData( - table: "Gatherings", - keyColumn: "GatheringId", - keyValue: 12L, - columns: new[] { "End", "Start" }, - values: new object[] { new DateTimeOffset(new DateTime(2026, 1, 5, 22, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), new DateTimeOffset(new DateTime(2026, 1, 5, 19, 30, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) }); - - migrationBuilder.UpdateData( - table: "Gatherings", - keyColumn: "GatheringId", - keyValue: 13L, - columns: new[] { "End", "Start" }, - values: new object[] { new DateTimeOffset(new DateTime(2026, 1, 8, 16, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), new DateTimeOffset(new DateTime(2026, 1, 8, 10, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) }); - - migrationBuilder.UpdateData( - table: "Gatherings", - keyColumn: "GatheringId", - keyValue: 14L, - columns: new[] { "End", "Start" }, - values: new object[] { new DateTimeOffset(new DateTime(2026, 1, 10, 17, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), new DateTimeOffset(new DateTime(2026, 1, 10, 9, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) }); - - migrationBuilder.UpdateData( - table: "Gatherings", - keyColumn: "GatheringId", - keyValue: 15L, - columns: new[] { "End", "Start" }, - values: new object[] { new DateTimeOffset(new DateTime(2026, 1, 12, 23, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), new DateTimeOffset(new DateTime(2026, 1, 12, 18, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) }); - } - } -} diff --git a/src/Evently.Server/Common/Adapters/Blazor/BlazorApp.razor b/src/Evently.Server/Common/Blazor/BlazorApp.razor similarity index 100% rename from src/Evently.Server/Common/Adapters/Blazor/BlazorApp.razor rename to src/Evently.Server/Common/Blazor/BlazorApp.razor diff --git a/src/Evently.Server/Common/Adapters/Blazor/Routes.razor b/src/Evently.Server/Common/Blazor/Routes.razor similarity index 100% rename from src/Evently.Server/Common/Adapters/Blazor/Routes.razor rename to src/Evently.Server/Common/Blazor/Routes.razor diff --git a/src/Evently.Server/Common/Data/AppDbContext.cs b/src/Evently.Server/Common/Data/AppDbContext.cs new file mode 100644 index 0000000..e595566 --- /dev/null +++ b/src/Evently.Server/Common/Data/AppDbContext.cs @@ -0,0 +1,582 @@ +using System.Reflection; +using Evently.Server.Domains.Entities; +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace Evently.Server.Common.Data; + +public class AppDbContext(DbContextOptions options) + : IdentityDbContext(options) +{ + public DbSet Accounts { get; set; } + public DbSet Bookings { get; set; } + public DbSet Gatherings { get; set; } + public DbSet GatheringCategoryDetails { get; set; } + public DbSet Categories { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + // for unit testing, sqlite is used + if (Database.ProviderName == "Microsoft.EntityFrameworkCore.Sqlite") + { + ConfigureSqlite(modelBuilder); + } + + // Postgres identity configuration + modelBuilder + .Entity() + .Property(g => g.GatheringId) + .HasIdentityOptions(startValue: 20); + + modelBuilder + .Entity() + .Property(c => c.CategoryId) + .HasIdentityOptions(startValue: 20); + + SeedData(modelBuilder); + } + + private static void SeedData(ModelBuilder modelBuilder) + { + // Fixed Account IDs for referencing + const string hostUserId = "empty-user-12345"; + const string guestUserId = "guest-user-22222"; + + // Seed Accounts (without proper password hashes - they won't be able to login) + modelBuilder + .Entity() + .HasData( + new Account + { + Id = hostUserId, // Fixed constant ID + UserName = "empty_user", + NormalizedUserName = "EMPTY_USER", + Email = "empty@example.com", + NormalizedEmail = "EMPTY@EXAMPLE.COM", + Name = "Empty User", + EmailConfirmed = false, + PasswordHash = null, // No password - unusable account + SecurityStamp = "EMPTY-SECURITY-STAMP-12345", // Fixed constant + ConcurrencyStamp = "EMPTY-CONCURRENCY-STAMP-12345", // Fixed constant + PhoneNumber = null, + PhoneNumberConfirmed = false, + TwoFactorEnabled = false, + LockoutEnabled = true, + AccessFailedCount = 0, + }, + new Account + { + Id = guestUserId, // Fixed constant ID + UserName = "guest_user2", + NormalizedUserName = "GUEST_USER_2", + Email = "guest@example.com", + NormalizedEmail = "GUEST@EXAMPLE.COM", + Name = "Guest User", + EmailConfirmed = false, + PasswordHash = null, // No password - unusable account + SecurityStamp = "EMPTY-SECURITY-STAMP-12345", // Fixed constant + ConcurrencyStamp = "EMPTY-CONCURRENCY-STAMP-12345", // Fixed constant + PhoneNumber = null, + PhoneNumberConfirmed = false, + TwoFactorEnabled = false, + LockoutEnabled = true, + AccessFailedCount = 0, + } + ); + + // Seed Categories + modelBuilder + .Entity() + .HasData( + new Category { CategoryId = 1, CategoryName = "Information Technology" }, + new Category { CategoryId = 2, CategoryName = "Business & Networking" }, + new Category { CategoryId = 3, CategoryName = "Arts & Culture" } + ); + + // Singapore timezone offset + TimeSpan singaporeOffset = TimeSpan.Zero; + + // Seed Gatherings + modelBuilder + .Entity() + .HasData( + new Gathering + { + GatheringId = 1, + Name = "Tech Innovation Summit", + Description = + "A comprehensive summit exploring the latest in AI and machine learning", + Location = "Marina Bay Sands Convention Centre, Singapore", + Start = new DateTimeOffset( + year: 2026, + month: 12, + day: 5, + hour: 9, + minute: 0, + second: 0, + singaporeOffset + ), + End = new DateTimeOffset( + year: 2026, + month: 12, + day: 5, + hour: 17, + minute: 0, + second: 0, + singaporeOffset + ), + OrganiserId = hostUserId, + }, + new Gathering + { + GatheringId = 2, + Name = "Startup Networking Night", + Description = "Connect with fellow entrepreneurs and investors", + Location = "Clarke Quay Central, Singapore", + Start = new DateTimeOffset( + year: 2026, + month: 12, + day: 10, + hour: 18, + minute: 30, + second: 0, + singaporeOffset + ), + End = new DateTimeOffset( + year: 2026, + month: 12, + day: 10, + hour: 22, + minute: 0, + second: 0, + singaporeOffset + ), + OrganiserId = hostUserId, + }, + new Gathering + { + GatheringId = 3, + Name = "Digital Art Exhibition", + Description = "Showcasing contemporary digital art from emerging artists", + Location = "National Gallery Singapore", + Start = new DateTimeOffset( + year: 2026, + month: 12, + day: 15, + hour: 10, + minute: 0, + second: 0, + singaporeOffset + ), + End = new DateTimeOffset( + year: 2026, + month: 12, + day: 15, + hour: 18, + minute: 0, + second: 0, + singaporeOffset + ), + OrganiserId = hostUserId, + }, + new Gathering + { + GatheringId = 4, + Name = "Web Development Workshop", + Description = "Learn modern web development techniques and best practices", + Location = "Singapore Science Centre", + Start = new DateTimeOffset( + year: 2026, + month: 12, + day: 8, + hour: 13, + minute: 0, + second: 0, + singaporeOffset + ), + End = new DateTimeOffset( + year: 2026, + month: 12, + day: 8, + hour: 17, + minute: 0, + second: 0, + singaporeOffset + ), + OrganiserId = guestUserId, + }, + new Gathering + { + GatheringId = 5, + Name = "Business Strategy Seminar", + Description = "Advanced strategies for scaling your business", + Location = "Raffles City Convention Centre, Singapore", + Start = new DateTimeOffset( + year: 2026, + month: 12, + day: 20, + hour: 14, + minute: 0, + second: 0, + singaporeOffset + ), + End = new DateTimeOffset( + year: 2026, + month: 12, + day: 20, + hour: 16, + minute: 30, + second: 0, + singaporeOffset + ), + OrganiserId = guestUserId, + }, + new Gathering + { + GatheringId = 6, + Name = "Photography Masterclass", + Description = "Professional photography techniques and portfolio building", + Location = "Gardens by the Bay, Singapore", + Start = new DateTimeOffset( + year: 2026, + month: 12, + day: 22, + hour: 8, + minute: 0, + second: 0, + singaporeOffset + ), + End = new DateTimeOffset( + year: 2026, + month: 12, + day: 22, + hour: 12, + minute: 0, + second: 0, + singaporeOffset + ), + OrganiserId = guestUserId, + }, + new Gathering + { + GatheringId = 7, + Name = "Mobile App Development Bootcamp", + Description = "Intensive bootcamp covering iOS and Android development", + Location = "NUS School of Computing, Singapore", + Start = new DateTimeOffset( + year: 2026, + month: 12, + day: 12, + hour: 9, + minute: 0, + second: 0, + singaporeOffset + ), + End = new DateTimeOffset( + year: 2026, + month: 12, + day: 12, + hour: 18, + minute: 0, + second: 0, + singaporeOffset + ), + OrganiserId = hostUserId, + }, + new Gathering + { + GatheringId = 8, + Name = "Investment & Finance Forum", + Description = "Learn about personal finance and investment strategies", + Location = "Suntec Singapore Convention Centre", + Start = new DateTimeOffset( + year: 2026, + month: 12, + day: 25, + hour: 14, + minute: 0, + second: 0, + singaporeOffset + ), + End = new DateTimeOffset( + year: 2026, + month: 12, + day: 25, + hour: 17, + minute: 30, + second: 0, + singaporeOffset + ), + OrganiserId = hostUserId, + }, + new Gathering + { + GatheringId = 9, + Name = "Creative Writing Workshop", + Description = "Explore storytelling techniques and creative expression", + Location = "Esplanade Theatres, Singapore", + Start = new DateTimeOffset( + year: 2026, + month: 12, + day: 28, + hour: 10, + minute: 0, + second: 0, + singaporeOffset + ), + End = new DateTimeOffset( + year: 2026, + month: 12, + day: 28, + hour: 15, + minute: 0, + second: 0, + singaporeOffset + ), + OrganiserId = guestUserId, + }, + new Gathering + { + GatheringId = 10, + Name = "Cloud Computing Conference", + Description = "Latest trends in cloud architecture and DevOps", + Location = "Singapore EXPO", + Start = new DateTimeOffset( + year: 2026, + month: 12, + day: 30, + hour: 9, + minute: 30, + second: 0, + singaporeOffset + ), + End = new DateTimeOffset( + year: 2026, + month: 12, + day: 30, + hour: 17, + minute: 30, + second: 0, + singaporeOffset + ), + OrganiserId = hostUserId, + }, + new Gathering + { + GatheringId = 11, + Name = "E-commerce Mastery", + Description = "Build and scale your online business effectively", + Location = "Marina Bay Financial Centre, Singapore", + Start = new DateTimeOffset( + year: 2027, + month: 1, + day: 3, + hour: 13, + minute: 0, + second: 0, + singaporeOffset + ), + End = new DateTimeOffset( + year: 2027, + month: 1, + day: 3, + hour: 18, + minute: 0, + second: 0, + singaporeOffset + ), + OrganiserId = guestUserId, + }, + new Gathering + { + GatheringId = 12, + Name = "Contemporary Dance Performance", + Description = "An evening of modern dance and artistic expression", + Location = "Victoria Theatre, Singapore", + Start = new DateTimeOffset( + year: 2027, + month: 1, + day: 5, + hour: 19, + minute: 30, + second: 0, + singaporeOffset + ), + End = new DateTimeOffset( + year: 2027, + month: 1, + day: 5, + hour: 22, + minute: 0, + second: 0, + singaporeOffset + ), + OrganiserId = hostUserId, + }, + new Gathering + { + GatheringId = 13, + Name = "Cybersecurity Awareness Training", + Description = "Essential cybersecurity practices for businesses", + Location = "Singapore Management University", + Start = new DateTimeOffset( + year: 2027, + month: 1, + day: 8, + hour: 10, + minute: 0, + second: 0, + singaporeOffset + ), + End = new DateTimeOffset( + year: 2027, + month: 1, + day: 8, + hour: 16, + minute: 0, + second: 0, + singaporeOffset + ), + OrganiserId = guestUserId, + }, + new Gathering + { + GatheringId = 14, + Name = "Leadership Excellence Workshop", + Description = "Develop essential leadership skills for modern managers", + Location = "Orchard Hotel Singapore", + Start = new DateTimeOffset( + year: 2027, + month: 1, + day: 10, + hour: 9, + minute: 0, + second: 0, + singaporeOffset + ), + End = new DateTimeOffset( + year: 2027, + month: 1, + day: 10, + hour: 17, + minute: 0, + second: 0, + singaporeOffset + ), + OrganiserId = hostUserId, + }, + new Gathering + { + GatheringId = 15, + Name = "Film & Media Production Showcase", + Description = "Independent filmmakers present their latest works", + Location = "Singapore International Film Festival Venue", + Start = new DateTimeOffset( + year: 2027, + month: 1, + day: 12, + hour: 18, + minute: 0, + second: 0, + singaporeOffset + ), + End = new DateTimeOffset( + year: 2027, + month: 1, + day: 12, + hour: 23, + minute: 0, + second: 0, + singaporeOffset + ), + OrganiserId = guestUserId, + } + ); + + // Seed GatheringCategoryDetails + modelBuilder + .Entity() + .HasData( + new GatheringCategoryDetail { GatheringId = 1, CategoryId = 1 }, // Tech Innovation Summit -> IT + new GatheringCategoryDetail { GatheringId = 2, CategoryId = 2 }, // Startup Networking Night -> Business + new GatheringCategoryDetail { GatheringId = 3, CategoryId = 3 }, // Digital Art Exhibition -> Arts + new GatheringCategoryDetail { GatheringId = 4, CategoryId = 1 }, // Web Development Workshop -> IT + new GatheringCategoryDetail { GatheringId = 5, CategoryId = 2 }, // Business Strategy Seminar -> Business + new GatheringCategoryDetail { GatheringId = 6, CategoryId = 3 }, // Photography Masterclass -> Arts + new GatheringCategoryDetail { GatheringId = 7, CategoryId = 1 }, // Mobile App Development Bootcamp -> IT + new GatheringCategoryDetail { GatheringId = 8, CategoryId = 2 }, // Investment & Finance Forum -> Business + new GatheringCategoryDetail { GatheringId = 9, CategoryId = 3 }, // Creative Writing Workshop -> Arts + new GatheringCategoryDetail { GatheringId = 10, CategoryId = 1 }, // Cloud Computing Conference -> IT + new GatheringCategoryDetail { GatheringId = 11, CategoryId = 2 }, // E-commerce Mastery -> Business + new GatheringCategoryDetail { GatheringId = 12, CategoryId = 3 }, // Contemporary Dance Performance -> Arts + new GatheringCategoryDetail { GatheringId = 13, CategoryId = 1 }, // Cybersecurity Awareness Training -> IT + new GatheringCategoryDetail { GatheringId = 14, CategoryId = 2 }, // Leadership Excellence Workshop -> Business + new GatheringCategoryDetail { GatheringId = 15, CategoryId = 3 } // Film & Media Production Showcase -> Arts + ); + + // Seed Bookings with fixed DateTimeOffset values + // Fixed DateTimeOffset for seeding (static value) + DateTimeOffset fixedCreationTime = new( + year: 2025, + month: 1, + day: 1, + hour: 0, + minute: 0, + second: 0, + TimeSpan.Zero + ); + + modelBuilder + .Entity() + .HasData( + new Booking + { + BookingId = "book_abc123456", + GatheringId = 1, + AttendeeId = guestUserId, + CreationDateTime = fixedCreationTime, + CheckInDateTime = null, + CheckoutDateTime = null, + CancellationDateTime = null, + }, + new Booking + { + BookingId = "book_def789012", + GatheringId = 2, + AttendeeId = hostUserId, + CreationDateTime = fixedCreationTime.AddHours(1), // Slightly different time + CheckInDateTime = null, + CheckoutDateTime = null, + CancellationDateTime = null, + } + ); + } + + // https://stackoverflow.com/a/76152994/6514532 + private static void ConfigureSqlite(ModelBuilder modelBuilder) + { + // SQLite does not have proper support for DateTimeOffset via Entity Framework Core, + // see the limitations here: https://docs.microsoft.com/en-us/ef/core/providers/sqlite/limitations#query-limitations. + // Based on: https://github.com/aspnet/EntityFrameworkCore/issues/10784#issuecomment-415769754. + foreach (IMutableEntityType entityType in modelBuilder.Model.GetEntityTypes()) + { + IEnumerable properties = entityType + .ClrType.GetProperties() + .Where(p => + p.PropertyType == typeof(DateTimeOffset) + || p.PropertyType == typeof(DateTimeOffset?) + ); + foreach (PropertyInfo property in properties) + { + modelBuilder + .Entity(entityType.Name) + .Property(property.Name) + .HasConversion(new DateTimeOffsetToBinaryConverter()); + } + } + } +} diff --git a/src/Evently.Server/Common/Adapters/Data/Migrations/20250913035915_SQLServer.Designer.cs b/src/Evently.Server/Common/Data/Migrations/20250913035915_SQLServer.Designer.cs similarity index 99% rename from src/Evently.Server/Common/Adapters/Data/Migrations/20250913035915_SQLServer.Designer.cs rename to src/Evently.Server/Common/Data/Migrations/20250913035915_SQLServer.Designer.cs index 8d23e5f..8f1f06a 100644 --- a/src/Evently.Server/Common/Adapters/Data/Migrations/20250913035915_SQLServer.Designer.cs +++ b/src/Evently.Server/Common/Data/Migrations/20250913035915_SQLServer.Designer.cs @@ -1,6 +1,6 @@ // using System; -using Evently.Server.Common.Adapters.Data; +using Evently.Server.Common.Data; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Metadata; @@ -9,7 +9,7 @@ #nullable disable -namespace Evently.Server.Common.Adapters.Data.Migrations +namespace Evently.Server.Common.Data.Migrations { [DbContext(typeof(AppDbContext))] [Migration("20250913035915_SQLServer")] diff --git a/src/Evently.Server/Common/Data/Migrations/20250913035915_SQLServer.cs b/src/Evently.Server/Common/Data/Migrations/20250913035915_SQLServer.cs new file mode 100644 index 0000000..94cd494 --- /dev/null +++ b/src/Evently.Server/Common/Data/Migrations/20250913035915_SQLServer.cs @@ -0,0 +1,867 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional + +namespace Evently.Server.Common.Data.Migrations +{ + /// + public partial class SQLServer : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AspNetRoles", + columns: table => new + { + Id = table.Column(type: "nvarchar(450)", nullable: false), + Name = table.Column( + type: "nvarchar(256)", + maxLength: 256, + nullable: true + ), + NormalizedName = table.Column( + type: "nvarchar(256)", + maxLength: 256, + nullable: true + ), + ConcurrencyStamp = table.Column(type: "nvarchar(max)", nullable: true), + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetRoles", x => x.Id); + } + ); + + migrationBuilder.CreateTable( + name: "AspNetUsers", + columns: table => new + { + Id = table.Column(type: "nvarchar(450)", nullable: false), + Name = table.Column( + type: "nvarchar(100)", + maxLength: 100, + nullable: false + ), + UserName = table.Column( + type: "nvarchar(256)", + maxLength: 256, + nullable: true + ), + NormalizedUserName = table.Column( + type: "nvarchar(256)", + maxLength: 256, + nullable: true + ), + Email = table.Column( + type: "nvarchar(256)", + maxLength: 256, + nullable: true + ), + NormalizedEmail = table.Column( + type: "nvarchar(256)", + maxLength: 256, + nullable: true + ), + EmailConfirmed = table.Column(type: "bit", nullable: false), + PasswordHash = table.Column(type: "nvarchar(max)", nullable: true), + SecurityStamp = table.Column(type: "nvarchar(max)", nullable: true), + ConcurrencyStamp = table.Column(type: "nvarchar(max)", nullable: true), + PhoneNumber = table.Column(type: "nvarchar(max)", nullable: true), + PhoneNumberConfirmed = table.Column(type: "bit", nullable: false), + TwoFactorEnabled = table.Column(type: "bit", nullable: false), + LockoutEnd = table.Column( + type: "datetimeoffset", + nullable: true + ), + LockoutEnabled = table.Column(type: "bit", nullable: false), + AccessFailedCount = table.Column(type: "int", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUsers", x => x.Id); + } + ); + + migrationBuilder.CreateTable( + name: "Categories", + columns: table => new + { + CategoryId = table + .Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + CategoryName = table.Column( + type: "nvarchar(100)", + maxLength: 100, + nullable: false + ), + Approved = table.Column(type: "bit", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_Categories", x => x.CategoryId); + } + ); + + migrationBuilder.CreateTable( + name: "Gatherings", + columns: table => new + { + GatheringId = table + .Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Name = table.Column( + type: "nvarchar(100)", + maxLength: 100, + nullable: false + ), + Description = table.Column( + type: "nvarchar(max)", + maxLength: 10000, + nullable: false + ), + Start = table.Column(type: "datetimeoffset", nullable: false), + End = table.Column(type: "datetimeoffset", nullable: false), + Location = table.Column( + type: "nvarchar(100)", + maxLength: 100, + nullable: false + ), + CoverSrc = table.Column( + type: "nvarchar(1000)", + maxLength: 1000, + nullable: true + ), + OrganiserId = table.Column( + type: "nvarchar(100)", + maxLength: 100, + nullable: false + ), + CancellationDateTime = table.Column( + type: "datetimeoffset", + nullable: true + ), + }, + constraints: table => + { + table.PrimaryKey("PK_Gatherings", x => x.GatheringId); + } + ); + + migrationBuilder.CreateTable( + name: "AspNetRoleClaims", + columns: table => new + { + Id = table + .Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + RoleId = table.Column(type: "nvarchar(450)", nullable: false), + ClaimType = table.Column(type: "nvarchar(max)", nullable: true), + ClaimValue = table.Column(type: "nvarchar(max)", nullable: true), + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id); + table.ForeignKey( + name: "FK_AspNetRoleClaims_AspNetRoles_RoleId", + column: x => x.RoleId, + principalTable: "AspNetRoles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade + ); + } + ); + + migrationBuilder.CreateTable( + name: "AspNetUserClaims", + columns: table => new + { + Id = table + .Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + UserId = table.Column(type: "nvarchar(450)", nullable: false), + ClaimType = table.Column(type: "nvarchar(max)", nullable: true), + ClaimValue = table.Column(type: "nvarchar(max)", nullable: true), + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserClaims", x => x.Id); + table.ForeignKey( + name: "FK_AspNetUserClaims_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade + ); + } + ); + + migrationBuilder.CreateTable( + name: "AspNetUserLogins", + columns: table => new + { + LoginProvider = table.Column(type: "nvarchar(450)", nullable: false), + ProviderKey = table.Column(type: "nvarchar(450)", nullable: false), + ProviderDisplayName = table.Column( + type: "nvarchar(max)", + nullable: true + ), + UserId = table.Column(type: "nvarchar(450)", nullable: false), + }, + constraints: table => + { + table.PrimaryKey( + "PK_AspNetUserLogins", + x => new { x.LoginProvider, x.ProviderKey } + ); + table.ForeignKey( + name: "FK_AspNetUserLogins_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade + ); + } + ); + + migrationBuilder.CreateTable( + name: "AspNetUserRoles", + columns: table => new + { + UserId = table.Column(type: "nvarchar(450)", nullable: false), + RoleId = table.Column(type: "nvarchar(450)", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId }); + table.ForeignKey( + name: "FK_AspNetUserRoles_AspNetRoles_RoleId", + column: x => x.RoleId, + principalTable: "AspNetRoles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade + ); + table.ForeignKey( + name: "FK_AspNetUserRoles_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade + ); + } + ); + + migrationBuilder.CreateTable( + name: "AspNetUserTokens", + columns: table => new + { + UserId = table.Column(type: "nvarchar(450)", nullable: false), + LoginProvider = table.Column(type: "nvarchar(450)", nullable: false), + Name = table.Column(type: "nvarchar(450)", nullable: false), + Value = table.Column(type: "nvarchar(max)", nullable: true), + }, + constraints: table => + { + table.PrimaryKey( + "PK_AspNetUserTokens", + x => new + { + x.UserId, + x.LoginProvider, + x.Name, + } + ); + table.ForeignKey( + name: "FK_AspNetUserTokens_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade + ); + } + ); + + migrationBuilder.CreateTable( + name: "Bookings", + columns: table => new + { + BookingId = table.Column( + type: "nvarchar(50)", + maxLength: 50, + nullable: false + ), + AttendeeId = table.Column(type: "nvarchar(450)", nullable: false), + GatheringId = table.Column(type: "bigint", nullable: false), + CreationDateTime = table.Column( + type: "datetimeoffset", + nullable: false + ), + CheckInDateTime = table.Column( + type: "datetimeoffset", + nullable: true + ), + CheckoutDateTime = table.Column( + type: "datetimeoffset", + nullable: true + ), + CancellationDateTime = table.Column( + type: "datetimeoffset", + nullable: true + ), + }, + constraints: table => + { + table.PrimaryKey("PK_Bookings", x => x.BookingId); + table.ForeignKey( + name: "FK_Bookings_AspNetUsers_AttendeeId", + column: x => x.AttendeeId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade + ); + table.ForeignKey( + name: "FK_Bookings_Gatherings_GatheringId", + column: x => x.GatheringId, + principalTable: "Gatherings", + principalColumn: "GatheringId", + onDelete: ReferentialAction.Cascade + ); + } + ); + + migrationBuilder.CreateTable( + name: "GatheringCategoryDetails", + columns: table => new + { + GatheringId = table.Column(type: "bigint", nullable: false), + CategoryId = table.Column(type: "bigint", nullable: false), + }, + constraints: table => + { + table.PrimaryKey( + "PK_GatheringCategoryDetails", + x => new { x.GatheringId, x.CategoryId } + ); + table.ForeignKey( + name: "FK_GatheringCategoryDetails_Categories_CategoryId", + column: x => x.CategoryId, + principalTable: "Categories", + principalColumn: "CategoryId", + onDelete: ReferentialAction.Cascade + ); + table.ForeignKey( + name: "FK_GatheringCategoryDetails_Gatherings_GatheringId", + column: x => x.GatheringId, + principalTable: "Gatherings", + principalColumn: "GatheringId", + onDelete: ReferentialAction.Cascade + ); + } + ); + + migrationBuilder.InsertData( + table: "AspNetUsers", + columns: new[] + { + "Id", + "AccessFailedCount", + "ConcurrencyStamp", + "Email", + "EmailConfirmed", + "LockoutEnabled", + "LockoutEnd", + "Name", + "NormalizedEmail", + "NormalizedUserName", + "PasswordHash", + "PhoneNumber", + "PhoneNumberConfirmed", + "SecurityStamp", + "TwoFactorEnabled", + "UserName", + }, + values: new object[,] + { + { + "empty-user-12345", + 0, + "EMPTY-CONCURRENCY-STAMP-12345", + "empty@example.com", + false, + true, + null, + "Empty User", + "EMPTY@EXAMPLE.COM", + "EMPTY_USER", + null, + null, + false, + "EMPTY-SECURITY-STAMP-12345", + false, + "empty_user", + }, + { + "guest-user-22222", + 0, + "EMPTY-CONCURRENCY-STAMP-12345", + "guest@example.com", + false, + true, + null, + "Guest User", + "GUEST@EXAMPLE.COM", + "GUEST_USER_2", + null, + null, + false, + "EMPTY-SECURITY-STAMP-12345", + false, + "guest_user2", + }, + } + ); + + migrationBuilder.InsertData( + table: "Categories", + columns: new[] { "CategoryId", "Approved", "CategoryName" }, + values: new object[,] + { + { 1L, false, "Information Technology" }, + { 2L, false, "Business & Networking" }, + { 3L, false, "Arts & Culture" }, + } + ); + + migrationBuilder.InsertData( + table: "Gatherings", + columns: new[] + { + "GatheringId", + "CancellationDateTime", + "CoverSrc", + "Description", + "End", + "Location", + "Name", + "OrganiserId", + "Start", + }, + values: new object[,] + { + { + 1L, + null, + "", + "A comprehensive summit exploring the latest in AI and machine learning", + new DateTimeOffset( + new DateTime(2025, 12, 5, 17, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + "Marina Bay Sands Convention Centre, Singapore", + "Tech Innovation Summit", + "empty-user-12345", + new DateTimeOffset( + new DateTime(2025, 12, 5, 9, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + }, + { + 2L, + null, + "", + "Connect with fellow entrepreneurs and investors", + new DateTimeOffset( + new DateTime(2025, 12, 10, 22, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + "Clarke Quay Central, Singapore", + "Startup Networking Night", + "empty-user-12345", + new DateTimeOffset( + new DateTime(2025, 12, 10, 18, 30, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + }, + { + 3L, + null, + "", + "Showcasing contemporary digital art from emerging artists", + new DateTimeOffset( + new DateTime(2025, 12, 15, 18, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + "National Gallery Singapore", + "Digital Art Exhibition", + "empty-user-12345", + new DateTimeOffset( + new DateTime(2025, 12, 15, 10, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + }, + { + 4L, + null, + "", + "Learn modern web development techniques and best practices", + new DateTimeOffset( + new DateTime(2025, 12, 8, 17, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + "Singapore Science Centre", + "Web Development Workshop", + "guest-user-22222", + new DateTimeOffset( + new DateTime(2025, 12, 8, 13, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + }, + { + 5L, + null, + "", + "Advanced strategies for scaling your business", + new DateTimeOffset( + new DateTime(2025, 12, 20, 16, 30, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + "Raffles City Convention Centre, Singapore", + "Business Strategy Seminar", + "guest-user-22222", + new DateTimeOffset( + new DateTime(2025, 12, 20, 14, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + }, + { + 6L, + null, + "", + "Professional photography techniques and portfolio building", + new DateTimeOffset( + new DateTime(2025, 12, 22, 12, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + "Gardens by the Bay, Singapore", + "Photography Masterclass", + "guest-user-22222", + new DateTimeOffset( + new DateTime(2025, 12, 22, 8, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + }, + { + 7L, + null, + "", + "Intensive bootcamp covering iOS and Android development", + new DateTimeOffset( + new DateTime(2025, 12, 12, 18, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + "NUS School of Computing, Singapore", + "Mobile App Development Bootcamp", + "empty-user-12345", + new DateTimeOffset( + new DateTime(2025, 12, 12, 9, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + }, + { + 8L, + null, + "", + "Learn about personal finance and investment strategies", + new DateTimeOffset( + new DateTime(2025, 12, 25, 17, 30, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + "Suntec Singapore Convention Centre", + "Investment & Finance Forum", + "empty-user-12345", + new DateTimeOffset( + new DateTime(2025, 12, 25, 14, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + }, + { + 9L, + null, + "", + "Explore storytelling techniques and creative expression", + new DateTimeOffset( + new DateTime(2025, 12, 28, 15, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + "Esplanade Theatres, Singapore", + "Creative Writing Workshop", + "guest-user-22222", + new DateTimeOffset( + new DateTime(2025, 12, 28, 10, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + }, + { + 10L, + null, + "", + "Latest trends in cloud architecture and DevOps", + new DateTimeOffset( + new DateTime(2025, 12, 30, 17, 30, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + "Singapore EXPO", + "Cloud Computing Conference", + "empty-user-12345", + new DateTimeOffset( + new DateTime(2025, 12, 30, 9, 30, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + }, + { + 11L, + null, + "", + "Build and scale your online business effectively", + new DateTimeOffset( + new DateTime(2026, 1, 3, 18, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + "Marina Bay Financial Centre, Singapore", + "E-commerce Mastery", + "guest-user-22222", + new DateTimeOffset( + new DateTime(2026, 1, 3, 13, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + }, + { + 12L, + null, + "", + "An evening of modern dance and artistic expression", + new DateTimeOffset( + new DateTime(2026, 1, 5, 22, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + "Victoria Theatre, Singapore", + "Contemporary Dance Performance", + "empty-user-12345", + new DateTimeOffset( + new DateTime(2026, 1, 5, 19, 30, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + }, + { + 13L, + null, + "", + "Essential cybersecurity practices for businesses", + new DateTimeOffset( + new DateTime(2026, 1, 8, 16, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + "Singapore Management University", + "Cybersecurity Awareness Training", + "guest-user-22222", + new DateTimeOffset( + new DateTime(2026, 1, 8, 10, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + }, + { + 14L, + null, + "", + "Develop essential leadership skills for modern managers", + new DateTimeOffset( + new DateTime(2026, 1, 10, 17, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + "Orchard Hotel Singapore", + "Leadership Excellence Workshop", + "empty-user-12345", + new DateTimeOffset( + new DateTime(2026, 1, 10, 9, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + }, + { + 15L, + null, + "", + "Independent filmmakers present their latest works", + new DateTimeOffset( + new DateTime(2026, 1, 12, 23, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + "Singapore International Film Festival Venue", + "Film & Media Production Showcase", + "guest-user-22222", + new DateTimeOffset( + new DateTime(2026, 1, 12, 18, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + }, + } + ); + + migrationBuilder.InsertData( + table: "Bookings", + columns: new[] + { + "BookingId", + "AttendeeId", + "CancellationDateTime", + "CheckInDateTime", + "CheckoutDateTime", + "CreationDateTime", + "GatheringId", + }, + values: new object[,] + { + { + "book_abc123456", + "guest-user-22222", + null, + null, + null, + new DateTimeOffset( + new DateTime(2024, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + 1L, + }, + { + "book_def789012", + "empty-user-12345", + null, + null, + null, + new DateTimeOffset( + new DateTime(2024, 1, 1, 1, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + 2L, + }, + } + ); + + migrationBuilder.InsertData( + table: "GatheringCategoryDetails", + columns: new[] { "CategoryId", "GatheringId" }, + values: new object[,] + { + { 1L, 1L }, + { 2L, 2L }, + { 3L, 3L }, + { 1L, 4L }, + { 2L, 5L }, + { 3L, 6L }, + { 1L, 7L }, + { 2L, 8L }, + { 3L, 9L }, + { 1L, 10L }, + { 2L, 11L }, + { 3L, 12L }, + { 1L, 13L }, + { 2L, 14L }, + { 3L, 15L }, + } + ); + + migrationBuilder.CreateIndex( + name: "IX_AspNetRoleClaims_RoleId", + table: "AspNetRoleClaims", + column: "RoleId" + ); + + migrationBuilder.CreateIndex( + name: "RoleNameIndex", + table: "AspNetRoles", + column: "NormalizedName", + unique: true, + filter: "[NormalizedName] IS NOT NULL" + ); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserClaims_UserId", + table: "AspNetUserClaims", + column: "UserId" + ); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserLogins_UserId", + table: "AspNetUserLogins", + column: "UserId" + ); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserRoles_RoleId", + table: "AspNetUserRoles", + column: "RoleId" + ); + + migrationBuilder.CreateIndex( + name: "EmailIndex", + table: "AspNetUsers", + column: "NormalizedEmail" + ); + + migrationBuilder.CreateIndex( + name: "UserNameIndex", + table: "AspNetUsers", + column: "NormalizedUserName", + unique: true, + filter: "[NormalizedUserName] IS NOT NULL" + ); + + migrationBuilder.CreateIndex( + name: "IX_Bookings_AttendeeId", + table: "Bookings", + column: "AttendeeId" + ); + + migrationBuilder.CreateIndex( + name: "IX_Bookings_GatheringId", + table: "Bookings", + column: "GatheringId" + ); + + migrationBuilder.CreateIndex( + name: "IX_GatheringCategoryDetails_CategoryId", + table: "GatheringCategoryDetails", + column: "CategoryId" + ); + } + + /// + 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: "Bookings"); + + migrationBuilder.DropTable(name: "GatheringCategoryDetails"); + + migrationBuilder.DropTable(name: "AspNetRoles"); + + migrationBuilder.DropTable(name: "AspNetUsers"); + + migrationBuilder.DropTable(name: "Categories"); + + migrationBuilder.DropTable(name: "Gatherings"); + } + } +} diff --git a/src/Evently.Server/Common/Adapters/Data/Migrations/20250927053802_UpdateSeededDates.Designer.cs b/src/Evently.Server/Common/Data/Migrations/20250927053802_UpdateSeededDates.Designer.cs similarity index 99% rename from src/Evently.Server/Common/Adapters/Data/Migrations/20250927053802_UpdateSeededDates.Designer.cs rename to src/Evently.Server/Common/Data/Migrations/20250927053802_UpdateSeededDates.Designer.cs index 8722a0d..d005d21 100644 --- a/src/Evently.Server/Common/Adapters/Data/Migrations/20250927053802_UpdateSeededDates.Designer.cs +++ b/src/Evently.Server/Common/Data/Migrations/20250927053802_UpdateSeededDates.Designer.cs @@ -1,6 +1,6 @@ // using System; -using Evently.Server.Common.Adapters.Data; +using Evently.Server.Common.Data; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Metadata; @@ -9,7 +9,7 @@ #nullable disable -namespace Evently.Server.Common.Adapters.Data.Migrations +namespace Evently.Server.Common.Data.Migrations { [DbContext(typeof(AppDbContext))] [Migration("20250927053802_UpdateSeededDates")] diff --git a/src/Evently.Server/Common/Data/Migrations/20250927053802_UpdateSeededDates.cs b/src/Evently.Server/Common/Data/Migrations/20250927053802_UpdateSeededDates.cs new file mode 100644 index 0000000..d8bf36f --- /dev/null +++ b/src/Evently.Server/Common/Data/Migrations/20250927053802_UpdateSeededDates.cs @@ -0,0 +1,603 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Evently.Server.Common.Data.Migrations +{ + /// + public partial class UpdateSeededDates : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.UpdateData( + table: "Bookings", + keyColumn: "BookingId", + keyValue: "book_abc123456", + column: "CreationDateTime", + value: new DateTimeOffset( + new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ) + ); + + migrationBuilder.UpdateData( + table: "Bookings", + keyColumn: "BookingId", + keyValue: "book_def789012", + column: "CreationDateTime", + value: new DateTimeOffset( + new DateTime(2025, 1, 1, 1, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ) + ); + + migrationBuilder.UpdateData( + table: "Gatherings", + keyColumn: "GatheringId", + keyValue: 1L, + columns: new[] { "End", "Start" }, + values: new object[] + { + new DateTimeOffset( + new DateTime(2026, 12, 5, 17, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + new DateTimeOffset( + new DateTime(2026, 12, 5, 9, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + } + ); + + migrationBuilder.UpdateData( + table: "Gatherings", + keyColumn: "GatheringId", + keyValue: 2L, + columns: new[] { "End", "Start" }, + values: new object[] + { + new DateTimeOffset( + new DateTime(2026, 12, 10, 22, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + new DateTimeOffset( + new DateTime(2026, 12, 10, 18, 30, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + } + ); + + migrationBuilder.UpdateData( + table: "Gatherings", + keyColumn: "GatheringId", + keyValue: 3L, + columns: new[] { "End", "Start" }, + values: new object[] + { + new DateTimeOffset( + new DateTime(2026, 12, 15, 18, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + new DateTimeOffset( + new DateTime(2026, 12, 15, 10, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + } + ); + + migrationBuilder.UpdateData( + table: "Gatherings", + keyColumn: "GatheringId", + keyValue: 4L, + columns: new[] { "End", "Start" }, + values: new object[] + { + new DateTimeOffset( + new DateTime(2026, 12, 8, 17, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + new DateTimeOffset( + new DateTime(2026, 12, 8, 13, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + } + ); + + migrationBuilder.UpdateData( + table: "Gatherings", + keyColumn: "GatheringId", + keyValue: 5L, + columns: new[] { "End", "Start" }, + values: new object[] + { + new DateTimeOffset( + new DateTime(2026, 12, 20, 16, 30, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + new DateTimeOffset( + new DateTime(2026, 12, 20, 14, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + } + ); + + migrationBuilder.UpdateData( + table: "Gatherings", + keyColumn: "GatheringId", + keyValue: 6L, + columns: new[] { "End", "Start" }, + values: new object[] + { + new DateTimeOffset( + new DateTime(2026, 12, 22, 12, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + new DateTimeOffset( + new DateTime(2026, 12, 22, 8, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + } + ); + + migrationBuilder.UpdateData( + table: "Gatherings", + keyColumn: "GatheringId", + keyValue: 7L, + columns: new[] { "End", "Start" }, + values: new object[] + { + new DateTimeOffset( + new DateTime(2026, 12, 12, 18, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + new DateTimeOffset( + new DateTime(2026, 12, 12, 9, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + } + ); + + migrationBuilder.UpdateData( + table: "Gatherings", + keyColumn: "GatheringId", + keyValue: 8L, + columns: new[] { "End", "Start" }, + values: new object[] + { + new DateTimeOffset( + new DateTime(2026, 12, 25, 17, 30, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + new DateTimeOffset( + new DateTime(2026, 12, 25, 14, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + } + ); + + migrationBuilder.UpdateData( + table: "Gatherings", + keyColumn: "GatheringId", + keyValue: 9L, + columns: new[] { "End", "Start" }, + values: new object[] + { + new DateTimeOffset( + new DateTime(2026, 12, 28, 15, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + new DateTimeOffset( + new DateTime(2026, 12, 28, 10, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + } + ); + + migrationBuilder.UpdateData( + table: "Gatherings", + keyColumn: "GatheringId", + keyValue: 10L, + columns: new[] { "End", "Start" }, + values: new object[] + { + new DateTimeOffset( + new DateTime(2026, 12, 30, 17, 30, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + new DateTimeOffset( + new DateTime(2026, 12, 30, 9, 30, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + } + ); + + migrationBuilder.UpdateData( + table: "Gatherings", + keyColumn: "GatheringId", + keyValue: 11L, + columns: new[] { "End", "Start" }, + values: new object[] + { + new DateTimeOffset( + new DateTime(2027, 1, 3, 18, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + new DateTimeOffset( + new DateTime(2027, 1, 3, 13, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + } + ); + + migrationBuilder.UpdateData( + table: "Gatherings", + keyColumn: "GatheringId", + keyValue: 12L, + columns: new[] { "End", "Start" }, + values: new object[] + { + new DateTimeOffset( + new DateTime(2027, 1, 5, 22, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + new DateTimeOffset( + new DateTime(2027, 1, 5, 19, 30, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + } + ); + + migrationBuilder.UpdateData( + table: "Gatherings", + keyColumn: "GatheringId", + keyValue: 13L, + columns: new[] { "End", "Start" }, + values: new object[] + { + new DateTimeOffset( + new DateTime(2027, 1, 8, 16, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + new DateTimeOffset( + new DateTime(2027, 1, 8, 10, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + } + ); + + migrationBuilder.UpdateData( + table: "Gatherings", + keyColumn: "GatheringId", + keyValue: 14L, + columns: new[] { "End", "Start" }, + values: new object[] + { + new DateTimeOffset( + new DateTime(2027, 1, 10, 17, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + new DateTimeOffset( + new DateTime(2027, 1, 10, 9, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + } + ); + + migrationBuilder.UpdateData( + table: "Gatherings", + keyColumn: "GatheringId", + keyValue: 15L, + columns: new[] { "End", "Start" }, + values: new object[] + { + new DateTimeOffset( + new DateTime(2027, 1, 12, 23, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + new DateTimeOffset( + new DateTime(2027, 1, 12, 18, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + } + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.UpdateData( + table: "Bookings", + keyColumn: "BookingId", + keyValue: "book_abc123456", + column: "CreationDateTime", + value: new DateTimeOffset( + new DateTime(2024, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ) + ); + + migrationBuilder.UpdateData( + table: "Bookings", + keyColumn: "BookingId", + keyValue: "book_def789012", + column: "CreationDateTime", + value: new DateTimeOffset( + new DateTime(2024, 1, 1, 1, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ) + ); + + migrationBuilder.UpdateData( + table: "Gatherings", + keyColumn: "GatheringId", + keyValue: 1L, + columns: new[] { "End", "Start" }, + values: new object[] + { + new DateTimeOffset( + new DateTime(2025, 12, 5, 17, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + new DateTimeOffset( + new DateTime(2025, 12, 5, 9, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + } + ); + + migrationBuilder.UpdateData( + table: "Gatherings", + keyColumn: "GatheringId", + keyValue: 2L, + columns: new[] { "End", "Start" }, + values: new object[] + { + new DateTimeOffset( + new DateTime(2025, 12, 10, 22, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + new DateTimeOffset( + new DateTime(2025, 12, 10, 18, 30, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + } + ); + + migrationBuilder.UpdateData( + table: "Gatherings", + keyColumn: "GatheringId", + keyValue: 3L, + columns: new[] { "End", "Start" }, + values: new object[] + { + new DateTimeOffset( + new DateTime(2025, 12, 15, 18, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + new DateTimeOffset( + new DateTime(2025, 12, 15, 10, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + } + ); + + migrationBuilder.UpdateData( + table: "Gatherings", + keyColumn: "GatheringId", + keyValue: 4L, + columns: new[] { "End", "Start" }, + values: new object[] + { + new DateTimeOffset( + new DateTime(2025, 12, 8, 17, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + new DateTimeOffset( + new DateTime(2025, 12, 8, 13, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + } + ); + + migrationBuilder.UpdateData( + table: "Gatherings", + keyColumn: "GatheringId", + keyValue: 5L, + columns: new[] { "End", "Start" }, + values: new object[] + { + new DateTimeOffset( + new DateTime(2025, 12, 20, 16, 30, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + new DateTimeOffset( + new DateTime(2025, 12, 20, 14, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + } + ); + + migrationBuilder.UpdateData( + table: "Gatherings", + keyColumn: "GatheringId", + keyValue: 6L, + columns: new[] { "End", "Start" }, + values: new object[] + { + new DateTimeOffset( + new DateTime(2025, 12, 22, 12, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + new DateTimeOffset( + new DateTime(2025, 12, 22, 8, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + } + ); + + migrationBuilder.UpdateData( + table: "Gatherings", + keyColumn: "GatheringId", + keyValue: 7L, + columns: new[] { "End", "Start" }, + values: new object[] + { + new DateTimeOffset( + new DateTime(2025, 12, 12, 18, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + new DateTimeOffset( + new DateTime(2025, 12, 12, 9, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + } + ); + + migrationBuilder.UpdateData( + table: "Gatherings", + keyColumn: "GatheringId", + keyValue: 8L, + columns: new[] { "End", "Start" }, + values: new object[] + { + new DateTimeOffset( + new DateTime(2025, 12, 25, 17, 30, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + new DateTimeOffset( + new DateTime(2025, 12, 25, 14, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + } + ); + + migrationBuilder.UpdateData( + table: "Gatherings", + keyColumn: "GatheringId", + keyValue: 9L, + columns: new[] { "End", "Start" }, + values: new object[] + { + new DateTimeOffset( + new DateTime(2025, 12, 28, 15, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + new DateTimeOffset( + new DateTime(2025, 12, 28, 10, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + } + ); + + migrationBuilder.UpdateData( + table: "Gatherings", + keyColumn: "GatheringId", + keyValue: 10L, + columns: new[] { "End", "Start" }, + values: new object[] + { + new DateTimeOffset( + new DateTime(2025, 12, 30, 17, 30, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + new DateTimeOffset( + new DateTime(2025, 12, 30, 9, 30, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + } + ); + + migrationBuilder.UpdateData( + table: "Gatherings", + keyColumn: "GatheringId", + keyValue: 11L, + columns: new[] { "End", "Start" }, + values: new object[] + { + new DateTimeOffset( + new DateTime(2026, 1, 3, 18, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + new DateTimeOffset( + new DateTime(2026, 1, 3, 13, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + } + ); + + migrationBuilder.UpdateData( + table: "Gatherings", + keyColumn: "GatheringId", + keyValue: 12L, + columns: new[] { "End", "Start" }, + values: new object[] + { + new DateTimeOffset( + new DateTime(2026, 1, 5, 22, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + new DateTimeOffset( + new DateTime(2026, 1, 5, 19, 30, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + } + ); + + migrationBuilder.UpdateData( + table: "Gatherings", + keyColumn: "GatheringId", + keyValue: 13L, + columns: new[] { "End", "Start" }, + values: new object[] + { + new DateTimeOffset( + new DateTime(2026, 1, 8, 16, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + new DateTimeOffset( + new DateTime(2026, 1, 8, 10, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + } + ); + + migrationBuilder.UpdateData( + table: "Gatherings", + keyColumn: "GatheringId", + keyValue: 14L, + columns: new[] { "End", "Start" }, + values: new object[] + { + new DateTimeOffset( + new DateTime(2026, 1, 10, 17, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + new DateTimeOffset( + new DateTime(2026, 1, 10, 9, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + } + ); + + migrationBuilder.UpdateData( + table: "Gatherings", + keyColumn: "GatheringId", + keyValue: 15L, + columns: new[] { "End", "Start" }, + values: new object[] + { + new DateTimeOffset( + new DateTime(2026, 1, 12, 23, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + new DateTimeOffset( + new DateTime(2026, 1, 12, 18, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + } + ); + } + } +} diff --git a/src/Evently.Server/Common/Adapters/Data/Migrations/AppDbContextModelSnapshot.cs b/src/Evently.Server/Common/Data/Migrations/AppDbContextModelSnapshot.cs similarity index 99% rename from src/Evently.Server/Common/Adapters/Data/Migrations/AppDbContextModelSnapshot.cs rename to src/Evently.Server/Common/Data/Migrations/AppDbContextModelSnapshot.cs index 97bed42..aa67b68 100644 --- a/src/Evently.Server/Common/Adapters/Data/Migrations/AppDbContextModelSnapshot.cs +++ b/src/Evently.Server/Common/Data/Migrations/AppDbContextModelSnapshot.cs @@ -1,6 +1,6 @@ // using System; -using Evently.Server.Common.Adapters.Data; +using Evently.Server.Common.Data; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Metadata; @@ -8,7 +8,7 @@ #nullable disable -namespace Evently.Server.Common.Adapters.Data.Migrations +namespace Evently.Server.Common.Data.Migrations { [DbContext(typeof(AppDbContext))] partial class AppDbContextModelSnapshot : ModelSnapshot diff --git a/src/Evently.Server/Common/Domains/Entities/Account.cs b/src/Evently.Server/Common/Domains/Entities/Account.cs deleted file mode 100644 index c635b1d..0000000 --- a/src/Evently.Server/Common/Domains/Entities/Account.cs +++ /dev/null @@ -1,13 +0,0 @@ -using Microsoft.AspNetCore.Identity; -using System.ComponentModel.DataAnnotations; -using System.Diagnostics.CodeAnalysis; - -namespace Evently.Server.Common.Domains.Entities; - -[SuppressMessage("ReSharper", "PropertyCanBeMadeInitOnly.Global")] -[SuppressMessage("ReSharper", "UnusedMember.Global")] -[SuppressMessage("ReSharper", "CollectionNeverUpdated.Global")] -public class Account : IdentityUser { - [StringLength(100)] public string Name { get; set; } = string.Empty; - public List Bookings { get; set; } = []; -} \ No newline at end of file diff --git a/src/Evently.Server/Common/Domains/Entities/Booking.cs b/src/Evently.Server/Common/Domains/Entities/Booking.cs deleted file mode 100644 index 600ae0e..0000000 --- a/src/Evently.Server/Common/Domains/Entities/Booking.cs +++ /dev/null @@ -1,32 +0,0 @@ -using Evently.Server.Common.Domains.Models; -using Evently.Server.Common.Extensions; -using NanoidDotNet; -using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; -using System.Diagnostics.CodeAnalysis; -using System.Text.Json.Serialization; - -namespace Evently.Server.Common.Domains.Entities; - -[SuppressMessage("ReSharper", "PropertyCanBeMadeInitOnly.Global")] -[SuppressMessage("ReSharper", "UnusedMember.Global")] -public class Booking { - [Key] - [DatabaseGenerated(DatabaseGeneratedOption.None)] - [StringLength(50)] - public string BookingId { get; set; } = $"book_{Nanoid.Generate(size: 10)}"; - - [ForeignKey("Account")] - [SuppressMessage("ReSharper", "EntityFramework.ModelValidation.UnlimitedStringLength", Justification = "MSSQL does not allow specified string limit")] - public string AttendeeId { get; set; } = string.Empty; - [JsonIgnore] public Account? Account { get; set; } - [NotMapped] public AccountDto? AccountDto => Account?.ToAccountDto(); - - public long GatheringId { get; set; } - public Gathering? Gathering { get; set; } - - public DateTimeOffset CreationDateTime { get; set; } = DateTimeOffset.UtcNow; - public DateTimeOffset? CheckInDateTime { get; set; } - public DateTimeOffset? CheckoutDateTime { get; set; } - public DateTimeOffset? CancellationDateTime { get; set; } -} \ No newline at end of file diff --git a/src/Evently.Server/Common/Domains/Entities/Category.cs b/src/Evently.Server/Common/Domains/Entities/Category.cs deleted file mode 100644 index 5d26c69..0000000 --- a/src/Evently.Server/Common/Domains/Entities/Category.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using System.Diagnostics.CodeAnalysis; - -namespace Evently.Server.Common.Domains.Entities; - -[SuppressMessage("ReSharper", "CollectionNeverUpdated.Global")] -[SuppressMessage("ReSharper", "PropertyCanBeMadeInitOnly.Global")] -public class Category { - [Key] public long CategoryId { get; set; } - - [StringLength(100)] public string CategoryName { get; set; } = string.Empty; - public bool Approved { get; set; } - - public List GatheringCategoryDetails { get; set; } = []; -} \ No newline at end of file diff --git a/src/Evently.Server/Common/Domains/Entities/Gathering.cs b/src/Evently.Server/Common/Domains/Entities/Gathering.cs deleted file mode 100644 index 9d990fa..0000000 --- a/src/Evently.Server/Common/Domains/Entities/Gathering.cs +++ /dev/null @@ -1,32 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; -using System.Diagnostics.CodeAnalysis; - -namespace Evently.Server.Common.Domains.Entities; - -[SuppressMessage("ReSharper", "PropertyCanBeMadeInitOnly.Global")] -[SuppressMessage("ReSharper", "CollectionNeverUpdated.Global")] -public class Gathering { - [Key] - [DatabaseGenerated(DatabaseGeneratedOption.Identity)] - public long GatheringId { get; set; } - - [StringLength(100)] public string Name { get; set; } = string.Empty; - - [StringLength(10_000)] public string Description { get; set; } = string.Empty; - - public DateTimeOffset Start { get; set; } = DateTimeOffset.UtcNow; - public DateTimeOffset End { get; set; } = DateTimeOffset.UtcNow; - - [StringLength(100)] public string Location { get; set; } = string.Empty; - - [StringLength(1000)] public string? CoverSrc { get; set; } = string.Empty; - - // convenience field that acts as a readonly field for Account that created the Gathering - [ForeignKey("Account")] - [StringLength(100)] public string OrganiserId { get; set; } = string.Empty; - public DateTimeOffset? CancellationDateTime { get; set; } - - public List Bookings { get; set; } = []; - public List GatheringCategoryDetails { get; set; } = []; -} \ No newline at end of file diff --git a/src/Evently.Server/Common/Domains/Entities/GatheringCategoryDetail.cs b/src/Evently.Server/Common/Domains/Entities/GatheringCategoryDetail.cs deleted file mode 100644 index a01019c..0000000 --- a/src/Evently.Server/Common/Domains/Entities/GatheringCategoryDetail.cs +++ /dev/null @@ -1,14 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using System.Diagnostics.CodeAnalysis; - -namespace Evently.Server.Common.Domains.Entities; - -[PrimaryKey(propertyName: nameof(GatheringId), nameof(CategoryId))] -[SuppressMessage("ReSharper", "PropertyCanBeMadeInitOnly.Global")] -[SuppressMessage("ReSharper", "UnusedMember.Global")] -public class GatheringCategoryDetail { - public long GatheringId { get; set; } - public Gathering? Gathering { get; set; } - public long CategoryId { get; set; } - public Category? Category { get; set; } -} \ No newline at end of file diff --git a/src/Evently.Server/Common/Domains/Exceptions/ExternalLoginProviderException.cs b/src/Evently.Server/Common/Domains/Exceptions/ExternalLoginProviderException.cs deleted file mode 100644 index 556d6c3..0000000 --- a/src/Evently.Server/Common/Domains/Exceptions/ExternalLoginProviderException.cs +++ /dev/null @@ -1,4 +0,0 @@ -namespace Evently.Server.Common.Domains.Exceptions; - -public class ExternalLoginProviderException(string provider, string message) : - Exception($"External login provider: {provider} error occurred: {message}"); \ No newline at end of file diff --git a/src/Evently.Server/Common/Domains/Interfaces/IAccountsService.cs b/src/Evently.Server/Common/Domains/Interfaces/IAccountsService.cs deleted file mode 100644 index 182095e..0000000 --- a/src/Evently.Server/Common/Domains/Interfaces/IAccountsService.cs +++ /dev/null @@ -1,9 +0,0 @@ -using Evently.Server.Common.Domains.Entities; -using System.Security.Claims; - -namespace Evently.Server.Common.Domains.Interfaces; - -public interface IAccountsService { - Task ExternalLogin(ClaimsPrincipal claimsPrincipal, string loginProvider); - Task FindByClaimsPrincipalAsync(ClaimsPrincipal claimsPrincipal); -} \ No newline at end of file diff --git a/src/Evently.Server/Common/Domains/Interfaces/IBookingService.cs b/src/Evently.Server/Common/Domains/Interfaces/IBookingService.cs deleted file mode 100644 index 70c0695..0000000 --- a/src/Evently.Server/Common/Domains/Interfaces/IBookingService.cs +++ /dev/null @@ -1,15 +0,0 @@ -using Evently.Server.Common.Domains.Entities; -using Evently.Server.Common.Domains.Models; - -namespace Evently.Server.Common.Domains.Interfaces; - -public interface IBookingService { - Task GetBooking(string bookingId); - Task> GetBookings(string? accountId, long? gatheringId, - DateTimeOffset? checkInStart, DateTimeOffset? checkInEnd, - DateTimeOffset? gatheringStartBefore, DateTimeOffset? gatheringStartAfter, DateTimeOffset? gatheringEndBefore, DateTimeOffset? gatheringEndAfter, - bool? isCancelled, int? offset, int? limit); - Task CreateBooking(BookingReqDto bookingReqDto); - Task UpdateBooking(string bookingId, BookingReqDto bookingReqDto); - Task RenderTicket(string bookingId); -} \ No newline at end of file diff --git a/src/Evently.Server/Common/Domains/Interfaces/ICategoryService.cs b/src/Evently.Server/Common/Domains/Interfaces/ICategoryService.cs deleted file mode 100644 index 6e6f7b5..0000000 --- a/src/Evently.Server/Common/Domains/Interfaces/ICategoryService.cs +++ /dev/null @@ -1,9 +0,0 @@ -using Evently.Server.Common.Domains.Entities; -using Evently.Server.Common.Domains.Models; - -namespace Evently.Server.Common.Domains.Interfaces; - -public interface ICategoryService { - Task> GetCategories(long? gatheringId, bool? approved); - Task CreateCategory(Category category); -} \ No newline at end of file diff --git a/src/Evently.Server/Common/Domains/Interfaces/IEmailerAdapter.cs b/src/Evently.Server/Common/Domains/Interfaces/IEmailerAdapter.cs deleted file mode 100644 index e5affa4..0000000 --- a/src/Evently.Server/Common/Domains/Interfaces/IEmailerAdapter.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Evently.Server.Common.Domains.Interfaces; - -public interface IEmailerAdapter { - // senderEmail: Either actual Sender email or email of the third party that IEmailer sends on behalf of. - Task SendEmailAsync(string senderEmail, string recipientEmail, string subject, string body); -} \ No newline at end of file diff --git a/src/Evently.Server/Common/Domains/Interfaces/IGatheringService.cs b/src/Evently.Server/Common/Domains/Interfaces/IGatheringService.cs deleted file mode 100644 index e1ad4f8..0000000 --- a/src/Evently.Server/Common/Domains/Interfaces/IGatheringService.cs +++ /dev/null @@ -1,25 +0,0 @@ -using Evently.Server.Common.Domains.Entities; -using Evently.Server.Common.Domains.Models; - -namespace Evently.Server.Common.Domains.Interfaces; - -public interface IGatheringService { - Task GetGathering(long gatheringId); - - Task> GetGatherings( - string? attendeeId, - string? organiserId, - string? name, - DateTimeOffset? startDateBefore, - DateTimeOffset? startDateAfter, - DateTimeOffset? endDateBefore, - DateTimeOffset? endDateAfter, - bool? isCancelled, - HashSet? categoryIds, - int? offset, - int? limit); - - Task CreateGathering(GatheringReqDto gatheringReqDto); - Task UpdateGathering(long gatheringId, GatheringReqDto gatheringReqDto); - Task DeleteGathering(long gatheringId); -} \ No newline at end of file diff --git a/src/Evently.Server/Common/Domains/Interfaces/IMediaRenderer.cs b/src/Evently.Server/Common/Domains/Interfaces/IMediaRenderer.cs deleted file mode 100644 index f2ee977..0000000 --- a/src/Evently.Server/Common/Domains/Interfaces/IMediaRenderer.cs +++ /dev/null @@ -1,14 +0,0 @@ -using Microsoft.AspNetCore.Components; -using System.Diagnostics.CodeAnalysis; - -namespace Evently.Server.Common.Domains.Interfaces; - -public interface IMediaRenderer { - Task RenderComponentHtml(Dictionary dictionary) where T : IComponent; - BinaryData RenderQr(string qrData); - - [SuppressMessage("ReSharper", - "UnusedMember.Global", - Justification = "May need to convert ticket HTML to PDF in future")] - BinaryData RenderPdf(string html); -} \ No newline at end of file diff --git a/src/Evently.Server/Common/Domains/Interfaces/IObjectStorageService.cs b/src/Evently.Server/Common/Domains/Interfaces/IObjectStorageService.cs deleted file mode 100644 index 2763b08..0000000 --- a/src/Evently.Server/Common/Domains/Interfaces/IObjectStorageService.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.Diagnostics.CodeAnalysis; - -namespace Evently.Server.Common.Domains.Interfaces; - -[SuppressMessage("ReSharper", "UnusedMember.Global")] -public interface IObjectStorageService { - // mimeType determines the browser's behaviour when the file is accessed directly via the browser, - // e.g. download ("application/octet-stream") or view ("images/*)" the file. - Task UploadFile(string containerName, string fileName, BinaryData binaryData, string mimeType = "application/octet-stream"); - Task GetFileUri(string containerName, string fileName); - Task GetFile(string containerName, string fileName); - Task IsFileExists(string containerName, string fileName); - Task PassesContentModeration(BinaryData binaryData); -} \ No newline at end of file diff --git a/src/Evently.Server/Common/Domains/Models/BookingReqDto.cs b/src/Evently.Server/Common/Domains/Models/BookingReqDto.cs deleted file mode 100644 index e0028c0..0000000 --- a/src/Evently.Server/Common/Domains/Models/BookingReqDto.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace Evently.Server.Common.Domains.Models; - -public sealed record BookingReqDto( - string BookingId, - string AttendeeId, - long GatheringId, - DateTimeOffset CreationDateTime, - DateTimeOffset? CheckInDateTime, - DateTimeOffset? CheckoutDateTime, - DateTimeOffset? CancellationDateTime); \ No newline at end of file diff --git a/src/Evently.Server/Common/Domains/Models/GatheringReqDto.cs b/src/Evently.Server/Common/Domains/Models/GatheringReqDto.cs deleted file mode 100644 index 1f45531..0000000 --- a/src/Evently.Server/Common/Domains/Models/GatheringReqDto.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace Evently.Server.Common.Domains.Models; - -public sealed record GatheringReqDto( - long GatheringId, - string Name, - string Description, - DateTimeOffset Start, - DateTimeOffset End, - DateTimeOffset? CancellationDateTime, - string Location, - string OrganiserId, - string? CoverSrc, - List GatheringCategoryDetails -); \ No newline at end of file diff --git a/src/Evently.Server/Common/Domains/Models/PageResult.cs b/src/Evently.Server/Common/Domains/Models/PageResult.cs deleted file mode 100644 index 217de02..0000000 --- a/src/Evently.Server/Common/Domains/Models/PageResult.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Evently.Server.Common.Domains.Models; - -public sealed class PageResult { - public List Items { get; init; } = []; - public int TotalCount { get; init; } -} \ No newline at end of file diff --git a/src/Evently.Server/Common/Domains/Models/Settings.cs b/src/Evently.Server/Common/Domains/Models/Settings.cs deleted file mode 100644 index 073e9e7..0000000 --- a/src/Evently.Server/Common/Domains/Models/Settings.cs +++ /dev/null @@ -1,43 +0,0 @@ -using System.ComponentModel.DataAnnotations.Schema; -using System.Diagnostics.CodeAnalysis; - -namespace Evently.Server.Common.Domains.Models; - -public sealed class Settings { - public StorageAccount StorageAccount { get; init; } = new(); - - [NotMapped] public AuthSetting Authentication { get; init; } = new(); - - [NotMapped] public EmailSettings EmailSettings { get; init; } = new(); - [NotMapped] public AzureAIFoundry AzureAiFoundry { get; init; } = new(); -} - -[SuppressMessage("ReSharper", "AutoPropertyCanBeMadeGetOnly.Global")] -public sealed class StorageAccount { - public string AccountName { get; init; } = string.Empty; - public string AzureStorageConnectionString { get; init; } = string.Empty; -} - -[SuppressMessage("ReSharper", "AutoPropertyCanBeMadeGetOnly.Global")] -public sealed class AuthSetting { - public OAuthSetting Google { get; init; } = new(); -} - -[SuppressMessage("ReSharper", "AutoPropertyCanBeMadeGetOnly.Global")] -public sealed class OAuthSetting { - public string ClientId { get; init; } = string.Empty; - public string ClientSecret { get; init; } = string.Empty; -} - -[SuppressMessage("ReSharper", "AutoPropertyCanBeMadeGetOnly.Global")] -public sealed class EmailSettings { - public string ActualFrom { get; init; } = string.Empty; - public string SmtpPassword { get; init; } = string.Empty; -} - -[SuppressMessage("ReSharper", "AutoPropertyCanBeMadeGetOnly.Global")] -// ReSharper disable once InconsistentNaming -public sealed class AzureAIFoundry { - public string ContentSafetyKey { get; init; } = string.Empty; - public string ContentSafetyEndpoint { get; init; } = string.Empty; -} \ No newline at end of file diff --git a/src/Evently.Server/Common/Extensions/LoggerExtension.cs b/src/Evently.Server/Common/Extensions/LoggerExtension.cs index eb99e5f..19cea1c 100644 --- a/src/Evently.Server/Common/Extensions/LoggerExtension.cs +++ b/src/Evently.Server/Common/Extensions/LoggerExtension.cs @@ -1,39 +1,45 @@ namespace Evently.Server.Common.Extensions; -public static partial class LoggerExtension { - [LoggerMessage( - EventId = 1, - Level = LogLevel.Information, - Message = "{key}: {value}")] - public static partial void LogValue( - this ILogger logger, string key, string? value); +public static partial class LoggerExtension +{ + [LoggerMessage(EventId = 1, Level = LogLevel.Information, Message = "{key}: {value}")] + public static partial void LogValue(this ILogger logger, string key, string? value); - [LoggerMessage( - EventId = 2, - Level = LogLevel.Information, - Message = "Callback Url: {callbackUrl}")] - public static partial void LogCallbackUrl( - this ILogger logger, string? callbackUrl); + [LoggerMessage( + EventId = 2, + Level = LogLevel.Information, + Message = "Callback Url: {callbackUrl}" + )] + public static partial void LogCallbackUrl(this ILogger logger, string? callbackUrl); - [LoggerMessage( - EventId = 3, - Level = LogLevel.Information, - Message = "Email sent successfully to {email}")] - public static partial void LogSuccessEmail( - this ILogger logger, string? email); + [LoggerMessage( + EventId = 3, + Level = LogLevel.Information, + Message = "Email sent successfully to {email}" + )] + public static partial void LogSuccessEmail(this ILogger logger, string? email); - [LoggerMessage( - EventId = 4, - Level = LogLevel.Error, - Message = "Error occurred at {context}: {errorMsg}")] - public static partial void LogErrorContext( - this ILogger logger, string context, string errorMsg); + [LoggerMessage( + EventId = 4, + Level = LogLevel.Error, + Message = "Error occurred at {context}: {errorMsg}" + )] + public static partial void LogErrorContext( + this ILogger logger, + string context, + string errorMsg + ); - [LoggerMessage( - EventId = 5, - Level = LogLevel.Error, - Message = "Analyze image failed. Status code: {statusCode}, Error code: {errorCode}, Error message: {errMsg}")] - public static partial void LogContentModerationError( - this ILogger logger, string statusCode, string errorCode, string errMsg); - // -} \ No newline at end of file + [LoggerMessage( + EventId = 5, + Level = LogLevel.Error, + Message = "Analyze image failed. Status code: {statusCode}, Error code: {errorCode}, Error message: {errMsg}" + )] + public static partial void LogContentModerationError( + this ILogger logger, + string statusCode, + string errorCode, + string errMsg + ); + // +} diff --git a/src/Evently.Server/Common/Extensions/MapperExtension.cs b/src/Evently.Server/Common/Extensions/MapperExtension.cs index b8bf244..f348bef 100644 --- a/src/Evently.Server/Common/Extensions/MapperExtension.cs +++ b/src/Evently.Server/Common/Extensions/MapperExtension.cs @@ -1,61 +1,72 @@ -using Evently.Server.Common.Domains.Entities; -using Evently.Server.Common.Domains.Models; +using Evently.Server.Domains.Entities; +using Evently.Server.Domains.Models; namespace Evently.Server.Common.Extensions; -public static class MapperExtension { - public static Gathering ToGathering(this GatheringReqDto gatheringReqDto) { - List gatheringCategoryDetails = gatheringReqDto.GatheringCategoryDetails - .Select((detail) => new GatheringCategoryDetail { - GatheringId = gatheringReqDto.GatheringId, - CategoryId = detail.CategoryId, - }) - .ToList(); - Gathering gathering = new() { - GatheringId = gatheringReqDto.GatheringId, - Name = gatheringReqDto.Name, - Description = gatheringReqDto.Description, - Start = gatheringReqDto.Start, - End = gatheringReqDto.End, - CancellationDateTime = gatheringReqDto.CancellationDateTime, - Location = gatheringReqDto.Location, - OrganiserId = gatheringReqDto.OrganiserId, - CoverSrc = gatheringReqDto.CoverSrc, - GatheringCategoryDetails = gatheringCategoryDetails, - }; - return gathering; - } +public static class MapperExtension +{ + public static Gathering ToGathering(this GatheringReqDto gatheringReqDto) + { + List gatheringCategoryDetails = gatheringReqDto + .GatheringCategoryDetails.Select( + (detail) => + new GatheringCategoryDetail + { + GatheringId = gatheringReqDto.GatheringId, + CategoryId = detail.CategoryId, + } + ) + .ToList(); + Gathering gathering = new() + { + GatheringId = gatheringReqDto.GatheringId, + Name = gatheringReqDto.Name, + Description = gatheringReqDto.Description, + Start = gatheringReqDto.Start, + End = gatheringReqDto.End, + CancellationDateTime = gatheringReqDto.CancellationDateTime, + Location = gatheringReqDto.Location, + OrganiserId = gatheringReqDto.OrganiserId, + CoverSrc = gatheringReqDto.CoverSrc, + GatheringCategoryDetails = gatheringCategoryDetails, + }; + return gathering; + } - public static Booking ToBooking(this BookingReqDto bookingReqDto) { - return new Booking { - BookingId = bookingReqDto.BookingId, - AttendeeId = bookingReqDto.AttendeeId, - GatheringId = bookingReqDto.GatheringId, - CreationDateTime = bookingReqDto.CreationDateTime, - CheckInDateTime = bookingReqDto.CheckInDateTime, - CheckoutDateTime = bookingReqDto.CheckoutDateTime, - CancellationDateTime = bookingReqDto.CancellationDateTime, - }; - } + public static Booking ToBooking(this BookingReqDto bookingReqDto) + { + return new Booking + { + BookingId = bookingReqDto.BookingId, + AttendeeId = bookingReqDto.AttendeeId, + GatheringId = bookingReqDto.GatheringId, + CreationDateTime = bookingReqDto.CreationDateTime, + CheckInDateTime = bookingReqDto.CheckInDateTime, + CheckoutDateTime = bookingReqDto.CheckoutDateTime, + CancellationDateTime = bookingReqDto.CancellationDateTime, + }; + } - public static BookingReqDto ToBookingDto(this Booking booking) { - return new BookingReqDto( - booking.BookingId, - booking.AttendeeId, - booking.GatheringId, - booking.CreationDateTime, - booking.CheckInDateTime, - booking.CheckoutDateTime, - booking.CancellationDateTime - ); - } + public static BookingReqDto ToBookingDto(this Booking booking) + { + return new BookingReqDto( + booking.BookingId, + booking.AttendeeId, + booking.GatheringId, + booking.CreationDateTime, + booking.CheckInDateTime, + booking.CheckoutDateTime, + booking.CancellationDateTime + ); + } - public static AccountDto ToAccountDto(this Account account) { - return new AccountDto( - account.Id, - Email: account.Email ?? string.Empty, - Username: account.UserName ?? string.Empty, - account.Name - ); - } -} \ No newline at end of file + public static AccountDto ToAccountDto(this Account account) + { + return new AccountDto( + account.Id, + Email: account.Email ?? string.Empty, + Username: account.UserName ?? string.Empty, + account.Name + ); + } +} diff --git a/src/Evently.Server/Common/Extensions/ServiceContainerExtensions.cs b/src/Evently.Server/Common/Extensions/ServiceContainerExtensions.cs index 51a1105..a9cee08 100644 --- a/src/Evently.Server/Common/Extensions/ServiceContainerExtensions.cs +++ b/src/Evently.Server/Common/Extensions/ServiceContainerExtensions.cs @@ -1,22 +1,26 @@ -using Evently.Server.Common.Domains.Models; +using Evently.Server.Domains.Models; using Microsoft.Extensions.Options; namespace Evently.Server.Common.Extensions; -public static class ServiceContainerExtensions { - public static IOptions LoadAppConfiguration(this IServiceCollection services, - ConfigurationManager configuration) { - // load .env variables, in addition to appsettings.json that is loaded by default - configuration.AddEnvironmentVariables(); +public static class ServiceContainerExtensions +{ + public static IOptions LoadAppConfiguration( + this IServiceCollection services, + ConfigurationManager configuration + ) + { + // load .env variables, in addition to appsettings.json that is loaded by default + configuration.AddEnvironmentVariables(); - // Inject IOptions into the App - services.Configure(configuration); + // Inject IOptions into the App + services.Configure(configuration); - // Bind all key value pairs to the Settings Object and return it, as it is used in Program.cs - Settings settings = new(); - configuration.Bind(settings); + // Bind all key value pairs to the Settings Object and return it, as it is used in Program.cs + Settings settings = new(); + configuration.Bind(settings); - IOptions options = Options.Create(settings); - return options; - } -} \ No newline at end of file + IOptions options = Options.Create(settings); + return options; + } +} diff --git a/src/Evently.Server/Common/Extensions/UtilsExtension.cs b/src/Evently.Server/Common/Extensions/UtilsExtension.cs index 135b954..d9ce25e 100644 --- a/src/Evently.Server/Common/Extensions/UtilsExtension.cs +++ b/src/Evently.Server/Common/Extensions/UtilsExtension.cs @@ -1,22 +1,23 @@ namespace Evently.Server.Common.Extensions; -public static class UtilsExtension { - public static Uri RootUri(this HttpRequest request) { - UriBuilder uriBuilder = new() { - Scheme = request.Scheme, - Host = request.Host.Host, - }; - if (request.Host.Port.HasValue) { - uriBuilder.Port = request.Host.Port.Value; - } +public static class UtilsExtension +{ + public static Uri RootUri(this HttpRequest request) + { + UriBuilder uriBuilder = new() { Scheme = request.Scheme, Host = request.Host.Host }; + if (request.Host.Port.HasValue) + { + uriBuilder.Port = request.Host.Port.Value; + } - return uriBuilder.Uri; - } + return uriBuilder.Uri; + } - public static async Task ToBinaryData(this IFormFile file) { - using MemoryStream ms = new(); - await file.CopyToAsync(ms); - byte[] bytes = ms.ToArray(); - return BinaryData.FromBytes(bytes); - } -} \ No newline at end of file + public static async Task ToBinaryData(this IFormFile file) + { + using MemoryStream ms = new(); + await file.CopyToAsync(ms); + byte[] bytes = ms.ToArray(); + return BinaryData.FromBytes(bytes); + } +} diff --git a/src/Evently.Server/Common/Middlewares/GlobalExceptionHandler.cs b/src/Evently.Server/Common/Middlewares/GlobalExceptionHandler.cs index 899e8dc..4695484 100644 --- a/src/Evently.Server/Common/Middlewares/GlobalExceptionHandler.cs +++ b/src/Evently.Server/Common/Middlewares/GlobalExceptionHandler.cs @@ -2,36 +2,49 @@ namespace Evently.Server.Common.Middlewares; -public class GlobalExceptionHandler(ILogger logger) : IExceptionHandler { +public class GlobalExceptionHandler(ILogger logger) : IExceptionHandler +{ + public async ValueTask TryHandleAsync( + HttpContext httpContext, + Exception exception, + CancellationToken cancellationToken + ) + { + string exceptionMessage = exception.Message; + logger.LogError( + "Error Message: {exceptionMessage}, Time of occurrence {time}", + exceptionMessage, + DateTime.UtcNow + ); - public async ValueTask TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken) { - string exceptionMessage = exception.Message; - logger.LogError( - "Error Message: {exceptionMessage}, Time of occurrence {time}", - exceptionMessage, - DateTime.UtcNow); + switch (exception) + { + case ArgumentException: + httpContext.Response.StatusCode = 400; + await httpContext.Response.WriteAsJsonAsync( + value: new + { + Title = "Validation Error", + Detail = exceptionMessage, + Status = StatusCodes.Status400BadRequest, + }, + cancellationToken + ); + break; + default: + httpContext.Response.StatusCode = 500; + await httpContext.Response.WriteAsJsonAsync( + value: new + { + Title = "Server Error", + Detail = "An unexpected error occurred.", + Status = StatusCodes.Status500InternalServerError, + }, + cancellationToken + ); + break; + } - switch (exception) { - case ArgumentException: - httpContext.Response.StatusCode = 400; - await httpContext.Response.WriteAsJsonAsync(value: new { - Title = "Validation Error", - Detail = exceptionMessage, - Status = StatusCodes.Status400BadRequest, - }, - cancellationToken); - break; - default: - httpContext.Response.StatusCode = 500; - await httpContext.Response.WriteAsJsonAsync(value: new { - Title = "Server Error", - Detail = "An unexpected error occurred.", - Status = StatusCodes.Status500InternalServerError, - }, - cancellationToken); - break; - } - - return true; - } -} \ No newline at end of file + return true; + } +} diff --git a/src/Evently.Server/Dockerfile b/src/Evently.Server/Dockerfile index 5cb75f5..4a84dd2 100644 --- a/src/Evently.Server/Dockerfile +++ b/src/Evently.Server/Dockerfile @@ -1,6 +1,6 @@ # See https://aka.ms/customizecontainer to learn how to customize your debug container and how Visual Studio uses this Dockerfile to build your images for faster debugging. # https://learn.microsoft.com/en-us/dotnet/core/docker/build-container?tabs=windows&pivots=dotnet-9-0#create-the-dockerfile -FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build RUN apt-get update RUN apt-get install curl --yes RUN curl -sL https://deb.nodesource.com/setup_20.x | bash @@ -19,7 +19,7 @@ RUN ls RUN dotnet publish ./src/Evently.Server/Evently.Server.csproj -c Release -o publish -p:UseAppHost=false # Build runtime image -FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS run +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS run WORKDIR /App/server COPY --from=build /App/publish . diff --git a/src/Evently.Server/Domains/Entities/Account.cs b/src/Evently.Server/Domains/Entities/Account.cs new file mode 100644 index 0000000..e24c4ab --- /dev/null +++ b/src/Evently.Server/Domains/Entities/Account.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations; +using System.Diagnostics.CodeAnalysis; +using Microsoft.AspNetCore.Identity; + +namespace Evently.Server.Domains.Entities; + +[SuppressMessage("ReSharper", "PropertyCanBeMadeInitOnly.Global")] +[SuppressMessage("ReSharper", "UnusedMember.Global")] +[SuppressMessage("ReSharper", "CollectionNeverUpdated.Global")] +public class Account : IdentityUser +{ + [StringLength(100)] + public string Name { get; set; } = string.Empty; + public List Bookings { get; set; } = []; +} diff --git a/src/Evently.Server/Domains/Entities/Booking.cs b/src/Evently.Server/Domains/Entities/Booking.cs new file mode 100644 index 0000000..b04cccd --- /dev/null +++ b/src/Evently.Server/Domains/Entities/Booking.cs @@ -0,0 +1,41 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; +using Evently.Server.Common.Extensions; +using Evently.Server.Domains.Models; +using NanoidDotNet; + +namespace Evently.Server.Domains.Entities; + +[SuppressMessage("ReSharper", "PropertyCanBeMadeInitOnly.Global")] +[SuppressMessage("ReSharper", "UnusedMember.Global")] +public class Booking +{ + [Key] + [DatabaseGenerated(DatabaseGeneratedOption.None)] + [StringLength(50)] + public string BookingId { get; set; } = $"book_{Nanoid.Generate(size: 10)}"; + + [ForeignKey("Account")] + [SuppressMessage( + "ReSharper", + "EntityFramework.ModelValidation.UnlimitedStringLength", + Justification = "MSSQL does not allow specified string limit" + )] + public string AttendeeId { get; set; } = string.Empty; + + [JsonIgnore] + public Account? Account { get; set; } + + [NotMapped] + public AccountDto? AccountDto => Account?.ToAccountDto(); + + public long GatheringId { get; set; } + public Gathering? Gathering { get; set; } + + public DateTimeOffset CreationDateTime { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset? CheckInDateTime { get; set; } + public DateTimeOffset? CheckoutDateTime { get; set; } + public DateTimeOffset? CancellationDateTime { get; set; } +} diff --git a/src/Evently.Server/Domains/Entities/Category.cs b/src/Evently.Server/Domains/Entities/Category.cs new file mode 100644 index 0000000..38e6cf3 --- /dev/null +++ b/src/Evently.Server/Domains/Entities/Category.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations; +using System.Diagnostics.CodeAnalysis; + +namespace Evently.Server.Domains.Entities; + +[SuppressMessage("ReSharper", "CollectionNeverUpdated.Global")] +[SuppressMessage("ReSharper", "PropertyCanBeMadeInitOnly.Global")] +public class Category +{ + [Key] + public long CategoryId { get; set; } + + [StringLength(100)] + public string CategoryName { get; set; } = string.Empty; + public bool Approved { get; set; } + + public List GatheringCategoryDetails { get; set; } = []; +} diff --git a/src/Evently.Server/Domains/Entities/Gathering.cs b/src/Evently.Server/Domains/Entities/Gathering.cs new file mode 100644 index 0000000..e6c6936 --- /dev/null +++ b/src/Evently.Server/Domains/Entities/Gathering.cs @@ -0,0 +1,39 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using System.Diagnostics.CodeAnalysis; + +namespace Evently.Server.Domains.Entities; + +[SuppressMessage("ReSharper", "PropertyCanBeMadeInitOnly.Global")] +[SuppressMessage("ReSharper", "CollectionNeverUpdated.Global")] +public class Gathering +{ + [Key] + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public long GatheringId { get; set; } + + [StringLength(100)] + public string Name { get; set; } = string.Empty; + + [StringLength(10_000)] + public string Description { get; set; } = string.Empty; + + public DateTimeOffset Start { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset End { get; set; } = DateTimeOffset.UtcNow; + + [StringLength(100)] + public string Location { get; set; } = string.Empty; + + [StringLength(1000)] + public string? CoverSrc { get; set; } = string.Empty; + + // convenience field that acts as a readonly field for Account that created the Gathering + [ForeignKey("Account")] + [StringLength(100)] + public string OrganiserId { get; set; } = string.Empty; + + public DateTimeOffset? CancellationDateTime { get; set; } + + public List Bookings { get; set; } = []; + public List GatheringCategoryDetails { get; set; } = []; +} diff --git a/src/Evently.Server/Domains/Entities/GatheringCategoryDetail.cs b/src/Evently.Server/Domains/Entities/GatheringCategoryDetail.cs new file mode 100644 index 0000000..7781af2 --- /dev/null +++ b/src/Evently.Server/Domains/Entities/GatheringCategoryDetail.cs @@ -0,0 +1,15 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.EntityFrameworkCore; + +namespace Evently.Server.Domains.Entities; + +[PrimaryKey(propertyName: nameof(GatheringId), nameof(CategoryId))] +[SuppressMessage("ReSharper", "PropertyCanBeMadeInitOnly.Global")] +[SuppressMessage("ReSharper", "UnusedMember.Global")] +public class GatheringCategoryDetail +{ + public long GatheringId { get; set; } + public Gathering? Gathering { get; set; } + public long CategoryId { get; set; } + public Category? Category { get; set; } +} diff --git a/src/Evently.Server/Domains/Exceptions/ExternalLoginProviderException.cs b/src/Evently.Server/Domains/Exceptions/ExternalLoginProviderException.cs new file mode 100644 index 0000000..c97efcb --- /dev/null +++ b/src/Evently.Server/Domains/Exceptions/ExternalLoginProviderException.cs @@ -0,0 +1,4 @@ +namespace Evently.Server.Domains.Exceptions; + +public class ExternalLoginProviderException(string provider, string message) + : Exception($"External login provider: {provider} error occurred: {message}"); diff --git a/src/Evently.Server/Domains/Interfaces/IAccountsService.cs b/src/Evently.Server/Domains/Interfaces/IAccountsService.cs new file mode 100644 index 0000000..2d3c4a4 --- /dev/null +++ b/src/Evently.Server/Domains/Interfaces/IAccountsService.cs @@ -0,0 +1,10 @@ +using System.Security.Claims; +using Evently.Server.Domains.Entities; + +namespace Evently.Server.Domains.Interfaces; + +public interface IAccountsService +{ + Task ExternalLogin(ClaimsPrincipal claimsPrincipal, string loginProvider); + Task FindByClaimsPrincipalAsync(ClaimsPrincipal claimsPrincipal); +} diff --git a/src/Evently.Server/Domains/Interfaces/IBookingService.cs b/src/Evently.Server/Domains/Interfaces/IBookingService.cs new file mode 100644 index 0000000..1d42d6b --- /dev/null +++ b/src/Evently.Server/Domains/Interfaces/IBookingService.cs @@ -0,0 +1,27 @@ +using Evently.Server.Domains.Entities; +using Evently.Server.Domains.Models; + +namespace Evently.Server.Domains.Interfaces; + +public interface IBookingService +{ + Task GetBooking(string bookingId); + + Task> GetBookings( + string? accountId, + long? gatheringId, + DateTimeOffset? checkInStart, + DateTimeOffset? checkInEnd, + DateTimeOffset? gatheringStartBefore, + DateTimeOffset? gatheringStartAfter, + DateTimeOffset? gatheringEndBefore, + DateTimeOffset? gatheringEndAfter, + bool? isCancelled, + int? offset, + int? limit + ); + + Task CreateBooking(BookingReqDto bookingReqDto); + Task UpdateBooking(string bookingId, BookingReqDto bookingReqDto); + Task RenderTicket(string bookingId); +} diff --git a/src/Evently.Server/Domains/Interfaces/ICategoryService.cs b/src/Evently.Server/Domains/Interfaces/ICategoryService.cs new file mode 100644 index 0000000..c4eea00 --- /dev/null +++ b/src/Evently.Server/Domains/Interfaces/ICategoryService.cs @@ -0,0 +1,10 @@ +using Evently.Server.Domains.Entities; +using Evently.Server.Domains.Models; + +namespace Evently.Server.Domains.Interfaces; + +public interface ICategoryService +{ + Task> GetCategories(long? gatheringId, bool? approved); + Task CreateCategory(Category category); +} diff --git a/src/Evently.Server/Domains/Interfaces/IEmailerAdapter.cs b/src/Evently.Server/Domains/Interfaces/IEmailerAdapter.cs new file mode 100644 index 0000000..3e3721d --- /dev/null +++ b/src/Evently.Server/Domains/Interfaces/IEmailerAdapter.cs @@ -0,0 +1,7 @@ +namespace Evently.Server.Domains.Interfaces; + +public interface IEmailerAdapter +{ + // senderEmail: Either actual Sender email or email of the third party that IEmailer sends on behalf of. + Task SendEmailAsync(string senderEmail, string recipientEmail, string subject, string body); +} diff --git a/src/Evently.Server/Domains/Interfaces/IGatheringService.cs b/src/Evently.Server/Domains/Interfaces/IGatheringService.cs new file mode 100644 index 0000000..d7d0f80 --- /dev/null +++ b/src/Evently.Server/Domains/Interfaces/IGatheringService.cs @@ -0,0 +1,27 @@ +using Evently.Server.Domains.Entities; +using Evently.Server.Domains.Models; + +namespace Evently.Server.Domains.Interfaces; + +public interface IGatheringService +{ + Task GetGathering(long gatheringId); + + Task> GetGatherings( + string? attendeeId, + string? organiserId, + string? name, + DateTimeOffset? startDateBefore, + DateTimeOffset? startDateAfter, + DateTimeOffset? endDateBefore, + DateTimeOffset? endDateAfter, + bool? isCancelled, + HashSet? categoryIds, + int? offset, + int? limit + ); + + Task CreateGathering(GatheringReqDto gatheringReqDto); + Task UpdateGathering(long gatheringId, GatheringReqDto gatheringReqDto); + Task DeleteGathering(long gatheringId); +} diff --git a/src/Evently.Server/Domains/Interfaces/IMediaRenderer.cs b/src/Evently.Server/Domains/Interfaces/IMediaRenderer.cs new file mode 100644 index 0000000..f0296c0 --- /dev/null +++ b/src/Evently.Server/Domains/Interfaces/IMediaRenderer.cs @@ -0,0 +1,18 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.AspNetCore.Components; + +namespace Evently.Server.Domains.Interfaces; + +public interface IMediaRenderer +{ + Task RenderComponentHtml(Dictionary dictionary) + where T : IComponent; + BinaryData RenderQr(string qrData); + + [SuppressMessage( + "ReSharper", + "UnusedMember.Global", + Justification = "May need to convert ticket HTML to PDF in future" + )] + BinaryData RenderPdf(string html); +} diff --git a/src/Evently.Server/Domains/Interfaces/IObjectStorageService.cs b/src/Evently.Server/Domains/Interfaces/IObjectStorageService.cs new file mode 100644 index 0000000..6ebdd83 --- /dev/null +++ b/src/Evently.Server/Domains/Interfaces/IObjectStorageService.cs @@ -0,0 +1,21 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Evently.Server.Domains.Interfaces; + +[SuppressMessage("ReSharper", "UnusedMember.Global")] +public interface IObjectStorageService +{ + // mimeType determines the browser's behaviour when the file is accessed directly via the browser, + // e.g. download ("application/octet-stream") or view ("images/*)" the file. + Task UploadFile( + string containerName, + string fileName, + BinaryData binaryData, + string mimeType = "application/octet-stream" + ); + + Task GetFileUri(string containerName, string fileName); + Task GetFile(string containerName, string fileName); + Task IsFileExists(string containerName, string fileName); + Task PassesContentModeration(BinaryData binaryData); +} diff --git a/src/Evently.Server/Common/Domains/Models/AccountDto.cs b/src/Evently.Server/Domains/Models/AccountDto.cs similarity index 61% rename from src/Evently.Server/Common/Domains/Models/AccountDto.cs rename to src/Evently.Server/Domains/Models/AccountDto.cs index 6f38ea7..e251d3f 100644 --- a/src/Evently.Server/Common/Domains/Models/AccountDto.cs +++ b/src/Evently.Server/Domains/Models/AccountDto.cs @@ -1,6 +1,6 @@ using JetBrains.Annotations; -namespace Evently.Server.Common.Domains.Models; +namespace Evently.Server.Domains.Models; [UsedImplicitly] -public sealed record AccountDto(string Id, string Email, string Username, string Name); \ No newline at end of file +public sealed record AccountDto(string Id, string Email, string Username, string Name); diff --git a/src/Evently.Server/Domains/Models/BookingReqDto.cs b/src/Evently.Server/Domains/Models/BookingReqDto.cs new file mode 100644 index 0000000..b1e7357 --- /dev/null +++ b/src/Evently.Server/Domains/Models/BookingReqDto.cs @@ -0,0 +1,11 @@ +namespace Evently.Server.Domains.Models; + +public sealed record BookingReqDto( + string BookingId, + string AttendeeId, + long GatheringId, + DateTimeOffset CreationDateTime, + DateTimeOffset? CheckInDateTime, + DateTimeOffset? CheckoutDateTime, + DateTimeOffset? CancellationDateTime +); diff --git a/src/Evently.Server/Common/Domains/Models/GatheringCategoryDetailDto.cs b/src/Evently.Server/Domains/Models/GatheringCategoryDetailDto.cs similarity index 63% rename from src/Evently.Server/Common/Domains/Models/GatheringCategoryDetailDto.cs rename to src/Evently.Server/Domains/Models/GatheringCategoryDetailDto.cs index 24127ea..ba0ec6d 100644 --- a/src/Evently.Server/Common/Domains/Models/GatheringCategoryDetailDto.cs +++ b/src/Evently.Server/Domains/Models/GatheringCategoryDetailDto.cs @@ -1,6 +1,6 @@ using JetBrains.Annotations; -namespace Evently.Server.Common.Domains.Models; +namespace Evently.Server.Domains.Models; [UsedImplicitly] -public sealed record GatheringCategoryDetailDto(long GatheringId, long CategoryId); \ No newline at end of file +public sealed record GatheringCategoryDetailDto(long GatheringId, long CategoryId); diff --git a/src/Evently.Server/Domains/Models/GatheringReqDto.cs b/src/Evently.Server/Domains/Models/GatheringReqDto.cs new file mode 100644 index 0000000..d3ce03f --- /dev/null +++ b/src/Evently.Server/Domains/Models/GatheringReqDto.cs @@ -0,0 +1,14 @@ +namespace Evently.Server.Domains.Models; + +public sealed record GatheringReqDto( + long GatheringId, + string Name, + string Description, + DateTimeOffset Start, + DateTimeOffset End, + DateTimeOffset? CancellationDateTime, + string Location, + string OrganiserId, + string? CoverSrc, + List GatheringCategoryDetails +); diff --git a/src/Evently.Server/Domains/Models/PageResult.cs b/src/Evently.Server/Domains/Models/PageResult.cs new file mode 100644 index 0000000..9994883 --- /dev/null +++ b/src/Evently.Server/Domains/Models/PageResult.cs @@ -0,0 +1,7 @@ +namespace Evently.Server.Domains.Models; + +public sealed class PageResult +{ + public List Items { get; init; } = []; + public int TotalCount { get; init; } +} diff --git a/src/Evently.Server/Domains/Models/Settings.cs b/src/Evently.Server/Domains/Models/Settings.cs new file mode 100644 index 0000000..95221cd --- /dev/null +++ b/src/Evently.Server/Domains/Models/Settings.cs @@ -0,0 +1,53 @@ +using System.ComponentModel.DataAnnotations.Schema; +using System.Diagnostics.CodeAnalysis; + +namespace Evently.Server.Domains.Models; + +public sealed class Settings +{ + public StorageAccount StorageAccount { get; init; } = new(); + + [NotMapped] + public AuthSetting Authentication { get; init; } = new(); + + [NotMapped] + public EmailSettings EmailSettings { get; init; } = new(); + + [NotMapped] + public AzureAIFoundry AzureAiFoundry { get; init; } = new(); +} + +[SuppressMessage("ReSharper", "AutoPropertyCanBeMadeGetOnly.Global")] +public sealed class StorageAccount +{ + public string AccountName { get; init; } = string.Empty; + public string AzureStorageConnectionString { get; init; } = string.Empty; +} + +[SuppressMessage("ReSharper", "AutoPropertyCanBeMadeGetOnly.Global")] +public sealed class AuthSetting +{ + public OAuthSetting Google { get; init; } = new(); +} + +[SuppressMessage("ReSharper", "AutoPropertyCanBeMadeGetOnly.Global")] +public sealed class OAuthSetting +{ + public string ClientId { get; init; } = string.Empty; + public string ClientSecret { get; init; } = string.Empty; +} + +[SuppressMessage("ReSharper", "AutoPropertyCanBeMadeGetOnly.Global")] +public sealed class EmailSettings +{ + public string ActualFrom { get; init; } = string.Empty; + public string SmtpPassword { get; init; } = string.Empty; +} + +[SuppressMessage("ReSharper", "AutoPropertyCanBeMadeGetOnly.Global")] +// ReSharper disable once InconsistentNaming +public sealed class AzureAIFoundry +{ + public string ContentSafetyKey { get; init; } = string.Empty; + public string ContentSafetyEndpoint { get; init; } = string.Empty; +} diff --git a/src/Evently.Server/Evently.Server.csproj b/src/Evently.Server/Evently.Server.csproj index 839adac..55a151b 100644 --- a/src/Evently.Server/Evently.Server.csproj +++ b/src/Evently.Server/Evently.Server.csproj @@ -1,55 +1,61 @@ - + + + net10.0 + enable + enable + ee94e8bf-92f6-4422-b368-12a6d0a8704d + Linux + ..\evently.client + npm run dev + https://localhost:50071 + - - net9.0 - enable - enable - ee94e8bf-92f6-4422-b368-12a6d0a8704d - Linux - ..\evently.client - npm run dev - https://localhost:50071 - + + + + + + + + + + + + + + + 10.0.0 + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + - - - - - - - - - - - - - - - 9.*-* - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - - - + + + false + + - - - false - - + + <_ContentIncludedByDefault Remove="Common\Adapters\Blazor\BlazorApp.razor" /> + <_ContentIncludedByDefault Remove="Common\Adapters\Blazor\Routes.razor" /> + - - - - \ No newline at end of file + + + + + diff --git a/src/Evently.Server/Features/Accounts/Controllers/AccountController.cs b/src/Evently.Server/Features/Accounts/Controllers/AccountController.cs index 6f240fc..984ebec 100644 --- a/src/Evently.Server/Features/Accounts/Controllers/AccountController.cs +++ b/src/Evently.Server/Features/Accounts/Controllers/AccountController.cs @@ -1,13 +1,13 @@ -using Evently.Server.Common.Domains.Entities; -using Evently.Server.Common.Domains.Exceptions; -using Evently.Server.Common.Domains.Interfaces; +using System.Security.Claims; using Evently.Server.Common.Extensions; +using Evently.Server.Domains.Entities; +using Evently.Server.Domains.Exceptions; +using Evently.Server.Domains.Interfaces; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Google; using Microsoft.AspNetCore.Authentication.MicrosoftAccount; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; -using System.Security.Claims; namespace Evently.Server.Features.Accounts.Controllers; @@ -15,89 +15,109 @@ namespace Evently.Server.Features.Accounts.Controllers; [ApiController] [Route("api/v1/Auth/external")] public sealed class AccountController( - IAccountsService accountService, - ILogger logger) : ControllerBase { - private readonly Dictionary _authSchemes = new() { - { "google", GoogleDefaults.AuthenticationScheme }, - { "microsoft", MicrosoftAccountDefaults.AuthenticationScheme }, - }; + IAccountsService accountService, + ILogger logger +) : ControllerBase +{ + private readonly Dictionary _authSchemes = new() + { + { "google", GoogleDefaults.AuthenticationScheme }, + { "microsoft", MicrosoftAccountDefaults.AuthenticationScheme }, + }; - /** - * Overall auth flow: - * 1. Login from FE browser. FE specified callback URL. - * 2. Lands on "{provider}/login". - * 3. One callback url is appended to the challenge URL in the form of {provider}/callback by the Login method. - * 4. Another callback url is appended to the challenge URL in the form of /signin-google/ by the OAuth middleware in Program.cs. - * 5. User is redirected to the Google Login page. - * 6. On success, redirected to /signin-google/ specified by middleware. - * 7. Middleware redirects to {provider}/callback. - * 8. Callback() method redirects to FE specified callback URL. - */ - [HttpGet("{provider}/login")] - public IActionResult Login(string provider, string? originUrl = "") { - Uri rootUri = Request.RootUri(); - string uri = Url.Action("Callback", "Account", values: new { provider }) ?? ""; - UriBuilder combined = new(rootUri) { - Path = uri, - Query = $"originUrl={originUrl}", - }; + /** + * Overall auth flow: + * 1. Login from FE browser. FE specified callback URL. + * 2. Lands on "{provider}/login". + * 3. One callback url is appended to the challenge URL in the form of {provider}/callback by the Login method. + * 4. Another callback url is appended to the challenge URL in the form of /signin-google/ by the OAuth middleware in Program.cs. + * 5. User is redirected to the Google Login page. + * 6. On success, redirected to /signin-google/ specified by middleware. + * 7. Middleware redirects to {provider}/callback. + * 8. Callback() method redirects to FE specified callback URL. + */ + [HttpGet("{provider}/login")] + public IActionResult Login(string provider, string? originUrl = "") + { + Uri rootUri = Request.RootUri(); + string uri = Url.Action("Callback", "Account", values: new { provider }) ?? ""; + UriBuilder combined = new(rootUri) { Path = uri, Query = $"originUrl={originUrl}" }; - logger.LogCallbackUrl(combined.Uri.AbsoluteUri); - AuthenticationProperties properties = new() { - RedirectUri = combined.Uri.AbsoluteUri, - IsPersistent = true, - }; - return Challenge(properties, _authSchemes.GetValueOrDefault(provider) ?? ""); - } + logger.LogCallbackUrl(combined.Uri.AbsoluteUri); + AuthenticationProperties properties = new() + { + RedirectUri = combined.Uri.AbsoluteUri, + IsPersistent = true, + }; + return Challenge(properties, _authSchemes.GetValueOrDefault(provider) ?? ""); + } - [HttpGet("{provider}/callback")] - public async Task> Callback(string provider, [FromQuery] string originUrl = "") { - AuthenticateResult result = await HttpContext.AuthenticateAsync(GoogleDefaults.AuthenticationScheme); + [HttpGet("{provider}/callback")] + public async Task> Callback( + string provider, + [FromQuery] string originUrl = "" + ) + { + AuthenticateResult result = await HttpContext.AuthenticateAsync( + GoogleDefaults.AuthenticationScheme + ); - if (!result.Succeeded || result.Principal is null) { - return Unauthorized(); - } + if (!result.Succeeded || result.Principal is null) + { + return Unauthorized(); + } - ClaimsPrincipal claimsPrincipal = result.Principal; - if (claimsPrincipal == null) { - throw new ExternalLoginProviderException(provider, "ClaimsPrincipal is null"); - } + ClaimsPrincipal claimsPrincipal = result.Principal; + if (claimsPrincipal == null) + { + throw new ExternalLoginProviderException(provider, "ClaimsPrincipal is null"); + } - await accountService.ExternalLogin(claimsPrincipal, - loginProvider: _authSchemes.GetValueOrDefault(provider) ?? ""); - return Redirect(originUrl); - } + await accountService.ExternalLogin( + claimsPrincipal, + loginProvider: _authSchemes.GetValueOrDefault(provider) ?? "" + ); + return Redirect(originUrl); + } - [HttpGet("account", Name = "GetAccount from Cookie")] - public async Task GetAccount() { - AuthenticateResult result = await HttpContext.AuthenticateAsync(IdentityConstants.ExternalScheme); - if (!result.Succeeded) { - return Unauthorized(); - } + [HttpGet("account", Name = "GetAccount from Cookie")] + public async Task GetAccount() + { + AuthenticateResult result = await HttpContext.AuthenticateAsync( + IdentityConstants.ExternalScheme + ); + if (!result.Succeeded) + { + return Unauthorized(); + } - ClaimsPrincipal principal = result.Principal ?? new ClaimsPrincipal(); - Account? user = await accountService.FindByClaimsPrincipalAsync(principal); - if (user is null) { - return NotFound(new { message = "User not found" }); - } + ClaimsPrincipal principal = result.Principal ?? new ClaimsPrincipal(); + Account? user = await accountService.FindByClaimsPrincipalAsync(principal); + if (user is null) + { + return NotFound(new { message = "User not found" }); + } - return Ok(user.ToAccountDto()); - } + return Ok(user.ToAccountDto()); + } - [HttpPost("logout")] - public async Task Logout(string? redirectUrl = "") { - // Sign out of an external identity provider (if used) - AuthenticationProperties authProps = new() { - RedirectUri = redirectUrl, - IsPersistent = true, - }; - await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme, authProps); - // Manually remove the authentication cookie - // Delete each cookie - foreach (string cookieName in Request.Cookies.Keys) { - Response.Cookies.Delete(cookieName); - } + [HttpPost("logout")] + public async Task Logout(string? redirectUrl = "") + { + // Sign out of an external identity provider (if used) + AuthenticationProperties authProps = new() + { + RedirectUri = redirectUrl, + IsPersistent = true, + }; + await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme, authProps); + // Manually remove the authentication cookie + // Delete each cookie + foreach (string cookieName in Request.Cookies.Keys) + { + Response.Cookies.Delete(cookieName); + } - return Ok(new { redirectUrl }); - } -} \ No newline at end of file + return Ok(new { redirectUrl }); + } +} diff --git a/src/Evently.Server/Features/Accounts/Services/AccountAuthorizationHandler.cs b/src/Evently.Server/Features/Accounts/Services/AccountAuthorizationHandler.cs index 333de27..686bbbb 100644 --- a/src/Evently.Server/Features/Accounts/Services/AccountAuthorizationHandler.cs +++ b/src/Evently.Server/Features/Accounts/Services/AccountAuthorizationHandler.cs @@ -1,36 +1,45 @@ -using Evently.Server.Common.Domains.Entities; +using System.Security.Claims; +using Evently.Server.Domains.Entities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; -using System.Security.Claims; namespace Evently.Server.Features.Accounts.Services; // Based on https://tinyurl.com/5cxw9vmu public sealed class AccountAuthorizationHandler(UserManager userManager) - : AuthorizationHandler { - protected override async Task HandleRequirementAsync( - AuthorizationHandlerContext context, - SameAccountRequirement requirement, - string? identityUserId) { - ClaimsPrincipal principal = context.User; - Account? user = await FindByClaimsPrincipalAsync(userManager, principal); + : AuthorizationHandler +{ + protected override async Task HandleRequirementAsync( + AuthorizationHandlerContext context, + SameAccountRequirement requirement, + string? identityUserId + ) + { + ClaimsPrincipal principal = context.User; + Account? user = await FindByClaimsPrincipalAsync(userManager, principal); - bool userMatch = user is not null && user.Id == identityUserId; - if (userMatch) { - context.Succeed(requirement); - } - } + bool userMatch = user is not null && user.Id == identityUserId; + if (userMatch) + { + context.Succeed(requirement); + } + } - private static async Task FindByClaimsPrincipalAsync(UserManager userManager, - ClaimsPrincipal claimsPrincipal) { - string loginProvider = claimsPrincipal.Identity?.AuthenticationType ?? string.Empty; - string providerKey = claimsPrincipal.FindFirstValue(ClaimTypes.NameIdentifier) ?? string.Empty; + private static async Task FindByClaimsPrincipalAsync( + UserManager userManager, + ClaimsPrincipal claimsPrincipal + ) + { + string loginProvider = claimsPrincipal.Identity?.AuthenticationType ?? string.Empty; + string providerKey = + claimsPrincipal.FindFirstValue(ClaimTypes.NameIdentifier) ?? string.Empty; - Account? user = await userManager.GetUserAsync(claimsPrincipal); - return user ?? await userManager.FindByLoginAsync(loginProvider, providerKey); - } + Account? user = await userManager.GetUserAsync(claimsPrincipal); + return user ?? await userManager.FindByLoginAsync(loginProvider, providerKey); + } } -public class SameAccountRequirement : IAuthorizationRequirement { - public const string PolicyName = "SameAccountPolicy"; -} \ No newline at end of file +public class SameAccountRequirement : IAuthorizationRequirement +{ + public const string PolicyName = "SameAccountPolicy"; +} diff --git a/src/Evently.Server/Features/Accounts/Services/AccountExtensions.cs b/src/Evently.Server/Features/Accounts/Services/AccountExtensions.cs index 574a128..e3232f9 100644 --- a/src/Evently.Server/Features/Accounts/Services/AccountExtensions.cs +++ b/src/Evently.Server/Features/Accounts/Services/AccountExtensions.cs @@ -1,27 +1,35 @@ -using Microsoft.AspNetCore.Authentication; +using System.Security.Claims; +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; -using System.Security.Claims; namespace Evently.Server.Features.Accounts.Services; -public static class AccountExtensions { - public static async Task IsResourceOwner(this ControllerBase controller, object? resourceIdentityUserId) { - IAuthorizationService authorizationService = - controller.HttpContext.RequestServices.GetRequiredService(); +public static class AccountExtensions +{ + public static async Task IsResourceOwner( + this ControllerBase controller, + object? resourceIdentityUserId + ) + { + IAuthorizationService authorizationService = + controller.HttpContext.RequestServices.GetRequiredService(); - AuthenticateResult authenticationResult = - await controller.HttpContext.AuthenticateAsync(IdentityConstants.ExternalScheme); - if (!authenticationResult.Succeeded || resourceIdentityUserId is null) { - return false; - } + AuthenticateResult authenticationResult = await controller.HttpContext.AuthenticateAsync( + IdentityConstants.ExternalScheme + ); + if (!authenticationResult.Succeeded || resourceIdentityUserId is null) + { + return false; + } - ClaimsPrincipal principal = authenticationResult.Principal ?? new ClaimsPrincipal(); - AuthorizationResult authorizationResult = - await authorizationService.AuthorizeAsync(principal, - resourceIdentityUserId, - SameAccountRequirement.PolicyName); - return authorizationResult.Succeeded; - } -} \ No newline at end of file + ClaimsPrincipal principal = authenticationResult.Principal ?? new ClaimsPrincipal(); + AuthorizationResult authorizationResult = await authorizationService.AuthorizeAsync( + principal, + resourceIdentityUserId, + SameAccountRequirement.PolicyName + ); + return authorizationResult.Succeeded; + } +} diff --git a/src/Evently.Server/Features/Accounts/Services/AccountService.cs b/src/Evently.Server/Features/Accounts/Services/AccountService.cs index dead37a..db8d6cb 100644 --- a/src/Evently.Server/Features/Accounts/Services/AccountService.cs +++ b/src/Evently.Server/Features/Accounts/Services/AccountService.cs @@ -1,68 +1,88 @@ -using Evently.Server.Common.Domains.Entities; -using Evently.Server.Common.Domains.Exceptions; -using Evently.Server.Common.Domains.Interfaces; -using Microsoft.AspNetCore.Identity; -using System.Security.Claims; +using System.Security.Claims; using System.Text.RegularExpressions; +using Evently.Server.Domains.Entities; +using Evently.Server.Domains.Exceptions; +using Evently.Server.Domains.Interfaces; +using Microsoft.AspNetCore.Identity; namespace Evently.Server.Features.Accounts.Services; // Based on https://tinyurl.com/4u4r7ywy -public sealed partial class AccountService(UserManager userManager) : IAccountsService { - public async Task ExternalLogin(ClaimsPrincipal claimsPrincipal, string loginProvider) { - string providerKey = claimsPrincipal.FindFirstValue(ClaimTypes.NameIdentifier) ?? string.Empty; - UserLoginInfo info = new(loginProvider, providerKey, loginProvider); +public sealed partial class AccountService(UserManager userManager) : IAccountsService +{ + public async Task ExternalLogin(ClaimsPrincipal claimsPrincipal, string loginProvider) + { + string providerKey = + claimsPrincipal.FindFirstValue(ClaimTypes.NameIdentifier) ?? string.Empty; + UserLoginInfo info = new(loginProvider, providerKey, loginProvider); - Account user = await userManager.FindByLoginAsync(info.LoginProvider, info.ProviderKey) - ?? await CreateExternalUser(claimsPrincipal, loginProvider); - await userManager.AddLoginAsync(user, info); - return user; - } + Account user = + await userManager.FindByLoginAsync(info.LoginProvider, info.ProviderKey) + ?? await CreateExternalUser(claimsPrincipal, loginProvider); + await userManager.AddLoginAsync(user, info); + return user; + } - public async Task FindByClaimsPrincipalAsync(ClaimsPrincipal claimsPrincipal) { - string loginProvider = claimsPrincipal.Identity?.AuthenticationType ?? string.Empty; - string providerKey = claimsPrincipal.FindFirstValue(ClaimTypes.NameIdentifier) ?? string.Empty; + public async Task FindByClaimsPrincipalAsync(ClaimsPrincipal claimsPrincipal) + { + string loginProvider = claimsPrincipal.Identity?.AuthenticationType ?? string.Empty; + string providerKey = + claimsPrincipal.FindFirstValue(ClaimTypes.NameIdentifier) ?? string.Empty; - Account? user = await userManager.GetUserAsync(claimsPrincipal); - return user ?? await userManager.FindByLoginAsync(loginProvider, providerKey); - } + Account? user = await userManager.GetUserAsync(claimsPrincipal); + return user ?? await userManager.FindByLoginAsync(loginProvider, providerKey); + } - private async Task CreateExternalUser(ClaimsPrincipal claimsPrincipal, string loginProvider) { - UserLoginInfo info = new(loginProvider, - providerKey: claimsPrincipal.FindFirstValue(ClaimTypes.NameIdentifier) ?? string.Empty, - loginProvider); - string? email = claimsPrincipal.FindFirstValue(ClaimTypes.Email); - if (email == null) { - throw new ExternalLoginProviderException(loginProvider, "Email is null"); - } + private async Task CreateExternalUser( + ClaimsPrincipal claimsPrincipal, + string loginProvider + ) + { + UserLoginInfo info = new( + loginProvider, + providerKey: claimsPrincipal.FindFirstValue(ClaimTypes.NameIdentifier) ?? string.Empty, + loginProvider + ); + string? email = claimsPrincipal.FindFirstValue(ClaimTypes.Email); + if (email == null) + { + throw new ExternalLoginProviderException(loginProvider, "Email is null"); + } - string username = claimsPrincipal.FindFirstValue(ClaimTypes.Name) ?? string.Empty; - username = UsernameRegex().Replace(username, ""); + string username = claimsPrincipal.FindFirstValue(ClaimTypes.Name) ?? string.Empty; + username = UsernameRegex().Replace(username, ""); - Account newUser = new() { - UserName = username, - Email = email, - EmailConfirmed = true, - }; + Account newUser = new() + { + UserName = username, + Email = email, + EmailConfirmed = true, + }; - IdentityResult result = await userManager.CreateAsync(newUser); + IdentityResult result = await userManager.CreateAsync(newUser); - if (!result.Succeeded) { - throw new ExternalLoginProviderException(loginProvider, - message: $"Unable to create user: {string.Join(", ", - values: result.Errors.Select(x => x.Description))}"); - } + if (!result.Succeeded) + { + throw new ExternalLoginProviderException( + loginProvider, + message: $"Unable to create user: {string.Join(", ", + values: result.Errors.Select(x => x.Description))}" + ); + } - IdentityResult loginResult = await userManager.AddLoginAsync(newUser, info); - if (!loginResult.Succeeded) { - throw new ExternalLoginProviderException(loginProvider, - message: $"Unable to login user: {string.Join(", ", - values: loginResult.Errors.Select(err => err.Description))}"); - } + IdentityResult loginResult = await userManager.AddLoginAsync(newUser, info); + if (!loginResult.Succeeded) + { + throw new ExternalLoginProviderException( + loginProvider, + message: $"Unable to login user: {string.Join(", ", + values: loginResult.Errors.Select(err => err.Description))}" + ); + } - return newUser; - } + return newUser; + } - [GeneratedRegex("[^a-zA-Z0-9]")] - private static partial Regex UsernameRegex(); -} \ No newline at end of file + [GeneratedRegex("[^a-zA-Z0-9]")] + private static partial Regex UsernameRegex(); +} diff --git a/src/Evently.Server/Features/Accounts/Services/AccountValidator.cs b/src/Evently.Server/Features/Accounts/Services/AccountValidator.cs index f5aa168..cc1d84a 100644 --- a/src/Evently.Server/Features/Accounts/Services/AccountValidator.cs +++ b/src/Evently.Server/Features/Accounts/Services/AccountValidator.cs @@ -1,20 +1,28 @@ -using Evently.Server.Common.Domains.Entities; +using Evently.Server.Domains.Entities; using FluentValidation; namespace Evently.Server.Features.Accounts.Services; -public sealed class AccountValidator : AbstractValidator { - public AccountValidator() { - RuleFor((member) => member.Name).NotEmpty().WithMessage("Name is required."); - RuleFor((member) => member.Email).NotEmpty().WithMessage("Email is required."); - RuleForEach((member) => member.Bookings).Custom((value, context) => { - if (value.AttendeeId == string.Empty) { - context.AddFailure("MemberId is required."); - } +public sealed class AccountValidator : AbstractValidator +{ + public AccountValidator() + { + RuleFor((member) => member.Name).NotEmpty().WithMessage("Name is required."); + RuleFor((member) => member.Email).NotEmpty().WithMessage("Email is required."); + RuleForEach((member) => member.Bookings) + .Custom( + (value, context) => + { + if (value.AttendeeId == string.Empty) + { + context.AddFailure("MemberId is required."); + } - if (string.IsNullOrEmpty(value.BookingId)) { - context.AddFailure("BookingId is required."); - } - }); - } -} \ No newline at end of file + if (string.IsNullOrEmpty(value.BookingId)) + { + context.AddFailure("BookingId is required."); + } + } + ); + } +} diff --git a/src/Evently.Server/Features/Bookings/Controllers/BookingsController.cs b/src/Evently.Server/Features/Bookings/Controllers/BookingsController.cs index a09db98..433717a 100644 --- a/src/Evently.Server/Features/Bookings/Controllers/BookingsController.cs +++ b/src/Evently.Server/Features/Bookings/Controllers/BookingsController.cs @@ -1,103 +1,133 @@ -using Evently.Server.Common.Domains.Entities; -using Evently.Server.Common.Domains.Interfaces; -using Evently.Server.Common.Domains.Models; +using System.Globalization; +using System.Threading.Channels; using Evently.Server.Common.Extensions; +using Evently.Server.Domains.Entities; +using Evently.Server.Domains.Interfaces; +using Evently.Server.Domains.Models; using Evently.Server.Features.Accounts.Services; using Microsoft.AspNetCore.Mvc; -using System.Globalization; -using System.Threading.Channels; namespace Evently.Server.Features.Bookings.Controllers; [ApiController] [Route("api/v1/[controller]")] -public sealed class BookingsController(IBookingService bookingService, ChannelWriter emailQueue, ILogger logger) - : ControllerBase { - [HttpGet("{bookingId}", Name = "GetBooking")] - public async Task> GetBooking(string bookingId) { - Booking? booking = await bookingService.GetBooking(bookingId); - if (booking is null) { - return NotFound(); - } +public sealed class BookingsController( + IBookingService bookingService, + ChannelWriter emailQueue, + ILogger logger +) : ControllerBase +{ + [HttpGet("{bookingId}", Name = "GetBooking")] + public async Task> GetBooking(string bookingId) + { + Booking? booking = await bookingService.GetBooking(bookingId); + if (booking is null) + { + return NotFound(); + } - return Ok(booking); - } + return Ok(booking); + } - [HttpGet("{bookingId}/preview", Name = "PreviewBooking")] - public async Task> PreviewBooking(string bookingId) { - string html = await bookingService.RenderTicket(bookingId); - return Content(html, "text/html"); - } + [HttpGet("{bookingId}/preview", Name = "PreviewBooking")] + public async Task> PreviewBooking(string bookingId) + { + string html = await bookingService.RenderTicket(bookingId); + return Content(html, "text/html"); + } - [HttpGet("", Name = "GetBookings")] - public async Task> GetBookings( - string? attendeeId, - long? gatheringId, - DateTimeOffset? checkInStart, - DateTimeOffset? checkInEnd, - DateTimeOffset? gatheringStartBefore, DateTimeOffset? gatheringStartAfter, DateTimeOffset? gatheringEndBefore, DateTimeOffset? gatheringEndAfter, - bool isCancelled, - int? offset, - int? limit) { - PageResult result = await bookingService.GetBookings(attendeeId, - gatheringId, - checkInStart, - checkInEnd, - gatheringStartBefore, - gatheringStartAfter, - gatheringEndBefore, - gatheringEndAfter, - isCancelled, - offset, - limit); - List bookingEvents = result.Items; - int total = result.TotalCount; - HttpContext.Response.Headers.Append("Access-Control-Expose-Headers", "X-Total-Count"); - HttpContext.Response.Headers.Append("X-Total-Count", value: total.ToString(CultureInfo.InvariantCulture)); - return Ok(bookingEvents); - } + [HttpGet("", Name = "GetBookings")] + public async Task> GetBookings( + string? attendeeId, + long? gatheringId, + DateTimeOffset? checkInStart, + DateTimeOffset? checkInEnd, + DateTimeOffset? gatheringStartBefore, + DateTimeOffset? gatheringStartAfter, + DateTimeOffset? gatheringEndBefore, + DateTimeOffset? gatheringEndAfter, + bool isCancelled, + int? offset, + int? limit + ) + { + PageResult result = await bookingService.GetBookings( + attendeeId, + gatheringId, + checkInStart, + checkInEnd, + gatheringStartBefore, + gatheringStartAfter, + gatheringEndBefore, + gatheringEndAfter, + isCancelled, + offset, + limit + ); + List bookingEvents = result.Items; + int total = result.TotalCount; + HttpContext.Response.Headers.Append("Access-Control-Expose-Headers", "X-Total-Count"); + HttpContext.Response.Headers.Append( + "X-Total-Count", + value: total.ToString(CultureInfo.InvariantCulture) + ); + return Ok(bookingEvents); + } - [HttpPost("", Name = "CreateBooking")] - public async Task> CreateBooking([FromBody] BookingReqDto bookingReqDto) { - Booking booking = await bookingService.CreateBooking(bookingReqDto); - await emailQueue.WriteAsync(booking.BookingId); - return Ok(booking); - } + [HttpPost("", Name = "CreateBooking")] + public async Task> CreateBooking([FromBody] BookingReqDto bookingReqDto) + { + Booking booking = await bookingService.CreateBooking(bookingReqDto); + await emailQueue.WriteAsync(booking.BookingId); + return Ok(booking); + } - [HttpPatch("{bookingId}/cancel", Name = "CancelBooking")] - public async Task CancelBooking(string bookingId) { - Booking? booking = await bookingService.GetBooking(bookingId); - if (booking?.Gathering is null) { - return NotFound(); - } + [HttpPatch("{bookingId}/cancel", Name = "CancelBooking")] + public async Task CancelBooking(string bookingId) + { + Booking? booking = await bookingService.GetBooking(bookingId); + if (booking?.Gathering is null) + { + return NotFound(); + } - bool isAuth = await this.IsResourceOwner(booking.AttendeeId); - logger.LogInformation("isAuth: {}", isAuth); - if (!isAuth) { - return Forbid(); - } + bool isAuth = await this.IsResourceOwner(booking.AttendeeId); + logger.LogInformation("isAuth: {}", isAuth); + if (!isAuth) + { + return Forbid(); + } - booking.CancellationDateTime = DateTimeOffset.UtcNow; - booking = await bookingService.UpdateBooking(bookingId, bookingReqDto: booking.ToBookingDto()); - return Ok(booking); - } + booking.CancellationDateTime = DateTimeOffset.UtcNow; + booking = await bookingService.UpdateBooking( + bookingId, + bookingReqDto: booking.ToBookingDto() + ); + return Ok(booking); + } - [HttpPatch("{bookingId}/checkIn", Name = "CheckInBooking")] - public async Task CheckInBooking(string bookingId) { - Booking? booking = await bookingService.GetBooking(bookingId); - if (booking?.Gathering is null) { - return NotFound(); - } + [HttpPatch("{bookingId}/checkIn", Name = "CheckInBooking")] + public async Task CheckInBooking(string bookingId) + { + Booking? booking = await bookingService.GetBooking(bookingId); + if (booking?.Gathering is null) + { + return NotFound(); + } - Gathering gathering = booking.Gathering; - bool isAuth = await this.IsResourceOwner(gathering.OrganiserId); - logger.LogInformation("isAuth: {}", isAuth); - if (!isAuth) { - return Forbid(); - } + Gathering gathering = booking.Gathering; + bool isAuth = await this.IsResourceOwner(gathering.OrganiserId); + logger.LogInformation("isAuth: {}", isAuth); + if (!isAuth) + { + return Forbid(); + } - booking.CheckInDateTime = DateTimeOffset.UtcNow; - booking = await bookingService.UpdateBooking(bookingId, bookingReqDto: booking.ToBookingDto()); - return Ok(booking); - } -} \ No newline at end of file + booking.CheckInDateTime = DateTimeOffset.UtcNow; + booking = await bookingService.UpdateBooking( + bookingId, + bookingReqDto: booking.ToBookingDto() + ); + return Ok(booking); + } +} diff --git a/src/Evently.Server/Features/Bookings/Services/BookingService.cs b/src/Evently.Server/Features/Bookings/Services/BookingService.cs index 5d2e709..2681ef2 100644 --- a/src/Evently.Server/Features/Bookings/Services/BookingService.cs +++ b/src/Evently.Server/Features/Bookings/Services/BookingService.cs @@ -1,129 +1,174 @@ -using Evently.Server.Common.Adapters.Data; -using Evently.Server.Common.Domains.Entities; -using Evently.Server.Common.Domains.Interfaces; -using Evently.Server.Common.Domains.Models; +using System.Text.Json; +using Evently.Server.Common.Data; using Evently.Server.Common.Extensions; +using Evently.Server.Domains.Entities; +using Evently.Server.Domains.Interfaces; +using Evently.Server.Domains.Models; using Evently.Server.Features.Emails.Views; using FluentValidation; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using NanoidDotNet; -using System.Text.Json; -using ValidationResult=FluentValidation.Results.ValidationResult; +using ValidationResult = FluentValidation.Results.ValidationResult; namespace Evently.Server.Features.Bookings.Services; public sealed class BookingService( - IMediaRenderer mediaRenderer, - IObjectStorageService objectStorageService, - IValidator validator, - IOptions settings, - AppDbContext db) - : IBookingService { - private readonly string _containerName = settings.Value.StorageAccount.AccountName; - - public async Task GetBooking(string bookingId) { - return await db.Bookings - .Include((b) => b.Account) - .Include((b) => b.Gathering) - .ThenInclude((g) => g!.GatheringCategoryDetails) - .ThenInclude((detail) => detail.Category) - .FirstOrDefaultAsync((be) => be.BookingId == bookingId); - } - - public async Task> GetBookings(string? accountId, long? gatheringId, - DateTimeOffset? checkInStart, DateTimeOffset? checkInEnd, - DateTimeOffset? gatheringStartBefore, DateTimeOffset? gatheringStartAfter, DateTimeOffset? gatheringEndBefore, DateTimeOffset? gatheringEndAfter, - bool? isCancelled, int? offset, int? limit) { - IQueryable query = db.Bookings - .Where((b) => accountId == null || b.AttendeeId == accountId) - .Where((b) => gatheringId == null || b.GatheringId == gatheringId) - .Where((c) => checkInStart == null || checkInStart <= c.CheckInDateTime) - .Where((b) => checkInEnd == null || b.CheckInDateTime <= checkInEnd) - .Where((b) => isCancelled == null || b.CancellationDateTime.HasValue == isCancelled) - .Where((b) => gatheringStartBefore == null || b.Gathering != null && b.Gathering.Start <= gatheringStartBefore) - .Where((b) => gatheringStartAfter == null || b.Gathering != null && b.Gathering.Start >= gatheringStartAfter) - .Where((b) => gatheringEndBefore == null || b.Gathering != null && b.Gathering.End <= gatheringEndBefore) - .Where((b) => gatheringEndAfter == null || b.Gathering != null && b.Gathering.End >= gatheringEndAfter) - .Include((b) => b.Account) - .Include((b) => b.Gathering) - .ThenInclude((g) => g!.GatheringCategoryDetails) - .ThenInclude((detail) => detail.Category); - - int totalCount = await query.CountAsync(); - - List bookingEvents = await query - .OrderByDescending((be) => be.CreationDateTime) - .Skip(offset ?? 0) - .Take(limit ?? int.MaxValue) - .ToListAsync(); - - return new PageResult { - Items = bookingEvents, - TotalCount = totalCount, - }; - } - - public async Task CreateBooking(BookingReqDto bookingReqDto) { - Booking booking = bookingReqDto.ToBooking(); - ValidationResult validationResult = await validator.ValidateAsync(booking); - if (!validationResult.IsValid) { - throw new ArgumentException($"Account has already booked this gathering (GatheringId: {booking.GatheringId})"); - } - - booking.BookingId = $"book_{await Nanoid.GenerateAsync(size: 10)}"; - await db.Bookings.AddAsync(booking); - await db.SaveChangesAsync(); - return (await GetBooking(booking.BookingId))!; - } - - public async Task UpdateBooking(string bookingId, BookingReqDto bookingReqDto) { - Booking booking = bookingReqDto.ToBooking(); - - ValidationResult validationResult = await validator.ValidateAsync(booking); - if (!validationResult.IsValid) { - throw new ArgumentException(string.Join("\n", values: validationResult.Errors.Select(e => e.ErrorMessage))); - } - - Booking current = await db.Bookings.AsTracking() - .FirstOrDefaultAsync((be) => be.BookingId == bookingId) - ?? throw new KeyNotFoundException($"{booking.BookingId} not found"); - - current.AttendeeId = booking.AttendeeId; - current.GatheringId = booking.GatheringId; - current.CreationDateTime = booking.CreationDateTime; - current.CheckInDateTime = booking.CheckInDateTime; - current.CheckoutDateTime = booking.CheckoutDateTime; - current.CancellationDateTime = booking.CancellationDateTime; - - await db.SaveChangesAsync(); - return (await GetBooking(booking.BookingId))!; - } - - public async Task RenderTicket(string bookingId) { - Booking? booking = await GetBooking(bookingId); - if (booking?.Account is null || booking.Gathering is null) { - throw new KeyNotFoundException( - $"Booking with id: {bookingId} not found or related member or gathering is null"); - } - - string qrData = JsonSerializer.Serialize(new { bookingEventId = bookingId }); - BinaryData binaryData = mediaRenderer.RenderQr(qrData); - string fileName = $"bookings/{bookingId}/qrcode.png"; - - Uri uri; - bool isFileExists = await objectStorageService.IsFileExists(_containerName, fileName); - if (!isFileExists) { - uri = await objectStorageService.UploadFile(_containerName, fileName, binaryData, "image/png"); - } else { - uri = await objectStorageService.GetFileUri(_containerName, fileName); - } - - Dictionary props = new() { - { "Booking", booking }, - { "QrCodeUrl", uri.AbsoluteUri }, - }; - - return await mediaRenderer.RenderComponentHtml(props); - } -} \ No newline at end of file + IMediaRenderer mediaRenderer, + IObjectStorageService objectStorageService, + IValidator validator, + IOptions settings, + AppDbContext db +) : IBookingService +{ + private readonly string _containerName = settings.Value.StorageAccount.AccountName; + + public async Task GetBooking(string bookingId) + { + return await db + .Bookings.Include((b) => b.Account) + .Include((b) => b.Gathering) + .ThenInclude((g) => g!.GatheringCategoryDetails) + .ThenInclude((detail) => detail.Category) + .FirstOrDefaultAsync((be) => be.BookingId == bookingId); + } + + public async Task> GetBookings( + string? accountId, + long? gatheringId, + DateTimeOffset? checkInStart, + DateTimeOffset? checkInEnd, + DateTimeOffset? gatheringStartBefore, + DateTimeOffset? gatheringStartAfter, + DateTimeOffset? gatheringEndBefore, + DateTimeOffset? gatheringEndAfter, + bool? isCancelled, + int? offset, + int? limit + ) + { + IQueryable query = db + .Bookings.Where((b) => accountId == null || b.AttendeeId == accountId) + .Where((b) => gatheringId == null || b.GatheringId == gatheringId) + .Where((c) => checkInStart == null || checkInStart <= c.CheckInDateTime) + .Where((b) => checkInEnd == null || b.CheckInDateTime <= checkInEnd) + .Where((b) => isCancelled == null || b.CancellationDateTime.HasValue == isCancelled) + .Where( + (b) => + gatheringStartBefore == null + || b.Gathering != null && b.Gathering.Start <= gatheringStartBefore + ) + .Where( + (b) => + gatheringStartAfter == null + || b.Gathering != null && b.Gathering.Start >= gatheringStartAfter + ) + .Where( + (b) => + gatheringEndBefore == null + || b.Gathering != null && b.Gathering.End <= gatheringEndBefore + ) + .Where( + (b) => + gatheringEndAfter == null + || b.Gathering != null && b.Gathering.End >= gatheringEndAfter + ) + .Include((b) => b.Account) + .Include((b) => b.Gathering) + .ThenInclude((g) => g!.GatheringCategoryDetails) + .ThenInclude((detail) => detail.Category); + + int totalCount = await query.CountAsync(); + + List bookingEvents = await query + .OrderByDescending((be) => be.CreationDateTime) + .Skip(offset ?? 0) + .Take(limit ?? int.MaxValue) + .ToListAsync(); + + return new PageResult { Items = bookingEvents, TotalCount = totalCount }; + } + + public async Task CreateBooking(BookingReqDto bookingReqDto) + { + Booking booking = bookingReqDto.ToBooking(); + ValidationResult validationResult = await validator.ValidateAsync(booking); + if (!validationResult.IsValid) + { + throw new ArgumentException( + $"Account has already booked this gathering (GatheringId: {booking.GatheringId})" + ); + } + + booking.BookingId = $"book_{await Nanoid.GenerateAsync(size: 10)}"; + await db.Bookings.AddAsync(booking); + await db.SaveChangesAsync(); + return (await GetBooking(booking.BookingId))!; + } + + public async Task UpdateBooking(string bookingId, BookingReqDto bookingReqDto) + { + Booking booking = bookingReqDto.ToBooking(); + + ValidationResult validationResult = await validator.ValidateAsync(booking); + if (!validationResult.IsValid) + { + throw new ArgumentException( + string.Join("\n", values: validationResult.Errors.Select(e => e.ErrorMessage)) + ); + } + + Booking current = + await db.Bookings.AsTracking().FirstOrDefaultAsync((be) => be.BookingId == bookingId) + ?? throw new KeyNotFoundException($"{booking.BookingId} not found"); + + current.AttendeeId = booking.AttendeeId; + current.GatheringId = booking.GatheringId; + current.CreationDateTime = booking.CreationDateTime; + current.CheckInDateTime = booking.CheckInDateTime; + current.CheckoutDateTime = booking.CheckoutDateTime; + current.CancellationDateTime = booking.CancellationDateTime; + + await db.SaveChangesAsync(); + return (await GetBooking(booking.BookingId))!; + } + + public async Task RenderTicket(string bookingId) + { + Booking? booking = await GetBooking(bookingId); + if (booking?.Account is null || booking.Gathering is null) + { + throw new KeyNotFoundException( + $"Booking with id: {bookingId} not found or related member or gathering is null" + ); + } + + string qrData = JsonSerializer.Serialize(new { bookingEventId = bookingId }); + BinaryData binaryData = mediaRenderer.RenderQr(qrData); + string fileName = $"bookings/{bookingId}/qrcode.png"; + + Uri uri; + bool isFileExists = await objectStorageService.IsFileExists(_containerName, fileName); + if (!isFileExists) + { + uri = await objectStorageService.UploadFile( + _containerName, + fileName, + binaryData, + "image/png" + ); + } + else + { + uri = await objectStorageService.GetFileUri(_containerName, fileName); + } + + Dictionary props = new() + { + { "Booking", booking }, + { "QrCodeUrl", uri.AbsoluteUri }, + }; + + return await mediaRenderer.RenderComponentHtml(props); + } +} diff --git a/src/Evently.Server/Features/Bookings/Services/BookingValidator.cs b/src/Evently.Server/Features/Bookings/Services/BookingValidator.cs index 5cf6c2f..556f03d 100644 --- a/src/Evently.Server/Features/Bookings/Services/BookingValidator.cs +++ b/src/Evently.Server/Features/Bookings/Services/BookingValidator.cs @@ -1,11 +1,15 @@ -using Evently.Server.Common.Domains.Entities; +using Evently.Server.Domains.Entities; using FluentValidation; namespace Evently.Server.Features.Bookings.Services; -public sealed class BookingValidator : AbstractValidator { - public BookingValidator() { - RuleFor((booking) => booking.GatheringId).NotEmpty().WithMessage("GatheringId is required."); - RuleFor((booking) => booking.AttendeeId).NotEmpty().WithMessage("AccountId is required."); - } -} \ No newline at end of file +public sealed class BookingValidator : AbstractValidator +{ + public BookingValidator() + { + RuleFor((booking) => booking.GatheringId) + .NotEmpty() + .WithMessage("GatheringId is required."); + RuleFor((booking) => booking.AttendeeId).NotEmpty().WithMessage("AccountId is required."); + } +} diff --git a/src/Evently.Server/Features/Categories/Controllers/CategoriesController.cs b/src/Evently.Server/Features/Categories/Controllers/CategoriesController.cs index 032ada0..e0f5bfb 100644 --- a/src/Evently.Server/Features/Categories/Controllers/CategoriesController.cs +++ b/src/Evently.Server/Features/Categories/Controllers/CategoriesController.cs @@ -1,27 +1,33 @@ -using Evently.Server.Common.Domains.Entities; -using Evently.Server.Common.Domains.Interfaces; -using Evently.Server.Common.Domains.Models; +using System.Globalization; +using Evently.Server.Domains.Entities; +using Evently.Server.Domains.Interfaces; +using Evently.Server.Domains.Models; using Microsoft.AspNetCore.Mvc; -using System.Globalization; namespace Evently.Server.Features.Categories.Controllers; [ApiController] [Route("api/v1/[controller]")] -public sealed class CategoriesController(ICategoryService categoryService) : ControllerBase { - [HttpGet(Name = "GetCategories")] - public async Task>> GetCategories(long? memberId, bool? approved) { - PageResult result = await categoryService.GetCategories(memberId, approved); - List topics = result.Items; - int total = result.TotalCount; - HttpContext.Response.Headers.Append("Access-Control-Expose-Headers", "X-Total-Count"); - HttpContext.Response.Headers.Append("X-Total-Count", value: total.ToString(CultureInfo.InvariantCulture)); - return Ok(topics); - } +public sealed class CategoriesController(ICategoryService categoryService) : ControllerBase +{ + [HttpGet(Name = "GetCategories")] + public async Task>> GetCategories(long? memberId, bool? approved) + { + PageResult result = await categoryService.GetCategories(memberId, approved); + List topics = result.Items; + int total = result.TotalCount; + HttpContext.Response.Headers.Append("Access-Control-Expose-Headers", "X-Total-Count"); + HttpContext.Response.Headers.Append( + "X-Total-Count", + value: total.ToString(CultureInfo.InvariantCulture) + ); + return Ok(topics); + } - [HttpPost("", Name = "CreateCategory")] - public async Task> CreateCategory(Category category) { - category = await categoryService.CreateCategory(category); - return Ok(category); - } -} \ No newline at end of file + [HttpPost("", Name = "CreateCategory")] + public async Task> CreateCategory(Category category) + { + category = await categoryService.CreateCategory(category); + return Ok(category); + } +} diff --git a/src/Evently.Server/Features/Categories/Services/CategoryService.cs b/src/Evently.Server/Features/Categories/Services/CategoryService.cs index 11a1d86..e046ef4 100644 --- a/src/Evently.Server/Features/Categories/Services/CategoryService.cs +++ b/src/Evently.Server/Features/Categories/Services/CategoryService.cs @@ -1,31 +1,36 @@ -using Evently.Server.Common.Adapters.Data; -using Evently.Server.Common.Domains.Entities; -using Evently.Server.Common.Domains.Interfaces; -using Evently.Server.Common.Domains.Models; +using Evently.Server.Common.Data; +using Evently.Server.Domains.Entities; +using Evently.Server.Domains.Interfaces; +using Evently.Server.Domains.Models; using Microsoft.EntityFrameworkCore; namespace Evently.Server.Features.Categories.Services; -public sealed class CategoryService(AppDbContext db) : ICategoryService { - public async Task CreateCategory(Category category) { - await db.Categories.AddAsync(category); - await db.SaveChangesAsync(); - return category; - } +public sealed class CategoryService(AppDbContext db) : ICategoryService +{ + public async Task CreateCategory(Category category) + { + await db.Categories.AddAsync(category); + await db.SaveChangesAsync(); + return category; + } - public async Task> GetCategories(long? gatheringId, bool? approved) { - IQueryable query = db.Categories - .Include((category) => category.GatheringCategoryDetails) - .Where((category) => - gatheringId == null || category.GatheringCategoryDetails.Any((detail) => detail.GatheringId == gatheringId)) - .Where((category) => approved == null || category.Approved == approved); + public async Task> GetCategories(long? gatheringId, bool? approved) + { + IQueryable query = db + .Categories.Include((category) => category.GatheringCategoryDetails) + .Where( + (category) => + gatheringId == null + || category.GatheringCategoryDetails.Any( + (detail) => detail.GatheringId == gatheringId + ) + ) + .Where((category) => approved == null || category.Approved == approved); - int totalCount = await query.CountAsync(); - List topics = await query.ToListAsync(); + int totalCount = await query.CountAsync(); + List topics = await query.ToListAsync(); - return new PageResult { - Items = topics, - TotalCount = totalCount, - }; - } -} \ No newline at end of file + return new PageResult { Items = topics, TotalCount = totalCount }; + } +} diff --git a/src/Evently.Server/Features/Emails/Services/EmailAdapter.cs b/src/Evently.Server/Features/Emails/Services/EmailAdapter.cs index 2c52b9d..930df90 100644 --- a/src/Evently.Server/Features/Emails/Services/EmailAdapter.cs +++ b/src/Evently.Server/Features/Emails/Services/EmailAdapter.cs @@ -1,45 +1,61 @@ -using Evently.Server.Common.Domains.Interfaces; -using Evently.Server.Common.Domains.Models; using Evently.Server.Common.Extensions; +using Evently.Server.Domains.Interfaces; +using Evently.Server.Domains.Models; using MailKit.Net.Smtp; using Microsoft.Extensions.Options; using MimeKit; namespace Evently.Server.Features.Emails.Services; -public sealed class EmailAdapter(ILogger logger, IOptions settings) : IEmailerAdapter { - private readonly string _from = settings.Value.EmailSettings.ActualFrom; - private readonly string _password = settings.Value.EmailSettings.SmtpPassword; +public sealed class EmailAdapter(ILogger logger, IOptions settings) + : IEmailerAdapter +{ + private readonly string _from = settings.Value.EmailSettings.ActualFrom; + private readonly string _password = settings.Value.EmailSettings.SmtpPassword; - public async Task SendEmailAsync(string senderEmail, string recipientEmail, string subject, string body) { - MimeMessage emailMessage = CreateMessage(senderEmail, recipientEmail, subject, body); + public async Task SendEmailAsync( + string senderEmail, + string recipientEmail, + string subject, + string body + ) + { + MimeMessage emailMessage = CreateMessage(senderEmail, recipientEmail, subject, body); - try { - await SendEmail(emailMessage); - } catch (Exception ex) { - logger.LogCallbackUrl(ex.Message); - } - } + try + { + await SendEmail(emailMessage); + } + catch (Exception ex) + { + logger.LogCallbackUrl(ex.Message); + } + } - private static MimeMessage CreateMessage(string senderEmail, string recipientEmail, string subject, string body) { - MimeMessage emailMessage = new(); - emailMessage.From.Add(new MailboxAddress(senderEmail, senderEmail)); - emailMessage.To.Add(new MailboxAddress(recipientEmail, recipientEmail)); - emailMessage.Subject = subject; + private static MimeMessage CreateMessage( + string senderEmail, + string recipientEmail, + string subject, + string body + ) + { + MimeMessage emailMessage = new(); + emailMessage.From.Add(new MailboxAddress(senderEmail, senderEmail)); + emailMessage.To.Add(new MailboxAddress(recipientEmail, recipientEmail)); + emailMessage.Subject = subject; - BodyBuilder bodyBuilder = new() { - HtmlBody = body, - }; - emailMessage.Body = bodyBuilder.ToMessageBody(); - return emailMessage; - } + BodyBuilder bodyBuilder = new() { HtmlBody = body }; + emailMessage.Body = bodyBuilder.ToMessageBody(); + return emailMessage; + } - private async Task SendEmail(MimeMessage emailMessage) { - SmtpClient client = new(); - await client.ConnectAsync("smtp.gmail.com", port: 587, useSsl: false); - // smtp auth - await client.AuthenticateAsync(_from, _password); - await client.SendAsync(emailMessage); - await client.DisconnectAsync(true); - } -} \ No newline at end of file + private async Task SendEmail(MimeMessage emailMessage) + { + SmtpClient client = new(); + await client.ConnectAsync("smtp.gmail.com", port: 587, useSsl: false); + // smtp auth + await client.AuthenticateAsync(_from, _password); + await client.SendAsync(emailMessage); + await client.DisconnectAsync(true); + } +} diff --git a/src/Evently.Server/Features/Emails/Services/EmailBackgroundService.cs b/src/Evently.Server/Features/Emails/Services/EmailBackgroundService.cs index d6e9a7a..023b0a7 100644 --- a/src/Evently.Server/Features/Emails/Services/EmailBackgroundService.cs +++ b/src/Evently.Server/Features/Emails/Services/EmailBackgroundService.cs @@ -1,35 +1,50 @@ -using Evently.Server.Common.Domains.Entities; -using Evently.Server.Common.Domains.Interfaces; +using System.Threading.Channels; using Evently.Server.Common.Extensions; -using System.Threading.Channels; +using Evently.Server.Domains.Entities; +using Evently.Server.Domains.Interfaces; namespace Evently.Server.Features.Emails.Services; public sealed class EmailBackgroundService( - IServiceScopeFactory scopeFactory, - ChannelReader reader, - IEmailerAdapter emailerAdapter, - ILogger logger) : BackgroundService { - protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - while (await reader.WaitToReadAsync(stoppingToken)) { - try { - string bookingId = await reader.ReadAsync(stoppingToken); + IServiceScopeFactory scopeFactory, + ChannelReader reader, + IEmailerAdapter emailerAdapter, + ILogger logger +) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (await reader.WaitToReadAsync(stoppingToken)) + { + try + { + string bookingId = await reader.ReadAsync(stoppingToken); - using IServiceScope scope = scopeFactory.CreateScope(); - IBookingService bookingService = scope.ServiceProvider.GetRequiredService(); + using IServiceScope scope = scopeFactory.CreateScope(); + IBookingService bookingService = + scope.ServiceProvider.GetRequiredService(); - Booking? booking = await bookingService.GetBooking(bookingId); - if (booking?.Account?.Email is null) { - continue; - } - Account account = booking.Account; + Booking? booking = await bookingService.GetBooking(bookingId); + if (booking?.Account?.Email is null) + { + continue; + } - string html = await bookingService.RenderTicket(bookingId); - await emailerAdapter.SendEmailAsync("noreply@evently", account.Email, "Test QR ticket", html); - logger.LogSuccessEmail(account.Email); - } catch (Exception ex) { - logger.LogError("email error: {}", ex.Message); - } - } - } -} \ No newline at end of file + Account account = booking.Account; + + string html = await bookingService.RenderTicket(bookingId); + await emailerAdapter.SendEmailAsync( + "noreply@evently", + account.Email, + "Test QR ticket", + html + ); + logger.LogSuccessEmail(account.Email); + } + catch (Exception ex) + { + logger.LogError("email error: {}", ex.Message); + } + } + } +} diff --git a/src/Evently.Server/Features/Emails/Services/MediaRenderer.cs b/src/Evently.Server/Features/Emails/Services/MediaRenderer.cs index a232daa..3f42422 100644 --- a/src/Evently.Server/Features/Emails/Services/MediaRenderer.cs +++ b/src/Evently.Server/Features/Emails/Services/MediaRenderer.cs @@ -1,37 +1,43 @@ -using Evently.Server.Common.Domains.Interfaces; +using Evently.Server.Domains.Interfaces; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Web.HtmlRendering; using PdfSharp; using PdfSharp.Pdf; using QRCoder; using TheArtOfDev.HtmlRenderer.PdfSharp; -using BlazorHtmlRenderer=Microsoft.AspNetCore.Components.Web.HtmlRenderer; +using BlazorHtmlRenderer = Microsoft.AspNetCore.Components.Web.HtmlRenderer; namespace Evently.Server.Features.Emails.Services; -public sealed class MediaRenderer(BlazorHtmlRenderer htmlRenderer) : IMediaRenderer { - public async Task RenderComponentHtml(Dictionary dictionary) where T : IComponent { - string html = await htmlRenderer.Dispatcher.InvokeAsync(async () => { - ParameterView parameters = ParameterView.FromDictionary(dictionary); - HtmlRootComponent output = await htmlRenderer.RenderComponentAsync(parameters); - return output.ToHtmlString(); - }); - return html; - } +public sealed class MediaRenderer(BlazorHtmlRenderer htmlRenderer) : IMediaRenderer +{ + public async Task RenderComponentHtml(Dictionary dictionary) + where T : IComponent + { + string html = await htmlRenderer.Dispatcher.InvokeAsync(async () => + { + ParameterView parameters = ParameterView.FromDictionary(dictionary); + HtmlRootComponent output = await htmlRenderer.RenderComponentAsync(parameters); + return output.ToHtmlString(); + }); + return html; + } - public BinaryData RenderQr(string qrData) { - using QRCodeGenerator qrGenerator = new(); - QRCodeData qrCodeData = qrGenerator.CreateQrCode(qrData, QRCodeGenerator.ECCLevel.Q); - PngByteQRCode qrCode = new(qrCodeData); - byte[] imageBytes = qrCode.GetGraphic(20); - return BinaryData.FromBytes(imageBytes); - } + public BinaryData RenderQr(string qrData) + { + using QRCodeGenerator qrGenerator = new(); + QRCodeData qrCodeData = qrGenerator.CreateQrCode(qrData, QRCodeGenerator.ECCLevel.Q); + PngByteQRCode qrCode = new(qrCodeData); + byte[] imageBytes = qrCode.GetGraphic(20); + return BinaryData.FromBytes(imageBytes); + } - public BinaryData RenderPdf(string html) { - using MemoryStream ms = new(); - using PdfDocument pdf = PdfGenerator.GeneratePdf(html, PageSize.A4); - pdf.Save(ms); - byte[] bytes = ms.ToArray(); - return BinaryData.FromBytes(bytes); - } -} \ No newline at end of file + public BinaryData RenderPdf(string html) + { + using MemoryStream ms = new(); + using PdfDocument pdf = PdfGenerator.GeneratePdf(html, PageSize.A4); + pdf.Save(ms); + byte[] bytes = ms.ToArray(); + return BinaryData.FromBytes(bytes); + } +} diff --git a/src/Evently.Server/Features/Emails/Views/Ticket.razor b/src/Evently.Server/Features/Emails/Views/Ticket.razor index 14f886f..9f36cc7 100644 --- a/src/Evently.Server/Features/Emails/Views/Ticket.razor +++ b/src/Evently.Server/Features/Emails/Views/Ticket.razor @@ -1,4 +1,4 @@ -@using Evently.Server.Common.Domains.Entities +@using Evently.Server.Domains.Entities @using Evently.Server.Features.Emails.Views.Components @inject NavigationManager Navigator @inject IWebHostEnvironment Env @@ -31,8 +31,10 @@
-

Your booking has been - confirmed!

+

+ Your booking has been + confirmed! +

@@ -68,7 +70,8 @@
-
DATE & +
+ DATE & TIME
@StartDate - @EndDate
@@ -81,7 +84,8 @@
-
LOCATION +
+ LOCATION
@Gathering.Location
@@ -93,7 +97,8 @@
-
ATTENDEE +
+ ATTENDEE
@@ -120,7 +125,9 @@
BOOKING ID: @Booking.BookingId + style="color: #495057; font-size: 12px; font-weight: 600; font-family: 'Courier New', monospace;"> + @Booking.BookingId +
@@ -142,8 +149,10 @@ [Parameter] [EditorRequired] public string QrCodeUrl { get; set; } = string.Empty; [Parameter] public string Classes { get; set; } = string.Empty; - protected override void OnAfterRender(bool firstRender) { - if (firstRender) { + protected override void OnAfterRender(bool firstRender) + { + if (firstRender) + { _baseUri = (Env.IsDevelopment() ? "https://localhost:5000/" : Navigator.BaseUri).TrimEnd('/'); } } diff --git a/src/Evently.Server/Features/Files/Controllers/FilesController.cs b/src/Evently.Server/Features/Files/Controllers/FilesController.cs index a22e577..9a5f63d 100644 --- a/src/Evently.Server/Features/Files/Controllers/FilesController.cs +++ b/src/Evently.Server/Features/Files/Controllers/FilesController.cs @@ -1,34 +1,55 @@ -using Evently.Server.Common.Domains.Interfaces; +using System.ComponentModel.DataAnnotations; +using Evently.Server.Domains.Interfaces; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.StaticFiles; -using System.ComponentModel.DataAnnotations; namespace Evently.Server.Features.Files.Controllers; [ApiController] [Route("api/v1/[controller]")] -public class FilesController(ILogger logger, IObjectStorageService objectStorageService) : ControllerBase { - [HttpGet("object-storage/{bucket}", Name = "GetFile")] - public async Task GetFile(string bucket, [Required] [FromQuery] string fileName) { - logger.LogInformation("fileName: {}", fileName); - try { - BinaryData binaryData = await objectStorageService.GetFile(bucket, fileName); - logger.LogInformation("binaryData.MediaType: {}", binaryData.MediaType); - string contentType = binaryData.MediaType ?? GetContentType(fileName); - return File(fileContents: binaryData.ToArray(), fileDownloadName: fileName, contentType: contentType); - } catch (Exception ex) { - return ex switch { - FileNotFoundException => NotFound($"File '{fileName}' not found in bucket '{bucket}'."), - _ => StatusCode(statusCode: 500, "An unexpected error occurred while retrieving the file."), - }; - } - } +public class FilesController( + ILogger logger, + IObjectStorageService objectStorageService +) : ControllerBase +{ + [HttpGet("object-storage/{bucket}", Name = "GetFile")] + public async Task GetFile(string bucket, [Required] [FromQuery] string fileName) + { + logger.LogInformation("fileName: {}", fileName); + try + { + BinaryData binaryData = await objectStorageService.GetFile(bucket, fileName); + logger.LogInformation("binaryData.MediaType: {}", binaryData.MediaType); + string contentType = binaryData.MediaType ?? GetContentType(fileName); + return File( + fileContents: binaryData.ToArray(), + fileDownloadName: fileName, + contentType: contentType + ); + } + catch (Exception ex) + { + return ex switch + { + FileNotFoundException => NotFound( + $"File '{fileName}' not found in bucket '{bucket}'." + ), + _ => StatusCode( + statusCode: 500, + "An unexpected error occurred while retrieving the file." + ), + }; + } + } - private static string GetContentType(string fileName) { - FileExtensionContentTypeProvider provider = new(); - if (!provider.TryGetContentType(fileName, contentType: out string? contentType)) { - contentType = "application/octet-stream"; // Default fallback - } - return contentType; - } -} \ No newline at end of file + private static string GetContentType(string fileName) + { + FileExtensionContentTypeProvider provider = new(); + if (!provider.TryGetContentType(fileName, contentType: out string? contentType)) + { + contentType = "application/octet-stream"; // Default fallback + } + + return contentType; + } +} diff --git a/src/Evently.Server/Features/Files/Services/ObjectStorageService.cs b/src/Evently.Server/Features/Files/Services/ObjectStorageService.cs index add97e3..3ad4373 100644 --- a/src/Evently.Server/Features/Files/Services/ObjectStorageService.cs +++ b/src/Evently.Server/Features/Files/Services/ObjectStorageService.cs @@ -2,108 +2,142 @@ using Azure.AI.ContentSafety; using Azure.Storage.Blobs; using Azure.Storage.Blobs.Models; -using Evently.Server.Common.Domains.Interfaces; -using Evently.Server.Common.Domains.Models; using Evently.Server.Common.Extensions; +using Evently.Server.Domains.Interfaces; +using Evently.Server.Domains.Models; using Microsoft.Extensions.Options; namespace Evently.Server.Features.Files.Services; // Based on https://tinyurl.com/5pam66xn -public sealed class ObjectStorageService : IObjectStorageService { - private readonly BlobServiceClient _blobServiceClient; - private readonly ContentSafetyClient? _contentSafetyClient; - private readonly ILogger _logger; - - public ObjectStorageService(IOptions settings, ILogger logger) { - _logger = logger; - _blobServiceClient = - new BlobServiceClient(settings.Value.StorageAccount.AzureStorageConnectionString); - - try { - _contentSafetyClient = new ContentSafetyClient( - endpoint: new Uri(settings.Value.AzureAiFoundry.ContentSafetyEndpoint), - credential: new AzureKeyCredential(settings.Value.AzureAiFoundry.ContentSafetyKey)); - } catch (Exception ex) { - // silence the error - _logger.LogError("error creating content safety client: {message}. Content moderation skipped.", ex.Message); - } - } - - public async Task UploadFile(string containerName, string fileName, BinaryData binaryData, - string mimeType = "application/octet-stream") { - BlobContainerClient containerClient = _blobServiceClient.GetBlobContainerClient(containerName); - await containerClient.CreateIfNotExistsAsync(PublicAccessType.BlobContainer); - - BlobClient blobClient = containerClient.GetBlobClient(fileName); - - BlobUploadOptions uploadOptions = new() { - HttpHeaders = new BlobHttpHeaders { - ContentType = mimeType, - }, - // https://tinyurl.com/ms4hvsta - // By default, there will be condition to prevent overwrite. - // Set the conditions to null so that overwrite will be allowed. - Conditions = null, - }; - await blobClient.UploadAsync(binaryData, uploadOptions); - return blobClient.Uri; - } - - public async Task IsFileExists(string containerName, string fileName) { - BlobContainerClient containerClient = _blobServiceClient.GetBlobContainerClient(containerName); - BlobClient blobClient = containerClient.GetBlobClient(fileName); - Response result = await blobClient.ExistsAsync(); - return result.Value; - } - - public Task GetFileUri(string containerName, string fileName) { - BlobContainerClient containerClient = _blobServiceClient.GetBlobContainerClient(containerName); - BlobClient blobClient = containerClient.GetBlobClient(fileName); - return Task.FromResult(blobClient.Uri); - } - - public async Task GetFile(string containerName, string fileName) { - BlobContainerClient containerClient = _blobServiceClient.GetBlobContainerClient(containerName); - BlobClient blobClient = containerClient.GetBlobClient(fileName); - - Response result = await blobClient.ExistsAsync(); - if (!result.Value) { - throw new FileNotFoundException($"File {fileName} not found"); - } - - using MemoryStream ms = new(); - try { - await blobClient.DownloadToAsync(ms); - } catch (Exception ex) { - _logger.LogError("error getting file: {}", ex.Message); - } - - byte[] bytes = ms.ToArray(); - BinaryData data = BinaryData.FromBytes(bytes); - return data; - } - - public async Task PassesContentModeration(BinaryData binaryData) { - if (_contentSafetyClient is null) { - return true; - } - - ContentSafetyImageData image = new(binaryData); - AnalyzeImageOptions request = new(image); - Response response; - try { - response = await _contentSafetyClient.AnalyzeImageAsync(request); - } catch (RequestFailedException ex) { - _logger.LogContentModerationError(ex.Status.ToString(), ex.ErrorCode ?? "", ex.Message); - throw; - } - - AnalyzeImageResult result = response.Value; - int dangerScore = result.CategoriesAnalysis - .Select(v => v.Severity ?? 0) - .DefaultIfEmpty(0) - .Aggregate((a, b) => a + b); - return dangerScore == 0; - } -} \ No newline at end of file +public sealed class ObjectStorageService : IObjectStorageService +{ + private readonly BlobServiceClient _blobServiceClient; + private readonly ContentSafetyClient? _contentSafetyClient; + private readonly ILogger _logger; + + public ObjectStorageService(IOptions settings, ILogger logger) + { + _logger = logger; + _blobServiceClient = new BlobServiceClient( + settings.Value.StorageAccount.AzureStorageConnectionString + ); + + try + { + _contentSafetyClient = new ContentSafetyClient( + endpoint: new Uri(settings.Value.AzureAiFoundry.ContentSafetyEndpoint), + credential: new AzureKeyCredential(settings.Value.AzureAiFoundry.ContentSafetyKey) + ); + } + catch (Exception ex) + { + // silence the error + _logger.LogError( + "error creating content safety client: {message}. Content moderation skipped.", + ex.Message + ); + } + } + + public async Task UploadFile( + string containerName, + string fileName, + BinaryData binaryData, + string mimeType = "application/octet-stream" + ) + { + BlobContainerClient containerClient = _blobServiceClient.GetBlobContainerClient( + containerName + ); + await containerClient.CreateIfNotExistsAsync(PublicAccessType.BlobContainer); + + BlobClient blobClient = containerClient.GetBlobClient(fileName); + + BlobUploadOptions uploadOptions = new() + { + HttpHeaders = new BlobHttpHeaders { ContentType = mimeType }, + // https://tinyurl.com/ms4hvsta + // By default, there will be condition to prevent overwrite. + // Set the conditions to null so that overwrite will be allowed. + Conditions = null, + }; + await blobClient.UploadAsync(binaryData, uploadOptions); + return blobClient.Uri; + } + + public async Task IsFileExists(string containerName, string fileName) + { + BlobContainerClient containerClient = _blobServiceClient.GetBlobContainerClient( + containerName + ); + BlobClient blobClient = containerClient.GetBlobClient(fileName); + Response result = await blobClient.ExistsAsync(); + return result.Value; + } + + public Task GetFileUri(string containerName, string fileName) + { + BlobContainerClient containerClient = _blobServiceClient.GetBlobContainerClient( + containerName + ); + BlobClient blobClient = containerClient.GetBlobClient(fileName); + return Task.FromResult(blobClient.Uri); + } + + public async Task GetFile(string containerName, string fileName) + { + BlobContainerClient containerClient = _blobServiceClient.GetBlobContainerClient( + containerName + ); + BlobClient blobClient = containerClient.GetBlobClient(fileName); + + Response result = await blobClient.ExistsAsync(); + if (!result.Value) + { + throw new FileNotFoundException($"File {fileName} not found"); + } + + using MemoryStream ms = new(); + try + { + await blobClient.DownloadToAsync(ms); + } + catch (Exception ex) + { + _logger.LogError("error getting file: {}", ex.Message); + } + + byte[] bytes = ms.ToArray(); + BinaryData data = BinaryData.FromBytes(bytes); + return data; + } + + public async Task PassesContentModeration(BinaryData binaryData) + { + if (_contentSafetyClient is null) + { + return true; + } + + ContentSafetyImageData image = new(binaryData); + AnalyzeImageOptions request = new(image); + Response response; + try + { + response = await _contentSafetyClient.AnalyzeImageAsync(request); + } + catch (RequestFailedException ex) + { + _logger.LogContentModerationError(ex.Status.ToString(), ex.ErrorCode ?? "", ex.Message); + throw; + } + + AnalyzeImageResult result = response.Value; + int dangerScore = result + .CategoriesAnalysis.Select(v => v.Severity ?? 0) + .DefaultIfEmpty(0) + .Aggregate((a, b) => a + b); + return dangerScore == 0; + } +} diff --git a/src/Evently.Server/Features/Gatherings/Controllers/GatheringsController.cs b/src/Evently.Server/Features/Gatherings/Controllers/GatheringsController.cs index 20c0a88..b008922 100644 --- a/src/Evently.Server/Features/Gatherings/Controllers/GatheringsController.cs +++ b/src/Evently.Server/Features/Gatherings/Controllers/GatheringsController.cs @@ -1,127 +1,168 @@ -using Evently.Server.Common.Domains.Entities; -using Evently.Server.Common.Domains.Interfaces; -using Evently.Server.Common.Domains.Models; +using System.Globalization; using Evently.Server.Common.Extensions; +using Evently.Server.Domains.Entities; +using Evently.Server.Domains.Interfaces; +using Evently.Server.Domains.Models; using Evently.Server.Features.Accounts.Services; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; using MimeKit; -using System.Globalization; namespace Evently.Server.Features.Gatherings.Controllers; [ApiController] [Route("api/v1/[controller]")] public sealed class GatheringsController( - IOptions settings, - ILogger logger, - IGatheringService gatheringService, - IObjectStorageService objectStorageService) : ControllerBase { - private readonly string _containerName = settings.Value.StorageAccount.AccountName; - - [HttpGet("{gatheringId:long}", Name = "GetGathering")] - public async Task> GetGathering(long gatheringId) { - Gathering? customer = await gatheringService.GetGathering(gatheringId); - if (customer is null) { - return NotFound(); - } - return Ok(customer); - } - - [HttpGet("", Name = "GetGatherings")] - public async Task>> GetGatherings(string? attendeeId, - string? organiserId, - string? name, - DateTimeOffset? startDateBefore, DateTimeOffset? startDateAfter, DateTimeOffset? endDateBefore, DateTimeOffset? endDateAfter, - bool? isCancelled, - [FromQuery(Name = "categoryIds[]")] long[]? categoryIds, - int? offset, int? limit) { - logger.LogInformation("categoryIds: {}", string.Join(",", values: categoryIds ?? [])); - PageResult result = await gatheringService.GetGatherings(attendeeId, - organiserId, - name, - startDateBefore, - startDateAfter, - endDateBefore, - endDateAfter, - isCancelled, - categoryIds: categoryIds?.ToHashSet(), - offset, - limit); - List exhibitions = result.Items; - int total = result.TotalCount; - HttpContext.Response.Headers.Append("Access-Control-Expose-Headers", "X-Total-Count"); - HttpContext.Response.Headers.Append("X-Total-Count", value: total.ToString(CultureInfo.InvariantCulture)); - return Ok(exhibitions); - } - - [HttpPost("", Name = "CreateGathering")] - public async Task> CreateGathering([FromForm] GatheringReqDto gatheringReqDto, [FromForm] IFormFile? coverImg) { - gatheringReqDto = gatheringReqDto with { GatheringId = 0L }; - - AuthenticateResult authenticationResult = - await HttpContext.AuthenticateAsync(IdentityConstants.ExternalScheme); - if (!authenticationResult.Succeeded) { - return Unauthorized(); - } - - if (coverImg != null) { - string uri = await UploadCoverImage(gatheringReqDto.GatheringId, coverImg); - gatheringReqDto = gatheringReqDto with { CoverSrc = uri }; - } - - Gathering gathering = await gatheringService.CreateGathering(gatheringReqDto); - return Ok(gathering); - } - - [HttpPut("{gatheringId:long}", Name = "UpdateGathering")] - public async Task UpdateGathering(long gatheringId, [FromForm] GatheringReqDto gatheringReqDto, [FromForm] IFormFile? coverImg) { - Gathering? gathering = await gatheringService.GetGathering(gatheringId); - if (gathering is null) { - return NotFound(); - } - - if (!await this.IsResourceOwner(gathering.OrganiserId)) { - return Forbid(); - } - - if (coverImg != null) { - string uri = await UploadCoverImage(gatheringReqDto.GatheringId, coverImg); - gatheringReqDto = gatheringReqDto with { CoverSrc = uri }; - } - - gathering = await gatheringService.UpdateGathering(gatheringId, gatheringReqDto); - return Ok(gathering); - } - - [HttpDelete("{gatheringId:long}", Name = "DeleteGathering")] - public async Task> DeleteGathering(long gatheringId) { - Gathering? exhibition = await gatheringService.GetGathering(gatheringId); - if (exhibition is null) { - return NotFound(); - } - - if (!await this.IsResourceOwner(exhibition.OrganiserId)) { - return Forbid(); - } - - await gatheringService.DeleteGathering(gatheringId); - return NoContent(); - } - - private async Task UploadCoverImage(long gatheringId, IFormFile coverImg) { - string fileName = $"gatherings/{gatheringId}/cover-image{Path.GetExtension(coverImg.FileName)}"; - BinaryData binaryData = await coverImg.ToBinaryData(); - bool isContentSafe = await objectStorageService.PassesContentModeration(binaryData); - if (!isContentSafe) { - return string.Empty; - } - Uri uri = await objectStorageService.UploadFile(_containerName, - fileName, - binaryData, - mimeType: MimeTypes.GetMimeType(coverImg.FileName)); - return uri.AbsoluteUri; - } -} \ No newline at end of file + IOptions settings, + ILogger logger, + IGatheringService gatheringService, + IObjectStorageService objectStorageService +) : ControllerBase +{ + private readonly string _containerName = settings.Value.StorageAccount.AccountName; + + [HttpGet("{gatheringId:long}", Name = "GetGathering")] + public async Task> GetGathering(long gatheringId) + { + Gathering? customer = await gatheringService.GetGathering(gatheringId); + if (customer is null) + { + return NotFound(); + } + + return Ok(customer); + } + + [HttpGet("", Name = "GetGatherings")] + public async Task>> GetGatherings( + string? attendeeId, + string? organiserId, + string? name, + DateTimeOffset? startDateBefore, + DateTimeOffset? startDateAfter, + DateTimeOffset? endDateBefore, + DateTimeOffset? endDateAfter, + bool? isCancelled, + [FromQuery(Name = "categoryIds[]")] long[]? categoryIds, + int? offset, + int? limit + ) + { + logger.LogInformation("categoryIds: {}", string.Join(",", values: categoryIds ?? [])); + PageResult result = await gatheringService.GetGatherings( + attendeeId, + organiserId, + name, + startDateBefore, + startDateAfter, + endDateBefore, + endDateAfter, + isCancelled, + categoryIds: categoryIds?.ToHashSet(), + offset, + limit + ); + List exhibitions = result.Items; + int total = result.TotalCount; + HttpContext.Response.Headers.Append("Access-Control-Expose-Headers", "X-Total-Count"); + HttpContext.Response.Headers.Append( + "X-Total-Count", + value: total.ToString(CultureInfo.InvariantCulture) + ); + return Ok(exhibitions); + } + + [HttpPost("", Name = "CreateGathering")] + public async Task> CreateGathering( + [FromForm] GatheringReqDto gatheringReqDto, + [FromForm] IFormFile? coverImg + ) + { + gatheringReqDto = gatheringReqDto with { GatheringId = 0L }; + + AuthenticateResult authenticationResult = await HttpContext.AuthenticateAsync( + IdentityConstants.ExternalScheme + ); + if (!authenticationResult.Succeeded) + { + return Unauthorized(); + } + + if (coverImg != null) + { + string uri = await UploadCoverImage(gatheringReqDto.GatheringId, coverImg); + gatheringReqDto = gatheringReqDto with { CoverSrc = uri }; + } + + Gathering gathering = await gatheringService.CreateGathering(gatheringReqDto); + return Ok(gathering); + } + + [HttpPut("{gatheringId:long}", Name = "UpdateGathering")] + public async Task UpdateGathering( + long gatheringId, + [FromForm] GatheringReqDto gatheringReqDto, + [FromForm] IFormFile? coverImg + ) + { + Gathering? gathering = await gatheringService.GetGathering(gatheringId); + if (gathering is null) + { + return NotFound(); + } + + if (!await this.IsResourceOwner(gathering.OrganiserId)) + { + return Forbid(); + } + + if (coverImg != null) + { + string uri = await UploadCoverImage(gatheringReqDto.GatheringId, coverImg); + gatheringReqDto = gatheringReqDto with { CoverSrc = uri }; + } + + gathering = await gatheringService.UpdateGathering(gatheringId, gatheringReqDto); + return Ok(gathering); + } + + [HttpDelete("{gatheringId:long}", Name = "DeleteGathering")] + public async Task> DeleteGathering(long gatheringId) + { + Gathering? exhibition = await gatheringService.GetGathering(gatheringId); + if (exhibition is null) + { + return NotFound(); + } + + if (!await this.IsResourceOwner(exhibition.OrganiserId)) + { + return Forbid(); + } + + await gatheringService.DeleteGathering(gatheringId); + return NoContent(); + } + + private async Task UploadCoverImage(long gatheringId, IFormFile coverImg) + { + string fileName = + $"gatherings/{gatheringId}/cover-image{Path.GetExtension(coverImg.FileName)}"; + BinaryData binaryData = await coverImg.ToBinaryData(); + bool isContentSafe = await objectStorageService.PassesContentModeration(binaryData); + if (!isContentSafe) + { + return string.Empty; + } + + Uri uri = await objectStorageService.UploadFile( + _containerName, + fileName, + binaryData, + mimeType: MimeTypes.GetMimeType(coverImg.FileName) + ); + return uri.AbsoluteUri; + } +} diff --git a/src/Evently.Server/Features/Gatherings/Services/GatheringService.cs b/src/Evently.Server/Features/Gatherings/Services/GatheringService.cs index 755eb8e..c0969f5 100644 --- a/src/Evently.Server/Features/Gatherings/Services/GatheringService.cs +++ b/src/Evently.Server/Features/Gatherings/Services/GatheringService.cs @@ -1,107 +1,133 @@ -using Evently.Server.Common.Adapters.Data; -using Evently.Server.Common.Domains.Entities; -using Evently.Server.Common.Domains.Interfaces; -using Evently.Server.Common.Domains.Models; +using Evently.Server.Common.Data; using Evently.Server.Common.Extensions; +using Evently.Server.Domains.Entities; +using Evently.Server.Domains.Interfaces; +using Evently.Server.Domains.Models; using FluentValidation; using FluentValidation.Results; using Microsoft.EntityFrameworkCore; namespace Evently.Server.Features.Gatherings.Services; -public sealed class GatheringService(AppDbContext db, IValidator validator) : IGatheringService { - public async Task GetGathering(long gatheringId) { - return await db.Gatherings - .Include(gathering => gathering.Bookings) - .Include(gathering => gathering.GatheringCategoryDetails) - .ThenInclude(detail => detail.Category) - .FirstOrDefaultAsync((gathering) => gathering.GatheringId == gatheringId); - } +public sealed class GatheringService(AppDbContext db, IValidator validator) + : IGatheringService +{ + public async Task GetGathering(long gatheringId) + { + return await db + .Gatherings.Include(gathering => gathering.Bookings) + .Include(gathering => gathering.GatheringCategoryDetails) + .ThenInclude(detail => detail.Category) + .FirstOrDefaultAsync((gathering) => gathering.GatheringId == gatheringId); + } - public async Task> GetGatherings( - string? attendeeId, - string? organiserId, - string? name, - DateTimeOffset? startDateBefore, - DateTimeOffset? startDateAfter, - DateTimeOffset? endDateBefore, - DateTimeOffset? endDateAfter, - bool? isCancelled, - HashSet? categoryIds, - int? offset, - int? limit) { - IQueryable query = db.Gatherings - .Where((gathering) => name == null || EF.Functions.Like(gathering.Name, $"%{name}%")) - .Where((gathering) => startDateBefore == null || gathering.Start <= startDateBefore) - .Where((gathering) => startDateAfter == null || gathering.Start >= startDateAfter) - .Where((gathering) => endDateBefore == null || gathering.End <= endDateBefore) - .Where((gathering) => endDateAfter == null || gathering.End >= endDateAfter) - .Where((gathering) => organiserId == null || gathering.OrganiserId == organiserId) - .Where(gathering => isCancelled == null || gathering.CancellationDateTime.HasValue == isCancelled) - .Where((gathering) => - categoryIds == null || categoryIds.Count == 0 || gathering.GatheringCategoryDetails.Any(detail => categoryIds.Contains(detail.CategoryId))) - .Where((gathering) => - attendeeId == null || gathering.Bookings.Any((be) => be.AttendeeId == attendeeId)) - .Include(gathering => gathering.Bookings.Where((be) => be.AttendeeId == attendeeId)) - .Include(gathering => gathering.GatheringCategoryDetails) - .ThenInclude(detail => detail.Category); + public async Task> GetGatherings( + string? attendeeId, + string? organiserId, + string? name, + DateTimeOffset? startDateBefore, + DateTimeOffset? startDateAfter, + DateTimeOffset? endDateBefore, + DateTimeOffset? endDateAfter, + bool? isCancelled, + HashSet? categoryIds, + int? offset, + int? limit + ) + { + IQueryable query = db + .Gatherings.Where( + (gathering) => name == null || EF.Functions.Like(gathering.Name, $"%{name}%") + ) + .Where((gathering) => startDateBefore == null || gathering.Start <= startDateBefore) + .Where((gathering) => startDateAfter == null || gathering.Start >= startDateAfter) + .Where((gathering) => endDateBefore == null || gathering.End <= endDateBefore) + .Where((gathering) => endDateAfter == null || gathering.End >= endDateAfter) + .Where((gathering) => organiserId == null || gathering.OrganiserId == organiserId) + .Where(gathering => + isCancelled == null || gathering.CancellationDateTime.HasValue == isCancelled + ) + .Where( + (gathering) => + categoryIds == null + || categoryIds.Count == 0 + || gathering.GatheringCategoryDetails.Any(detail => + categoryIds.Contains(detail.CategoryId) + ) + ) + .Where( + (gathering) => + attendeeId == null + || gathering.Bookings.Any((be) => be.AttendeeId == attendeeId) + ) + .Include(gathering => gathering.Bookings.Where((be) => be.AttendeeId == attendeeId)) + .Include(gathering => gathering.GatheringCategoryDetails) + .ThenInclude(detail => detail.Category); - int totalCount = await query.CountAsync(); + int totalCount = await query.CountAsync(); - List gatherings = await query - .OrderBy(gathering => gathering.Start) - .Skip(offset ?? 0) - .Take(limit ?? int.MaxValue) - .Select((gathering) => gathering) - .ToListAsync(); + List gatherings = await query + .OrderBy(gathering => gathering.Start) + .Skip(offset ?? 0) + .Take(limit ?? int.MaxValue) + .Select((gathering) => gathering) + .ToListAsync(); - return new PageResult { - Items = gatherings, - TotalCount = totalCount, - }; - } + return new PageResult { Items = gatherings, TotalCount = totalCount }; + } - public async Task CreateGathering(GatheringReqDto gatheringReqDto) { - Gathering gathering = gatheringReqDto.ToGathering(); - ValidationResult validationResult = await validator.ValidateAsync(gathering); - if (!validationResult.IsValid) { - throw new ArgumentException(string.Join(", ", values: validationResult.Errors.Select(e => e.ErrorMessage))); - } + public async Task CreateGathering(GatheringReqDto gatheringReqDto) + { + Gathering gathering = gatheringReqDto.ToGathering(); + ValidationResult validationResult = await validator.ValidateAsync(gathering); + if (!validationResult.IsValid) + { + throw new ArgumentException( + string.Join(", ", values: validationResult.Errors.Select(e => e.ErrorMessage)) + ); + } - db.Gatherings.Add(gathering); - await db.SaveChangesAsync(); - return gathering; - } + db.Gatherings.Add(gathering); + await db.SaveChangesAsync(); + return gathering; + } - public async Task UpdateGathering(long gatheringId, GatheringReqDto gatheringReqDto) { - Gathering gathering = gatheringReqDto.ToGathering(); - ValidationResult validationResult = await validator.ValidateAsync(gathering); - if (!validationResult.IsValid) { - throw new ArgumentException(string.Join(", ", values: validationResult.Errors.Select(e => e.ErrorMessage))); - } + public async Task UpdateGathering(long gatheringId, GatheringReqDto gatheringReqDto) + { + Gathering gathering = gatheringReqDto.ToGathering(); + ValidationResult validationResult = await validator.ValidateAsync(gathering); + if (!validationResult.IsValid) + { + throw new ArgumentException( + string.Join(", ", values: validationResult.Errors.Select(e => e.ErrorMessage)) + ); + } - Gathering current = await db.Gatherings.AsTracking() - .Include((g) => g.GatheringCategoryDetails) - .FirstOrDefaultAsync((ex) => ex.GatheringId == gatheringId) - ?? throw new KeyNotFoundException($"{gatheringId} not found"); + Gathering current = + await db + .Gatherings.AsTracking() + .Include((g) => g.GatheringCategoryDetails) + .FirstOrDefaultAsync((ex) => ex.GatheringId == gatheringId) + ?? throw new KeyNotFoundException($"{gatheringId} not found"); - current.Name = gathering.Name; - current.Description = gathering.Description; - current.Start = gathering.Start; - current.End = gathering.End; - current.Location = gathering.Location; - current.CoverSrc = gathering.CoverSrc; - current.GatheringCategoryDetails = gathering.GatheringCategoryDetails; + current.Name = gathering.Name; + current.Description = gathering.Description; + current.Start = gathering.Start; + current.End = gathering.End; + current.Location = gathering.Location; + current.CoverSrc = gathering.CoverSrc; + current.GatheringCategoryDetails = gathering.GatheringCategoryDetails; - await db.SaveChangesAsync(); - return current; - } + await db.SaveChangesAsync(); + return current; + } - public async Task DeleteGathering(long gatheringId) { - Gathering gathering = await db.Gatherings - .AsTracking() - .SingleAsync((gathering) => gathering.GatheringId == gatheringId); - db.Remove(gathering); - await db.SaveChangesAsync(); - } -} \ No newline at end of file + public async Task DeleteGathering(long gatheringId) + { + Gathering gathering = await db + .Gatherings.AsTracking() + .SingleAsync((gathering) => gathering.GatheringId == gatheringId); + db.Remove(gathering); + await db.SaveChangesAsync(); + } +} diff --git a/src/Evently.Server/Features/Gatherings/Services/GatheringValidator.cs b/src/Evently.Server/Features/Gatherings/Services/GatheringValidator.cs index f0ea341..ec0b0ec 100644 --- a/src/Evently.Server/Features/Gatherings/Services/GatheringValidator.cs +++ b/src/Evently.Server/Features/Gatherings/Services/GatheringValidator.cs @@ -1,23 +1,37 @@ -using Evently.Server.Common.Domains.Entities; +using Evently.Server.Domains.Entities; using FluentValidation; namespace Evently.Server.Features.Gatherings.Services; -public sealed class GatheringValidator : AbstractValidator { - public GatheringValidator() { - RuleFor((exhibition) => exhibition.Name).NotEmpty().WithMessage("Name is required."); - RuleFor((exhibition) => exhibition.Description).NotEmpty().WithMessage("Description is required."); - RuleFor((exhibition) => exhibition.Start).NotEmpty().WithMessage("Starting Date is required."); - RuleFor((exhibition) => exhibition.End).NotEmpty().WithMessage("End Date is required."); - RuleFor((exhibition) => exhibition.OrganiserId).NotEmpty().WithMessage("Event Organiser Id is required."); - RuleForEach((exhibition) => exhibition.Bookings).Custom((value, context) => { - if (value.GatheringId == 0) { - context.AddFailure("ExhibitionId is required."); - } +public sealed class GatheringValidator : AbstractValidator +{ + public GatheringValidator() + { + RuleFor((exhibition) => exhibition.Name).NotEmpty().WithMessage("Name is required."); + RuleFor((exhibition) => exhibition.Description) + .NotEmpty() + .WithMessage("Description is required."); + RuleFor((exhibition) => exhibition.Start) + .NotEmpty() + .WithMessage("Starting Date is required."); + RuleFor((exhibition) => exhibition.End).NotEmpty().WithMessage("End Date is required."); + RuleFor((exhibition) => exhibition.OrganiserId) + .NotEmpty() + .WithMessage("Event Organiser Id is required."); + RuleForEach((exhibition) => exhibition.Bookings) + .Custom( + (value, context) => + { + if (value.GatheringId == 0) + { + context.AddFailure("ExhibitionId is required."); + } - if (string.IsNullOrEmpty(value.BookingId)) { - context.AddFailure("BookingEventId is required."); - } - }); - } -} \ No newline at end of file + if (string.IsNullOrEmpty(value.BookingId)) + { + context.AddFailure("BookingEventId is required."); + } + } + ); + } +} diff --git a/src/Evently.Server/Features/HealthChecks/Controllers/HealthChecksController.cs b/src/Evently.Server/Features/HealthChecks/Controllers/HealthChecksController.cs index 4de00bb..0932769 100644 --- a/src/Evently.Server/Features/HealthChecks/Controllers/HealthChecksController.cs +++ b/src/Evently.Server/Features/HealthChecks/Controllers/HealthChecksController.cs @@ -5,25 +5,31 @@ namespace Evently.Server.Features.HealthChecks.Controllers; [ApiController] [Route("api/v1/[controller]")] -public sealed class HealthChecksController(HealthCheckService healthCheckService) : ControllerBase { - private readonly Dictionary _statuses = new() { - { HealthStatus.Healthy, "Healthy" }, - { HealthStatus.Unhealthy, "Unhealthy" }, - { HealthStatus.Degraded, "Degraded" }, - }; +public sealed class HealthChecksController(HealthCheckService healthCheckService) : ControllerBase +{ + private readonly Dictionary _statuses = new() + { + { HealthStatus.Healthy, "Healthy" }, + { HealthStatus.Unhealthy, "Unhealthy" }, + { HealthStatus.Degraded, "Degraded" }, + }; - [HttpGet(Name = "HealthCheck")] - public async Task GetHealthcheck() { - HealthReport healthReport = await healthCheckService.CheckHealthAsync(); + [HttpGet(Name = "HealthCheck")] + public async Task GetHealthcheck() + { + HealthReport healthReport = await healthCheckService.CheckHealthAsync(); - Dictionary statuses = healthReport.Entries - .ToDictionary(keySelector: key => key.Key, elementSelector: value => _statuses[value.Value.Status]); - statuses["Server"] = "Healthy"; - return Ok(statuses); - } + Dictionary statuses = healthReport.Entries.ToDictionary( + keySelector: key => key.Key, + elementSelector: value => _statuses[value.Value.Status] + ); + statuses["Server"] = "Healthy"; + return Ok(statuses); + } - [HttpGet("middlewares/error-middleware", Name = "TestErrorMiddleware")] - public Task TestErrorMiddleware() { - throw new ArgumentException("Test Error Middleware"); - } -} \ No newline at end of file + [HttpGet("middlewares/error-middleware", Name = "TestErrorMiddleware")] + public Task TestErrorMiddleware() + { + throw new ArgumentException("Test Error Middleware"); + } +} diff --git a/src/Evently.Server/Program.cs b/src/Evently.Server/Program.cs index 75b182f..c4c5ff2 100644 --- a/src/Evently.Server/Program.cs +++ b/src/Evently.Server/Program.cs @@ -1,10 +1,12 @@ -using Evently.Server.Common.Adapters.Blazor; -using Evently.Server.Common.Adapters.Data; -using Evently.Server.Common.Domains.Entities; -using Evently.Server.Common.Domains.Interfaces; -using Evently.Server.Common.Domains.Models; +using System.Text.Json.Serialization; +using System.Threading.Channels; +using Evently.Server.Common.Blazor; +using Evently.Server.Common.Data; using Evently.Server.Common.Extensions; using Evently.Server.Common.Middlewares; +using Evently.Server.Domains.Entities; +using Evently.Server.Domains.Interfaces; +using Evently.Server.Domains.Models; using Evently.Server.Features.Accounts.Services; using Evently.Server.Features.Bookings.Services; using Evently.Server.Features.Categories.Services; @@ -17,35 +19,45 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Console; using Microsoft.Extensions.Options; -using System.Text.Json.Serialization; -using System.Threading.Channels; -using AccountService=Evently.Server.Features.Accounts.Services.AccountService; -using BlazorHtmlRenderer=Microsoft.AspNetCore.Components.Web.HtmlRenderer; +using AccountService = Evently.Server.Features.Accounts.Services.AccountService; +using BlazorHtmlRenderer = Microsoft.AspNetCore.Components.Web.HtmlRenderer; WebApplicationBuilder builder = WebApplication.CreateBuilder(args); ConfigurationManager config = builder.Configuration; -ILoggerFactory logFactory = LoggerFactory.Create((logBuilder) => { - logBuilder.AddSimpleConsole((opts) => opts.ColorBehavior = LoggerColorBehavior.Disabled); -}); +ILoggerFactory logFactory = LoggerFactory.Create( + (logBuilder) => + { + logBuilder.AddSimpleConsole((opts) => opts.ColorBehavior = LoggerColorBehavior.Disabled); + } +); ILogger logger = logFactory.CreateLogger(); // Inject appsettings.json into the application IOptions settings = builder.Services.LoadAppConfiguration(config); // register DB -// retrieve the heroku postgres db conn string, otherwise, get the local default string? dbConnStr = builder.Configuration.GetConnectionString("WebApiDatabase"); logger.LogValue("dbConnStr", dbConnStr); -builder.Services.AddDbContext((options) => { - options.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking); - options.UseSqlServer(dbConnStr, - sqlServerOptionsAction: opt => opt.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery)); -}); +builder.Services.AddDbContext( + (options) => + { + options.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking); + options.UseSqlServer( + dbConnStr, + sqlServerOptionsAction: opt => + opt.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery) + ); + } +); // Add services to the container. -builder.Services.AddControllersWithViews().AddJsonOptions((options) => - options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles); +builder + .Services.AddControllersWithViews() + .AddJsonOptions( + (options) => options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles + ); + // Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi builder.Services.AddOpenApi(); @@ -54,8 +66,7 @@ builder.Services.AddTransient(); builder.Services.AddHttpContextAccessor(); builder.Services.AddTransient(); -builder.Services.AddHealthChecks() - .AddDbContextCheck(); +builder.Services.AddHealthChecks().AddDbContextCheck(); builder.Services.AddSingleton(); builder.Services.AddTransient(); @@ -75,65 +86,72 @@ builder.Services.AddScoped, GatheringValidator>(); builder.Services.AddScoped, BookingValidator>(); -builder.Services.AddIdentityApiEndpoints() - .AddEntityFrameworkStores(); +builder.Services.AddIdentityApiEndpoints().AddEntityFrameworkStores(); // https://learn.microsoft.com/en-us/dotnet/core/compatibility/aspnet-core/7.0/default-authentication-scheme#new-behavior // No default auth scheme is set -builder.Services.AddAuthentication() - .AddCookie() - .AddGoogle((options) => { - options.ClientId = settings.Value.Authentication.Google.ClientId; - options.ClientSecret = settings.Value.Authentication.Google.ClientSecret; - options.CallbackPath = "/api/signin-google"; // rmb to resister in the Google oauth dashboard - options.SignInScheme = - IdentityConstants - .ExternalScheme; // important to default to external scheme - https://stackoverflow.com/a/78674926/6514532 - - // Enable refresh token - options.SaveTokens = true; - options.AccessType = "offline"; - - // For debugging purpose - options.Events.OnRedirectToAuthorizationEndpoint = (context) => { - logger.LogInformation("Request Path: {Request}", context.Request.RootUri().AbsoluteUri); - context.HttpContext.Response.Redirect(context.RedirectUri); - return Task.CompletedTask; - }; - }); - -builder.Services.AddAuthorizationBuilder() - .AddPolicy(SameAccountRequirement.PolicyName, - configurePolicy: (policy) => - policy.Requirements.Add(new SameAccountRequirement())); +builder + .Services.AddAuthentication() + .AddCookie() + .AddGoogle( + (options) => + { + options.ClientId = settings.Value.Authentication.Google.ClientId; + options.ClientSecret = settings.Value.Authentication.Google.ClientSecret; + options.CallbackPath = "/api/signin-google"; // rmb to resister in the Google oauth dashboard + options.SignInScheme = IdentityConstants.ExternalScheme; // important to default to external scheme - https://stackoverflow.com/a/78674926/6514532 + + // Enable refresh token + options.SaveTokens = true; + options.AccessType = "offline"; + + // For debugging purpose + options.Events.OnRedirectToAuthorizationEndpoint = (context) => + { + logger.LogInformation( + "Request Path: {Request}", + context.Request.RootUri().AbsoluteUri + ); + context.HttpContext.Response.Redirect(context.RedirectUri); + return Task.CompletedTask; + }; + } + ); + +builder + .Services.AddAuthorizationBuilder() + .AddPolicy( + SameAccountRequirement.PolicyName, + configurePolicy: (policy) => policy.Requirements.Add(new SameAccountRequirement()) + ); // Add razor pages support to render Blazor files -builder.Services.AddRazorComponents() - .AddInteractiveServerComponents(); +builder.Services.AddRazorComponents().AddInteractiveServerComponents(); builder.Services.AddExceptionHandler(); WebApplication app = builder.Build(); -using (IServiceScope serviceScope = app.Services.CreateScope()) { - AppDbContext dbContext = serviceScope.ServiceProvider.GetRequiredService(); - await dbContext.Database.MigrateAsync(); +using (IServiceScope serviceScope = app.Services.CreateScope()) +{ + AppDbContext dbContext = serviceScope.ServiceProvider.GetRequiredService(); + await dbContext.Database.MigrateAsync(); } // Use the global exception handler -app.UseExceptionHandler((_) => {}); +app.UseExceptionHandler((_) => { }); // To serve the Svelte SPA files app.UseFileServer(); // needed for Blazor app.UseAntiforgery(); -app.MapRazorComponents() - .AddInteractiveServerRenderMode(); +app.MapRazorComponents().AddInteractiveServerRenderMode(); // Configure the HTTP request pipeline. -if (app.Environment.IsDevelopment()) { - app.MapOpenApi(); +if (app.Environment.IsDevelopment()) +{ + app.MapOpenApi(); } app.UseHttpsRedirection(); @@ -150,4 +168,4 @@ // Needed for unit testing // ReSharper disable once ClassNeverInstantiated.Global -public partial class Program; \ No newline at end of file +public partial class Program; diff --git a/src/evently.client/src/lib/domains/entities/entities.ts b/src/evently.client/src/domains/entities/entities.ts similarity index 100% rename from src/evently.client/src/lib/domains/entities/entities.ts rename to src/evently.client/src/domains/entities/entities.ts diff --git a/src/evently.client/src/lib/domains/entities/index.ts b/src/evently.client/src/domains/entities/index.ts similarity index 100% rename from src/evently.client/src/lib/domains/entities/index.ts rename to src/evently.client/src/domains/entities/index.ts diff --git a/src/evently.client/src/lib/domains/interfaces/index.ts b/src/evently.client/src/domains/interfaces/index.ts similarity index 100% rename from src/evently.client/src/lib/domains/interfaces/index.ts rename to src/evently.client/src/domains/interfaces/index.ts diff --git a/src/evently.client/src/lib/domains/interfaces/page-result.ts b/src/evently.client/src/domains/interfaces/page-result.ts similarity index 100% rename from src/evently.client/src/lib/domains/interfaces/page-result.ts rename to src/evently.client/src/domains/interfaces/page-result.ts diff --git a/src/evently.client/src/lib/domains/interfaces/route-context.ts b/src/evently.client/src/domains/interfaces/route-context.ts similarity index 70% rename from src/evently.client/src/lib/domains/interfaces/route-context.ts rename to src/evently.client/src/domains/interfaces/route-context.ts index e8c5610..0af6b5f 100644 --- a/src/evently.client/src/lib/domains/interfaces/route-context.ts +++ b/src/evently.client/src/domains/interfaces/route-context.ts @@ -1,4 +1,4 @@ -import { Account } from "~/lib/domains/entities"; +import { Account } from "~/domains/entities"; export interface RouteContext { // The ReturnType of your useAuth hook or the value of your AuthContext diff --git a/src/evently.client/src/lib/domains/models/index.ts b/src/evently.client/src/domains/models/index.ts similarity index 100% rename from src/evently.client/src/lib/domains/models/index.ts rename to src/evently.client/src/domains/models/index.ts diff --git a/src/evently.client/src/lib/domains/models/toast-content.ts b/src/evently.client/src/domains/models/toast-content.ts similarity index 100% rename from src/evently.client/src/lib/domains/models/toast-content.ts rename to src/evently.client/src/domains/models/toast-content.ts diff --git a/src/evently.client/src/lib/domains/models/upsert-dtos.ts b/src/evently.client/src/domains/models/upsert-dtos.ts similarity index 100% rename from src/evently.client/src/lib/domains/models/upsert-dtos.ts rename to src/evently.client/src/domains/models/upsert-dtos.ts diff --git a/src/evently.client/src/lib/components/-card.test.tsx b/src/evently.client/src/lib/components/-card.test.tsx index 73c6ac5..3f8f92d 100644 --- a/src/evently.client/src/lib/components/-card.test.tsx +++ b/src/evently.client/src/lib/components/-card.test.tsx @@ -1,6 +1,6 @@ import { render, screen, waitFor } from "@testing-library/react"; import { Card } from "~/lib/components/card.tsx"; -import { Gathering } from "~/lib/domains/entities"; +import { Gathering } from "~/domains/entities"; import { getMockGathering } from "~/lib/services/gathering-service.mock"; import { TestComponentWrapper, diff --git a/src/evently.client/src/lib/components/card.tsx b/src/evently.client/src/lib/components/card.tsx index 512083f..09db4af 100644 --- a/src/evently.client/src/lib/components/card.tsx +++ b/src/evently.client/src/lib/components/card.tsx @@ -2,7 +2,7 @@ import Placeholder2 from "~/lib/assets/event_placeholder_2.png"; import { type JSX } from "react"; import { Link } from "@tanstack/react-router"; -import { Category, Gathering } from "~/lib/domains/entities"; +import { Category, Gathering } from "~/domains/entities"; import { Icon } from "@iconify/react"; import { DateTime } from "luxon"; import { hashString } from "~/lib/services"; diff --git a/src/evently.client/src/lib/components/test-component-wrapper.tsx b/src/evently.client/src/lib/components/test-component-wrapper.tsx index a6eced9..627c042 100644 --- a/src/evently.client/src/lib/components/test-component-wrapper.tsx +++ b/src/evently.client/src/lib/components/test-component-wrapper.tsx @@ -1,5 +1,5 @@ import type { JSX, ReactNode } from "react"; -import { Account } from "~/lib/domains/entities"; +import { Account } from "~/domains/entities"; import { type AnyRoute, createMemoryHistory, @@ -9,7 +9,7 @@ import { Outlet, RouterProvider } from "@tanstack/react-router"; -import type { RouteContext } from "~/lib/domains/interfaces"; +import type { RouteContext } from "~/domains/interfaces"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; interface TestComponentProps { diff --git a/src/evently.client/src/lib/services/auth-service.ts b/src/evently.client/src/lib/services/auth-service.ts index 68fe31e..5a870d1 100644 --- a/src/evently.client/src/lib/services/auth-service.ts +++ b/src/evently.client/src/lib/services/auth-service.ts @@ -1,6 +1,6 @@ import { redirect } from "@tanstack/react-router"; import axios, { type AxiosResponse } from "axios"; -import { Account } from "~/lib/domains/entities"; +import { Account } from "~/domains/entities"; import { sleep } from "~/lib/services/util-service"; export async function login(redirectUrl: string) { diff --git a/src/evently.client/src/lib/services/booking-service.ts b/src/evently.client/src/lib/services/booking-service.ts index ff0cec2..afe9305 100644 --- a/src/evently.client/src/lib/services/booking-service.ts +++ b/src/evently.client/src/lib/services/booking-service.ts @@ -1,7 +1,7 @@ import axios from "axios"; -import { Booking } from "~/lib/domains/entities"; -import { BookingReqDto } from "~/lib/domains/models"; -import type { PageResult } from "~/lib/domains/interfaces"; +import { Booking } from "~/domains/entities"; +import { BookingReqDto } from "~/domains/models"; +import type { PageResult } from "~/domains/interfaces"; export interface GetBookingsParams { attendeeId?: string; diff --git a/src/evently.client/src/lib/services/category-service.ts b/src/evently.client/src/lib/services/category-service.ts index 1bc3152..a3fa3f2 100644 --- a/src/evently.client/src/lib/services/category-service.ts +++ b/src/evently.client/src/lib/services/category-service.ts @@ -1,4 +1,4 @@ -import { Category } from "~/lib/domains/entities"; +import { Category } from "~/domains/entities"; import axios from "axios"; export async function getCategories(): Promise { diff --git a/src/evently.client/src/lib/services/gathering-service.mock.ts b/src/evently.client/src/lib/services/gathering-service.mock.ts index 04615d3..8c92d6c 100644 --- a/src/evently.client/src/lib/services/gathering-service.mock.ts +++ b/src/evently.client/src/lib/services/gathering-service.mock.ts @@ -1,7 +1,7 @@ -import { Booking, Category, Gathering, GatheringCategoryDetail } from "~/lib/domains/entities"; -import { GatheringReqDto } from "~/lib/domains/models"; +import { Booking, Category, Gathering, GatheringCategoryDetail } from "~/domains/entities"; +import { GatheringReqDto } from "~/domains/models"; import type { GetGatheringsParams } from "./gathering-service"; -import type { PageResult } from "~/lib/domains/interfaces"; +import type { PageResult } from "~/domains/interfaces"; // Mock data for categories const mockCategories: Category[] = [ diff --git a/src/evently.client/src/lib/services/gathering-service.ts b/src/evently.client/src/lib/services/gathering-service.ts index f5efec2..b8e2518 100644 --- a/src/evently.client/src/lib/services/gathering-service.ts +++ b/src/evently.client/src/lib/services/gathering-service.ts @@ -1,7 +1,7 @@ -import type { Gathering } from "~/lib/domains/entities"; +import type { Gathering } from "~/domains/entities"; import axios from "axios"; -import { GatheringCategoryDetailReqDto, GatheringReqDto } from "~/lib/domains/models"; -import type { PageResult } from "~/lib/domains/interfaces"; +import { GatheringCategoryDetailReqDto, GatheringReqDto } from "~/domains/models"; +import type { PageResult } from "~/domains/interfaces"; import cloneDeep from "lodash.clonedeep"; export interface GetGatheringsParams { diff --git a/src/evently.client/src/routes/__root.tsx b/src/evently.client/src/routes/__root.tsx index 62b91c1..68a339d 100644 --- a/src/evently.client/src/routes/__root.tsx +++ b/src/evently.client/src/routes/__root.tsx @@ -3,8 +3,8 @@ import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"; import { Navbar, NotFound } from "~/lib/components"; import { type JSX, useEffect } from "react"; import { getAccount } from "~/lib/services"; -import { Account } from "~/lib/domains/entities"; -import type { RouteContext } from "~/lib/domains/interfaces/route-context.ts"; +import { Account } from "~/domains/entities"; +import type { RouteContext } from "~/domains/interfaces/route-context.ts"; import polyfill from "@oddbird/css-anchor-positioning/fn"; export const Route = createRootRouteWithContext()({ diff --git a/src/evently.client/src/routes/bookings/(auth)/attending/index.tsx b/src/evently.client/src/routes/bookings/(auth)/attending/index.tsx index 520c60b..e1a7fa7 100644 --- a/src/evently.client/src/routes/bookings/(auth)/attending/index.tsx +++ b/src/evently.client/src/routes/bookings/(auth)/attending/index.tsx @@ -1,6 +1,6 @@ import { createFileRoute } from "@tanstack/react-router"; import { type JSX, useState } from "react"; -import { Booking, Gathering } from "~/lib/domains/entities"; +import { Booking, Gathering } from "~/domains/entities"; import { getBookings, type GetBookingsParams } from "~/lib/services"; import { Card, Tabs, TabState } from "~/lib/components"; import cloneDeep from "lodash.clonedeep"; diff --git a/src/evently.client/src/routes/bookings/(auth)/hosting/$gatheringId/-components/bookings-table.tsx b/src/evently.client/src/routes/bookings/(auth)/hosting/$gatheringId/-components/bookings-table.tsx index a342389..cee8f34 100644 --- a/src/evently.client/src/routes/bookings/(auth)/hosting/$gatheringId/-components/bookings-table.tsx +++ b/src/evently.client/src/routes/bookings/(auth)/hosting/$gatheringId/-components/bookings-table.tsx @@ -1,5 +1,5 @@ import type { JSX } from "react"; -import { Booking } from "~/lib/domains/entities"; +import { Booking } from "~/domains/entities"; import { toIsoDateTimeString } from "~/lib/services"; import { Icon } from "@iconify/react"; diff --git a/src/evently.client/src/routes/bookings/(auth)/hosting/$gatheringId/-components/jumbotron.tsx b/src/evently.client/src/routes/bookings/(auth)/hosting/$gatheringId/-components/jumbotron.tsx index 7078794..fd6f2bd 100644 --- a/src/evently.client/src/routes/bookings/(auth)/hosting/$gatheringId/-components/jumbotron.tsx +++ b/src/evently.client/src/routes/bookings/(auth)/hosting/$gatheringId/-components/jumbotron.tsx @@ -1,7 +1,7 @@ import { Icon } from "@iconify/react/dist/iconify.js"; import { DateTime } from "luxon"; import type { JSX } from "react"; -import { Gathering } from "~/lib/domains/entities"; +import { Gathering } from "~/domains/entities"; interface JumbotronProps { gathering: Gathering; diff --git a/src/evently.client/src/routes/bookings/(auth)/hosting/$gatheringId/dashboard.index.tsx b/src/evently.client/src/routes/bookings/(auth)/hosting/$gatheringId/dashboard.index.tsx index 02088ca..fd35ba6 100644 --- a/src/evently.client/src/routes/bookings/(auth)/hosting/$gatheringId/dashboard.index.tsx +++ b/src/evently.client/src/routes/bookings/(auth)/hosting/$gatheringId/dashboard.index.tsx @@ -1,4 +1,4 @@ -import { Booking, Gathering } from "~/lib/domains/entities"; +import { Booking, Gathering } from "~/domains/entities"; import { downloadFile, getBookings, @@ -15,7 +15,7 @@ import { json2csv } from "json-2-csv"; import { useQuery } from "@tanstack/react-query"; import { BookingsTable, Jumbotron, StatsCard } from "./-components"; import { useInterval } from "usehooks-ts"; -import type { PageResult } from "~/lib/domains/interfaces"; +import type { PageResult } from "~/domains/interfaces"; export const Route = createFileRoute("/bookings/(auth)/hosting/$gatheringId/dashboard/")({ loader: async ({ params }) => { diff --git a/src/evently.client/src/routes/bookings/(auth)/hosting/$gatheringId/dashboard.scan.tsx b/src/evently.client/src/routes/bookings/(auth)/hosting/$gatheringId/dashboard.scan.tsx index 6ca4943..32b8a31 100644 --- a/src/evently.client/src/routes/bookings/(auth)/hosting/$gatheringId/dashboard.scan.tsx +++ b/src/evently.client/src/routes/bookings/(auth)/hosting/$gatheringId/dashboard.scan.tsx @@ -1,9 +1,9 @@ import { createFileRoute } from "@tanstack/react-router"; import { Scanner } from "./-components"; import { checkInBooking, sleep } from "~/lib/services"; -import { Booking } from "~/lib/domains/entities"; +import { Booking } from "~/domains/entities"; import { useCallback, useState } from "react"; -import { ToastContent, ToastStatus, toastStyles } from "~/lib/domains/models"; +import { ToastContent, ToastStatus, toastStyles } from "~/domains/models"; import { useForm } from "@tanstack/react-form"; import { FieldErrMsg as FieldInfo } from "~/lib/components"; diff --git a/src/evently.client/src/routes/bookings/(auth)/hosting/index.tsx b/src/evently.client/src/routes/bookings/(auth)/hosting/index.tsx index e788c6e..1cbe4d4 100644 --- a/src/evently.client/src/routes/bookings/(auth)/hosting/index.tsx +++ b/src/evently.client/src/routes/bookings/(auth)/hosting/index.tsx @@ -1,6 +1,6 @@ import { createFileRoute, Link } from "@tanstack/react-router"; import { type JSX, useState } from "react"; -import { Gathering } from "~/lib/domains/entities"; +import { Gathering } from "~/domains/entities"; import { getGatherings, type GetGatheringsParams } from "~/lib/services"; import { Card, Tabs, TabState } from "~/lib/components"; import { useQuery } from "@tanstack/react-query"; diff --git a/src/evently.client/src/routes/gatherings/$gatheringId/(auth).update.tsx b/src/evently.client/src/routes/gatherings/$gatheringId/(auth).update.tsx index 8f14b83..e7e1589 100644 --- a/src/evently.client/src/routes/gatherings/$gatheringId/(auth).update.tsx +++ b/src/evently.client/src/routes/gatherings/$gatheringId/(auth).update.tsx @@ -1,5 +1,5 @@ import { createFileRoute } from "@tanstack/react-router"; -import { Category, Gathering } from "~/lib/domains/entities"; +import { Category, Gathering } from "~/domains/entities"; import { type JSX, useEffect, useState } from "react"; import { authenticateRoute, @@ -13,7 +13,7 @@ import { type GatheringForm as IGatheringForm, useGatheringForm } from "~/routes/gatherings/-services"; -import { GatheringReqDto, ToastContent } from "~/lib/domains/models"; +import { GatheringReqDto, ToastContent } from "~/domains/models"; import { GatheringForm } from "~/routes/gatherings/-components"; export const Route = createFileRoute("/gatherings/$gatheringId/(auth)/update")({ diff --git a/src/evently.client/src/routes/gatherings/$gatheringId/-components/jumbotron.tsx b/src/evently.client/src/routes/gatherings/$gatheringId/-components/jumbotron.tsx index 2ad3ee0..0543c86 100644 --- a/src/evently.client/src/routes/gatherings/$gatheringId/-components/jumbotron.tsx +++ b/src/evently.client/src/routes/gatherings/$gatheringId/-components/jumbotron.tsx @@ -1,6 +1,6 @@ import type { JSX } from "react"; import { Icon } from "@iconify/react"; -import { Booking, Category, Gathering } from "~/lib/domains/entities"; +import { Booking, Category, Gathering } from "~/domains/entities"; import { DateTime } from "luxon"; interface JumbotronProps { diff --git a/src/evently.client/src/routes/gatherings/$gatheringId/-components/qr-dialog.tsx b/src/evently.client/src/routes/gatherings/$gatheringId/-components/qr-dialog.tsx index 87f3263..b3fe324 100644 --- a/src/evently.client/src/routes/gatherings/$gatheringId/-components/qr-dialog.tsx +++ b/src/evently.client/src/routes/gatherings/$gatheringId/-components/qr-dialog.tsx @@ -1,5 +1,5 @@ import { type JSX, type Ref, useEffect, useRef } from "react"; -import { Booking } from "~/lib/domains/entities"; +import { Booking } from "~/domains/entities"; import QRCode from "qrcode"; // 1. Define the props for the child component diff --git a/src/evently.client/src/routes/gatherings/$gatheringId/index.tsx b/src/evently.client/src/routes/gatherings/$gatheringId/index.tsx index 3ba6d64..3589f60 100644 --- a/src/evently.client/src/routes/gatherings/$gatheringId/index.tsx +++ b/src/evently.client/src/routes/gatherings/$gatheringId/index.tsx @@ -1,6 +1,6 @@ import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; import { type JSX, useRef } from "react"; -import { Booking, Gathering } from "~/lib/domains/entities"; +import { Booking, Gathering } from "~/domains/entities"; import { cancelBooking, createBooking, @@ -10,7 +10,7 @@ import { updateGathering } from "~/lib/services"; import { useMutation } from "@tanstack/react-query"; -import { BookingReqDto, GatheringReqDto } from "~/lib/domains/models"; +import { BookingReqDto, GatheringReqDto } from "~/domains/models"; import { CancellationDialog, Jumbotron, QrDialog } from "./-components"; import Placeholder1 from "~/lib/assets/event_placeholder_1.webp"; import Placeholder2 from "~/lib/assets/event_placeholder_2.png"; diff --git a/src/evently.client/src/routes/gatherings/(auth).create.tsx b/src/evently.client/src/routes/gatherings/(auth).create.tsx index 4c99074..dae6d1a 100644 --- a/src/evently.client/src/routes/gatherings/(auth).create.tsx +++ b/src/evently.client/src/routes/gatherings/(auth).create.tsx @@ -1,9 +1,9 @@ import { createFileRoute } from "@tanstack/react-router"; -import { Category, Gathering } from "~/lib/domains/entities"; +import { Category, Gathering } from "~/domains/entities"; import { type JSX, useState } from "react"; import { authenticateRoute, createGathering, getCategories, sleep } from "~/lib/services"; import { type GatheringForm as IGatheringForm, useGatheringForm } from "./-services"; -import { GatheringReqDto, ToastContent } from "~/lib/domains/models"; +import { GatheringReqDto, ToastContent } from "~/domains/models"; import { GatheringForm } from "~/routes/gatherings/-components"; export const Route = createFileRoute("/gatherings/(auth)/create")({ diff --git a/src/evently.client/src/routes/gatherings/-components/filter-bar.tsx b/src/evently.client/src/routes/gatherings/-components/filter-bar.tsx index 2c22ac9..e6b0dc2 100644 --- a/src/evently.client/src/routes/gatherings/-components/filter-bar.tsx +++ b/src/evently.client/src/routes/gatherings/-components/filter-bar.tsx @@ -1,6 +1,6 @@ import React, { type JSX } from "react"; import { type GetGatheringsParams, toIsoDateString } from "~/lib/services"; -import { Category } from "~/lib/domains/entities"; +import { Category } from "~/domains/entities"; import { DateTime } from "luxon"; import { Icon } from "@iconify/react"; diff --git a/src/evently.client/src/routes/gatherings/-components/gathering-form.tsx b/src/evently.client/src/routes/gatherings/-components/gathering-form.tsx index f702e17..3ec7b60 100644 --- a/src/evently.client/src/routes/gatherings/-components/gathering-form.tsx +++ b/src/evently.client/src/routes/gatherings/-components/gathering-form.tsx @@ -3,10 +3,10 @@ import { compressImage, type GatheringForm as IGatheringForm } from "../-service import { FieldErrMsg as FieldInfo } from "~/lib/components"; import { Icon } from "@iconify/react"; import { DateTime } from "luxon"; -import { GatheringCategoryDetailReqDto, GatheringReqDto, ToastContent } from "~/lib/domains/models"; +import { GatheringCategoryDetailReqDto, GatheringReqDto, ToastContent } from "~/domains/models"; import { useRouter } from "@tanstack/react-router"; import { toIsoDateTimeString } from "~/lib/services"; -import { Category } from "~/lib/domains/entities"; +import { Category } from "~/domains/entities"; interface GatheringFormProps { file: File | null; diff --git a/src/evently.client/src/routes/gatherings/-services/use-gathering-form.ts b/src/evently.client/src/routes/gatherings/-services/use-gathering-form.ts index 48a693a..d92f7ba 100644 --- a/src/evently.client/src/routes/gatherings/-services/use-gathering-form.ts +++ b/src/evently.client/src/routes/gatherings/-services/use-gathering-form.ts @@ -1,5 +1,5 @@ import { useForm } from "@tanstack/react-form"; -import { GatheringReqDto } from "~/lib/domains/models"; +import { GatheringReqDto } from "~/domains/models"; export function useGatheringForm( defaultGathering: GatheringReqDto, diff --git a/src/evently.client/src/routes/gatherings/index.tsx b/src/evently.client/src/routes/gatherings/index.tsx index f4b3278..5a2517a 100644 --- a/src/evently.client/src/routes/gatherings/index.tsx +++ b/src/evently.client/src/routes/gatherings/index.tsx @@ -1,17 +1,27 @@ import { createFileRoute } from "@tanstack/react-router"; import { type JSX, useState } from "react"; import { useQuery } from "@tanstack/react-query"; -import { Category, Gathering } from "~/lib/domains/entities"; +import { Category, Gathering } from "~/domains/entities"; import { getCategories, getGatherings, type GetGatheringsParams } from "~/lib/services"; import { Card } from "~/lib/components"; -import type { PageResult } from "~/lib/domains/interfaces"; +import type { PageResult } from "~/domains/interfaces"; import { FilterBar } from "~/routes/gatherings/-components"; import { Icon } from "@iconify/react/dist/offline"; export const Route = createFileRoute("/gatherings/")({ component: GatheringsPage, loader: async () => { - const categories: Category[] = await getCategories(); + let categories: Category[] = []; + let attempts = 2; + while (attempts > 0) { + try { + categories = await getCategories(); + break; + } catch (error) { + attempts -= 1; + console.error(error); + } + } return { categories }; }, pendingComponent: () => ( diff --git a/tests/Evently.Server.Test/Common/Extensions/MapperExtensionTests.cs b/tests/Evently.Server.Test/Common/Extensions/MapperExtensionTests.cs index 281acca..87070f8 100644 --- a/tests/Evently.Server.Test/Common/Extensions/MapperExtensionTests.cs +++ b/tests/Evently.Server.Test/Common/Extensions/MapperExtensionTests.cs @@ -1,126 +1,135 @@ -using Evently.Server.Common.Domains.Entities; -using Evently.Server.Common.Domains.Models; -using Evently.Server.Common.Extensions; +using Evently.Server.Common.Extensions; +using Evently.Server.Domains.Entities; +using Evently.Server.Domains.Models; namespace Evently.Server.Test.Common.Extensions; -public class MapperExtensionTests { - [Fact] - public void TestMapToGathering() { - // Arrange (mock values) - DateTimeOffset start = DateTimeOffset.UtcNow.AddDays(3); - DateTimeOffset end = start.AddHours(2); - - GatheringReqDto dto = new( - GatheringId: 0, - "Mock Gathering", - "Mock Description", - start, - end, - CancellationDateTime: null, - "Mock Location", - "organizer-mock", - "mock-cover.jpg", - GatheringCategoryDetails: [] - ); - - // Act - Gathering entity = dto.ToGathering(); - - // Assert - Assert.NotNull(entity); - Assert.Equal(dto.Name, entity.Name); - Assert.Equal(dto.Description, entity.Description); - Assert.Equal(dto.Start, entity.Start); - Assert.Equal(dto.End, entity.End); - Assert.Equal(dto.CancellationDateTime, entity.CancellationDateTime); - Assert.Equal(dto.Location, entity.Location); - Assert.Equal(dto.OrganiserId, entity.OrganiserId); - Assert.Equal(dto.CoverSrc, entity.CoverSrc); - Assert.NotNull(entity.GatheringCategoryDetails); - Assert.Empty(entity.GatheringCategoryDetails); - } - - - [Fact] - public void TestMapToBooking() { - // Arrange (mock values) - const string attendeeId = "attendee-mock"; - const string bookingId = "book_mock"; - const long gatheringId = 42L; - - DateTimeOffset creation = DateTimeOffset.UtcNow.AddDays(1); - DateTimeOffset checkIn = creation.AddHours(1); - DateTimeOffset checkout = creation.AddHours(2); - DateTimeOffset cancellation = creation.AddHours(3); - - // Create DTO with mock values - BookingReqDto dto = new(attendeeId, bookingId, gatheringId, creation, checkIn, checkout, cancellation); - - // Act - Booking booking = dto.ToBooking(); - - // Assert: direct field comparisons (no reflection) - Assert.NotNull(booking); - Assert.Equal(dto.AttendeeId, booking.AttendeeId); - Assert.Equal(dto.GatheringId, booking.GatheringId); - Assert.Equal(dto.CreationDateTime, booking.CreationDateTime); - Assert.Equal(dto.CheckInDateTime, booking.CheckInDateTime); - Assert.Equal(dto.CheckoutDateTime, booking.CheckoutDateTime); - Assert.Equal(dto.CancellationDateTime, booking.CancellationDateTime); - - // If BookingId is part of the mapping, also validate it: - // Assert.Equal(dto.BookingId, booking.BookingId); - } - - [Fact] - public void TestMapToBookingDto() { - // Arrange (mock values) - const string attendeeId = "attendee-mock"; - const long gatheringId = 7L; - - DateTimeOffset creation = DateTimeOffset.UtcNow.AddDays(2); - DateTimeOffset checkIn = creation.AddHours(1); - DateTimeOffset checkout = creation.AddHours(2); - DateTimeOffset? cancellation = null; - - Booking booking = new() { - AttendeeId = attendeeId, - GatheringId = gatheringId, - CreationDateTime = creation, - CheckInDateTime = checkIn, - CheckoutDateTime = checkout, - CancellationDateTime = cancellation, - }; - - // Act - BookingReqDto dto = booking.ToBookingDto(); - - // Assert: direct field comparisons (no reflection) - Assert.NotNull(dto); - Assert.Equal(booking.AttendeeId, dto.AttendeeId); - Assert.Equal(booking.GatheringId, dto.GatheringId); - Assert.Equal(booking.CreationDateTime, dto.CreationDateTime); - Assert.Equal(booking.CheckInDateTime, dto.CheckInDateTime); - Assert.Equal(booking.CheckoutDateTime, dto.CheckoutDateTime); - Assert.Equal(booking.CancellationDateTime, dto.CancellationDateTime); - } - - - [Fact] - public void TestMapToAccountDto() { - // Arrange (mock values) - Account account = new() { - Id = "acc_mock", - Email = "mock@example.com", - }; - - // Act - AccountDto accountDto = account.ToAccountDto(); - - // Assert: direct field comparisons (no reflection) - Assert.NotNull(accountDto); - Assert.Equal(account.Id, accountDto.Id); - Assert.Equal(account.Email, accountDto.Email); - } -} \ No newline at end of file +public class MapperExtensionTests +{ + [Fact] + public void TestMapToGathering() + { + // Arrange (mock values) + DateTimeOffset start = DateTimeOffset.UtcNow.AddDays(3); + DateTimeOffset end = start.AddHours(2); + + GatheringReqDto dto = new( + GatheringId: 0, + "Mock Gathering", + "Mock Description", + start, + end, + CancellationDateTime: null, + "Mock Location", + "organizer-mock", + "mock-cover.jpg", + GatheringCategoryDetails: [] + ); + + // Act + Gathering entity = dto.ToGathering(); + + // Assert + Assert.NotNull(entity); + Assert.Equal(dto.Name, entity.Name); + Assert.Equal(dto.Description, entity.Description); + Assert.Equal(dto.Start, entity.Start); + Assert.Equal(dto.End, entity.End); + Assert.Equal(dto.CancellationDateTime, entity.CancellationDateTime); + Assert.Equal(dto.Location, entity.Location); + Assert.Equal(dto.OrganiserId, entity.OrganiserId); + Assert.Equal(dto.CoverSrc, entity.CoverSrc); + Assert.NotNull(entity.GatheringCategoryDetails); + Assert.Empty(entity.GatheringCategoryDetails); + } + + [Fact] + public void TestMapToBooking() + { + // Arrange (mock values) + const string attendeeId = "attendee-mock"; + const string bookingId = "book_mock"; + const long gatheringId = 42L; + + DateTimeOffset creation = DateTimeOffset.UtcNow.AddDays(1); + DateTimeOffset checkIn = creation.AddHours(1); + DateTimeOffset checkout = creation.AddHours(2); + DateTimeOffset cancellation = creation.AddHours(3); + + // Create DTO with mock values + BookingReqDto dto = new( + attendeeId, + bookingId, + gatheringId, + creation, + checkIn, + checkout, + cancellation + ); + + // Act + Booking booking = dto.ToBooking(); + + // Assert: direct field comparisons (no reflection) + Assert.NotNull(booking); + Assert.Equal(dto.AttendeeId, booking.AttendeeId); + Assert.Equal(dto.GatheringId, booking.GatheringId); + Assert.Equal(dto.CreationDateTime, booking.CreationDateTime); + Assert.Equal(dto.CheckInDateTime, booking.CheckInDateTime); + Assert.Equal(dto.CheckoutDateTime, booking.CheckoutDateTime); + Assert.Equal(dto.CancellationDateTime, booking.CancellationDateTime); + + // If BookingId is part of the mapping, also validate it: + // Assert.Equal(dto.BookingId, booking.BookingId); + } + + [Fact] + public void TestMapToBookingDto() + { + // Arrange (mock values) + const string attendeeId = "attendee-mock"; + const long gatheringId = 7L; + + DateTimeOffset creation = DateTimeOffset.UtcNow.AddDays(2); + DateTimeOffset checkIn = creation.AddHours(1); + DateTimeOffset checkout = creation.AddHours(2); + DateTimeOffset? cancellation = null; + + Booking booking = new() + { + AttendeeId = attendeeId, + GatheringId = gatheringId, + CreationDateTime = creation, + CheckInDateTime = checkIn, + CheckoutDateTime = checkout, + CancellationDateTime = cancellation, + }; + + // Act + BookingReqDto dto = booking.ToBookingDto(); + + // Assert: direct field comparisons (no reflection) + Assert.NotNull(dto); + Assert.Equal(booking.AttendeeId, dto.AttendeeId); + Assert.Equal(booking.GatheringId, dto.GatheringId); + Assert.Equal(booking.CreationDateTime, dto.CreationDateTime); + Assert.Equal(booking.CheckInDateTime, dto.CheckInDateTime); + Assert.Equal(booking.CheckoutDateTime, dto.CheckoutDateTime); + Assert.Equal(booking.CancellationDateTime, dto.CancellationDateTime); + } + + [Fact] + public void TestMapToAccountDto() + { + // Arrange (mock values) + Account account = new() { Id = "acc_mock", Email = "mock@example.com" }; + + // Act + AccountDto accountDto = account.ToAccountDto(); + + // Assert: direct field comparisons (no reflection) + Assert.NotNull(accountDto); + Assert.Equal(account.Id, accountDto.Id); + Assert.Equal(account.Email, accountDto.Email); + } +} diff --git a/tests/Evently.Server.Test/Common/Setup/DatabaseFixture.cs b/tests/Evently.Server.Test/Common/Setup/DatabaseFixture.cs new file mode 100644 index 0000000..581f6c3 --- /dev/null +++ b/tests/Evently.Server.Test/Common/Setup/DatabaseFixture.cs @@ -0,0 +1,29 @@ +using Evently.Server.Common.Data; +using Microsoft.EntityFrameworkCore; +using Testcontainers.MsSql; + +namespace Evently.Server.Test.Common.Setup; + +public class DatabaseFixture : IDisposable { + private readonly MsSqlContainer _container = new MsSqlBuilder().Build(); + private AppDbContext? _dbContext; + + public async Task GetDbContext() { + // if (_container.State == TestcontainersStates.Created) { + // return _dbContext!; + // } + await _container.StartAsync(); + string connString = _container.GetConnectionString(); + DbContextOptions contextOptions = new DbContextOptionsBuilder() + .UseSqlServer(connString) + .Options; + _dbContext = new AppDbContext(contextOptions); + await _dbContext.Database.EnsureCreatedAsync(); + return _dbContext; + } + + public void Dispose() { + _dbContext?.Dispose(); + _container.DisposeAsync().AsTask().Wait(); + } +} diff --git a/tests/Evently.Server.Test/Evently.Server.Test.csproj b/tests/Evently.Server.Test/Evently.Server.Test.csproj index 266df90..1609990 100644 --- a/tests/Evently.Server.Test/Evently.Server.Test.csproj +++ b/tests/Evently.Server.Test/Evently.Server.Test.csproj @@ -1,34 +1,32 @@  + + net10.0 + enable + enable + false + - - net9.0 - enable - enable - false - + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - - + + + + + + diff --git a/tests/Evently.Server.Test/Features/Bookings/Services/BookingServiceTests.cs b/tests/Evently.Server.Test/Features/Bookings/Services/BookingServiceTests.cs index 45313e9..aa1d3ce 100644 --- a/tests/Evently.Server.Test/Features/Bookings/Services/BookingServiceTests.cs +++ b/tests/Evently.Server.Test/Features/Bookings/Services/BookingServiceTests.cs @@ -1,53 +1,31 @@ -using Evently.Server.Common.Adapters.Data; -using Evently.Server.Common.Domains.Entities; -using Evently.Server.Common.Domains.Interfaces; -using Evently.Server.Common.Domains.Models; +using Evently.Server.Common.Data; +using Evently.Server.Domains.Entities; +using Evently.Server.Domains.Interfaces; +using Evently.Server.Domains.Models; using Evently.Server.Features.Bookings.Services; -using Microsoft.Data.Sqlite; -using Microsoft.EntityFrameworkCore; +using Evently.Server.Test.Common.Setup; using Microsoft.Extensions.Options; using Moq; namespace Evently.Server.Test.Features.Bookings.Services; -public class BookingServiceTests : IDisposable { - private readonly IBookingService _bookingService; - private readonly SqliteConnection _conn; - private readonly AppDbContext _dbContext; +public class BookingServiceTests(DatabaseFixture dbFixture) : IClassFixture { + private readonly Mock _fileStorageServiceMock = new(); - public BookingServiceTests() { - _conn = new SqliteConnection("Filename=:memory:"); - _conn.Open(); - - // These options will be used by the context instances in this test suite, including the connection opened above. - DbContextOptions contextOptions = new DbContextOptionsBuilder() - .UseSqlite(_conn) - .Options; - - // Create the schema and seed some data - AppDbContext dbContext = new(contextOptions); - - dbContext.Database.EnsureCreated(); - _dbContext = dbContext; - - Mock mediaRendererMock = new(); - Mock fileStorageServiceMock = new(); - IOptions options = Options.Create(new Settings()); - - _bookingService = new BookingService(mediaRendererMock.Object, - fileStorageServiceMock.Object, - validator: new BookingValidator(), - options, - _dbContext); - } - - public void Dispose() { - _dbContext.Dispose(); - _conn.Dispose(); - } + private readonly Mock _mediaRendererMock = new(); + private readonly IOptions _options = Options.Create(new Settings()); [Fact] public async Task CreateBooking_WithValidData_ShouldCreateBooking() { + AppDbContext dbContext = await dbFixture.GetDbContext(); + BookingService bookingService = new( + _mediaRendererMock.Object, + _fileStorageServiceMock.Object, + validator: new BookingValidator(), + _options, + dbContext + ); + DateTimeOffset now = DateTimeOffset.Now; // Arrange BookingReqDto bookingReqDto = new( @@ -61,7 +39,7 @@ public async Task CreateBooking_WithValidData_ShouldCreateBooking() { ); // Act - Booking result = await _bookingService.CreateBooking(bookingReqDto); + Booking result = await bookingService.CreateBooking(bookingReqDto); // Assert Assert.NotNull(result); @@ -76,6 +54,15 @@ public async Task CreateBooking_WithValidData_ShouldCreateBooking() { [Fact] public async Task CreateBooking_WithEmptyAttendeeId_ShouldThrowException() { + AppDbContext dbContext = await dbFixture.GetDbContext(); + BookingService bookingService = new( + _mediaRendererMock.Object, + _fileStorageServiceMock.Object, + validator: new BookingValidator(), + _options, + dbContext + ); + DateTimeOffset now = DateTimeOffset.Now; // Arrange BookingReqDto invalidBookingReqDto = new( @@ -89,13 +76,24 @@ public async Task CreateBooking_WithEmptyAttendeeId_ShouldThrowException() { ); // Act & Assert - await Assert.ThrowsAsync(() => _bookingService.CreateBooking(invalidBookingReqDto)); + await Assert.ThrowsAsync(() => + bookingService.CreateBooking(invalidBookingReqDto) + ); } [Fact] public async Task GetBooking_WithValidBookingId_ShouldReturnBooking() { + AppDbContext dbContext = await dbFixture.GetDbContext(); + BookingService bookingService = new( + _mediaRendererMock.Object, + _fileStorageServiceMock.Object, + validator: new BookingValidator(), + _options, + dbContext + ); + // Act - Booking? result = await _bookingService.GetBooking("book_abc123456"); + Booking? result = await bookingService.GetBooking("book_abc123456"); // Assert Assert.NotNull(result); @@ -104,6 +102,15 @@ public async Task GetBooking_WithValidBookingId_ShouldReturnBooking() { [Fact] public async Task UpdateBooking_WithNonExistentBookingId_ShouldThrowKeyNotFoundException() { + AppDbContext dbContext = await dbFixture.GetDbContext(); + BookingService bookingService = new( + _mediaRendererMock.Object, + _fileStorageServiceMock.Object, + validator: new BookingValidator(), + _options, + dbContext + ); + // Arrange const string nonExistentBookingId = "book_nonexistent"; BookingReqDto updateRequest = new( @@ -118,14 +125,24 @@ public async Task UpdateBooking_WithNonExistentBookingId_ShouldThrowKeyNotFoundE // Act & Assert await Assert.ThrowsAsync(() => - _bookingService.UpdateBooking(nonExistentBookingId, updateRequest)); + bookingService.UpdateBooking(nonExistentBookingId, updateRequest) + ); } [Fact] public async Task UpdateBooking_WithCancellation_ShouldUpdateCancellationDateTime() { + AppDbContext dbContext = await dbFixture.GetDbContext(); + BookingService bookingService = new( + _mediaRendererMock.Object, + _fileStorageServiceMock.Object, + validator: new BookingValidator(), + _options, + dbContext + ); + // Arrange DateTimeOffset cancellationTime = DateTimeOffset.Now.AddMinutes(30); - Booking? booking = await _bookingService.GetBooking("book_abc123456"); + Booking? booking = await bookingService.GetBooking("book_abc123456"); Assert.NotNull(booking); BookingReqDto updateRequest = new( @@ -139,10 +156,10 @@ public async Task UpdateBooking_WithCancellation_ShouldUpdateCancellationDateTim ); // Act - booking = await _bookingService.UpdateBooking("book_abc123456", updateRequest); + booking = await bookingService.UpdateBooking("book_abc123456", updateRequest); // Assert Assert.NotNull(booking); Assert.Equal(cancellationTime, booking.CancellationDateTime); } -} \ No newline at end of file +} diff --git a/tests/Evently.Server.Test/Features/Gatherings/Services/GatheringServiceTests.cs b/tests/Evently.Server.Test/Features/Gatherings/Services/GatheringServiceTests.cs index 655c196..3785417 100644 --- a/tests/Evently.Server.Test/Features/Gatherings/Services/GatheringServiceTests.cs +++ b/tests/Evently.Server.Test/Features/Gatherings/Services/GatheringServiceTests.cs @@ -1,276 +1,306 @@ -using Evently.Server.Common.Adapters.Data; -using Evently.Server.Common.Domains.Entities; -using Evently.Server.Common.Domains.Interfaces; -using Evently.Server.Common.Domains.Models; +using Evently.Server.Common.Data; +using Evently.Server.Domains.Entities; +using Evently.Server.Domains.Interfaces; +using Evently.Server.Domains.Models; using Evently.Server.Features.Gatherings.Services; +using Evently.Server.Test.Common.Setup; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; namespace Evently.Server.Test.Features.Gatherings.Services; -public class GatheringServiceTests : IDisposable { - private readonly SqliteConnection _conn; - private readonly AppDbContext _dbContext; - private readonly IGatheringService _gatheringService; - - public GatheringServiceTests() { - _conn = new SqliteConnection("Filename=:memory:"); - _conn.Open(); - - // These options will be used by the context instances in this test suite, including the connection opened above. - DbContextOptions contextOptions = new DbContextOptionsBuilder() - .UseSqlite(_conn) - .Options; - - // Create the schema and seed some data - AppDbContext dbContext = new(contextOptions); - - dbContext.Database.EnsureCreated(); - _dbContext = dbContext; - - _gatheringService = new GatheringService(_dbContext, validator: new GatheringValidator()); - } - - public void Dispose() { - _dbContext.Dispose(); - _conn.Dispose(); - } - - [Fact] - public async Task CreateGathering_WithValidData_ShouldCreateGathering() { - // Arrange - GatheringReqDto gatheringReqDto = new( - GatheringId: 0, - "Test Gathering", - "Test Description", - Start: DateTimeOffset.UtcNow.AddDays(1), - End: DateTimeOffset.UtcNow.AddDays(1).AddHours(2), - CancellationDateTime: null, - "Test Location", - "organizer123", - "test-cover.jpg", - GatheringCategoryDetails: [] - ); - - // Act - Gathering result = await _gatheringService.CreateGathering(gatheringReqDto); - - // Assert - Assert.NotNull(result); - Assert.Equal(gatheringReqDto.Name, result.Name); - Assert.Equal(gatheringReqDto.Description, result.Description); - Assert.Equal(gatheringReqDto.Start, result.Start); - Assert.Equal(gatheringReqDto.End, result.End); - Assert.Equal(gatheringReqDto.Location, result.Location); - Assert.Equal(gatheringReqDto.OrganiserId, result.OrganiserId); - - // Verify it was saved to database - Gathering? savedGathering = await _dbContext.Gatherings.FirstOrDefaultAsync(g => g.GatheringId == result.GatheringId); - Assert.NotNull(savedGathering); - } - - [Fact] - public async Task CreateGathering_WithInvalidData_ShouldThrowArgumentException() { - // Arrange - GatheringReqDto invalidGatheringReqDto = new( - GatheringId: 0, - "", // Invalid empty name - "Test Description", - Start: DateTimeOffset.UtcNow.AddDays(1), - End: DateTimeOffset.UtcNow.AddDays(1).AddHours(2), - CancellationDateTime: null, - "Test Location", - "organizer123", - CoverSrc: null, - GatheringCategoryDetails: [] - ); - - // Act & Assert - await Assert.ThrowsAsync(() => _gatheringService.CreateGathering(invalidGatheringReqDto)); - } - - [Fact] - public async Task GetGathering_WithExistingId_ShouldReturnGathering() { - // Arrange - Gathering gathering = new() { - Name = "Test Gathering", - Description = "Test Description", - Start = DateTimeOffset.UtcNow.AddDays(1), - End = DateTimeOffset.UtcNow.AddDays(1).AddHours(2), - Location = "Test Location", - OrganiserId = "organizer123", - Bookings = [], - GatheringCategoryDetails = [], - }; - - _dbContext.Gatherings.Add(gathering); - await _dbContext.SaveChangesAsync(); - - // Act - Gathering? result = await _gatheringService.GetGathering(gathering.GatheringId); - - // Assert - Assert.NotNull(result); - Assert.Equal(gathering.GatheringId, result.GatheringId); - Assert.Equal(gathering.Name, result.Name); - Assert.Equal(gathering.Description, result.Description); - } - - [Fact] - public async Task GetGathering_WithNonExistentId_ShouldReturnNull() { - // Arrange - const long nonExistentId = 999; - - // Act - Gathering? result = await _gatheringService.GetGathering(nonExistentId); - - // Assert - Assert.Null(result); - } - - [Fact] - public async Task GetGatherings_WithNameFilter_ShouldReturnFilteredResults() { - // Arrange - List gatherings = [ - new() { - Name = "XYZ Conference", - Description = "Description 1", - Start = DateTimeOffset.UtcNow.AddDays(1), - End = DateTimeOffset.UtcNow.AddDays(1).AddHours(2), - Location = "Location 1", - OrganiserId = "organizer1", - Bookings = [], - GatheringCategoryDetails = [], - }, - - new() { - Name = "Art Workshop", - Description = "Description 2", - Start = DateTimeOffset.UtcNow.AddDays(2), - End = DateTimeOffset.UtcNow.AddDays(2).AddHours(2), - Location = "Location 2", - OrganiserId = "organizer2", - Bookings = [], - GatheringCategoryDetails = [], - }, - - ]; - - _dbContext.Gatherings.AddRange(gatherings); - await _dbContext.SaveChangesAsync(); - - // Act - PageResult result = await _gatheringService.GetGatherings(attendeeId: null, - organiserId: null, - "XYZ", - startDateBefore: null, - startDateAfter: null, - endDateBefore: null, - endDateAfter: null, - isCancelled: null, - categoryIds: [], - offset: null, - limit: null); - - // Assert - Assert.NotNull(result); - Assert.Equal(expected: 1, result.TotalCount); - Assert.Equal("XYZ Conference", result.Items.First().Name); - } - - [Fact] - public async Task UpdateGathering_WithValidData_ShouldUpdateGathering() { - // Arrange - Gathering gathering = new() { - Name = "Original Name", - Description = "Original Description", - Start = DateTimeOffset.UtcNow.AddDays(1), - End = DateTimeOffset.UtcNow.AddDays(1).AddHours(2), - Location = "Original Location", - OrganiserId = "organizer123", - GatheringCategoryDetails = [], - }; - - _dbContext.Gatherings.Add(gathering); - await _dbContext.SaveChangesAsync(); - - GatheringReqDto updateDto = new( - gathering.GatheringId, - "Updated Name", - "Updated Description", - Start: DateTimeOffset.UtcNow.AddDays(2), - End: DateTimeOffset.UtcNow.AddDays(2).AddHours(3), - CancellationDateTime: null, - "Updated Location", - "organizer123", - "updated-cover.jpg", - GatheringCategoryDetails: [] - ); - - // Act - Gathering result = await _gatheringService.UpdateGathering(gathering.GatheringId, updateDto); - - // Assert - Assert.NotNull(result); - Assert.Equal("Updated Name", result.Name); - Assert.Equal("Updated Description", result.Description); - Assert.Equal(updateDto.Start, result.Start); - Assert.Equal(updateDto.End, result.End); - Assert.Equal("Updated Location", result.Location); - Assert.Equal("updated-cover.jpg", result.CoverSrc); - } - - [Fact] - public async Task UpdateGathering_WithNonExistentId_ShouldThrowKeyNotFoundException() { - // Arrange - GatheringReqDto updateDto = new( - GatheringId: 999, - "Updated Name", - "Updated Description", - Start: DateTimeOffset.UtcNow.AddDays(2), - End: DateTimeOffset.UtcNow.AddDays(2).AddHours(3), - CancellationDateTime: null, - "Updated Location", - "organizer123", - CoverSrc: null, - GatheringCategoryDetails: [] - ); - - // Act & Assert - await Assert.ThrowsAsync(() => _gatheringService.UpdateGathering(gatheringId: 999, updateDto)); - } - - [Fact] - public async Task DeleteGathering_WithExistingId_ShouldDeleteGathering() { - // Arrange - Gathering gathering = new() { - Name = "Test Gathering", - Description = "Test Description", - Start = DateTimeOffset.UtcNow.AddDays(1), - End = DateTimeOffset.UtcNow.AddDays(1).AddHours(2), - Location = "Test Location", - OrganiserId = "organizer123", - GatheringCategoryDetails = [], - }; - - _dbContext.Gatherings.Add(gathering); - await _dbContext.SaveChangesAsync(); - long gatheringId = gathering.GatheringId; - - // Act - await _gatheringService.DeleteGathering(gatheringId); - - // Assert - Gathering? deletedGathering = await _dbContext.Gatherings.FirstOrDefaultAsync(g => g.GatheringId == gatheringId); - Assert.Null(deletedGathering); - } - - [Fact] - public async Task DeleteGathering_WithNonExistentId_ShouldThrowInvalidOperationException() { - // Arrange - const long nonExistentId = 999; - - // Act & Assert - await Assert.ThrowsAsync(() => _gatheringService.DeleteGathering(nonExistentId)); - } -} \ No newline at end of file +public class GatheringServiceTests(DatabaseFixture dbFixture) : IClassFixture +{ + + [Fact] + public async Task CreateGathering_WithValidData_ShouldCreateGathering() { + AppDbContext dbContext = await dbFixture.GetDbContext(); + GatheringService gatheringService = new(dbContext, validator: new GatheringValidator()); + + // Arrange + GatheringReqDto gatheringReqDto = new( + GatheringId: 0, + "Test Gathering", + "Test Description", + Start: DateTimeOffset.UtcNow.AddDays(1), + End: DateTimeOffset.UtcNow.AddDays(1).AddHours(2), + CancellationDateTime: null, + "Test Location", + "organizer123", + "test-cover.jpg", + GatheringCategoryDetails: [] + ); + + // Act + Gathering result = await gatheringService.CreateGathering(gatheringReqDto); + + // Assert + Assert.NotNull(result); + Assert.Equal(gatheringReqDto.Name, result.Name); + Assert.Equal(gatheringReqDto.Description, result.Description); + Assert.Equal(gatheringReqDto.Start, result.Start); + Assert.Equal(gatheringReqDto.End, result.End); + Assert.Equal(gatheringReqDto.Location, result.Location); + Assert.Equal(gatheringReqDto.OrganiserId, result.OrganiserId); + + // Verify it was saved to database + Gathering? savedGathering = await dbContext.Gatherings.FirstOrDefaultAsync(g => + g.GatheringId == result.GatheringId + ); + Assert.NotNull(savedGathering); + } + + [Fact] + public async Task CreateGathering_WithInvalidData_ShouldThrowArgumentException() + { + AppDbContext dbContext = await dbFixture.GetDbContext(); + GatheringService gatheringService = new(dbContext, validator: new GatheringValidator()); + + // Arrange + GatheringReqDto invalidGatheringReqDto = new( + GatheringId: 0, + "", // Invalid empty name + "Test Description", + Start: DateTimeOffset.UtcNow.AddDays(1), + End: DateTimeOffset.UtcNow.AddDays(1).AddHours(2), + CancellationDateTime: null, + "Test Location", + "organizer123", + CoverSrc: null, + GatheringCategoryDetails: [] + ); + + // Act & Assert + await Assert.ThrowsAsync(() => + gatheringService.CreateGathering(invalidGatheringReqDto) + ); + } + + [Fact] + public async Task GetGathering_WithExistingId_ShouldReturnGathering() + { + AppDbContext dbContext = await dbFixture.GetDbContext(); + GatheringService gatheringService = new(dbContext, validator: new GatheringValidator()); + + // Arrange + Gathering gathering = new() + { + Name = "Test Gathering", + Description = "Test Description", + Start = DateTimeOffset.UtcNow.AddDays(1), + End = DateTimeOffset.UtcNow.AddDays(1).AddHours(2), + Location = "Test Location", + OrganiserId = "organizer123", + Bookings = [], + GatheringCategoryDetails = [], + }; + + dbContext.Gatherings.Add(gathering); + await dbContext.SaveChangesAsync(); + + // Act + Gathering? result = await gatheringService.GetGathering(gathering.GatheringId); + + // Assert + Assert.NotNull(result); + Assert.Equal(gathering.GatheringId, result.GatheringId); + Assert.Equal(gathering.Name, result.Name); + Assert.Equal(gathering.Description, result.Description); + } + + [Fact] + public async Task GetGathering_WithNonExistentId_ShouldReturnNull() + { + AppDbContext dbContext = await dbFixture.GetDbContext(); + GatheringService gatheringService = new(dbContext, validator: new GatheringValidator()); + + // Arrange + const long nonExistentId = 999; + + // Act + Gathering? result = await gatheringService.GetGathering(nonExistentId); + + // Assert + Assert.Null(result); + } + + [Fact] + public async Task GetGatherings_WithNameFilter_ShouldReturnFilteredResults() + { + AppDbContext dbContext = await dbFixture.GetDbContext(); + GatheringService gatheringService = new(dbContext, validator: new GatheringValidator()); + + // Arrange + List gatherings = + [ + new() + { + Name = "XYZ Conference", + Description = "Description 1", + Start = DateTimeOffset.UtcNow.AddDays(1), + End = DateTimeOffset.UtcNow.AddDays(1).AddHours(2), + Location = "Location 1", + OrganiserId = "organizer1", + Bookings = [], + GatheringCategoryDetails = [], + }, + new() + { + Name = "Art Workshop", + Description = "Description 2", + Start = DateTimeOffset.UtcNow.AddDays(2), + End = DateTimeOffset.UtcNow.AddDays(2).AddHours(2), + Location = "Location 2", + OrganiserId = "organizer2", + Bookings = [], + GatheringCategoryDetails = [], + }, + ]; + + dbContext.Gatherings.AddRange(gatherings); + await dbContext.SaveChangesAsync(); + + // Act + PageResult result = await gatheringService.GetGatherings( + attendeeId: null, + organiserId: null, + "XYZ", + startDateBefore: null, + startDateAfter: null, + endDateBefore: null, + endDateAfter: null, + isCancelled: null, + categoryIds: [], + offset: null, + limit: null + ); + + // Assert + Assert.NotNull(result); + Assert.Equal(expected: 1, result.TotalCount); + Assert.Equal("XYZ Conference", result.Items.First().Name); + } + + [Fact] + public async Task UpdateGathering_WithValidData_ShouldUpdateGathering() + { + AppDbContext dbContext = await dbFixture.GetDbContext(); + GatheringService gatheringService = new(dbContext, validator: new GatheringValidator()); + + // Arrange + Gathering gathering = new() + { + Name = "Original Name", + Description = "Original Description", + Start = DateTimeOffset.UtcNow.AddDays(1), + End = DateTimeOffset.UtcNow.AddDays(1).AddHours(2), + Location = "Original Location", + OrganiserId = "organizer123", + GatheringCategoryDetails = [], + }; + + dbContext.Gatherings.Add(gathering); + await dbContext.SaveChangesAsync(); + + GatheringReqDto updateDto = new( + gathering.GatheringId, + "Updated Name", + "Updated Description", + Start: DateTimeOffset.UtcNow.AddDays(2), + End: DateTimeOffset.UtcNow.AddDays(2).AddHours(3), + CancellationDateTime: null, + "Updated Location", + "organizer123", + "updated-cover.jpg", + GatheringCategoryDetails: [] + ); + + // Act + Gathering result = await gatheringService.UpdateGathering( + gathering.GatheringId, + updateDto + ); + + // Assert + Assert.NotNull(result); + Assert.Equal("Updated Name", result.Name); + Assert.Equal("Updated Description", result.Description); + Assert.Equal(updateDto.Start, result.Start); + Assert.Equal(updateDto.End, result.End); + Assert.Equal("Updated Location", result.Location); + Assert.Equal("updated-cover.jpg", result.CoverSrc); + } + + [Fact] + public async Task UpdateGathering_WithNonExistentId_ShouldThrowKeyNotFoundException() + { + AppDbContext dbContext = await dbFixture.GetDbContext(); + GatheringService gatheringService = new(dbContext, validator: new GatheringValidator()); + + // Arrange + GatheringReqDto updateDto = new( + GatheringId: 999, + "Updated Name", + "Updated Description", + Start: DateTimeOffset.UtcNow.AddDays(2), + End: DateTimeOffset.UtcNow.AddDays(2).AddHours(3), + CancellationDateTime: null, + "Updated Location", + "organizer123", + CoverSrc: null, + GatheringCategoryDetails: [] + ); + + // Act & Assert + await Assert.ThrowsAsync(() => + gatheringService.UpdateGathering(gatheringId: 999, updateDto) + ); + } + + [Fact] + public async Task DeleteGathering_WithExistingId_ShouldDeleteGathering() + { + AppDbContext dbContext = await dbFixture.GetDbContext(); + GatheringService gatheringService = new(dbContext, validator: new GatheringValidator()); + + // Arrange + Gathering gathering = new() + { + Name = "Test Gathering", + Description = "Test Description", + Start = DateTimeOffset.UtcNow.AddDays(1), + End = DateTimeOffset.UtcNow.AddDays(1).AddHours(2), + Location = "Test Location", + OrganiserId = "organizer123", + GatheringCategoryDetails = [], + }; + + dbContext.Gatherings.Add(gathering); + await dbContext.SaveChangesAsync(); + long gatheringId = gathering.GatheringId; + + // Act + await gatheringService.DeleteGathering(gatheringId); + + // Assert + Gathering? deletedGathering = await dbContext.Gatherings.FirstOrDefaultAsync(g => + g.GatheringId == gatheringId + ); + Assert.Null(deletedGathering); + } + + [Fact] + public async Task DeleteGathering_WithNonExistentId_ShouldThrowInvalidOperationException() + { + AppDbContext dbContext = await dbFixture.GetDbContext(); + GatheringService gatheringService = new(dbContext, validator: new GatheringValidator()); + + // Arrange + const long nonExistentId = 999; + + // Act & Assert + await Assert.ThrowsAsync(() => + gatheringService.DeleteGathering(nonExistentId) + ); + } +}