Compare commits

...
50 Commits
Author SHA1 Message Date
khwezi e6e0475db1 Merge pull request 'emailjobs' (#21) from emailjobs into master
Reviewed-on: #21
2026-05-15 08:39:36 +02:00
Khwezi Mngoma be9c83c8a3 Removed Abstractions listing from Git Tag
continuous-integration/drone/pr Build is passing
2026-05-15 08:39:10 +02:00
Khwezi Mngoma 65687d231e Ensured UTC is used 2026-05-15 08:37:58 +02:00
khwezi 5090c60797 Merge pull request 'Fixed Lead->Customer Relationship' (#20) from emailjobs into master
Reviewed-on: #20
2026-05-15 07:55:56 +02:00
Khwezi Mngoma 4523ef6151 Fixed Lead->Customer Relationship
continuous-integration/drone/pr Build is passing
2026-05-15 07:54:35 +02:00
khwezi 9432252e15 Merge pull request 'Added khongisa host entry on pipeline commands' (#19) from emailjobs into master
Reviewed-on: #19
2026-05-14 02:54:00 +02:00
Khwezi Mngoma 36b3656886 Added khongisa host entry on pipeline commands
continuous-integration/drone/pr Build is passing
2026-05-14 02:53:36 +02:00
khwezi 47111a1a3a Merge pull request 'emailjobs' (#18) from emailjobs into master
Reviewed-on: #18
2026-05-14 02:49:34 +02:00
Khwezi Mngoma f606b8fd3c Removed other packages from pipeline
continuous-integration/drone/pr Build is failing
2026-05-14 02:48:46 +02:00
Khwezi Mngoma 2610275bef Stable run on Notification creation 2026-05-14 02:46:07 +02:00
Khwezi Mngoma 134d8429c0 Completed refactor 2026-05-14 01:33:21 +02:00
Khwezi Mngoma 42001998d6 Fixed package references and namespaces
Refactored mappers
2026-05-13 20:15:21 +02:00
Khwezi Mngoma a42c51d7b2 Retructured solution 2026-05-13 20:06:24 +02:00
khwezi 6eb3d50375 Merge pull request 'Updated job scheduler' (#17) from emailjobs into master
Reviewed-on: #17
2026-05-10 17:33:23 +02:00
Khwezi Mngoma 26075cd9a7 Updated job scheduler
continuous-integration/drone/pr Build is passing
2026-05-10 17:32:09 +02:00
khwezi 4deb732804 Merge pull request 'emailjobs' (#16) from emailjobs into master
Reviewed-on: #16
2026-05-10 16:51:24 +02:00
Khwezi Mngoma cecd9f90e9 Implemented service bus handling of emails and notification processing
continuous-integration/drone/pr Build is passing
2026-05-10 16:50:36 +02:00
Khwezi Mngoma 73ba41beaf Added outgoing email notification processing event 2026-05-10 16:07:53 +02:00
Khwezi Mngoma e8e9a85c57 Migrated database changes after refactoring the Notification model 2026-05-10 15:27:26 +02:00
Khwezi Mngoma 394429677e Added package management 2026-05-10 14:18:56 +02:00
khwezi 20d9387d0b Merge pull request 'Migrated database changes' (#15) from develop into master
Reviewed-on: #15
2026-05-10 11:17:54 +02:00
Khwezi Mngoma 32d1019eb5 Migrated database changes
continuous-integration/drone/pr Build is passing
2026-05-10 11:16:52 +02:00
khwezi 9f6d0ccaa0 Merge pull request 'Populated README' (#14) from develop into master
Reviewed-on: #14
2026-05-10 09:48:35 +02:00
Khwezi Mngoma 275befc258 Populated README
continuous-integration/drone/pr Build is passing
2026-05-10 09:48:12 +02:00
khwezi 1acbc4d213 Merge pull request 'fixed git repo naming' (#13) from develop into master
Reviewed-on: #13
2026-05-10 09:22:37 +02:00
Khwezi Mngoma 193496bbf9 fixed git repo naming
continuous-integration/drone/pr Build is passing
2026-05-10 09:22:09 +02:00
khwezi 8c99668fac Merge pull request 'Added tag and release step after publish' (#12) from develop into master
Reviewed-on: #12
2026-05-10 09:19:33 +02:00
Khwezi Mngoma a015801423 Added tag and release step after publish
continuous-integration/drone/pr Build is failing
2026-05-10 09:18:54 +02:00
khwezi ad44f46204 Merge pull request 'Refactored Quartz instance id to AUTO, removed constant' (#11) from develop into master
Reviewed-on: #11
2026-05-10 08:21:08 +02:00
Khwezi Mngoma 9d64b5e76d Refactored Quartz instance id to AUTO, removed constant
continuous-integration/drone/pr Build is passing
2026-05-10 08:20:18 +02:00
khwezi 49d999c1e3 Merge pull request 'Refactored postgres extension' (#10) from develop into master
Reviewed-on: #10
2026-05-09 17:45:00 +02:00
Khwezi Mngoma fd6b8cd965 Refactored postgres extension
continuous-integration/drone/pr Build is passing
2026-05-09 17:44:16 +02:00
khwezi 9ed4777a18 Merge pull request 'Refactored database references' (#9) from develop into master
Reviewed-on: #9
2026-05-09 17:01:02 +02:00
Khwezi Mngoma ff34326a53 Refactored database references
continuous-integration/drone/pr Build is passing
2026-05-09 16:58:34 +02:00
khwezi 0cf44f68cc Merge pull request 'Added scheduler constants' (#8) from develop into master
Reviewed-on: #8
2026-05-09 15:27:50 +02:00
Khwezi Mngoma 6db6228bc9 Added scheduler constants
continuous-integration/drone/pr Build is passing
2026-05-09 15:27:07 +02:00
khwezi 41ed5a4288 Merge pull request 'Fixed quartz host config table prefix' (#7) from develop into master
Reviewed-on: #7
2026-05-09 13:30:33 +02:00
Khwezi Mngoma 61118c6223 Fixed quartz host config table prefix
continuous-integration/drone/pr Build is passing
2026-05-09 13:29:35 +02:00
khwezi bbcba5e06c Merge pull request 'Fixed quartz table name prefix' (#6) from develop into master
Reviewed-on: #6
2026-05-09 13:28:29 +02:00
Khwezi Mngoma bd852b30c3 Fixed quartz table name prefix
continuous-integration/drone/pr Build is passing
2026-05-09 13:27:54 +02:00
khwezi 502cc326dd Merge pull request 'Updated pipeline to use major version with minor always 0' (#5) from develop into master
Reviewed-on: #5
2026-05-09 12:02:19 +02:00
Khwezi Mngoma 37e2116aaf Updated pipeline to use major version with minor always 0
continuous-integration/drone/pr Build is passing
2026-05-09 12:01:52 +02:00
khwezi 4675d4c5fc Merge pull request 'Updated pipeline to use major versions only' (#4) from develop into master
continuous-integration/drone/push Build was killed
continuous-integration/drone Build is failing
Reviewed-on: #4
2026-05-09 11:56:20 +02:00
Khwezi Mngoma 60980292a2 Updated pipeline to use major versions only
continuous-integration/drone/pr Build is failing
2026-05-09 11:55:43 +02:00
khwezi f80bb2fff9 Merge pull request 'Changed target branch from nain to master' (#3) from dronepipeline into master
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is passing
Reviewed-on: #3
2026-05-09 11:37:39 +02:00
Khwezi Mngoma df1e31cc86 Changed target branch from nain to master
continuous-integration/drone/pr Build is passing
2026-05-09 11:37:11 +02:00
khwezi 6767906b0d Merge pull request 'Added pipeline separator' (#2) from dronepipeline into master
Reviewed-on: #2
2026-05-09 11:25:34 +02:00
Khwezi Mngoma 8cb1943ac8 Added pipeline separator 2026-05-09 11:25:08 +02:00
khwezi a344af4498 Merge pull request 'Added .drone.yml pipeline' (#1) from dronepipeline into master
Reviewed-on: #1
2026-05-09 11:12:38 +02:00
Khwezi Mngoma c95efd1faf Added .drone.yml pipeline 2026-05-09 11:11:29 +02:00
252 changed files with 7860 additions and 7463 deletions
+51
View File
@@ -0,0 +1,51 @@
---
kind: pipeline
type: docker
name: litecharms-nuget-libraries
steps:
- name: build
image: mcr.microsoft.com/dotnet/sdk:10.0
commands:
- dotnet restore
- dotnet build -c Release --no-restore
- name: pack-and-publish
image: mcr.microsoft.com/dotnet/sdk:10.0
environment:
NEXUS_KEY: { from_secret: nexus_api_key }
NEXUS_URL: https://nexus.khongisa.co.za/repository/nuget-hosted/
VERSION: 1.${DRONE_BUILD_NUMBER}.0
commands:
- dotnet pack LiteCharms.Features/LiteCharms.Features.csproj -c Release -p:PackageVersion=$VERSION -o dist/
- dotnet nuget push dist/LiteCharms.Features.$VERSION.nupkg --api-key $NEXUS_KEY --source $NEXUS_URL
- name: gitea-tag-release
image: alpine/git
environment:
GITEA_TOKEN: { from_secret: git_token }
GITEA_USER: { from_secret: git_username }
GITEA_PASS: { from_secret: git_password }
VERSION: 1.${DRONE_BUILD_NUMBER}.0
commands:
- echo "169.255.58.144 gitea.khongisa.co.za" >> /etc/hosts
- apk add --no-cache curl
- git remote set-url origin https://$${GITEA_USER}:$${GITEA_PASS}@gitea.khongisa.co.za/litecharms/components.git
- git tag $VERSION
- git push origin $VERSION
- |
curl -X POST "https://gitea.khongisa.co.za/api/v1/repos/litecharms/components/releases" \
-H "Authorization: token $${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d "{
\"tag_name\": \"$VERSION\",
\"target_commitish\": \"${DRONE_COMMIT_SHA}\",
\"name\": \"Library Suite $VERSION\",
\"body\": \"### Published NuGet Packages\nAll packages versioned as **$VERSION**:\n* LiteCharms.Features\n\n[View in Nexus](https://nexus.khongisa.co.za/repository/nuget-group/)\",
\"draft\": false,
\"prerelease\": false
}"
trigger:
event:
- pull_request
+248
View File
@@ -0,0 +1,248 @@
# Remove the line below if you want to inherit .editorconfig settings from higher directories
root = true
# C# files
[*.cs]
#### Core EditorConfig Options ####
# Indentation and spacing
indent_size = 4
indent_style = space
tab_width = 4
# New line preferences
end_of_line = crlf
insert_final_newline = false
#### .NET Code Actions ####
# Type members
dotnet_hide_advanced_members = false
dotnet_member_insertion_location = with_other_members_of_the_same_kind
dotnet_property_generation_behavior = prefer_throwing_properties
# Symbol search
dotnet_search_reference_assemblies = true
#### .NET Coding Conventions ####
# Organize usings
dotnet_separate_import_directive_groups = false
dotnet_sort_system_directives_first = false
file_header_template = unset
# this. and Me. preferences
dotnet_style_qualification_for_event = false
dotnet_style_qualification_for_field = false
dotnet_style_qualification_for_method = false
dotnet_style_qualification_for_property = false
# Language keywords vs BCL types preferences
dotnet_style_predefined_type_for_locals_parameters_members = true
dotnet_style_predefined_type_for_member_access = true
# Parentheses preferences
dotnet_style_parentheses_in_arithmetic_binary_operators = 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_prefer_system_hash_code = true
dotnet_style_coalesce_expression = true
dotnet_style_collection_initializer = true
dotnet_style_explicit_tuple_names = true
dotnet_style_namespace_match_folder = true
dotnet_style_null_propagation = true
dotnet_style_object_initializer = true
dotnet_style_operator_placement_when_wrapping = beginning_of_line
dotnet_style_prefer_auto_properties = true
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_non_hidden_explicit_cast_in_source = true
dotnet_style_prefer_simplified_boolean_expressions = true
dotnet_style_prefer_simplified_interpolation = true
# Field preferences
dotnet_style_readonly_field = true
# Parameter preferences
dotnet_code_quality_unused_parameters = all
# Suppression preferences
dotnet_remove_unnecessary_suppression_exclusions = none
# New line preferences
dotnet_style_allow_multiple_blank_lines_experimental = true
dotnet_style_allow_statement_immediately_after_block_experimental = true
#### C# Coding Conventions ####
# var preferences
csharp_style_var_elsewhere = false
csharp_style_var_for_built_in_types = false
csharp_style_var_when_type_is_apparent = false
# Expression-bodied members
csharp_style_expression_bodied_accessors = true
csharp_style_expression_bodied_constructors = false
csharp_style_expression_bodied_indexers = true
csharp_style_expression_bodied_lambdas = true
csharp_style_expression_bodied_local_functions = true
csharp_style_expression_bodied_methods = false
csharp_style_expression_bodied_operators = false
csharp_style_expression_bodied_properties = true
# Pattern matching preferences
csharp_style_pattern_matching_over_as_with_null_check = true
csharp_style_pattern_matching_over_is_with_cast_check = true
csharp_style_prefer_extended_property_pattern = true
csharp_style_prefer_not_pattern = true
csharp_style_prefer_pattern_matching = true
csharp_style_prefer_switch_expression = true
# Null-checking preferences
csharp_style_conditional_delegate_call = true
# Modifier preferences
csharp_prefer_static_anonymous_function = true
csharp_prefer_static_local_function = true
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
csharp_style_prefer_readonly_struct_member = true
# Code-block preferences
csharp_prefer_braces = true
csharp_prefer_simple_using_statement = true
csharp_prefer_system_threading_lock = true
csharp_style_namespace_declarations = block_scoped
csharp_style_prefer_method_group_conversion = true
csharp_style_prefer_primary_constructors = true
csharp_style_prefer_simple_property_accessors = true
csharp_style_prefer_top_level_statements = true
# Expression-level preferences
csharp_prefer_simple_default_expression = true
csharp_style_deconstructed_variable_declaration = true
csharp_style_implicit_object_creation_when_type_is_apparent = true
csharp_style_inlined_variable_declaration = true
csharp_style_prefer_implicitly_typed_lambda_expression = true
csharp_style_prefer_index_operator = true
csharp_style_prefer_local_over_anonymous_function = true
csharp_style_prefer_null_check_over_type_check = true
csharp_style_prefer_range_operator = true
csharp_style_prefer_tuple_swap = true
csharp_style_prefer_unbound_generic_type_in_nameof = true
csharp_style_prefer_utf8_string_literals = true
csharp_style_throw_expression = true
csharp_style_unused_value_assignment_preference = discard_variable
csharp_style_unused_value_expression_statement_preference = discard_variable
# 'using' directive preferences
csharp_using_directive_placement = outside_namespace
# New line preferences
csharp_style_allow_blank_line_after_colon_in_constructor_initializer_experimental = true
csharp_style_allow_blank_line_after_token_in_arrow_expression_clause_experimental = true
csharp_style_allow_blank_line_after_token_in_conditional_expression_experimental = true
csharp_style_allow_blank_lines_between_consecutive_braces_experimental = true
csharp_style_allow_embedded_statements_on_same_line_experimental = true
#### C# Formatting Rules ####
# New line preferences
csharp_new_line_before_catch = true
csharp_new_line_before_else = true
csharp_new_line_before_finally = true
csharp_new_line_before_members_in_anonymous_types = true
csharp_new_line_before_members_in_object_initializers = true
csharp_new_line_before_open_brace = all
csharp_new_line_between_query_expression_clauses = true
# Indentation preferences
csharp_indent_block_contents = true
csharp_indent_braces = false
csharp_indent_case_contents = true
csharp_indent_case_contents_when_block = true
csharp_indent_labels = one_less_than_current
csharp_indent_switch_labels = true
# Space preferences
csharp_space_after_cast = false
csharp_space_after_colon_in_inheritance_clause = true
csharp_space_after_comma = true
csharp_space_after_dot = false
csharp_space_after_keywords_in_control_flow_statements = true
csharp_space_after_semicolon_in_for_statement = true
csharp_space_around_binary_operators = before_and_after
csharp_space_around_declaration_statements = false
csharp_space_before_colon_in_inheritance_clause = true
csharp_space_before_comma = false
csharp_space_before_dot = false
csharp_space_before_open_square_brackets = false
csharp_space_before_semicolon_in_for_statement = false
csharp_space_between_empty_square_brackets = false
csharp_space_between_method_call_empty_parameter_list_parentheses = false
csharp_space_between_method_call_name_and_opening_parenthesis = false
csharp_space_between_method_call_parameter_list_parentheses = false
csharp_space_between_method_declaration_empty_parameter_list_parentheses = false
csharp_space_between_method_declaration_name_and_open_parenthesis = false
csharp_space_between_method_declaration_parameter_list_parentheses = false
csharp_space_between_parentheses = false
csharp_space_between_square_brackets = false
# Wrapping preferences
csharp_preserve_single_line_blocks = true
csharp_preserve_single_line_statements = true
#### Naming styles ####
# Naming rules
dotnet_naming_rule.interface_should_be_begins_with_i.severity = suggestion
dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface
dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i
dotnet_naming_rule.types_should_be_pascal_case.severity = suggestion
dotnet_naming_rule.types_should_be_pascal_case.symbols = types
dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case
dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = suggestion
dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members
dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case
# Symbol specifications
dotnet_naming_symbols.interface.applicable_kinds = interface
dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.interface.required_modifiers =
dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum
dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.types.required_modifiers =
dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method
dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.non_field_members.required_modifiers =
# Naming styles
dotnet_naming_style.pascal_case.required_prefix =
dotnet_naming_style.pascal_case.required_suffix =
dotnet_naming_style.pascal_case.word_separator =
dotnet_naming_style.pascal_case.capitalization = pascal_case
dotnet_naming_style.begins_with_i.required_prefix = I
dotnet_naming_style.begins_with_i.required_suffix =
dotnet_naming_style.begins_with_i.word_separator =
dotnet_naming_style.begins_with_i.capitalization = pascal_case
@@ -1,40 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<SignAssembly>True</SignAssembly>
<AssemblyOriginatorKeyFile>..\LiteCharms.snk</AssemblyOriginatorKeyFile>
</PropertyGroup>
<!-- Nuget Package Details -->
<PropertyGroup>
<PackageId>LiteCharms.Abstractions</PackageId>
<Version>1.0.20</Version>
<Authors>Khwezi Mngoma</Authors>
<Company>Lite Charms (PTY) Ltd</Company>
<Description>Shared abstractions for Lite Charms applications.</Description>
<PackageProjectUrl>https://gitea.khongisa.co.za/litecharms/components</PackageProjectUrl>
<RepositoryUrl>https://gitea.khongisa.co.za/litecharms/components.git</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PackageLicenseFile>LICENSE</PackageLicenseFile>
<PackageTags>utility;dotnet</PackageTags>
<PackageIcon>icon.png</PackageIcon>
</PropertyGroup>
<ItemGroup>
<None Include="..\LICENSE" Pack="true" PackagePath="\" />
<None Include="..\icon.png" Pack="true" PackagePath="\" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="FluentResults" Version="4.0.0" />
<PackageReference Include="Mediator.Abstractions" Version="3.0.2" />
<Using Include="Mediator" />
<Using Include="FluentResults" />
<Using Include="System.Threading.Channels" />
</ItemGroup>
</Project>
@@ -1,22 +0,0 @@
namespace LiteCharms.Entities.Configuration;
public class NotificationConfiguration : IEntityTypeConfiguration<Notification>
{
public void Configure(EntityTypeBuilder<Notification> builder)
{
builder.ToTable(nameof(Notification));
builder.HasKey(f => f.Id);
builder.Property(f => f.CreatedAt).IsRequired().ValueGeneratedOnAdd();
builder.Property(f => f.Direction).IsRequired();
builder.Property(f => f.Author).IsRequired();
builder.Property(f => f.Title).IsRequired();
builder.Property(f => f.Description).IsRequired();
builder.Property(f => f.Platform).IsRequired();
builder.Property(f => f.PlatformAddress).IsRequired();
builder.Property(f => f.CorrelationId).IsRequired();
builder.Property(f => f.CorrelationIdType).IsRequired();
builder.Property(f => f.IsInternal).HasDefaultValue(true);
builder.Property(f => f.Processed).HasDefaultValue(false);
}
}
@@ -1,29 +0,0 @@
namespace LiteCharms.Entities.Configuration;
public class OrderConfiguration : IEntityTypeConfiguration<Order>
{
public void Configure(EntityTypeBuilder<Order> builder)
{
builder.ToTable(nameof(Order));
builder.HasKey(f => f.Id);
builder.Property(f => f.CreatedAt).ValueGeneratedOnAdd();
builder.Property(f => f.UpdatedAt).IsRequired(false).ValueGeneratedOnUpdate();
builder.Property(f => f.CustomerId).IsRequired();
builder.Property(f => f.QuoteId).IsRequired(false);
builder.Property(f => f.RefundId).IsRequired(false);
builder.Property(f => f.ShoppingCartId).IsRequired();
builder.Property(f => f.Status).HasConversion<int>().IsRequired();
builder.Property(f => f.Notes).HasColumnType("jsonb").IsRequired(false);
builder.HasOne(f => f.Quote)
.WithOne(f => f.Order)
.HasForeignKey<Order>(f => f.QuoteId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne(f => f.Customer)
.WithMany(f => f.Orders)
.HasForeignKey(f => f.CustomerId)
.OnDelete(DeleteBehavior.Restrict);
}
}
@@ -1,14 +0,0 @@
namespace LiteCharms.Entities.Configuration;
public class ProductConfiguration : IEntityTypeConfiguration<Product>
{
public void Configure(EntityTypeBuilder<Product> builder)
{
builder.ToTable(nameof(Product));
builder.HasKey(f => f.Id);
builder.Property(f => f.Name).IsRequired();
builder.Property(f => f.Description).IsRequired();
builder.Property(f => f.Active).HasDefaultValue(true);
}
}
@@ -1,23 +0,0 @@
namespace LiteCharms.Entities.Configuration;
public class QuoteConfiguration : IEntityTypeConfiguration<Quote>
{
public void Configure(EntityTypeBuilder<Quote> builder)
{
builder.ToTable(nameof(Quote));
builder.HasKey(f => f.Id);
builder.Property(f => f.CreatedAt).IsRequired().ValueGeneratedOnAdd();
builder.Property(f => f.UpdatedAt).IsRequired().ValueGeneratedOnAddOrUpdate();
builder.Property(f => f.ExpiredAt).IsRequired(false);
builder.Property(f => f.CustomerId).IsRequired();
builder.Property(f => f.Status).IsRequired();
builder.Property(f => f.ShoppingCartId).IsRequired();
builder.Property(f => f.Reason).IsRequired(false);
builder.HasOne(f => f.Customer)
.WithMany()
.HasForeignKey(f => f.CustomerId)
.OnDelete(DeleteBehavior.Cascade);
}
}
@@ -1,31 +0,0 @@
namespace LiteCharms.Entities.Configuration;
public class ShoppingCartConfiguration : IEntityTypeConfiguration<ShoppingCart>
{
public void Configure(EntityTypeBuilder<ShoppingCart> builder)
{
builder.ToTable(nameof(ShoppingCart));
builder.HasKey(f => f.Id);
builder.Property(f => f.CreatedAt).IsRequired().ValueGeneratedOnAdd();
builder.Property(f => f.UpdatedAt).IsRequired().ValueGeneratedOnAddOrUpdate();
builder.Property(f => f.CustomerId).IsRequired(false);
builder.Property(f => f.OrderId).IsRequired(false);
builder.Property(f => f.QuoteId).IsRequired(false);
builder.HasOne(f => f.Customer)
.WithMany(c => c.ShoppingCarts)
.HasForeignKey(f => f.CustomerId)
.OnDelete(DeleteBehavior.NoAction);
builder.HasOne(f => f.Order)
.WithOne(o => o.ShoppingCart)
.HasForeignKey<Order>(o => o.ShoppingCartId)
.OnDelete(DeleteBehavior.NoAction);
builder.HasOne(f => f.Quote)
.WithOne(o => o.ShoppingCart)
.HasForeignKey<Quote>(o => o.ShoppingCartId)
.OnDelete(DeleteBehavior.NoAction);
}
}
@@ -1,25 +0,0 @@
namespace LiteCharms.Entities.Configuration;
public class ShoppingCartItemConfiguration : IEntityTypeConfiguration<ShoppingCartItem>
{
public void Configure(EntityTypeBuilder<ShoppingCartItem> builder)
{
builder.ToTable(nameof(ShoppingCartItem));
builder.HasKey(f => f.Id);
builder.Property(f => f.CreatedAt).IsRequired().ValueGeneratedOnAdd();
builder.Property(f => f.UpdatedAt).IsRequired().ValueGeneratedOnAddOrUpdate();
builder.Property(f => f.Quantity).IsRequired().HasDefaultValue(1);
builder.Property(f => f.ProductPriceId).IsRequired();
builder.HasOne(f => f.ProductPrice)
.WithMany()
.HasForeignKey(f => f.ProductPriceId)
.OnDelete(DeleteBehavior.NoAction);
builder.HasOne(f => f.ShoppingCart)
.WithMany(f => f.ShoppingCartItems)
.HasForeignKey(f => f.ShoppingCartId)
.OnDelete(DeleteBehavior.NoAction);
}
}
@@ -1,45 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<SignAssembly>True</SignAssembly>
<AssemblyOriginatorKeyFile>..\LiteCharms.snk</AssemblyOriginatorKeyFile>
</PropertyGroup>
<!-- Nuget Package Details -->
<PropertyGroup>
<PackageId>LiteCharms.Entities</PackageId>
<Version>1.0.20</Version>
<Authors>Khwezi Mngoma</Authors>
<Company>Lite Charms (PTY) Ltd</Company>
<Description>Shared entities for Lite Charms applications.</Description>
<PackageProjectUrl>https://gitea.khongisa.co.za/litecharms/components</PackageProjectUrl>
<RepositoryUrl>https://gitea.khongisa.co.za/litecharms/components.git</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PackageLicenseFile>LICENSE</PackageLicenseFile>
<PackageTags>utility;dotnet</PackageTags>
<PackageIcon>icon.png</PackageIcon>
</PropertyGroup>
<ItemGroup>
<None Include="..\LICENSE" Pack="true" PackagePath="\"/>
<None Include="..\icon.png" Pack="true" PackagePath="\" />
</ItemGroup>
<!-- Database -->
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.7" />
<!-- Global Usings -->
<Using Include="Microsoft.EntityFrameworkCore" />
<Using Include="Microsoft.EntityFrameworkCore.Metadata.Builders" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\LiteCharms.Models\LiteCharms.Models.csproj" />
</ItemGroup>
</Project>
-15
View File
@@ -1,15 +0,0 @@
using LiteCharms.Entities.Configuration;
namespace LiteCharms.Entities;
[EntityTypeConfiguration<OrderConfiguration, Order>]
public class Order : Models.Order
{
public virtual OrderRefund? Refund { get; set; }
public virtual Customer? Customer { get; set; }
public virtual Quote? Quote { get; set; }
public virtual ShoppingCart? ShoppingCart { get; set; }
}
-8
View File
@@ -1,8 +0,0 @@
namespace LiteCharms.Entities;
public class ShoppingCartItem : Models.ShoppingCartItem
{
public virtual ShoppingCart? ShoppingCart { get; set; }
public virtual ProductPrice? ProductPrice { get; set; }
}
-13
View File
@@ -1,13 +0,0 @@
using LiteCharms.Models.Configuraton.Email;
namespace LiteCharms.Extensions;
public static class Email
{
public static IServiceCollection AddEmailServices(this IServiceCollection services, IConfiguration configuration)
{
services.Configure<SmtpSettings>(configuration.GetSection("Email"));
return services;
}
}
-149
View File
@@ -1,149 +0,0 @@
using LiteCharms.Models;
namespace LiteCharms.Extensions;
public static class EntityModeMappers
{
public static ShoppingCartItem ToModel(this Entities.ShoppingCartItem entity) =>
new()
{
Id = entity.Id,
CreatedAt = entity.CreatedAt,
UpdatedAt = entity.UpdatedAt,
ProductPriceId = entity.ProductPriceId,
Quantity = entity.Quantity,
ShoppingCartId = entity.ShoppingCartId
};
public static ShoppingCart ToModel(this Entities.ShoppingCart entity) =>
new()
{
Id = entity.Id,
CreatedAt = entity.CreatedAt,
UpdatedAt = entity.UpdatedAt,
CustomerId = entity.CustomerId,
OrderId = entity.OrderId,
QuoteId = entity.QuoteId
};
public static Quote ToModel(this Entities.Quote entity) =>
new()
{
Id = entity.Id,
CreatedAt = entity.CreatedAt,
UpdatedAt = entity.UpdatedAt,
CustomerId = entity.CustomerId,
ExpiredAt = entity.ExpiredAt,
Reason = entity.Reason,
ShoppingCartId = entity.ShoppingCartId,
Status = entity.Status
};
public static Notification ToModel(this Entities.Notification entity) =>
new()
{
Id = entity.Id,
CreatedAt = entity.CreatedAt,
Description = entity.Description,
Direction = entity.Direction,
CorrelationId = entity.CorrelationId,
CorrelationIdType = entity.CorrelationIdType,
IsInternal = entity.IsInternal,
Author = entity.Author,
Platform = entity.Platform,
PlatformAddress = entity.PlatformAddress,
Title = entity.Title,
Processed = entity.Processed
};
public static Customer ToModel(this Entities.Customer entity) =>
new()
{
Id = entity.Id,
CreatedAt = entity.CreatedAt,
UpdatedAt = entity.UpdatedAt,
Active = entity.Active,
Address = entity.Address,
City = entity.City,
Company = entity.Company,
Country = entity.Country,
Discord = entity.Discord,
Email = entity.Email,
LastName = entity.LastName,
LinkedIn = entity.LinkedIn,
Name = entity.Name,
Phone = entity.Phone,
PostalCode = entity.PostalCode,
Region = entity.Region,
Slack = entity.Slack,
Tax = entity.Tax,
Website = entity.Website,
Whatsapp = entity.Whatsapp
};
public static Lead ToModel(this Entities.Lead entity) =>
new()
{
Id = entity.Id,
CreatedAt = entity.CreatedAt,
UpdatedAt = entity.UpdatedAt,
AdGroupId = entity.AdGroupId,
AdName = entity.AdName,
AppClickId = entity.AppClickId,
AttributionHash = entity.AttributionHash,
CampaignId = entity.CampaignId,
ClickLocation = entity.ClickLocation,
CustomerId = entity.CustomerId,
FeedItemId = entity.FeedItemId,
Source = entity.Source,
ClickId = entity.ClickId,
TargetId = entity.TargetId,
WebClickId = entity.WebClickId,
Status = entity.Status
};
public static Order ToModel(this Entities.Order entity) =>
new()
{
Id = entity.Id,
CreatedAt = entity.CreatedAt,
UpdatedAt = entity.UpdatedAt,
CustomerId = entity.CustomerId,
Notes = entity.Notes,
RefundId = entity.RefundId,
QuoteId = entity.QuoteId,
Status = entity.Status,
ShoppingCartId = entity.ShoppingCartId
};
public static OrderRefund ToModel(this Entities.OrderRefund entity) =>
new()
{
Id = entity.Id,
CreatedAt = entity.CreatedAt,
OrderId = entity.OrderId,
Reason = entity.Reason,
Amount = entity.Amount
};
public static Product ToModel(this Entities.Product entity) =>
new()
{
Id = entity.Id,
Name = entity.Name,
Description = entity.Description,
Active = entity.Active
};
public static ProductPrice ToModel(this Entities.ProductPrice entity) =>
new()
{
Id = entity.Id,
ProductId = entity.ProductId,
Price = entity.Price,
Active = entity.Active,
CreatedAt = entity.CreatedAt,
Discount = entity.Discount,
UpdatedAt = entity.UpdatedAt
};
}
@@ -1,113 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<SignAssembly>True</SignAssembly>
<AssemblyOriginatorKeyFile>..\LiteCharms.snk</AssemblyOriginatorKeyFile>
<JsonSerializerIsReflectionEnabledByDefault>true</JsonSerializerIsReflectionEnabledByDefault>
</PropertyGroup>
<!-- Warnings And Exclusions -->
<PropertyGroup>
<NoWarn>$(NoWarn);MA0004</NoWarn>
<!-- https://github.com/dotnet/aspnetcore/issues/50836 -->
<NoWarn>$(NoWarn);AD0001</NoWarn>
<PublishTrimmed>true</PublishTrimmed>
<NoWarn>$(NoWarn);IL2080;IL2065;IL2075;IL2087;IL2057;IL2060;IL2070;IL2067;IL2072;IL2026;IL2104</NoWarn>
<NoWarn>$(NoWarn);IL2110;IL2111</NoWarn>
</PropertyGroup>
<!-- Nuget Package Details -->
<PropertyGroup>
<PackageId>LiteCharms.Extensions</PackageId>
<Version>1.0.20</Version>
<Authors>Khwezi Mngoma</Authors>
<Company>Lite Charms (PTY) Ltd</Company>
<Description>Extension components for Lite Charms applications.</Description>
<PackageProjectUrl>https://gitea.khongisa.co.za/litecharms/components</PackageProjectUrl>
<RepositoryUrl>https://gitea.khongisa.co.za/litecharms/components.git</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PackageLicenseFile>LICENSE</PackageLicenseFile>
<PackageTags>utility;dotnet</PackageTags>
<PackageIcon>icon.png</PackageIcon>
</PropertyGroup>
<ItemGroup>
<None Include="..\LICENSE" Pack="true" PackagePath="\" />
<None Include="..\icon.png" Pack="true" PackagePath="\" />
</ItemGroup>
<!-- Health Checks -->
<ItemGroup>
<PackageReference Include="AspNetCore.HealthChecks.UI.Client" Version="9.0.0" />
<PackageReference Include="AspNetCore.HealthChecks.UI" Version="9.0.0" />
<PackageReference Include="AspNetCore.HealthChecks.UI.Core" Version="9.0.0" />
<PackageReference Include="AspNetCore.HealthChecks.UI.Data" Version="9.0.0" />
<PackageReference Include="AspNetCore.HealthChecks.UI.InMemory.Storage" Version="9.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.7" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.7" />
<PackageReference Include="Quartz.AspNetCore" Version="3.18.1" />
<PackageReference Include="Quartz.Extensions.DependencyInjection" Version="3.18.1" />
<!-- Global Usings -->
<Using Include="Microsoft.Extensions.Diagnostics.HealthChecks" />
<Using Include="Microsoft.AspNetCore.Diagnostics.HealthChecks" />
</ItemGroup>
<!-- Open Telemetry -->
<ItemGroup>
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.3" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.15.3" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.2" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.15.1" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.15.1" />
<PackageReference Include="OpenTelemetry.Exporter.Console" Version="1.15.3" />
<!-- Global Usings -->
<Using Include="OpenTelemetry.Resources" />
<Using Include="OpenTelemetry.Exporter" />
<Using Include="OpenTelemetry.Logs" />
<Using Include="OpenTelemetry.Metrics" />
<Using Include="OpenTelemetry.Trace" />
</ItemGroup>
<!-- Database -->
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.7">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
<!-- Global Usings -->
<Using Include="Npgsql" />
<Using Include="Microsoft.EntityFrameworkCore" />
<Using Include="Microsoft.EntityFrameworkCore.Design" />
<Using Include="Microsoft.EntityFrameworkCore.Metadata.Builders" />
</ItemGroup>
<!-- Shared Usings -->
<ItemGroup>
<Using Include="Quartz" />
<Using Include="Microsoft.AspNetCore.Builder" />
<Using Include="Microsoft.Extensions.Configuration" />
<Using Include="Microsoft.Extensions.DependencyInjection" />
<Using Include="Microsoft.Extensions.Logging" />
</ItemGroup>
<!-- Project References -->
<ItemGroup>
<ProjectReference Include="..\LiteCharms.Entities\LiteCharms.Entities.csproj" />
<ProjectReference Include="..\LiteCharms.Infrastructure\LiteCharms.Infrastructure.csproj" />
<ProjectReference Include="..\LiteCharms.Models\LiteCharms.Models.csproj" />
</ItemGroup>
</Project>
-14
View File
@@ -1,14 +0,0 @@
using LiteCharms.Infrastructure.Database;
namespace LiteCharms.Extensions;
public static class Postgres
{
public static IServiceCollection AddLeadGeneratorDatabase(this IServiceCollection services, IConfiguration configuration)
{
services.AddPooledDbContextFactory<LeadGeneratorDbContext>(options =>
options.UseNpgsql(configuration.GetConnectionString("PostgresLeadGenerator")));
return services;
}
}
@@ -0,0 +1,35 @@
using LiteCharms.Features.Extensions;
namespace LiteCharms.Features.Tests;
public class CommonFixture : IDisposable
{
public IConfiguration Configuration { get; set; }
public IServiceProvider Services { get; set; }
public IMediator Mediator { get; set; }
public CommonFixture()
{
Configuration = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json")
.AddUserSecrets<CommonFixture>()
.AddEnvironmentVariables()
.Build();
Services = new ServiceCollection()
.AddMediator()
.AddLogging()
.AddShopServices()
.AddEmailServiceBus()
.AddShopDatabase(Configuration)
.AddEmailServices(Configuration)
.BuildServiceProvider();
Mediator = Services.GetRequiredService<IMediator>();
}
public void Dispose() { }
}
@@ -0,0 +1,50 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<UserSecretsId>62fa604a-1340-4edb-9ddd-3305fcf46fca</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="10.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Mediator.SourceGenerator" Version="3.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.5.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<!-- Global Usings -->
<ItemGroup>
<Using Include="Mediator"/>
<Using Include="Xunit.Abstractions"/>
<Using Include="Microsoft.Extensions.DependencyInjection"/>
<Using Include="Microsoft.Extensions.Configuration"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\LiteCharms.Features\LiteCharms.Features.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<None Update="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
@@ -0,0 +1,35 @@
using LiteCharms.Features.Shop.Notifications;
namespace LiteCharms.Features.Tests;
public class NotificationsFeatureTests(CommonFixture fixture, ITestOutputHelper output) : IClassFixture<CommonFixture>
{
private readonly NotificationService notificationService = fixture.Services.GetRequiredService<NotificationService>();
[Fact]
public async Task CreateNotificationCommand_ShouldSucceed()
{
Shop.Notifications.Models.CreateNotification request = new()
{
CorrelationId = Guid.CreateVersion7().ToString(),
CorrelationIdType = Shop.CorrelationIdTypes.None,
Direction = Shop.NotificationDirection.Outgoing,
Platform = Shop.NotificationPlatforms.Email,
Priority = Shop.Priorities.Medium,
Sender = "xUnit Test",
SenderAddress = "khwezi@mngoma.africa",
Recipient = $"{Email.Extensions.Constants.ShopEmailFromName} [Test]",
RecipientAddress = Email.Extensions.Constants.ShopEmailFromAddress,
Subject = "Test Message",
Message = "This is an automation test",
IsHtml = false,
IsInternal = true,
};
var createResult = await notificationService.CreateNotificationAsync(request);
Assert.True(createResult.IsSuccess);
foreach (var error in createResult.Errors) output.WriteLine(error.Message);
}
}
@@ -0,0 +1,22 @@
{
"Email": {
"Credentials": {
"Username": "shop@litecharms.co.za"
},
"Port": 465,
"Host": "mail.litecharms.co.za",
"UseSsl": true
},
"Monitoring": {
"ApiKey": "",
"Address": "http://aspire-dashboard-service.aspire.svc.cluster.local:18889",
"ServiceName": "LiteCharms.LeadGenerator"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
@@ -0,0 +1,13 @@
using LiteCharms.Features.Extensions;
using static LiteCharms.Features.Extensions.Timezones;
namespace LiteCharms.Features.Abstractions;
public abstract class EventBase
{
public Guid Id { get; set; } = Guid.CreateVersion7();
public DateTimeOffset EnqueueAt { get; set; } = SouthAfricanTimeZone.UtcNow();
public string CorrelationId { get; set; } = Guid.CreateVersion7().ToString();
}
@@ -1,4 +1,4 @@
namespace LiteCharms.Abstractions;
namespace LiteCharms.Features.Abstractions;
public interface IEvent : INotification
{
@@ -1,64 +0,0 @@
namespace LiteCharms.Features.Customers.Commands;
public class CreateCustomerCommand : IRequest<Result<Guid>>
{
public string? Company { get; set; }
public string Name { get; set; }
public string LastName { get; set; }
public string? Tax { get; set; }
public string Email { get; set; }
public string? Discord { get; set; }
public string? Slack { get; set; }
public string? LinkedIn { get; set; }
public string? Whatsapp { get; set; }
public string? Website { get; set; }
public string? Phone { get; set; }
public string? Address { get; set; }
public string? City { get; set; }
public string? Region { get; set; }
public string? Country { get; set; }
public string? PostalCode { get; set; }
private CreateCustomerCommand(string name, string lastName, string? company, string? tax, string email, string? discord, string? slack, string? linkedIn, string? whatsapp, string? website, string? phone, string? address, string? city, string? region, string? country, string? postalCode)
{
Name = name;
LastName = lastName;
Company = company;
Tax = tax;
Email = email;
Discord = discord;
Slack = slack;
LinkedIn = linkedIn;
Whatsapp = whatsapp;
Website = website;
Phone = phone;
Address = address;
City = city;
Region = region;
Country = country;
PostalCode = postalCode;
}
public static CreateCustomerCommand Create(string name, string lastName, string? company, string? tax, string email, string? discord, string? slack, string? linkedIn, string? whatsapp, string? website, string? phone, string? address, string? city, string? region, string? country, string? postalCode)
{
if (string.IsNullOrWhiteSpace(name) && string.IsNullOrWhiteSpace(lastName) && string.IsNullOrWhiteSpace(email))
throw new ArgumentException("At the following fields must be provided: Name, LastName, Email");
return new(name, lastName, company, tax, email, discord, slack, linkedIn, whatsapp, website, phone, address, city, region, country, postalCode);
}
}
@@ -1,48 +0,0 @@
using LiteCharms.Infrastructure.Database;
namespace LiteCharms.Features.Customers.Commands.Handlers;
public class CreateCustomerCommandHandler(IDbContextFactory<LeadGeneratorDbContext> contextFactory) : IRequestHandler<CreateCustomerCommand, Result<Guid>>
{
public async ValueTask<Result<Guid>> Handle(CreateCustomerCommand request, CancellationToken cancellationToken)
{
try
{
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var customerEmail = request.Email.ToLower().Trim();
if (await context.Customers.AnyAsync(c => c.Email == customerEmail, cancellationToken))
return Result.Fail<Guid>(new Error($"A customer with the email {customerEmail} already exists"));
var newCustomer = context.Customers.Add(new Entities.Customer
{
Company = request.Company,
Name = request.Name,
LastName = request.LastName,
Tax = request.Tax,
Email = customerEmail,
Discord = request.Discord,
Slack = request.Slack,
LinkedIn = request.LinkedIn,
Whatsapp = request.Whatsapp,
Website = request.Website,
Phone = request.Phone,
Address = request.Address,
City = request.City,
Region = request.Region,
Country = request.Country,
PostalCode = request.PostalCode,
Active = true,
});
return await context.SaveChangesAsync(cancellationToken) > 0
? Result.Ok(newCustomer.Entity.Id)
: Result.Fail<Guid>(new Error($"Failed to create customer {customerEmail}"));
}
catch (Exception ex)
{
return Result.Fail<Guid>(new Error(ex.Message).CausedBy(ex));
}
}
}
@@ -1,44 +0,0 @@
using LiteCharms.Infrastructure.Database;
namespace LiteCharms.Features.Customers.Commands.Handlers;
public class UpdateCustomerCommandHandler(IDbContextFactory<LeadGeneratorDbContext> contextFactory) : IRequestHandler<UpdateCustomerCommand, Result>
{
public async ValueTask<Result> Handle(UpdateCustomerCommand request, CancellationToken cancellationToken)
{
try
{
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var customer = await context.Customers.FirstOrDefaultAsync(c => c.Id == request.CustomerId, cancellationToken);
if (customer is null)
return Result.Fail(new Error($"Customer with ID {request.CustomerId} not found."));
customer.Name = request.Name;
customer.LastName = request.LastName;
customer.Email = request.Email;
customer.Company = request.Company;
customer.Address = request.Address;
customer.City = request.City;
customer.Region = request.Region;
customer.Country = request.Country;
customer.PostalCode = request.PostalCode;
customer.Phone = request.Phone;
customer.Tax = request.Tax;
customer.City = request.City;
customer.Discord = request.Discord;
customer.Slack = request.Slack;
customer.LinkedIn = request.LinkedIn;
customer.Whatsapp = request.Whatsapp;
return await context.SaveChangesAsync(cancellationToken) > 0
? Result.Ok()
: Result.Fail(new Error($"Failed to update the customer {request.CustomerId}."));
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
}
@@ -1,70 +0,0 @@
namespace LiteCharms.Features.Customers.Commands;
public class UpdateCustomerCommand : IRequest<Result>
{
public Guid CustomerId { get; set; }
public string? Company { get; set; }
public string? Name { get; set; }
public string? LastName { get; set; }
public string? Tax { get; set; }
public string? Email { get; set; }
public string? Discord { get; set; }
public string? Slack { get; set; }
public string? LinkedIn { get; set; }
public string? Whatsapp { get; set; }
public string? Website { get; set; }
public string? Phone { get; set; }
public string? Address { get; set; }
public string? City { get; set; }
public string? Region { get; set; }
public string? Country { get; set; }
public string? PostalCode { get; set; }
private UpdateCustomerCommand(Guid customerId, string name, string lastName, string? company, string? tax, string email, string? discord, string? slack, string? linkedIn, string? whatsapp, string? website, string? phone, string? address, string? city, string? region, string? country, string? postalCode)
{
CustomerId = customerId;
Name = name;
LastName = lastName;
Company = company;
Tax = tax;
Email = email;
Discord = discord;
Slack = slack;
LinkedIn = linkedIn;
Whatsapp = whatsapp;
Website = website;
Phone = phone;
Address = address;
City = city;
Region = region;
Country = country;
PostalCode = postalCode;
}
public static UpdateCustomerCommand Create(Guid customerId, string name, string lastName, string? company, string? tax, string email, string? discord, string? slack, string? linkedIn, string? whatsapp, string? website, string? phone, string? address, string? city, string? region, string? country, string? postalCode)
{
if (customerId == Guid.Empty)
throw new ArgumentException("Customer ID is required.", nameof(customerId));
if (string.IsNullOrWhiteSpace(name) && string.IsNullOrWhiteSpace(lastName) && string.IsNullOrWhiteSpace(email))
throw new ArgumentException("At the following fields must be provided: Name, LastName, Email");
return new(customerId, name, lastName, company, tax, email, discord, slack, linkedIn, whatsapp, website, phone, address, city, region, country, postalCode);
}
}
@@ -1,18 +0,0 @@
using LiteCharms.Models;
namespace LiteCharms.Features.Customers.Queries;
public class GetCustomerQuery : IRequest<Result<Customer>>
{
public Guid CustomerId { get; set; }
private GetCustomerQuery(Guid customerId) => CustomerId = customerId;
public static GetCustomerQuery Create(Guid customerId)
{
if(customerId == Guid.Empty)
throw new ArgumentException("Customer ID is required.", nameof(customerId));
return new(customerId);
}
}
@@ -1,30 +0,0 @@
using LiteCharms.Models;
namespace LiteCharms.Features.Customers.Queries;
public class GetCustomersQuery : IRequest<Result<Customer[]>>
{
public DateOnly From { get; set; }
public DateOnly To { get; set; }
public int MaxRecords { get; set; }
private GetCustomersQuery(DateOnly from, DateOnly to, int maxRecords = 1000)
{
From = from;
To = to;
MaxRecords = maxRecords;
}
public static GetCustomersQuery Create(DateOnly from, DateOnly to, int maxRecords = 1000)
{
if (from > to)
throw new ArgumentException("From date cannot be greater than To date.");
if(maxRecords <= 0)
throw new ArgumentException("MaxRecords must be a positive integer.");
return new(from, to, maxRecords);
}
}
@@ -1,26 +0,0 @@
using LiteCharms.Extensions;
using LiteCharms.Infrastructure.Database;
using LiteCharms.Models;
namespace LiteCharms.Features.Customers.Queries.Handlers;
public class GetCustomerQueryHandler(IDbContextFactory<LeadGeneratorDbContext> contextFactory) : IRequestHandler<GetCustomerQuery, Result<Customer>>
{
public async ValueTask<Result<Customer>> Handle(GetCustomerQuery request, CancellationToken cancellationToken)
{
try
{
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var customer = await context.Customers.FirstOrDefaultAsync(c => c.Id == request.CustomerId, cancellationToken);
return customer is not null
? Result.Ok(customer.ToModel())
: Result.Fail<Customer>($"Customer not found with id {request.CustomerId}");
}
catch (Exception ex)
{
return Result.Fail<Customer>(new Error(ex.Message).CausedBy(ex));
}
}
}
@@ -1,33 +0,0 @@
using LiteCharms.Extensions;
using LiteCharms.Infrastructure.Database;
using LiteCharms.Models;
namespace LiteCharms.Features.Customers.Queries.Handlers;
public class GetCustomersQueryHandler(IDbContextFactory<LeadGeneratorDbContext> contextFactory) : IRequestHandler<GetCustomersQuery, Result<Customer[]>>
{
public async ValueTask<Result<Customer[]>> Handle(GetCustomersQuery request, CancellationToken cancellationToken)
{
try
{
var fromDate = request.From.ToDateTime(TimeOnly.MinValue);
var toDate = request.To.ToDateTime(TimeOnly.MaxValue);
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var customers = await context.Customers.AsNoTracking()
.OrderByDescending(o => o.CreatedAt)
.Where(c => c.CreatedAt >= fromDate && c.CreatedAt <= toDate)
.Take(request.MaxRecords)
.ToArrayAsync(cancellationToken);
return customers?.Length > 0
? Result.Ok(customers.Select(c => c.ToModel()).ToArray())
: Result.Fail<Customer[]>(new Error("No customers found in the specified date range."));
}
catch (Exception ex)
{
return Result.Fail<Customer[]>(new Error(ex.Message).CausedBy(ex));
}
}
}
@@ -1,4 +1,4 @@
namespace LiteCharms.Models.Configuraton.Email;
namespace LiteCharms.Features.Email.Configuration;
public class Account
{
@@ -1,4 +1,4 @@
namespace LiteCharms.Models.Configuraton.Email;
namespace LiteCharms.Features.Email.Configuration;
public class SmtpSettings
{
+192
View File
@@ -0,0 +1,192 @@
using LiteCharms.Features.Email.Configuration;
using LiteCharms.Features.Email.Extensions;
using LiteCharms.Features.Email.Models;
using LiteCharms.Features.Shop;
namespace LiteCharms.Features.Email;
public class EmailService(IOptions<SmtpSettings> options) : IDisposable
{
private readonly SmtpSettings settings = options.Value;
private readonly SmtpClient client = new();
private readonly int sendMaxCount = 10;
private int sendCount = 0;
public EmailStatuses Status { get; private set; } = EmailStatuses.Disconnected;
public async ValueTask<Result<Response>> SendEmailAsync(Message message, CancellationToken cancellationToken = default)
{
using var activity = EmailTelemetry.Source.StartActivity("Email Send");
activity?.SetTag("email.recipient", message.Recipient?.Address);
try
{
if (Status != EmailStatuses.Connected)
{
activity?.SetStatus(ActivityStatusCode.Error, "Disconnected");
return Result.Fail<Response>("Smtp service is disconnected.");
}
var email = new MimeMessage();
email.From.Add(new MailboxAddress(message.Sender!.Name, message.Sender.Address!));
email.To.Add(new MailboxAddress(message.Recipient!.Name, message.Recipient!.Address!));
email.Subject = message.Subject!;
var bodyBuilder = new BodyBuilder();
foreach (var attachment in message.Body?.Attachments!)
bodyBuilder.Attachments.Add(attachment.Name!, attachment.FileStream!, cancellationToken);
if (!message.Body.Properties.IsHtml) bodyBuilder.TextBody = message.Body.Message;
if (message.Body.Properties.IsHtml) bodyBuilder.HtmlBody = message.Body.Message;
email.Body = bodyBuilder.ToMessageBody();
var response = await client.SendAsync(email, cancellationToken);
bool emailSent = response.Contains("OK", StringComparison.InvariantCultureIgnoreCase);
message.Dispose();
Interlocked.Increment(ref sendCount);
if (sendCount % sendMaxCount == 0)
{
using var delayActivity = EmailTelemetry.Source.StartActivity("Rate Limit Pause");
sendCount = 0;
await Task.Delay(1000, cancellationToken);
}
if (emailSent)
{
EmailTelemetry.EmailsSent.Add(1, new TagList { { "host", settings.Host } });
return Result.Ok(Response.Create(EmailStatuses.Success));
}
await DisconnectAsync(cancellationToken);
if (response.Contains("421"))
{
Status = EmailStatuses.TooManyConnections;
return Result.Fail<Response>(response);
}
if (response.Contains("451"))
{
Status = EmailStatuses.ConnectionAborted;
return Result.Fail<Response>(response);
}
EmailTelemetry.EmailsFailed.Add(1, new TagList { { "error_message", response } });
Status = EmailStatuses.Disconnected;
return Result.Fail<Response>("General error, disconnected");
}
catch (Exception ex)
{
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
activity?.AddException(ex);
EmailTelemetry.EmailsFailed.Add(1, new TagList { { "exception", ex.GetType().Name } });
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result<Response>> ConnectAsync(CancellationToken cancellationToken = default)
{
using var activity = EmailTelemetry.Source.StartActivity("Email Connect");
activity?.SetTag("email.smtp.connect", settings.Host);
try
{
if (Status is EmailStatuses.Connected) return Result.Ok(Response.Create(Status));
await client.ConnectAsync(settings.Host!, settings.Port, settings.UseSsl, cancellationToken);
await client.AuthenticateAsync(settings.Credentials!.Username!, settings.Credentials.Password!, cancellationToken);
Status = EmailStatuses.Connected;
activity?.SetStatus(ActivityStatusCode.Ok, "Connected");
return Result.Ok(Response.Create(Status));
}
catch (MailKit.ProtocolException ex)
{
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
activity?.AddException(ex);
EmailTelemetry.EmailsFailed.Add(1, new TagList { { "exception", ex.GetType().Name } });
Status = EmailStatuses.ProtocolError;
return Result.Fail<Response>(new Error(ex.Message).CausedBy(ex));
}
catch (Exception ex) when (ex is MailKit.Security.SslHandshakeException || ex is MailKit.Security.AuthenticationException)
{
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
activity?.AddException(ex);
EmailTelemetry.EmailsFailed.Add(1, new TagList { { "exception", ex.GetType().Name } });
Status = EmailStatuses.AuthenticationError;
return Result.Fail<Response>(new Error(ex.Message).CausedBy(ex));
}
catch (Exception ex)
{
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
activity?.AddException(ex);
EmailTelemetry.EmailsFailed.Add(1, new TagList { { "exception", ex.GetType().Name } });
Status = EmailStatuses.GeneralError;
return Result.Fail<Response>(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result> DisconnectAsync(CancellationToken cancellationToken = default)
{
using var activity = EmailTelemetry.Source.StartActivity("Email Disconnect");
activity?.SetTag("email.smtp.disconnect", settings.Host);
try
{
if (Status is EmailStatuses.Disconnected) return Result.Ok();
await client.DisconnectAsync(true, cancellationToken);
activity?.SetStatus(ActivityStatusCode.Ok, "Disconnected");
Status = EmailStatuses.Disconnected;
return Result.Ok();
}
catch (Exception ex)
{
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
activity?.AddException(ex);
EmailTelemetry.EmailsFailed.Add(1, new TagList { { "exception", ex.GetType().Name } });
Status = EmailStatuses.GeneralError;
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
public void Dispose()
{
client.Dispose();
GC.SuppressFinalize(this);
}
}
@@ -0,0 +1,27 @@
using LiteCharms.Features.Shop;
using LiteCharms.Features.Shop.Notifications;
using static LiteCharms.Features.Email.Extensions.Constants;
namespace LiteCharms.Features.Email.Events.Handlers;
public class SendShopEmailEnquiryEventHandler(NotificationService notificationService) :
INotificationHandler<SendShopEmailEnquiryEvent>
{
public async ValueTask Handle(SendShopEmailEnquiryEvent notification, CancellationToken cancellationToken) =>
await notificationService.CreateNotificationAsync(new Shop.Notifications.Models.CreateNotification
{
CorrelationId = notification.CorrelationId,
CorrelationIdType = CorrelationIdTypes.None,
Direction = NotificationDirection.Outgoing,
IsHtml = false,
IsInternal = true,
Message = notification.Message,
Platform = NotificationPlatforms.Email,
Priority = notification.Priority,
Subject = notification.Subject!,
Sender = notification.SenderName!,
SenderAddress = notification.SenderAddress!,
Recipient = ShopEmailFromName,
RecipientAddress = ShopEmailFromAddress
}, cancellationToken);
}
@@ -0,0 +1,40 @@
using LiteCharms.Features.Abstractions;
using LiteCharms.Features.Shop;
namespace LiteCharms.Features.Email.Events;
public class SendShopEmailEnquiryEvent : EventBase, IEvent
{
public string Name { get; set; } = nameof(SendShopEmailEnquiryEvent);
public string? SenderName { get; set; }
public string? SenderAddress { get; set; }
public string? Subject { get; set; }
public string? Message { get; set; }
public Priorities Priority { get; set; }
public SendShopEmailEnquiryEvent() { }
private SendShopEmailEnquiryEvent(string senderName, string senderAddress, string subject, string message, Priorities priority = Priorities.Medium)
{
SenderName = senderName;
SenderAddress = senderAddress;
Subject = subject;
Message = message;
Priority = priority;
}
public static SendShopEmailEnquiryEvent Create(string senderName, string senderAddress, string subject, string message, Priorities priority = Priorities.Medium)
{
ArgumentNullException.ThrowIfNullOrWhiteSpace(senderName, nameof(senderName));
ArgumentNullException.ThrowIfNullOrWhiteSpace(senderAddress, nameof(senderAddress));
ArgumentNullException.ThrowIfNullOrWhiteSpace(subject, nameof(subject));
ArgumentNullException.ThrowIfNullOrWhiteSpace(message, nameof(message));
return new(senderName, senderAddress, subject, message, priority);
}
}
@@ -0,0 +1,8 @@
namespace LiteCharms.Features.Email.Extensions;
public static class Constants
{
public const string ShopSchedulerName = "shop";
public const string ShopEmailFromName = "Khongisa Shop";
public const string ShopEmailFromAddress = "shop@litecharms.co.za";
}
@@ -0,0 +1,9 @@
namespace LiteCharms.Features.Email.Extensions;
public static class EmailTelemetry
{
public static readonly ActivitySource Source = new("LiteCharms.EmailService");
public static readonly Meter Meter = new("LiteCharms.EmailService");
public static readonly Counter<long> EmailsSent = Meter.CreateCounter<long>("emails_sent_total", "count", "Total successful emails sent");
public static readonly Counter<long> EmailsFailed = Meter.CreateCounter<long>("emails_failed_total", "count", "Total failed email attempts");
}
@@ -0,0 +1,8 @@
namespace LiteCharms.Features.Email.Models;
public class Attachment
{
public string? Name { get; set; }
public Stream? FileStream { get; set; }
}
+26
View File
@@ -0,0 +1,26 @@
namespace LiteCharms.Features.Email.Models;
public class Body : IDisposable
{
public string? Message { get; set; }
public ReadOnlyCollection<Attachment>? Attachments { get; set; }
public BodyProperties Properties { get; set; } = new();
public void Dispose()
{
if (Attachments is null) return;
foreach (var attachment in Attachments!)
{
if (attachment is not null)
{
attachment.FileStream!.Close();
attachment.FileStream!.Dispose();
}
}
GC.SuppressFinalize(this);
}
}
@@ -0,0 +1,8 @@
namespace LiteCharms.Features.Email.Models;
public class BodyProperties
{
public bool IsHtml { get; set; }
public bool HasAttachments { get; set; }
}
@@ -1,4 +1,6 @@
namespace LiteCharms.Models;
using System.ComponentModel.DataAnnotations;
namespace LiteCharms.Features.Email.Models;
public sealed class EmailEnquiry
{
@@ -0,0 +1,19 @@
namespace LiteCharms.Features.Email.Models;
public class Message : IDisposable
{
public Party? Sender { get; set; }
public Party? Recipient { get; set; }
public string? Subject { get; set; }
public Body? Body { get; set; }
public void Dispose()
{
Body?.Dispose();
GC.SuppressFinalize(this);
}
}
@@ -0,0 +1,8 @@
namespace LiteCharms.Features.Email.Models;
public class Party
{
public string? Name { get; set; }
public string? Address { get; set; }
}
@@ -0,0 +1,22 @@
using LiteCharms.Features.Shop;
namespace LiteCharms.Features.Email.Models;
public class Response
{
public int Code { get; set; }
public string? Error { get; set; }
public EmailStatuses Status { get; set; }
private Response(EmailStatuses status, int code = 0, string? error = null)
{
Status = status;
Code = code;
Error = error;
}
public static Response Create(EmailStatuses status, int code = 0, string? error = null) =>
new(status, code, error);
}
+20
View File
@@ -0,0 +1,20 @@
using LiteCharms.Features.Email;
using LiteCharms.Features.Email.Configuration;
namespace LiteCharms.Features.Extensions;
public static class Email
{
public static IServiceCollection AddEmailServices(this IServiceCollection services, IConfiguration configuration)
{
services.Configure<SmtpSettings>(configuration.GetSection("Email"));
services.AddSingleton<EmailService>();
services.AddOpenTelemetry()
.WithTracing(tracing => tracing.AddSource("LiteCharms.EmailService"))
.WithMetrics(metrics => metrics.AddMeter("LiteCharms.EmailService"));
return services;
}
}
@@ -0,0 +1,199 @@
using LiteCharms.Features.Shop.CartPackages.Models;
using LiteCharms.Features.Shop.Customers.Models;
using LiteCharms.Features.Shop.Leads.Models;
using LiteCharms.Features.Shop.Notifications.Models;
using LiteCharms.Features.Shop.Orders.Models;
using LiteCharms.Features.Shop.Products.Models;
using LiteCharms.Features.Shop.Quotes.Models;
using LiteCharms.Features.Shop.ShoppingCarts.Models;
namespace LiteCharms.Features.Extensions;
public static class EntityModeMappers
{
public static ShoppingCartPackage ToModel(this Features.Shop.ShoppingCarts.Entities.ShoppingCartPackage entity) =>
new()
{
Id = entity.Id,
CreatedAt = entity.CreatedAt,
PackageId = entity.PackageId,
ShoppingCartId = entity.ShoppingCartId
};
public static PackageItem ToModel(this Features.Shop.CartPackages.Entities.PackageItem entity) =>
new()
{
Id = entity.Id,
Active = entity.Active,
CreatedAt = entity.CreatedAt,
PackageId = entity.PackageId,
ProductPriceId = entity.ProductPriceId
};
public static Package ToModel(this Features.Shop.CartPackages.Entities.Package entity) =>
new()
{
Id = entity.Id,
CreatedAt = entity.CreatedAt,
Active = entity.Active,
Description = entity.Description,
Name = entity.Name,
UpdatedAt = entity.UpdatedAt,
ImageUrl = entity.ImageUrl,
Summary = entity.Summary
};
public static ShoppingCartItem ToModel(this Features.Shop.ShoppingCarts.Entities.ShoppingCartItem entity) =>
new()
{
Id = entity.Id,
CreatedAt = entity.CreatedAt,
UpdatedAt = entity.UpdatedAt,
ProductPriceId = entity.ProductPriceId,
Quantity = entity.Quantity,
ShoppingCartId = entity.ShoppingCartId
};
public static ShoppingCart ToModel(this Features.Shop.ShoppingCarts.Entities.ShoppingCart entity) =>
new()
{
Id = entity.Id,
CreatedAt = entity.CreatedAt,
UpdatedAt = entity.UpdatedAt,
CustomerId = entity.CustomerId,
OrderId = entity.OrderId
};
public static Quote ToModel(this Features.Shop.Quotes.Entities.Quote entity) =>
new()
{
Id = entity.Id,
CreatedAt = entity.CreatedAt,
UpdatedAt = entity.UpdatedAt,
CustomerId = entity.CustomerId,
ExpiredAt = entity.ExpiredAt,
Reason = entity.Reason,
ShoppingCartId = entity.ShoppingCartId,
Status = entity.Status,
InvoiceUrl = entity.InvoiceUrl,
OrderId = entity.OrderId
};
public static Notification ToModel(this Features.Shop.Notifications.Entities.Notification entity) =>
new()
{
Id = entity.Id,
CreatedAt = entity.CreatedAt,
Message = entity.Message,
Direction = entity.Direction,
CorrelationId = entity.CorrelationId,
CorrelationIdType = entity.CorrelationIdType,
IsInternal = entity.IsInternal,
SenderAddress = entity.SenderAddress,
Platform = entity.Platform,
RecipientName = entity.RecipientName,
Subject = entity.Subject,
Processed = entity.Processed,
SenderName = entity.SenderName,
RecipientAddress = entity.RecipientAddress,
Priority = entity.Priority,
UpdatedAt = entity?.UpdatedAt,
IsHtml = entity!.IsHtml,
HasError = entity.HasError,
Errors = entity.Errors
};
public static Customer ToModel(this Features.Shop.Customers.Entities.Customer entity) =>
new()
{
Id = entity.Id,
CreatedAt = entity.CreatedAt,
UpdatedAt = entity.UpdatedAt,
Active = entity.Active,
Address = entity.Address,
City = entity.City,
Company = entity.Company,
Country = entity.Country,
Discord = entity.Discord,
Email = entity.Email,
LastName = entity.LastName,
LinkedIn = entity.LinkedIn,
Name = entity.Name,
Phone = entity.Phone,
PostalCode = entity.PostalCode,
Region = entity.Region,
Slack = entity.Slack,
Tax = entity.Tax,
Website = entity.Website,
Whatsapp = entity.Whatsapp
};
public static Lead ToModel(this Features.Shop.Leads.Entities.Lead entity) =>
new()
{
Id = entity.Id,
CreatedAt = entity.CreatedAt,
UpdatedAt = entity.UpdatedAt,
AdGroupId = entity.AdGroupId,
AdName = entity.AdName,
AppClickId = entity.AppClickId,
AttributionHash = entity.AttributionHash,
CampaignId = entity.CampaignId,
ClickLocation = entity.ClickLocation,
CustomerId = entity.CustomerId,
FeedItemId = entity.FeedItemId,
Source = entity.Source,
ClickId = entity.ClickId,
TargetId = entity.TargetId,
WebClickId = entity.WebClickId,
Status = entity.Status
};
public static Order ToModel(this Features.Shop.Orders.Entities.Order entity) =>
new()
{
Id = entity.Id,
CreatedAt = entity.CreatedAt,
UpdatedAt = entity.UpdatedAt,
CustomerId = entity.CustomerId,
Notes = entity.Notes,
Status = entity.Status,
Requirements = entity.Requirements,
Terms = entity.Terms,
InvoiceUrl = entity.InvoiceUrl
};
public static OrderRefund ToModel(this Features.Shop.Orders.Entities.OrderRefund entity) =>
new()
{
Id = entity.Id,
CreatedAt = entity.CreatedAt,
OrderId = entity.OrderId,
Reason = entity.Reason,
Amount = entity.Amount
};
public static Product ToModel(this Features.Shop.Products.Entities.Product entity) =>
new()
{
Id = entity.Id,
Name = entity.Name,
Description = entity.Description,
Active = entity.Active,
Summary = entity.Summary,
ImageUrl = entity.ImageUrl,
Thumbnails = entity.Thumbnails
};
public static ProductPrice ToModel(this Features.Shop.Products.Entities.ProductPrice entity) =>
new()
{
Id = entity.Id,
ProductId = entity.ProductId,
Price = entity.Price,
Active = entity.Active,
CreatedAt = entity.CreatedAt,
Discount = entity.Discount,
UpdatedAt = entity.UpdatedAt
};
}
+7
View File
@@ -0,0 +1,7 @@
namespace LiteCharms.Features.Extensions;
public static class Hash
{
public static Func<string?, string?> GenerateSha256HashString = (input) =>
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(input!)));
}
@@ -1,6 +1,6 @@
using LiteCharms.Infrastructure.HealthChecks;
using LiteCharms.Features.HealthChecks;
namespace LiteCharms.Extensions;
namespace LiteCharms.Features.Extensions;
public static class HealthChecks
{
@@ -1,4 +1,4 @@
namespace LiteCharms.Extensions;
namespace LiteCharms.Features.Extensions;
public static class Monitoring
{
@@ -0,0 +1,14 @@
using LiteCharms.Features.Shop.Postgres;
namespace LiteCharms.Features.Extensions;
public static class Postgres
{
public static IServiceCollection AddShopDatabase(this IServiceCollection services, IConfiguration configuration)
{
services.AddPooledDbContextFactory<ShopDbContext>(options =>
options.UseNpgsql(configuration.GetConnectionString("PostgresShop")));
return services;
}
}
@@ -1,13 +1,13 @@
using LiteCharms.Abstractions;
using LiteCharms.Infrastructure.Quartz;
using LiteCharms.Features.Quartz;
using LiteCharms.Features.Quartz.Abstractions;
namespace LiteCharms.Extensions;
namespace LiteCharms.Features.Extensions;
public static class Quartz
{
private const string databaseConfigName = "PostgresScheduler";
public static IServiceCollection AddQuartzSchedulerClient(this IServiceCollection services, string schedulerName, string schedulerId, IConfiguration configuration)
public static IServiceCollection AddQuartzSchedulerClient(this IServiceCollection services, string schedulerName, IConfiguration configuration)
{
var connectionString = configuration.GetConnectionString(databaseConfigName);
@@ -16,7 +16,7 @@ public static class Quartz
services.AddQuartz(config =>
{
config.SchedulerName = schedulerName;
config.SchedulerId = schedulerId;
config.SchedulerId = "AUTO";
config.UseSimpleTypeLoader();
config.UseDefaultThreadPool(options => options.MaxConcurrency = 0);
@@ -28,7 +28,7 @@ public static class Quartz
storage.UseSystemTextJsonSerializer();
storage.SetProperty("quartz.jobStore.clustered", "true");
storage.SetProperty("quartz.jobStore.tablePrefix", "quartz_");
storage.SetProperty("quartz.jobStore.tablePrefix", "qrtz_");
storage.UsePostgres(connectionString!);
storage.UseClustering(cluster =>
@@ -42,7 +42,7 @@ public static class Quartz
return services;
}
public static IServiceCollection AddQuartzScheduler(this IServiceCollection services, string schedulerName, string schedulerId, IConfiguration configuration)
public static IServiceCollection AddQuartzScheduler(this IServiceCollection services, string schedulerName, IConfiguration configuration)
{
var connectionString = configuration.GetConnectionString(databaseConfigName);
@@ -51,7 +51,7 @@ public static class Quartz
services.AddQuartz(config =>
{
config.SchedulerName = schedulerName;
config.SchedulerId = schedulerId;
config.SchedulerId = "AUTO";
config.InterruptJobsOnShutdown = true;
config.InterruptJobsOnShutdownWithWait = true;
config.MaxBatchSize = 5;
@@ -66,7 +66,7 @@ public static class Quartz
storage.UseSystemTextJsonSerializer();
storage.SetProperty("quartz.jobStore.clustered", "true");
storage.SetProperty("quartz.jobStore.tablePrefix", "quartz_");
storage.SetProperty("quartz.jobStore.tablePrefix", "qrtz_");
storage.UsePostgres(connectionString!);
storage.UseClustering(cluster =>
@@ -1,9 +1,9 @@
using LiteCharms.Abstractions;
using LiteCharms.Infrastructure.ServiceBus;
using LiteCharms.Infrastructure.ServiceBus.Exchanges;
using LiteCharms.Infrastructure.ServiceBus.Queues;
using LiteCharms.Features.ServiceBus;
using LiteCharms.Features.ServiceBus.Abstractions;
using LiteCharms.Features.ServiceBus.Exchanges;
using LiteCharms.Features.ServiceBus.Queues;
namespace LiteCharms.Extensions;
namespace LiteCharms.Features.Extensions;
public static class ServiceBus
{
+27
View File
@@ -0,0 +1,27 @@
using LiteCharms.Features.Shop.CartPackages;
using LiteCharms.Features.Shop.Customers;
using LiteCharms.Features.Shop.Leads;
using LiteCharms.Features.Shop.Notifications;
using LiteCharms.Features.Shop.Orders;
using LiteCharms.Features.Shop.Products;
using LiteCharms.Features.Shop.Quotes;
using LiteCharms.Features.Shop.ShoppingCarts;
namespace LiteCharms.Features.Extensions;
public static class Shop
{
public static IServiceCollection AddShopServices(this IServiceCollection services)
{
services.AddSingleton<PackageService>()
.AddSingleton<LeadService>()
.AddSingleton<NotificationService>()
.AddSingleton<OrderService>()
.AddSingleton<ProductService>()
.AddSingleton<QuoteService>()
.AddSingleton<ShoppingCartService>()
.AddSingleton<CustomerService>();
return services;
}
}
@@ -1,4 +1,4 @@
namespace LiteCharms.Abstractions;
namespace LiteCharms.Features.Extensions;
public static class Timezones
{
@@ -23,5 +23,5 @@ public static class Timezones
return DateTimeOffset.Parse(localised!);
}
public static DateTimeOffset UtcNow(this TimeZoneInfo timezone) => ToDateTimeWithTimeZone(DateTime.Now, timezone);
public static DateTime UtcNow(this TimeZoneInfo timezone) => ToDateTimeWithTimeZone(DateTime.Now, timezone).UtcDateTime;
}
@@ -1,8 +1,8 @@
namespace LiteCharms.Infrastructure.HealthChecks;
namespace LiteCharms.Features.HealthChecks;
public class PostgresHealthCheck(IConfiguration configuration) : IHealthCheck
{
private readonly string connectionString = configuration.GetConnectionString("PostgresLeadGenerator")!;
private readonly string connectionString = configuration.GetConnectionString("PostgresShop")!;
public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
{
@@ -1,4 +1,4 @@
namespace LiteCharms.Infrastructure.HealthChecks;
namespace LiteCharms.Features.HealthChecks;
public class QuartzHealthCheck(ISchedulerFactory schedulerFactory) : IHealthCheck
{
@@ -1,55 +0,0 @@
namespace LiteCharms.Features.Leads.Commands;
public class CreateLeadCommand : IRequest<Result<Guid>>
{
public Guid? CustomerId { get; set; }
public string? Source { get; set; }
public string? ClickId { get; set; }
public string? WebClickId { get; set; }
public string? AppClickId { get; set; }
public long? CampaignId { get; set; }
public long? AdGroupId { get; set; }
public long? AdName { get; set; }
public long? TargetId { get; set; }
public long? FeedItemId { get; set; }
public string? ClickLocation { get; set; }
public string? AttribusionHash { get; set; }
private CreateLeadCommand(Guid? customerId, string source, string clickId, string webClickId, string appClickId, long? campaignId, long? adGroupId, long? adName, long? targetId, long? feedItemId, string? clickLocation, string? attribusionHash)
{
CustomerId = customerId;
Source = source;
ClickId = clickId;
WebClickId = webClickId;
AppClickId = appClickId;
CampaignId = campaignId;
AdGroupId = adGroupId;
AdName = adName;
TargetId = targetId;
FeedItemId = feedItemId;
ClickLocation = clickLocation;
AttribusionHash = attribusionHash;
}
public static CreateLeadCommand Create(Guid? customerId, string source, string clickId, string webClickId, string appClickId, long? campaignId, long? adGroupId, long? adName, long? targetId, long? feedItemId, string? clickLocation, string? attribusionHash)
{
if(string.IsNullOrWhiteSpace(source))
throw new ArgumentNullException("Lead source is required to create a lead.", nameof(source));
if (string.IsNullOrWhiteSpace(clickId) || string.IsNullOrWhiteSpace(appClickId) || string.IsNullOrWhiteSpace(webClickId))
throw new ArgumentException("ClickId, App ClickId and Web ClickId are required to create a lead.");
return new(customerId, source, clickId, webClickId, appClickId, campaignId, adGroupId, adName, targetId, feedItemId, clickLocation, attribusionHash);
}
}
@@ -1,47 +0,0 @@
using LiteCharms.Features.Utilities.Commands;
using LiteCharms.Infrastructure.Database;
namespace LiteCharms.Features.Leads.Commands.Handlers;
public class CreateLeadCommandHandler(IDbContextFactory<LeadGeneratorDbContext> contextFactory, ISender mediator) : IRequestHandler<CreateLeadCommand, Result<Guid>>
{
public async ValueTask<Result<Guid>> Handle(CreateLeadCommand request, CancellationToken cancellationToken)
{
try
{
var hashCommand = ComputeHashCommand.Create($"{request.ClickId}{request.AppClickId}{request.WebClickId}");
var hashResult = await mediator.Send(hashCommand, cancellationToken);
if(hashResult.IsFailed)
return Result.Fail<Guid>(new Error($"Failed to compute hash for lead -> Google ClickId: {request.ClickId}, App ClickId: {request.AppClickId}, Web ClickId: {request.WebClickId}")
.CausedBy(hashResult.Errors));
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var newLead = context.Leads.Add(new Entities.Lead
{
WebClickId = request.WebClickId,
AppClickId = request.AppClickId,
Source = request.Source,
ClickId = request.ClickId,
AdGroupId = request.AdGroupId,
AdName = request.AdName,
CampaignId = request.CampaignId,
ClickLocation = request.ClickLocation,
CustomerId = request.CustomerId,
FeedItemId = request.FeedItemId,
Status = Models.LeadStatus.New,
TargetId = request.TargetId,
AttributionHash = hashResult.Value
});
return await context.SaveChangesAsync(cancellationToken) > 0
? Result.Ok(newLead.Entity.Id)
: Result.Fail<Guid>(new Error($"Failed to create lead -> Google ClickId: {request.ClickId}, App ClickId: {request.AppClickId}, Web ClickId: {request.WebClickId}"));
}
catch (Exception ex)
{
return Result.Fail<Guid>(new Error(ex.Message).CausedBy(ex));
}
}
}
@@ -1,29 +0,0 @@
using LiteCharms.Infrastructure.Database;
namespace LiteCharms.Features.Leads.Commands.Handlers;
public class UpdateLeadCommandHandler(IDbContextFactory<LeadGeneratorDbContext> contextFactory) : IRequestHandler<UpdateLeadCommand, Result>
{
public async ValueTask<Result> Handle(UpdateLeadCommand request, CancellationToken cancellationToken)
{
try
{
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var lead = await context.Leads.FirstOrDefaultAsync(l => l.Id == request.LeadId, cancellationToken);
if (lead is null)
return Result.Fail(new Error($"Lead with ID {request.LeadId} not found."));
lead.Status = request.Status;
return await context.SaveChangesAsync(cancellationToken) > 0
? Result.Ok()
: Result.Fail(new Error($"Failed to update the lead {request.LeadId}."));
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
}
@@ -1,27 +0,0 @@
using LiteCharms.Models;
namespace LiteCharms.Features.Leads.Commands;
public class UpdateLeadCommand : IRequest<Result>
{
public Guid LeadId { get; set; }
public LeadStatus Status { get; set; }
private UpdateLeadCommand(Guid leadId, LeadStatus status)
{
LeadId = leadId;
Status = status;
}
public static UpdateLeadCommand Create(Guid leadId, LeadStatus status)
{
if (leadId == Guid.Empty)
throw new ArgumentException("Lead ID cannot be empty.", nameof(leadId));
if (!Enum.IsDefined(typeof(LeadStatus), status))
throw new ArgumentException("Invalid lead status.", nameof(status));
return new(leadId, status);
}
}
@@ -1,30 +0,0 @@
using LiteCharms.Models;
namespace LiteCharms.Features.Leads.Queries;
public class GetCustomerLeadsQuery : IRequest<Result<Lead[]>>
{
public Guid CustomerId { get; }
public DateOnly From { get; set; }
public DateOnly To { get; set; }
private GetCustomerLeadsQuery(Guid customerId, DateOnly from, DateOnly to)
{
CustomerId = customerId;
From = from;
To = to;
}
public static GetCustomerLeadsQuery Create(Guid customerId, DateOnly from, DateOnly to)
{
if(customerId == Guid.Empty)
throw new ArgumentException("Customer ID cannot be empty.", nameof(customerId));
if(from > to)
throw new ArgumentException("The 'From' date cannot be later than the 'To' date.");
return new(customerId, from, to);
}
}
@@ -1,30 +0,0 @@
using LiteCharms.Models;
namespace LiteCharms.Features.Leads.Queries;
public class GetLeadsQuery : IRequest<Result<Lead[]>>
{
public DateOnly From { get; set; }
public DateOnly To { get; set; }
public int MaxRecords { get; set; }
private GetLeadsQuery(DateOnly from, DateOnly to, int maxRecords = 1000)
{
From = from;
To = to;
MaxRecords = maxRecords;
}
public static GetLeadsQuery Create(DateOnly from, DateOnly to, int maxRecords = 1000)
{
if (from > to)
throw new ArgumentException("From date cannot be greater than To date.");
if(maxRecords <= 0)
throw new ArgumentException("MaxRecords must be a positive integer.");
return new(from, to, maxRecords);
}
}
@@ -1,33 +0,0 @@
using LiteCharms.Extensions;
using LiteCharms.Infrastructure.Database;
using LiteCharms.Models;
namespace LiteCharms.Features.Leads.Queries.Handlers;
public class GetCustomerLeadsQueryHandler(IDbContextFactory<LeadGeneratorDbContext> contextFactory) : IRequestHandler<GetCustomerLeadsQuery, Result<Lead[]>>
{
public async ValueTask<Result<Lead[]>> Handle(GetCustomerLeadsQuery request, CancellationToken cancellationToken)
{
try
{
var fromDate = request.From.ToDateTime(TimeOnly.MinValue);
var toDate = request.To.ToDateTime(TimeOnly.MaxValue);
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var leads = await context.Leads.AsNoTracking()
.OrderByDescending(o => o.CreatedAt)
.Where(lead => lead.CustomerId == request.CustomerId)
.Where(lead => lead.CreatedAt.Date >= fromDate && lead.CreatedAt.Date <= toDate)
.ToArrayAsync(cancellationToken);
return leads?.Length > 0
? Result.Ok(leads.Select(l => l.ToModel()).ToArray())
: Result.Fail(new Error($"No customer {request.CustomerId} leads found for the specified date range {request.From} to {request.To}."));
}
catch (Exception ex)
{
return Result.Fail<Lead[]>(new Error(ex.Message).CausedBy(ex));
}
}
}
@@ -1,33 +0,0 @@
using LiteCharms.Extensions;
using LiteCharms.Infrastructure.Database;
using LiteCharms.Models;
namespace LiteCharms.Features.Leads.Queries.Handlers;
public class GetLeadsQueryHandler(IDbContextFactory<LeadGeneratorDbContext> contextFactory) : IRequestHandler<GetLeadsQuery, Result<Lead[]>>
{
public async ValueTask<Result<Lead[]>> Handle(GetLeadsQuery request, CancellationToken cancellationToken)
{
try
{
var fromDate = request.From.ToDateTime(TimeOnly.MinValue);
var toDate = request.To.ToDateTime(TimeOnly.MaxValue);
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var leads = await context.Leads.AsNoTracking()
.OrderByDescending(o => o.CreatedAt)
.Where(l => l.CreatedAt.Date >= fromDate && l.CreatedAt.Date <= toDate)
.Take(request.MaxRecords)
.ToArrayAsync(cancellationToken);
return leads?.Length > 0
? Result.Ok(leads.Select(l => l.ToModel()).ToArray())
: Result.Fail(new Error($"No leads found for the specified date range {request.From} to {request.To}."));
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
}
+96 -4
View File
@@ -21,6 +21,7 @@
<PackageLicenseFile>LICENSE</PackageLicenseFile>
<PackageTags>utility;dotnet</PackageTags>
<PackageIcon>icon.png</PackageIcon>
<UserSecretsId>8a78916e-c86b-4f4b-9f4e-d8e7769b5d23</UserSecretsId>
</PropertyGroup>
<ItemGroup>
@@ -28,6 +29,86 @@
<None Include="..\icon.png" Pack="true" PackagePath="\" />
</ItemGroup>
<!-- Quartz Scheduler-->
<ItemGroup>
<PackageReference Include="OpenTelemetry" Version="1.15.3" />
<PackageReference Include="Quartz" Version="3.18.1" />
<PackageReference Include="Quartz.Plugins" Version="3.18.1" />
<PackageReference Include="Quartz.Plugins.TimeZoneConverter" Version="3.18.1" />
<PackageReference Include="Quartz.Serialization.SystemTextJson" Version="3.18.1" />
<PackageReference Include="Quartz.AspNetCore" Version="3.18.1" />
<PackageReference Include="Quartz.Extensions.DependencyInjection" Version="3.18.1" />
<!-- Global Usings -->
<Using Include="Quartz" />
</ItemGroup>
<!-- Configuration -->
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.8" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.8" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.8" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.8" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.8" />
<!-- Global Usings -->
<Using Include="Microsoft.Extensions.Configuration" />
</ItemGroup>
<!-- Health Checks -->
<ItemGroup>
<PackageReference Include="AspNetCore.HealthChecks.UI.Client" Version="9.0.0" />
<PackageReference Include="AspNetCore.HealthChecks.UI" Version="9.0.0" />
<PackageReference Include="AspNetCore.HealthChecks.UI.Core" Version="9.0.0" />
<PackageReference Include="AspNetCore.HealthChecks.UI.Data" Version="9.0.0" />
<PackageReference Include="AspNetCore.HealthChecks.UI.InMemory.Storage" Version="9.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.8" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.8" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="10.0.8" />
<!-- Global Usings -->
<Using Include="Microsoft.Extensions.Diagnostics.HealthChecks" />
<Using Include="Microsoft.AspNetCore.Diagnostics.HealthChecks" />
</ItemGroup>
<!-- Open Telemetry -->
<ItemGroup>
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.3" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.15.3" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.2" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.15.1" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.15.1" />
<PackageReference Include="OpenTelemetry.Exporter.Console" Version="1.15.3" />
<!-- Global Usings -->
<Using Include="OpenTelemetry.Resources" />
<Using Include="OpenTelemetry.Exporter" />
<Using Include="OpenTelemetry.Logs" />
<Using Include="OpenTelemetry.Metrics" />
<Using Include="OpenTelemetry.Trace" />
</ItemGroup>
<!-- Database -->
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.8">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.8">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
<!-- Global Usings -->
<Using Include="Npgsql" />
<Using Include="Microsoft.EntityFrameworkCore" />
<Using Include="Microsoft.EntityFrameworkCore.Design" />
<Using Include="Microsoft.EntityFrameworkCore.Metadata.Builders" />
</ItemGroup>
<!-- Email -->
<ItemGroup>
<PackageReference Include="MailKit" Version="4.16.0" />
@@ -50,15 +131,26 @@
<!-- Shared Usings -->
<ItemGroup>
<Using Include="Microsoft.AspNetCore.Builder" />
<Using Include="Microsoft.Extensions.Hosting" />
<Using Include="System.Text" />
<Using Include="System.Text.Json" />
<Using Include="System.Threading.Channels" />
<Using Include="System.Collections.ObjectModel" />
<Using Include="System.Diagnostics" />
<Using Include="System.Diagnostics.Metrics" />
<Using Include="Microsoft.Extensions.DependencyInjection" />
<Using Include="System.Security.Cryptography" />
<Using Include="Microsoft.EntityFrameworkCore" />
<Using Include="Microsoft.Extensions.Options" />
<Using Include="Microsoft.Extensions.Logging" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\LiteCharms.Extensions\LiteCharms.Extensions.csproj" />
<ProjectReference Include="..\LiteCharms.Infrastructure\LiteCharms.Infrastructure.csproj" />
<None Update="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
<ItemGroup>
<Folder Include="Shop\Postgres\Migrations\" />
</ItemGroup>
</Project>
@@ -0,0 +1,29 @@
namespace LiteCharms.Features.Mediator;
public sealed class LoggingPipelineBehavior<TRequest, TResponse>(ILogger<LoggingPipelineBehavior<TRequest, TResponse>> logger) :
IPipelineBehavior<TRequest, TResponse?>
where TRequest : IRequest<TResponse>
where TResponse : ResultBase, new()
{
public async ValueTask<TResponse?> Handle(TRequest message, MessageHandlerDelegate<TRequest, TResponse?> next, CancellationToken cancellationToken)
{
TResponse? response = await next(message, cancellationToken);
if (response is null)
logger.LogCritical("{Request} {TypeName} was returned as null", typeof(TRequest).Name, typeof(TRequest).Name);
if(response?.IsFailed == true || response?.Errors?.Any() == true)
{
foreach (var error in response.Errors)
{
if (!string.IsNullOrWhiteSpace(error.Message))
logger.LogWarning("{Request} {Error}", typeof(TRequest).Name, error.Message);
if (error?.Reasons?.Count > 0)
error.Reasons.ForEach(r => logger.LogError("{Request} {Reason}", typeof(TRequest).Name, r.ToString()));
}
}
return response;
}
}
@@ -0,0 +1,12 @@
namespace LiteCharms.Features.Mediator;
public static class MediatorTelemetry
{
public const string ServiceName = "LiteCharms.Mediator";
public static readonly ActivitySource Source = new(ServiceName);
public static readonly Meter Meter = new(ServiceName);
public static readonly Counter<long> RequestCounter = Meter.CreateCounter<long>("mediator_requests_total");
public static readonly Histogram<double> RequestDuration = Meter.CreateHistogram<double>("mediator_request_duration_ms");
}
@@ -0,0 +1,66 @@
namespace LiteCharms.Features.Mediator;
public sealed class TelemetryPipelineBehavior<TRequest, TResponse> :
IPipelineBehavior<TRequest, TResponse?>
where TRequest : IRequest<TResponse>
where TResponse : ResultBase, new()
{
public async ValueTask<TResponse?> Handle(TRequest message, MessageHandlerDelegate<TRequest, TResponse?> next, CancellationToken cancellationToken)
{
var requestName = typeof(TRequest).Name;
using var activity = MediatorTelemetry.Source.StartActivity(requestName);
activity?.SetTag("mediator.request_type", typeof(TRequest).FullName);
var stopWatch = Stopwatch.StartNew();
var status = "Success";
try
{
TResponse? response = await next(message, cancellationToken);
if (response is null)
{
status = "NullResponse";
activity?.SetStatus(ActivityStatusCode.Error, "Response was null");
return response;
}
if (response.IsFailed)
{
status = "Failed";
activity?.SetStatus(ActivityStatusCode.Error, "Request failed");
var firstError = response.Errors.FirstOrDefault()?.Message ?? "Unknown Error";
activity?.SetTag("error.message", firstError);
foreach (var error in response.Errors)
activity?.AddEvent(new ActivityEvent("Result Error", tags: new() { { "message", error.Message } }));
}
else
activity?.SetStatus(ActivityStatusCode.Ok);
return response;
}
catch (Exception ex)
{
status = "Exception";
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
activity?.AddException(ex);
throw;
}
finally
{
stopWatch.Stop();
var tags = new TagList { { "request", requestName }, { "status", status } };
MediatorTelemetry.RequestCounter.Add(1, tags);
MediatorTelemetry.RequestDuration.Record(stopWatch.Elapsed.TotalMilliseconds, tags);
}
}
}
+10
View File
@@ -0,0 +1,10 @@
namespace LiteCharms.Features.Models;
public class DateRange
{
public DateOnly From { get; set; }
public DateOnly To { get; set; }
public int MaxRecords { get; set; }
}
@@ -1,63 +0,0 @@
using LiteCharms.Models;
namespace LiteCharms.Features.Notifications.Commands;
public class CreateNotificationCommand : IRequest<Result<Guid>>
{
public NotificationDirection Direction { get; set; }
public string? Author { get; set; }
public string? Title { get; set; }
public string? Description { get; set; }
public string? Platform { get; set; }
public string? PlatformAddress { get; set; }
public string? CorrelationId { get; set; }
public string? CorrelationIdType { get; set; }
public bool IsInternal { get; set; }
private CreateNotificationCommand(NotificationDirection direction, string author, string title, string description, string platform, string platformAddress, string correlationId, string correlationIdType, bool isInternal)
{
Direction = direction;
Author = author;
Title = title;
Description = description;
Platform = platform;
PlatformAddress = platformAddress;
CorrelationId = correlationId;
CorrelationIdType = correlationIdType;
IsInternal = isInternal;
}
public static CreateNotificationCommand Create(NotificationDirection direction, string author, string title, string description, string platform, string platformAddress, string correlationId, string correlationIdType, bool isInternal)
{
if (string.IsNullOrWhiteSpace(author))
throw new ArgumentException("Author cannot be null or whitespace.", nameof(author));
if (string.IsNullOrWhiteSpace(title))
throw new ArgumentException("Title cannot be null or whitespace.", nameof(title));
if (string.IsNullOrWhiteSpace(description))
throw new ArgumentException("Description cannot be null or whitespace.", nameof(description));
if (string.IsNullOrWhiteSpace(platform))
throw new ArgumentException("Platform cannot be null or whitespace.", nameof(platform));
if (string.IsNullOrWhiteSpace(platformAddress))
throw new ArgumentException("PlatformAddress cannot be null or whitespace.", nameof(platformAddress));
if (string.IsNullOrWhiteSpace(correlationId))
throw new ArgumentException("CorrelationId cannot be null or whitespace.", nameof(correlationId));
if (string.IsNullOrWhiteSpace(correlationIdType))
throw new ArgumentException("CorrelationIdType cannot be null or whitespace.", nameof(correlationIdType));
return new(direction, author, title, description, platform, platformAddress, correlationId, correlationIdType, isInternal);
}
}
@@ -1,35 +0,0 @@
using LiteCharms.Infrastructure.Database;
namespace LiteCharms.Features.Notifications.Commands.Handlers;
public class CreateNotificationCommandHandler(IDbContextFactory<LeadGeneratorDbContext> contextFactory) : IRequestHandler<CreateNotificationCommand, Result<Guid>>
{
public async ValueTask<Result<Guid>> Handle(CreateNotificationCommand request, CancellationToken cancellationToken)
{
try
{
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var newNotification = context.Notifications.Add(new Entities.Notification
{
Direction = request.Direction,
Author = request.Author,
Title = request.Title,
Description = request.Description,
Platform = request.Platform,
PlatformAddress = request.PlatformAddress,
CorrelationId = request.CorrelationId,
CorrelationIdType = request.CorrelationIdType,
IsInternal = request.IsInternal,
});
return newNotification is not null
? Result.Ok(newNotification.Entity.Id)
: Result.Fail(new Error("Failed to create notification"));
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
}
@@ -1,29 +0,0 @@
using LiteCharms.Infrastructure.Database;
namespace LiteCharms.Features.Notifications.Commands.Handlers;
public class UpdateNotificationCommandHandler(IDbContextFactory<LeadGeneratorDbContext> contextFactory) : IRequestHandler<UpdateNotificationCommand, Result>
{
public async ValueTask<Result> Handle(UpdateNotificationCommand request, CancellationToken cancellationToken)
{
try
{
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var notification = await context.Notifications.FirstOrDefaultAsync(n => n.Id == request.NotificationId, cancellationToken);
if(notification is null)
return Result.Fail(new Error($"Notification with id {request.NotificationId} not found."));
notification.Processed = request.Processed;
return await context.SaveChangesAsync(cancellationToken) > 0
? Result.Ok()
: Result.Fail(new Error($"Failed to update notification with id {request.NotificationId}."));
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
}
@@ -1,22 +0,0 @@
namespace LiteCharms.Features.Notifications.Commands;
public class UpdateNotificationCommand : IRequest<Result>
{
public Guid NotificationId { get; set; }
public bool Processed { get; set; }
private UpdateNotificationCommand(Guid notificationId, bool processed)
{
NotificationId = notificationId;
Processed = processed;
}
public static UpdateNotificationCommand Create(Guid notificationId, bool processed)
{
if(notificationId == Guid.Empty)
throw new ArgumentException("Notification ID cannot be empty.", nameof(notificationId));
return new(notificationId, processed);
}
}
@@ -1,18 +0,0 @@
using LiteCharms.Models;
namespace LiteCharms.Features.Notifications.Queries;
public class GetNotificationQuery : IRequest<Result<Notification>>
{
public Guid NotificationId { get; set; }
private GetNotificationQuery(Guid notificationId) => NotificationId = notificationId;
public static GetNotificationQuery Create(Guid notificationId)
{
if (notificationId == Guid.Empty)
throw new ArgumentException("Notification ID is required.", nameof(notificationId));
return new(notificationId);
}
}
@@ -1,30 +0,0 @@
using LiteCharms.Models;
namespace LiteCharms.Features.Notifications.Queries;
public class GetNotificationsQuery : IRequest<Result<Notification[]>>
{
public DateOnly From { get; set; }
public DateOnly To { get; set; }
public int MaxRecords { get; set; }
private GetNotificationsQuery(DateOnly from, DateOnly to, int maxRecords = 1000)
{
From = from;
To = to;
MaxRecords = maxRecords;
}
public static GetNotificationsQuery Create(DateOnly from, DateOnly to, int maxRecords = 1000)
{
if (from > to)
throw new ArgumentException("From date cannot be greater than To date.");
if(maxRecords <= 0)
throw new ArgumentException("MaxRecords must be a positive integer.", nameof(maxRecords));
return new(from, to, maxRecords);
}
}
@@ -1,26 +0,0 @@
using LiteCharms.Extensions;
using LiteCharms.Infrastructure.Database;
using LiteCharms.Models;
namespace LiteCharms.Features.Notifications.Queries.Handlers;
public class GetNotificationQueryHandler(IDbContextFactory<LeadGeneratorDbContext> contextFactory) : IRequestHandler<GetNotificationQuery, Result<Notification>>
{
public async ValueTask<Result<Notification>> Handle(GetNotificationQuery request, CancellationToken cancellationToken)
{
try
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var notification = await context.Notifications.FindAsync(new object[] { request.NotificationId }, cancellationToken);
return notification is not null
? Result.Ok(notification.ToModel())
: Result.Fail<Notification>(new Error($"Notification with id {request.NotificationId} not found"));
}
catch (Exception ex)
{
return Result.Fail<Notification>(new Error(ex.Message).CausedBy(ex));
}
}
}
@@ -1,33 +0,0 @@
using LiteCharms.Extensions;
using LiteCharms.Infrastructure.Database;
using LiteCharms.Models;
namespace LiteCharms.Features.Notifications.Queries.Handlers;
public class GetNotificationsQueryHandler(IDbContextFactory<LeadGeneratorDbContext> contextFactory) : IRequestHandler<GetNotificationsQuery, Result<Notification[]>>
{
public async ValueTask<Result<Notification[]>> Handle(GetNotificationsQuery request, CancellationToken cancellationToken)
{
try
{
var fromDate = request.From.ToDateTime(TimeOnly.MinValue);
var toDate = request.To.ToDateTime(TimeOnly.MaxValue);
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var notifications = await context.Notifications.AsNoTracking()
.Where(n => n.CreatedAt >= fromDate && n.CreatedAt <= toDate)
.OrderByDescending(n => n.CreatedAt)
.Take(request.MaxRecords)
.ToArrayAsync(cancellationToken);
return notifications?.Length > 0
? Result.Ok(notifications.Select(n => n.ToModel()).ToArray())
: Result.Fail(new Error($"No notifications found for the specified date range {request.From} to {request.To}."));
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
}
@@ -1,28 +0,0 @@
namespace LiteCharms.Features.Orders.Commands;
public class CreateOrderCommand : IRequest<Result<Guid>>
{
public Guid CustomerId { get; set; }
public Guid ShoppingCartId { get; set; }
public Guid? QuoteId { get; set; }
private CreateOrderCommand(Guid customerId, Guid shoppingCartId, Guid? quoteId = null)
{
CustomerId = customerId;
ShoppingCartId = shoppingCartId;
QuoteId = quoteId;
}
public static CreateOrderCommand Create(Guid customerId, Guid shoppingCartId, Guid? quoteId = null)
{
if (customerId == Guid.Empty)
throw new ArgumentException("CustomerId is required.", nameof(customerId));
if (shoppingCartId == Guid.Empty)
throw new ArgumentException("ShoppingCartId is required.", nameof(shoppingCartId));
return new(customerId, shoppingCartId, quoteId);
}
}
@@ -1,39 +0,0 @@
using LiteCharms.Infrastructure.Database;
namespace LiteCharms.Features.Orders.Commands.Handlers;
public class CreateOrderCommandHandler(IDbContextFactory<LeadGeneratorDbContext> contextFactory) : IRequestHandler<CreateOrderCommand, Result<Guid>>
{
public async ValueTask<Result<Guid>> Handle(CreateOrderCommand request, CancellationToken cancellationToken)
{
try
{
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
if(!await context.Customers.AnyAsync(c => c.Id == request.CustomerId, cancellationToken))
return Result.Fail<Guid>(new Error($"Customer {request.CustomerId} does not exist."));
if(!await context.ShoppingCarts.AnyAsync(sc => sc.Id == request.ShoppingCartId, cancellationToken))
return Result.Fail<Guid>(new Error($"Shopping cart {request.ShoppingCartId} does not exist."));
if(request.QuoteId.HasValue && !await context.Quotes.AnyAsync(q => q.Id == request.QuoteId.Value, cancellationToken))
return Result.Fail<Guid>(new Error($"Quote {request.QuoteId.Value} does not exist."));
var newOrder = context.Orders.Add(new Entities.Order
{
CustomerId = request.CustomerId,
ShoppingCartId = request.ShoppingCartId,
QuoteId = request.QuoteId,
CreatedAt = DateTime.UtcNow
});
return await context.SaveChangesAsync(cancellationToken) > 0
? Result.Ok(newOrder.Entity.Id)
: Result.Fail<Guid>(new Error($"Failed to create customer {request.CustomerId} order using shopping cart {request.ShoppingCartId}."));
}
catch (Exception ex)
{
return Result.Fail<Guid>(new Error(ex.Message).CausedBy(ex));
}
}
}
@@ -1,29 +0,0 @@
using LiteCharms.Infrastructure.Database;
namespace LiteCharms.Features.Orders.Commands.Handlers;
public class UpdateOrderStatusCommandHandler(IDbContextFactory<LeadGeneratorDbContext> contextFactory) : IRequestHandler<UpdateOrderStatusCommand, Result>
{
public async ValueTask<Result> Handle(UpdateOrderStatusCommand request, CancellationToken cancellationToken)
{
try
{
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var order = await context.Orders.FirstOrDefaultAsync(o => o.Id == request.OrderId, cancellationToken);
if (order is null)
return Result.Fail(new Error($"Order {request.OrderId} not found"));
order.Status = request.Status;
return await context.SaveChangesAsync(cancellationToken) > 0
? Result.Ok()
: Result.Fail(new Error($"Failed to update order {request.OrderId}"));
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
}
@@ -1,30 +0,0 @@
using LiteCharms.Models;
namespace LiteCharms.Features.Orders.Commands;
public class UpdateOrderStatusCommand : IRequest<Result>
{
public Guid OrderId { get; set; }
public OrderStatus Status { get; set; }
public string? Note { get; set; }
private UpdateOrderStatusCommand(Guid orderId, OrderStatus status, string? note)
{
OrderId = orderId;
Status = status;
Note = note;
}
public static UpdateOrderStatusCommand Create(Guid orderId, OrderStatus status, string? note)
{
if (orderId == Guid.Empty)
throw new ArgumentException("OrderId is required.", nameof(orderId));
if (!Enum.IsDefined(typeof(OrderStatus), status))
throw new ArgumentException("Invalid order status.", nameof(status));
return new(orderId, status, note);
}
}
@@ -1,18 +0,0 @@
using LiteCharms.Models;
namespace LiteCharms.Features.Orders.Queries;
public class GetCustomerOrdersQuery : IRequest<Result<Order[]>>
{
public Guid CustomerId { get; }
private GetCustomerOrdersQuery(Guid customerId) => CustomerId = customerId;
public static GetCustomerOrdersQuery Create(Guid customerId)
{
if (customerId == Guid.Empty)
throw new ArgumentException("CustomerId is required.", nameof(customerId));
return new(customerId);
}
}
@@ -1,27 +0,0 @@
using LiteCharms.Models;
namespace LiteCharms.Features.Orders.Queries;
public class GetOrderRefundQuery : IRequest<Result<OrderRefund>>
{
public Guid OrderId { get; set; }
public Guid OrderRefundId { get; set; }
private GetOrderRefundQuery(Guid orderId, Guid orderRefundId)
{
OrderId = orderId;
OrderRefundId = orderRefundId;
}
public static GetOrderRefundQuery Create(Guid orderId, Guid orderRefundId)
{
if (orderId == Guid.Empty)
throw new ArgumentException("OrderId is required.", nameof(orderId));
if (orderRefundId == Guid.Empty)
throw new ArgumentException("OrderRefundId is required.", nameof(orderRefundId));
return new(orderId, orderRefundId);
}
}
@@ -1,30 +0,0 @@
using LiteCharms.Models;
namespace LiteCharms.Features.Orders.Queries;
public class GetOrdersQuery : IRequest<Result<Order[]>>
{
public DateOnly From { get; set; }
public DateOnly To { get; set; }
public int MaxRecords { get; set; }
private GetOrdersQuery(DateOnly from, DateOnly to, int maxRecords = 1000)
{
From = from;
To = to;
MaxRecords = maxRecords;
}
public static GetOrdersQuery Create(DateOnly from, DateOnly to, int maxRecords = 1000)
{
if (from > to)
throw new ArgumentException("From date cannot be greater than To date.");
if(maxRecords <= 0)
throw new ArgumentException("MaxRecords must be a positive integer.");
return new(from, to, maxRecords);
}
}
@@ -1,32 +0,0 @@
using LiteCharms.Extensions;
using LiteCharms.Infrastructure.Database;
using LiteCharms.Models;
namespace LiteCharms.Features.Orders.Queries.Handlers;
public class GetCustomerOrdersQueryHandler(IDbContextFactory<LeadGeneratorDbContext> contextFactory) : IRequestHandler<GetCustomerOrdersQuery, Result<Order[]>>
{
public async ValueTask<Result<Order[]>> Handle(GetCustomerOrdersQuery request, CancellationToken cancellationToken)
{
try
{
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
if(!await context.Customers.AsNoTracking().AnyAsync(c => c.Id == request.CustomerId, cancellationToken))
return Result.Fail<Order[]>(new Error($"Customer with Id {request.CustomerId} does not exist."));
var orders = await context.Orders.AsNoTracking()
.OrderByDescending(o => o.CreatedAt)
.Where(o => o.CustomerId == request.CustomerId)
.ToArrayAsync(cancellationToken);
return orders?.Length > 0
? Result.Ok(orders.Select(o => o.ToModel()).ToArray())
: Result.Fail<Order[]>(new Error($"No orders found for customer with Id {request.CustomerId}."));
}
catch (Exception ex)
{
return Result.Fail<Order[]>(new Error(ex.Message).CausedBy(ex));
}
}
}
@@ -1,27 +0,0 @@
using LiteCharms.Extensions;
using LiteCharms.Infrastructure.Database;
using LiteCharms.Models;
namespace LiteCharms.Features.Orders.Queries.Handlers;
public class GetOrderRefundQueryHandler(IDbContextFactory<LeadGeneratorDbContext> contextFactory) : IRequestHandler<GetOrderRefundQuery, Result<OrderRefund>>
{
public async ValueTask<Result<OrderRefund>> Handle(GetOrderRefundQuery request, CancellationToken cancellationToken)
{
try
{
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var refund = await context.OrderRefunds.AsNoTracking()
.FirstOrDefaultAsync(r => r.OrderId == request.OrderId && r.Id == request.OrderRefundId, cancellationToken);
return refund is not null
? Result.Ok(refund.ToModel())
: Result.Fail<OrderRefund>(new Error($"Refund {request.OrderRefundId} not found for the given OrderId: {request.OrderId}"));
}
catch (Exception ex)
{
return Result.Fail<OrderRefund>(new Error(ex.Message).CausedBy(ex));
}
}
}
@@ -1,33 +0,0 @@
using LiteCharms.Extensions;
using LiteCharms.Infrastructure.Database;
using LiteCharms.Models;
namespace LiteCharms.Features.Orders.Queries.Handlers;
public class GetOrdersQueryHandler(IDbContextFactory<LeadGeneratorDbContext> contextFactory) : IRequestHandler<GetOrdersQuery, Result<Order[]>>
{
public async ValueTask<Result<Order[]>> Handle(GetOrdersQuery request, CancellationToken cancellationToken)
{
try
{
var fromDate = request.From.ToDateTime(TimeOnly.MinValue);
var toDate = request.To.ToDateTime(TimeOnly.MaxValue);
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var orders = await context.Orders
.OrderByDescending(o => o.CreatedAt)
.Where(o => o.CreatedAt >= fromDate && o.CreatedAt <= toDate)
.Take(request.MaxRecords)
.ToArrayAsync(cancellationToken);
return orders?.Length > 0
? Result.Ok(orders.Select(o => o.ToModel()).ToArray())
: Result.Fail<Order[]>(new Error($"No orders found for the specified date range {request.From} - {request.To}."));
}
catch (Exception ex)
{
return Result.Fail<Order[]>(new Error(ex.Message).CausedBy(ex));
}
}
}
@@ -1,18 +0,0 @@
using LiteCharms.Models;
namespace LiteCharms.Features.Products.Queries;
public class GetProductPriceQuery : IRequest<Result<ProductPrice>>
{
public Guid ProductId { get; set; }
private GetProductPriceQuery(Guid productId) => ProductId = productId;
public static GetProductPriceQuery Create(Guid productId)
{
if (productId == Guid.Empty)
throw new ArgumentException("ProductId is required.", nameof(productId));
return new(productId);
}
}
@@ -1,18 +0,0 @@
using LiteCharms.Models;
namespace LiteCharms.Features.Products.Queries;
public class GetProductPricesQuery : IRequest<Result<ProductPrice[]>>
{
public int MaxRecords { get; set; }
private GetProductPricesQuery(int maxRecords = 1000) => MaxRecords = maxRecords;
public static GetProductPricesQuery Create(int maxRecords = 1000)
{
if (maxRecords <= 0)
throw new ArgumentOutOfRangeException(nameof(maxRecords), "MaxRecords must be greater than zero.");
return new(maxRecords);
}
}
@@ -1,18 +0,0 @@
using LiteCharms.Models;
namespace LiteCharms.Features.Products.Queries;
public class GetProductQuery : IRequest<Result<Product>>
{
public Guid ProductId { get; set; }
private GetProductQuery(Guid productId) => ProductId = productId;
public static GetProductQuery Create(Guid productId)
{
if(productId == Guid.Empty)
throw new ArgumentException("Product ID is required.", nameof(productId));
return new(productId);
}
}
@@ -1,18 +0,0 @@
using LiteCharms.Models;
namespace LiteCharms.Features.Products.Queries;
public class GetProductsQuery : IRequest<Result<Product[]>>
{
public int MaxRecords { get; set; }
private GetProductsQuery(int maxRecords = 1000) => MaxRecords = maxRecords;
public static GetProductsQuery Create(int maxRecords = 1000)
{
if (maxRecords <= 0)
throw new ArgumentException("MaxRecords must be a positive integer.");
return new(maxRecords);
}
}
@@ -1,32 +0,0 @@
using LiteCharms.Extensions;
using LiteCharms.Infrastructure.Database;
using LiteCharms.Models;
namespace LiteCharms.Features.Products.Queries.Handlers;
public class GetProductPriceQueryHandler(IDbContextFactory<LeadGeneratorDbContext> contextFactory) : IRequestHandler<GetProductPriceQuery, Result<ProductPrice>>
{
public async ValueTask<Result<ProductPrice>> Handle(GetProductPriceQuery request, CancellationToken cancellationToken)
{
try
{
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
if(!await context.Products.AnyAsync(p => p.Id == request.ProductId, cancellationToken))
return Result.Fail<ProductPrice>(new Error($"Product {request.ProductId} not found."));
var productPrice = await context.ProductPrices.AsNoTracking()
.Where(pp => pp.ProductId == request.ProductId && pp.Active)
.OrderByDescending(pp => pp.CreatedAt)
.FirstOrDefaultAsync(cancellationToken);
return productPrice is not null
? Result.Ok(productPrice.ToModel())
: Result.Fail<ProductPrice>(new Error($"Product price {request.ProductId} not found."));
}
catch (Exception ex)
{
return Result.Fail<ProductPrice>(new Error(ex.Message).CausedBy(ex));
}
}
}
@@ -1,27 +0,0 @@
using LiteCharms.Extensions;
using LiteCharms.Infrastructure.Database;
using LiteCharms.Models;
namespace LiteCharms.Features.Products.Queries.Handlers;
public class GetProductPricesQueryHandler(IDbContextFactory<LeadGeneratorDbContext> contextFactory) : IRequestHandler<GetProductPricesQuery, Result<ProductPrice[]>>
{
public async ValueTask<Result<ProductPrice[]>> Handle(GetProductPricesQuery request, CancellationToken cancellationToken)
{
try
{
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var products = await context.ProductPrices.AsNoTracking()
.OrderByDescending(o => o.Id)
.Take(request.MaxRecords)
.ToArrayAsync(cancellationToken);
return Result.Ok(products.Select(p => p.ToModel()).ToArray());
}
catch (Exception ex)
{
return Result.Fail<ProductPrice[]>(new Error(ex.Message).CausedBy(ex));
}
}
}
@@ -1,26 +0,0 @@
using LiteCharms.Extensions;
using LiteCharms.Infrastructure.Database;
using LiteCharms.Models;
namespace LiteCharms.Features.Products.Queries.Handlers;
public class GetProductQueryHandler(IDbContextFactory<LeadGeneratorDbContext> contextFactory) : IRequestHandler<GetProductQuery, Result<Product>>
{
public async ValueTask<Result<Product>> Handle(GetProductQuery request, CancellationToken cancellationToken)
{
try
{
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var product = await context.Products.FirstOrDefaultAsync(p => p.Id == request.ProductId, cancellationToken);
return product is not null
? Result.Ok(product.ToModel())
: Result.Fail<Product>(new Error($"Product with ID {request.ProductId} not found."));
}
catch (Exception ex)
{
return Result.Fail<Product>(new Error(ex.Message).CausedBy(ex));
}
}
}
@@ -1,27 +0,0 @@
using LiteCharms.Extensions;
using LiteCharms.Infrastructure.Database;
using LiteCharms.Models;
namespace LiteCharms.Features.Products.Queries.Handlers;
public class GetProductsQueryHandler(IDbContextFactory<LeadGeneratorDbContext> contextFactory) : IRequestHandler<GetProductsQuery, Result<Product[]>>
{
public async ValueTask<Result<Product[]>> Handle(GetProductsQuery request, CancellationToken cancellationToken)
{
try
{
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var products = await context.Products.AsNoTracking()
.OrderByDescending(o => o.Id)
.Take(request.MaxRecords)
.ToArrayAsync(cancellationToken);
return Result.Ok(products.Select(p => p.ToModel()).ToArray());
}
catch (Exception ex)
{
return Result.Fail<Product[]>(new Error(ex.Message).CausedBy(ex));
}
}
}
@@ -1,4 +1,6 @@
namespace LiteCharms.Abstractions;
using LiteCharms.Features.Abstractions;
namespace LiteCharms.Features.Quartz.Abstractions;
public interface IJobOrchestrator
{
@@ -1,7 +1,7 @@
using LiteCharms.Abstractions;
using static LiteCharms.Abstractions.Timezones;
using LiteCharms.Features.Abstractions;
using LiteCharms.Features.Quartz.Abstractions;
namespace LiteCharms.Infrastructure.Quartz;
namespace LiteCharms.Features.Quartz;
public class JobOrchestrator(ISchedulerFactory schedulerFactory) : IJobOrchestrator
{
@@ -35,7 +35,7 @@ public class JobOrchestrator(ISchedulerFactory schedulerFactory) : IJobOrchestra
var chainedJobGroup = "scheduled-jobs";
var scheduler = await schedulerFactory.GetScheduler(cancellationToken);
var jobKey = new JobKey($"{notification.Name.ToLower()}-{notification.CorrelationId.ToLower()}", chainedJobGroup);
var jobKey = new JobKey($"{notification.Name.ToLower()}", chainedJobGroup);
var triggerKey = new TriggerKey($"{jobKey.Name}-trigger", chainedJobGroup);
var job = JobBuilder.Create<MediatorJob<TNotification>>()
@@ -46,12 +46,12 @@ public class JobOrchestrator(ISchedulerFactory schedulerFactory) : IJobOrchestra
.StoreDurably()
.Build();
var now = SouthAfricanTimeZone.UtcNow();
var now = DateTime.UtcNow;
var trigger = global::Quartz.TriggerBuilder.Create()
.WithIdentity(triggerKey)
.WithDescription($"Scheduled via Main Job at {now:g}")
.WithCronSchedule(cronExpression, cron => cron.InTimeZone(SouthAfricanTimeZone)
.WithDescription($"Scheduled via Main Job at {now:g} UTC")
.WithCronSchedule(cronExpression, cron => cron
.WithMisfireHandlingInstructionFireAndProceed())
.StartAt(now)
.Build();
+25
View File
@@ -0,0 +1,25 @@
using LiteCharms.Features.Abstractions;
using LiteCharms.Features.Mediator;
namespace LiteCharms.Features.Quartz;
[DisallowConcurrentExecution]
public class MediatorJob<TNotification>(IMediator mediator) : IJob where TNotification : IEvent
{
public async Task Execute(IJobExecutionContext context)
{
var data = context.MergedJobDataMap["Payload"] as string;
if (string.IsNullOrWhiteSpace(data)) return;
var notification = JsonSerializer.Deserialize<TNotification>(data);
if (notification is null) return;
using var activity = MediatorTelemetry.Source.StartActivity($"Quartz: {typeof(TNotification).Name}");
activity?.SetTag("event.correlation_id", notification.CorrelationId);
await mediator.Publish(notification, context.CancellationToken);
}
}
@@ -1,4 +1,4 @@
namespace LiteCharms.Infrastructure.Quartz;
namespace LiteCharms.Features.Quartz;
public class RetryJobListener : IJobListener
{
@@ -1,25 +0,0 @@
namespace LiteCharms.Features.Quotes.Commands;
public class AssignQuoteToOrderCommand : IRequest<Result>
{
public Guid OrderId { get; set; }
public Guid QuoteId { get; set; }
private AssignQuoteToOrderCommand(Guid orderId, Guid quoteId)
{
OrderId = orderId;
QuoteId = quoteId;
}
public static AssignQuoteToOrderCommand Create(Guid orderId, Guid quoteId)
{
if(orderId == Guid.Empty)
throw new ArgumentException("Order ID is required.", nameof(orderId));
if(quoteId == Guid.Empty)
throw new ArgumentException("Quote ID is required.", nameof(quoteId));
return new AssignQuoteToOrderCommand(orderId, quoteId);
}
}

Some files were not shown because too many files have changed in this diff Show More