Compare commits
97
Commits
604d21162a
...
1.43.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b70d9559b0 | ||
|
|
032b9e1818 | ||
|
|
424c1c6f8c | ||
|
|
81d5e8f07c | ||
|
|
7d5e9a18d8 | ||
|
|
20a53942b5 | ||
|
|
3656223b5f | ||
|
|
9edb2aa4aa | ||
|
|
ccf30ac36b | ||
|
|
6ed023f2cf | ||
|
|
d6fdf1b9c8 | ||
|
|
2c9f5a846c | ||
|
|
89a343a85f | ||
|
|
41f7c05be3 | ||
|
|
52d204e286 | ||
|
|
1a03355e84 | ||
|
|
f245bc94e1 | ||
|
|
7743c3178e | ||
|
|
da141311ff | ||
|
|
ab3d8e6e9a | ||
|
|
97bde73777 | ||
|
|
db4c348288 | ||
|
|
a65e926a53 | ||
|
|
6683234642 | ||
|
|
1471d9e597 | ||
|
|
6ddbb9479a | ||
|
|
e978aa17f8 | ||
|
|
6c7349a0f8 | ||
|
|
a31f75c5ef | ||
|
|
e97fd6cd3f | ||
|
|
7f4246ac63 | ||
|
|
184c7c252a | ||
|
|
dfc62c8fe1 | ||
|
|
bfe8c458d6 | ||
|
|
0f91f102e5 | ||
|
|
e6e0475db1 | ||
|
|
be9c83c8a3 | ||
|
|
65687d231e | ||
|
|
5090c60797 | ||
|
|
4523ef6151 | ||
|
|
9432252e15 | ||
|
|
36b3656886 | ||
|
|
47111a1a3a | ||
|
|
f606b8fd3c | ||
|
|
2610275bef | ||
|
|
134d8429c0 | ||
|
|
42001998d6 | ||
|
|
a42c51d7b2 | ||
|
|
6eb3d50375 | ||
|
|
26075cd9a7 | ||
|
|
4deb732804 | ||
|
|
cecd9f90e9 | ||
|
|
73ba41beaf | ||
|
|
e8e9a85c57 | ||
|
|
394429677e | ||
|
|
20d9387d0b | ||
|
|
32d1019eb5 | ||
|
|
9f6d0ccaa0 | ||
|
|
275befc258 | ||
|
|
1acbc4d213 | ||
|
|
193496bbf9 | ||
|
|
8c99668fac | ||
|
|
a015801423 | ||
|
|
ad44f46204 | ||
|
|
9d64b5e76d | ||
|
|
49d999c1e3 | ||
|
|
fd6b8cd965 | ||
|
|
9ed4777a18 | ||
|
|
ff34326a53 | ||
|
|
0cf44f68cc | ||
|
|
6db6228bc9 | ||
|
|
41ed5a4288 | ||
|
|
61118c6223 | ||
|
|
bbcba5e06c | ||
|
|
bd852b30c3 | ||
|
|
502cc326dd | ||
|
|
37e2116aaf | ||
|
|
4675d4c5fc | ||
|
|
60980292a2 | ||
|
|
f80bb2fff9 | ||
|
|
df1e31cc86 | ||
|
|
6767906b0d | ||
|
|
8cb1943ac8 | ||
|
|
a344af4498 | ||
|
|
c95efd1faf | ||
|
|
3065d8e27a | ||
|
|
d998aac7c4 | ||
|
|
33633502e2 | ||
|
|
fe1d78342b | ||
|
|
acc0d44c7c | ||
|
|
c6ce9c85df | ||
|
|
6ca87413d6 | ||
|
|
4321b03735 | ||
|
|
83f51c6a23 | ||
|
|
4b822c63b2 | ||
|
|
f5c9557f59 | ||
|
|
33112dcf79 |
+51
@@ -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
@@ -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,28 +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.RefundId).IsRequired(false);
|
||||
builder.Property(f => f.ProductPriceId).IsRequired();
|
||||
builder.Property(f => f.Status).HasConversion<int>().IsRequired();
|
||||
builder.Property(f => f.Notes).HasColumnType("jsonb").IsRequired(false);
|
||||
|
||||
builder.HasOne(f => f.ProductPrice)
|
||||
.WithMany()
|
||||
.HasForeignKey(f => f.ProductPriceId)
|
||||
.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,11 +0,0 @@
|
||||
using LiteCharms.Entities.Configuration;
|
||||
|
||||
namespace LiteCharms.Entities;
|
||||
|
||||
[EntityTypeConfiguration<CustomerConfiguration, Customer>]
|
||||
public class Customer : Models.Customer
|
||||
{
|
||||
public virtual ICollection<Lead>? Leads { get; set; }
|
||||
|
||||
public virtual ICollection<Order>? Orders { get; set; }
|
||||
}
|
||||
@@ -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.2</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>
|
||||
@@ -1,13 +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 ProductPrice? ProductPrice { get; set; }
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
using LiteCharms.Models;
|
||||
|
||||
namespace LiteCharms.Extensions;
|
||||
|
||||
public static class EntityModeMappers
|
||||
{
|
||||
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,
|
||||
GoogleClickId = entity.GoogleClickId,
|
||||
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,
|
||||
ProductPriceId = entity.ProductPriceId,
|
||||
Notes = entity.Notes,
|
||||
RefundId = entity.RefundId
|
||||
};
|
||||
|
||||
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,97 +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.Extensions</PackageId>
|
||||
<Version>1.0.7</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>
|
||||
|
||||
<!-- Shared Usings -->
|
||||
<ItemGroup>
|
||||
<Using Include="Microsoft.AspNetCore.Builder" />
|
||||
<Using Include="Microsoft.Extensions.Configuration" />
|
||||
<Using Include="Microsoft.Extensions.DependencyInjection" />
|
||||
<Using Include="Microsoft.Extensions.Logging" />
|
||||
</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" />
|
||||
|
||||
<!-- 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" />
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\LiteCharms.Entities\LiteCharms.Entities.csproj" />
|
||||
<ProjectReference Include="..\LiteCharms.Infrastructure\LiteCharms.Infrastructure.csproj" />
|
||||
<ProjectReference Include="..\LiteCharms.Models\LiteCharms.Models.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,37 @@
|
||||
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())
|
||||
.AddUserSecrets<CommonFixture>()
|
||||
.AddJsonFile(Path.Combine(Directory.GetCurrentDirectory(), "appsettings.json"), optional: true, reloadOnChange: true)
|
||||
.AddEnvironmentVariables()
|
||||
.Build();
|
||||
|
||||
Services = new ServiceCollection()
|
||||
.AddMediator()
|
||||
.AddLogging()
|
||||
.AddShopServices()
|
||||
.AddEmailServiceBus()
|
||||
.AddGarageS3(Configuration)
|
||||
.AddShopDatabase(Configuration)
|
||||
.AddEmailServices(Configuration)
|
||||
.AddSingleton(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.1">
|
||||
<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>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,65 @@
|
||||
using LiteCharms.Features.Models;
|
||||
using LiteCharms.Features.Shop.Notifications;
|
||||
using LiteCharms.Features.Shop.Notifications.Events;
|
||||
using static LiteCharms.Features.Extensions.Email;
|
||||
|
||||
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 = $"{ShopEmailFromName} [Test]",
|
||||
RecipientAddress = 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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetNotifications_ShouldReturn_AllNotifications()
|
||||
{
|
||||
DateRange range = new()
|
||||
{
|
||||
From = DateOnly.FromDateTime(new DateTime(2026, 04, 01, 0, 0, 0, DateTimeKind.Utc)),
|
||||
To = DateOnly.FromDateTime(DateTime.UtcNow),
|
||||
MaxRecords = 10
|
||||
};
|
||||
|
||||
var getResult = await notificationService.GetNotificationsAsync(range);
|
||||
|
||||
Assert.True(getResult.IsSuccess);
|
||||
|
||||
foreach (var error in getResult.Errors) output.WriteLine(error.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessEmailNotificationsEvent_ShouldSucceed()
|
||||
{
|
||||
var notification = ProcessEmailNotificationsEvent.Create();
|
||||
|
||||
await fixture.Mediator.Publish(notification);
|
||||
|
||||
Assert.True(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using LiteCharms.Features.Shop.Products;
|
||||
|
||||
namespace LiteCharms.Features.Tests;
|
||||
|
||||
public class ProductsFeatureTests(CommonFixture fixture, ITestOutputHelper output) : IClassFixture<CommonFixture>
|
||||
{
|
||||
[Fact]
|
||||
public async Task GetProductsAsync_ReturnsProducts()
|
||||
{
|
||||
var productService = fixture.Services.GetRequiredService<ProductService>();
|
||||
|
||||
var result = await productService.GetProductsAsync();
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.NotNull(result.Value);
|
||||
|
||||
output.WriteLine($"Retrieved {result.Value.Length} products.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using LiteCharms.Features.S3.Abstractions;
|
||||
|
||||
namespace LiteCharms.Features.Tests;
|
||||
|
||||
public class S3ServiceFeatureTests(CommonFixture fixture, ITestOutputHelper output) : IClassFixture<CommonFixture>
|
||||
{
|
||||
[Fact]
|
||||
public async Task BookshopS3Service_MustReturnUrl()
|
||||
{
|
||||
var service = fixture.Services.GetKeyedService<IS3Service>(S3.Constants.BookshopBucketName);
|
||||
|
||||
var fileName = "appsettings.json";
|
||||
|
||||
string path = Path.Combine(Directory.GetCurrentDirectory(), fileName);
|
||||
|
||||
Assert.True(File.Exists(path));
|
||||
|
||||
var stream = File.OpenRead(path);
|
||||
|
||||
var result = await service!.UploadFileAsync(fileName, stream, MimeKit.MimeTypes.GetMimeType(fileName));
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.NotNull(result.Value);
|
||||
Assert.NotEmpty(result.Value);
|
||||
|
||||
output.WriteLine(result.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BookshopS3Service_MustDeleteFile()
|
||||
{
|
||||
var service = fixture.Services.GetKeyedService<IS3Service>(S3.Constants.BookshopBucketName);
|
||||
|
||||
var fileName = "appsettings.json";
|
||||
|
||||
string path = Path.Combine(Directory.GetCurrentDirectory(), fileName);
|
||||
|
||||
Assert.True(File.Exists(path));
|
||||
|
||||
var stream = File.OpenRead(path);
|
||||
|
||||
var uploadResult = await service!.UploadFileAsync(fileName, stream, MimeKit.MimeTypes.GetMimeType(fileName));
|
||||
|
||||
Assert.True(uploadResult.IsSuccess);
|
||||
Assert.NotNull(uploadResult.Value);
|
||||
Assert.NotEmpty(uploadResult.Value);
|
||||
|
||||
var fileKey = uploadResult.Value.Split('/').Last();
|
||||
|
||||
var deleteResult = await service!.DeleteFileAsync(fileKey);
|
||||
|
||||
Assert.True(deleteResult.IsSuccess);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"BookshopS3Settings": {
|
||||
"ServiceUrl": "http://192.168.1.177:30900",
|
||||
"Region": "garage",
|
||||
"BucketName": "bookshop",
|
||||
"CdnBaseUrl": "https://bookshop.cdn.khongisa.co.za"
|
||||
},
|
||||
"BookshopQuotesS3Settings": {
|
||||
"ServiceUrl": "http://192.168.1.177:30900",
|
||||
"Region": "garage",
|
||||
"BucketName": "bookshop.quotes",
|
||||
"CdnBaseUrl": "https://bookshop.quotes.cdn.khongisa.co.za"
|
||||
},
|
||||
"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();
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace LiteCharms.Features.Abstractions;
|
||||
|
||||
public interface IEvent : INotification
|
||||
{
|
||||
Guid Id { get; set; }
|
||||
|
||||
string Name { get; set; }
|
||||
|
||||
DateTimeOffset EnqueueAt { get; set; }
|
||||
|
||||
string CorrelationId { get; set; }
|
||||
}
|
||||
@@ -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,38 +0,0 @@
|
||||
using LiteCharms.Infrastructure.Database;
|
||||
|
||||
namespace LiteCharms.Features.Customers.Commands.Handlers;
|
||||
|
||||
public class RefundCustomerCommandHandler(IDbContextFactory<LeadGeneratorDbContext> contextFactory) : IRequestHandler<RefundCustomerCommand, Result<Guid>>
|
||||
{
|
||||
public async ValueTask<Result<Guid>> Handle(RefundCustomerCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
if(!await context.Orders.AnyAsync(o => o.Id == request.OrderId, cancellationToken))
|
||||
return Result.Fail<Guid>(new Error($"Order with Id: {request.OrderId} does not exist"));
|
||||
|
||||
if (!await context.Customers.AnyAsync(c => c.Id == request.CustomerId, cancellationToken))
|
||||
return Result.Fail<Guid>(new Error($"Customer with Id: {request.CustomerId} does not exist"));
|
||||
|
||||
if(!await context.Orders.AnyAsync(o => o.Id == request.OrderId && o.CustomerId == request.CustomerId, cancellationToken))
|
||||
return Result.Fail<Guid>(new Error($"Order with Id: {request.OrderId} does not belong to Customer with Id: {request.CustomerId}"));
|
||||
|
||||
var refund = context.OrderRefunds.Add(new Entities.OrderRefund
|
||||
{
|
||||
OrderId = request.OrderId,
|
||||
Reason = request.Reason,
|
||||
Amount = request.Amount
|
||||
});
|
||||
|
||||
return await context.SaveChangesAsync(cancellationToken) > 0
|
||||
? Result.Ok(refund.Entity.Id)
|
||||
: Result.Fail<Guid>(new Error($"Failed to create refund for OrderId: {request.OrderId}"));
|
||||
}
|
||||
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,38 +0,0 @@
|
||||
namespace LiteCharms.Features.Customers.Commands;
|
||||
|
||||
public class RefundCustomerCommand : IRequest<Result<Guid>>
|
||||
{
|
||||
public Guid OrderId { get; set; }
|
||||
|
||||
public Guid CustomerId { get; set; }
|
||||
|
||||
public string? Reason { get; set; }
|
||||
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
private RefundCustomerCommand(Guid orderId, Guid customerId, string? reason, decimal amount)
|
||||
{
|
||||
OrderId = orderId;
|
||||
CustomerId = customerId;
|
||||
Reason = reason;
|
||||
Amount = amount;
|
||||
CustomerId = customerId;
|
||||
}
|
||||
|
||||
public static RefundCustomerCommand Create(Guid orderId, Guid customerId, string? reason, decimal amount)
|
||||
{
|
||||
if (orderId == Guid.Empty)
|
||||
throw new ArgumentException("OrderId is required", nameof(orderId));
|
||||
|
||||
if (customerId == Guid.Empty)
|
||||
throw new ArgumentException("CustomerId is required", nameof(customerId));
|
||||
|
||||
if (amount <= 0)
|
||||
throw new ArgumentException("Amount must be greater than zero", nameof(amount));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(reason))
|
||||
throw new ArgumentException("Reason is required", nameof(reason));
|
||||
|
||||
return new(orderId, customerId, reason, amount);
|
||||
}
|
||||
}
|
||||
@@ -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 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,18 +0,0 @@
|
||||
using LiteCharms.Models;
|
||||
|
||||
namespace LiteCharms.Features.Customers.Queries;
|
||||
|
||||
public class GetCustomerRefundsQuery : IRequest<Result<OrderRefund[]>>
|
||||
{
|
||||
public Guid CustomerId { get; set; }
|
||||
|
||||
private GetCustomerRefundsQuery(Guid customerId) => CustomerId = customerId;
|
||||
|
||||
public static GetCustomerRefundsQuery Create(Guid customerId)
|
||||
{
|
||||
if (customerId == Guid.Empty)
|
||||
throw new ArgumentException("CustomerId is required.", nameof(customerId));
|
||||
|
||||
return new(customerId);
|
||||
}
|
||||
}
|
||||
@@ -1,24 +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; }
|
||||
|
||||
private GetCustomersQuery(DateOnly from, DateOnly to)
|
||||
{
|
||||
From = from;
|
||||
To = to;
|
||||
}
|
||||
|
||||
public static GetCustomersQuery Create(DateOnly from, DateOnly to)
|
||||
{
|
||||
if (from > to)
|
||||
throw new ArgumentException("From date cannot be greater than To date.");
|
||||
|
||||
return new(from, to);
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
using LiteCharms.Extensions;
|
||||
using LiteCharms.Infrastructure.Database;
|
||||
using LiteCharms.Models;
|
||||
|
||||
namespace LiteCharms.Features.Customers.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);
|
||||
|
||||
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,31 +0,0 @@
|
||||
using LiteCharms.Extensions;
|
||||
using LiteCharms.Infrastructure.Database;
|
||||
using LiteCharms.Models;
|
||||
|
||||
namespace LiteCharms.Features.Customers.Queries.Handlers;
|
||||
|
||||
public class GetCustomerRefundsQueryHandler(IDbContextFactory<LeadGeneratorDbContext> contextFactory) : IRequestHandler<GetCustomerRefundsQuery, Result<OrderRefund[]>>
|
||||
{
|
||||
public async ValueTask<Result<OrderRefund[]>> Handle(GetCustomerRefundsQuery 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<OrderRefund[]>(new Error($"Customer with Id {request.CustomerId} does not exist."));
|
||||
|
||||
var refunds = await context.OrderRefunds.AsNoTracking().AsSplitQuery()
|
||||
.OrderByDescending(o => o.CreatedAt)
|
||||
.Where(r => r.Order!.CustomerId == request.CustomerId).ToArrayAsync(cancellationToken);
|
||||
|
||||
return refunds?.Length > 0
|
||||
? Result.Ok(refunds.Select(r => r.ToModel()).ToArray())
|
||||
: Result.Fail<OrderRefund[]>(new Error($"No refunds found for customer with Id {request.CustomerId}."));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Fail<OrderRefund[]>(new Error(ex.Message).CausedBy(ex));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,32 +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)
|
||||
.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
-1
@@ -1,4 +1,4 @@
|
||||
namespace LiteCharms.Models.Configuraton.Email;
|
||||
namespace LiteCharms.Features.Email.Configuration;
|
||||
|
||||
public class Account
|
||||
{
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
namespace LiteCharms.Models.Configuraton.Email;
|
||||
namespace LiteCharms.Features.Email.Configuration;
|
||||
|
||||
public class SmtpSettings
|
||||
{
|
||||
@@ -0,0 +1,193 @@
|
||||
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();
|
||||
|
||||
if (message.Body!.Properties.HasAttachments)
|
||||
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.Extensions.Email;
|
||||
|
||||
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,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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace LiteCharms.Features.Email.Models;
|
||||
|
||||
public sealed class EmailEnquiryModel
|
||||
{
|
||||
[Required]
|
||||
[MinLength(2)]
|
||||
[MaxLength(255)]
|
||||
[Display(Name = "Full Name")]
|
||||
public string? FullName { get; set; }
|
||||
|
||||
[Required]
|
||||
[EmailAddress]
|
||||
[MinLength(5)]
|
||||
[MaxLength(255)]
|
||||
[Display(Name = "Email Address")]
|
||||
public string? EmailAddress { get; set; }
|
||||
|
||||
[Required]
|
||||
[MinLength(2)]
|
||||
[MaxLength(255)]
|
||||
[Display(Name = "Subject")]
|
||||
public string? EmailSubject { get; set; }
|
||||
|
||||
[Required]
|
||||
[MinLength(2)]
|
||||
[MaxLength(2000)]
|
||||
[Display(Name = "Message")]
|
||||
public string? Message { get; set; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using LiteCharms.Features.Email;
|
||||
using LiteCharms.Features.Email.Configuration;
|
||||
|
||||
namespace LiteCharms.Features.Extensions;
|
||||
|
||||
public static class Email
|
||||
{
|
||||
public const string ShopEmailFromName = "Khongisa Shop";
|
||||
public const string ShopEmailFromAddress = "shop@litecharms.co.za";
|
||||
|
||||
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,201 @@
|
||||
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,
|
||||
CreatedAt = entity.CreatedAt,
|
||||
Name = entity.Name,
|
||||
Description = entity.Description,
|
||||
Active = entity.Active,
|
||||
Summary = entity.Summary,
|
||||
ImageUrl = entity.ImageUrl,
|
||||
Thumbnails = entity.Thumbnails,
|
||||
Metadata = entity.Metadata,
|
||||
};
|
||||
|
||||
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
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace LiteCharms.Features.Extensions;
|
||||
|
||||
public static class Hash
|
||||
{
|
||||
public static Func<string?, string?> StringToSha256Hash = (input) =>
|
||||
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(input!)));
|
||||
|
||||
public static Func<Stream, string?> StreamToSha256Hash = (stream) =>
|
||||
Convert.ToHexString(SHA256.HashData(stream));
|
||||
|
||||
public static Func<byte[], string?> BytesToSha256Hash = (bytes) =>
|
||||
Convert.ToHexString(SHA256.HashData(bytes));
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using LiteCharms.Features.HealthChecks;
|
||||
using static LiteCharms.Features.Extensions.Postgres;
|
||||
|
||||
namespace LiteCharms.Features.Extensions;
|
||||
|
||||
public static class HealthChecks
|
||||
{
|
||||
public static IServiceCollection AddShopQuartzHealthCheck(this IServiceCollection services)
|
||||
{
|
||||
services.AddHealthChecks().AddCheck<ShopQuartzHealthCheck>("ShopQuartz");
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddMidrandShopQuartzHealthCheck(this IServiceCollection services)
|
||||
{
|
||||
services.AddHealthChecks().AddCheck<MidrandShopQuartzHealthCheck>("MidrandShopQuartz");
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddShopPostgresHealthCheck(this IServiceCollection services)
|
||||
{
|
||||
services.AddHealthChecks().AddCheck<PostgresShopHealthCheck>(ShopDbConfigName);
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddMidrandShopPostgresHealthCheck(this IServiceCollection services)
|
||||
{
|
||||
services.AddHealthChecks().AddCheck<PostgresMidrandShopHealthCheck>(MidrandShopDbConfigName);
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddHealthChecksSupport(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddHealthChecks()
|
||||
.AddCheck("Self", () => HealthCheckResult.Healthy());
|
||||
|
||||
//services.AddHealthChecksUI(setup =>
|
||||
//{
|
||||
// setup.AddHealthCheckEndpoint("Lead Generator", $"{configuration["ASPNETCORE_URLS"]}/health");
|
||||
// setup.SetEvaluationTimeInSeconds(15);
|
||||
//}).AddInMemoryStorage(databaseName: "healthuidb");
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -1,41 +1,7 @@
|
||||
using LiteCharms.Infrastructure.Database;
|
||||
using LiteCharms.Infrastructure.HealthChecks;
|
||||
using LiteCharms.Models.Configuraton.Email;
|
||||
namespace LiteCharms.Features.Extensions;
|
||||
|
||||
namespace LiteCharms.Extensions;
|
||||
|
||||
public static class Services
|
||||
public static class Monitoring
|
||||
{
|
||||
public static IServiceCollection AddEmail(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.Configure<SmtpSettings>(configuration.GetSection("Email"));
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddHealthChecksSupport(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddHealthChecks()
|
||||
.AddCheck("Self", () => HealthCheckResult.Healthy())
|
||||
.AddCheck<PostgresHealthCheck>("PostgreSQL");
|
||||
|
||||
//services.AddHealthChecksUI(setup =>
|
||||
//{
|
||||
// setup.AddHealthCheckEndpoint("Lead Generator", $"{configuration["ASPNETCORE_URLS"]}/health");
|
||||
// setup.SetEvaluationTimeInSeconds(15);
|
||||
//}).AddInMemoryStorage(databaseName: "healthuidb");
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddLeadGeneratorDatabase(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddPooledDbContextFactory<LeadGeneratorDbContext>(options =>
|
||||
options.UseNpgsql(configuration.GetConnectionString("PostgresLeadGenerator")));
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
public static WebApplicationBuilder AddMonitoring(this WebApplicationBuilder builder)
|
||||
{
|
||||
var serviceName = builder.Configuration.GetValue<string>("Monitoring:ServiceName") ?? "LiteCharms";
|
||||
@@ -0,0 +1,27 @@
|
||||
using LiteCharms.Features.MidrandShop.Postgres;
|
||||
using LiteCharms.Features.Shop.Postgres;
|
||||
|
||||
namespace LiteCharms.Features.Extensions;
|
||||
|
||||
public static class Postgres
|
||||
{
|
||||
public const string MidrandShopDbConfigName = "PostgresMidrandShop";
|
||||
public const string ShopDbConfigName = "PostgresShop";
|
||||
public const string SchedulerDbConfigName = "PostgresScheduler";
|
||||
|
||||
public static IServiceCollection AddShopDatabase(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddPooledDbContextFactory<ShopDbContext>(options =>
|
||||
options.UseNpgsql(configuration.GetConnectionString(ShopDbConfigName)));
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddMidrandShopDatabase(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddPooledDbContextFactory<MidrandShopDbContext>(options =>
|
||||
options.UseNpgsql(configuration.GetConnectionString(MidrandShopDbConfigName)));
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using LiteCharms.Features.Quartz;
|
||||
using LiteCharms.Features.Quartz.Abstractions;
|
||||
using static LiteCharms.Features.Extensions.Postgres;
|
||||
|
||||
namespace LiteCharms.Features.Extensions;
|
||||
|
||||
public static class Quartz
|
||||
{
|
||||
public const string ShopSchedulerName = "shop";
|
||||
public const string MidrandShopSchedulerName = "midrandshop";
|
||||
|
||||
public static IServiceCollection AddQuartzSchedulerClient(this IServiceCollection services, string schedulerName, IConfiguration configuration)
|
||||
{
|
||||
var connectionString = configuration.GetConnectionString(SchedulerDbConfigName);
|
||||
|
||||
services.ConfigureCommon();
|
||||
|
||||
services.AddQuartz(config =>
|
||||
{
|
||||
config.SchedulerName = schedulerName;
|
||||
config.SchedulerId = "AUTO";
|
||||
|
||||
config.UseSimpleTypeLoader();
|
||||
config.UseDefaultThreadPool(options => options.MaxConcurrency = 0);
|
||||
config.UseTimeZoneConverter();
|
||||
|
||||
config.UsePersistentStore(storage =>
|
||||
{
|
||||
storage.PerformSchemaValidation = false;
|
||||
|
||||
storage.UseSystemTextJsonSerializer();
|
||||
storage.SetProperty("quartz.jobStore.clustered", "true");
|
||||
storage.SetProperty("quartz.jobStore.tablePrefix", "qrtz_");
|
||||
|
||||
storage.UsePostgres(connectionString!);
|
||||
storage.UseClustering(cluster =>
|
||||
{
|
||||
cluster.CheckinInterval = TimeSpan.FromSeconds(30);
|
||||
cluster.CheckinMisfireThreshold = TimeSpan.FromSeconds(90);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddQuartzScheduler(this IServiceCollection services, string schedulerName, IConfiguration configuration)
|
||||
{
|
||||
var connectionString = configuration.GetConnectionString(SchedulerDbConfigName);
|
||||
|
||||
services.ConfigureCommon();
|
||||
|
||||
services.AddQuartzHostedService(options => options.WaitForJobsToComplete = true);
|
||||
|
||||
services.AddQuartz(config =>
|
||||
{
|
||||
config.SchedulerName = schedulerName;
|
||||
config.SchedulerId = "AUTO";
|
||||
config.InterruptJobsOnShutdown = true;
|
||||
config.InterruptJobsOnShutdownWithWait = true;
|
||||
config.MaxBatchSize = 5;
|
||||
|
||||
config.UseSimpleTypeLoader();
|
||||
config.UseDefaultThreadPool(options => options.MaxConcurrency = 1);
|
||||
config.UseTimeZoneConverter();
|
||||
|
||||
config.SetProperty("quartz.jobStore.misfireThreshold", TimeSpan.FromMinutes(2).TotalMilliseconds.ToString());
|
||||
|
||||
config.UsePersistentStore(storage =>
|
||||
{
|
||||
storage.PerformSchemaValidation = false;
|
||||
|
||||
storage.UseSystemTextJsonSerializer();
|
||||
storage.SetProperty("quartz.jobStore.clustered", "true");
|
||||
storage.SetProperty("quartz.jobStore.tablePrefix", "qrtz_");
|
||||
|
||||
storage.UsePostgres(connectionString!);
|
||||
storage.UseClustering(cluster =>
|
||||
{
|
||||
cluster.CheckinInterval = TimeSpan.FromSeconds(30);
|
||||
cluster.CheckinMisfireThreshold = TimeSpan.FromSeconds(90);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
private static IServiceCollection ConfigureCommon(this IServiceCollection services)
|
||||
{
|
||||
services.Configure<QuartzOptions>(options =>
|
||||
{
|
||||
options.Scheduling.IgnoreDuplicates = true;
|
||||
options.Scheduling.OverWriteExistingData = true;
|
||||
|
||||
options["quartz.plugin.jobHistory.type"] = "Quartz.Plugin.History.LoggingJobHistoryPlugin, Quartz.Plugins";
|
||||
options["quartz.plugin.triggerHistory.type"] = "Quartz.Plugin.History.LoggingTriggerHistoryPlugin, Quartz.Plugins";
|
||||
});
|
||||
|
||||
services.AddTransient<RetryJobListener>();
|
||||
services.AddTransient<IJobOrchestrator, JobOrchestrator>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using LiteCharms.Features.S3;
|
||||
using LiteCharms.Features.S3.Abstractions;
|
||||
using static LiteCharms.Features.S3.Constants;
|
||||
|
||||
namespace LiteCharms.Features.Extensions;
|
||||
|
||||
public static class S3
|
||||
{
|
||||
public static IServiceCollection AddGarageS3(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(configuration.GetSection($"{BookshopS3SettingsSection}:ServiceUrl").Value))
|
||||
{
|
||||
services.AddKeyedSingleton<IAmazonS3, AmazonS3Client>(BookshopBucketName, (provider, client) =>
|
||||
new AmazonS3Client(new BasicAWSCredentials(configuration.GetSection($"{BookshopS3SettingsSection}:AccessKey").Value,
|
||||
configuration.GetSection($"{BookshopS3SettingsSection}:SecretKey").Value),
|
||||
new AmazonS3Config
|
||||
{
|
||||
ServiceURL = configuration.GetSection($"{BookshopS3SettingsSection}:ServiceUrl").Value,
|
||||
AuthenticationRegion = configuration.GetSection($"{BookshopS3SettingsSection}:Region").Value,
|
||||
ForcePathStyle = true,
|
||||
EndpointDiscoveryEnabled = true,
|
||||
UseHttp = configuration.GetSection($"{BookshopS3SettingsSection}:ServiceUrl").Value!.Contains("http://")
|
||||
}));
|
||||
|
||||
services.AddKeyedScoped<IS3Service, BookshopS3Service>(BookshopBucketName);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(configuration.GetSection($"{BookshopInvoicesS3SettingsSection}:ServiceUrl").Value))
|
||||
{
|
||||
services.AddKeyedSingleton<IAmazonS3, AmazonS3Client>(BookshopInvoicesBucketName, (provider, client) =>
|
||||
new AmazonS3Client(new BasicAWSCredentials(configuration.GetSection($"{BookshopInvoicesS3SettingsSection}:AccessKey").Value,
|
||||
configuration.GetSection($"{BookshopInvoicesS3SettingsSection}:SecretKey").Value),
|
||||
new AmazonS3Config
|
||||
{
|
||||
ServiceURL = configuration.GetSection($"{BookshopInvoicesS3SettingsSection}:ServiceUrl").Value,
|
||||
AuthenticationRegion = configuration.GetSection($"{BookshopInvoicesS3SettingsSection}:Region").Value,
|
||||
ForcePathStyle = true,
|
||||
EndpointDiscoveryEnabled = true,
|
||||
UseHttp = configuration.GetSection($"{BookshopS3SettingsSection}:ServiceUrl").Value!.Contains("http://")
|
||||
}));
|
||||
|
||||
services.AddKeyedScoped<IS3Service, BookshopInvoicesS3Service>(BookshopInvoicesBucketName);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(configuration.GetSection($"{BookshopQuotesS3SettingsSection}:ServiceUrl").Value))
|
||||
{
|
||||
services.AddKeyedSingleton<IAmazonS3, AmazonS3Client>(BookshopQuotesBucketName, (provider, client) =>
|
||||
new AmazonS3Client(new BasicAWSCredentials(configuration.GetSection($"{BookshopQuotesS3SettingsSection}:AccessKey").Value,
|
||||
configuration.GetSection($"{BookshopQuotesS3SettingsSection}:SecretKey").Value),
|
||||
new AmazonS3Config
|
||||
{
|
||||
ServiceURL = configuration.GetSection($"{BookshopQuotesS3SettingsSection}:ServiceUrl").Value,
|
||||
AuthenticationRegion = configuration.GetSection($"{BookshopQuotesS3SettingsSection}:Region").Value,
|
||||
ForcePathStyle = true,
|
||||
EndpointDiscoveryEnabled = true,
|
||||
UseHttp = configuration.GetSection($"{BookshopS3SettingsSection}:ServiceUrl").Value!.Contains("http://")
|
||||
}));
|
||||
|
||||
services.AddKeyedScoped<IS3Service, BookshopQuotesS3Service>(BookshopQuotesBucketName);
|
||||
}
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using LiteCharms.Features.ServiceBus;
|
||||
using LiteCharms.Features.ServiceBus.Abstractions;
|
||||
using LiteCharms.Features.ServiceBus.Exchanges;
|
||||
using LiteCharms.Features.ServiceBus.Queues;
|
||||
|
||||
namespace LiteCharms.Features.Extensions;
|
||||
|
||||
public static class ServiceBus
|
||||
{
|
||||
public static IServiceCollection AddGeneralServiceBus(this IServiceCollection services) => services
|
||||
.AddSingleton<GeneralQueue>()
|
||||
.AddHostedService<GeneralExchange>()
|
||||
.AddKeyedSingleton<IEventBus, GeneralServiceBus>(Constants.GeneralServiceBus)
|
||||
.AddMemoryCache();
|
||||
|
||||
public static IServiceCollection AddEmailServiceBus(this IServiceCollection services) => services
|
||||
.AddSingleton<EmailQueue>()
|
||||
.AddHostedService<EmailExchange>()
|
||||
.AddKeyedTransient<IEventBus, EmailServiceBus>(Constants.EmailServiceBus)
|
||||
.AddMemoryCache();
|
||||
|
||||
public static IServiceCollection AddSalesServiceBus(this IServiceCollection services) => services
|
||||
.AddSingleton<SalesQueue>()
|
||||
.AddHostedService<SalesExchange>()
|
||||
.AddKeyedSingleton<IEventBus, SalesServiceBus>(Constants.SalesServiceBus)
|
||||
.AddMemoryCache();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace LiteCharms.Features.Extensions;
|
||||
|
||||
public static class Timezones
|
||||
{
|
||||
public static TimeZoneInfo SouthAfricanTimeZone => TimeZoneInfo.FindSystemTimeZoneById("South Africa Standard Time");
|
||||
|
||||
public static string? LocaliseDateTime(this DateTime dateTime, TimeSpan offset) => offset.Hours > 0
|
||||
? $"{dateTime:yyyy-MM-ddTHH:mm:ss.fff}+{offset.Hours:00}:{offset.Minutes:00}"
|
||||
: $"{dateTime:yyyy-MM-ddTHH:mm:ss.fff}{offset.Hours:00}:{offset.Minutes:00}";
|
||||
|
||||
public static string? LocaliseDateTimeOffset(this DateTimeOffset dateTime, TimeSpan offset) => LocaliseDateTime(dateTime.DateTime, offset);
|
||||
|
||||
public static DateTimeOffset ToDateTimeWithTimeZone(this DateTime source, TimeZoneInfo? timezone = null)
|
||||
{
|
||||
DateTime sourceDateAdjusted = source.Kind != DateTimeKind.Utc
|
||||
? new(source.Ticks, DateTimeKind.Utc)
|
||||
: source;
|
||||
|
||||
var localised = timezone is null
|
||||
? new DateTimeOffset(sourceDateAdjusted.Ticks, SouthAfricanTimeZone.BaseUtcOffset).LocaliseDateTimeOffset(SouthAfricanTimeZone.BaseUtcOffset)
|
||||
: new DateTimeOffset(sourceDateAdjusted.Ticks, timezone!.BaseUtcOffset).LocaliseDateTimeOffset(timezone.BaseUtcOffset);
|
||||
|
||||
return DateTimeOffset.Parse(localised!);
|
||||
}
|
||||
|
||||
public static DateTime UtcNow(this TimeZoneInfo timezone) => ToDateTimeWithTimeZone(DateTime.Now, timezone).UtcDateTime;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using static LiteCharms.Features.Extensions.Quartz;
|
||||
|
||||
namespace LiteCharms.Features.HealthChecks;
|
||||
|
||||
public class MidrandShopQuartzHealthCheck(ISchedulerFactory schedulerFactory) : IHealthCheck
|
||||
{
|
||||
public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var scheduler = await schedulerFactory.GetScheduler(MidrandShopSchedulerName, cancellationToken);
|
||||
|
||||
if(scheduler == null)
|
||||
return HealthCheckResult.Unhealthy($"Scheduler with name '{MidrandShopSchedulerName}' not found.");
|
||||
|
||||
if (!scheduler.IsStarted)
|
||||
return HealthCheckResult.Unhealthy($"{MidrandShopSchedulerName} Quartz scheduler is not running");
|
||||
|
||||
await scheduler.CheckExists(new JobKey(Guid.NewGuid().ToString()), cancellationToken);
|
||||
|
||||
return HealthCheckResult.Healthy($"{MidrandShopSchedulerName} Quartz scheduler is ready");
|
||||
}
|
||||
catch (SchedulerException)
|
||||
{
|
||||
return HealthCheckResult.Unhealthy($"{MidrandShopSchedulerName} Quartz scheduler cannot connect to the store");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using static LiteCharms.Features.Extensions.Postgres;
|
||||
|
||||
namespace LiteCharms.Features.HealthChecks;
|
||||
|
||||
public class PostgresMidrandShopHealthCheck(IConfiguration configuration) : IHealthCheck
|
||||
{
|
||||
private readonly string connectionString = configuration.GetConnectionString(MidrandShopDbConfigName)!;
|
||||
|
||||
public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var dataSource = NpgsqlDataSource.Create(connectionString);
|
||||
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
|
||||
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = "SELECT 1";
|
||||
|
||||
await command.ExecuteScalarAsync(cancellationToken);
|
||||
|
||||
return HealthCheckResult.Healthy($"{MidrandShopDbConfigName} is responsive.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return HealthCheckResult.Unhealthy($"{MidrandShopDbConfigName} is unreachable.", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
-5
@@ -1,8 +1,10 @@
|
||||
namespace LiteCharms.Infrastructure.HealthChecks;
|
||||
using static LiteCharms.Features.Extensions.Postgres;
|
||||
|
||||
public class PostgresHealthCheck(IConfiguration configuration) : IHealthCheck
|
||||
namespace LiteCharms.Features.HealthChecks;
|
||||
|
||||
public class PostgresShopHealthCheck(IConfiguration configuration) : IHealthCheck
|
||||
{
|
||||
private readonly string connectionString = configuration.GetConnectionString("PostgresLeadGenerator")!;
|
||||
private readonly string connectionString = configuration.GetConnectionString(ShopDbConfigName)!;
|
||||
|
||||
public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -16,11 +18,11 @@ public class PostgresHealthCheck(IConfiguration configuration) : IHealthCheck
|
||||
|
||||
await command.ExecuteScalarAsync(cancellationToken);
|
||||
|
||||
return HealthCheckResult.Healthy("PostgreSQL is responsive.");
|
||||
return HealthCheckResult.Healthy($"{ShopDbConfigName} is responsive.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return HealthCheckResult.Unhealthy("PostgreSQL is unreachable.", ex);
|
||||
return HealthCheckResult.Unhealthy($"{ShopDbConfigName} is unreachable.", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using static LiteCharms.Features.Extensions.Quartz;
|
||||
|
||||
namespace LiteCharms.Features.HealthChecks;
|
||||
|
||||
public class ShopQuartzHealthCheck(ISchedulerFactory schedulerFactory) : IHealthCheck
|
||||
{
|
||||
public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var scheduler = await schedulerFactory.GetScheduler(ShopSchedulerName, cancellationToken);
|
||||
|
||||
if(scheduler == null)
|
||||
return HealthCheckResult.Unhealthy($"Scheduler with name '{ShopSchedulerName}' not found.");
|
||||
|
||||
if (!scheduler.IsStarted)
|
||||
return HealthCheckResult.Unhealthy($"{ShopSchedulerName} Quartz scheduler is not running");
|
||||
|
||||
await scheduler.CheckExists(new JobKey(Guid.NewGuid().ToString()), cancellationToken);
|
||||
|
||||
return HealthCheckResult.Healthy($"{ShopSchedulerName} Quartz scheduler is ready");
|
||||
}
|
||||
catch (SchedulerException)
|
||||
{
|
||||
return HealthCheckResult.Unhealthy($"{ShopSchedulerName} Quartz scheduler cannot connect to the store");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
namespace LiteCharms.Features.Leads.Commands;
|
||||
|
||||
public class CreateLeadCommand : IRequest<Result<Guid>>
|
||||
{
|
||||
public Guid? CustomerId { get; set; }
|
||||
|
||||
public string? GoogleClickId { 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 googleClickId, string webClickId, string appClickId, long? campaignId, long? adGroupId, long? adName, long? targetId, long? feedItemId, string? clickLocation, string? attribusionHash)
|
||||
{
|
||||
CustomerId = customerId;
|
||||
GoogleClickId = googleClickId;
|
||||
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 googleClickId, string webClickId, string appClickId, long? campaignId, long? adGroupId, long? adName, long? targetId, long? feedItemId, string? clickLocation, string? attribusionHash)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(googleClickId) || string.IsNullOrWhiteSpace(appClickId) || string.IsNullOrWhiteSpace(webClickId))
|
||||
throw new ArgumentException("Google ClickId, App ClickId and Web ClickId are required to create a lead.");
|
||||
|
||||
return new(customerId, googleClickId, webClickId, appClickId, campaignId, adGroupId, adName, targetId, feedItemId, clickLocation, attribusionHash);
|
||||
}
|
||||
}
|
||||
@@ -1,46 +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.GoogleClickId}{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.GoogleClickId}, 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,
|
||||
GoogleClickId = request.GoogleClickId,
|
||||
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.GoogleClickId}, 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,24 +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; }
|
||||
|
||||
private GetLeadsQuery(DateOnly from, DateOnly to)
|
||||
{
|
||||
From = from;
|
||||
To = to;
|
||||
}
|
||||
|
||||
public static GetLeadsQuery Create(DateOnly from, DateOnly to)
|
||||
{
|
||||
if (from > to)
|
||||
throw new ArgumentException("From date cannot be greater than To date.");
|
||||
|
||||
return new(from, to);
|
||||
}
|
||||
}
|
||||
@@ -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,32 +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)
|
||||
.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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@
|
||||
<!-- Nuget Package Details -->
|
||||
<PropertyGroup>
|
||||
<PackageId>LiteCharms.Features</PackageId>
|
||||
<Version>1.0.12</Version>
|
||||
<Version>1.0.20</Version>
|
||||
<Authors>Khwezi Mngoma</Authors>
|
||||
<Company>Lite Charms (PTY) Ltd</Company>
|
||||
<Description>Feature components for Lite Charms applications.</Description>
|
||||
@@ -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,14 +29,94 @@
|
||||
<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" />
|
||||
<PackageReference Include="MimeKit" Version="4.16.0" />
|
||||
|
||||
<!-- Global Usings-->
|
||||
<Using Include="MimeKit"/>
|
||||
<Using Include="MailKit.Net.Smtp"/>
|
||||
<Using Include="MimeKit" />
|
||||
<Using Include="MailKit.Net.Smtp" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- CQRS -->
|
||||
@@ -47,18 +128,40 @@
|
||||
<Using Include="FluentResults" />
|
||||
<Using Include="Mediator" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Amazon S3 SDK -->
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AWSSDK.Extensions.NetCore.Setup" Version="4.0.4.1" />
|
||||
<PackageReference Include="AWSSDK.S3" Version="4.0.23.4" />
|
||||
|
||||
<!-- global Usings -->
|
||||
<Using Include="Amazon.S3" />
|
||||
<Using Include="Amazon.S3.Model" />
|
||||
<Using Include="Amazon.Runtime" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Shared Usings -->
|
||||
<ItemGroup>
|
||||
<Using Include="Microsoft.AspNetCore.Builder" />
|
||||
<Using Include="Microsoft.Extensions.Hosting" />
|
||||
<Using Include="System.Text" />
|
||||
<Using Include="System.Security.Cryptography" />
|
||||
<Using Include="Microsoft.EntityFrameworkCore" />
|
||||
<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.Extensions.Options" />
|
||||
<Using Include="Microsoft.Extensions.Logging" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\LiteCharms.Extensions\LiteCharms.Extensions.csproj" />
|
||||
<ProjectReference Include="..\LiteCharms.Infrastructure\LiteCharms.Infrastructure.csproj" />
|
||||
<ItemGroup>
|
||||
<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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace LiteCharms.Features.MidrandShop.Postgres;
|
||||
|
||||
public class MidrandShopDbContext(DbContextOptions<MidrandShopDbContext> options) : DbContext(options)
|
||||
{
|
||||
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using static LiteCharms.Features.Extensions.Postgres;
|
||||
|
||||
namespace LiteCharms.Features.MidrandShop.Postgres;
|
||||
|
||||
public class MidrandShopDbContextFactory : IDesignTimeDbContextFactory<MidrandShopDbContext>
|
||||
{
|
||||
public MidrandShopDbContext CreateDbContext(string[] args)
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.SetBasePath(Directory.GetCurrentDirectory())
|
||||
.AddUserSecrets(typeof(MidrandShopDbContext).Assembly)
|
||||
.AddJsonFile("appsettings.json")
|
||||
.AddEnvironmentVariables()
|
||||
.Build();
|
||||
|
||||
var optionsBuilder = new DbContextOptionsBuilder<MidrandShopDbContext>();
|
||||
optionsBuilder.UseNpgsql(configuration.GetConnectionString(MidrandShopDbConfigName));
|
||||
|
||||
return new MidrandShopDbContext(optionsBuilder.Options);
|
||||
}
|
||||
}
|
||||
@@ -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,25 +0,0 @@
|
||||
namespace LiteCharms.Features.Orders.Commands;
|
||||
|
||||
public class CreateOrderCommand : IRequest<Result<Guid>>
|
||||
{
|
||||
public Guid CustomerId { get; set; }
|
||||
|
||||
public Guid ProductPriceId { get; set; }
|
||||
|
||||
private CreateOrderCommand(Guid customerId, Guid productPriceId)
|
||||
{
|
||||
CustomerId = customerId;
|
||||
ProductPriceId = productPriceId;
|
||||
}
|
||||
|
||||
public static CreateOrderCommand Create(Guid customerId, Guid productPriceId)
|
||||
{
|
||||
if (customerId == Guid.Empty)
|
||||
throw new ArgumentException("CustomerId is required.", nameof(customerId));
|
||||
|
||||
if (productPriceId == Guid.Empty)
|
||||
throw new ArgumentException("ProductPriceId is required.", nameof(productPriceId));
|
||||
|
||||
return new(customerId, productPriceId);
|
||||
}
|
||||
}
|
||||
@@ -1,29 +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);
|
||||
|
||||
var newOrder = context.Orders.Add(new Entities.Order
|
||||
{
|
||||
CustomerId = request.CustomerId,
|
||||
ProductPriceId = request.ProductPriceId,
|
||||
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 product price {request.ProductPriceId}."));
|
||||
}
|
||||
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 UpdateOrderCommandHandler(IDbContextFactory<LeadGeneratorDbContext> contextFactory) : IRequestHandler<UpdateOrderCommand, Result>
|
||||
{
|
||||
public async ValueTask<Result> Handle(UpdateOrderCommand 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 UpdateOrderCommand : IRequest<Result>
|
||||
{
|
||||
public Guid OrderId { get; set; }
|
||||
|
||||
public OrderStatus Status { get; set; }
|
||||
|
||||
public string? Note { get; set; }
|
||||
|
||||
private UpdateOrderCommand(Guid orderId, OrderStatus status, string? note)
|
||||
{
|
||||
OrderId = orderId;
|
||||
Status = status;
|
||||
Note = note;
|
||||
}
|
||||
|
||||
public static UpdateOrderCommand 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,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,24 +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; }
|
||||
|
||||
private GetOrdersQuery(DateOnly from, DateOnly to)
|
||||
{
|
||||
From = from;
|
||||
To = to;
|
||||
}
|
||||
|
||||
public static GetOrdersQuery Create(DateOnly from, DateOnly to)
|
||||
{
|
||||
if (from > to)
|
||||
throw new ArgumentException("From date cannot be greater than To date.");
|
||||
|
||||
return new(from, to);
|
||||
}
|
||||
}
|
||||
@@ -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,32 +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)
|
||||
.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 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,8 +0,0 @@
|
||||
using LiteCharms.Models;
|
||||
|
||||
namespace LiteCharms.Features.Products.Queries;
|
||||
|
||||
public class GetProductsQuery : IRequest<Result<Product[]>>
|
||||
{
|
||||
public static GetProductsQuery Create() => new();
|
||||
}
|
||||
@@ -1,29 +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);
|
||||
|
||||
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,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,26 +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)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
|
||||
return Result.Ok(products.Select(p => p.ToModel()).ToArray());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Fail<Product[]>(new Error(ex.Message).CausedBy(ex));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using LiteCharms.Features.Abstractions;
|
||||
|
||||
namespace LiteCharms.Features.Quartz.Abstractions;
|
||||
|
||||
public interface IJobOrchestrator
|
||||
{
|
||||
Task SendAsync<TNotification>(TNotification notification, CancellationToken cancellationToken = default)
|
||||
where TNotification : IEvent;
|
||||
|
||||
Task ScheduleAsync<TNotification>(TNotification notification, string cronExpression, CancellationToken cancellationToken = default)
|
||||
where TNotification : IEvent;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using LiteCharms.Features.Abstractions;
|
||||
using LiteCharms.Features.Quartz.Abstractions;
|
||||
|
||||
namespace LiteCharms.Features.Quartz;
|
||||
|
||||
public class JobOrchestrator(ISchedulerFactory schedulerFactory) : IJobOrchestrator
|
||||
{
|
||||
public async Task SendAsync<TNotification>(TNotification notification, CancellationToken cancellationToken = default)
|
||||
where TNotification : IEvent
|
||||
{
|
||||
var chainedJobGroup = "onetime-jobs";
|
||||
|
||||
var scheduler = await schedulerFactory.GetScheduler(cancellationToken);
|
||||
var jobKey = new JobKey($"{notification.Name.ToLower()}-{notification.CorrelationId.ToLower()}", chainedJobGroup);
|
||||
var triggerKey = new TriggerKey($"{jobKey.Name}-trigger", chainedJobGroup);
|
||||
|
||||
var job = JobBuilder.Create<MediatorJob<TNotification>>()
|
||||
.WithIdentity(jobKey)
|
||||
.WithDescription($"Correlation ID: {notification.CorrelationId}")
|
||||
.UsingJobData(new JobDataMap { ["Payload"] = JsonSerializer.Serialize(notification) })
|
||||
.DisallowConcurrentExecution()
|
||||
.Build();
|
||||
|
||||
var trigger = global::Quartz.TriggerBuilder.Create()
|
||||
.WithIdentity(triggerKey)
|
||||
.StartNow()
|
||||
.Build();
|
||||
|
||||
await scheduler.ScheduleJob(job, new List<ITrigger> { trigger }.AsReadOnly(), replace: true, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task ScheduleAsync<TNotification>(TNotification notification, string cronExpression, CancellationToken cancellationToken = default)
|
||||
where TNotification : IEvent
|
||||
{
|
||||
var chainedJobGroup = "scheduled-jobs";
|
||||
|
||||
var scheduler = await schedulerFactory.GetScheduler(cancellationToken);
|
||||
var jobKey = new JobKey($"{notification.Name.ToLower()}", chainedJobGroup);
|
||||
var triggerKey = new TriggerKey($"{jobKey.Name}-trigger", chainedJobGroup);
|
||||
|
||||
var job = JobBuilder.Create<MediatorJob<TNotification>>()
|
||||
.WithIdentity(jobKey)
|
||||
.WithDescription($"Correlation ID: {notification.CorrelationId}")
|
||||
.UsingJobData(new JobDataMap { ["Payload"] = JsonSerializer.Serialize(notification) })
|
||||
.DisallowConcurrentExecution()
|
||||
.StoreDurably()
|
||||
.Build();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
var trigger = global::Quartz.TriggerBuilder.Create()
|
||||
.WithIdentity(triggerKey)
|
||||
.WithDescription($"Scheduled via Main Job at {now:g}")
|
||||
.WithCronSchedule(cronExpression, cron => cron
|
||||
.WithMisfireHandlingInstructionIgnoreMisfires())
|
||||
.StartAt(now)
|
||||
.Build();
|
||||
|
||||
await scheduler.AddJob(job, replace: true, cancellationToken);
|
||||
|
||||
if (await scheduler.CheckExists(triggerKey, cancellationToken))
|
||||
await scheduler.RescheduleJob(triggerKey, trigger, cancellationToken);
|
||||
else
|
||||
await scheduler.ScheduleJob(job, new List<ITrigger> { trigger }.AsReadOnly(), replace: true, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
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))
|
||||
{
|
||||
Trace.WriteLine("Job Payload missing, job ended");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
var notification = JsonSerializer.Deserialize<TNotification>(data);
|
||||
|
||||
if (notification is null)
|
||||
{
|
||||
Trace.WriteLine("Notification could not be JSon converted from data string, job ended");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
using var activity = MediatorTelemetry.Source.StartActivity($"Quartz: {typeof(TNotification).Name}");
|
||||
|
||||
activity?.SetTag("event.correlation_id", notification.CorrelationId);
|
||||
|
||||
await mediator.Publish(notification, context.CancellationToken);
|
||||
|
||||
Trace.WriteLine("Job published");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace LiteCharms.Features.Quartz;
|
||||
|
||||
public class RetryJobListener : IJobListener
|
||||
{
|
||||
public string Name => "RetryJobListener";
|
||||
|
||||
public int RetryCount { get; set; } = 3;
|
||||
|
||||
public Task JobExecutionVetoed(IJobExecutionContext context, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
|
||||
public Task JobToBeExecuted(IJobExecutionContext context, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
|
||||
public async Task JobWasExecuted(IJobExecutionContext context, JobExecutionException? jobException, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (jobException is not null && context.RefireCount < RetryCount)
|
||||
jobException.RefireImmediately = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace LiteCharms.Features.S3.Abstractions;
|
||||
|
||||
public interface IS3Service
|
||||
{
|
||||
Task<Result<string>> UploadFileAsync(string fileName, Stream fileStream, string contentType, CancellationToken cancellationToken = default);
|
||||
Task<Result> DeleteFileAsync(string fileKey, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using static LiteCharms.Features.Extensions.Hash;
|
||||
|
||||
namespace LiteCharms.Features.S3.Abstractions;
|
||||
|
||||
public abstract class S3ServiceBase(IAmazonS3 amazonS3)
|
||||
{
|
||||
protected readonly IAmazonS3 Client = amazonS3;
|
||||
|
||||
protected abstract string BucketName { get; }
|
||||
protected abstract string CdnBaseUrl { get; }
|
||||
|
||||
public virtual async Task<Result<string>> UploadFileAsync(string fileName, Stream fileStream, string contentType, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(BucketName))
|
||||
return Result.Fail<string>("Bucket name is not configured.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(CdnBaseUrl))
|
||||
return Result.Fail<string>("CDN base URL is not configured.");
|
||||
|
||||
using var stream = new MemoryStream();
|
||||
|
||||
await fileStream.CopyToAsync(stream, cancellationToken);
|
||||
await fileStream.DisposeAsync();
|
||||
|
||||
stream.Seek(0, SeekOrigin.Begin);
|
||||
|
||||
var fileHash = StreamToSha256Hash(stream);
|
||||
|
||||
if(string.IsNullOrWhiteSpace(fileHash))
|
||||
return Result.Fail<string>("Failed to compute file hash.");
|
||||
|
||||
var fileKey = $"{fileHash.ToLower()}{Path.GetExtension(fileName)}";
|
||||
|
||||
var putRequest = new PutObjectRequest
|
||||
{
|
||||
BucketName = BucketName,
|
||||
Key = fileKey,
|
||||
InputStream = stream,
|
||||
ContentType = contentType,
|
||||
UseChunkEncoding = false
|
||||
};
|
||||
|
||||
stream.Seek(0, SeekOrigin.Begin);
|
||||
|
||||
var response = await Client.PutObjectAsync(putRequest, cancellationToken);
|
||||
|
||||
return response.HttpStatusCode != System.Net.HttpStatusCode.OK
|
||||
? Result.Fail<string>($"Failed to upload {fileName} to S3.")
|
||||
: Result.Ok($"{CdnBaseUrl}/{fileKey}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Fail<string>(new Error($"Error uploading {fileName} to S3: {ex.Message}").CausedBy(ex));
|
||||
}
|
||||
}
|
||||
|
||||
public virtual async Task<Result> DeleteFileAsync(string fileKey, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(BucketName))
|
||||
return Result.Fail("Bucket name is not configured.");
|
||||
|
||||
var deleteRequest = new DeleteObjectRequest
|
||||
{
|
||||
BucketName = BucketName,
|
||||
Key = fileKey
|
||||
};
|
||||
|
||||
var response = await Client.DeleteObjectAsync(deleteRequest, cancellationToken);
|
||||
|
||||
return response.HttpStatusCode != System.Net.HttpStatusCode.NoContent
|
||||
? Result.Fail($"Failed to delete {fileKey} from S3.")
|
||||
: Result.Ok();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Fail(new Error($"Error deleting {fileKey} from S3: {ex.Message}").CausedBy(ex));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using LiteCharms.Features.S3.Abstractions;
|
||||
using static LiteCharms.Features.S3.Constants;
|
||||
|
||||
namespace LiteCharms.Features.S3;
|
||||
|
||||
public class BookshopInvoicesS3Service(IConfiguration configuration, [FromKeyedServices(BookshopInvoicesBucketName)] IAmazonS3 amazonS3) :
|
||||
S3ServiceBase(amazonS3), IS3Service
|
||||
{
|
||||
protected override string BucketName => configuration.GetSection($"{BookshopInvoicesS3SettingsSection}:BucketName").Value ?? "";
|
||||
protected override string CdnBaseUrl => configuration.GetSection($"{BookshopInvoicesS3SettingsSection}:CdnBaseUrl").Value ?? "";
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using LiteCharms.Features.S3.Abstractions;
|
||||
using static LiteCharms.Features.S3.Constants;
|
||||
|
||||
namespace LiteCharms.Features.S3;
|
||||
|
||||
public class BookshopQuotesS3Service(IConfiguration configuration, [FromKeyedServices(BookshopQuotesBucketName)] IAmazonS3 amazonS3) :
|
||||
S3ServiceBase(amazonS3), IS3Service
|
||||
{
|
||||
protected override string BucketName => configuration.GetSection($"{BookshopQuotesS3SettingsSection}:BucketName").Value ?? "";
|
||||
protected override string CdnBaseUrl => configuration.GetSection($"{BookshopQuotesS3SettingsSection}:CdnBaseUrl").Value ?? "";
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using LiteCharms.Features.S3.Abstractions;
|
||||
using static LiteCharms.Features.S3.Constants;
|
||||
|
||||
namespace LiteCharms.Features.S3;
|
||||
|
||||
public class BookshopS3Service(IConfiguration configuration, [FromKeyedServices(BookshopBucketName)] IAmazonS3 amazonS3) :
|
||||
S3ServiceBase(amazonS3), IS3Service
|
||||
{
|
||||
protected override string BucketName => configuration.GetSection($"{BookshopS3SettingsSection}:BucketName").Value ?? "";
|
||||
protected override string CdnBaseUrl => configuration.GetSection($"{BookshopS3SettingsSection}:CdnBaseUrl").Value ?? "";
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace LiteCharms.Features.S3.Configuration;
|
||||
|
||||
public class S3Settings
|
||||
{
|
||||
public string? ServiceUrl { get; set; }
|
||||
|
||||
public string? AccessKey { get; set; }
|
||||
|
||||
public string? SecretKey { get; set; }
|
||||
|
||||
public string? BucketName { get; set; }
|
||||
|
||||
public string? Region { get; set; }
|
||||
|
||||
public string? CdnBaseUrl { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace LiteCharms.Features.S3;
|
||||
|
||||
public static class Constants
|
||||
{
|
||||
public const string BookshopS3SettingsSection = "BookshopS3Settings";
|
||||
public const string BookshopInvoicesS3SettingsSection = "BookshopInvoicesS3Settings";
|
||||
public const string BookshopQuotesS3SettingsSection = "BookshopQuotesS3Settings";
|
||||
|
||||
public const string BookshopBucketName = "bookshop";
|
||||
public const string BookshopInvoicesBucketName = "bookshop.invoices";
|
||||
public const string BookshopQuotesBucketName = "bookshop.quotes";
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using LiteCharms.Features.Abstractions;
|
||||
|
||||
namespace LiteCharms.Features.ServiceBus.Abstractions;
|
||||
|
||||
public abstract class EventBusQueueBase
|
||||
{
|
||||
protected readonly Channel<IEvent> channel = Channel.CreateBounded<IEvent>(Constants.QueueBounds);
|
||||
|
||||
public ChannelWriter<IEvent> Outgoing => channel.Writer;
|
||||
|
||||
public ChannelReader<IEvent> Incoming => channel.Reader;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using LiteCharms.Features.Abstractions;
|
||||
|
||||
namespace LiteCharms.Features.ServiceBus.Abstractions;
|
||||
|
||||
public interface IEventBus
|
||||
{
|
||||
Task<Result> PublishAsync<TEvent>(TEvent notification, CancellationToken cancellationToken = default)
|
||||
where TEvent : class, IEvent;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using LiteCharms.Features.Abstractions;
|
||||
|
||||
namespace LiteCharms.Features.ServiceBus.Abstractions;
|
||||
|
||||
public interface IEventBusQueue
|
||||
{
|
||||
ChannelWriter<IEvent> Outgoing { get; }
|
||||
|
||||
ChannelReader<IEvent> Incoming { get; }
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user