From 4565b57536e06f66d820172262248d972a69b6bb Mon Sep 17 00:00:00 2001 From: Vitaliy <43200383+s04v@users.noreply.github.com> Date: Sun, 10 Mar 2024 16:19:21 +0100 Subject: [PATCH] #1090: Notification to the consumer when the product is back in stock (#1091) --- .../Views/Product/ProductDetail.cshtml | 25 +++++++ .../wwwroot/product-detail.js | 18 +++++ .../Extensions/IWorkContext.cs | 2 + .../Extensions/WorkContext.cs | 5 ++ .../Controllers/StockApiController.cs | 22 +++++- .../EmailTemplates/BackInStockEmail.cshtml | 30 ++++++++ .../Event/BackInStock.cs | 14 ++++ .../Event/BackInStockSendEmailHandler.cs | 26 +++++++ .../Models/BackInStockSubscription.cs | 15 ++++ .../ModuleInitializer.cs | 7 ++ .../Services/IStockSubscriptionService.cs | 15 ++++ .../Services/StockService.cs | 13 +++- .../Services/StockSubscriptionService.cs | 70 +++++++++++++++++++ ...0306234328_AddedBackInStockSubscription.cs | 35 ++++++++++ .../Migrations/SimplDbContextModelSnapshot.cs | 19 +++++ 15 files changed, 314 insertions(+), 2 deletions(-) create mode 100644 src/Modules/SimplCommerce.Module.Inventory/Areas/Inventory/Views/EmailTemplates/BackInStockEmail.cshtml create mode 100644 src/Modules/SimplCommerce.Module.Inventory/Event/BackInStock.cs create mode 100644 src/Modules/SimplCommerce.Module.Inventory/Event/BackInStockSendEmailHandler.cs create mode 100644 src/Modules/SimplCommerce.Module.Inventory/Models/BackInStockSubscription.cs create mode 100644 src/Modules/SimplCommerce.Module.Inventory/Services/IStockSubscriptionService.cs create mode 100644 src/Modules/SimplCommerce.Module.Inventory/Services/StockSubscriptionService.cs create mode 100644 src/SimplCommerce.WebHost/Migrations/20240306234328_AddedBackInStockSubscription.cs diff --git a/src/Modules/SimplCommerce.Module.Catalog/Areas/Catalog/Views/Product/ProductDetail.cshtml b/src/Modules/SimplCommerce.Module.Catalog/Areas/Catalog/Views/Product/ProductDetail.cshtml index fcac14b389..86c94339b3 100644 --- a/src/Modules/SimplCommerce.Module.Catalog/Areas/Catalog/Views/Product/ProductDetail.cshtml +++ b/src/Modules/SimplCommerce.Module.Catalog/Areas/Catalog/Views/Product/ProductDetail.cshtml @@ -199,6 +199,31 @@
@Localizer["Out of stock"]
+ +
+
+
+ @if (SignInManager.IsSignedIn(User)) + { +

Subscribe and we'll notify you when the product is back in stock.

+
+ + + +
+ } + else + { +

Leave your email and we'll notify you when the product is in stock

+
+ + + +
+ } +
+
+
}
diff --git a/src/Modules/SimplCommerce.Module.Catalog/wwwroot/product-detail.js b/src/Modules/SimplCommerce.Module.Catalog/wwwroot/product-detail.js index dc4995ae96..e3f61b2db6 100644 --- a/src/Modules/SimplCommerce.Module.Catalog/wwwroot/product-detail.js +++ b/src/Modules/SimplCommerce.Module.Catalog/wwwroot/product-detail.js @@ -71,4 +71,22 @@ $(document).ready(function () { }); } }); + + $("#subscribeBackInStock").on('click', function (e) { + e.preventDefault(); + var $form = $(this).closest("form"), + productId = $(this).closest("form").find('input[name=productId]').val(), + customerEmail = $(this).closest("form").find('input[name=customerEmail]').val(); + + var that = this; + $.post($form.attr('action'), $form.serializeArray()) + .done(function (result) { + $(that).closest('.back-in-stock-subscribe').html('Thank you. We\'ll notify you.'); + }) + .fail(function (result) { + if (result.status === 409) { + $(that).closest('.back-in-stock-subscribe').html('You\'ve already subscribed.'); + } + }); + }); }); diff --git a/src/Modules/SimplCommerce.Module.Core/Extensions/IWorkContext.cs b/src/Modules/SimplCommerce.Module.Core/Extensions/IWorkContext.cs index b84c5b8930..6cf5cff920 100644 --- a/src/Modules/SimplCommerce.Module.Core/Extensions/IWorkContext.cs +++ b/src/Modules/SimplCommerce.Module.Core/Extensions/IWorkContext.cs @@ -5,6 +5,8 @@ namespace SimplCommerce.Module.Core.Extensions { public interface IWorkContext { + string GetCurrentHostName(); + Task GetCurrentUser(); } } diff --git a/src/Modules/SimplCommerce.Module.Core/Extensions/WorkContext.cs b/src/Modules/SimplCommerce.Module.Core/Extensions/WorkContext.cs index 4e0b463021..de2b71d59a 100644 --- a/src/Modules/SimplCommerce.Module.Core/Extensions/WorkContext.cs +++ b/src/Modules/SimplCommerce.Module.Core/Extensions/WorkContext.cs @@ -33,6 +33,11 @@ public class WorkContext : IWorkContext _configuration = configuration; } + public string GetCurrentHostName() + { + return _httpContext.Request.Host.Value; + } + public async Task GetCurrentUser() { if (_currentUser != null) diff --git a/src/Modules/SimplCommerce.Module.Inventory/Areas/Inventory/Controllers/StockApiController.cs b/src/Modules/SimplCommerce.Module.Inventory/Areas/Inventory/Controllers/StockApiController.cs index dbe2be3596..1d3e60b452 100644 --- a/src/Modules/SimplCommerce.Module.Inventory/Areas/Inventory/Controllers/StockApiController.cs +++ b/src/Modules/SimplCommerce.Module.Inventory/Areas/Inventory/Controllers/StockApiController.cs @@ -20,17 +20,21 @@ public class StockApiController : Controller { private readonly IRepository _stockRepository; private readonly IStockService _stockService; + private readonly IStockSubscriptionService _stockSubscriptionService; private readonly IWorkContext _workContext; private readonly IRepository _warehouseRepository; private readonly IRepository _stockHistoryRepository; + private readonly IRepository _backInStockSubscriptionRepository; - public StockApiController(IRepository stockRepository, IStockService stockService, IWorkContext workContext, IRepository warehouseRepository, IRepository stockHistoryRepository) + public StockApiController(IRepository stockRepository, IStockService stockService, IWorkContext workContext, IRepository warehouseRepository, IRepository stockHistoryRepository, IRepository backInStockSubscriptionRepository, IStockSubscriptionService stockSubscriptionService) { _stockRepository = stockRepository; _stockService = stockService; _workContext = workContext; _warehouseRepository = warehouseRepository; _stockHistoryRepository = stockHistoryRepository; + _backInStockSubscriptionRepository = backInStockSubscriptionRepository; + _stockSubscriptionService = stockSubscriptionService; } [HttpPost("grid")] @@ -135,5 +139,21 @@ public async Task GetStockHistory(int warehouseId, int productId) return Ok(stockHistory); } + + [AllowAnonymous] + [HttpPost("back-in-stock")] + public async Task BackInStockSubscribe(long productId, string customerEmail) + { + if (await _backInStockSubscriptionRepository.Query() + .Where(o => o.ProductId == productId && o.CustomerEmail == customerEmail) + .AnyAsync()) + { + return Conflict(); + } + + await _stockSubscriptionService.BackInStockSubscribeAsync(productId, customerEmail); + + return Ok(); + } } } diff --git a/src/Modules/SimplCommerce.Module.Inventory/Areas/Inventory/Views/EmailTemplates/BackInStockEmail.cshtml b/src/Modules/SimplCommerce.Module.Inventory/Areas/Inventory/Views/EmailTemplates/BackInStockEmail.cshtml new file mode 100644 index 0000000000..4589c25ffa --- /dev/null +++ b/src/Modules/SimplCommerce.Module.Inventory/Areas/Inventory/Views/EmailTemplates/BackInStockEmail.cshtml @@ -0,0 +1,30 @@ +@using SimplCommerce.Module.Catalog.Models +@using SimplCommerce.Module.Core.Extensions +@using SimplCommerce.Module.Core.Services + +@inject IWorkContext _workContext +@inject IMediaService _mediaService + +@{ + Layout = null; + + var hostName = _workContext.GetCurrentHostName(); + var thumbnailUrl = _mediaService.GetThumbnailUrl(Model.ThumbnailImage); +} + +@model SimplCommerce.Module.Catalog.Models.Product + +
+ Hi, @Model.Name in now available. + Get in now before it out of stock again. +
+
+
+ +

@Model.Name

+

$1000

+
+ + View product + +
diff --git a/src/Modules/SimplCommerce.Module.Inventory/Event/BackInStock.cs b/src/Modules/SimplCommerce.Module.Inventory/Event/BackInStock.cs new file mode 100644 index 0000000000..fdaef7f6e6 --- /dev/null +++ b/src/Modules/SimplCommerce.Module.Inventory/Event/BackInStock.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using MediatR; + +namespace SimplCommerce.Module.Inventory.Event +{ + public class BackInStock : INotification + { + public long ProductId { get; set; } + } +} diff --git a/src/Modules/SimplCommerce.Module.Inventory/Event/BackInStockSendEmailHandler.cs b/src/Modules/SimplCommerce.Module.Inventory/Event/BackInStockSendEmailHandler.cs new file mode 100644 index 0000000000..e1205dfd64 --- /dev/null +++ b/src/Modules/SimplCommerce.Module.Inventory/Event/BackInStockSendEmailHandler.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using MediatR; +using SimplCommerce.Module.Inventory.Services; + +namespace SimplCommerce.Module.Inventory.Event +{ + public class BackInStockSendEmailHandler : INotificationHandler + { + public readonly IStockSubscriptionService _stockSubscriptionService; + + public BackInStockSendEmailHandler(IStockSubscriptionService stockSubscriptionService) + { + _stockSubscriptionService = stockSubscriptionService; + } + + public async Task Handle(BackInStock notification, CancellationToken cancellationToken) + { + await _stockSubscriptionService.BackInStockSendNotificationsAsync(notification.ProductId); + } + } +} diff --git a/src/Modules/SimplCommerce.Module.Inventory/Models/BackInStockSubscription.cs b/src/Modules/SimplCommerce.Module.Inventory/Models/BackInStockSubscription.cs new file mode 100644 index 0000000000..620a506259 --- /dev/null +++ b/src/Modules/SimplCommerce.Module.Inventory/Models/BackInStockSubscription.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using SimplCommerce.Infrastructure.Models; + +namespace SimplCommerce.Module.Inventory.Models +{ + public class BackInStockSubscription : EntityBase + { + public long ProductId { get; set; } + public string CustomerEmail { get; set; } + } +} diff --git a/src/Modules/SimplCommerce.Module.Inventory/ModuleInitializer.cs b/src/Modules/SimplCommerce.Module.Inventory/ModuleInitializer.cs index cc6fb11e1b..cc07700b5c 100644 --- a/src/Modules/SimplCommerce.Module.Inventory/ModuleInitializer.cs +++ b/src/Modules/SimplCommerce.Module.Inventory/ModuleInitializer.cs @@ -4,6 +4,10 @@ using SimplCommerce.Infrastructure.Modules; using SimplCommerce.Module.Inventory.Services; using SimplCommerce.Infrastructure; +using MediatR; +using SimplCommerce.Module.Catalog.Events; +using SimplCommerce.Module.Core.Events; +using SimplCommerce.Module.Inventory.Event; namespace SimplCommerce.Module.Inventory { @@ -12,6 +16,9 @@ public class ModuleInitializer : IModuleInitializer public void ConfigureServices(IServiceCollection serviceCollection) { serviceCollection.AddTransient(); + serviceCollection.AddTransient(); + serviceCollection.AddTransient, BackInStockSendEmailHandler>(); + GlobalConfiguration.RegisterAngularModule("simplAdmin.inventory"); } diff --git a/src/Modules/SimplCommerce.Module.Inventory/Services/IStockSubscriptionService.cs b/src/Modules/SimplCommerce.Module.Inventory/Services/IStockSubscriptionService.cs new file mode 100644 index 0000000000..67b7830fa5 --- /dev/null +++ b/src/Modules/SimplCommerce.Module.Inventory/Services/IStockSubscriptionService.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace SimplCommerce.Module.Inventory.Services +{ + public interface IStockSubscriptionService + { + Task BackInStockSubscribeAsync(long productId, string customerEmail); + + Task BackInStockSendNotificationsAsync(long productId); + } +} diff --git a/src/Modules/SimplCommerce.Module.Inventory/Services/StockService.cs b/src/Modules/SimplCommerce.Module.Inventory/Services/StockService.cs index 100a7a7b15..20561c4e21 100644 --- a/src/Modules/SimplCommerce.Module.Inventory/Services/StockService.cs +++ b/src/Modules/SimplCommerce.Module.Inventory/Services/StockService.cs @@ -1,9 +1,11 @@ using System; using System.Linq; using System.Threading.Tasks; +using MediatR; using Microsoft.EntityFrameworkCore; using SimplCommerce.Infrastructure.Data; using SimplCommerce.Module.Catalog.Models; +using SimplCommerce.Module.Inventory.Event; using SimplCommerce.Module.Inventory.Models; namespace SimplCommerce.Module.Inventory.Services @@ -13,12 +15,14 @@ public class StockService : IStockService private readonly IRepository _stockRepository; private readonly IRepository _productRepository; private readonly IRepository _stockHistoryRepository; + private readonly IMediator _mediator; - public StockService(IRepository stockRepository, IRepository productRepository, IRepository stockHistoryRepository) + public StockService(IRepository stockRepository, IRepository productRepository, IRepository stockHistoryRepository, IMediator mediator) { _stockRepository = stockRepository; _productRepository = productRepository; _stockHistoryRepository = stockHistoryRepository; + _mediator = mediator; } public async Task AddAllProduct(Warehouse warehouse) @@ -44,6 +48,8 @@ public async Task UpdateStock(StockUpdateRequest stockUpdateRequest) var product = await _productRepository.Query().FirstOrDefaultAsync(x => x.Id == stockUpdateRequest.ProductId); var stock = await _stockRepository.Query().FirstOrDefaultAsync(x => x.ProductId == stockUpdateRequest.ProductId && x.WarehouseId == stockUpdateRequest.WarehouseId); + var prevStockQuantity = product.StockQuantity; + stock.Quantity = stock.Quantity + stockUpdateRequest.AdjustedQuantity; product.StockQuantity = product.StockQuantity + stockUpdateRequest.AdjustedQuantity; var stockHistory = new StockHistory @@ -58,6 +64,11 @@ public async Task UpdateStock(StockUpdateRequest stockUpdateRequest) _stockHistoryRepository.Add(stockHistory); await _stockHistoryRepository.SaveChangesAsync(); + + if (prevStockQuantity <= 0 && product.StockQuantity > 0) + { + await _mediator.Publish(new BackInStock { ProductId = product.Id }); + } } } } diff --git a/src/Modules/SimplCommerce.Module.Inventory/Services/StockSubscriptionService.cs b/src/Modules/SimplCommerce.Module.Inventory/Services/StockSubscriptionService.cs new file mode 100644 index 0000000000..92d4733b23 --- /dev/null +++ b/src/Modules/SimplCommerce.Module.Inventory/Services/StockSubscriptionService.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.EntityFrameworkCore; +using SimplCommerce.Infrastructure.Data; +using SimplCommerce.Infrastructure.Web; +using SimplCommerce.Module.Catalog.Models; +using SimplCommerce.Module.Core.Models; +using SimplCommerce.Module.Core.Services; +using SimplCommerce.Module.Inventory.Models; + +namespace SimplCommerce.Module.Inventory.Services +{ + public class StockSubscriptionService : IStockSubscriptionService + { + private readonly IRepository _backInStockSubscriptionRepository; + private readonly IRepository _productRepository; + private readonly IEmailSender _emailSender; + private readonly IRazorViewRenderer _viewRender; + + public StockSubscriptionService(IRepository backInStockSubscriptionRepository, IEmailSender emailSender, IRazorViewRenderer viewRender, IRepository productRepository) + { + _backInStockSubscriptionRepository = backInStockSubscriptionRepository; + _emailSender = emailSender; + _viewRender = viewRender; + _productRepository = productRepository; + } + + public async Task BackInStockSendNotificationsAsync(long productId) + { + var subscriptions = await _backInStockSubscriptionRepository + .Query() + .Where(o => o.ProductId == productId) + .ToListAsync(); + + var product = await _productRepository + .Query() + .Where(o => o.Id == productId) + .Include(o => o.ThumbnailImage) + .FirstOrDefaultAsync(); + + var emailBody = await _viewRender.RenderViewToStringAsync("/Areas/Inventory/Views/EmailTemplates/BackInStockEmail.cshtml", product); + var emailSubject = $"Back in stock"; + + foreach (var subscription in subscriptions) + { + await _emailSender.SendEmailAsync(subscription.CustomerEmail, emailSubject, emailBody, true); + + _backInStockSubscriptionRepository.Remove(subscription); + } + + await _backInStockSubscriptionRepository.SaveChangesAsync(); + } + + public async Task BackInStockSubscribeAsync(long productId, string customerEmail) + { + var subscription = new BackInStockSubscription + { + ProductId = productId, + CustomerEmail = customerEmail + }; + + _backInStockSubscriptionRepository.Add(subscription); + await _backInStockSubscriptionRepository.SaveChangesAsync(); + } + } +} diff --git a/src/SimplCommerce.WebHost/Migrations/20240306234328_AddedBackInStockSubscription.cs b/src/SimplCommerce.WebHost/Migrations/20240306234328_AddedBackInStockSubscription.cs new file mode 100644 index 0000000000..38be813cd7 --- /dev/null +++ b/src/SimplCommerce.WebHost/Migrations/20240306234328_AddedBackInStockSubscription.cs @@ -0,0 +1,35 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SimplCommerce.WebHost.Migrations +{ + /// + public partial class AddedBackInStockSubscription : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Inventory_BackInStockSubscription", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + ProductId = table.Column(type: "bigint", nullable: false), + CustomerEmail = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Inventory_BackInStockSubscription", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Inventory_BackInStockSubscription"); + } + } +} diff --git a/src/SimplCommerce.WebHost/Migrations/SimplDbContextModelSnapshot.cs b/src/SimplCommerce.WebHost/Migrations/SimplDbContextModelSnapshot.cs index 0e4fbfd2f4..9cab7a16fe 100644 --- a/src/SimplCommerce.WebHost/Migrations/SimplDbContextModelSnapshot.cs +++ b/src/SimplCommerce.WebHost/Migrations/SimplDbContextModelSnapshot.cs @@ -2200,6 +2200,25 @@ protected override void BuildModel(ModelBuilder modelBuilder) }); }); + modelBuilder.Entity("SimplCommerce.Module.Inventory.Models.BackInStockSubscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CustomerEmail") + .HasColumnType("nvarchar(max)"); + + b.Property("ProductId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.ToTable("Inventory_BackInStockSubscription", (string)null); + }); + modelBuilder.Entity("SimplCommerce.Module.Inventory.Models.Stock", b => { b.Property("Id")