From ecb4889dd308711c4f1336bd51742c16635bb64b Mon Sep 17 00:00:00 2001 From: Steve Smith Date: Tue, 22 Aug 2017 13:51:08 -0400 Subject: [PATCH] Basket persistence (#41) * Renamed Cart to Basket throughout * Implemented cookie-based anonymous basket handling and transfer to user upon login. Still need to implement transfer upon registration. --- src/ApplicationCore/Entities/Basket.cs | 5 +- .../BasketWithItemsSpecification.cs | 12 ++- src/Infrastructure/Data/CatalogContext.cs | 13 ++- src/Web/Constants.cs | 7 ++ src/Web/Controllers/AccountController.cs | 19 +++- src/Web/Controllers/BasketController.cs | 86 +++++++++++++++++++ src/Web/Controllers/CartController.cs | 72 ---------------- src/Web/Interfaces/IBasketService.cs | 7 +- src/Web/Services/BasketService.cs | 63 ++++++++------ src/Web/Startup.cs | 7 +- 10 files changed, 173 insertions(+), 118 deletions(-) create mode 100644 src/Web/Constants.cs create mode 100644 src/Web/Controllers/BasketController.cs delete mode 100644 src/Web/Controllers/CartController.cs diff --git a/src/ApplicationCore/Entities/Basket.cs b/src/ApplicationCore/Entities/Basket.cs index a682beb..c0ab5d7 100644 --- a/src/ApplicationCore/Entities/Basket.cs +++ b/src/ApplicationCore/Entities/Basket.cs @@ -6,13 +6,14 @@ namespace Microsoft.eShopWeb.ApplicationCore.Entities public class Basket : BaseEntity { public string BuyerId { get; set; } - public List Items { get; set; } = new List(); + private readonly List _items = new List(); + public IEnumerable Items => _items.ToList(); public void AddItem(int catalogItemId, decimal unitPrice, int quantity = 1) { if (!Items.Any(i => i.CatalogItemId == catalogItemId)) { - Items.Add(new BasketItem() + _items.Add(new BasketItem() { CatalogItemId = catalogItemId, Quantity = quantity, diff --git a/src/ApplicationCore/Specifications/BasketWithItemsSpecification.cs b/src/ApplicationCore/Specifications/BasketWithItemsSpecification.cs index 1cdcc88..a70c36e 100644 --- a/src/ApplicationCore/Specifications/BasketWithItemsSpecification.cs +++ b/src/ApplicationCore/Specifications/BasketWithItemsSpecification.cs @@ -13,10 +13,18 @@ namespace ApplicationCore.Specifications BasketId = basketId; AddInclude(b => b.Items); } + public BasketWithItemsSpecification(string buyerId) + { + BuyerId = buyerId; + AddInclude(b => b.Items); + } - public int BasketId { get; } + public int? BasketId { get; } + public string BuyerId { get; } - public Expression> Criteria => b => b.Id == BasketId; + public Expression> Criteria => b => + (BasketId.HasValue && b.Id == BasketId.Value) + || (BuyerId != null && b.BuyerId == BuyerId); public List>> Includes { get; } = new List>>(); diff --git a/src/Infrastructure/Data/CatalogContext.cs b/src/Infrastructure/Data/CatalogContext.cs index 9663c90..52becb7 100644 --- a/src/Infrastructure/Data/CatalogContext.cs +++ b/src/Infrastructure/Data/CatalogContext.cs @@ -1,6 +1,8 @@ -using Microsoft.EntityFrameworkCore; +using System; +using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; using Microsoft.eShopWeb.ApplicationCore.Entities; +using Microsoft.EntityFrameworkCore.Metadata; namespace Infrastructure.Data { @@ -14,13 +16,22 @@ namespace Infrastructure.Data public DbSet CatalogItems { get; set; } public DbSet CatalogBrands { get; set; } public DbSet CatalogTypes { get; set; } + protected override void OnModelCreating(ModelBuilder builder) { + builder.Entity(ConfigureBasket); builder.Entity(ConfigureCatalogBrand); builder.Entity(ConfigureCatalogType); builder.Entity(ConfigureCatalogItem); } + private void ConfigureBasket(EntityTypeBuilder builder) + { + var navigation = builder.Metadata.FindNavigation(nameof(Basket.Items)); + + navigation.SetPropertyAccessMode(PropertyAccessMode.Field); + } + void ConfigureCatalogItem(EntityTypeBuilder builder) { builder.ToTable("Catalog"); diff --git a/src/Web/Constants.cs b/src/Web/Constants.cs new file mode 100644 index 0000000..8ab7ff2 --- /dev/null +++ b/src/Web/Constants.cs @@ -0,0 +1,7 @@ +namespace Web +{ + public static class Constants + { + public const string BASKET_COOKIENAME = "eShop"; + } +} diff --git a/src/Web/Controllers/AccountController.cs b/src/Web/Controllers/AccountController.cs index abcb55d..1623971 100644 --- a/src/Web/Controllers/AccountController.cs +++ b/src/Web/Controllers/AccountController.cs @@ -5,6 +5,10 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Options; using Infrastructure.Identity; +using System; +using Microsoft.eShopWeb.ApplicationCore.Entities; +using ApplicationCore.Interfaces; +using Web; namespace Microsoft.eShopWeb.Controllers { @@ -14,20 +18,21 @@ namespace Microsoft.eShopWeb.Controllers private readonly UserManager _userManager; private readonly SignInManager _signInManager; private readonly string _externalCookieScheme; + private readonly IBasketService _basketService; public AccountController( UserManager userManager, SignInManager signInManager, - IOptions identityCookieOptions - -) + IOptions identityCookieOptions, + IBasketService basketService) { _userManager = userManager; _signInManager = signInManager; _externalCookieScheme = identityCookieOptions.Value.ExternalCookieAuthenticationScheme; + _basketService = basketService; } - // GET: /Account/SignIn + // GET: /Account/SignIn [HttpGet] [AllowAnonymous] public async Task SignIn(string returnUrl = null) @@ -53,6 +58,12 @@ namespace Microsoft.eShopWeb.Controllers var result = await _signInManager.PasswordSignInAsync(model.Email, model.Password, model.RememberMe, lockoutOnFailure: false); if (result.Succeeded) { + string anonymousBasketId = Request.Cookies[Constants.BASKET_COOKIENAME]; + if (!String.IsNullOrEmpty(anonymousBasketId)) + { + _basketService.TransferBasket(anonymousBasketId, model.Email); + Response.Cookies.Delete(Constants.BASKET_COOKIENAME); + } return RedirectToLocal(returnUrl); } ModelState.AddModelError(string.Empty, "Invalid login attempt."); diff --git a/src/Web/Controllers/BasketController.cs b/src/Web/Controllers/BasketController.cs new file mode 100644 index 0000000..3aea615 --- /dev/null +++ b/src/Web/Controllers/BasketController.cs @@ -0,0 +1,86 @@ +using Microsoft.AspNetCore.Mvc; +using System.Threading.Tasks; +using ApplicationCore.Interfaces; +using Microsoft.AspNetCore.Http; +using Microsoft.eShopWeb.ViewModels; +using Microsoft.AspNetCore.Identity; +using Infrastructure.Identity; +using System; +using Web; + +namespace Microsoft.eShopWeb.Controllers +{ + [Route("[controller]/[action]")] + public class BasketController : Controller + { + private readonly IBasketService _basketService; + private const string _basketSessionKey = "basketId"; + private readonly IUriComposer _uriComposer; + private readonly SignInManager _signInManager; + + public BasketController(IBasketService basketService, + IUriComposer uriComposer, + SignInManager signInManager) + { + _basketService = basketService; + _uriComposer = uriComposer; + _signInManager = signInManager; + } + + [HttpGet] + public async Task Index() + { + var basketModel = await GetBasketViewModelAsync(); + + return View(basketModel); + } + + // POST: /Basket/AddToBasket + [HttpPost] + public async Task AddToBasket(CatalogItemViewModel productDetails) + { + if (productDetails?.Id == null) + { + return RedirectToAction("Index", "Catalog"); + } + var basketViewModel = await GetBasketViewModelAsync(); + + await _basketService.AddItemToBasket(basketViewModel.Id, productDetails.Id, productDetails.Price, 1); + + return RedirectToAction("Index"); + } + + [HttpPost] + public async Task Checkout() + { + var basket = await GetBasketViewModelAsync(); + + await _basketService.Checkout(basket.Id); + + return View("Checkout"); + } + + private async Task GetBasketViewModelAsync() + { + if (_signInManager.IsSignedIn(HttpContext.User)) + { + return await _basketService.GetOrCreateBasketForUser(User.Identity.Name); + } + string anonymousId = GetOrSetBasketCookie(); + return await _basketService.GetOrCreateBasketForUser(anonymousId); + } + + private string GetOrSetBasketCookie() + { + if (Request.Cookies.ContainsKey(Constants.BASKET_COOKIENAME)) + { + return Request.Cookies[Constants.BASKET_COOKIENAME]; + } + string anonymousId = Guid.NewGuid().ToString(); + var cookieOptions = new CookieOptions(); + cookieOptions.Expires = DateTime.Today.AddYears(10); + Response.Cookies.Append(Constants.BASKET_COOKIENAME, anonymousId, cookieOptions); + return anonymousId; + } + } +} diff --git a/src/Web/Controllers/CartController.cs b/src/Web/Controllers/CartController.cs deleted file mode 100644 index c7694ec..0000000 --- a/src/Web/Controllers/CartController.cs +++ /dev/null @@ -1,72 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using System.Threading.Tasks; -using ApplicationCore.Interfaces; -using Microsoft.AspNetCore.Http; -using Microsoft.eShopWeb.ViewModels; - -namespace Microsoft.eShopWeb.Controllers -{ - [Route("[controller]/[action]")] - public class BasketController : Controller - { - private readonly IBasketService _basketService; - private const string _basketSessionKey = "basketId"; - private readonly IUriComposer _uriComposer; - - public BasketController(IBasketService basketService, - IUriComposer uriComposer) - { - _basketService = basketService; - _uriComposer = uriComposer; - } - - [HttpGet] - public async Task Index() - { - var basketModel = await GetBasketFromSessionAsync(); - - return View(basketModel); - } - - // POST: /Basket/AddToBasket - [HttpPost] - public async Task AddToBasket(CatalogItemViewModel productDetails) - { - if (productDetails?.Id == null) - { - return RedirectToAction("Index", "Catalog"); - } - var basket = await GetBasketFromSessionAsync(); - - await _basketService.AddItemToBasket(basket.Id, productDetails.Id, productDetails.Price, 1); - - return RedirectToAction("Index"); - } - - [HttpPost] - public async Task Checkout() - { - var basket = await GetBasketFromSessionAsync(); - - await _basketService.Checkout(basket.Id); - - return View("Checkout"); - } - - private async Task GetBasketFromSessionAsync() - { - string basketId = HttpContext.Session.GetString(_basketSessionKey); - BasketViewModel basket = null; - if (basketId == null) - { - basket = await _basketService.CreateBasketForUser(User.Identity.Name); - HttpContext.Session.SetString(_basketSessionKey, basket.Id.ToString()); - } - else - { - basket = await _basketService.GetBasket(int.Parse(basketId)); - } - return basket; - } - } -} diff --git a/src/Web/Interfaces/IBasketService.cs b/src/Web/Interfaces/IBasketService.cs index 51e982a..7794e7c 100644 --- a/src/Web/Interfaces/IBasketService.cs +++ b/src/Web/Interfaces/IBasketService.cs @@ -5,12 +5,9 @@ namespace ApplicationCore.Interfaces { public interface IBasketService { - Task GetBasket(int basketId); - Task CreateBasket(); - Task CreateBasketForUser(string userId); - + Task GetOrCreateBasketForUser(string userName); + Task TransferBasket(string anonymousId, string userName); Task AddItemToBasket(int basketId, int catalogItemId, decimal price, int quantity); - Task Checkout(int basketId); } } diff --git a/src/Web/Services/BasketService.cs b/src/Web/Services/BasketService.cs index 1b9b494..5e74e22 100644 --- a/src/Web/Services/BasketService.cs +++ b/src/Web/Services/BasketService.cs @@ -23,42 +23,43 @@ namespace Web.Services _uriComposer = uriComposer; _itemRepository = itemRepository; } - public async Task GetBasket(int basketId) - { - var basketSpec = new BasketWithItemsSpecification(basketId); - var basket = _basketRepository.List(basketSpec).FirstOrDefault(); - if (basket == null) - { - return await CreateBasket(); - } + public async Task GetOrCreateBasketForUser(string userName) + { + var basketSpec = new BasketWithItemsSpecification(userName); + var basket = _basketRepository.List(basketSpec).FirstOrDefault(); + + if(basket == null) + { + return await CreateBasketForUser(userName); + } + return CreateViewModelFromBasket(basket); + } + + private BasketViewModel CreateViewModelFromBasket(Basket basket) + { var viewModel = new BasketViewModel(); viewModel.Id = basket.Id; viewModel.BuyerId = basket.BuyerId; viewModel.Items = basket.Items.Select(i => - { - var itemModel = new BasketItemViewModel() - { - Id = i.Id, - UnitPrice = i.UnitPrice, - Quantity = i.Quantity, - CatalogItemId = i.CatalogItemId + { + var itemModel = new BasketItemViewModel() + { + Id = i.Id, + UnitPrice = i.UnitPrice, + Quantity = i.Quantity, + CatalogItemId = i.CatalogItemId - }; - var item = _itemRepository.GetById(i.CatalogItemId); - itemModel.PictureUrl = _uriComposer.ComposePicUri(item.PictureUri); - itemModel.ProductName = item.Name; - return itemModel; - }) + }; + var item = _itemRepository.GetById(i.CatalogItemId); + itemModel.PictureUrl = _uriComposer.ComposePicUri(item.PictureUri); + itemModel.ProductName = item.Name; + return itemModel; + }) .ToList(); return viewModel; } - public Task CreateBasket() - { - return CreateBasketForUser(null); - } - public async Task CreateBasketForUser(string userId) { var basket = new Basket() { BuyerId = userId }; @@ -89,5 +90,15 @@ namespace Web.Services _basketRepository.Delete(basket); } + + public Task TransferBasket(string anonymousId, string userName) + { + var basketSpec = new BasketWithItemsSpecification(anonymousId); + var basket = _basketRepository.List(basketSpec).FirstOrDefault(); + if (basket == null) return Task.CompletedTask; + basket.BuyerId = userName; + _basketRepository.Update(basket); + return Task.CompletedTask; + } } } diff --git a/src/Web/Startup.cs b/src/Web/Startup.cs index b00a1d4..31de781 100644 --- a/src/Web/Startup.cs +++ b/src/Web/Startup.cs @@ -85,9 +85,6 @@ namespace Microsoft.eShopWeb // Add memory cache services services.AddMemoryCache(); - // Add session related services. - services.AddSession(); - services.AddMvc(); _services = services; @@ -126,10 +123,7 @@ namespace Microsoft.eShopWeb app.UseExceptionHandler("/Catalog/Error"); } - app.UseSession(); - app.UseStaticFiles(); - app.UseIdentity(); app.UseMvc(); @@ -171,6 +165,7 @@ namespace Microsoft.eShopWeb var defaultUser = new ApplicationUser { UserName = "demouser@microsoft.com", Email = "demouser@microsoft.com" }; userManager.CreateAsync(defaultUser, "Pass@word1").Wait(); + } } }