Compare commits
186 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ad410c64e | |||
| e193aa7c1c | |||
| 840d4568e2 | |||
| 6e580ecdf6 | |||
| 60095057b7 | |||
| 4c194c1141 | |||
| b41136e2c7 | |||
| 41eb4daeb4 | |||
| c423f04b42 | |||
| 7fe5f7aef3 | |||
| a567fc7cd7 | |||
| 31254932ae | |||
| c53434a578 | |||
| 5a0aae8182 | |||
| 3f3e0dbe88 | |||
| 8d8ebffabf | |||
| dc4addb43a | |||
| e2d29261da | |||
| 5d5b59d610 | |||
| f001b02633 | |||
| 90a11dc65e | |||
| de955a96a8 | |||
| cdf5cfb5cd | |||
| c4d3bb4cdf | |||
| 65f102f18a | |||
| cdc80db214 | |||
| 4576b5aa2b | |||
| 3847927ace | |||
| d38d1dd059 | |||
| c27aba1954 | |||
| e646d16053 | |||
| 1c946dab26 | |||
| 20c3ad9569 | |||
| 9977cf27b9 | |||
| cf7eed0603 | |||
| 8e9ac1e1ad | |||
| fa79bd8021 | |||
| 16dae7c9fb | |||
| 5666ffd474 | |||
| f8153e86b4 | |||
| eef1096ec5 | |||
| 84d33d3607 | |||
| 8f97d7cf38 | |||
| f51cc03327 | |||
| 652ca82a57 | |||
| aff6fcabf4 | |||
| a50830ffaa | |||
| ee6f8a283e | |||
| 8140b5fe65 | |||
| fda97db5fa | |||
| 9285cedfa9 | |||
| 29574f4df0 | |||
| 343874551a | |||
| b4a48c9cbf | |||
| 0eac9d533f | |||
| 961f03c1c7 | |||
| a0cf847e51 | |||
| 24ba609e0c | |||
| 4bac14881d | |||
| 29f6d66c44 | |||
| fd6057d691 | |||
| bcfc9ef962 | |||
| 7961d934ba | |||
| b4e967acc9 | |||
| 0a95df4c39 | |||
| ad9fa0ab91 | |||
| 4df903e456 | |||
| 205bbb9f3f | |||
| 763d24f11f | |||
| 0ed04211bf | |||
| 73ef4b04a9 | |||
| 5ab2d29aac | |||
| 780415b6d4 | |||
| 139ca1f866 | |||
| 879094073a | |||
| 45c2e8310a | |||
| b369dad452 | |||
| ac31c6ada8 | |||
| c938bfec09 | |||
| 5eb6dbc8b2 | |||
| adc14038db | |||
| afc984f3bc | |||
| 61cb6c2228 | |||
| f1ef614cbb | |||
| 8fe129e19c | |||
| c4f73fd999 | |||
| 48f4cd45f1 | |||
| f88cc42a88 | |||
| 0e21ec283d | |||
| e7a798b5e9 | |||
| 494b806744 | |||
| 41b6b71b31 | |||
| 0702caa42d | |||
| ee6beef603 | |||
| 4f6dbfcd37 | |||
| 1c3f3eaf0d | |||
| 91ede2d568 | |||
| 2e77666d9e | |||
| 4d21740124 | |||
| 1977b6b301 | |||
| 18d1640808 | |||
| e40c958066 | |||
| 0ab14d8b63 | |||
| 2db3b3d293 | |||
| 50eee03dbe | |||
| 466458e230 | |||
| 60fcc70e98 | |||
| 141d32f591 | |||
| 38e765203d | |||
| d9e7f225ae | |||
| 9eb3526a2e | |||
| 4397976ed8 | |||
| 2546c34ffc | |||
| 4e53ff8a37 | |||
| 2a0b34c730 | |||
| 6ae63e2ad1 | |||
| 902942eee6 | |||
| 70860efcfb | |||
| 20b747e89c | |||
| 7136e4fc70 | |||
| 4a85d01d1a | |||
| d55bf4f82f | |||
| f5efdde37c | |||
| 87da491ed6 | |||
| 1592d5dc8f | |||
| 08a64d1578 | |||
| f67f5eaf53 | |||
| 50a8a59d92 | |||
| 70c6e0bfbc | |||
| 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 |
+7
-23
@@ -16,30 +16,13 @@ steps:
|
|||||||
NEXUS_KEY: { from_secret: nexus_api_key }
|
NEXUS_KEY: { from_secret: nexus_api_key }
|
||||||
NEXUS_URL: https://nexus.khongisa.co.za/repository/nuget-hosted/
|
NEXUS_URL: https://nexus.khongisa.co.za/repository/nuget-hosted/
|
||||||
VERSION: 1.${DRONE_BUILD_NUMBER}.0
|
VERSION: 1.${DRONE_BUILD_NUMBER}.0
|
||||||
commands:
|
commands:
|
||||||
# Abstractions
|
|
||||||
- dotnet pack LiteCharms.Abstractions/LiteCharms.Abstractions.csproj -c Release -p:PackageVersion=$VERSION -o dist/
|
|
||||||
- dotnet nuget push dist/LiteCharms.Abstractions.$VERSION.nupkg --api-key $NEXUS_KEY --source $NEXUS_URL
|
|
||||||
|
|
||||||
# Models
|
|
||||||
- dotnet pack LiteCharms.Models/LiteCharms.Models.csproj -c Release -p:PackageVersion=$VERSION -o dist/
|
|
||||||
- dotnet nuget push dist/LiteCharms.Models.$VERSION.nupkg --api-key $NEXUS_KEY --source $NEXUS_URL
|
|
||||||
|
|
||||||
# Infrastructure
|
|
||||||
- dotnet pack LiteCharms.Infrastructure/LiteCharms.Infrastructure.csproj -c Release -p:PackageVersion=$VERSION -o dist/
|
|
||||||
- dotnet nuget push dist/LiteCharms.Infrastructure.$VERSION.nupkg --api-key $NEXUS_KEY --source $NEXUS_URL
|
|
||||||
|
|
||||||
# Features
|
|
||||||
- dotnet pack LiteCharms.Features/LiteCharms.Features.csproj -c Release -p:PackageVersion=$VERSION -o dist/
|
- 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
|
- dotnet nuget push dist/LiteCharms.Features.$VERSION.nupkg --api-key $NEXUS_KEY --source $NEXUS_URL
|
||||||
|
- dotnet pack LiteCharms.Features.TechShop/LiteCharms.Features.TechShop.csproj -c Release -p:PackageVersion=$VERSION -o dist/
|
||||||
# Extensions
|
- dotnet nuget push dist/LiteCharms.Features.TechShop.$VERSION.nupkg --api-key $NEXUS_KEY --source $NEXUS_URL
|
||||||
- dotnet pack LiteCharms.Extensions/LiteCharms.Extensions.csproj -c Release -p:PackageVersion=$VERSION -o dist/
|
- dotnet pack LiteCharms.Features.MidrandBooks/LiteCharms.Features.MidrandBooks.csproj -c Release -p:PackageVersion=$VERSION -o dist/
|
||||||
- dotnet nuget push dist/LiteCharms.Extensions.$VERSION.nupkg --api-key $NEXUS_KEY --source $NEXUS_URL
|
- dotnet nuget push dist/LiteCharms.Features.MidrandBooks.$VERSION.nupkg --api-key $NEXUS_KEY --source $NEXUS_URL
|
||||||
|
|
||||||
# Entities
|
|
||||||
- dotnet pack LiteCharms.Entities/LiteCharms.Entities.csproj -c Release -p:PackageVersion=$VERSION -o dist/
|
|
||||||
- dotnet nuget push dist/LiteCharms.Entities.$VERSION.nupkg --api-key $NEXUS_KEY --source $NEXUS_URL
|
|
||||||
|
|
||||||
- name: gitea-tag-release
|
- name: gitea-tag-release
|
||||||
image: alpine/git
|
image: alpine/git
|
||||||
@@ -49,6 +32,7 @@ steps:
|
|||||||
GITEA_PASS: { from_secret: git_password }
|
GITEA_PASS: { from_secret: git_password }
|
||||||
VERSION: 1.${DRONE_BUILD_NUMBER}.0
|
VERSION: 1.${DRONE_BUILD_NUMBER}.0
|
||||||
commands:
|
commands:
|
||||||
|
- echo "169.255.58.144 gitea.khongisa.co.za" >> /etc/hosts
|
||||||
- apk add --no-cache curl
|
- apk add --no-cache curl
|
||||||
- git remote set-url origin https://$${GITEA_USER}:$${GITEA_PASS}@gitea.khongisa.co.za/litecharms/components.git
|
- git remote set-url origin https://$${GITEA_USER}:$${GITEA_PASS}@gitea.khongisa.co.za/litecharms/components.git
|
||||||
- git tag $VERSION
|
- git tag $VERSION
|
||||||
@@ -61,7 +45,7 @@ steps:
|
|||||||
\"tag_name\": \"$VERSION\",
|
\"tag_name\": \"$VERSION\",
|
||||||
\"target_commitish\": \"${DRONE_COMMIT_SHA}\",
|
\"target_commitish\": \"${DRONE_COMMIT_SHA}\",
|
||||||
\"name\": \"Library Suite $VERSION\",
|
\"name\": \"Library Suite $VERSION\",
|
||||||
\"body\": \"### Published NuGet Packages\nAll packages versioned as **$VERSION**:\n* LiteCharms.Abstractions\n* LiteCharms.Models\n* LiteCharms.Infrastructure\n* LiteCharms.Features\n* LiteCharms.Extensions\n* LiteCharms.Entities\n\n[View in Nexus](https://nexus.khongisa.co.za/repository/nuget-group/)\",
|
\"body\": \"### Published NuGet Packages\nAll packages versioned as **$VERSION**:\n* LiteCharms.Features\n* LiteCharms.Features.TechShop\n* LiteCharms.Features.MidrandBooks\n\n[View in Nexus](https://nexus.khongisa.co.za/repository/nuget-group/)\",
|
||||||
\"draft\": false,
|
\"draft\": false,
|
||||||
\"prerelease\": false
|
\"prerelease\": false
|
||||||
}"
|
}"
|
||||||
|
|||||||
@@ -3,6 +3,45 @@ root = true
|
|||||||
|
|
||||||
# C# files
|
# C# files
|
||||||
[*.cs]
|
[*.cs]
|
||||||
|
# IDE0250: Prefer make struct 'readonly'
|
||||||
|
dotnet_diagnostic.IDE0250.severity = warning
|
||||||
|
|
||||||
|
# IDE0060: Remove unused parameters (Good cleanup pairing)
|
||||||
|
dotnet_diagnostic.IDE0060.severity = warning
|
||||||
|
|
||||||
|
# CA1852: Seal internal types (Available in modern .NET)
|
||||||
|
dotnet_diagnostic.CA1852.severity = warning
|
||||||
|
|
||||||
|
# MA0018: Add sealed modifier to types that are never inherited
|
||||||
|
dotnet_diagnostic.MA0018.severity = warning
|
||||||
|
|
||||||
|
# Enforce that classes should be sealed
|
||||||
|
dotnet_diagnostic.MA0053.severity = warning
|
||||||
|
|
||||||
|
# CRITICAL: Force the analyzer to also flag PUBLIC classes, not just internal ones
|
||||||
|
meziantou_analyzer.MA0053.public_class_should_be_sealed = true
|
||||||
|
MA0053.public_class_should_be_sealed = true
|
||||||
|
|
||||||
|
# Keep the rule active as a warning by default
|
||||||
|
dotnet_diagnostic.MA0048.severity = warning
|
||||||
|
|
||||||
|
# Specific exclusions for Meziantou.Analyzer MA0048
|
||||||
|
# Disable the rule for enums
|
||||||
|
meziantou_analyzer.MA0048.exclude_enums = true
|
||||||
|
|
||||||
|
# Disable the rule for records
|
||||||
|
meziantou_analyzer.MA0048.exclude_records = true
|
||||||
|
|
||||||
|
#EXCLUDE specific files that are meant to hold grouped enums/records
|
||||||
|
dotnet_diagnostic.MA0048.severity = warning
|
||||||
|
|
||||||
|
# Disable the requirement to specify ConfigureAwait(false)
|
||||||
|
dotnet_diagnostic.MA0004.severity = none
|
||||||
|
|
||||||
|
# ALTERNATIVE: Exclude any file ending with 'Enums.cs' or 'Records.cs'
|
||||||
|
# (e.g., BillingEnums.cs, CustomerRecords.cs)
|
||||||
|
[**/*{Enums,Records}.cs]
|
||||||
|
dotnet_diagnostic.MA0048.severity = none
|
||||||
|
|
||||||
#### Core EditorConfig Options ####
|
#### Core EditorConfig Options ####
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -360,4 +360,7 @@ MigrationBackup/
|
|||||||
.ionide/
|
.ionide/
|
||||||
|
|
||||||
# Fody - auto-generated XML schema
|
# Fody - auto-generated XML schema
|
||||||
FodyWeavers.xsd
|
FodyWeavers.xsd
|
||||||
|
/LiteCharms.Features.Tests/http/http-client.env.json
|
||||||
|
/LiteCharms.Features.Tests/http/midrandshop-api/http-client.env.json
|
||||||
|
/LiteCharms.Features.Tests/http/authentik/http-client.env.json
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
namespace LiteCharms.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;
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<SignAssembly>True</SignAssembly>
|
|
||||||
<AssemblyOriginatorKeyFile>..\LiteCharms.snk</AssemblyOriginatorKeyFile>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<!-- Nuget Package Details -->
|
|
||||||
<PropertyGroup>
|
|
||||||
<PackageId>LiteCharms.Abstractions</PackageId>
|
|
||||||
<Version>1.0.20</Version>
|
|
||||||
<Authors>Khwezi Mngoma</Authors>
|
|
||||||
<Company>Lite Charms (PTY) Ltd</Company>
|
|
||||||
<Description>Shared abstractions for Lite Charms applications.</Description>
|
|
||||||
<PackageProjectUrl>https://gitea.khongisa.co.za/litecharms/components</PackageProjectUrl>
|
|
||||||
<RepositoryUrl>https://gitea.khongisa.co.za/litecharms/components.git</RepositoryUrl>
|
|
||||||
<RepositoryType>git</RepositoryType>
|
|
||||||
<PackageLicenseFile>LICENSE</PackageLicenseFile>
|
|
||||||
<PackageTags>utility;dotnet</PackageTags>
|
|
||||||
<PackageIcon>icon.png</PackageIcon>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<None Include="..\LICENSE" Pack="true" PackagePath="\" />
|
|
||||||
<None Include="..\icon.png" Pack="true" PackagePath="\" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="FluentResults" Version="4.0.0" />
|
|
||||||
<PackageReference Include="Mediator.Abstractions" Version="3.0.2" />
|
|
||||||
|
|
||||||
<Using Include="Mediator" />
|
|
||||||
<Using Include="FluentResults" />
|
|
||||||
<Using Include="System.Threading.Channels" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
namespace LiteCharms.Entities.Configuration;
|
|
||||||
|
|
||||||
public class NotificationConfiguration : IEntityTypeConfiguration<Notification>
|
|
||||||
{
|
|
||||||
public void Configure(EntityTypeBuilder<Notification> builder)
|
|
||||||
{
|
|
||||||
builder.ToTable(nameof(Notification));
|
|
||||||
|
|
||||||
builder.HasKey(f => f.Id);
|
|
||||||
builder.Property(f => f.CreatedAt).IsRequired().ValueGeneratedOnAdd();
|
|
||||||
builder.Property(f => f.Direction).IsRequired();
|
|
||||||
builder.Property(f => f.Author).IsRequired();
|
|
||||||
builder.Property(f => f.Title).IsRequired();
|
|
||||||
builder.Property(f => f.Description).IsRequired();
|
|
||||||
builder.Property(f => f.Platform).IsRequired();
|
|
||||||
builder.Property(f => f.PlatformAddress).IsRequired();
|
|
||||||
builder.Property(f => f.CorrelationId).IsRequired();
|
|
||||||
builder.Property(f => f.CorrelationIdType).IsRequired();
|
|
||||||
builder.Property(f => f.IsInternal).HasDefaultValue(true);
|
|
||||||
builder.Property(f => f.Processed).HasDefaultValue(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
namespace LiteCharms.Entities.Configuration;
|
|
||||||
|
|
||||||
public class OrderConfiguration : IEntityTypeConfiguration<Order>
|
|
||||||
{
|
|
||||||
public void Configure(EntityTypeBuilder<Order> builder)
|
|
||||||
{
|
|
||||||
builder.ToTable(nameof(Order));
|
|
||||||
|
|
||||||
builder.HasKey(f => f.Id);
|
|
||||||
builder.Property(f => f.CreatedAt).ValueGeneratedOnAdd();
|
|
||||||
builder.Property(f => f.UpdatedAt).IsRequired(false).ValueGeneratedOnUpdate();
|
|
||||||
builder.Property(f => f.CustomerId).IsRequired();
|
|
||||||
builder.Property(f => f.QuoteId).IsRequired(false);
|
|
||||||
builder.Property(f => f.RefundId).IsRequired(false);
|
|
||||||
builder.Property(f => f.ShoppingCartId).IsRequired();
|
|
||||||
builder.Property(f => f.Status).HasConversion<int>().IsRequired();
|
|
||||||
builder.Property(f => f.Notes).HasColumnType("jsonb").IsRequired(false);
|
|
||||||
|
|
||||||
builder.HasOne(f => f.Quote)
|
|
||||||
.WithOne(f => f.Order)
|
|
||||||
.HasForeignKey<Order>(f => f.QuoteId)
|
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
|
||||||
|
|
||||||
builder.HasOne(f => f.Customer)
|
|
||||||
.WithMany(f => f.Orders)
|
|
||||||
.HasForeignKey(f => f.CustomerId)
|
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
namespace LiteCharms.Entities.Configuration;
|
|
||||||
|
|
||||||
public class ProductConfiguration : IEntityTypeConfiguration<Product>
|
|
||||||
{
|
|
||||||
public void Configure(EntityTypeBuilder<Product> builder)
|
|
||||||
{
|
|
||||||
builder.ToTable(nameof(Product));
|
|
||||||
|
|
||||||
builder.HasKey(f => f.Id);
|
|
||||||
builder.Property(f => f.Name).IsRequired();
|
|
||||||
builder.Property(f => f.Description).IsRequired();
|
|
||||||
builder.Property(f => f.Active).HasDefaultValue(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
namespace LiteCharms.Entities.Configuration;
|
|
||||||
|
|
||||||
public class QuoteConfiguration : IEntityTypeConfiguration<Quote>
|
|
||||||
{
|
|
||||||
public void Configure(EntityTypeBuilder<Quote> builder)
|
|
||||||
{
|
|
||||||
builder.ToTable(nameof(Quote));
|
|
||||||
|
|
||||||
builder.HasKey(f => f.Id);
|
|
||||||
builder.Property(f => f.CreatedAt).IsRequired().ValueGeneratedOnAdd();
|
|
||||||
builder.Property(f => f.UpdatedAt).IsRequired().ValueGeneratedOnAddOrUpdate();
|
|
||||||
builder.Property(f => f.ExpiredAt).IsRequired(false);
|
|
||||||
builder.Property(f => f.CustomerId).IsRequired();
|
|
||||||
builder.Property(f => f.Status).IsRequired();
|
|
||||||
builder.Property(f => f.ShoppingCartId).IsRequired();
|
|
||||||
builder.Property(f => f.Reason).IsRequired(false);
|
|
||||||
|
|
||||||
builder.HasOne(f => f.Customer)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey(f => f.CustomerId)
|
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
namespace LiteCharms.Entities.Configuration;
|
|
||||||
|
|
||||||
public class ShoppingCartConfiguration : IEntityTypeConfiguration<ShoppingCart>
|
|
||||||
{
|
|
||||||
public void Configure(EntityTypeBuilder<ShoppingCart> builder)
|
|
||||||
{
|
|
||||||
builder.ToTable(nameof(ShoppingCart));
|
|
||||||
|
|
||||||
builder.HasKey(f => f.Id);
|
|
||||||
builder.Property(f => f.CreatedAt).IsRequired().ValueGeneratedOnAdd();
|
|
||||||
builder.Property(f => f.UpdatedAt).IsRequired().ValueGeneratedOnAddOrUpdate();
|
|
||||||
builder.Property(f => f.CustomerId).IsRequired(false);
|
|
||||||
builder.Property(f => f.OrderId).IsRequired(false);
|
|
||||||
builder.Property(f => f.QuoteId).IsRequired(false);
|
|
||||||
|
|
||||||
builder.HasOne(f => f.Customer)
|
|
||||||
.WithMany(c => c.ShoppingCarts)
|
|
||||||
.HasForeignKey(f => f.CustomerId)
|
|
||||||
.OnDelete(DeleteBehavior.NoAction);
|
|
||||||
|
|
||||||
builder.HasOne(f => f.Order)
|
|
||||||
.WithOne(o => o.ShoppingCart)
|
|
||||||
.HasForeignKey<Order>(o => o.ShoppingCartId)
|
|
||||||
.OnDelete(DeleteBehavior.NoAction);
|
|
||||||
|
|
||||||
builder.HasOne(f => f.Quote)
|
|
||||||
.WithOne(o => o.ShoppingCart)
|
|
||||||
.HasForeignKey<Quote>(o => o.ShoppingCartId)
|
|
||||||
.OnDelete(DeleteBehavior.NoAction);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
namespace LiteCharms.Entities.Configuration;
|
|
||||||
|
|
||||||
public class ShoppingCartItemConfiguration : IEntityTypeConfiguration<ShoppingCartItem>
|
|
||||||
{
|
|
||||||
public void Configure(EntityTypeBuilder<ShoppingCartItem> builder)
|
|
||||||
{
|
|
||||||
builder.ToTable(nameof(ShoppingCartItem));
|
|
||||||
|
|
||||||
builder.HasKey(f => f.Id);
|
|
||||||
builder.Property(f => f.CreatedAt).IsRequired().ValueGeneratedOnAdd();
|
|
||||||
builder.Property(f => f.UpdatedAt).IsRequired().ValueGeneratedOnAddOrUpdate();
|
|
||||||
builder.Property(f => f.Quantity).IsRequired().HasDefaultValue(1);
|
|
||||||
builder.Property(f => f.ProductPriceId).IsRequired();
|
|
||||||
|
|
||||||
builder.HasOne(f => f.ProductPrice)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey(f => f.ProductPriceId)
|
|
||||||
.OnDelete(DeleteBehavior.NoAction);
|
|
||||||
|
|
||||||
builder.HasOne(f => f.ShoppingCart)
|
|
||||||
.WithMany(f => f.ShoppingCartItems)
|
|
||||||
.HasForeignKey(f => f.ShoppingCartId)
|
|
||||||
.OnDelete(DeleteBehavior.NoAction);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<SignAssembly>True</SignAssembly>
|
|
||||||
<AssemblyOriginatorKeyFile>..\LiteCharms.snk</AssemblyOriginatorKeyFile>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<!-- Nuget Package Details -->
|
|
||||||
<PropertyGroup>
|
|
||||||
<PackageId>LiteCharms.Entities</PackageId>
|
|
||||||
<Version>1.0.20</Version>
|
|
||||||
<Authors>Khwezi Mngoma</Authors>
|
|
||||||
<Company>Lite Charms (PTY) Ltd</Company>
|
|
||||||
<Description>Shared entities for Lite Charms applications.</Description>
|
|
||||||
<PackageProjectUrl>https://gitea.khongisa.co.za/litecharms/components</PackageProjectUrl>
|
|
||||||
<RepositoryUrl>https://gitea.khongisa.co.za/litecharms/components.git</RepositoryUrl>
|
|
||||||
<RepositoryType>git</RepositoryType>
|
|
||||||
<PackageLicenseFile>LICENSE</PackageLicenseFile>
|
|
||||||
<PackageTags>utility;dotnet</PackageTags>
|
|
||||||
<PackageIcon>icon.png</PackageIcon>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<None Include="..\LICENSE" Pack="true" PackagePath="\"/>
|
|
||||||
<None Include="..\icon.png" Pack="true" PackagePath="\" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<!-- Database -->
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.7" />
|
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.7" />
|
|
||||||
|
|
||||||
<!-- Global Usings -->
|
|
||||||
<Using Include="Microsoft.EntityFrameworkCore" />
|
|
||||||
<Using Include="Microsoft.EntityFrameworkCore.Metadata.Builders" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="..\LiteCharms.Models\LiteCharms.Models.csproj" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
using LiteCharms.Entities.Configuration;
|
|
||||||
|
|
||||||
namespace LiteCharms.Entities;
|
|
||||||
|
|
||||||
[EntityTypeConfiguration<OrderConfiguration, Order>]
|
|
||||||
public class Order : Models.Order
|
|
||||||
{
|
|
||||||
public virtual OrderRefund? Refund { get; set; }
|
|
||||||
|
|
||||||
public virtual Customer? Customer { get; set; }
|
|
||||||
|
|
||||||
public virtual Quote? Quote { get; set; }
|
|
||||||
|
|
||||||
public virtual ShoppingCart? ShoppingCart { get; set; }
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
namespace LiteCharms.Entities;
|
|
||||||
|
|
||||||
public class ShoppingCartItem : Models.ShoppingCartItem
|
|
||||||
{
|
|
||||||
public virtual ShoppingCart? ShoppingCart { get; set; }
|
|
||||||
|
|
||||||
public virtual ProductPrice? ProductPrice { get; set; }
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
using LiteCharms.Models.Configuraton.Email;
|
|
||||||
|
|
||||||
namespace LiteCharms.Extensions;
|
|
||||||
|
|
||||||
public static class Email
|
|
||||||
{
|
|
||||||
public static IServiceCollection AddEmailServices(this IServiceCollection services, IConfiguration configuration)
|
|
||||||
{
|
|
||||||
services.Configure<SmtpSettings>(configuration.GetSection("Email"));
|
|
||||||
|
|
||||||
return services;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
using LiteCharms.Models;
|
|
||||||
|
|
||||||
namespace LiteCharms.Extensions;
|
|
||||||
|
|
||||||
public static class EntityModeMappers
|
|
||||||
{
|
|
||||||
public static ShoppingCartItem ToModel(this Entities.ShoppingCartItem entity) =>
|
|
||||||
new()
|
|
||||||
{
|
|
||||||
Id = entity.Id,
|
|
||||||
CreatedAt = entity.CreatedAt,
|
|
||||||
UpdatedAt = entity.UpdatedAt,
|
|
||||||
ProductPriceId = entity.ProductPriceId,
|
|
||||||
Quantity = entity.Quantity,
|
|
||||||
ShoppingCartId = entity.ShoppingCartId
|
|
||||||
};
|
|
||||||
|
|
||||||
public static ShoppingCart ToModel(this Entities.ShoppingCart entity) =>
|
|
||||||
new()
|
|
||||||
{
|
|
||||||
Id = entity.Id,
|
|
||||||
CreatedAt = entity.CreatedAt,
|
|
||||||
UpdatedAt = entity.UpdatedAt,
|
|
||||||
CustomerId = entity.CustomerId,
|
|
||||||
OrderId = entity.OrderId,
|
|
||||||
QuoteId = entity.QuoteId
|
|
||||||
};
|
|
||||||
|
|
||||||
public static Quote ToModel(this Entities.Quote entity) =>
|
|
||||||
new()
|
|
||||||
{
|
|
||||||
Id = entity.Id,
|
|
||||||
CreatedAt = entity.CreatedAt,
|
|
||||||
UpdatedAt = entity.UpdatedAt,
|
|
||||||
CustomerId = entity.CustomerId,
|
|
||||||
ExpiredAt = entity.ExpiredAt,
|
|
||||||
Reason = entity.Reason,
|
|
||||||
ShoppingCartId = entity.ShoppingCartId,
|
|
||||||
Status = entity.Status
|
|
||||||
};
|
|
||||||
|
|
||||||
public static Notification ToModel(this Entities.Notification entity) =>
|
|
||||||
new()
|
|
||||||
{
|
|
||||||
Id = entity.Id,
|
|
||||||
CreatedAt = entity.CreatedAt,
|
|
||||||
Description = entity.Description,
|
|
||||||
Direction = entity.Direction,
|
|
||||||
CorrelationId = entity.CorrelationId,
|
|
||||||
CorrelationIdType = entity.CorrelationIdType,
|
|
||||||
IsInternal = entity.IsInternal,
|
|
||||||
Author = entity.Author,
|
|
||||||
Platform = entity.Platform,
|
|
||||||
PlatformAddress = entity.PlatformAddress,
|
|
||||||
Title = entity.Title,
|
|
||||||
Processed = entity.Processed
|
|
||||||
};
|
|
||||||
|
|
||||||
public static Customer ToModel(this Entities.Customer entity) =>
|
|
||||||
new()
|
|
||||||
{
|
|
||||||
Id = entity.Id,
|
|
||||||
CreatedAt = entity.CreatedAt,
|
|
||||||
UpdatedAt = entity.UpdatedAt,
|
|
||||||
Active = entity.Active,
|
|
||||||
Address = entity.Address,
|
|
||||||
City = entity.City,
|
|
||||||
Company = entity.Company,
|
|
||||||
Country = entity.Country,
|
|
||||||
Discord = entity.Discord,
|
|
||||||
Email = entity.Email,
|
|
||||||
LastName = entity.LastName,
|
|
||||||
LinkedIn = entity.LinkedIn,
|
|
||||||
Name = entity.Name,
|
|
||||||
Phone = entity.Phone,
|
|
||||||
PostalCode = entity.PostalCode,
|
|
||||||
Region = entity.Region,
|
|
||||||
Slack = entity.Slack,
|
|
||||||
Tax = entity.Tax,
|
|
||||||
Website = entity.Website,
|
|
||||||
Whatsapp = entity.Whatsapp
|
|
||||||
};
|
|
||||||
|
|
||||||
public static Lead ToModel(this Entities.Lead entity) =>
|
|
||||||
new()
|
|
||||||
{
|
|
||||||
Id = entity.Id,
|
|
||||||
CreatedAt = entity.CreatedAt,
|
|
||||||
UpdatedAt = entity.UpdatedAt,
|
|
||||||
AdGroupId = entity.AdGroupId,
|
|
||||||
AdName = entity.AdName,
|
|
||||||
AppClickId = entity.AppClickId,
|
|
||||||
AttributionHash = entity.AttributionHash,
|
|
||||||
CampaignId = entity.CampaignId,
|
|
||||||
ClickLocation = entity.ClickLocation,
|
|
||||||
CustomerId = entity.CustomerId,
|
|
||||||
FeedItemId = entity.FeedItemId,
|
|
||||||
Source = entity.Source,
|
|
||||||
ClickId = entity.ClickId,
|
|
||||||
TargetId = entity.TargetId,
|
|
||||||
WebClickId = entity.WebClickId,
|
|
||||||
Status = entity.Status
|
|
||||||
};
|
|
||||||
|
|
||||||
public static Order ToModel(this Entities.Order entity) =>
|
|
||||||
new()
|
|
||||||
{
|
|
||||||
Id = entity.Id,
|
|
||||||
CreatedAt = entity.CreatedAt,
|
|
||||||
UpdatedAt = entity.UpdatedAt,
|
|
||||||
CustomerId = entity.CustomerId,
|
|
||||||
Notes = entity.Notes,
|
|
||||||
RefundId = entity.RefundId,
|
|
||||||
QuoteId = entity.QuoteId,
|
|
||||||
Status = entity.Status,
|
|
||||||
ShoppingCartId = entity.ShoppingCartId
|
|
||||||
};
|
|
||||||
|
|
||||||
public static OrderRefund ToModel(this Entities.OrderRefund entity) =>
|
|
||||||
new()
|
|
||||||
{
|
|
||||||
Id = entity.Id,
|
|
||||||
CreatedAt = entity.CreatedAt,
|
|
||||||
OrderId = entity.OrderId,
|
|
||||||
Reason = entity.Reason,
|
|
||||||
Amount = entity.Amount
|
|
||||||
};
|
|
||||||
|
|
||||||
public static Product ToModel(this Entities.Product entity) =>
|
|
||||||
new()
|
|
||||||
{
|
|
||||||
Id = entity.Id,
|
|
||||||
Name = entity.Name,
|
|
||||||
Description = entity.Description,
|
|
||||||
Active = entity.Active
|
|
||||||
};
|
|
||||||
|
|
||||||
public static ProductPrice ToModel(this Entities.ProductPrice entity) =>
|
|
||||||
new()
|
|
||||||
{
|
|
||||||
Id = entity.Id,
|
|
||||||
ProductId = entity.ProductId,
|
|
||||||
Price = entity.Price,
|
|
||||||
Active = entity.Active,
|
|
||||||
CreatedAt = entity.CreatedAt,
|
|
||||||
Discount = entity.Discount,
|
|
||||||
UpdatedAt = entity.UpdatedAt
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
using LiteCharms.Infrastructure.HealthChecks;
|
|
||||||
|
|
||||||
namespace LiteCharms.Extensions;
|
|
||||||
|
|
||||||
public static class HealthChecks
|
|
||||||
{
|
|
||||||
public static IServiceCollection AddQuartzHealtchCheck(this IServiceCollection services)
|
|
||||||
{
|
|
||||||
services.AddHealthChecks().AddCheck<QuartzHealthCheck>("Quartz");
|
|
||||||
|
|
||||||
return services;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static IServiceCollection AddPostgresHealtchCheck(this IServiceCollection services)
|
|
||||||
{
|
|
||||||
services.AddHealthChecks().AddCheck<PostgresHealthCheck>("PostgreSQL");
|
|
||||||
|
|
||||||
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,14 +0,0 @@
|
|||||||
using LiteCharms.Infrastructure.Database;
|
|
||||||
|
|
||||||
namespace LiteCharms.Extensions;
|
|
||||||
|
|
||||||
public static class Postgres
|
|
||||||
{
|
|
||||||
public static IServiceCollection AddShopDatabase(this IServiceCollection services, IConfiguration configuration)
|
|
||||||
{
|
|
||||||
services.AddPooledDbContextFactory<ShopDbContext>(options =>
|
|
||||||
options.UseNpgsql(configuration.GetConnectionString("PostgresShop")));
|
|
||||||
|
|
||||||
return services;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
using LiteCharms.Features.MidrandBooks.Categories;
|
||||||
|
using LiteCharms.Features.MidrandBooks.Products;
|
||||||
|
|
||||||
|
namespace LiteCharms.Features.MidrandBooks.Seed;
|
||||||
|
|
||||||
|
public sealed class CategorySeederService(CategoryService categoryService, ProductService productService, IFeatureManager features,
|
||||||
|
ILogger<CategorySeederService> logger) : BackgroundService
|
||||||
|
{
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
if (!await features.IsEnabledAsync("CategorySeederService")) return;
|
||||||
|
|
||||||
|
logger.LogInformation("Category and Product-Tag Mapping Seeding started (15-char limit applied)");
|
||||||
|
|
||||||
|
// Initialize Bogus to ensure repeatable distribution matrix pathing
|
||||||
|
var faker = new Faker();
|
||||||
|
Randomizer.Seed = new Random(101);
|
||||||
|
|
||||||
|
// 1. Curate Broad Book Categories (IsMain = true, Max 20, Max 15 chars)
|
||||||
|
var broadMainCategories = new[]
|
||||||
|
{
|
||||||
|
"Fiction", "Non-Fiction", "Youth & Kids", "Academic",
|
||||||
|
"Biographies", "Business", "Sci-Fi & Fantasy",
|
||||||
|
"Thrillers", "Self-Help", "History",
|
||||||
|
"Spirituality", "Arts & Photo", "Technology",
|
||||||
|
"Cookbooks", "Travel & Maps", "Poetry & Drama", "Graphic Novels"
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. Curate Niche Subcategories/Tags (IsMain = false, Max 15 chars)
|
||||||
|
var specializedSubCategories = new[]
|
||||||
|
{
|
||||||
|
"Cyberpunk", "Space Opera", "Historical Fix", "Cozy Mystery", "True Crime",
|
||||||
|
"Agile Project", "Software Eng", "AI & ML", "Cloud Comput",
|
||||||
|
"SA History", "African Lit", "Apartheid Era", "Mandela Legacy",
|
||||||
|
"Finance", "Investments", "Startup", "Leadership",
|
||||||
|
"CBT Therapy", "Mindfulness", "Yoga & Health",
|
||||||
|
"Baking Basics", "African Food", "Vegan Recipes",
|
||||||
|
"Ancient World", "WWII History", "Geopolitics",
|
||||||
|
"Writing Guides", "Criticism", "Classic Poetry",
|
||||||
|
"Early Learning", "Teen Romance", "Survival",
|
||||||
|
"Urban Fantasy", "Dark Fantasy", "Psych Thriller", "Hard Sci-Fi",
|
||||||
|
"Data Science", "DevOps", "Cybersecurity",
|
||||||
|
"Economics", "Real Estate", "Governance",
|
||||||
|
"Essays", "Memoirs", "Art History",
|
||||||
|
"Architecture", "Photography", "Travel Writing",
|
||||||
|
"Gaming Culture", "Philosophy", "Ethics",
|
||||||
|
"DIY Home", "SA Gardening", "Parenting"
|
||||||
|
};
|
||||||
|
|
||||||
|
// 3. Seed Main Categories into the System
|
||||||
|
logger.LogInformation("Seeding broad main categories...");
|
||||||
|
foreach (var mainCat in broadMainCategories)
|
||||||
|
{
|
||||||
|
if (stoppingToken.IsCancellationRequested) return;
|
||||||
|
|
||||||
|
// Defensive truncation fallback just in case strings get modified later
|
||||||
|
string safeName = mainCat.Length > 15 ? mainCat.Substring(0, 15) : mainCat;
|
||||||
|
|
||||||
|
var result = await categoryService.CreateCategoryAsync(safeName, isMain: true, stoppingToken);
|
||||||
|
if (result.IsFailed && !result.Errors[0].Message.Contains("already exists", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
logger.LogWarning("Notice while adding main category '{Name}': {Msg}", safeName, result.Errors[0].Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Seed Subcategories into the System
|
||||||
|
logger.LogInformation("Seeding boundless specialized niche tags...");
|
||||||
|
foreach (var subCat in specializedSubCategories)
|
||||||
|
{
|
||||||
|
if (stoppingToken.IsCancellationRequested) return;
|
||||||
|
|
||||||
|
string safeName = subCat.Length > 15 ? subCat.Substring(0, 15) : subCat;
|
||||||
|
|
||||||
|
var result = await categoryService.CreateCategoryAsync(safeName, isMain: false, stoppingToken);
|
||||||
|
if (result.IsFailed && !result.Errors[0].Message.Contains("already exists", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
logger.LogWarning("Notice while adding subcategory '{Name}': {Msg}", safeName, result.Errors[0].Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Query back all enabled categories to extract active IDs for junction mapping
|
||||||
|
var fetchMainResult = await categoryService.GetCategoriesAsync(isMain: true, stoppingToken);
|
||||||
|
var fetchSubResult = await categoryService.GetCategoriesAsync(isMain: false, stoppingToken);
|
||||||
|
|
||||||
|
if (fetchMainResult.IsFailed || fetchSubResult.IsFailed)
|
||||||
|
{
|
||||||
|
logger.LogError("Aborting junction seeding: Could not retrieve categories from data store.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var mainCategoryIds = fetchMainResult.Value.Select(c => c.Id).ToArray();
|
||||||
|
var subCategoryIds = fetchSubResult.Value.Select(c => c.Id).ToArray();
|
||||||
|
|
||||||
|
// 6. Map Categories to your Product Collection (Product IDs 0 - 21)
|
||||||
|
logger.LogInformation("Beginning Product-Category mapping assignments for Product IDs 0 through 21...");
|
||||||
|
|
||||||
|
for (long productId = 0; productId <= 21; productId++)
|
||||||
|
{
|
||||||
|
if (stoppingToken.IsCancellationRequested) break;
|
||||||
|
|
||||||
|
// Every book belongs to 1 or 2 main categories
|
||||||
|
int mainCategoriesToAssign = faker.Random.Number(1, 2);
|
||||||
|
var chosenMainIds = faker.PickRandom(mainCategoryIds, mainCategoriesToAssign).Distinct();
|
||||||
|
|
||||||
|
foreach (var mainId in chosenMainIds)
|
||||||
|
{
|
||||||
|
var linkResult = await productService.AddProductCategoryAsync(productId, mainId, stoppingToken);
|
||||||
|
if (linkResult.IsFailed)
|
||||||
|
{
|
||||||
|
if (!linkResult.Errors[0].Message.Contains("exist", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
logger.LogDebug("Junction note for Product {PId} and Main Category {CId}: {Msg}", productId, mainId, linkResult.Errors[0].Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every book gets 1 to 4 granular subgenre tags
|
||||||
|
int subCategoriesToAssign = faker.Random.Number(1, 4);
|
||||||
|
var chosenSubIds = faker.PickRandom(subCategoryIds, subCategoriesToAssign).Distinct();
|
||||||
|
|
||||||
|
foreach (var subId in chosenSubIds)
|
||||||
|
{
|
||||||
|
var linkResult = await productService.AddProductCategoryAsync(productId, subId, stoppingToken);
|
||||||
|
if (linkResult.IsFailed && !linkResult.Errors[0].Message.Contains("exist", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
logger.LogDebug("Junction note for Product {PId} and Sub Category {CId}: {Msg}", productId, subId, linkResult.Errors[0].Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogInformation("Category and Product-Tag Mapping Seeding completed successfully.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
namespace LiteCharms.Features.MidrandBooks.Seed.Configuration;
|
||||||
|
|
||||||
|
public sealed class CdnSettings
|
||||||
|
{
|
||||||
|
public string? BaseCdn { get; set; }
|
||||||
|
|
||||||
|
public string[]? BookCovers { get; set; }
|
||||||
|
|
||||||
|
public string[]? Authors { get; set; }
|
||||||
|
|
||||||
|
public string[]? AuthorThumbnails { get; set; }
|
||||||
|
|
||||||
|
public string[]? BookThumbnails { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
using LiteCharms.Features.MidrandBooks.Customers;
|
||||||
|
using LiteCharms.Features.MidrandBooks.Customers.Models;
|
||||||
|
using LiteCharms.Features.MidrandBooks.Orders;
|
||||||
|
using LiteCharms.Features.MidrandBooks.Orders.Models;
|
||||||
|
|
||||||
|
namespace LiteCharms.Features.MidrandBooks.Seed;
|
||||||
|
|
||||||
|
public sealed class CustomerSeederService(CustomerService customerService, OrderService orderService, IFeatureManager features,
|
||||||
|
ILogger<CustomerSeederService> logger) : BackgroundService
|
||||||
|
{
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
if (!await features.IsEnabledAsync("CustomerSeederService")) return;
|
||||||
|
|
||||||
|
logger.LogInformation("Customer Seeding started");
|
||||||
|
|
||||||
|
// 1: Add shipping providers (shippingProvider IDs will be created in sequence: 1, 2, 3)
|
||||||
|
await orderService.CreateShippingProviderAsync(new CreateShippingProvider(ShippingProviderTypes.FastWay, "FastWay Couriers", 39, "https://www.fastway.co.za/our-services/track-your-parcel"), stoppingToken);
|
||||||
|
await orderService.CreateShippingProviderAsync(new CreateShippingProvider(ShippingProviderTypes.DHL, "DHL Couriers", 60, "https://www.dhl.com/za-en/home/tracking.html"), stoppingToken);
|
||||||
|
await orderService.CreateShippingProviderAsync(new CreateShippingProvider(ShippingProviderTypes.PostNet, "Postnet Overnight Mail", 45, "https://www.postnet.co.za/tracker"), stoppingToken);
|
||||||
|
|
||||||
|
// Initialize Bogus Faker engine
|
||||||
|
var faker = new Faker();
|
||||||
|
var culture = CultureInfo.InvariantCulture;
|
||||||
|
|
||||||
|
// Ensure repeatable datasets across executions
|
||||||
|
Randomizer.Seed = new Random(84);
|
||||||
|
|
||||||
|
// South African Provinces array lookup helper
|
||||||
|
var southAfricanProvinces = new[]
|
||||||
|
{
|
||||||
|
"Gauteng", "Western Cape", "KwaZulu-Natal", "Eastern Cape",
|
||||||
|
"Free State", "Limpopo", "Mpumalanga", "North West", "Northern Cape"
|
||||||
|
};
|
||||||
|
|
||||||
|
// South African major towns matching geographic boundaries roughly
|
||||||
|
var southAfricanCities = new[] { "Midrand", "Johannesburg", "Pretoria", "Cape Town", "Durban", "Gqeberha", "Polokwane", "Nelspruit", "Bloemfontein" };
|
||||||
|
|
||||||
|
// Tracks sequential Address IDs added globally to the system across all loops
|
||||||
|
long addressSequenceCounter = 0;
|
||||||
|
|
||||||
|
// 2: Create 15 customers with resources sequentially
|
||||||
|
for (int c = 0; c < 15; c++)
|
||||||
|
{
|
||||||
|
if (stoppingToken.IsCancellationRequested) break;
|
||||||
|
|
||||||
|
// Determine if this specific iteration represents a Corporate Client or an Individual Consumer
|
||||||
|
bool isCompanyCustomer = faker.Random.Bool(0.4f); // 40% chance of seeding a corporate entity
|
||||||
|
|
||||||
|
string customerFirstName = faker.Name.FirstName();
|
||||||
|
string customerLastName = faker.Name.LastName();
|
||||||
|
|
||||||
|
string companyName = isCompanyCustomer ? faker.Company.CompanyName() : "";
|
||||||
|
string companySuffix = isCompanyCustomer ? faker.Company.CompanySuffix() : "";
|
||||||
|
string fullCompanyName = isCompanyCustomer ? $"{companyName} {companySuffix}" : "";
|
||||||
|
|
||||||
|
string customerEmail = isCompanyCustomer
|
||||||
|
? faker.Internet.Email(firstName: companyName, provider: "co.za").ToLower(culture)
|
||||||
|
: faker.Internet.Email(customerFirstName, customerLastName).ToLower(culture);
|
||||||
|
|
||||||
|
string customerPhone = faker.Phone.PhoneNumber("087#######"); // Corporate VOIP / Personal South African cell line format
|
||||||
|
string customerWebsite = isCompanyCustomer ? faker.Internet.Url().Replace("www.", $"www.{companyName.ToLower(culture)}.") : "";
|
||||||
|
string customerVat = isCompanyCustomer ? faker.Phone.PhoneNumber("4#########") : ""; // SA VAT registration starts with a 4
|
||||||
|
|
||||||
|
// Randomly select distinct Social Media channels
|
||||||
|
var chosenSocialType = faker.PickRandom<SocialMediaTypes>();
|
||||||
|
string socialMediaUrl = chosenSocialType switch
|
||||||
|
{
|
||||||
|
SocialMediaTypes.LinkedIn => isCompanyCustomer ? $"https://linkedin.com/company/{companyName.ToLower(culture)}" : $"https://linkedin.com/in/{customerFirstName.ToLower(culture)}-{customerLastName.ToLower(culture)}",
|
||||||
|
SocialMediaTypes.GitHub => $"https://github.com/{(isCompanyCustomer ? "orgs/" + companyName.ToLower(culture) : customerFirstName.ToLower(culture))}",
|
||||||
|
_ => $"https://x.com/{(isCompanyCustomer ? companyName.ToLower(culture) : customerFirstName.ToLower(culture))}"
|
||||||
|
};
|
||||||
|
|
||||||
|
// 3: Create customer
|
||||||
|
var createCustomerResult = await customerService.CreateCustomerAsync(new CreateCustomer
|
||||||
|
{
|
||||||
|
Company = fullCompanyName,
|
||||||
|
Email = customerEmail,
|
||||||
|
Phone = customerPhone,
|
||||||
|
Website = customerWebsite,
|
||||||
|
SocialMedia =
|
||||||
|
[
|
||||||
|
new Models.SocialMedia
|
||||||
|
{
|
||||||
|
Name = chosenSocialType.ToString(),
|
||||||
|
Type = chosenSocialType,
|
||||||
|
ImageUrl = $"https://cdn.example.com/icons/{chosenSocialType.ToString().ToLower(culture)}.png",
|
||||||
|
Url = socialMediaUrl
|
||||||
|
}
|
||||||
|
],
|
||||||
|
VatNumber = customerVat
|
||||||
|
}, stoppingToken);
|
||||||
|
|
||||||
|
if (createCustomerResult.IsFailed)
|
||||||
|
{
|
||||||
|
logger.LogError("Failed to create customer record at index {Index}: {Error}", c, createCustomerResult.Errors[0].Message);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
var assignedCustomerId = createCustomerResult.Value;
|
||||||
|
|
||||||
|
// 4: Create customer contact (only if customer is a company entity)
|
||||||
|
if (isCompanyCustomer)
|
||||||
|
{
|
||||||
|
var contactFirstName = faker.Name.FirstName();
|
||||||
|
var contactLastName = faker.Name.LastName();
|
||||||
|
|
||||||
|
var createContactResult = await customerService.CreateCustomerContactAsync(assignedCustomerId, new CreateCustomerContact
|
||||||
|
{
|
||||||
|
Name = contactFirstName,
|
||||||
|
LastName = contactLastName,
|
||||||
|
Phone = faker.Phone.PhoneNumber("082#######"), // Typical South African mobile prefix format
|
||||||
|
Email = faker.Internet.Email(contactFirstName, contactLastName, provider: "company.co.za").ToLower(culture),
|
||||||
|
Type = ContactTypes.Business
|
||||||
|
}, stoppingToken);
|
||||||
|
|
||||||
|
if (createContactResult.IsFailed)
|
||||||
|
{
|
||||||
|
logger.LogError("Failed to create company customer contact relation: {Error}", createContactResult.Errors[0].Message);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared Randomizations for Regional Postal/Building details
|
||||||
|
var primaryState = faker.PickRandom(southAfricanProvinces);
|
||||||
|
var primaryCity = faker.PickRandom(southAfricanCities);
|
||||||
|
var shippingPostalCode = faker.Random.Replace("####");
|
||||||
|
|
||||||
|
var billingState = faker.PickRandom(southAfricanProvinces);
|
||||||
|
var billingCity = faker.PickRandom(southAfricanCities);
|
||||||
|
var billingPostalCode = faker.Random.Replace("####");
|
||||||
|
|
||||||
|
// 5: Create customer address - SHIPPING
|
||||||
|
var createShippingAddressResult = await customerService.CreateCustomerAddressAsync(assignedCustomerId, new CreateCustomerAddress
|
||||||
|
{
|
||||||
|
Name = isCompanyCustomer ? "Head Office Distribution" : "My Home Residence",
|
||||||
|
BuildingType = faker.PickRandom<AddressBuildingTypes>(),
|
||||||
|
Type = AddressType.Shipping,
|
||||||
|
Street = $"{faker.Address.BuildingNumber()} {faker.Address.StreetName()} Street",
|
||||||
|
City = primaryCity,
|
||||||
|
State = primaryState,
|
||||||
|
Country = "South Africa",
|
||||||
|
IsPrimary = true,
|
||||||
|
Enabled = true,
|
||||||
|
PostalCode = shippingPostalCode
|
||||||
|
}, stoppingToken);
|
||||||
|
|
||||||
|
long currentCustomerShippingAddressId = 0;
|
||||||
|
if (createShippingAddressResult.IsSuccess)
|
||||||
|
{
|
||||||
|
addressSequenceCounter++;
|
||||||
|
currentCustomerShippingAddressId = addressSequenceCounter;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
logger.LogWarning("Failed to attach Shipping address profile: {Error}", createShippingAddressResult.Errors[0].Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6: Create customer address - BILLING
|
||||||
|
var createBillingAddressResult = await customerService.CreateCustomerAddressAsync(assignedCustomerId, new CreateCustomerAddress
|
||||||
|
{
|
||||||
|
Name = isCompanyCustomer ? "Accounts Payable Department" : "Billing Address",
|
||||||
|
BuildingType = faker.PickRandom<AddressBuildingTypes>(),
|
||||||
|
Type = AddressType.Billing,
|
||||||
|
Street = isCompanyCustomer ? $"{faker.Address.BuildingNumber()} {faker.Address.StreetName()} Boulevard" : $"{faker.Address.BuildingNumber()} {faker.Address.StreetName()} Street",
|
||||||
|
City = billingCity,
|
||||||
|
State = billingState,
|
||||||
|
Country = "South Africa",
|
||||||
|
IsPrimary = false,
|
||||||
|
Enabled = true,
|
||||||
|
PostalCode = billingPostalCode
|
||||||
|
}, stoppingToken);
|
||||||
|
|
||||||
|
long currentCustomerBillingAddressId = 0;
|
||||||
|
if (createBillingAddressResult.IsSuccess)
|
||||||
|
{
|
||||||
|
addressSequenceCounter++;
|
||||||
|
currentCustomerBillingAddressId = addressSequenceCounter;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
logger.LogError("Failed to attach Billing address profile: {Error}", createBillingAddressResult.Errors[0].Message);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7: Challenge Extrapolation — Create a random number of orders (0 to 4 orders) per customer
|
||||||
|
int ordersToGenerate = faker.Random.Number(0, 4);
|
||||||
|
for (int o = 0; o < ordersToGenerate; o++)
|
||||||
|
{
|
||||||
|
var deliveryInstructions = faker.PickRandom(
|
||||||
|
"Leave at reception desk",
|
||||||
|
"Please call before delivery",
|
||||||
|
"At the intercom, dial 1 then option 2",
|
||||||
|
"Leave with security guard at front gate",
|
||||||
|
"Deliver to back delivery bay"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Use the calculated sequential Billing Address Id for order creation
|
||||||
|
var orderResult = await orderService.CreateOrderAsync(
|
||||||
|
assignedCustomerId,
|
||||||
|
new CreateOrder(currentCustomerBillingAddressId, deliveryInstructions),
|
||||||
|
stoppingToken
|
||||||
|
);
|
||||||
|
|
||||||
|
if (orderResult.IsFailed)
|
||||||
|
{
|
||||||
|
logger.LogWarning("Failed to create purchase order shell context: {Error}", orderResult.Errors[0].Message);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
long seededOrderId = orderResult.Value;
|
||||||
|
|
||||||
|
// Build a varying array of items using valid product bounds (IDs: 0 to 21)
|
||||||
|
int lineItemsCount = faker.Random.Number(1, 5);
|
||||||
|
var itemsList = new List<CreateOrderItem>();
|
||||||
|
|
||||||
|
for (int i = 0; i < lineItemsCount; i++)
|
||||||
|
{
|
||||||
|
long randomProductId = faker.Random.Number(0, 21);
|
||||||
|
long randomProductPriceId = faker.Random.Number(0, 21);
|
||||||
|
int itemQuantity = faker.Random.Number(1, 3);
|
||||||
|
|
||||||
|
itemsList.Add(new CreateOrderItem(randomProductId, randomProductPriceId, itemQuantity));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push bulk items payload into order via matching test framework signatures
|
||||||
|
var addItemsResult = await orderService.AddItemsToOrderAsync(seededOrderId, [.. itemsList], stoppingToken);
|
||||||
|
if (addItemsResult.IsFailed)
|
||||||
|
{
|
||||||
|
logger.LogWarning("Failed to link item collections to Order Id {Id}", seededOrderId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Randomly select an order status matrix pathing
|
||||||
|
var targetedOrderStatus = faker.PickRandom<OrderStatus>();
|
||||||
|
await orderService.UpdateOrderStatusAsync(seededOrderId, targetedOrderStatus, stoppingToken);
|
||||||
|
|
||||||
|
// Check lifecycle workflow criteria: Attach dynamic shipping if status warrants it
|
||||||
|
if (targetedOrderStatus != OrderStatus.Pending &&
|
||||||
|
targetedOrderStatus != OrderStatus.Cancelled &&
|
||||||
|
targetedOrderStatus != OrderStatus.Failed &&
|
||||||
|
currentCustomerShippingAddressId > 0)
|
||||||
|
{
|
||||||
|
// Select from seeded Shipping Providers in step 1 (IDs: 1, 2, or 3)
|
||||||
|
long randomShippingProviderId = faker.Random.Number(1, 3);
|
||||||
|
|
||||||
|
var addShippingResult = await orderService.AddShippingToOrderAsync(
|
||||||
|
seededOrderId,
|
||||||
|
new CreateShipping(currentCustomerShippingAddressId, randomShippingProviderId),
|
||||||
|
stoppingToken
|
||||||
|
);
|
||||||
|
|
||||||
|
if (addShippingResult.IsSuccess)
|
||||||
|
{
|
||||||
|
long assignedShippingId = addShippingResult.Value;
|
||||||
|
|
||||||
|
// Transition logistics flags matching delivery metrics
|
||||||
|
var shippingStatus = faker.PickRandom<ShippingStatuses>();
|
||||||
|
await orderService.UpdateShippingStatusAsync(seededOrderId, shippingStatus, stoppingToken);
|
||||||
|
|
||||||
|
if (shippingStatus == ShippingStatuses.Shipped || shippingStatus == ShippingStatuses.Delivered)
|
||||||
|
{
|
||||||
|
string rawTrackingCode = $"ZA{faker.Random.Replace("#########")}NV";
|
||||||
|
await orderService.UpdateShippingTrackingNumberAsync(seededOrderId, assignedShippingId, rawTrackingCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogInformation("Successfully seeded customer profile #{Index}: {Name} alongside {Count} orders.", c, isCompanyCustomer ? fullCompanyName : $"{customerFirstName} {customerLastName}", ordersToGenerate);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogInformation("Customer Seeding completed successfully.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<UserSecretsId>5c3bc894-8654-4691-99e8-f90d3414843f</UserSecretsId>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<!-- Quartz Scheduler-->
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Bogus" Version="35.6.5" />
|
||||||
|
<PackageReference Include="Meziantou.Analyzer" Version="3.0.101">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="Microsoft.FeatureManagement.AspNetCore" Version="4.5.0" />
|
||||||
|
<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.2" />
|
||||||
|
|
||||||
|
<!-- 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.17.0" />
|
||||||
|
<PackageReference Include="MimeKit" Version="4.17.0" />
|
||||||
|
|
||||||
|
<!-- Global Usings-->
|
||||||
|
<Using Include="MimeKit" />
|
||||||
|
<Using Include="MailKit.Net.Smtp" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!-- CQRS -->
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="FluentResults" Version="4.0.0" />
|
||||||
|
<PackageReference Include="Mediator.Abstractions" Version="3.0.2" />
|
||||||
|
|
||||||
|
<!-- Global Usings -->
|
||||||
|
<Using Include="FluentResults" />
|
||||||
|
<Using Include="Mediator" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!-- Amazon S3 SDK -->
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="AWSSDK.Extensions.NetCore.Setup" Version="4.0.4.4" />
|
||||||
|
<PackageReference Include="AWSSDK.S3" Version="4.0.24.1" />
|
||||||
|
<ProjectReference Include="..\LiteCharms.Features\LiteCharms.Features.csproj" />
|
||||||
|
|
||||||
|
<!-- global Usings -->
|
||||||
|
<Using Include="Amazon.S3" />
|
||||||
|
<Using Include="Amazon.S3.Model" />
|
||||||
|
<Using Include="Amazon.Runtime" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!-- Shared Usings -->
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="Bogus" />
|
||||||
|
<Using Include="Microsoft.FeatureManagement" />
|
||||||
|
<Using Include="System.Globalization" />
|
||||||
|
<Using Include="System.Reflection" />
|
||||||
|
<Using Include="Microsoft.AspNetCore.Builder" />
|
||||||
|
<Using Include="Microsoft.Extensions.Hosting" />
|
||||||
|
<Using Include="System.Text" />
|
||||||
|
<Using Include="System.Text.Json" />
|
||||||
|
<Using Include="System.Threading.Channels" />
|
||||||
|
<Using Include="System.Collections.ObjectModel" />
|
||||||
|
<Using Include="System.Diagnostics" />
|
||||||
|
<Using Include="System.Diagnostics.Metrics" />
|
||||||
|
<Using Include="Microsoft.Extensions.DependencyInjection" />
|
||||||
|
<Using Include="System.Security.Cryptography" />
|
||||||
|
<Using Include="Microsoft.Extensions.Options" />
|
||||||
|
<Using Include="Microsoft.Extensions.Logging" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\LiteCharms.Features.MidrandBooks\LiteCharms.Features.MidrandBooks.csproj" />
|
||||||
|
<ProjectReference Include="..\LiteCharms.Features\LiteCharms.Features.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<None Update="appsettings.json">
|
||||||
|
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||||
|
</None>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
using LiteCharms.Features.MidrandBooks.AuthorBooks;
|
||||||
|
using LiteCharms.Features.MidrandBooks.Authors;
|
||||||
|
using LiteCharms.Features.MidrandBooks.Products;
|
||||||
|
using LiteCharms.Features.MidrandBooks.Seed.Configuration;
|
||||||
|
|
||||||
|
namespace LiteCharms.Features.MidrandBooks.Seed;
|
||||||
|
|
||||||
|
public sealed class ProductsSeederService(ProductService productService, AuthorService authorService, BooksService booksService,
|
||||||
|
IFeatureManager features, IOptions<CdnSettings> options, ILogger<ProductsSeederService> logger) : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly CdnSettings cdnSettings = options.Value;
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
if (await features.IsEnabledAsync("ProductsSeederService") is not true) return;
|
||||||
|
|
||||||
|
logger.LogInformation("Product Seeding started");
|
||||||
|
|
||||||
|
if (cdnSettings.BookCovers is null || cdnSettings.BookCovers.Length == 0)
|
||||||
|
{
|
||||||
|
logger.LogWarning("No book covers found in CDN settings. Seeding aborted.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize Bogus Faker engine
|
||||||
|
var faker = new Faker();
|
||||||
|
var culture = CultureInfo.InvariantCulture;
|
||||||
|
|
||||||
|
// Ensure repeatable data sets if run multiple times by anchoring the seed
|
||||||
|
Randomizer.Seed = new Random(42);
|
||||||
|
|
||||||
|
foreach (var bookCover in cdnSettings.BookCovers)
|
||||||
|
{
|
||||||
|
if (stoppingToken.IsCancellationRequested) break;
|
||||||
|
|
||||||
|
// Generate beautifully mixed eclectic topics on the fly
|
||||||
|
var bookTopic = faker.PickRandom(
|
||||||
|
// --- Tech & IT ---
|
||||||
|
"C# 12 & Modern .NET Architecture",
|
||||||
|
"PostgreSQL Database Optimization",
|
||||||
|
"Docker & Kubernetes in Production",
|
||||||
|
"Domain-Driven Design Paradigms",
|
||||||
|
"Artificial Intelligence with Python",
|
||||||
|
|
||||||
|
// --- Sci-Fi & Fantasy ---
|
||||||
|
"The Chronicles of the Quantum Nebula",
|
||||||
|
"Legends of the Lost Cybernetic Kingdom",
|
||||||
|
"Parallel Dimensions and Rogue Time Streams",
|
||||||
|
"The Last Android in Neo-Johannesburg",
|
||||||
|
|
||||||
|
// --- Thrillers, Mystery & Crime ---
|
||||||
|
"The Midnight Code Cryptograph",
|
||||||
|
"Shadows in the Highveld",
|
||||||
|
"The Silent Witness of Midrand",
|
||||||
|
"Deception on the 14th Floor",
|
||||||
|
|
||||||
|
// --- Business, Finance & Wealth ---
|
||||||
|
"Mastering the South African Tech Market",
|
||||||
|
"The Modern Entrepreneur's Blueprint",
|
||||||
|
"Generational Wealth and Venture Capital",
|
||||||
|
"Negotiation Tactics for High-Stakes Deals",
|
||||||
|
|
||||||
|
// --- Self-Help & Personal Growth ---
|
||||||
|
"The Art of Relentless Focus",
|
||||||
|
"Building High-Performance Habits",
|
||||||
|
"The Mindfulness Guide for Software Engineers",
|
||||||
|
"Unlocking Creative Flow Under Pressure"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Dynamic raw title generation formulas executed via random function picker
|
||||||
|
var titlePatterns = new Func<string>[]
|
||||||
|
{
|
||||||
|
() => $"{faker.Company.CatchPhrase()} with {bookTopic}",
|
||||||
|
() => $"The {faker.Commerce.ProductAdjective()} Guide to {bookTopic}",
|
||||||
|
() => $"Mastering {bookTopic}: A {faker.Company.Bs()} Blueprint",
|
||||||
|
() => $"{bookTopic} for the Modern {faker.Name.JobTitle()}",
|
||||||
|
() => $"Advanced {bookTopic}: Demystifying the {faker.Company.CatchPhrase()}",
|
||||||
|
() => $"{faker.Random.Replace("###")} Blueprints for {bookTopic}"
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pick a format template and resolve it down to raw string text
|
||||||
|
var rawTitle = faker.PickRandom(titlePatterns)();
|
||||||
|
var bookTitle = rawTitle.Length > 255 ? rawTitle[..252] + "..." : rawTitle;
|
||||||
|
|
||||||
|
var rawSummary = $"A comprehensive guide to mastering {bookTopic}. Learn modern implementation techniques through real-world software engineering paradigms.";
|
||||||
|
var bookSummary = rawSummary.Length > 512 ? rawSummary[..509] + "..." : rawSummary;
|
||||||
|
|
||||||
|
// Generating a single concise paragraph ensures a rich text description falling safely well under 1024
|
||||||
|
var rawDescription = faker.Lorem.Paragraph(3);
|
||||||
|
var bookDescription = rawDescription.Length > 1024 ? rawDescription[..1021] + "..." : rawDescription;
|
||||||
|
|
||||||
|
var authorFirstName = faker.Name.FirstName();
|
||||||
|
var authorLastName = faker.Name.LastName();
|
||||||
|
var publisherCompany = faker.Company.CompanyName();
|
||||||
|
|
||||||
|
// Safe bounded random picking for book thumbnails
|
||||||
|
string? pickedBookThumbnail = null;
|
||||||
|
string? pickedBookThumbnail1 = null;
|
||||||
|
string? pickedBookThumbnail2 = null;
|
||||||
|
string? pickedBookThumbnail3 = null;
|
||||||
|
string? pickedBookThumbnail4 = null;
|
||||||
|
if (cdnSettings.BookThumbnails is not null && cdnSettings.BookThumbnails.Length > 0)
|
||||||
|
{
|
||||||
|
pickedBookThumbnail = $"{cdnSettings.BaseCdn}{faker.PickRandom(cdnSettings.BookThumbnails)}";
|
||||||
|
pickedBookThumbnail1 = $"{cdnSettings.BaseCdn}{faker.PickRandom(cdnSettings.BookThumbnails)}";
|
||||||
|
pickedBookThumbnail2 = $"{cdnSettings.BaseCdn}{faker.PickRandom(cdnSettings.BookThumbnails)}";
|
||||||
|
pickedBookThumbnail3 = $"{cdnSettings.BaseCdn}{faker.PickRandom(cdnSettings.BookThumbnails)}";
|
||||||
|
pickedBookThumbnail4 = $"{cdnSettings.BaseCdn}{faker.PickRandom(cdnSettings.BookThumbnails)}";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1: Add Product
|
||||||
|
var productCreateResult = await productService.CreateProductAsync(new Products.Models.CreateProduct
|
||||||
|
{
|
||||||
|
Name = bookTitle,
|
||||||
|
Summary = bookSummary,
|
||||||
|
Description = bookDescription,
|
||||||
|
ImageUrl = $"{cdnSettings.BaseCdn}{bookCover}",
|
||||||
|
Type = ProductTypes.Book,
|
||||||
|
Metadata = new Models.ProductMetadata
|
||||||
|
{
|
||||||
|
CopyrightInfo = $"© {DateTime.UtcNow.Year} {publisherCompany}. All rights reserved.",
|
||||||
|
ManufactureDate = faker.Date.Past(3).ToString("yyyy-MM-dd", culture),
|
||||||
|
Manufacturer = $"{authorFirstName} {authorLastName} / {publisherCompany}",
|
||||||
|
SerialNumber = faker.Phone.PhoneNumber("978-##########")
|
||||||
|
},
|
||||||
|
Categories = ["Coding", "Computers", "IT"],
|
||||||
|
ThumbnailUrls = pickedBookThumbnail is not null ? [pickedBookThumbnail, pickedBookThumbnail1!, pickedBookThumbnail2!, pickedBookThumbnail3!, pickedBookThumbnail4!] : null
|
||||||
|
}, stoppingToken);
|
||||||
|
|
||||||
|
if (productCreateResult.IsFailed)
|
||||||
|
{
|
||||||
|
logger.LogError("Failed to create product: {Error}", productCreateResult.Errors[0].Message);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Enable product so it can show on the shop
|
||||||
|
var enableProductResult = await productService.UpdateProductStatusAsync(productId: productCreateResult.Value, isEnabled: true, stoppingToken);
|
||||||
|
|
||||||
|
if (enableProductResult.IsFailed)
|
||||||
|
{
|
||||||
|
logger.LogError("Failed to enable created product: {Error}", enableProductResult.Errors[0].Message);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Create Product Price
|
||||||
|
var productPriceCreateResult = await productService.CreateProductPriceAsync(productId: productCreateResult.Value, request: new Products.Models.CreateProductPrice
|
||||||
|
{
|
||||||
|
Amount = Math.Round(faker.Random.Decimal(150m, 650m), 2),
|
||||||
|
Discount = 0.0m
|
||||||
|
}, stoppingToken);
|
||||||
|
|
||||||
|
if (productPriceCreateResult.IsFailed)
|
||||||
|
{
|
||||||
|
logger.LogError("Failed to create product price: {Error}", productPriceCreateResult.Errors[0].Message);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safe bounded picking for Authors (Real Avatars)
|
||||||
|
string authorAvatarUrl = faker.Internet.Avatar(); // Fallback
|
||||||
|
if (cdnSettings.Authors is not null && cdnSettings.Authors.Length > 0)
|
||||||
|
{
|
||||||
|
authorAvatarUrl = $"{cdnSettings.BaseCdn}{faker.PickRandom(cdnSettings.Authors)}";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safe bounded picking for Author Thumbnails (Cartoon Avatars)
|
||||||
|
string? authorThumbnailUrl = null;
|
||||||
|
if (cdnSettings.AuthorThumbnails is not null && cdnSettings.AuthorThumbnails.Length > 0)
|
||||||
|
{
|
||||||
|
var selectedThumb = faker.PickRandom(cdnSettings.AuthorThumbnails);
|
||||||
|
authorThumbnailUrl = $"{cdnSettings.BaseCdn}{selectedThumb}.jpg";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Synthesize a highly dynamic, organic opening bio statement
|
||||||
|
var professionalBackgrounds = new[]
|
||||||
|
{
|
||||||
|
$"{authorFirstName} {authorLastName} is an award-winning {faker.Name.JobDescriptor()} {faker.Name.JobTitle()} with over {faker.Random.Number(5, 25)} years of core engineering domain expertise.",
|
||||||
|
$"As a veteran systems consultant and practicing {faker.Name.JobTitle()}, {authorFirstName} has spent decades leading digital infrastructure transformations and managing complex topologies.",
|
||||||
|
$"Operating from modern innovation hubs, {authorFirstName} {authorLastName} specializes in global product strategies and serves as an authority in {faker.Name.JobDescriptor()} computing.",
|
||||||
|
$"With a rich professional background as a principal {faker.Name.JobTitle()} at {publisherCompany}, {authorFirstName} has spent a lifetime refining the system workflows highlighted here."
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pick a randomized context hook and append a 2-paragraph contextual narrative block
|
||||||
|
var biographyPrefix = faker.PickRandom(professionalBackgrounds);
|
||||||
|
var authorBiography = $"{biographyPrefix} {faker.Lorem.Paragraph(2)}";
|
||||||
|
|
||||||
|
// Step 4: Create Author
|
||||||
|
var authorCreateResult = await authorService.CreateAuthorAsync(request: new Authors.Models.CreateAuthor
|
||||||
|
{
|
||||||
|
Name = authorFirstName,
|
||||||
|
LastName = authorLastName,
|
||||||
|
Company = publisherCompany,
|
||||||
|
VatNumber = faker.Random.Bool() ? faker.Phone.PhoneNumber("4#########") : "",
|
||||||
|
PublisherType = faker.PickRandom<PublisherTypes>(),
|
||||||
|
Email = faker.Internet.Email(authorFirstName, authorLastName),
|
||||||
|
Website = faker.Internet.Url(),
|
||||||
|
ImageUrl = authorAvatarUrl,
|
||||||
|
SocialMedia =
|
||||||
|
[
|
||||||
|
new Models.SocialMedia
|
||||||
|
{
|
||||||
|
Name = "LinkedIn",
|
||||||
|
ImageUrl = "https://cdn.example.com/icons/linkedin.png",
|
||||||
|
Type = SocialMediaTypes.LinkedIn,
|
||||||
|
Url = $"https://linkedin.com/in/{authorFirstName.ToLower(culture)}-{authorLastName.ToLower(culture)}"
|
||||||
|
},
|
||||||
|
new Models.SocialMedia
|
||||||
|
{
|
||||||
|
Name = "GitHub",
|
||||||
|
ImageUrl = "https://cdn.example.com/icons/github.png",
|
||||||
|
Type = SocialMediaTypes.GitHub,
|
||||||
|
Url = $"https://github.com/tech-{authorFirstName.ToLower(culture)}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
Biography = authorBiography,
|
||||||
|
ThumbnailImageUrl = authorThumbnailUrl
|
||||||
|
}, stoppingToken);
|
||||||
|
|
||||||
|
if (authorCreateResult.IsFailed)
|
||||||
|
{
|
||||||
|
logger.LogError("Failed to create author: {Error}", authorCreateResult.Errors[0].Message);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5: Create Author-Book link (product linkage)
|
||||||
|
var authorBookCreateResult = await booksService.CreateBookAsync(authorId: authorCreateResult.Value, productId: productCreateResult.Value, stoppingToken);
|
||||||
|
|
||||||
|
if (authorBookCreateResult.IsFailed)
|
||||||
|
{
|
||||||
|
logger.LogError("Failed to create author-book linkage: {Error}", authorBookCreateResult.Errors[0].Message);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
var enableAuthorBookResult = await booksService.UpdateBookStatusAsync(bookId: authorBookCreateResult.Value, isEnabled: true, stoppingToken);
|
||||||
|
|
||||||
|
if (enableAuthorBookResult.IsFailed)
|
||||||
|
{
|
||||||
|
logger.LogError("Failed to enable author-book link: {Error}", enableAuthorBookResult.Errors[0].Message);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogInformation("Successfully seeded book product: {Title}", bookTitle);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogInformation("Product Seeding completed successfully.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using LiteCharms.Features.MidrandBooks.Extensions;
|
||||||
|
using LiteCharms.Features.MidrandBooks.Seed;
|
||||||
|
using LiteCharms.Features.MidrandBooks.Seed.Configuration;
|
||||||
|
|
||||||
|
var builder = Host.CreateApplicationBuilder(args);
|
||||||
|
|
||||||
|
builder.Configuration
|
||||||
|
.AddCommandLine(args)
|
||||||
|
.AddUserSecrets(typeof(Program).Assembly)
|
||||||
|
.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
|
||||||
|
.AddEnvironmentVariables();
|
||||||
|
|
||||||
|
builder.Services.AddScopedFeatureManagement();
|
||||||
|
|
||||||
|
builder.Services
|
||||||
|
.AddLogging()
|
||||||
|
.AddShopServices()
|
||||||
|
.AddHostedService<ProductsSeederService>()
|
||||||
|
.AddHostedService<CategorySeederService>()
|
||||||
|
.AddHostedService<CustomerSeederService>()
|
||||||
|
.AddMidrandShopDatabase(builder.Configuration);
|
||||||
|
|
||||||
|
builder.Services.Configure<CdnSettings>(options => builder.Configuration.GetSection(nameof(CdnSettings)).Bind(options));
|
||||||
|
|
||||||
|
using var host = builder.Build();
|
||||||
|
|
||||||
|
await host.RunAsync();
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
{
|
||||||
|
"FeatureManagement": {
|
||||||
|
"CategorySeederService": false,
|
||||||
|
"CustomerSeederService": false,
|
||||||
|
"ProductsSeederService": false
|
||||||
|
},
|
||||||
|
"CdnSettings": {
|
||||||
|
"BaseCdn": "https://bookshop.cdn.khongisa.co.za/design/",
|
||||||
|
"BookCovers": [
|
||||||
|
"144e0314-0bd8-4e2c-8814-2b34608c0600_1764780116467.webp",
|
||||||
|
"2bf1f9a2-7b25-4fcf-9aa7-08941ea21e6c_1764838499686.webp",
|
||||||
|
"3cd172b9-416e-4b3a-b613-7ff0a3aecc4c_1764780129459.webp",
|
||||||
|
"762947b6-63ac-436e-98fb-91dd94de1d82_1764780126141.webp",
|
||||||
|
"7b42f23f-666c-4dd9-82e1-9b928e1b4db7_1764780186376.webp",
|
||||||
|
"8e281eea-8910-473d-b650-43f539995d9e_1764780152316.webp",
|
||||||
|
"91d18e2c-6ee6-44b4-84a5-8692db5dbfe4_1764780114484.webp",
|
||||||
|
"94fdf403-4d10-4cb4-a537-fc914a1f5ba8_1764780198423.webp",
|
||||||
|
"9b881786-75e1-47f7-9bc9-27188d46d1ec_1764780122458.webp",
|
||||||
|
"c417236d-a628-45eb-82c2-ec1cb0326e4f_1765006317965.webp",
|
||||||
|
"clpqjsgi71jpr1i6392e449l2.webp",
|
||||||
|
"clps1mk9f0m1z1i32grvq17tg.webp",
|
||||||
|
"clq550xxy2dab1ywb6mdv4acv.webp",
|
||||||
|
"clq5535o52dj51yw557ml49c1.webp",
|
||||||
|
"clq55455w2dcm1yvd8d8258d8.webp",
|
||||||
|
"clq558fkh2djy1ywbghwcawnh.webp",
|
||||||
|
"clq55cvqh2dqi1yyratl123wq.webp",
|
||||||
|
"clrhnk94e115k1y00bg8h9ixb.webp",
|
||||||
|
"d44a3c04-f124-4f0b-8301-3841ae2fd439_1764780121224.webp",
|
||||||
|
"e6ba52f208914285bcdf1966cfb08f6f.jpg",
|
||||||
|
"fa9cbbe6-f947-4f83-8e98-61d2661f43e0_1764841636705.webp"
|
||||||
|
],
|
||||||
|
"Authors": [
|
||||||
|
"authors/uifaces-human-avatar.jpg",
|
||||||
|
"authors/uifaces-human-avatar-1.jpg",
|
||||||
|
"authors/uifaces-human-avatar-2.jpg",
|
||||||
|
"authors/uifaces-human-avatar-3.jpg",
|
||||||
|
"authors/uifaces-human-avatar-4.jpg",
|
||||||
|
"authors/uifaces-human-avatar-5.jpg",
|
||||||
|
"authors/uifaces-human-avatar-6.jpg",
|
||||||
|
"authors/uifaces-human-avatar-7.jpg",
|
||||||
|
"authors/uifaces-human-avatar-8.jpg",
|
||||||
|
"authors/uifaces-human-avatar-9.jpg",
|
||||||
|
"authors/uifaces-human-avatar-10.jpg",
|
||||||
|
"authors/uifaces-human-avatar-11.jpg",
|
||||||
|
"authors/uifaces-human-avatar-12.jpg",
|
||||||
|
"authors/uifaces-human-avatar-13.jpg",
|
||||||
|
"authors/uifaces-human-avatar-14.jpg",
|
||||||
|
"authors/uifaces-human-avatar-15.jpg",
|
||||||
|
"authors/uifaces-human-avatar-16.jpg"
|
||||||
|
],
|
||||||
|
"AuthorThumbnails": [
|
||||||
|
"authors/thumbnails/uifaces-cartoon-avatar-1",
|
||||||
|
"authors/thumbnails/uifaces-cartoon-avatar-2",
|
||||||
|
"authors/thumbnails/uifaces-cartoon-avatar-3",
|
||||||
|
"authors/thumbnails/uifaces-cartoon-avatar-4",
|
||||||
|
"authors/thumbnails/uifaces-cartoon-avatar-5",
|
||||||
|
"authors/thumbnails/uifaces-cartoon-avatar-6",
|
||||||
|
"authors/thumbnails/uifaces-cartoon-avatar-7",
|
||||||
|
"authors/thumbnails/uifaces-cartoon-avatar-8",
|
||||||
|
"authors/thumbnails/uifaces-cartoon-avatar-9",
|
||||||
|
"authors/thumbnails/uifaces-cartoon-avatar-10"
|
||||||
|
],
|
||||||
|
"BookThumbnails": [
|
||||||
|
"thumbnails/book_thumbnail_001.jpg",
|
||||||
|
"thumbnails/book_thumbnail_002.jpg",
|
||||||
|
"thumbnails/book_thumbnail_003.jpg",
|
||||||
|
"thumbnails/book_thumbnail_004.jpg",
|
||||||
|
"thumbnails/book_thumbnail_005.jpg",
|
||||||
|
"thumbnails/book_thumbnail_006.jpg",
|
||||||
|
"thumbnails/book_thumbnail_007.jpg",
|
||||||
|
"thumbnails/book_thumbnail_008.jpg",
|
||||||
|
"thumbnails/book_thumbnail_009.jpg",
|
||||||
|
"thumbnails/book_thumbnail_010.jpg",
|
||||||
|
"thumbnails/book_thumbnail_011.jpg",
|
||||||
|
"thumbnails/book_thumbnail_012.jpg",
|
||||||
|
"thumbnails/book_thumbnail_013.jpg",
|
||||||
|
"thumbnails/book_thumbnail_014.jpg",
|
||||||
|
"thumbnails/book_thumbnail_015.jpg",
|
||||||
|
"thumbnails/book_thumbnail_016.jpg",
|
||||||
|
"thumbnails/book_thumbnail_017.jpg",
|
||||||
|
"thumbnails/book_thumbnail_018.jpg",
|
||||||
|
"thumbnails/book_thumbnail_019.jpg",
|
||||||
|
"thumbnails/book_thumbnail_020.jpg",
|
||||||
|
"thumbnails/book_thumbnail_021.jpg",
|
||||||
|
"thumbnails/book_thumbnail_022.jpg",
|
||||||
|
"thumbnails/book_thumbnail_023.jpg",
|
||||||
|
"thumbnails/book_thumbnail_024.jpg",
|
||||||
|
"thumbnails/book_thumbnail_025.jpg",
|
||||||
|
"thumbnails/book_thumbnail_026.jpg",
|
||||||
|
"thumbnails/book_thumbnail_027.jpg",
|
||||||
|
"thumbnails/book_thumbnail_028.jpg",
|
||||||
|
"thumbnails/book_thumbnail_029.jpg",
|
||||||
|
"thumbnails/book_thumbnail_030.jpg",
|
||||||
|
"thumbnails/book_thumbnail_031.jpg",
|
||||||
|
"thumbnails/book_thumbnail_032.jpg",
|
||||||
|
"thumbnails/book_thumbnail_033.jpg",
|
||||||
|
"thumbnails/book_thumbnail_034.jpg",
|
||||||
|
"thumbnails/book_thumbnail_035.jpg",
|
||||||
|
"thumbnails/book_thumbnail_036.jpg",
|
||||||
|
"thumbnails/book_thumbnail_037.jpg",
|
||||||
|
"thumbnails/book_thumbnail_038.jpg",
|
||||||
|
"thumbnails/book_thumbnail_039.jpg",
|
||||||
|
"thumbnails/book_thumbnail_040.jpg",
|
||||||
|
"thumbnails/book_thumbnail_041.jpg",
|
||||||
|
"thumbnails/book_thumbnail_042.jpg",
|
||||||
|
"thumbnails/book_thumbnail_043.jpg",
|
||||||
|
"thumbnails/book_thumbnail_044.jpg",
|
||||||
|
"thumbnails/book_thumbnail_045.jpg",
|
||||||
|
"thumbnails/book_thumbnail_046.jpg",
|
||||||
|
"thumbnails/book_thumbnail_047.jpg",
|
||||||
|
"thumbnails/book_thumbnail_048.jpg",
|
||||||
|
"thumbnails/book_thumbnail_049.jpg",
|
||||||
|
"thumbnails/book_thumbnail_050.jpg",
|
||||||
|
"thumbnails/book_thumbnail_051.jpg",
|
||||||
|
"thumbnails/book_thumbnail_052.jpg",
|
||||||
|
"thumbnails/book_thumbnail_053.jpg",
|
||||||
|
"thumbnails/book_thumbnail_054.jpg",
|
||||||
|
"thumbnails/book_thumbnail_055.jpg",
|
||||||
|
"thumbnails/book_thumbnail_056.jpg",
|
||||||
|
"thumbnails/book_thumbnail_057.jpg",
|
||||||
|
"thumbnails/book_thumbnail_058.jpg",
|
||||||
|
"thumbnails/book_thumbnail_059.jpg",
|
||||||
|
"thumbnails/book_thumbnail_060.jpg",
|
||||||
|
"thumbnails/book_thumbnail_061.jpg",
|
||||||
|
"thumbnails/book_thumbnail_062.jpg",
|
||||||
|
"thumbnails/book_thumbnail_063.jpg",
|
||||||
|
"thumbnails/book_thumbnail_064.jpg",
|
||||||
|
"thumbnails/book_thumbnail_065.jpg",
|
||||||
|
"thumbnails/book_thumbnail_066.jpg",
|
||||||
|
"thumbnails/book_thumbnail_067.jpg",
|
||||||
|
"thumbnails/book_thumbnail_068.jpg",
|
||||||
|
"thumbnails/book_thumbnail_069.jpg",
|
||||||
|
"thumbnails/book_thumbnail_070.jpg",
|
||||||
|
"thumbnails/book_thumbnail_071.jpg",
|
||||||
|
"thumbnails/book_thumbnail_072.jpg",
|
||||||
|
"thumbnails/book_thumbnail_073.jpg",
|
||||||
|
"thumbnails/book_thumbnail_074.jpg",
|
||||||
|
"thumbnails/book_thumbnail_075.jpg",
|
||||||
|
"thumbnails/book_thumbnail_076.jpg",
|
||||||
|
"thumbnails/book_thumbnail_077.jpg",
|
||||||
|
"thumbnails/book_thumbnail_078.jpg",
|
||||||
|
"thumbnails/book_thumbnail_079.jpg",
|
||||||
|
"thumbnails/book_thumbnail_080.jpg",
|
||||||
|
"thumbnails/book_thumbnail_081.jpg",
|
||||||
|
"thumbnails/book_thumbnail_082.jpg",
|
||||||
|
"thumbnails/book_thumbnail_083.jpg",
|
||||||
|
"thumbnails/book_thumbnail_084.jpg",
|
||||||
|
"thumbnails/book_thumbnail_085.jpg",
|
||||||
|
"thumbnails/book_thumbnail_086.jpg",
|
||||||
|
"thumbnails/book_thumbnail_087.jpg",
|
||||||
|
"thumbnails/book_thumbnail_088.jpg",
|
||||||
|
"thumbnails/book_thumbnail_089.jpg",
|
||||||
|
"thumbnails/book_thumbnail_090.jpg",
|
||||||
|
"thumbnails/book_thumbnail_091.jpg",
|
||||||
|
"thumbnails/book_thumbnail_092.jpg",
|
||||||
|
"thumbnails/book_thumbnail_093.jpg",
|
||||||
|
"thumbnails/book_thumbnail_094.jpg",
|
||||||
|
"thumbnails/book_thumbnail_095.jpg",
|
||||||
|
"thumbnails/book_thumbnail_096.jpg",
|
||||||
|
"thumbnails/book_thumbnail_097.jpg",
|
||||||
|
"thumbnails/book_thumbnail_098.jpg",
|
||||||
|
"thumbnails/book_thumbnail_099.jpg",
|
||||||
|
"thumbnails/book_thumbnail_100.jpg",
|
||||||
|
"thumbnails/book_thumbnail_101.jpg",
|
||||||
|
"thumbnails/book_thumbnail_102.jpg",
|
||||||
|
"thumbnails/book_thumbnail_103.jpg",
|
||||||
|
"thumbnails/book_thumbnail_104.jpg",
|
||||||
|
"thumbnails/book_thumbnail_105.jpg",
|
||||||
|
"thumbnails/book_thumbnail_106.jpg",
|
||||||
|
"thumbnails/book_thumbnail_107.jpg",
|
||||||
|
"thumbnails/book_thumbnail_108.jpg",
|
||||||
|
"thumbnails/book_thumbnail_109.jpg",
|
||||||
|
"thumbnails/book_thumbnail_110.jpg",
|
||||||
|
"thumbnails/book_thumbnail_111.jpg",
|
||||||
|
"thumbnails/book_thumbnail_112.jpg",
|
||||||
|
"thumbnails/book_thumbnail_113.jpg",
|
||||||
|
"thumbnails/book_thumbnail_114.jpg",
|
||||||
|
"thumbnails/book_thumbnail_115.jpg",
|
||||||
|
"thumbnails/book_thumbnail_116.jpg",
|
||||||
|
"thumbnails/book_thumbnail_117.jpg",
|
||||||
|
"thumbnails/book_thumbnail_118.jpg",
|
||||||
|
"thumbnails/book_thumbnail_119.jpg",
|
||||||
|
"thumbnails/book_thumbnail_120.jpg",
|
||||||
|
"thumbnails/book_thumbnail_121.jpg",
|
||||||
|
"thumbnails/book_thumbnail_122.jpg",
|
||||||
|
"thumbnails/book_thumbnail_123.jpg",
|
||||||
|
"thumbnails/book_thumbnail_124.jpg",
|
||||||
|
"thumbnails/book_thumbnail_125.jpg",
|
||||||
|
"thumbnails/book_thumbnail_126.jpg",
|
||||||
|
"thumbnails/book_thumbnail_127.jpg",
|
||||||
|
"thumbnails/book_thumbnail_128.jpg",
|
||||||
|
"thumbnails/book_thumbnail_129.jpg",
|
||||||
|
"thumbnails/book_thumbnail_130.jpg",
|
||||||
|
"thumbnails/book_thumbnail_131.jpg",
|
||||||
|
"thumbnails/book_thumbnail_132.jpg",
|
||||||
|
"thumbnails/book_thumbnail_133.jpg",
|
||||||
|
"thumbnails/book_thumbnail_134.jpg",
|
||||||
|
"thumbnails/book_thumbnail_135.jpg",
|
||||||
|
"thumbnails/book_thumbnail_136.jpg",
|
||||||
|
"thumbnails/book_thumbnail_137.jpg",
|
||||||
|
"thumbnails/book_thumbnail_138.jpg",
|
||||||
|
"thumbnails/book_thumbnail_139.jpg",
|
||||||
|
"thumbnails/book_thumbnail_140.jpg",
|
||||||
|
"thumbnails/book_thumbnail_141.jpg",
|
||||||
|
"thumbnails/book_thumbnail_142.jpg",
|
||||||
|
"thumbnails/book_thumbnail_143.jpg",
|
||||||
|
"thumbnails/book_thumbnail_144.jpg",
|
||||||
|
"thumbnails/book_thumbnail_145.jpg",
|
||||||
|
"thumbnails/book_thumbnail_146.jpg",
|
||||||
|
"thumbnails/book_thumbnail_147.jpg",
|
||||||
|
"thumbnails/book_thumbnail_148.jpg",
|
||||||
|
"thumbnails/book_thumbnail_149.jpg",
|
||||||
|
"thumbnails/book_thumbnail_150.jpg",
|
||||||
|
"thumbnails/book_thumbnail_151.jpg",
|
||||||
|
"thumbnails/book_thumbnail_152.jpg",
|
||||||
|
"thumbnails/book_thumbnail_153.jpg",
|
||||||
|
"thumbnails/book_thumbnail_154.jpg",
|
||||||
|
"thumbnails/book_thumbnail_155.jpg",
|
||||||
|
"thumbnails/book_thumbnail_156.jpg",
|
||||||
|
"thumbnails/book_thumbnail_157.jpg",
|
||||||
|
"thumbnails/book_thumbnail_158.jpg",
|
||||||
|
"thumbnails/book_thumbnail_159.jpg",
|
||||||
|
"thumbnails/book_thumbnail_160.jpg",
|
||||||
|
"thumbnails/book_thumbnail_161.jpg",
|
||||||
|
"thumbnails/book_thumbnail_162.jpg",
|
||||||
|
"thumbnails/book_thumbnail_163.jpg",
|
||||||
|
"thumbnails/book_thumbnail_164.jpg",
|
||||||
|
"thumbnails/book_thumbnail_165.jpg",
|
||||||
|
"thumbnails/book_thumbnail_166.jpg",
|
||||||
|
"thumbnails/book_thumbnail_167.jpg",
|
||||||
|
"thumbnails/book_thumbnail_168.jpg",
|
||||||
|
"thumbnails/book_thumbnail_169.jpg",
|
||||||
|
"thumbnails/book_thumbnail_170.jpg",
|
||||||
|
"thumbnails/book_thumbnail_171.jpg",
|
||||||
|
"thumbnails/book_thumbnail_172.jpg",
|
||||||
|
"thumbnails/book_thumbnail_173.jpg",
|
||||||
|
"thumbnails/book_thumbnail_174.jpg",
|
||||||
|
"thumbnails/book_thumbnail_175.jpg",
|
||||||
|
"thumbnails/book_thumbnail_176.jpg",
|
||||||
|
"thumbnails/book_thumbnail_177.jpg",
|
||||||
|
"thumbnails/book_thumbnail_178.jpg",
|
||||||
|
"thumbnails/book_thumbnail_179.jpg",
|
||||||
|
"thumbnails/book_thumbnail_180.jpg",
|
||||||
|
"thumbnails/book_thumbnail_181.jpg",
|
||||||
|
"thumbnails/book_thumbnail_182.jpg",
|
||||||
|
"thumbnails/book_thumbnail_183.jpg",
|
||||||
|
"thumbnails/book_thumbnail_184.jpg",
|
||||||
|
"thumbnails/book_thumbnail_185.jpg",
|
||||||
|
"thumbnails/book_thumbnail_186.jpg",
|
||||||
|
"thumbnails/book_thumbnail_187.jpg",
|
||||||
|
"thumbnails/book_thumbnail_188.jpg",
|
||||||
|
"thumbnails/book_thumbnail_189.jpg",
|
||||||
|
"thumbnails/book_thumbnail_190.jpg",
|
||||||
|
"thumbnails/book_thumbnail_191.jpg",
|
||||||
|
"thumbnails/book_thumbnail_192.jpg",
|
||||||
|
"thumbnails/book_thumbnail_193.jpg",
|
||||||
|
"thumbnails/book_thumbnail_194.jpg",
|
||||||
|
"thumbnails/book_thumbnail_195.jpg",
|
||||||
|
"thumbnails/book_thumbnail_196.jpg",
|
||||||
|
"thumbnails/book_thumbnail_197.jpg",
|
||||||
|
"thumbnails/book_thumbnail_198.jpg",
|
||||||
|
"thumbnails/book_thumbnail_199.jpg",
|
||||||
|
"thumbnails/book_thumbnail_200.jpg"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
using LiteCharms.Features.MidrandBooks.Authors;
|
||||||
|
using LiteCharms.Features.MidrandBooks.Authors.Models;
|
||||||
|
using LiteCharms.Features.MidrandBooks.Tests.Common;
|
||||||
|
using LiteCharms.Features.Models;
|
||||||
|
|
||||||
|
namespace LiteCharms.Features.MidrandBooks.Tests;
|
||||||
|
|
||||||
|
public class AuthorServiceFeatureTests(Fixture fixture) : IClassFixture<Fixture>
|
||||||
|
{
|
||||||
|
private readonly AuthorService authorService = fixture.Services.GetRequiredService<AuthorService>();
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task CreateAuthorAsync_ShouldReturn_ResultWithAuthorId()
|
||||||
|
{
|
||||||
|
var request = new CreateAuthor
|
||||||
|
{
|
||||||
|
Name = "John",
|
||||||
|
LastName = "Doe",
|
||||||
|
Company = "Solo Publishers",
|
||||||
|
Email = "solo@publishers.co.za",
|
||||||
|
PublisherType = PublisherTypes.Independent,
|
||||||
|
ImageUrl = ""
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await authorService.CreateAuthorAsync(request, fixture.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.True(result.Value > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task UpdateAuthorAsync_ShouldReturn_ResultWithSuccess()
|
||||||
|
{
|
||||||
|
var request = new UpdateAuthor
|
||||||
|
{
|
||||||
|
Name = "Jane",
|
||||||
|
LastName = "Doe",
|
||||||
|
Company = "Solo Publishers",
|
||||||
|
Email = "solo@publishers.co.za",
|
||||||
|
PublisherType = PublisherTypes.Independent,
|
||||||
|
ImageUrl = ""
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await authorService.UpdateAuthorAsync(1, request, fixture.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task GetAuthors_ShouldReturn_ResultWithAuthorList()
|
||||||
|
{
|
||||||
|
var range = new DateRange
|
||||||
|
{
|
||||||
|
From = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(-7)),
|
||||||
|
To = DateOnly.FromDateTime(DateTime.UtcNow),
|
||||||
|
MaxRecords = 1000
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await authorService.GetAuthorsAsync(range, fixture.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.NotEmpty(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task GetAuthorAsync_ShouldReturn_ResultWithAuthor()
|
||||||
|
{
|
||||||
|
var result = await authorService.GetAuthorAsync(1, fixture.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.NotNull(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task UpdateAuthorStatusAsync_ShouldReturn_ResultWithSuccess()
|
||||||
|
{
|
||||||
|
var result = await authorService.UpdateAuthorStatusAsync(1, true, fixture.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
using LiteCharms.Features.MidrandBooks.AuthorBooks;
|
||||||
|
using LiteCharms.Features.MidrandBooks.Tests.Common;
|
||||||
|
|
||||||
|
namespace LiteCharms.Features.MidrandBooks.Tests;
|
||||||
|
|
||||||
|
public class BooksServiceFeatureTests(Fixture fixture) : IClassFixture<Fixture>
|
||||||
|
{
|
||||||
|
private readonly BooksService bookService = fixture.Services.GetRequiredService<BooksService>();
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task CreateBookAsync_ShouldReturn_ResultWithBookId()
|
||||||
|
{
|
||||||
|
var result = await bookService.CreateBookAsync(1, 2, fixture.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task GetBookAsync_ShouldReturn_ResultWithBook()
|
||||||
|
{
|
||||||
|
var result = await bookService.GetBookAsync(1, fixture.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.NotNull(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task GetBooksByAuthorAsync_ShouldReturn_ResultWithAuthorBooks()
|
||||||
|
{
|
||||||
|
var result = await bookService.GetBooksByAuthorAsync(1, fixture.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.NotEmpty(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task GetPublishedBooksAsync_ShouldReturn_ResultWithBublishedBooks()
|
||||||
|
{
|
||||||
|
var result = await bookService.GetPublishedBooksAsync(0, 1000, fixture.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.NotEmpty(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task UpdateBookStatusAsync_ShouldReturn_ResultWithSuccess()
|
||||||
|
{
|
||||||
|
var result = await bookService.UpdateBookStatusAsync(1, true, fixture.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
using LiteCharms.Features.MidrandBooks.Categories;
|
||||||
|
using LiteCharms.Features.MidrandBooks.Tests.Common;
|
||||||
|
|
||||||
|
namespace LiteCharms.Features.MidrandBooks.Tests;
|
||||||
|
|
||||||
|
public class CategoryServiceFeatureTests(Fixture fixture) : IClassFixture<Fixture>
|
||||||
|
{
|
||||||
|
private readonly CategoryService categoryService = fixture.Services.GetRequiredService<CategoryService>();
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task UpdateCategoryStatusAsync_ShouldReturn_ResultWithSuccess()
|
||||||
|
{
|
||||||
|
var result = await categoryService.UpdateCategoryStatusAsync(3, false, false, fixture.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task GetCategoryAsync_ShouldReturn_ResultWithCategory()
|
||||||
|
{
|
||||||
|
var result = await categoryService.GetCategoryAsync(3, fixture.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.NotNull(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task GetCategoriesAsync_ShouldReturn_All_ResultWithCategoryList()
|
||||||
|
{
|
||||||
|
var result = await categoryService.GetCategoriesAsync(isMain: null,fixture.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.NotEmpty(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task GetCategoriesAsync_ShouldReturn_MainCategory_ResultWithCategoryList()
|
||||||
|
{
|
||||||
|
var result = await categoryService.GetCategoriesAsync(true, fixture.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.NotEmpty(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task GetCategoriesAsync_ShouldReturn_SubMainCategory_ResultWithCategoryList()
|
||||||
|
{
|
||||||
|
var result = await categoryService.GetCategoriesAsync(false, fixture.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.NotEmpty(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task CreateCategoriesAsync_ShouldReturn_ResultWithSuccess()
|
||||||
|
{
|
||||||
|
var result = await categoryService.CreateCategoriesAsync(fixture.CancellationToken, "Test", "Test 1", "Test 2");
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task CreateCategoryAsync_ShouldReturn_ResultWithCategoryId()
|
||||||
|
{
|
||||||
|
var result = await categoryService.CreateCategoryAsync("Test", true, fixture.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.True(result.Value > 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using LiteCharms.Features.Extensions;
|
||||||
|
using LiteCharms.Features.MidrandBooks.Extensions;
|
||||||
|
|
||||||
|
namespace LiteCharms.Features.MidrandBooks.Tests.Common;
|
||||||
|
|
||||||
|
public class Fixture : IDisposable
|
||||||
|
{
|
||||||
|
public IConfiguration Configuration { get; set; }
|
||||||
|
|
||||||
|
public IServiceProvider Services { get; set; }
|
||||||
|
|
||||||
|
public IMediator Mediator { get; set; }
|
||||||
|
|
||||||
|
private readonly CancellationTokenSource cancellationTokenSource = new();
|
||||||
|
|
||||||
|
public CancellationToken CancellationToken => cancellationTokenSource.Token;
|
||||||
|
|
||||||
|
public Fixture()
|
||||||
|
{
|
||||||
|
Configuration = new ConfigurationBuilder()
|
||||||
|
.SetBasePath(Directory.GetCurrentDirectory())
|
||||||
|
.AddUserSecrets<Fixture>()
|
||||||
|
.AddJsonFile(Path.Combine(Directory.GetCurrentDirectory(), "appsettings.json"), optional: true, reloadOnChange: true)
|
||||||
|
.AddEnvironmentVariables()
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
Services = new ServiceCollection()
|
||||||
|
.AddHttpClient()
|
||||||
|
.AddMediator()
|
||||||
|
.AddLogging()
|
||||||
|
.AddEmailServiceBus()
|
||||||
|
.AddGarageS3(Configuration)
|
||||||
|
.AddMidrandShopDatabase(Configuration)
|
||||||
|
.AddEmailServices(Configuration)
|
||||||
|
.AddSingleton(Configuration)
|
||||||
|
.AddShopServices()
|
||||||
|
.AddHashServices(Configuration)
|
||||||
|
.BuildServiceProvider();
|
||||||
|
|
||||||
|
Mediator = Services.GetRequiredService<IMediator>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() { }
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace LiteCharms.Features.MidrandBooks.Tests.Common;
|
||||||
|
|
||||||
|
public class IntegrationFactAttribute : FactAttribute
|
||||||
|
{
|
||||||
|
public IntegrationFactAttribute()
|
||||||
|
{
|
||||||
|
if(!Debugger.IsAttached)
|
||||||
|
Skip = "This test requires the debugger to be attached.";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
using LiteCharms.Features.MidrandBooks.Customers;
|
||||||
|
using LiteCharms.Features.MidrandBooks.Customers.Models;
|
||||||
|
using LiteCharms.Features.MidrandBooks.Tests.Common;
|
||||||
|
|
||||||
|
namespace LiteCharms.Features.MidrandBooks.Tests;
|
||||||
|
|
||||||
|
public class CustomerServiceFeatureTests(Fixture fixture) : IClassFixture<Fixture>
|
||||||
|
{
|
||||||
|
private readonly CustomerService customerService = fixture.Services.GetRequiredService<CustomerService>();
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task CreateCustomerAsync_ShouldReturn_ResultWithCustomerId()
|
||||||
|
{
|
||||||
|
var request = new CreateCustomer
|
||||||
|
{
|
||||||
|
Company = "Book Lovers",
|
||||||
|
Email = "hank@booklovers.com",
|
||||||
|
Phone = "555 1245 8577",
|
||||||
|
Website = "https://www.booklovers.com"
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await customerService.CreateCustomerAsync(request, fixture.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.True(result.Value > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task CreateCustomerContactAsync_ShouldReturn_ResultWithCustomerContactId()
|
||||||
|
{
|
||||||
|
var request = new CreateCustomerContact
|
||||||
|
{
|
||||||
|
Name = "Sipho",
|
||||||
|
LastName = "Madlanga",
|
||||||
|
Phone = "0710857365",
|
||||||
|
Email = "sipho@madlanga.africa",
|
||||||
|
Type = ContactTypes.Business
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await customerService.CreateCustomerContactAsync(1, request, fixture.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.True(result.Value > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task CreateCustomerAddressAsync_ShouldReturn_ResultWithCustomerAddressId()
|
||||||
|
{
|
||||||
|
var request = new CreateCustomerAddress
|
||||||
|
{
|
||||||
|
Name = "Business",
|
||||||
|
BuildingType = AddressBuildingTypes.MixedUse,
|
||||||
|
Type = AddressType.Shipping,
|
||||||
|
Street = "123 Building 4, XYZ Suburb, Some Region",
|
||||||
|
City = "Johannesburg",
|
||||||
|
State = "Gauteng",
|
||||||
|
Country = "South Africa",
|
||||||
|
IsPrimary = true,
|
||||||
|
Enabled = true,
|
||||||
|
PostalCode = "12345"
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await customerService.CreateCustomerAddressAsync(1, request, fixture.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.True(result.Value > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task UpdateCustomerAsync_ShouldReturn_ResultWithSuccess()
|
||||||
|
{
|
||||||
|
var request = new UpdateCustomer
|
||||||
|
{
|
||||||
|
Company = "Book Lovers",
|
||||||
|
Email = "hank@booklovers.com",
|
||||||
|
Phone = "555 1245 8578",
|
||||||
|
Website = "https://www.booklovers.com"
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await customerService.UpdateCustomerAsync(1, request, fixture.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task UpdateCustomerContactAsync_ShouldReturn_ResultWithSuccess()
|
||||||
|
{
|
||||||
|
var request = new UpdateCustomerContact
|
||||||
|
{
|
||||||
|
Name = "Sipho",
|
||||||
|
LastName = "Madlanga",
|
||||||
|
Phone = "0710857366",
|
||||||
|
Email = "sipho@madlanga.africa",
|
||||||
|
Type = ContactTypes.Business
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await customerService.UpdateCustomerContactAsync(1, request, fixture.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task UpdateCustomerAddressAsync_ShouldReturn_ResultWithSuccess()
|
||||||
|
{
|
||||||
|
var request = new UpdateCustomerAddress
|
||||||
|
{
|
||||||
|
Name = "Business",
|
||||||
|
BuildingType = AddressBuildingTypes.MixedUse,
|
||||||
|
Type = AddressType.Shipping,
|
||||||
|
Street = "123 Building 4, XYZ Suburb, Some Region",
|
||||||
|
City = "Johannesburg",
|
||||||
|
State = "Gauteng",
|
||||||
|
Country = "South Africa",
|
||||||
|
IsPrimary = true,
|
||||||
|
Enabled = true,
|
||||||
|
PostalCode = "12346"
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await customerService.UpdateCustomerAddressAsync(1, request, fixture.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task UpdateCustomerStatusAsync_ShouldReturn_ResultWithSuccess()
|
||||||
|
{
|
||||||
|
var result = await customerService.UpdateCustomerStatusAsync(1, true, fixture.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task UpdateCustomerContactStatusAsync_ShouldReturn_ResultWithSuccess()
|
||||||
|
{
|
||||||
|
var result = await customerService.UpdateCustomerContactStatusAsync(1, true, true, fixture.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task UpdateCustomerAddressStatusAsync_ShouldReturn_ResultWithSuccess()
|
||||||
|
{
|
||||||
|
var result = await customerService.UpdateCustomerAddressStatusAsync(1, true, true, fixture.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task GetCustomersAsync_ShouldReturn_ResultWithCustomerList()
|
||||||
|
{
|
||||||
|
var result = await customerService.GetCustomersAsync(fixture.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.NotEmpty(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task GetCustomerContactsAsync_ShouldReturn_ResultWithCustomerContactList()
|
||||||
|
{
|
||||||
|
var result = await customerService.GetCustomerContactsAsync(1, fixture.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.NotEmpty(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task GetCustomerAddressesAsync_ShouldReturn_ResultWithCustomerAddressList()
|
||||||
|
{
|
||||||
|
var result = await customerService.GetCustomerAddressesAsync(1, fixture.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.NotEmpty(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task GetCustomerAsync_ShouldReturn_ResultWithCustomer()
|
||||||
|
{
|
||||||
|
var result = await customerService.GetCustomerAsync(1, fixture.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.NotNull(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task GetCustomerContactAsync_ShouldReturn_ResultWithCustomerContact()
|
||||||
|
{
|
||||||
|
var result = await customerService.GetCustomerContactsAsync(1, fixture.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.NotNull(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task GetCustomerAddressAsync_ShouldReturn_ResultWithCustomerAddress()
|
||||||
|
{
|
||||||
|
var result = await customerService.GetCustomerAddressAsync(1, fixture.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.NotNull(result.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<UserSecretsId>b205af96-ceef-44e1-851c-458c9fd1c437</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.6.0" />
|
||||||
|
<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.MidrandBooks\LiteCharms.Features.MidrandBooks.csproj" />
|
||||||
|
<ProjectReference Include="..\LiteCharms.Features\LiteCharms.Features.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="System.Net" />
|
||||||
|
<Using Include="System.Text.Json" />
|
||||||
|
<Using Include="System.Diagnostics" />
|
||||||
|
<Using Include="Xunit" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<None Update="appsettings.json">
|
||||||
|
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||||
|
</None>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
using LiteCharms.Features.MidrandBooks.Orders;
|
||||||
|
using LiteCharms.Features.MidrandBooks.Orders.Models;
|
||||||
|
using LiteCharms.Features.MidrandBooks.Tests.Common;
|
||||||
|
using LiteCharms.Features.Models;
|
||||||
|
|
||||||
|
namespace LiteCharms.Features.MidrandBooks.Tests;
|
||||||
|
|
||||||
|
public class OrderServiceFeatureTests(Fixture fixture) : IClassFixture<Fixture>
|
||||||
|
{
|
||||||
|
private readonly OrderService orderService = fixture.Services.GetRequiredService<OrderService>();
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task CreateOrderAsync_ShouldReturn_ResultWithOrderId()
|
||||||
|
{
|
||||||
|
var request = new CreateOrder(250, "At the intercomm, dial 1 then option 2");
|
||||||
|
|
||||||
|
var result = await orderService.CreateOrderAsync(1, request, fixture.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.True(result.Value > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task AddItemToOrderAsync_ShouldReturn_ResultWithOrderItemId()
|
||||||
|
{
|
||||||
|
var request = new CreateOrderItem(1, 1, 2);
|
||||||
|
|
||||||
|
var result = await orderService.AddItemToOrderAsync(1, request, fixture.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.True(result.Value > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task AddItemsToOrderAsync_ShouldReturn_ResultWithSuccess()
|
||||||
|
{
|
||||||
|
var requests = new List<CreateOrderItem>
|
||||||
|
{
|
||||||
|
new(1, 1, 1),
|
||||||
|
new(1, 1, 3)
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await orderService.AddItemsToOrderAsync(1, [.. requests], fixture.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task RemoveItemFromOrderAsync_ShouldReturn_ResultWithSuccess()
|
||||||
|
{
|
||||||
|
var result = await orderService.RemoveItemFromOrderAsync(1, 5, fixture.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task ClearOrderItemsAsync_ShouldReturn_ResultWithSuccess()
|
||||||
|
{
|
||||||
|
var result = await orderService.ClearOrderItemsAsync(1, fixture.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task CancelOrderAsync_ShouldReturn_ResultWithSuccess()
|
||||||
|
{
|
||||||
|
var result = await orderService.CancelOrderAsync(1, fixture.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task GetOrderAsync_ShouldReturn_ResultWithOrder()
|
||||||
|
{
|
||||||
|
var result = await orderService.GetOrderAsync(1, fixture.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.NotNull(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task GetOrdersByCustomerAsync_ShouldReturn_ResultWithOrderList()
|
||||||
|
{
|
||||||
|
var result = await orderService.GetOrdersByCustomerAsync(1, fixture.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.NotEmpty(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task GetOrdersAsync_ShouldReturn_ResultWithOrderList()
|
||||||
|
{
|
||||||
|
var range = new DateRange
|
||||||
|
{
|
||||||
|
From = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(-7)),
|
||||||
|
To = DateOnly.FromDateTime(DateTime.UtcNow),
|
||||||
|
MaxRecords = 1000
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await orderService.GetOrdersAsync(range, 0, fixture.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.NotEmpty(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task UpdateOrderStatusAsync_ShouldReturn_ResultWithSuccess()
|
||||||
|
{
|
||||||
|
var result = await orderService.UpdateOrderStatusAsync(1, OrderStatus.Pending, fixture.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task AddShippingToOrderAsync_ShouldReturn_ResultWithSuccess()
|
||||||
|
{
|
||||||
|
var request = new CreateShipping(1, 2);
|
||||||
|
|
||||||
|
var result = await orderService.AddShippingToOrderAsync(1, request, fixture.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.True(result.Value > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task UpdateShippingStatusAsync_ShouldReturn_ResultWithSuccess()
|
||||||
|
{
|
||||||
|
var result = await orderService.UpdateShippingStatusAsync(1, ShippingStatuses.Shipped, fixture.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task GetShippingByOrderIdAsync_ShouldReturn_ResultWithShipping()
|
||||||
|
{
|
||||||
|
var result = await orderService.GetShippingByOrderIdAsync(1, fixture.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.NotNull(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task RemoveShippingFromOrderAsync_ShouldReturn_ResultWithSuccess()
|
||||||
|
{
|
||||||
|
var result = await orderService.RemoveShippingFromOrderAsync(1, 1, fixture.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task UpdateShippingTrackingNumberAsync_ShouldReturn_ResultWithSuccess()
|
||||||
|
{
|
||||||
|
var result = await orderService.UpdateShippingTrackingNumberAsync(1, 2, "NA0009969397");
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task CreateShippingProviderAsync_ShouldReturn_ResultWithShippingProviderId()
|
||||||
|
{
|
||||||
|
var request = new CreateShippingProvider(ShippingProviderTypes.FastWay, "FastWay Couriers", 50, "https://www.fastway.co.za/our-services/track-your-parcel");
|
||||||
|
|
||||||
|
var result = await orderService.CreateShippingProviderAsync(request, fixture.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.True(result.Value > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task GetShippingProvidersAsync_ShouldReturn_ResultWithShippingProviderList()
|
||||||
|
{
|
||||||
|
var result = await orderService.GetShippingProvidersAsync(true, fixture.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.NotEmpty(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task GetShippingProviderAsync_ShouldReturn_ResultWithShippingProvider()
|
||||||
|
{
|
||||||
|
var result = await orderService.GetShippingProviderAsync(2, fixture.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.NotNull(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task UpdateShippingProviderAsync_ShouldReturn_ResultWithSuccess()
|
||||||
|
{
|
||||||
|
var request = new UpdateShippingProvider(2,true, "FastWay Couriers", 50, "https://www.fastway.co.za/our-services/track-your-parcel");
|
||||||
|
|
||||||
|
var result = await orderService.UpdateShippingProviderAsync(request, fixture.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
using LiteCharms.Features.MidrandBooks.Pages;
|
||||||
|
using LiteCharms.Features.MidrandBooks.Tests.Common;
|
||||||
|
|
||||||
|
namespace LiteCharms.Features.MidrandBooks.Tests;
|
||||||
|
|
||||||
|
public class PageServiceFeatureTests(Fixture fixture) : IClassFixture<Fixture>
|
||||||
|
{
|
||||||
|
private readonly PageService pageService = fixture.Services.GetRequiredService<PageService>();
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task CreatePageAsync_ShouldReturn_ResultWithPageId()
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task GetPagesAsync_ByBookId_ShouldReturn_ResultWithPageList()
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task GetPageAsync_ShouldReturn_ResultWithPage()
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task GetPageByNumberAsync_ById_And_BookPageNumber_ShouldReturn_ResultWithPage()
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task UpdatePageAsync_ShouldReturn_ResultWithSuccess()
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task DeletePageAsync_ShouldReturn_ResultWithSuccess()
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task DeleteByPageTypeAsync_ShouldReturn_ResultWithSuccess()
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task DeleteAllAsync_ShouldReturn_ResultWithSuccess()
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task UpdatePageStatusAsync_ShouldReturn_ResultWithSuccess()
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
using LiteCharms.Features.MidrandBooks.Payments;
|
||||||
|
using LiteCharms.Features.MidrandBooks.Payments.Models;
|
||||||
|
using LiteCharms.Features.MidrandBooks.Tests.Common;
|
||||||
|
|
||||||
|
namespace LiteCharms.Features.MidrandBooks.Tests;
|
||||||
|
|
||||||
|
public sealed class PayfastServiceFeatureTests(Fixture fixture) : IClassFixture<Fixture>
|
||||||
|
{
|
||||||
|
private readonly PayfastService payfastService = fixture.Services.GetRequiredService<PayfastService>();
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task WriteLedgerEntryAsync_ShouldReturn_ResultWithGatewayLedgerId()
|
||||||
|
{
|
||||||
|
var request = new CreateGatewayLedgerEntry
|
||||||
|
{
|
||||||
|
OrderId = 1,
|
||||||
|
PaymentId = 1,
|
||||||
|
MerchantPaymentId = "M_REF_TEST_99",
|
||||||
|
PayfastPaymentId = "PF_SYS_ID_10023",
|
||||||
|
CustomerEmail = "buyer@litecharms.co.za",
|
||||||
|
AmountGross = 350.00m,
|
||||||
|
AmountFee = 12.50m,
|
||||||
|
AmountNet = 337.50m,
|
||||||
|
PaymentStatus = "COMPLETE"
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await payfastService.WriteLedgerEntryAsync(request, fixture.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.True(result.Value > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task ValidateReferrerIpAsync_WithValidPayfastHostIp_ShouldReturnTrue()
|
||||||
|
{
|
||||||
|
var addresses = await Dns.GetHostAddressesAsync("sandbox.payfast.co.za", fixture.CancellationToken);
|
||||||
|
|
||||||
|
string liveTargetIp = addresses.First().ToString();
|
||||||
|
|
||||||
|
var result = await payfastService.ValidateReferrerIpAsync(liveTargetIp, true, fixture.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.True(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task ValidateReferrerIpAsync_WithUntrustedIp_ShouldReturnFalse()
|
||||||
|
{
|
||||||
|
string rogueIp = "8.8.8.8";
|
||||||
|
|
||||||
|
var result = await payfastService.ValidateReferrerIpAsync(rogueIp, true, fixture.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.False(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public void ValidatePaymentAmount_WhenWithinAllowableDelta_ShouldReturnTrue()
|
||||||
|
{
|
||||||
|
decimal systemExpectedTotal = 199.99m;
|
||||||
|
string gatewayClearedGross = "200.00"; // Variance is exactly R0.01
|
||||||
|
|
||||||
|
var result = payfastService.ValidatePaymentAmount(systemExpectedTotal, gatewayClearedGross);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.True(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public void ValidatePaymentAmount_WhenVarianceBreachesDeltaBounds_ShouldReturnFalse()
|
||||||
|
{
|
||||||
|
decimal systemExpectedTotal = 199.99m;
|
||||||
|
string gatewayClearedGross = "150.00";
|
||||||
|
|
||||||
|
var result = payfastService.ValidatePaymentAmount(systemExpectedTotal, gatewayClearedGross);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.False(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task ValidateServerConfirmationAsync_WithUnrecognizedPayload_ShouldReturnFalseFromCentralGateway()
|
||||||
|
{
|
||||||
|
// Arrange - Execute against actual Payfast servers using raw mock parameters.
|
||||||
|
// The server handshake will return 200 OK with string payload 'INVALID'
|
||||||
|
string arbitraryParameters = "merchant_id=10000000&payment_status=COMPLETE";
|
||||||
|
|
||||||
|
var result = await payfastService.ValidateServerConfirmationAsync(arbitraryParameters, isSandbox: true, fixture.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.False(result.Value); // Handshake data rejected as fraudulent/unrecognized
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public void GenerateSignature_WithStandardTelemetryData_ShouldSucceedAndHashString()
|
||||||
|
{
|
||||||
|
var telemetryPayload = new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
{ "merchant_id", "10049307" },
|
||||||
|
{ "merchant_key", "ju6navn0jcbf0" },
|
||||||
|
{ "amount_gross", "250.00" },
|
||||||
|
{ "item_name", "Midrand School Textbook Variant A" }
|
||||||
|
};
|
||||||
|
|
||||||
|
string passphrase = "oauth_test_signature_pass";
|
||||||
|
|
||||||
|
var result = PayfastService.GenerateSignature(telemetryPayload, passphrase);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(result.Value));
|
||||||
|
Assert.Equal(32, result.Value.Length); // MD5 outputs hex representations totaling 32 characters
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
using LiteCharms.Features.MidrandBooks.Payments;
|
||||||
|
using LiteCharms.Features.MidrandBooks.Payments.Models;
|
||||||
|
using LiteCharms.Features.MidrandBooks.Tests.Common;
|
||||||
|
|
||||||
|
namespace LiteCharms.Features.MidrandBooks.Tests;
|
||||||
|
|
||||||
|
public sealed class PaymentServiceFeatureTests(Fixture fixture) : IClassFixture<Fixture>
|
||||||
|
{
|
||||||
|
private readonly PaymentService paymentService = fixture.Services.GetRequiredService<PaymentService>();
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task CreateRefundAsync_ShouldReturn_ResultWithRefundId()
|
||||||
|
{
|
||||||
|
var request = new CreateRefund
|
||||||
|
{
|
||||||
|
Amount = 50,
|
||||||
|
OrderId = 2,
|
||||||
|
Type = RefundTypes.Partial,
|
||||||
|
Reason = "Returned damaged book",
|
||||||
|
Status = RefundStatus.Completed,
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await paymentService.CreateRefundAsync(request, fixture.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.True(result.Value > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task WriteLedgerEntryAsync_ShouldReturn_ResultWithSuccess()
|
||||||
|
{
|
||||||
|
var request = new CreateLedgerEntry
|
||||||
|
{
|
||||||
|
CustomerId = 1,
|
||||||
|
OrderId = 1,
|
||||||
|
PaymentGatewayId = 1,
|
||||||
|
PaymentGatewayReference = "TEST REFERENCE",
|
||||||
|
PaymentId = 1,
|
||||||
|
Status = LedgerStatuses.Received,
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await paymentService.WriteLedgerEntryAsync(request, fixture.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task GetPaymentGatewayAsync_ShouldReturn_ResultWithPaymentGateway()
|
||||||
|
{
|
||||||
|
var result = await paymentService.GetPaymentGatewayAsync(1, fixture.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.NotNull(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task CreatePaymentGatewayAsync_ShouldReturn_ResultWithGatewayId()
|
||||||
|
{
|
||||||
|
var request = new CreatePaymentGateway
|
||||||
|
{
|
||||||
|
IsSandbox = true,
|
||||||
|
MerchantId = "10049307",
|
||||||
|
MerchantKey = "ju6navn0jcbf0",
|
||||||
|
Name = "Payfast",
|
||||||
|
Website = "https://sandbox.payfast.co.za/eng/process",
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await paymentService.CreatePaymentGatewayAsync(request, fixture.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.True(result.Value > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task CompletePaymentAsync_ShouldReturn_ResultWithSuccess()
|
||||||
|
{
|
||||||
|
var result = await paymentService.CompletePaymentAsync(1, PaymentStatuses.Paid, fixture.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task UpdatePaymentAsync_ShouldReturn_ResultWithSuccess()
|
||||||
|
{
|
||||||
|
var result = await paymentService.UpdatePaymentAsync(1, 200, fixture.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task CreatePaymentAsync_ShouldReturn_ResultWithPaymentId()
|
||||||
|
{
|
||||||
|
var result = await paymentService.CreatePaymentAsync(100, 1, "HASHEDID", fixture.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.True(result.Value > 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
using LiteCharms.Features.MidrandBooks.Products;
|
||||||
|
using LiteCharms.Features.MidrandBooks.Products.Models;
|
||||||
|
using LiteCharms.Features.MidrandBooks.Tests.Common;
|
||||||
|
using LiteCharms.Features.Models;
|
||||||
|
|
||||||
|
namespace LiteCharms.Features.MidrandBooks.Tests;
|
||||||
|
|
||||||
|
public class ProductServiceFeatureTests(Fixture fixture, ITestOutputHelper output) : IClassFixture<Fixture>
|
||||||
|
{
|
||||||
|
private readonly ProductService productService = fixture.Services.GetRequiredService<ProductService>();
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task CheckProductStockAvailabilityAsync_ShouldReturn_ResultWithProductInventory()
|
||||||
|
{
|
||||||
|
var result = await productService.CheckProductStockAvailabilityAsync(1, 1, fixture.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.NotNull(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task ReserveProductInventoryAsync_ShouldReturn_ResultWithSuccess()
|
||||||
|
{
|
||||||
|
var request = new ReserveStock
|
||||||
|
{
|
||||||
|
ProductId = 1,
|
||||||
|
ProductPriceId = 1,
|
||||||
|
Reservation = 100,
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await productService.ReserveProductInventoryAsync(request, fixture.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.True(result.Value > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task AllocateProductInventoryAsync_ShouldReturn_ResultWithSuccess()
|
||||||
|
{
|
||||||
|
var request = new AllocateStock
|
||||||
|
{
|
||||||
|
ProductId = 1,
|
||||||
|
ProductPriceId = 1,
|
||||||
|
Allocation = 500,
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await productService.AllocateProductInventoryAsync(request, fixture.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.True(result.Value > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task AddProductCategoryAsync_ShouldReturn_ResultWithId()
|
||||||
|
{
|
||||||
|
var result = await productService.AddProductCategoryAsync(1, 2, fixture.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task GetProductCategoriesAsync_ShouldReturn_ResultWithCategoryList()
|
||||||
|
{
|
||||||
|
var result = await productService.GetProductCategoriesAsync(1, fixture.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.NotEmpty(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task DeleteProductCategoryAsync_ShouldReturn_ResultWithSuccess()
|
||||||
|
{
|
||||||
|
var result = await productService.DeleteProductCategoryAsync(1, 1, fixture.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task DeleteAllProductCategoriesAsync_ShouldReturn_ResultWithSuccess()
|
||||||
|
{
|
||||||
|
var result = await productService.DeleteAllProductCategoriesAsync(1, fixture.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task GetProductPriceAsync_ShouldReturn_ResultOneProductPrice()
|
||||||
|
{
|
||||||
|
var result = await productService.GetProductPriceAsync(2, fixture.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.NotNull(result.Value);
|
||||||
|
|
||||||
|
output.WriteLine(JsonSerializer.Serialize(result.Value));
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task GetProductPricesAsync_ShouldReturn_ResultProductPriceList()
|
||||||
|
{
|
||||||
|
var result = await productService.GetProductPricesAsync(2, fixture.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.NotEmpty(result.Value);
|
||||||
|
|
||||||
|
output.WriteLine(JsonSerializer.Serialize(result.Value));
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task SearchProductsAsync_ShouldReturn_ResultMatchingProducts()
|
||||||
|
{
|
||||||
|
var filter = new ProductFilter
|
||||||
|
{
|
||||||
|
Name = "system",
|
||||||
|
Manufacturer = "techwave",
|
||||||
|
SerialNumber = "2024",
|
||||||
|
MinPrice = 10,
|
||||||
|
MaxPrice = 30
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await productService.SearchProductsAsync(filter, fixture.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.NotEmpty(result.Value);
|
||||||
|
|
||||||
|
output.WriteLine(JsonSerializer.Serialize(result.Value));
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task GetProductAsync_ShouldReturn_ResultOneProduct()
|
||||||
|
{
|
||||||
|
var result = await productService.GetProductAsync(2, fixture.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.NotNull(result.Value);
|
||||||
|
|
||||||
|
output.WriteLine(JsonSerializer.Serialize(result.Value));
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task GetProductsAsync_ShouldReturn_ResultProducts()
|
||||||
|
{
|
||||||
|
var range = new DateRange
|
||||||
|
{
|
||||||
|
From = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(-7)),
|
||||||
|
To = DateOnly.FromDateTime(DateTime.UtcNow),
|
||||||
|
MaxRecords = 1000
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await productService.GetProductsAsync(0, range, fixture.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.NotEmpty(result.Value);
|
||||||
|
|
||||||
|
output.WriteLine(JsonSerializer.Serialize(result.Value));
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task UpdateProductStatusAsync_ShouldResurn_ResultTrue()
|
||||||
|
{
|
||||||
|
var result = await productService.UpdateProductStatusAsync(2, true, fixture.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task UpdateProductPriceStatusAsync_ShouldReturn_ResultTrue()
|
||||||
|
{
|
||||||
|
var result = await productService.UpdateProductPriceStatusAsync(2, true, fixture.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task CreateProductPriceAsync_Should_Return_NewProductPriceId()
|
||||||
|
{
|
||||||
|
var request = new CreateProductPrice
|
||||||
|
{
|
||||||
|
Amount = 29.99m,
|
||||||
|
Discount = 0.00m
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await productService.CreateProductPriceAsync(2, request, fixture.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess, "Product price creation should be successful.");
|
||||||
|
Assert.True(result.Value > 0, "New ProductPriceId should be greater than 0.");
|
||||||
|
|
||||||
|
output.WriteLine($"Created ProductPriceId: {result.Value}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntegrationFact]
|
||||||
|
public async Task CreateProductAsync_Result_Returns_ProductId()
|
||||||
|
{
|
||||||
|
var request = new CreateProduct
|
||||||
|
{
|
||||||
|
Name = "Systems Rewired",
|
||||||
|
Description = "[Design], <Code>, AND /CHAORS/ IN ***SYNC***",
|
||||||
|
Summary = "A comprehensive guide to systems thinking and design.",
|
||||||
|
ImageUrl = "https://bookshop.cdn.khongisa.co.za/design/2bf1f9a2-7b25-4fcf-9aa7-08941ea21e6c_1764838499686.webp",
|
||||||
|
Type = ProductTypes.Book,
|
||||||
|
Categories = ["Systems Thinking", "Design", "Programming"],
|
||||||
|
Metadata = new ProductMetadata
|
||||||
|
{
|
||||||
|
CopyrightInfo = "© 2024 John Doe. All rights reserved.",
|
||||||
|
ManufactureDate = "2024-06-01",
|
||||||
|
Manufacturer = "TechWave Publishing",
|
||||||
|
SerialNumber = "SR-2024-0001"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await productService.CreateProductAsync(request, fixture.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess, "Product creation should be successful.");
|
||||||
|
Assert.True(result.Value > 0, "ProductId should be greater than 0.");
|
||||||
|
|
||||||
|
output.WriteLine($"Created ProductId: {result.Value}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"ValidPayfastHosts": [
|
||||||
|
"www.payfast.co.za",
|
||||||
|
"sandbox.payfast.co.za",
|
||||||
|
"w1w.payfast.co.za",
|
||||||
|
"w2w.payfast.co.za",
|
||||||
|
"ips.payfast.co.za",
|
||||||
|
"api.payfast.co.za",
|
||||||
|
"payment.payfast.io"
|
||||||
|
],
|
||||||
|
"HasherSettings": {
|
||||||
|
"MinHashLength": 11
|
||||||
|
},
|
||||||
|
"BookshopS3Settings": {
|
||||||
|
"ServiceUrl": "http://192.168.1.177:30900",
|
||||||
|
"Region": "garage",
|
||||||
|
"BucketName": "bookshop",
|
||||||
|
"CdnBaseUrl": "https://bookshop.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,143 @@
|
|||||||
|
using LiteCharms.Features.Abstractions;
|
||||||
|
using LiteCharms.Features.MidrandBooks.AuthorBooks.Models;
|
||||||
|
using LiteCharms.Features.MidrandBooks.Extensions;
|
||||||
|
using LiteCharms.Features.MidrandBooks.Postgres;
|
||||||
|
|
||||||
|
namespace LiteCharms.Features.MidrandBooks.AuthorBooks;
|
||||||
|
|
||||||
|
public sealed class BooksService(IDbContextFactory<MidrandBooksDbContext> contextFactory) : IService
|
||||||
|
{
|
||||||
|
public async ValueTask<Result> UpdateBookStatusAsync(long bookId, bool isEnabled, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
var rowsUpdated = await context.Books
|
||||||
|
.Where(b => b.Id == bookId)
|
||||||
|
.ExecuteUpdateAsync(setters => setters
|
||||||
|
.SetProperty(b => b.Enabled, isEnabled)
|
||||||
|
.SetProperty(b => b.UpdatedAt, DateTime.UtcNow), cancellationToken);
|
||||||
|
|
||||||
|
return rowsUpdated > 0
|
||||||
|
? Result.Ok()
|
||||||
|
: Result.Fail(new Error($"Book with ID {bookId} not found"));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail(new Error(ex.Message).CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Result<long>> CreateBookAsync(long authorId, long productId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
if(!await context.Authors.AnyAsync(a => a.Id == authorId, cancellationToken))
|
||||||
|
return Result.Fail<long>("Author not found.");
|
||||||
|
|
||||||
|
if (!await context.Products.AnyAsync(p => p.Id == productId, cancellationToken))
|
||||||
|
return Result.Fail<long>("Product not found.");
|
||||||
|
|
||||||
|
var book = context.Books.Add(new Entities.AuthorBook
|
||||||
|
{
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
AuthorId = authorId,
|
||||||
|
ProductId = productId
|
||||||
|
});
|
||||||
|
|
||||||
|
return await context.SaveChangesAsync(cancellationToken) > 0
|
||||||
|
? Result.Ok(book.Entity.Id)
|
||||||
|
: Result.Fail<long>("Failed to create book.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail<long>(new Error(ex.Message).CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Result<AuthorBook>> GetBookAsync(long bookId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
var book = await context.Books
|
||||||
|
.AsNoTracking()
|
||||||
|
.Include(b => b.Author)
|
||||||
|
.Include(b => b.Product)
|
||||||
|
.ThenInclude(b => b!.Prices)
|
||||||
|
.Include(b => b.Pages)
|
||||||
|
.FirstOrDefaultAsync(b => b.Id == bookId, cancellationToken);
|
||||||
|
|
||||||
|
return book is null
|
||||||
|
? Result.Fail<AuthorBook>(new Error($"Book with ID {bookId} not found"))
|
||||||
|
: Result.Ok(book.ToModel());
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail<AuthorBook>(new Error(ex.Message).CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Result<AuthorBook[]>> GetBooksByAuthorAsync(long authorId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
if(!await context.Authors.AnyAsync(a => a.Id == authorId, cancellationToken))
|
||||||
|
return Result.Fail<AuthorBook[]>(new Error($"Author with ID {authorId} not found"));
|
||||||
|
|
||||||
|
var books = await context.Books
|
||||||
|
.AsNoTracking()
|
||||||
|
.Include(b => b.Author)
|
||||||
|
.Include(b => b.Product)
|
||||||
|
.ThenInclude(b => b!.Prices)
|
||||||
|
.OrderByDescending(b => b.CreatedAt)
|
||||||
|
.Where(b => b.AuthorId == authorId)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return books?.Count > 0
|
||||||
|
? Result.Ok(books.Select(b => b.ToModel()).ToArray())
|
||||||
|
: Result.Fail<AuthorBook[]>(new Error($"No books found for author with ID {authorId}"));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail<AuthorBook[]>(new Error(ex.Message).CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Result<AuthorBook[]>> GetPublishedBooksAsync(int offset, int limit, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
var books = await context.Books
|
||||||
|
.AsNoTracking()
|
||||||
|
.Include(b => b.Author)
|
||||||
|
.Include(b => b.Product)
|
||||||
|
.ThenInclude(b => b!.Prices)
|
||||||
|
.Include(b => b.Pages)
|
||||||
|
.Where(b => b.Enabled && b.Product!.Enabled && b.Author!.Enabled)
|
||||||
|
.OrderByDescending(b => b.Ranking)
|
||||||
|
.ThenByDescending(b => b.Ranking)
|
||||||
|
.ThenByDescending(b => b.CreatedAt)
|
||||||
|
.ThenByDescending(b => b.UpdatedAt)
|
||||||
|
.Skip(offset).Take(limit)
|
||||||
|
.AsSplitQuery()
|
||||||
|
.ToArrayAsync(cancellationToken);
|
||||||
|
|
||||||
|
return books?.Length > 0
|
||||||
|
? Result.Ok(books.Select(b => b.ToModel()).ToArray())
|
||||||
|
: Result.Fail<AuthorBook[]>(new Error("No published books found."));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail<AuthorBook[]>(new Error(ex.Message).CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using LiteCharms.Features.MidrandBooks.Authors.Entities;
|
||||||
|
using LiteCharms.Features.MidrandBooks.Pages.Entities;
|
||||||
|
using LiteCharms.Features.MidrandBooks.Products.Entities;
|
||||||
|
|
||||||
|
namespace LiteCharms.Features.MidrandBooks.AuthorBooks.Entities;
|
||||||
|
|
||||||
|
public class AuthorBook : Models.AuthorBook
|
||||||
|
{
|
||||||
|
public virtual Author? Author { get; set; }
|
||||||
|
|
||||||
|
public new virtual Product? Product { get; set; }
|
||||||
|
|
||||||
|
public virtual ICollection<BookPage> Pages { get; set; } = [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
namespace LiteCharms.Features.MidrandBooks.AuthorBooks.Entities;
|
||||||
|
|
||||||
|
public sealed class AuthorBookConfiguration : IEntityTypeConfiguration<AuthorBook>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<AuthorBook> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("Books");
|
||||||
|
|
||||||
|
builder.HasKey(f => f.AuthorId);
|
||||||
|
builder.Property(f => f.CreatedAt).IsRequired().ValueGeneratedOnAdd().HasDefaultValueSql("now()");
|
||||||
|
builder.Property(f => f.UpdatedAt).HasDefaultValueSql("now()");
|
||||||
|
builder.Property(f => f.AuthorId).IsRequired();
|
||||||
|
builder.Property(f => f.ProductId).IsRequired();
|
||||||
|
builder.Property(f => f.Rating).IsRequired(false);
|
||||||
|
builder.Property(f => f.Ranking).IsRequired(false);
|
||||||
|
builder.Property(f => f.Enabled).HasDefaultValue(true);
|
||||||
|
|
||||||
|
builder.HasOne(f => f.Author)
|
||||||
|
.WithMany(a => a.Books)
|
||||||
|
.HasForeignKey(f => f.AuthorId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
builder.HasOne(f => f.Product)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(f => f.ProductId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using LiteCharms.Features.MidrandBooks.Products.Models;
|
||||||
|
|
||||||
|
namespace LiteCharms.Features.MidrandBooks.AuthorBooks.Models;
|
||||||
|
|
||||||
|
public class AuthorBook
|
||||||
|
{
|
||||||
|
public long Id { get; set; }
|
||||||
|
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
|
||||||
|
public DateTime? UpdatedAt { get; set; }
|
||||||
|
|
||||||
|
public long AuthorId { get; set; }
|
||||||
|
|
||||||
|
public long ProductId { get; set; }
|
||||||
|
|
||||||
|
public int Rating { get; set; }
|
||||||
|
|
||||||
|
public int Ranking { get; set; }
|
||||||
|
|
||||||
|
public Product? Product { get; set; }
|
||||||
|
|
||||||
|
public bool Enabled { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
using LiteCharms.Features.Abstractions;
|
||||||
|
using LiteCharms.Features.MidrandBooks.Authors.Models;
|
||||||
|
using LiteCharms.Features.MidrandBooks.Extensions;
|
||||||
|
using LiteCharms.Features.MidrandBooks.Postgres;
|
||||||
|
using LiteCharms.Features.Models;
|
||||||
|
|
||||||
|
namespace LiteCharms.Features.MidrandBooks.Authors;
|
||||||
|
|
||||||
|
public sealed class AuthorService(IDbContextFactory<MidrandBooksDbContext> contextFactory) : IService
|
||||||
|
{
|
||||||
|
public async ValueTask<Result<Author>> GetAuthorByProductIdAsync(long productId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
var author = await context.Books
|
||||||
|
.AsNoTracking()
|
||||||
|
.Include(i => i.Author)
|
||||||
|
.Where(b => b.ProductId == productId)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (author is null)
|
||||||
|
return Result.Fail<Author>(new Error($"No author association discovered for Product ID {productId}"));
|
||||||
|
|
||||||
|
return Result.Ok(author.Author!.ToModel());
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail<Author>(new Error(ex.Message).CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Result> UpdateAuthorStatusAsync(long authorId, bool isEnabled, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
var rowsUpdated = await context.Authors
|
||||||
|
.Where(a => a.Id == authorId)
|
||||||
|
.ExecuteUpdateAsync(setters => setters
|
||||||
|
.SetProperty(a => a.Enabled, isEnabled)
|
||||||
|
.SetProperty(a => a.UpdatedAt, DateTime.UtcNow), cancellationToken);
|
||||||
|
|
||||||
|
return rowsUpdated > 0
|
||||||
|
? Result.Ok()
|
||||||
|
: Result.Fail(new Error($"Author with ID {authorId} not found"));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail(new Error(ex.Message).CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Result<Author>> GetAuthorAsync(long authorId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
var author = await context.Authors.FirstOrDefaultAsync(a => a.Id == authorId, cancellationToken);
|
||||||
|
|
||||||
|
return author is not null
|
||||||
|
? Result.Ok(author.ToModel())
|
||||||
|
: Result.Fail<Author>(new Error($"Author with ID {authorId} not found"));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail<Author>(new Error(ex.Message).CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Result<Author[]>> GetAuthorsAsync(DateRange range, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var fromDate = range.From.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc);
|
||||||
|
var toDate = range.To.ToDateTime(TimeOnly.MaxValue, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
var authors = await context.Authors.AsNoTracking()
|
||||||
|
.OrderByDescending(o => o.CreatedAt)
|
||||||
|
.ThenByDescending(o => o.UpdatedAt)
|
||||||
|
.Where(a => a.CreatedAt >= fromDate && a.CreatedAt <= toDate)
|
||||||
|
.Take(range.MaxRecords)
|
||||||
|
.ToArrayAsync(cancellationToken);
|
||||||
|
|
||||||
|
return authors?.Length > 0
|
||||||
|
? Result.Ok(authors.Select(a => a.ToModel()).ToArray())
|
||||||
|
: Result.Fail<Author[]>(new Error("No authors found in the specified date range."));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail<Author[]>(new Error(ex.Message).CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Result> UpdateAuthorAsync(long authorId, UpdateAuthor request, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
var author = await context.Authors.FirstOrDefaultAsync(a => a.Id == authorId, cancellationToken);
|
||||||
|
|
||||||
|
if (author is null)
|
||||||
|
return Result.Fail(new Error($"Author with ID {authorId} not found"));
|
||||||
|
|
||||||
|
author.UpdatedAt = DateTime.UtcNow;
|
||||||
|
author.PublisherType = request.PublisherType;
|
||||||
|
author.Company = request.Company;
|
||||||
|
author.VatNumber = request.VatNumber;
|
||||||
|
author.Name = request.Name;
|
||||||
|
author.LastName = request.LastName;
|
||||||
|
author.Biography = request.Biography;
|
||||||
|
author.Email = request.Email;
|
||||||
|
author.Website = request.Website;
|
||||||
|
author.ImageUrl = request.ImageUrl;
|
||||||
|
author.ThumbnailImageUrl = request.ThumbnailImageUrl;
|
||||||
|
author.SocialMedia = request.SocialMedia;
|
||||||
|
|
||||||
|
return await context.SaveChangesAsync(cancellationToken) > 0
|
||||||
|
? Result.Ok()
|
||||||
|
: Result.Fail(new Error($"Failed to update author with ID {authorId}"));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail(new Error(ex.Message).CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Result<long>> CreateAuthorAsync(CreateAuthor request, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (await context.Authors.AnyAsync(a => a.Name == request.Name && a.LastName == request.LastName, cancellationToken))
|
||||||
|
return Result.Fail<long>(new Error($"An author with the name {request.Name} {request.LastName} already exists"));
|
||||||
|
|
||||||
|
if (await context.Authors.AnyAsync(a => a.Email == request.Email, cancellationToken))
|
||||||
|
return Result.Fail<long>(new Error($"An author with the email {request.Email} already exists"));
|
||||||
|
|
||||||
|
var newAuthor = context.Authors.Add(new Entities.Author
|
||||||
|
{
|
||||||
|
Company = request.Company,
|
||||||
|
VatNumber = request.VatNumber,
|
||||||
|
PublisherType = request.PublisherType,
|
||||||
|
Name = request.Name,
|
||||||
|
LastName = request.LastName,
|
||||||
|
Biography = request.Biography,
|
||||||
|
Email = request.Email,
|
||||||
|
Website = request.Website,
|
||||||
|
ImageUrl = request.ImageUrl,
|
||||||
|
ThumbnailImageUrl = request.ThumbnailImageUrl,
|
||||||
|
SocialMedia = request.SocialMedia
|
||||||
|
});
|
||||||
|
|
||||||
|
return await context.SaveChangesAsync(cancellationToken) > 0
|
||||||
|
? Result.Ok(newAuthor.Entity.Id)
|
||||||
|
: Result.Fail<long>(new Error($"Failed to create author {request.Name} {request.LastName}"));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail<long>(new Error(ex.Message).CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using LiteCharms.Features.MidrandBooks.AuthorBooks.Entities;
|
||||||
|
|
||||||
|
namespace LiteCharms.Features.MidrandBooks.Authors.Entities;
|
||||||
|
|
||||||
|
[EntityTypeConfiguration<AuthorConfiguration, Author>]
|
||||||
|
public sealed class Author : Models.Author
|
||||||
|
{
|
||||||
|
public ICollection<AuthorBook> Books { get; set; } = [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
namespace LiteCharms.Features.MidrandBooks.Authors.Entities;
|
||||||
|
|
||||||
|
public sealed class AuthorConfiguration : IEntityTypeConfiguration<Author>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<Author> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("Authors");
|
||||||
|
|
||||||
|
builder.HasKey(f => f.Id);
|
||||||
|
builder.Property(f => f.CreatedAt).IsRequired().ValueGeneratedOnAdd().HasDefaultValueSql("now()");
|
||||||
|
builder.Property(f => f.UpdatedAt).HasDefaultValueSql("now()");
|
||||||
|
builder.Property(f => f.PublisherType).IsRequired();
|
||||||
|
builder.Property(f => f.VatNumber).IsRequired(false).HasMaxLength(255);
|
||||||
|
builder.Property(f => f.Name).IsRequired().HasMaxLength(255);
|
||||||
|
builder.Property(f => f.LastName).IsRequired().HasMaxLength(255);
|
||||||
|
builder.Property(f => f.Biography).IsRequired(false).HasMaxLength(2048);
|
||||||
|
builder.Property(f => f.Email).IsRequired().HasMaxLength(512);
|
||||||
|
builder.Property(f => f.Website).IsRequired(false).HasMaxLength(1024);
|
||||||
|
builder.Property(f => f.ImageUrl).IsRequired().HasMaxLength(2048);
|
||||||
|
builder.Property(f => f.ThumbnailImageUrl).IsRequired(false).HasMaxLength(2048);
|
||||||
|
builder.Property(f => f.Enabled).HasDefaultValue(true);
|
||||||
|
|
||||||
|
builder.OwnsMany(f => f.SocialMedia, b => { b.ToJson(); });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using LiteCharms.Features.Models;
|
||||||
|
|
||||||
|
namespace LiteCharms.Features.MidrandBooks.Authors.Models;
|
||||||
|
|
||||||
|
public class Author
|
||||||
|
{
|
||||||
|
public long Id { get; set; }
|
||||||
|
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
|
||||||
|
public DateTime? UpdatedAt { get; set; }
|
||||||
|
|
||||||
|
public PublisherTypes PublisherType { get; set; }
|
||||||
|
|
||||||
|
public string? Company { get; set; }
|
||||||
|
|
||||||
|
public string? VatNumber { get; set; }
|
||||||
|
|
||||||
|
public string? Name { get; set; }
|
||||||
|
|
||||||
|
public string? LastName { get; set; }
|
||||||
|
|
||||||
|
public string? Biography { get; set; }
|
||||||
|
|
||||||
|
public string? Email { get; set; }
|
||||||
|
|
||||||
|
public string? Website { get; set; }
|
||||||
|
|
||||||
|
public string? ImageUrl { get; set; }
|
||||||
|
|
||||||
|
public string? ThumbnailImageUrl { get; set; }
|
||||||
|
|
||||||
|
public ICollection<SocialMedia>? SocialMedia { get; set; }
|
||||||
|
|
||||||
|
public bool Enabled { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using LiteCharms.Features.Models;
|
||||||
|
|
||||||
|
namespace LiteCharms.Features.MidrandBooks.Authors.Models;
|
||||||
|
|
||||||
|
public sealed record UpdateAuthor : CreateAuthor;
|
||||||
|
|
||||||
|
public record CreateAuthor
|
||||||
|
{
|
||||||
|
public required PublisherTypes PublisherType { get; set; }
|
||||||
|
|
||||||
|
public string? Company { get; set; }
|
||||||
|
|
||||||
|
public string? VatNumber { get; set; }
|
||||||
|
|
||||||
|
public required string Name { get; set; }
|
||||||
|
|
||||||
|
public required string LastName { get; set; }
|
||||||
|
|
||||||
|
public string? Biography { get; set; }
|
||||||
|
|
||||||
|
public required string Email { get; set; }
|
||||||
|
|
||||||
|
public string? Website { get; set; }
|
||||||
|
|
||||||
|
public required string ImageUrl { get; set; }
|
||||||
|
|
||||||
|
public string? ThumbnailImageUrl { get; set; }
|
||||||
|
|
||||||
|
public SocialMedia[]? SocialMedia { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
using LiteCharms.Features.Abstractions;
|
||||||
|
using LiteCharms.Features.MidrandBooks.Categories.Models;
|
||||||
|
using LiteCharms.Features.MidrandBooks.Extensions;
|
||||||
|
using LiteCharms.Features.MidrandBooks.Postgres;
|
||||||
|
|
||||||
|
namespace LiteCharms.Features.MidrandBooks.Categories;
|
||||||
|
|
||||||
|
public sealed class CategoryService(IDbContextFactory<MidrandBooksDbContext> contextFactory) : IService
|
||||||
|
{
|
||||||
|
public async ValueTask<Result> UpdateCategoryStatusAsync(long categoryId, bool enabled, bool isMain, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
var rowsUpdated = await context.Categories
|
||||||
|
.Where(c => c.Id == categoryId && c.Enabled)
|
||||||
|
.ExecuteUpdateAsync(setters => setters
|
||||||
|
.SetProperty(c => c.Enabled, enabled)
|
||||||
|
.SetProperty(c => c.IsMain, isMain), cancellationToken);
|
||||||
|
|
||||||
|
return rowsUpdated > 0
|
||||||
|
? Result.Ok()
|
||||||
|
: Result.Fail(new Error($"Failed to update category"));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail(new Error(ex.Message).CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Result<Category>> GetCategoryAsync(long categoryId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
var category = await context.Categories.AsNoTracking().FirstOrDefaultAsync(c => c.Id == categoryId, cancellationToken);
|
||||||
|
|
||||||
|
return category is not null
|
||||||
|
? Result.Ok(category.ToModel())
|
||||||
|
: Result.Fail<Category>("Failed to create new category");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail<Category>(new Error(ex.Message).CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Result<Category[]>> GetCategoriesAsync(bool? isMain = null, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
var query = context.Categories.AsNoTracking()
|
||||||
|
.OrderByDescending(o => o.IsMain)
|
||||||
|
.ThenByDescending(o => o.Id)
|
||||||
|
.ThenBy(o => o.Name)
|
||||||
|
.AsQueryable();
|
||||||
|
|
||||||
|
query = isMain is null
|
||||||
|
? query.Where(c => c.Enabled).AsQueryable()
|
||||||
|
: query.Where(c => c.Enabled && c.IsMain == isMain.Value);
|
||||||
|
|
||||||
|
var categories = await query.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return categories?.Count > 0
|
||||||
|
? Result.Ok(categories.Select(c => c.ToModel()).ToArray())
|
||||||
|
: Result.Fail<Category[]>("No categories found");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail<Category[]>(new Error(ex.Message).CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Result> CreateCategoriesAsync(CancellationToken cancellationToken = default, params string[] categories)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
foreach (var category in categories)
|
||||||
|
{
|
||||||
|
if (await context.Categories.AnyAsync(c => EF.Functions.ILike(c.Name!, category!), cancellationToken))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
context.Categories.Add(new Entities.Category
|
||||||
|
{
|
||||||
|
Name = category.Humanize(LetterCasing.Title),
|
||||||
|
IsMain = false,
|
||||||
|
Enabled = true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return await context.SaveChangesAsync(cancellationToken) > 0
|
||||||
|
? Result.Ok()
|
||||||
|
: Result.Fail("Failed to add any category in the list");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail(new Error(ex.Message).CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Result<long>> CreateCategoryAsync(string category, bool isMain, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (await context.Categories.AnyAsync(c => EF.Functions.ILike(c.Name!, category!), cancellationToken))
|
||||||
|
return Result.Fail($"Category '{category}' already exists");
|
||||||
|
|
||||||
|
var newCategory = context.Categories.Add(new Entities.Category
|
||||||
|
{
|
||||||
|
Name = StringHumanizeExtensions.Humanize(category, LetterCasing.Title),
|
||||||
|
IsMain = isMain,
|
||||||
|
Enabled = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return await context.SaveChangesAsync(cancellationToken) > 0
|
||||||
|
? Result.Ok(newCategory.Entity.Id)
|
||||||
|
: Result.Fail("Failed to create new category");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail(new Error(ex.Message).CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
namespace LiteCharms.Features.MidrandBooks.Categories.Entities;
|
||||||
|
|
||||||
|
[EntityTypeConfiguration<CategoryConfiguration, Category>]
|
||||||
|
public sealed class Category : Models.Category;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
namespace LiteCharms.Features.MidrandBooks.Categories.Entities;
|
||||||
|
|
||||||
|
public sealed class CategoryConfiguration : IEntityTypeConfiguration<Category>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<Category> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("Categories");
|
||||||
|
|
||||||
|
builder.HasKey(c => c.Id);
|
||||||
|
builder.Property(c => c.Name).IsRequired().HasMaxLength(15);
|
||||||
|
builder.Property(c => c.IsMain).HasDefaultValue(false);
|
||||||
|
builder.Property(c => c.Enabled).HasDefaultValue(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace LiteCharms.Features.MidrandBooks.Categories.Models;
|
||||||
|
|
||||||
|
public class Category
|
||||||
|
{
|
||||||
|
public long Id { get; set; }
|
||||||
|
|
||||||
|
public string? Name { get; set; }
|
||||||
|
|
||||||
|
public bool IsMain { get; set; }
|
||||||
|
|
||||||
|
public bool Enabled { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,398 @@
|
|||||||
|
using LiteCharms.Features.Abstractions;
|
||||||
|
using LiteCharms.Features.MidrandBooks.Customers.Models;
|
||||||
|
using LiteCharms.Features.MidrandBooks.Extensions;
|
||||||
|
using LiteCharms.Features.MidrandBooks.Postgres;
|
||||||
|
|
||||||
|
namespace LiteCharms.Features.MidrandBooks.Customers;
|
||||||
|
|
||||||
|
public sealed class CustomerService(IDbContextFactory<MidrandBooksDbContext> contextFactory) : IService
|
||||||
|
{
|
||||||
|
public async ValueTask<Result<long>> CreateCustomerAsync(CreateCustomer request, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (await context.Customers.AnyAsync(c => EF.Functions.ILike(c.Email!, $"%{request.Email}%"), cancellationToken))
|
||||||
|
return Result.Fail<long>(new Error($"Customer with email '{request.Email}' already exists."));
|
||||||
|
|
||||||
|
var customer = context.Customers.Add(new Entities.Customer
|
||||||
|
{
|
||||||
|
Company = request.Company,
|
||||||
|
VatNumber = request.VatNumber,
|
||||||
|
Email = request.Email,
|
||||||
|
Website = request.Website,
|
||||||
|
Phone = request.Phone,
|
||||||
|
SocialMedia = request.SocialMedia,
|
||||||
|
Enabled = true
|
||||||
|
});
|
||||||
|
|
||||||
|
return await context.SaveChangesAsync(cancellationToken) > 0
|
||||||
|
? Result.Ok(customer.Entity.Id)
|
||||||
|
: Result.Fail<long>(new Error("Failed to create customer."));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail<long>(new Error(ex.Message).CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Result<long>> CreateCustomerContactAsync(long customerId, CreateCustomerContact request, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (!await context.Customers.AnyAsync(c => c.Id == customerId, cancellationToken))
|
||||||
|
return Result.Fail<long>(new Error($"Customer with ID '{customerId}' does not exist."));
|
||||||
|
|
||||||
|
if (await context.Contacts.AnyAsync(cc => cc.CustomerId == customerId && EF.Functions.ILike(cc.Email!, $"%{request.Email}%"), cancellationToken))
|
||||||
|
return Result.Fail<long>(new Error($"Contact with email '{request.Email}' already exists for this customer."));
|
||||||
|
|
||||||
|
var contact = context.Contacts.Add(new Entities.Contact
|
||||||
|
{
|
||||||
|
CustomerId = customerId,
|
||||||
|
Name = request.Name,
|
||||||
|
Email = request.Email,
|
||||||
|
Phone = request.Phone,
|
||||||
|
LastName = request.LastName,
|
||||||
|
Type = request.Type,
|
||||||
|
Enabled = true
|
||||||
|
});
|
||||||
|
|
||||||
|
return await context.SaveChangesAsync(cancellationToken) > 0
|
||||||
|
? Result.Ok(contact.Entity.Id)
|
||||||
|
: Result.Fail<long>(new Error("Failed to create customer contact."));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail<long>(new Error(ex.Message).CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Result<long>> CreateCustomerAddressAsync(long customerId, CreateCustomerAddress request, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (!await context.Customers.AnyAsync(c => c.Id == customerId, cancellationToken))
|
||||||
|
return Result.Fail<long>(new Error($"Customer with ID '{customerId}' does not exist."));
|
||||||
|
|
||||||
|
var address = context.Addresses.Add(new Entities.Address
|
||||||
|
{
|
||||||
|
CustomerId = customerId,
|
||||||
|
Street = request.Street,
|
||||||
|
City = request.City,
|
||||||
|
State = request.State,
|
||||||
|
PostalCode = request.PostalCode,
|
||||||
|
Country = request.Country,
|
||||||
|
Type = request.Type,
|
||||||
|
Enabled = true,
|
||||||
|
BuildingType = request.BuildingType,
|
||||||
|
IsPrimary = request.IsPrimary,
|
||||||
|
Name = request.Name
|
||||||
|
});
|
||||||
|
|
||||||
|
return await context.SaveChangesAsync(cancellationToken) > 0
|
||||||
|
? Result.Ok(address.Entity.Id)
|
||||||
|
: Result.Fail<long>(new Error("Failed to create customer address."));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail<long>(new Error(ex.Message).CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Result> UpdateCustomerAsync(long customerId, UpdateCustomer request, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
var customer = await context.Customers.FirstOrDefaultAsync(c => c.Id == customerId, cancellationToken);
|
||||||
|
|
||||||
|
if (customer is null)
|
||||||
|
return Result.Fail(new Error($"Customer with ID '{customerId}' does not exist."));
|
||||||
|
|
||||||
|
customer.UpdatedAt = DateTime.UtcNow;
|
||||||
|
customer.Company = request.Company;
|
||||||
|
customer.VatNumber = request.VatNumber;
|
||||||
|
customer.Email = request.Email;
|
||||||
|
customer.Website = request.Website;
|
||||||
|
customer.Phone = request.Phone;
|
||||||
|
customer.SocialMedia = request.SocialMedia;
|
||||||
|
|
||||||
|
return await context.SaveChangesAsync(cancellationToken) > 0
|
||||||
|
? Result.Ok()
|
||||||
|
: Result.Fail(new Error("Failed to update customer."));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail(new Error(ex.Message).CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Result> UpdateCustomerContactAsync(long contactId, UpdateCustomerContact request, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
var rowsUpdated = await context.Contacts
|
||||||
|
.Where(cc => cc.Id == contactId)
|
||||||
|
.ExecuteUpdateAsync(setters => setters
|
||||||
|
.SetProperty(cc => cc.Name, request.Name)
|
||||||
|
.SetProperty(cc => cc.LastName, request.LastName)
|
||||||
|
.SetProperty(cc => cc.Email, request.Email)
|
||||||
|
.SetProperty(cc => cc.Phone, request.Phone)
|
||||||
|
.SetProperty(cc => cc.Type, request.Type)
|
||||||
|
.SetProperty(cc => cc.UpdatedAt, DateTime.UtcNow), cancellationToken);
|
||||||
|
|
||||||
|
return rowsUpdated > 0
|
||||||
|
? Result.Ok()
|
||||||
|
: Result.Fail(new Error($"Contact with ID '{contactId}' does not exist."));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail(new Error(ex.Message).CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Result> UpdateCustomerAddressAsync(long addressId, UpdateCustomerAddress request, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
var rowsUpdated = await context.Addresses
|
||||||
|
.Where(a => a.Id == addressId)
|
||||||
|
.ExecuteUpdateAsync(setters => setters
|
||||||
|
.SetProperty(a => a.Street, request.Street)
|
||||||
|
.SetProperty(a => a.City, request.City)
|
||||||
|
.SetProperty(a => a.State, request.State)
|
||||||
|
.SetProperty(a => a.PostalCode, request.PostalCode)
|
||||||
|
.SetProperty(a => a.Country, request.Country)
|
||||||
|
.SetProperty(a => a.Type, request.Type)
|
||||||
|
.SetProperty(a => a.BuildingType, request.BuildingType)
|
||||||
|
.SetProperty(a => a.IsPrimary, request.IsPrimary)
|
||||||
|
.SetProperty(a => a.Name, request.Name)
|
||||||
|
.SetProperty(a => a.UpdatedAt, DateTime.UtcNow), cancellationToken);
|
||||||
|
|
||||||
|
return rowsUpdated > 0
|
||||||
|
? Result.Ok()
|
||||||
|
: Result.Fail(new Error($"Address with ID '{addressId}' does not exist."));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail(new Error(ex.Message).CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Result> UpdateCustomerStatusAsync(long customerId, bool enabled, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
var rowsUpdated = await context.Customers
|
||||||
|
.Where(c => c.Id == customerId)
|
||||||
|
.ExecuteUpdateAsync(setters => setters
|
||||||
|
.SetProperty(c => c.Enabled, enabled)
|
||||||
|
.SetProperty(c => c.UpdatedAt, DateTime.UtcNow), cancellationToken);
|
||||||
|
|
||||||
|
return rowsUpdated > 0
|
||||||
|
? Result.Ok()
|
||||||
|
: Result.Fail(new Error($"Customer with ID '{customerId}' does not exist."));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail(new Error(ex.Message).CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Result> UpdateCustomerContactStatusAsync(long contactId, bool enabled, bool isPrimary, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
var rowsUpdated = await context.Contacts
|
||||||
|
.Where(cc => cc.Id == contactId)
|
||||||
|
.ExecuteUpdateAsync(setters => setters
|
||||||
|
.SetProperty(cc => cc.Enabled, enabled)
|
||||||
|
.SetProperty(cc => cc.IsPrimary, isPrimary)
|
||||||
|
.SetProperty(cc => cc.UpdatedAt, DateTime.UtcNow), cancellationToken);
|
||||||
|
|
||||||
|
return rowsUpdated > 0
|
||||||
|
? Result.Ok()
|
||||||
|
: Result.Fail(new Error($"Contact with ID '{contactId}' does not exist."));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail(new Error(ex.Message).CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Result> UpdateCustomerAddressStatusAsync(long addressId, bool enabled, bool isPrimary, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
var rowsUpdated = await context.Addresses
|
||||||
|
.Where(a => a.Id == addressId)
|
||||||
|
.ExecuteUpdateAsync(setters => setters
|
||||||
|
.SetProperty(a => a.Enabled, enabled)
|
||||||
|
.SetProperty(a => a.IsPrimary, isPrimary)
|
||||||
|
.SetProperty(a => a.UpdatedAt, DateTime.UtcNow), cancellationToken);
|
||||||
|
|
||||||
|
return rowsUpdated > 0
|
||||||
|
? Result.Ok()
|
||||||
|
: Result.Fail(new Error($"Address with ID '{addressId}' does not exist."));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail(new Error(ex.Message).CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Result<Customer[]>> GetCustomersAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
var customers = await context.Customers
|
||||||
|
.AsNoTracking()
|
||||||
|
.Include(c => c.Contacts)
|
||||||
|
.Include(c => c.Addresses)
|
||||||
|
.OrderByDescending(c => c.CreatedAt)
|
||||||
|
.ThenByDescending(c => c.UpdatedAt)
|
||||||
|
.AsSplitQuery()
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return customers?.Count > 0
|
||||||
|
? Result.Ok(customers.Select(c => c.ToModel()).ToArray())
|
||||||
|
: Result.Fail<Customer[]>(new Error("No customers found."));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail<Customer[]>(new Error(ex.Message).CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Result<Contact[]>> GetCustomerContactsAsync(long customerId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (!await context.Customers.AnyAsync(c => c.Id == customerId, cancellationToken))
|
||||||
|
return Result.Fail<Contact[]>(new Error($"Customer with ID '{customerId}' does not exist."));
|
||||||
|
|
||||||
|
var contacts = await context.Contacts
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(cc => cc.CustomerId == customerId)
|
||||||
|
.OrderByDescending(cc => cc.CreatedAt)
|
||||||
|
.ThenByDescending(cc => cc.UpdatedAt)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return contacts?.Count > 0
|
||||||
|
? Result.Ok(contacts.Select(cc => cc.ToModel()).ToArray())
|
||||||
|
: Result.Fail<Contact[]>(new Error("No contacts found for the specified customer."));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail<Contact[]>(new Error(ex.Message).CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Result<Address[]>> GetCustomerAddressesAsync(long customerId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (!await context.Customers.AnyAsync(c => c.Id == customerId, cancellationToken))
|
||||||
|
return Result.Fail<Address[]>(new Error($"Customer with ID '{customerId}' does not exist."));
|
||||||
|
|
||||||
|
var addresses = await context.Addresses
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(a => a.CustomerId == customerId)
|
||||||
|
.OrderByDescending(a => a.CreatedAt)
|
||||||
|
.ThenByDescending(a => a.UpdatedAt)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return addresses?.Count > 0
|
||||||
|
? Result.Ok(addresses.Select(a => a.ToModel()).ToArray())
|
||||||
|
: Result.Fail<Address[]>(new Error($"No addresses found for customer with ID '{customerId}'."));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail<Address[]>(new Error(ex.Message).CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Result<Customer>> GetCustomerAsync(long customerId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
var customer = await context.Customers
|
||||||
|
.AsNoTracking()
|
||||||
|
.Include(c => c.Contacts)
|
||||||
|
.Include(c => c.Addresses)
|
||||||
|
.FirstOrDefaultAsync(c => c.Id == customerId, cancellationToken);
|
||||||
|
|
||||||
|
return customer is not null
|
||||||
|
? Result.Ok(customer.ToModel())
|
||||||
|
: Result.Fail<Customer>(new Error($"Customer with ID '{customerId}' does not exist."));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail<Customer>(new Error(ex.Message).CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Result<Contact>> GetCustomerContactAsync(long contactId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
var contact = await context.Contacts
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(cc => cc.Id == contactId, cancellationToken);
|
||||||
|
|
||||||
|
return contact is not null
|
||||||
|
? Result.Ok(contact.ToModel())
|
||||||
|
: Result.Fail<Contact>(new Error($"Contact with ID '{contactId}' does not exist."));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail<Contact>(new Error(ex.Message).CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Result<Address>> GetCustomerAddressAsync(long addressId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
var address = await context.Addresses
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(a => a.Id == addressId, cancellationToken);
|
||||||
|
|
||||||
|
return address is not null
|
||||||
|
? Result.Ok(address.ToModel())
|
||||||
|
: Result.Fail<Address>(new Error($"Address with ID '{addressId}' does not exist."));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail<Address>(new Error(ex.Message).CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace LiteCharms.Features.MidrandBooks.Customers.Entities;
|
||||||
|
|
||||||
|
[EntityTypeConfiguration<AddressConfiguration, Address>]
|
||||||
|
public class Address : Models.Address
|
||||||
|
{
|
||||||
|
public virtual Customer? Customer { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
namespace LiteCharms.Features.MidrandBooks.Customers.Entities;
|
||||||
|
|
||||||
|
public sealed class AddressConfiguration : IEntityTypeConfiguration<Address>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<Address> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("Addresses");
|
||||||
|
|
||||||
|
builder.HasKey(a => a.Id);
|
||||||
|
builder.Property(a => a.CustomerId).IsRequired();
|
||||||
|
builder.Property(a => a.CreatedAt).IsRequired().ValueGeneratedOnAdd().HasDefaultValueSql("now()");
|
||||||
|
builder.Property(a => a.UpdatedAt).HasDefaultValueSql("now()");
|
||||||
|
builder.Property(a => a.Name).IsRequired();
|
||||||
|
builder.Property(a => a.Type).IsRequired();
|
||||||
|
builder.Property(a => a.BuildingType).IsRequired();
|
||||||
|
builder.Property(a => a.Street).IsRequired();
|
||||||
|
builder.Property(a => a.City).IsRequired();
|
||||||
|
builder.Property(a => a.State).IsRequired();
|
||||||
|
builder.Property(a => a.PostalCode).IsRequired();
|
||||||
|
builder.Property(a => a.Country).IsRequired();
|
||||||
|
builder.Property(a => a.IsPrimary).HasDefaultValue(false);
|
||||||
|
builder.Property(a => a.Enabled).HasDefaultValue(true);
|
||||||
|
|
||||||
|
builder.HasOne(a => a.Customer)
|
||||||
|
.WithMany(c => c.Addresses)
|
||||||
|
.HasForeignKey(a => a.CustomerId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace LiteCharms.Features.MidrandBooks.Customers.Entities;
|
||||||
|
|
||||||
|
[EntityTypeConfiguration<ContactConfiguration, Contact>]
|
||||||
|
public class Contact : Models.Contact
|
||||||
|
{
|
||||||
|
public virtual Customer? Customer { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
namespace LiteCharms.Features.MidrandBooks.Customers.Entities;
|
||||||
|
|
||||||
|
public sealed class ContactConfiguration : IEntityTypeConfiguration<Contact>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<Contact> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("Contacts");
|
||||||
|
|
||||||
|
builder.HasKey(c => c.Id);
|
||||||
|
builder.Property(c => c.CustomerId).IsRequired();
|
||||||
|
builder.Property(c => c.CreatedAt).IsRequired().ValueGeneratedOnAdd().HasDefaultValueSql("now()");
|
||||||
|
builder.Property(c => c.UpdatedAt).HasDefaultValueSql("now()");
|
||||||
|
builder.Property(c => c.Name).IsRequired();
|
||||||
|
builder.Property(c => c.LastName).IsRequired();
|
||||||
|
builder.Property(c => c.Type).IsRequired();
|
||||||
|
builder.Property(c => c.Phone).IsRequired();
|
||||||
|
builder.Property(c => c.Email).IsRequired();
|
||||||
|
builder.Property(c => c.IsPrimary).HasDefaultValue(false);
|
||||||
|
builder.Property(c => c.Enabled).HasDefaultValue(true);
|
||||||
|
|
||||||
|
builder.HasOne(c => c.Customer)
|
||||||
|
.WithMany(c => c.Contacts)
|
||||||
|
.HasForeignKey(c => c.CustomerId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace LiteCharms.Features.MidrandBooks.Customers.Entities;
|
||||||
|
|
||||||
|
[EntityTypeConfiguration<CustomerConfiguration, Customer>]
|
||||||
|
public class Customer : Models.Customer
|
||||||
|
{
|
||||||
|
public virtual ICollection<Contact> Contacts { get; set; } = [];
|
||||||
|
|
||||||
|
public virtual ICollection<Address> Addresses { get; set; } = [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
namespace LiteCharms.Features.MidrandBooks.Customers.Entities;
|
||||||
|
|
||||||
|
public sealed class CustomerConfiguration : IEntityTypeConfiguration<Customer>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<Customer> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("Customers");
|
||||||
|
|
||||||
|
builder.HasKey(c => c.Id);
|
||||||
|
builder.Property(c => c.CreatedAt).IsRequired().ValueGeneratedOnAdd().HasDefaultValueSql("now()");
|
||||||
|
builder.Property(c => c.UpdatedAt).HasDefaultValueSql("now()");
|
||||||
|
builder.Property(c => c.Company).IsRequired(false);
|
||||||
|
builder.Property(c => c.VatNumber).IsRequired(false);
|
||||||
|
builder.Property(c => c.Email).IsRequired();
|
||||||
|
builder.Property(c => c.Phone).IsRequired();
|
||||||
|
builder.Property(c => c.Website).IsRequired();
|
||||||
|
builder.Property(c => c.Enabled).HasDefaultValue(true);
|
||||||
|
|
||||||
|
builder.OwnsMany(f => f.SocialMedia, b => { b.ToJson(); });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
namespace LiteCharms.Features.MidrandBooks.Customers.Models;
|
||||||
|
|
||||||
|
public class Address
|
||||||
|
{
|
||||||
|
public long Id { get; set; }
|
||||||
|
|
||||||
|
public long CustomerId { get; set; }
|
||||||
|
|
||||||
|
public string? Name { get; set; }
|
||||||
|
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
|
||||||
|
public DateTime? UpdatedAt { get; set; }
|
||||||
|
|
||||||
|
public AddressType Type { get; set; }
|
||||||
|
|
||||||
|
public AddressBuildingTypes BuildingType { get; set; }
|
||||||
|
|
||||||
|
public string? Street { get; set; }
|
||||||
|
|
||||||
|
public string? City { get; set; }
|
||||||
|
|
||||||
|
public string? State { get; set; }
|
||||||
|
|
||||||
|
public string? PostalCode { get; set; }
|
||||||
|
|
||||||
|
public string? Country { get; set; }
|
||||||
|
|
||||||
|
public bool IsPrimary { get; set; }
|
||||||
|
|
||||||
|
public bool Enabled { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
namespace LiteCharms.Features.MidrandBooks.Customers.Models;
|
||||||
|
|
||||||
|
public class Contact
|
||||||
|
{
|
||||||
|
public long Id { get; set; }
|
||||||
|
|
||||||
|
public long CustomerId { get; set; }
|
||||||
|
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
|
||||||
|
public DateTime? UpdatedAt { get; set; }
|
||||||
|
|
||||||
|
public ContactTypes Type { get; set; }
|
||||||
|
|
||||||
|
public string? Name { get; set; }
|
||||||
|
|
||||||
|
public string? LastName { get; set; }
|
||||||
|
|
||||||
|
public string? Email { get; set; }
|
||||||
|
|
||||||
|
public string? Phone { get; set; }
|
||||||
|
|
||||||
|
public bool IsPrimary { get; set; }
|
||||||
|
|
||||||
|
public bool Enabled { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using LiteCharms.Features.Models;
|
||||||
|
|
||||||
|
namespace LiteCharms.Features.MidrandBooks.Customers.Models;
|
||||||
|
|
||||||
|
public class Customer
|
||||||
|
{
|
||||||
|
public long Id { get; set; }
|
||||||
|
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
|
||||||
|
public DateTime? UpdatedAt { get; set; }
|
||||||
|
|
||||||
|
public string? Company { get; set; }
|
||||||
|
|
||||||
|
public string? VatNumber { get; set; }
|
||||||
|
|
||||||
|
public string? Email { get; set; }
|
||||||
|
|
||||||
|
public string? Website { get; set; }
|
||||||
|
|
||||||
|
public string? Phone { get; set; }
|
||||||
|
|
||||||
|
public ICollection<SocialMedia>? SocialMedia { get; set; }
|
||||||
|
|
||||||
|
public bool Enabled { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
using LiteCharms.Features.Models;
|
||||||
|
|
||||||
|
namespace LiteCharms.Features.MidrandBooks.Customers.Models;
|
||||||
|
|
||||||
|
public record CreateCustomer
|
||||||
|
{
|
||||||
|
public string? Company { get; set; }
|
||||||
|
|
||||||
|
public string? VatNumber { get; set; }
|
||||||
|
|
||||||
|
public string? Email { get; set; }
|
||||||
|
|
||||||
|
public string? Website { get; set; }
|
||||||
|
|
||||||
|
public string? Phone { get; set; }
|
||||||
|
|
||||||
|
public ICollection<SocialMedia>? SocialMedia { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record UpdateCustomer : CreateCustomer;
|
||||||
|
|
||||||
|
public record CreateCustomerContact
|
||||||
|
{
|
||||||
|
public ContactTypes Type { get; set; }
|
||||||
|
|
||||||
|
public string? Name { get; set; }
|
||||||
|
|
||||||
|
public string? LastName { get; set; }
|
||||||
|
|
||||||
|
public string? Email { get; set; }
|
||||||
|
|
||||||
|
public string? Phone { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record UpdateCustomerContact : CreateCustomerContact;
|
||||||
|
|
||||||
|
public record CreateCustomerAddress
|
||||||
|
{
|
||||||
|
public string? Name { get; set; }
|
||||||
|
|
||||||
|
public AddressType Type { get; set; }
|
||||||
|
|
||||||
|
public AddressBuildingTypes BuildingType { get; set; }
|
||||||
|
|
||||||
|
public string? Street { get; set; }
|
||||||
|
|
||||||
|
public string? City { get; set; }
|
||||||
|
|
||||||
|
public string? State { get; set; }
|
||||||
|
|
||||||
|
public string? PostalCode { get; set; }
|
||||||
|
|
||||||
|
public string? Country { get; set; }
|
||||||
|
|
||||||
|
public bool IsPrimary { get; set; }
|
||||||
|
|
||||||
|
public bool Enabled { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record UpdateCustomerAddress : CreateCustomerAddress;
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using LiteCharms.Features.MidrandBooks.HealthChecks;
|
||||||
|
using static LiteCharms.Features.Extensions.Postgres;
|
||||||
|
using static LiteCharms.Features.MidrandBooks.Extensions.Postgres;
|
||||||
|
|
||||||
|
namespace LiteCharms.Features.MidrandBooks.Extensions;
|
||||||
|
|
||||||
|
public static class HealthChecks
|
||||||
|
{
|
||||||
|
public static IServiceCollection AddMidrandShopQuartzHealthCheck(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddHealthChecks().AddCheck<MidrandShopQuartzHealthCheck>(SchedulerDbConfigName);
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IServiceCollection AddMidrandShopPostgresHealthCheck(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddHealthChecks().AddCheck<PostgresMidrandShopHealthCheck>(MidrandBooksDbConfigName);
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IServiceCollection AddHealthChecksSupport(this IServiceCollection services, IConfiguration configuration)
|
||||||
|
{
|
||||||
|
services.AddHealthChecks()
|
||||||
|
.AddCheck("Self", () => HealthCheckResult.Healthy());
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
using LiteCharms.Features.MidrandBooks.AuthorBooks.Models;
|
||||||
|
using LiteCharms.Features.MidrandBooks.Authors.Models;
|
||||||
|
using LiteCharms.Features.MidrandBooks.Categories.Models;
|
||||||
|
using LiteCharms.Features.MidrandBooks.Customers.Models;
|
||||||
|
using LiteCharms.Features.MidrandBooks.Orders.Models;
|
||||||
|
using LiteCharms.Features.MidrandBooks.Pages.Models;
|
||||||
|
using LiteCharms.Features.MidrandBooks.Payments.Models;
|
||||||
|
using LiteCharms.Features.MidrandBooks.Products.Models;
|
||||||
|
|
||||||
|
namespace LiteCharms.Features.MidrandBooks.Extensions;
|
||||||
|
|
||||||
|
public static class Mappers
|
||||||
|
{
|
||||||
|
public static PaymentGatewayLedger ToModel(this Payments.Entities.PaymentGatewayLedger entity) => new()
|
||||||
|
{
|
||||||
|
Id = entity.Id,
|
||||||
|
CreatedAt = entity.CreatedAt,
|
||||||
|
CustomerEmail = entity.CustomerEmail,
|
||||||
|
OrderId = entity.OrderId,
|
||||||
|
PaymentId = entity.PaymentId,
|
||||||
|
MerchantPaymentId = entity.MerchantPaymentId,
|
||||||
|
PayfastPaymentId = entity.PayfastPaymentId,
|
||||||
|
PaymentStatus = entity.PaymentStatus,
|
||||||
|
AmountGross = entity.AmountGross,
|
||||||
|
AmountFee = entity.AmountFee,
|
||||||
|
AmountNet = entity.AmountNet
|
||||||
|
};
|
||||||
|
|
||||||
|
public static Refund ToModel(this Payments.Entities.Refund entity) => new()
|
||||||
|
{
|
||||||
|
CreatedAt = entity.CreatedAt,
|
||||||
|
Amount = entity.Amount,
|
||||||
|
Id = entity.Id,
|
||||||
|
OrderId = entity.OrderId,
|
||||||
|
Reason = entity.Reason,
|
||||||
|
Status = entity.Status,
|
||||||
|
Type = entity.Type,
|
||||||
|
UpdatedAt = entity.UpdatedAt,
|
||||||
|
};
|
||||||
|
|
||||||
|
public static PaymentLedger ToModel(this Payments.Entities.PaymentLedger entity) => new()
|
||||||
|
{
|
||||||
|
Id = entity.Id,
|
||||||
|
CreatedAt = entity.CreatedAt,
|
||||||
|
CustomerId = entity.CustomerId,
|
||||||
|
OrderId = entity.OrderId,
|
||||||
|
PaymentId = entity.PaymentId,
|
||||||
|
Status = entity.Status,
|
||||||
|
MerchantPaymentId = entity.MerchantPaymentId,
|
||||||
|
};
|
||||||
|
|
||||||
|
public static PaymentGateway ToModel(this Payments.Entities.PaymentGateway entity) => new()
|
||||||
|
{
|
||||||
|
Id = entity.Id,
|
||||||
|
CreatedAt = entity.CreatedAt,
|
||||||
|
UpdatedAt = entity.UpdatedAt,
|
||||||
|
Enabled = entity.Enabled,
|
||||||
|
IsSandbox = entity.IsSandbox,
|
||||||
|
MerchantId = entity.MerchantId,
|
||||||
|
MerchantKey = entity.MerchantKey,
|
||||||
|
Name = entity.Name,
|
||||||
|
Website = entity.Website,
|
||||||
|
};
|
||||||
|
|
||||||
|
public static Payment ToModel(this Payments.Entities.Payment entity) => new()
|
||||||
|
{
|
||||||
|
Id = entity.Id,
|
||||||
|
Amount = entity.Amount,
|
||||||
|
CreatedAt = entity.CreatedAt,
|
||||||
|
OrderId = entity.OrderId,
|
||||||
|
Reference = entity.Reference,
|
||||||
|
Status = entity.Status,
|
||||||
|
UpdatedAt = entity.UpdatedAt,
|
||||||
|
};
|
||||||
|
|
||||||
|
public static ProductInventory ToModel(this Products.Entities.ProductInventory entity) => new()
|
||||||
|
{
|
||||||
|
Id = entity.Id,
|
||||||
|
CreatedAt = entity.CreatedAt,
|
||||||
|
ProductId = entity.ProductId,
|
||||||
|
ProductPriceId = entity.ProductPriceId,
|
||||||
|
Status = entity.Status,
|
||||||
|
TotalAllocated = entity.TotalAllocated,
|
||||||
|
TotalReserved = entity.TotalReserved,
|
||||||
|
};
|
||||||
|
|
||||||
|
public static Category ToModel(this Categories.Entities.Category entity) => new()
|
||||||
|
{
|
||||||
|
Id = entity.Id,
|
||||||
|
Name = entity.Name,
|
||||||
|
IsMain = entity.IsMain,
|
||||||
|
Enabled = entity.Enabled,
|
||||||
|
};
|
||||||
|
|
||||||
|
public static ShippingProvider ToModel(this Orders.Entities.ShippingProvider entity) => new()
|
||||||
|
{
|
||||||
|
Id = entity.Id,
|
||||||
|
CreatedAt = entity.CreatedAt,
|
||||||
|
UpdatedAt = entity.UpdatedAt,
|
||||||
|
Name = entity.Name,
|
||||||
|
Type = entity.Type,
|
||||||
|
Price = entity.Price,
|
||||||
|
Enabled = entity.Enabled,
|
||||||
|
TrackingUrl = entity.TrackingUrl,
|
||||||
|
};
|
||||||
|
|
||||||
|
public static Shipping ToModel(this Orders.Entities.Shipping entity) => new()
|
||||||
|
{
|
||||||
|
Id = entity.Id,
|
||||||
|
CreatedAt = entity.CreatedAt,
|
||||||
|
UpdatedAt = entity.UpdatedAt,
|
||||||
|
OrderId = entity.OrderId,
|
||||||
|
AddressId = entity.AddressId,
|
||||||
|
Status = entity.Status,
|
||||||
|
TrackingNumber = entity.TrackingNumber
|
||||||
|
};
|
||||||
|
|
||||||
|
public static OrderItem ToModel(this Orders.Entities.OrderItem entity) => new()
|
||||||
|
{
|
||||||
|
Id = entity.Id,
|
||||||
|
CreatedAt = entity.CreatedAt,
|
||||||
|
OrderId = entity.OrderId,
|
||||||
|
Quantity = entity.Quantity,
|
||||||
|
AuthorBookId = entity.AuthorBookId,
|
||||||
|
ProductPriceId = entity.ProductPriceId
|
||||||
|
};
|
||||||
|
|
||||||
|
public static Order ToModel(this Orders.Entities.Order entity) => new()
|
||||||
|
{
|
||||||
|
Id = entity.Id,
|
||||||
|
CustomerId = entity.CustomerId,
|
||||||
|
CreatedAt = entity.CreatedAt,
|
||||||
|
UpdatedAt = entity.UpdatedAt,
|
||||||
|
Status = entity.Status,
|
||||||
|
Total = entity.Total,
|
||||||
|
InvoiceUrl = entity.InvoiceUrl,
|
||||||
|
Notes = entity.Notes
|
||||||
|
};
|
||||||
|
|
||||||
|
public static Customer ToModel(this Customers.Entities.Customer entiry) => new()
|
||||||
|
{
|
||||||
|
Id = entiry.Id,
|
||||||
|
Company = entiry.Company,
|
||||||
|
CreatedAt = entiry.CreatedAt,
|
||||||
|
Email = entiry.Email,
|
||||||
|
Enabled = entiry.Enabled,
|
||||||
|
Phone = entiry.Phone,
|
||||||
|
SocialMedia = entiry.SocialMedia,
|
||||||
|
UpdatedAt = entiry.UpdatedAt,
|
||||||
|
VatNumber = entiry.VatNumber,
|
||||||
|
Website = entiry.Website
|
||||||
|
};
|
||||||
|
|
||||||
|
public static Address ToModel(this Customers.Entities.Address entity) => new()
|
||||||
|
{
|
||||||
|
Id = entity.Id,
|
||||||
|
BuildingType = entity.BuildingType,
|
||||||
|
CreatedAt = entity.CreatedAt,
|
||||||
|
CustomerId = entity.CustomerId,
|
||||||
|
Enabled = entity.Enabled,
|
||||||
|
IsPrimary = entity.IsPrimary,
|
||||||
|
Name = entity.Name,
|
||||||
|
PostalCode = entity.PostalCode,
|
||||||
|
Type = entity.Type,
|
||||||
|
UpdatedAt = entity.UpdatedAt,
|
||||||
|
Street = entity.Street,
|
||||||
|
City = entity.City,
|
||||||
|
State = entity.State,
|
||||||
|
Country = entity.Country
|
||||||
|
};
|
||||||
|
|
||||||
|
public static Contact ToModel(this Customers.Entities.Contact entity) => new()
|
||||||
|
{
|
||||||
|
Id = entity.Id,
|
||||||
|
Type = entity.Type,
|
||||||
|
CreatedAt = entity.CreatedAt,
|
||||||
|
UpdatedAt = entity.UpdatedAt,
|
||||||
|
CustomerId = entity.CustomerId,
|
||||||
|
Email = entity.Email,
|
||||||
|
Enabled = entity.Enabled,
|
||||||
|
LastName = entity.LastName,
|
||||||
|
Name = entity.Name,
|
||||||
|
Phone = entity.Phone
|
||||||
|
};
|
||||||
|
|
||||||
|
public static BookPage ToModel(this Pages.Entities.BookPage entity) => new()
|
||||||
|
{
|
||||||
|
Id = entity.Id,
|
||||||
|
CreatedAt = entity.CreatedAt,
|
||||||
|
UpdatedAt = entity.UpdatedAt,
|
||||||
|
AuthorBookId = entity.AuthorBookId,
|
||||||
|
Content = entity.Content,
|
||||||
|
ContentType = entity.ContentType,
|
||||||
|
Number = entity.Number,
|
||||||
|
Enabled = entity.Enabled,
|
||||||
|
Notes = entity.Notes,
|
||||||
|
References = entity.References,
|
||||||
|
Type = entity.Type
|
||||||
|
};
|
||||||
|
|
||||||
|
public static AuthorBook ToModel(this AuthorBooks.Entities.AuthorBook entity) => new()
|
||||||
|
{
|
||||||
|
Id = entity.Id,
|
||||||
|
ProductId = entity.ProductId,
|
||||||
|
CreatedAt = entity.CreatedAt,
|
||||||
|
AuthorId = entity.AuthorId,
|
||||||
|
Ranking = entity.Ranking,
|
||||||
|
Rating = entity.Rating,
|
||||||
|
Enabled = entity.Enabled,
|
||||||
|
Product = entity.Product?.ToModel(),
|
||||||
|
};
|
||||||
|
|
||||||
|
public static ProductPrice ToModel(this Products.Entities.ProductPrice entity) => new()
|
||||||
|
{
|
||||||
|
Id = entity.Id,
|
||||||
|
CreatedAt = entity.CreatedAt,
|
||||||
|
UpdatedAt = entity.UpdatedAt,
|
||||||
|
ProductId = entity.ProductId,
|
||||||
|
Amount = entity.Amount,
|
||||||
|
Discount = entity.Discount,
|
||||||
|
Enabled = entity.Enabled
|
||||||
|
};
|
||||||
|
|
||||||
|
public static Product ToModel(this Products.Entities.Product entity) => new Product
|
||||||
|
{
|
||||||
|
Id = entity.Id,
|
||||||
|
CreatedAt = entity.CreatedAt,
|
||||||
|
UpdatedAt = entity.UpdatedAt,
|
||||||
|
Name = entity.Name,
|
||||||
|
Summary = entity.Summary,
|
||||||
|
Description = entity.Description,
|
||||||
|
Type = entity.Type,
|
||||||
|
ImageUrl = entity.ImageUrl,
|
||||||
|
ThumbnailUrls = entity.ThumbnailUrls,
|
||||||
|
Metadata = entity.Metadata,
|
||||||
|
Enabled = entity.Enabled,
|
||||||
|
Price = entity.Prices?.FirstOrDefault(p => p.Enabled)?.ToModel() ?? null,
|
||||||
|
};
|
||||||
|
|
||||||
|
public static Author ToModel(this Authors.Entities.Author entity) => new()
|
||||||
|
{
|
||||||
|
Id = entity.Id,
|
||||||
|
CreatedAt = entity.CreatedAt,
|
||||||
|
UpdatedAt = entity.UpdatedAt,
|
||||||
|
Name = entity.Name,
|
||||||
|
LastName = entity.LastName,
|
||||||
|
Biography = entity.Biography,
|
||||||
|
Email = entity.Email,
|
||||||
|
Website = entity.Website,
|
||||||
|
ImageUrl = entity.ImageUrl,
|
||||||
|
ThumbnailImageUrl = entity.ThumbnailImageUrl,
|
||||||
|
SocialMedia = entity.SocialMedia,
|
||||||
|
Enabled = entity.Enabled,
|
||||||
|
Company = entity.Company,
|
||||||
|
PublisherType = entity.PublisherType,
|
||||||
|
VatNumber = entity.VatNumber
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using LiteCharms.Features.MidrandBooks.Postgres;
|
||||||
|
|
||||||
|
namespace LiteCharms.Features.MidrandBooks.Extensions;
|
||||||
|
|
||||||
|
public static class Postgres
|
||||||
|
{
|
||||||
|
public const string MidrandBooksDbConfigName = "PostgresMidrandBooks";
|
||||||
|
|
||||||
|
public static IServiceCollection AddMidrandShopDatabase(this IServiceCollection services, IConfiguration configuration)
|
||||||
|
{
|
||||||
|
var connectionString = configuration.GetConnectionString(MidrandBooksDbConfigName);
|
||||||
|
|
||||||
|
var dataSourceBuilder = new NpgsqlDataSourceBuilder(connectionString);
|
||||||
|
|
||||||
|
dataSourceBuilder.ConfigureTypeLoading(options => { options.EnableTypeLoading(false); });
|
||||||
|
|
||||||
|
var dataSource = dataSourceBuilder.Build();
|
||||||
|
|
||||||
|
services.AddSingleton(dataSource);
|
||||||
|
|
||||||
|
services.AddPooledDbContextFactory<MidrandBooksDbContext>(options =>
|
||||||
|
options.UseNpgsql(dataSource));
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using LiteCharms.Features.Abstractions;
|
||||||
|
|
||||||
|
namespace LiteCharms.Features.MidrandBooks.Extensions;
|
||||||
|
|
||||||
|
public static class Shop
|
||||||
|
{
|
||||||
|
public static IServiceCollection AddShopServices(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
var serviceType = typeof(IService);
|
||||||
|
|
||||||
|
var implementations = Assembly.GetExecutingAssembly().GetTypes()
|
||||||
|
.Where(t => serviceType.IsAssignableFrom(t) && t.IsClass && !t.IsAbstract);
|
||||||
|
|
||||||
|
foreach (var implementation in implementations)
|
||||||
|
services.AddScoped(implementation);
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using static LiteCharms.Features.Extensions.Quartz;
|
||||||
|
|
||||||
|
namespace LiteCharms.Features.MidrandBooks.HealthChecks;
|
||||||
|
|
||||||
|
public sealed 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.MidrandBooks.Extensions.Postgres;
|
||||||
|
|
||||||
|
namespace LiteCharms.Features.MidrandBooks.HealthChecks;
|
||||||
|
|
||||||
|
public sealed class PostgresMidrandShopHealthCheck(IConfiguration configuration) : IHealthCheck
|
||||||
|
{
|
||||||
|
private readonly string connectionString = configuration.GetConnectionString(MidrandBooksDbConfigName)!;
|
||||||
|
|
||||||
|
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($"{MidrandBooksDbConfigName} is responsive.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return HealthCheckResult.Unhealthy($"{MidrandBooksDbConfigName} is unreachable.", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<SignAssembly>True</SignAssembly>
|
||||||
|
<AssemblyOriginatorKeyFile>..\LiteCharms.snk</AssemblyOriginatorKeyFile>
|
||||||
|
<UserSecretsId>5be62f49-3ed0-4468-884e-1b04e048b45a</UserSecretsId>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<!-- Nuget Package Details -->
|
||||||
|
<PropertyGroup>
|
||||||
|
<PackageId>LiteCharms.Features.MidrandBooks</PackageId>
|
||||||
|
<Version>1.0.20</Version>
|
||||||
|
<Authors>Khwezi Mngoma</Authors>
|
||||||
|
<Company>Lite Charms (PTY) Ltd</Company>
|
||||||
|
<Description>MidrandBooks feature 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>
|
||||||
|
|
||||||
|
<!-- Quartz Scheduler-->
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Humanizer" Version="3.0.10" />
|
||||||
|
<PackageReference Include="Meziantou.Analyzer" Version="3.0.101">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<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.2" />
|
||||||
|
|
||||||
|
<!-- 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.17.0" />
|
||||||
|
<PackageReference Include="MimeKit" Version="4.17.0" />
|
||||||
|
|
||||||
|
<!-- Global Usings-->
|
||||||
|
<Using Include="MimeKit" />
|
||||||
|
<Using Include="MailKit.Net.Smtp" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!-- CQRS -->
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="FluentResults" Version="4.0.0" />
|
||||||
|
<PackageReference Include="Mediator.Abstractions" Version="3.0.2" />
|
||||||
|
|
||||||
|
<!-- Global Usings -->
|
||||||
|
<Using Include="FluentResults" />
|
||||||
|
<Using Include="Mediator" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!-- Amazon S3 SDK -->
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="AWSSDK.Extensions.NetCore.Setup" Version="4.0.4.4" />
|
||||||
|
<PackageReference Include="AWSSDK.S3" Version="4.0.24.1" />
|
||||||
|
<ProjectReference Include="..\LiteCharms.Features\LiteCharms.Features.csproj" />
|
||||||
|
|
||||||
|
<!-- global Usings -->
|
||||||
|
<Using Include="Amazon.S3" />
|
||||||
|
<Using Include="Amazon.S3.Model" />
|
||||||
|
<Using Include="Amazon.Runtime" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!-- Shared Usings -->
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="System.Net.Sockets" />
|
||||||
|
<Using Include="System.Text.RegularExpressions" />
|
||||||
|
<Using Include="System.Web" />
|
||||||
|
<Using Include="System.Net" />
|
||||||
|
<Using Include="Humanizer" />
|
||||||
|
<Using Include="System.Globalization" />
|
||||||
|
<Using Include="System.Reflection" />
|
||||||
|
<Using Include="Microsoft.AspNetCore.Builder" />
|
||||||
|
<Using Include="Microsoft.Extensions.Hosting" />
|
||||||
|
<Using Include="System.Text" />
|
||||||
|
<Using Include="System.Text.Json" />
|
||||||
|
<Using Include="System.Threading.Channels" />
|
||||||
|
<Using Include="System.Collections.ObjectModel" />
|
||||||
|
<Using Include="System.Diagnostics" />
|
||||||
|
<Using Include="System.Diagnostics.Metrics" />
|
||||||
|
<Using Include="Microsoft.Extensions.DependencyInjection" />
|
||||||
|
<Using Include="System.Security.Cryptography" />
|
||||||
|
<Using Include="Microsoft.Extensions.Options" />
|
||||||
|
<Using Include="Microsoft.Extensions.Logging" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
using LiteCharms.Features.MidrandBooks.Payments.Entities;
|
||||||
|
|
||||||
|
namespace LiteCharms.Features.MidrandBooks.Orders.Entities;
|
||||||
|
|
||||||
|
[EntityTypeConfiguration<OrderConfiguration, Order>]
|
||||||
|
public class Order : Models.Order
|
||||||
|
{
|
||||||
|
public virtual Shipping? Shipping { get; set; }
|
||||||
|
|
||||||
|
public virtual ICollection<OrderItem> OrderItems { get; set; } = [];
|
||||||
|
|
||||||
|
public virtual ICollection<Refund> Refunds { get; set; } = [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
namespace LiteCharms.Features.MidrandBooks.Orders.Entities;
|
||||||
|
|
||||||
|
public sealed class OrderConfiguration : IEntityTypeConfiguration<Order>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<Order> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("Orders");
|
||||||
|
|
||||||
|
builder.HasKey(o => o.Id);
|
||||||
|
builder.Property(o => o.CustomerId).IsRequired();
|
||||||
|
builder.Property(o => o.CreatedAt).IsRequired().ValueGeneratedOnAdd().HasDefaultValueSql("now()");
|
||||||
|
builder.Property(o => o.UpdatedAt).HasDefaultValueSql("now()");
|
||||||
|
builder.Property(o => o.Status).IsRequired();
|
||||||
|
builder.Property(o => o.Total).IsRequired().HasPrecision(18, 2);
|
||||||
|
builder.Property(o => o.Notes).HasMaxLength(1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using LiteCharms.Features.MidrandBooks.AuthorBooks.Entities;
|
||||||
|
using LiteCharms.Features.MidrandBooks.Products.Entities;
|
||||||
|
|
||||||
|
namespace LiteCharms.Features.MidrandBooks.Orders.Entities;
|
||||||
|
|
||||||
|
[EntityTypeConfiguration<OrderItemConfiguration, OrderItem>]
|
||||||
|
public class OrderItem : Models.OrderItem
|
||||||
|
{
|
||||||
|
public virtual Order? Order { get; set; }
|
||||||
|
|
||||||
|
public virtual AuthorBook? AuthorBook { get; set; }
|
||||||
|
|
||||||
|
public virtual ProductPrice? ProductPrice { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
namespace LiteCharms.Features.MidrandBooks.Orders.Entities;
|
||||||
|
|
||||||
|
public sealed class OrderItemConfiguration : IEntityTypeConfiguration<OrderItem>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<OrderItem> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("OrderItems");
|
||||||
|
|
||||||
|
builder.HasKey(oi => oi.Id);
|
||||||
|
builder.Property(oi => oi.CreatedAt).IsRequired().ValueGeneratedOnAdd().HasDefaultValueSql("now()");
|
||||||
|
builder.Property(oi => oi.OrderId).IsRequired();
|
||||||
|
builder.Property(oi => oi.AuthorBookId).IsRequired();
|
||||||
|
builder.Property(oi => oi.ProductPriceId).IsRequired();
|
||||||
|
builder.Property(oi => oi.Quantity).IsRequired();
|
||||||
|
|
||||||
|
builder.HasOne(oi => oi.Order)
|
||||||
|
.WithMany(o => o.OrderItems)
|
||||||
|
.HasForeignKey(oi => oi.OrderId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
builder.HasOne(oi => oi.AuthorBook)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(oi => oi.AuthorBookId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
builder.HasOne(oi => oi.ProductPrice)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(oi => oi.ProductPriceId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
using LiteCharms.Features.MidrandBooks.Customers.Entities;
|
||||||
|
|
||||||
|
namespace LiteCharms.Features.MidrandBooks.Orders.Entities;
|
||||||
|
|
||||||
|
[EntityTypeConfiguration<ShippingConfiguration, Shipping>]
|
||||||
|
public class Shipping : Models.Shipping
|
||||||
|
{
|
||||||
|
public virtual Order? Order { get; set; }
|
||||||
|
|
||||||
|
public virtual Address? Address { get; set; }
|
||||||
|
|
||||||
|
public virtual ShippingProvider? ShippingProvider { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
namespace LiteCharms.Features.MidrandBooks.Orders.Entities;
|
||||||
|
|
||||||
|
public sealed class ShippingConfiguration : IEntityTypeConfiguration<Shipping>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<Shipping> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("Shippings");
|
||||||
|
|
||||||
|
builder.HasKey(s => s.Id);
|
||||||
|
builder.Property(s => s.OrderId).IsRequired();
|
||||||
|
builder.Property(s => s.AddressId).IsRequired();
|
||||||
|
builder.Property(s => s.ShippingProviderId).IsRequired();
|
||||||
|
builder.Property(s => s.CreatedAt).IsRequired().ValueGeneratedOnAdd().HasDefaultValueSql("now()");
|
||||||
|
builder.Property(s => s.UpdatedAt).HasDefaultValueSql("now()");
|
||||||
|
builder.Property(s => s.Status).IsRequired();
|
||||||
|
builder.Property(s => s.TrackingNumber).HasMaxLength(255);
|
||||||
|
|
||||||
|
builder.HasOne(s => s.Order)
|
||||||
|
.WithOne(o => o.Shipping)
|
||||||
|
.HasForeignKey<Shipping>(s => s.OrderId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
builder.HasOne(s => s.Address)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(s => s.AddressId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
builder.HasOne(f => f.ShippingProvider)
|
||||||
|
.WithMany(f => f.Shippings)
|
||||||
|
.HasForeignKey(f => f.ShippingProviderId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace LiteCharms.Features.MidrandBooks.Orders.Entities;
|
||||||
|
|
||||||
|
public class ShippingProvider : Models.ShippingProvider
|
||||||
|
{
|
||||||
|
public virtual ICollection<Shipping> Shippings { get; set; } = [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
namespace LiteCharms.Features.MidrandBooks.Orders.Entities;
|
||||||
|
|
||||||
|
public sealed class ShippingProviderConfiguration : IEntityTypeConfiguration<ShippingProvider>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<ShippingProvider> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("ShippingProviders");
|
||||||
|
|
||||||
|
builder.HasKey(f => f.Id);
|
||||||
|
builder.Property(f => f.CreatedAt).IsRequired().ValueGeneratedOnAdd().HasDefaultValueSql("now()");
|
||||||
|
builder.Property(f => f.UpdatedAt).HasDefaultValueSql("now()");
|
||||||
|
builder.Property(f => f.Type).IsRequired();
|
||||||
|
builder.Property(f => f.Name).IsRequired().HasMaxLength(100);
|
||||||
|
builder.Property(f => f.Price).IsRequired().HasPrecision(18, 2);
|
||||||
|
builder.Property(f => f.Enabled).HasDefaultValue(true);
|
||||||
|
builder.Property(f => f.TrackingUrl).HasMaxLength(200);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
namespace LiteCharms.Features.MidrandBooks.Orders.Models;
|
||||||
|
|
||||||
|
public class Order
|
||||||
|
{
|
||||||
|
public long Id { get; set; }
|
||||||
|
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
|
||||||
|
public DateTime? UpdatedAt { get; set; }
|
||||||
|
|
||||||
|
public long CustomerId { get; set; }
|
||||||
|
|
||||||
|
public OrderStatus Status { get; set; }
|
||||||
|
|
||||||
|
public decimal Total { get; set; }
|
||||||
|
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
|
||||||
|
public string? InvoiceUrl { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
namespace LiteCharms.Features.MidrandBooks.Orders.Models;
|
||||||
|
|
||||||
|
public class OrderItem
|
||||||
|
{
|
||||||
|
public long Id { get; set; }
|
||||||
|
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
|
||||||
|
public long OrderId { get; set; }
|
||||||
|
|
||||||
|
public long AuthorBookId { get; set; }
|
||||||
|
|
||||||
|
public long ProductPriceId { get; set; }
|
||||||
|
|
||||||
|
public int Quantity { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
namespace LiteCharms.Features.MidrandBooks.Orders.Models;
|
||||||
|
|
||||||
|
public sealed record CreateOrder(decimal TotalPrice, string? Notes);
|
||||||
|
|
||||||
|
public sealed record CreateOrderItem(long AuthorBookId, long ProductPriceId, int Quantity);
|
||||||
|
|
||||||
|
public sealed record CreateShipping(long AddressId, long ShippingProviderId, string? TrackingNumber = null);
|
||||||
|
|
||||||
|
public sealed record CreateShippingProvider(ShippingProviderTypes Type, string Name, decimal Price, string TrackingUrl);
|
||||||
|
|
||||||
|
public sealed record UpdateShippingProvider(long ProviderId, bool Enabled, string Name, decimal Price, string TrackingUrl);
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
namespace LiteCharms.Features.MidrandBooks.Orders.Models;
|
||||||
|
|
||||||
|
public class Shipping
|
||||||
|
{
|
||||||
|
public long Id { get; set; }
|
||||||
|
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
|
||||||
|
public DateTime? UpdatedAt { get; set; }
|
||||||
|
|
||||||
|
public long OrderId { get; set; }
|
||||||
|
|
||||||
|
public long AddressId { get; set; }
|
||||||
|
|
||||||
|
public long ShippingProviderId { get; set; }
|
||||||
|
|
||||||
|
public string? TrackingNumber { get; set; }
|
||||||
|
|
||||||
|
public ShippingStatuses Status { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
namespace LiteCharms.Features.MidrandBooks.Orders.Models;
|
||||||
|
|
||||||
|
public class ShippingProvider
|
||||||
|
{
|
||||||
|
public long Id { get; set; }
|
||||||
|
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
|
||||||
|
public DateTime? UpdatedAt { get; set; }
|
||||||
|
|
||||||
|
public ShippingProviderTypes Type { get; set; }
|
||||||
|
|
||||||
|
public string? Name { get; set; }
|
||||||
|
|
||||||
|
public decimal? Price { get; set; }
|
||||||
|
|
||||||
|
public string? TrackingUrl { get; set; }
|
||||||
|
|
||||||
|
public bool Enabled { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,462 @@
|
|||||||
|
using LiteCharms.Features.Abstractions;
|
||||||
|
using LiteCharms.Features.MidrandBooks.Extensions;
|
||||||
|
using LiteCharms.Features.MidrandBooks.Orders.Models;
|
||||||
|
using LiteCharms.Features.MidrandBooks.Postgres;
|
||||||
|
using LiteCharms.Features.Models;
|
||||||
|
|
||||||
|
namespace LiteCharms.Features.MidrandBooks.Orders;
|
||||||
|
|
||||||
|
public sealed class OrderService(IDbContextFactory<MidrandBooksDbContext> contextFactory) : IService
|
||||||
|
{
|
||||||
|
public async ValueTask<Result<long>> CreateOrderAsync(long customerId, CreateOrder request, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (!await context.Customers.AnyAsync(c => c.Id == customerId, cancellationToken))
|
||||||
|
return Result.Fail<long>("Customer not found.");
|
||||||
|
|
||||||
|
var order = context.Orders.Add(new Entities.Order
|
||||||
|
{
|
||||||
|
CustomerId = customerId,
|
||||||
|
Status = OrderStatus.Pending,
|
||||||
|
Total = request.TotalPrice
|
||||||
|
});
|
||||||
|
|
||||||
|
return await context.SaveChangesAsync(cancellationToken) > 0
|
||||||
|
? Result.Ok(order.Entity.Id)
|
||||||
|
: Result.Fail<long>("Failed to create order.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail<long>(new Error(ex.Message).CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Result<long>> AddItemToOrderAsync(long orderId, CreateOrderItem request, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (!await context.Orders.AnyAsync(o => o.Id == orderId, cancellationToken))
|
||||||
|
return Result.Fail<long>("Order not found.");
|
||||||
|
|
||||||
|
if(!await context.Books.AnyAsync(ab => ab.Id == request.AuthorBookId, cancellationToken))
|
||||||
|
return Result.Fail<long>("Author book not found.");
|
||||||
|
|
||||||
|
if (!await context.Prices.AnyAsync(pp => pp.Id == request.ProductPriceId, cancellationToken))
|
||||||
|
return Result.Fail<long>("Product price not found.");
|
||||||
|
|
||||||
|
var existingItem = await context.OrderItems.FirstOrDefaultAsync(i => i.ProductPriceId == request.ProductPriceId && i.OrderId == orderId, cancellationToken);
|
||||||
|
|
||||||
|
if(existingItem is not null)
|
||||||
|
{
|
||||||
|
existingItem.Quantity += request.Quantity;
|
||||||
|
|
||||||
|
return await context.SaveChangesAsync(cancellationToken) > 0
|
||||||
|
? Result.Ok(existingItem.Id)
|
||||||
|
: Result.Fail<long>("Update existing order item.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var orderItem = context.OrderItems.Add(new Entities.OrderItem
|
||||||
|
{
|
||||||
|
OrderId = orderId,
|
||||||
|
AuthorBookId = request.AuthorBookId,
|
||||||
|
ProductPriceId = request.ProductPriceId,
|
||||||
|
Quantity = request.Quantity
|
||||||
|
});
|
||||||
|
|
||||||
|
return await context.SaveChangesAsync(cancellationToken) > 0
|
||||||
|
? Result.Ok(orderItem.Entity.Id)
|
||||||
|
: Result.Fail<long>("Failed to add item to order.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail<long>(new Error(ex.Message).CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Result> AddItemsToOrderAsync(long orderId, CreateOrderItem[] items, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if(items.Length == 0)
|
||||||
|
return Result.Fail("No items to add.");
|
||||||
|
|
||||||
|
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (!await context.Orders.AnyAsync(o => o.Id == orderId, cancellationToken))
|
||||||
|
return Result.Fail("Order not found.");
|
||||||
|
|
||||||
|
foreach (var item in items)
|
||||||
|
{
|
||||||
|
if (!await context.Books.AnyAsync(ab => ab.Id == item.AuthorBookId, cancellationToken))
|
||||||
|
return Result.Fail($"Author book with ID {item.AuthorBookId} not found.");
|
||||||
|
|
||||||
|
if (!await context.Prices.AnyAsync(pp => pp.Id == item.ProductPriceId, cancellationToken))
|
||||||
|
return Result.Fail($"Product price with ID {item.ProductPriceId} not found.");
|
||||||
|
|
||||||
|
var existingItem = await context.OrderItems.FirstOrDefaultAsync(i => i.ProductPriceId == item.ProductPriceId && i.OrderId == orderId, cancellationToken);
|
||||||
|
|
||||||
|
if (existingItem is not null)
|
||||||
|
existingItem.Quantity += item.Quantity;
|
||||||
|
else
|
||||||
|
context.OrderItems.Add(new Entities.OrderItem
|
||||||
|
{
|
||||||
|
OrderId = orderId,
|
||||||
|
AuthorBookId = item.AuthorBookId,
|
||||||
|
ProductPriceId = item.ProductPriceId,
|
||||||
|
Quantity = item.Quantity
|
||||||
|
});
|
||||||
|
|
||||||
|
await context.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result.Ok();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail(new Error(ex.Message).CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Result> RemoveItemFromOrderAsync(long orderId, long orderItemId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
var rowsDeleted = await context.OrderItems
|
||||||
|
.Where(oi => oi.Id == orderItemId && oi.OrderId == orderId)
|
||||||
|
.ExecuteDeleteAsync(cancellationToken);
|
||||||
|
|
||||||
|
return rowsDeleted > 0
|
||||||
|
? Result.Ok()
|
||||||
|
: Result.Fail("Order item not found or failed to remove.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail(new Error(ex.Message).CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Result> ClearOrderItemsAsync(long orderId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
var deletedItems = await context.OrderItems.Where(oi => oi.OrderId == orderId)
|
||||||
|
.ExecuteDeleteAsync(cancellationToken);
|
||||||
|
|
||||||
|
return await context.SaveChangesAsync(cancellationToken) > 0
|
||||||
|
? Result.Ok()
|
||||||
|
: Result.Fail("Failed to clear order items.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail(new Error(ex.Message).CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Result> CancelOrderAsync(long orderId, CancellationToken cancellationToken = default) =>
|
||||||
|
await UpdateOrderStatusAsync(orderId, OrderStatus.Cancelled, cancellationToken);
|
||||||
|
|
||||||
|
public async ValueTask<Result<Order>> GetOrderAsync(long orderId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
var order = await context.Orders.AsNoTracking().FirstOrDefaultAsync(o => o.Id == orderId, cancellationToken);
|
||||||
|
|
||||||
|
return order is not null
|
||||||
|
? Result.Ok(order.ToModel())
|
||||||
|
: Result.Fail<Order>("Order not found.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail<Order>(new Error(ex.Message).CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Result<Order[]>> GetOrdersByCustomerAsync(long customerId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
if(!await context.Customers.AnyAsync(c => c.Id == customerId, cancellationToken))
|
||||||
|
return Result.Fail<Order[]>("Customer not found.");
|
||||||
|
|
||||||
|
var orders = await context.Orders
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(o => o.CustomerId == customerId)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return Result.Ok(orders.Select(o => o.ToModel()).ToArray());
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail<Order[]>(new Error(ex.Message).CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Result<Order[]>> GetOrdersAsync(DateRange range, int index, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var fromDate = range.From.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc);
|
||||||
|
var toDate = range.To.ToDateTime(TimeOnly.MaxValue, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
var orders = await context.Orders
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(o => o.CreatedAt >= fromDate && o.CreatedAt <= toDate)
|
||||||
|
.Skip(index).Take(range.MaxRecords)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return Result.Ok(orders.Select(o => o.ToModel()).ToArray());
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail<Order[]>(new Error(ex.Message).CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Result> UpdateOrderStatusAsync(long orderId, OrderStatus newStatus, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
var rowsUpdated = await context.Orders
|
||||||
|
.Where(o => o.Id == orderId)
|
||||||
|
.ExecuteUpdateAsync(setters => setters
|
||||||
|
.SetProperty(o => o.Status, newStatus)
|
||||||
|
.SetProperty(o => o.UpdatedAt, DateTime.UtcNow), cancellationToken);
|
||||||
|
|
||||||
|
return rowsUpdated > 0
|
||||||
|
? Result.Ok()
|
||||||
|
: Result.Fail("Order not found or status update failed.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail(new Error(ex.Message).CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Result<long>> AddShippingToOrderAsync(long orderId, CreateShipping request, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
if(!await context.Orders.AnyAsync(o => o.Id == orderId, cancellationToken))
|
||||||
|
return Result.Fail("Order not found.");
|
||||||
|
|
||||||
|
if(!await context.Addresses.AnyAsync(a => a.Id == request.AddressId, cancellationToken))
|
||||||
|
return Result.Fail("Address not found.");
|
||||||
|
|
||||||
|
if(!await context.ShippingProviders.AnyAsync(sp => sp.Id == request.ShippingProviderId && sp.Enabled, cancellationToken))
|
||||||
|
return Result.Fail("Shipping provider not found or disabled.");
|
||||||
|
|
||||||
|
if(await context.Shippings.AnyAsync(s => s.OrderId == orderId, cancellationToken))
|
||||||
|
return Result.Fail("Shipping already exists for this order.");
|
||||||
|
|
||||||
|
var shipping = context.Shippings.Add(new Entities.Shipping
|
||||||
|
{
|
||||||
|
OrderId = orderId,
|
||||||
|
AddressId = request.AddressId,
|
||||||
|
ShippingProviderId = request.ShippingProviderId,
|
||||||
|
Status = ShippingStatuses.Pending,
|
||||||
|
TrackingNumber = request.TrackingNumber
|
||||||
|
});
|
||||||
|
|
||||||
|
return await context.SaveChangesAsync(cancellationToken) > 0
|
||||||
|
? Result.Ok()
|
||||||
|
: Result.Fail("Failed to add shipping to order.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail(new Error(ex.Message).CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Result> UpdateShippingStatusAsync(long orderId, ShippingStatuses newStatus, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
var rowsUpdated = await context.Shippings
|
||||||
|
.Where(s => s.OrderId == orderId)
|
||||||
|
.ExecuteUpdateAsync(setters => setters
|
||||||
|
.SetProperty(s => s.Status, newStatus)
|
||||||
|
.SetProperty(s => s.UpdatedAt, DateTime.UtcNow),
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
return rowsUpdated > 0
|
||||||
|
? Result.Ok()
|
||||||
|
: Result.Fail("Shipping not found for this order or status update failed.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail(new Error(ex.Message).CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<Shipping>> GetShippingByOrderIdAsync(long orderId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
var shipping = await context.Shippings
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(s => s.OrderId == orderId, cancellationToken);
|
||||||
|
|
||||||
|
return shipping is not null
|
||||||
|
? Result.Ok(shipping.ToModel())
|
||||||
|
: Result.Fail<Shipping>("Shipping not found for this order.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail<Shipping>(new Error(ex.Message).CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result> RemoveShippingFromOrderAsync(long orderId, long shippingId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
var rowsDeleted = await context.Shippings
|
||||||
|
.Where(s => s.Id == shippingId && s.OrderId == orderId)
|
||||||
|
.ExecuteDeleteAsync(cancellationToken);
|
||||||
|
|
||||||
|
return rowsDeleted > 0
|
||||||
|
? Result.Ok()
|
||||||
|
: Result.Fail("Shipping record not found for this order.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail(new Error(ex.Message).CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Result> UpdateShippingTrackingNumberAsync(long orderId, long shippingId, string trackingNumber, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
var rowsUpdated = await context.Shippings
|
||||||
|
.Where(s => s.Id == shippingId && s.OrderId == orderId)
|
||||||
|
.ExecuteUpdateAsync(setters => setters
|
||||||
|
.SetProperty(s => s.TrackingNumber, trackingNumber)
|
||||||
|
.SetProperty(s => s.UpdatedAt, DateTime.UtcNow), cancellationToken);
|
||||||
|
|
||||||
|
return rowsUpdated > 0
|
||||||
|
? Result.Ok()
|
||||||
|
: Result.Fail("Shipping record not found for this order.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail(new Error(ex.Message).CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Result<long>> CreateShippingProviderAsync(CreateShippingProvider request, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
if(await context.ShippingProviders.AnyAsync(sp => sp.Type == request.Type, cancellationToken))
|
||||||
|
return Result.Fail("Shipping provider with the same type already exists.");
|
||||||
|
|
||||||
|
var shippingProvider = context.ShippingProviders.Add(new Entities.ShippingProvider
|
||||||
|
{
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
Name = request.Name,
|
||||||
|
Type = request.Type,
|
||||||
|
Price = request.Price,
|
||||||
|
TrackingUrl = request.TrackingUrl
|
||||||
|
});
|
||||||
|
|
||||||
|
return await context.SaveChangesAsync(cancellationToken) > 0
|
||||||
|
? Result.Ok(shippingProvider.Entity.Id)
|
||||||
|
: Result.Fail("Failed to create shipping provider.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail(new Error(ex.Message).CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Result<ShippingProvider[]>> GetShippingProvidersAsync(bool isEnabled, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
var providers = await context.ShippingProviders.AsNoTracking().Where(sp => sp.Enabled == isEnabled)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return Result.Ok(providers.Select(sp => sp.ToModel()).ToArray());
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail<ShippingProvider[]>(new Error(ex.Message).CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Result<ShippingProvider>> GetShippingProviderAsync(long providerId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
var provider = await context.ShippingProviders.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(sp => sp.Id == providerId, cancellationToken);
|
||||||
|
|
||||||
|
return provider is not null
|
||||||
|
? Result.Ok(provider.ToModel())
|
||||||
|
: Result.Fail<ShippingProvider>("Shipping provider not found.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail<ShippingProvider>(new Error(ex.Message).CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Result> UpdateShippingProviderAsync(UpdateShippingProvider request, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
var rowsUpdated = await context.ShippingProviders
|
||||||
|
.Where(sp => sp.Id == request.ProviderId)
|
||||||
|
.ExecuteUpdateAsync(setters => setters
|
||||||
|
.SetProperty(sp => sp.Name, request.Name)
|
||||||
|
.SetProperty(sp => sp.Price, request.Price)
|
||||||
|
.SetProperty(sp => sp.TrackingUrl, request.TrackingUrl)
|
||||||
|
.SetProperty(sp => sp.Enabled, request.Enabled)
|
||||||
|
.SetProperty(sp => sp.UpdatedAt, DateTime.UtcNow), cancellationToken);
|
||||||
|
|
||||||
|
return rowsUpdated > 0
|
||||||
|
? Result.Ok()
|
||||||
|
: Result.Fail("Shipping provider not found.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail(new Error(ex.Message).CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using LiteCharms.Features.MidrandBooks.AuthorBooks.Entities;
|
||||||
|
|
||||||
|
namespace LiteCharms.Features.MidrandBooks.Pages.Entities;
|
||||||
|
|
||||||
|
[EntityTypeConfiguration<BookPageConfiguration, BookPage>]
|
||||||
|
public class BookPage : Models.BookPage
|
||||||
|
{
|
||||||
|
public virtual AuthorBook Book { get; set; } = new();
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
namespace LiteCharms.Features.MidrandBooks.Pages.Entities;
|
||||||
|
|
||||||
|
public sealed class BookPageConfiguration : IEntityTypeConfiguration<BookPage>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<BookPage> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("BookPages");
|
||||||
|
|
||||||
|
builder.HasKey(bp => bp.Id);
|
||||||
|
builder.Property(bp => bp.CreatedAt).IsRequired().ValueGeneratedOnAdd().HasDefaultValueSql("now()");
|
||||||
|
builder.Property(bp => bp.UpdatedAt).HasDefaultValueSql("now()");
|
||||||
|
builder.Property(bp => bp.Number).IsRequired().HasDefaultValue(0);
|
||||||
|
builder.Property(bp => bp.AuthorBookId).IsRequired();
|
||||||
|
builder.Property(bp => bp.Content).IsRequired();
|
||||||
|
builder.Property(bp => bp.Type).IsRequired();
|
||||||
|
builder.Property(bp => bp.ContentType).IsRequired();
|
||||||
|
builder.Property(bp => bp.Enabled).HasDefaultValue(true);
|
||||||
|
builder.Property(bp => bp.Notes).IsRequired(false);
|
||||||
|
|
||||||
|
builder.OwnsMany(f => f.References, b => { b.ToJson(); });
|
||||||
|
|
||||||
|
builder.HasOne(f =>f.Book)
|
||||||
|
.WithMany(b => b.Pages)
|
||||||
|
.HasForeignKey(f => f.AuthorBookId)
|
||||||
|
.OnDelete(DeleteBehavior.NoAction);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using LiteCharms.Features.Models;
|
||||||
|
|
||||||
|
namespace LiteCharms.Features.MidrandBooks.Pages.Models;
|
||||||
|
|
||||||
|
public class BookPage
|
||||||
|
{
|
||||||
|
public long Id { get; set; }
|
||||||
|
|
||||||
|
public long AuthorBookId { get; set; }
|
||||||
|
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
|
||||||
|
public DateTime? UpdatedAt { get; set; }
|
||||||
|
|
||||||
|
public BookPageTypes Type { get; set; }
|
||||||
|
|
||||||
|
public BookContentTypes ContentType { get; set; }
|
||||||
|
|
||||||
|
public int Number { get; set; }
|
||||||
|
|
||||||
|
public byte[]? Content { get; set; }
|
||||||
|
|
||||||
|
public string[]? Notes { get; set; }
|
||||||
|
|
||||||
|
public ICollection<PageReference>? References { get; set; }
|
||||||
|
|
||||||
|
public bool Enabled { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using LiteCharms.Features.Models;
|
||||||
|
|
||||||
|
namespace LiteCharms.Features.MidrandBooks.Pages.Models;
|
||||||
|
|
||||||
|
public class CreateBookPage
|
||||||
|
{
|
||||||
|
public BookPageTypes Type { get; set; }
|
||||||
|
|
||||||
|
public BookContentTypes ContentType { get; set; }
|
||||||
|
|
||||||
|
public int Number { get; set; }
|
||||||
|
|
||||||
|
public byte[]? Content { get; set; }
|
||||||
|
|
||||||
|
public string[]? Notes { get; set; }
|
||||||
|
|
||||||
|
public ICollection<PageReference>? References { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class UpdateBookPage : CreateBookPage;
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
using LiteCharms.Features.Abstractions;
|
||||||
|
using LiteCharms.Features.MidrandBooks.Extensions;
|
||||||
|
using LiteCharms.Features.MidrandBooks.Pages.Models;
|
||||||
|
using LiteCharms.Features.MidrandBooks.Postgres;
|
||||||
|
|
||||||
|
namespace LiteCharms.Features.MidrandBooks.Pages;
|
||||||
|
|
||||||
|
public sealed class PageService(IDbContextFactory<MidrandBooksDbContext> contextFactory) : IService
|
||||||
|
{
|
||||||
|
public async ValueTask<Result> DeleteAllAsync(long authorBookId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
var rowsDeleted = await context.Pages
|
||||||
|
.Where(p => p.AuthorBookId == authorBookId)
|
||||||
|
.ExecuteDeleteAsync(cancellationToken);
|
||||||
|
|
||||||
|
return rowsDeleted > 0
|
||||||
|
? Result.Ok()
|
||||||
|
: Result.Fail("No pages found for the specified book");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail(new Error(ex.Message).CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Result> DeleteByPageTypeAsync(long authorBookId, int pageNumber, BookPageTypes pageType, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
var rowsDeleted = await context.Pages
|
||||||
|
.Where(p => p.AuthorBookId == authorBookId && p.Number == pageNumber && p.Type == pageType)
|
||||||
|
.ExecuteDeleteAsync(cancellationToken);
|
||||||
|
|
||||||
|
return rowsDeleted > 0
|
||||||
|
? Result.Ok()
|
||||||
|
: Result.Fail("Page not found");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail(new Error(ex.Message).CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Result> UpdatePageStatusAsync(long bookPageId, bool enabled, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
var rowsUpdated = await context.Pages
|
||||||
|
.Where(p => p.Id == bookPageId)
|
||||||
|
.ExecuteUpdateAsync(setters => setters
|
||||||
|
.SetProperty(p => p.Enabled, enabled)
|
||||||
|
.SetProperty(p => p.UpdatedAt, DateTime.UtcNow), cancellationToken);
|
||||||
|
|
||||||
|
return rowsUpdated > 0
|
||||||
|
? Result.Ok()
|
||||||
|
: Result.Fail("Page not found");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail(new Error(ex.Message).CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Result> DeletePageAsync(long bookPageId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
var rowsDeleted = await context.Pages
|
||||||
|
.Where(p => p.Id == bookPageId)
|
||||||
|
.ExecuteDeleteAsync(cancellationToken);
|
||||||
|
|
||||||
|
return rowsDeleted > 0
|
||||||
|
? Result.Ok()
|
||||||
|
: Result.Fail("Page not found");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail(new Error(ex.Message).CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Result> UpdatePageAsync(long bookPageId, UpdateBookPage request, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
var rowsUpdated = await context.Pages
|
||||||
|
.Where(p => p.Id == bookPageId)
|
||||||
|
.ExecuteUpdateAsync(setters => setters
|
||||||
|
.SetProperty(p => p.Type, request.Type)
|
||||||
|
.SetProperty(p => p.ContentType, request.ContentType)
|
||||||
|
.SetProperty(p => p.Number, request.Number)
|
||||||
|
.SetProperty(p => p.Content, request.Content)
|
||||||
|
.SetProperty(p => p.Notes, request.Notes)
|
||||||
|
.SetProperty(p => p.References, request.References)
|
||||||
|
.SetProperty(p => p.UpdatedAt, DateTime.UtcNow), cancellationToken);
|
||||||
|
|
||||||
|
return rowsUpdated > 0
|
||||||
|
? Result.Ok()
|
||||||
|
: Result.Fail("Page not found");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail(new Error(ex.Message).CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Result<long>> CreatePageAsync(long authorBookId, CreateBookPage request, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (!await context.Books.AnyAsync(b => b.Id == authorBookId, cancellationToken))
|
||||||
|
return Result.Fail<long>("Book not found");
|
||||||
|
|
||||||
|
if (await context.Pages.AnyAsync(p => p.AuthorBookId == authorBookId && p.Number == request.Number && p.Type == request.Type, cancellationToken))
|
||||||
|
return Result.Fail<long>("A page with the same number already exists for this book");
|
||||||
|
|
||||||
|
var page = context.Pages.Add(new Entities.BookPage
|
||||||
|
{
|
||||||
|
AuthorBookId = authorBookId,
|
||||||
|
Type = request.Type,
|
||||||
|
ContentType = request.ContentType,
|
||||||
|
Number = request.Number,
|
||||||
|
Content = request.Content,
|
||||||
|
Notes = request.Notes,
|
||||||
|
References = request.References,
|
||||||
|
Enabled = true
|
||||||
|
});
|
||||||
|
|
||||||
|
return await context.SaveChangesAsync(cancellationToken) > 0
|
||||||
|
? Result.Ok(page.Entity.Id)
|
||||||
|
: Result.Fail<long>("Failed to create page");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail<long>(new Error(ex.Message).CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Result<BookPage[]>> GetPagesAsync(long authorBookId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (!await context.Books.AnyAsync(b => b.Id == authorBookId, cancellationToken))
|
||||||
|
return Result.Fail<BookPage[]>("Book not found");
|
||||||
|
|
||||||
|
var pages = await context.Pages.AsNoTracking()
|
||||||
|
.Where(p => p.AuthorBookId == authorBookId).ToArrayAsync(cancellationToken);
|
||||||
|
|
||||||
|
return pages?.Length > 0
|
||||||
|
? Result.Ok(pages.Select(p => p.ToModel()).ToArray())
|
||||||
|
: Result.Fail<BookPage[]>("No pages found for the specified book");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail<BookPage[]>(new Error(ex.Message).CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Result<BookPage>> GetPageByNumberAsync(long pageId, int number, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
var page = await context.Pages.FirstOrDefaultAsync(p => p.Id == pageId && p.Number == number, cancellationToken);
|
||||||
|
|
||||||
|
return page is not null
|
||||||
|
? page.ToModel()
|
||||||
|
: Result.Fail<BookPage>("Page not found");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail<BookPage>(new Error(ex.Message).CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Result<BookPage>> GetPageAsync(long pageId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
var page = await context.Pages.FirstOrDefaultAsync(p => p.Id == pageId, cancellationToken);
|
||||||
|
|
||||||
|
return page is not null
|
||||||
|
? page.ToModel()
|
||||||
|
: Result.Fail<BookPage>("Page not found");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail<BookPage>(new Error(ex.Message).CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using LiteCharms.Features.MidrandBooks.Orders.Entities;
|
||||||
|
|
||||||
|
namespace LiteCharms.Features.MidrandBooks.Payments.Entities;
|
||||||
|
|
||||||
|
[EntityTypeConfiguration<PaymentConfiguration, Payment>]
|
||||||
|
public class Payment : Models.Payment
|
||||||
|
{
|
||||||
|
public virtual Order? Order { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
namespace LiteCharms.Features.MidrandBooks.Payments.Entities;
|
||||||
|
|
||||||
|
public sealed class PaymentConfiguration : IEntityTypeConfiguration<Payment>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<Payment> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("Payments");
|
||||||
|
|
||||||
|
builder.HasKey(f => f.Id);
|
||||||
|
builder.Property(f => f.CreatedAt).IsRequired().ValueGeneratedOnAdd().HasDefaultValueSql("now()");
|
||||||
|
builder.Property(f => f.UpdatedAt);
|
||||||
|
builder.Property(f => f.Status).IsRequired();
|
||||||
|
builder.Property(f => f.Reference).IsRequired();
|
||||||
|
builder.Property(f => f.OrderId).IsRequired();
|
||||||
|
builder.Property(f => f.Amount).IsRequired().HasPrecision(18, 2);
|
||||||
|
|
||||||
|
builder.HasOne(f => f.Order)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(f => f.OrderId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
namespace LiteCharms.Features.MidrandBooks.Payments.Entities;
|
||||||
|
|
||||||
|
[EntityTypeConfiguration<PaymentGatewayConfiguration, PaymentGateway>]
|
||||||
|
public class PaymentGateway : Models.PaymentGateway;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
namespace LiteCharms.Features.MidrandBooks.Payments.Entities;
|
||||||
|
|
||||||
|
public sealed class PaymentGatewayConfiguration : IEntityTypeConfiguration<PaymentGateway>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<PaymentGateway> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("Gateways");
|
||||||
|
|
||||||
|
builder.HasKey(f => f.Id);
|
||||||
|
builder.Property(f => f.CreatedAt).IsRequired().ValueGeneratedOnAdd().HasDefaultValueSql("now()");
|
||||||
|
builder.Property(f => f.UpdatedAt);
|
||||||
|
builder.Property(f => f.Website).IsRequired(false);
|
||||||
|
builder.Property(f => f.IsSandbox);
|
||||||
|
builder.Property(f => f.MerchantKey).IsRequired();
|
||||||
|
builder.Property(f => f.MerchantId).IsRequired();
|
||||||
|
builder.Property(f => f.Enabled);
|
||||||
|
builder.Property(f => f.Name).IsRequired();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using LiteCharms.Features.MidrandBooks.Orders.Entities;
|
||||||
|
|
||||||
|
namespace LiteCharms.Features.MidrandBooks.Payments.Entities;
|
||||||
|
|
||||||
|
[EntityTypeConfiguration<PaymentGatewayLedgerConfiguration, PaymentGatewayLedger>]
|
||||||
|
public class PaymentGatewayLedger : Models.PaymentGatewayLedger
|
||||||
|
{
|
||||||
|
public virtual Order? Order { get; set; }
|
||||||
|
|
||||||
|
public virtual Payment? Payment { get; set; }
|
||||||
|
}
|
||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
namespace LiteCharms.Features.MidrandBooks.Payments.Entities;
|
||||||
|
|
||||||
|
public sealed class PaymentGatewayLedgerConfiguration : IEntityTypeConfiguration<PaymentGatewayLedger>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<PaymentGatewayLedger> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("GatewayLedger");
|
||||||
|
|
||||||
|
builder.HasKey(f => f.Id);
|
||||||
|
builder.Property(f => f.CreatedAt).IsRequired().ValueGeneratedOnAdd().HasDefaultValueSql("now()");
|
||||||
|
builder.Property(f => f.OrderId).IsRequired();
|
||||||
|
builder.Property(f => f.PaymentId).IsRequired();
|
||||||
|
builder.Property(f => f.PayfastPaymentId).IsRequired();
|
||||||
|
builder.Property(f => f.MerchantPaymentId).IsRequired();
|
||||||
|
builder.Property(f => f.AmountGross).IsRequired().HasPrecision(18, 2);
|
||||||
|
builder.Property(f => f.AmountFee).IsRequired().HasPrecision(18, 2);
|
||||||
|
builder.Property(f => f.AmountNet).IsRequired().HasPrecision(18, 2);
|
||||||
|
builder.Property(f => f.CustomerEmail).IsRequired(false);
|
||||||
|
|
||||||
|
builder.HasOne(f => f.Order)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(f => f.OrderId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
builder.HasOne(f => f.Payment)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(f => f.PaymentId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using LiteCharms.Features.MidrandBooks.Customers.Entities;
|
||||||
|
using LiteCharms.Features.MidrandBooks.Orders.Entities;
|
||||||
|
|
||||||
|
namespace LiteCharms.Features.MidrandBooks.Payments.Entities;
|
||||||
|
|
||||||
|
[EntityTypeConfiguration<PaymentLedgerConfiguration, PaymentLedger>]
|
||||||
|
public class PaymentLedger : Models.PaymentLedger
|
||||||
|
{
|
||||||
|
public virtual Payment? Payment { get; set; }
|
||||||
|
|
||||||
|
public virtual Order? Order { get; set; }
|
||||||
|
|
||||||
|
public virtual Customer? Customer { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
namespace LiteCharms.Features.MidrandBooks.Payments.Entities;
|
||||||
|
|
||||||
|
public sealed class PaymentLedgerConfiguration : IEntityTypeConfiguration<PaymentLedger>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<PaymentLedger> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("Ledger");
|
||||||
|
|
||||||
|
builder.HasKey(f => f.Id);
|
||||||
|
builder.Property(f => f.CreatedAt).IsRequired().ValueGeneratedOnAdd().HasDefaultValueSql("now()");
|
||||||
|
builder.Property(f => f.Status).IsRequired();
|
||||||
|
builder.Property(f => f.MerchantPaymentId).IsRequired(false);
|
||||||
|
builder.Property(f => f.OrderId).IsRequired();
|
||||||
|
builder.Property(f => f.CustomerId).IsRequired();
|
||||||
|
builder.Property(f => f.PaymentId).IsRequired();
|
||||||
|
|
||||||
|
builder.HasOne(f => f.Payment)
|
||||||
|
.WithMany()
|
||||||
|
.IsRequired()
|
||||||
|
.HasForeignKey(f => f.PaymentId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
builder.HasOne(f => f.Order)
|
||||||
|
.WithMany()
|
||||||
|
.IsRequired()
|
||||||
|
.HasForeignKey(f => f.OrderId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
builder.HasOne(f => f.Customer)
|
||||||
|
.WithMany()
|
||||||
|
.IsRequired()
|
||||||
|
.HasForeignKey(f => f.CustomerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using LiteCharms.Features.MidrandBooks.Orders.Entities;
|
||||||
|
|
||||||
|
namespace LiteCharms.Features.MidrandBooks.Payments.Entities;
|
||||||
|
|
||||||
|
[EntityTypeConfiguration<RefundConfiguration, Refund>]
|
||||||
|
public class Refund : Models.Refund
|
||||||
|
{
|
||||||
|
public virtual Order? Order { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
namespace LiteCharms.Features.MidrandBooks.Payments.Entities;
|
||||||
|
|
||||||
|
public sealed class RefundConfiguration : IEntityTypeConfiguration<Refund>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<Refund> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("Refunds");
|
||||||
|
|
||||||
|
builder.HasKey(r => r.Id);
|
||||||
|
builder.Property(r => r.OrderId).IsRequired();
|
||||||
|
builder.Property(o => o.CreatedAt).IsRequired().ValueGeneratedOnAdd().HasDefaultValueSql("now()");
|
||||||
|
builder.Property(o => o.UpdatedAt).HasDefaultValueSql("now()");
|
||||||
|
builder.Property(o => o.Status).IsRequired();
|
||||||
|
builder.Property(r => r.Amount).IsRequired().HasPrecision(18, 2);
|
||||||
|
builder.Property(r => r.Reason).HasMaxLength(1000);
|
||||||
|
|
||||||
|
builder.HasOne(r => r.Order)
|
||||||
|
.WithMany(o => o.Refunds)
|
||||||
|
.HasForeignKey(r => r.OrderId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
}
|
||||||
|
}
|
||||||
+164
@@ -0,0 +1,164 @@
|
|||||||
|
using LiteCharms.Features.Hasher;
|
||||||
|
using LiteCharms.Features.Hasher.Configuration;
|
||||||
|
using LiteCharms.Features.Mediator;
|
||||||
|
using LiteCharms.Features.MidrandBooks.Orders;
|
||||||
|
using LiteCharms.Features.MidrandBooks.Payments.Models;
|
||||||
|
|
||||||
|
namespace LiteCharms.Features.MidrandBooks.Payments.Events.Handlers;
|
||||||
|
|
||||||
|
public sealed class PayfastPaymentConfirmationReceivedEventHandler(IServiceProvider services, IOptions<HasherSettings> hasherOptions, ILogger<PayfastPaymentConfirmationReceivedEvent> logger) :
|
||||||
|
INotificationHandler<PayfastPaymentConfirmationReceivedEvent>
|
||||||
|
{
|
||||||
|
private readonly HasherSettings hasherSettings = hasherOptions.Value;
|
||||||
|
|
||||||
|
public async ValueTask Handle(PayfastPaymentConfirmationReceivedEvent notification, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var activity = MediatorTelemetry.Source.StartActivity($"Quartz: {typeof(PayfastPaymentConfirmationReceivedEvent).Name}");
|
||||||
|
activity?.SetTag("event.correlation_id", notification.CorrelationId);
|
||||||
|
|
||||||
|
await using var scope = services.CreateAsyncScope();
|
||||||
|
var hashService = scope.ServiceProvider.GetRequiredService<HashService>();
|
||||||
|
var orderService = scope.ServiceProvider.GetRequiredService<OrderService>();
|
||||||
|
var paymentService = scope.ServiceProvider.GetRequiredService<PaymentService>();
|
||||||
|
var payfastService = scope.ServiceProvider.GetRequiredService<PayfastService>();
|
||||||
|
|
||||||
|
var payload = notification.Payload ?? throw new Exception("Payload metadata context context is null.");
|
||||||
|
|
||||||
|
var dict = payload.ToParamDictionary();
|
||||||
|
var localSignature = PayfastService.GenerateSignature(dict, hasherSettings.PayfastPassphrase);
|
||||||
|
|
||||||
|
if (localSignature.IsFailed)
|
||||||
|
throw new Exception("Failed to generate local signature for incoming webhook payload.");
|
||||||
|
|
||||||
|
if (!string.Equals(localSignature.Value, payload.Signature, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
logger.LogCritical("Incoming webhook signature verification failed. Possible payload tampering.");
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var hashResult = hashService.DecodeLongIdHash(payload.MerchantPaymentId!);
|
||||||
|
|
||||||
|
if (hashResult.IsFailed) throw new Exception("Failed to decode application tracking hash key identifier.");
|
||||||
|
|
||||||
|
var orderResult = await orderService.GetOrderAsync(hashResult.Value, cancellationToken);
|
||||||
|
|
||||||
|
if (orderResult.IsFailed) throw new Exception("Target system order entity context cannot be traced.");
|
||||||
|
|
||||||
|
var paymentResult = await paymentService.GetOrderPaymentAsync(orderResult.Value.Id, cancellationToken);
|
||||||
|
|
||||||
|
if (paymentResult.IsFailed) throw new Exception("Target payment ledger entity cannot be resolved.");
|
||||||
|
|
||||||
|
decimal.TryParse(payload.AmountGross, CultureInfo.InvariantCulture, out var gross);
|
||||||
|
decimal.TryParse(payload.AmountFee, CultureInfo.InvariantCulture, out var fee);
|
||||||
|
decimal.TryParse(payload.AmountNet, CultureInfo.InvariantCulture, out var net);
|
||||||
|
string status = payload.PaymentStatus ?? "UNKNOWN";
|
||||||
|
|
||||||
|
var isAlreadyProcessed = await paymentService.HasLedgerEntryAsync(orderResult.Value.Id, paymentResult.Value.Id, cancellationToken);
|
||||||
|
|
||||||
|
if (isAlreadyProcessed.Value)
|
||||||
|
{
|
||||||
|
logger.LogWarning("Webhook reference token '{Ref}' already verified. Skipping validation routines.", payload.MerchantPaymentId);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notification.PerformBackgroundChecks)
|
||||||
|
{
|
||||||
|
var isHostValid = await payfastService.ValidateReferrerIpAsync(notification.RemoteIpAddress!, notification.AllowLoopback, cancellationToken);
|
||||||
|
|
||||||
|
if (isHostValid.IsFailed)
|
||||||
|
throw new Exception("Security validation exception: Webhook packet source address failed cluster validation checks.");
|
||||||
|
|
||||||
|
if (!isHostValid.Value)
|
||||||
|
throw new Exception("Security validation exception: Webhook packet source address failed cluster validation checks.");
|
||||||
|
|
||||||
|
var isAmountValid = payfastService.ValidatePaymentAmount(orderResult.Value.Total, payload.AmountGross);
|
||||||
|
|
||||||
|
if (!isAmountValid.Value)
|
||||||
|
throw new Exception("Security validation exception: Transaction cost variance bounds breached.");
|
||||||
|
|
||||||
|
var paramList = new List<string>();
|
||||||
|
|
||||||
|
foreach (var kvp in dict)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(kvp.Value))
|
||||||
|
{
|
||||||
|
string encoded = HttpUtility.UrlEncode(kvp.Value.Trim());
|
||||||
|
|
||||||
|
string safeValue = PayfastService.PercentEncodingRegex.Replace(encoded, m => m.Value.ToLowerInvariant());
|
||||||
|
paramList.Add($"{kvp.Key}={safeValue}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
string rawParamString = string.Join("&", paramList);
|
||||||
|
|
||||||
|
var serverConfirmation = await payfastService.ValidateServerConfirmationAsync(rawParamString, isSandbox: true, cancellationToken);
|
||||||
|
|
||||||
|
if (serverConfirmation.IsFailed)
|
||||||
|
throw new Exception("Security validation exception: Payfast central handshake server rejected payload legitimacy.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await payfastService.WriteLedgerEntryAsync(new CreateGatewayLedgerEntry
|
||||||
|
{
|
||||||
|
OrderId = orderResult.Value.Id,
|
||||||
|
PaymentId = paymentResult.Value.Id,
|
||||||
|
MerchantPaymentId = payload.MerchantPaymentId!,
|
||||||
|
PayfastPaymentId = payload.PaymentId,
|
||||||
|
CustomerEmail = payload.EmailAddress,
|
||||||
|
AmountFee = fee,
|
||||||
|
AmountGross = gross,
|
||||||
|
AmountNet = net,
|
||||||
|
PaymentStatus = status,
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
if (status.Equals("COMPLETE", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var ledgerWriteResult = await paymentService.WriteLedgerEntryAsync(new CreateLedgerEntry
|
||||||
|
{
|
||||||
|
OrderId = orderResult.Value.Id,
|
||||||
|
PaymentId = paymentResult.Value.Id,
|
||||||
|
PaymentGatewayReference = payload.PaymentId!,
|
||||||
|
Status = LedgerStatuses.Completed,
|
||||||
|
CustomerId = orderResult.Value.CustomerId,
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
if (ledgerWriteResult.IsFailed)
|
||||||
|
throw new Exception("Failed to write ledger entry for payment confirmation.");
|
||||||
|
|
||||||
|
var completePaymentResult = await paymentService.CompletePaymentAsync(paymentResult.Value.Id, PaymentStatuses.Paid, cancellationToken);
|
||||||
|
|
||||||
|
if (completePaymentResult.IsFailed)
|
||||||
|
throw new Exception("Failed to update payment status to 'Paid' for payment confirmation.");
|
||||||
|
|
||||||
|
var updateOrderResult = await orderService.UpdateOrderStatusAsync(orderResult.Value.Id, OrderStatus.Completed, cancellationToken);
|
||||||
|
|
||||||
|
if (updateOrderResult.IsFailed)
|
||||||
|
throw new Exception("Failed to update order status to 'Completed' for payment confirmation.");
|
||||||
|
|
||||||
|
logger.LogInformation("Order payment verified secure and cleared successfully.");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
LedgerStatuses ledgerStatus;
|
||||||
|
|
||||||
|
if (status.Equals("CANCELLED", StringComparison.OrdinalIgnoreCase))
|
||||||
|
ledgerStatus = LedgerStatuses.Cancelled;
|
||||||
|
else
|
||||||
|
ledgerStatus = LedgerStatuses.Failed;
|
||||||
|
|
||||||
|
var ledgerWriteResult = await paymentService.WriteLedgerEntryAsync(new CreateLedgerEntry
|
||||||
|
{
|
||||||
|
OrderId = orderResult.Value.Id,
|
||||||
|
PaymentId = paymentResult.Value.Id,
|
||||||
|
PaymentGatewayReference = payload.PaymentId!,
|
||||||
|
Status = ledgerStatus,
|
||||||
|
CustomerId = orderResult.Value.CustomerId,
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
logger.LogInformation("Webhook validation pipeline passed checks successfully, logged entry to ledger with status: {Status}", status);
|
||||||
|
}
|
||||||
|
activity?.SetStatus(ActivityStatusCode.Ok);
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
using LiteCharms.Features.Abstractions;
|
||||||
|
using LiteCharms.Features.MidrandBooks.Payments.Models;
|
||||||
|
|
||||||
|
namespace LiteCharms.Features.MidrandBooks.Payments.Events;
|
||||||
|
|
||||||
|
public sealed class PayfastPaymentConfirmationReceivedEvent : EventBase, IEvent
|
||||||
|
{
|
||||||
|
public string Name { get; set; } = nameof(PayfastPaymentConfirmationReceivedEvent);
|
||||||
|
|
||||||
|
public PayfastWebhookPayload? Payload { get; set; }
|
||||||
|
|
||||||
|
public string? RemoteIpAddress { get; set; }
|
||||||
|
|
||||||
|
public bool PerformBackgroundChecks { get; set; }
|
||||||
|
|
||||||
|
public bool AllowLoopback { get; set; }
|
||||||
|
|
||||||
|
public PayfastPaymentConfirmationReceivedEvent() { }
|
||||||
|
|
||||||
|
private PayfastPaymentConfirmationReceivedEvent(PayfastWebhookPayload? payload, string paymentId, bool performBackgroundChecks = true, bool allowLoopback = false)
|
||||||
|
{
|
||||||
|
Payload = payload;
|
||||||
|
CorrelationId = paymentId;
|
||||||
|
PerformBackgroundChecks = performBackgroundChecks;
|
||||||
|
AllowLoopback = allowLoopback;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static PayfastPaymentConfirmationReceivedEvent Create(PayfastWebhookPayload? payload, string paymentId, bool performBackgroundChecks = true, bool allowLoopback = false) =>
|
||||||
|
new(payload, paymentId, performBackgroundChecks, allowLoopback);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user