diff --git a/ECommerceApi.JJHH17/ECommerceApi.JJHH17/Controllers/CategoriesController.cs b/ECommerceApi.JJHH17/ECommerceApi.JJHH17/Controllers/CategoriesController.cs new file mode 100644 index 00000000..e53ed0ec --- /dev/null +++ b/ECommerceApi.JJHH17/ECommerceApi.JJHH17/Controllers/CategoriesController.cs @@ -0,0 +1,53 @@ +using ECommerceApi.JJHH17.Models; +using ECommerceApi.JJHH17.Services; +using Microsoft.AspNetCore.Mvc; + +namespace ECommerceApi.JJHH17.Controllers +{ + [ApiController] + [Route("api/[controller]")] + // Example call: http://localhost:5609/api/category/ + public class CategoriesController : ControllerBase + { + private readonly ICategoryService _categoryService; + + public CategoriesController(ICategoryService categoryService) + { + _categoryService = categoryService; + } + + [HttpGet] + public ActionResult> GetAllCategories([FromQuery] Pagination pagination) + { + if (pagination.PageNumber < 1) pagination.PageNumber = 1; + if (pagination.PageSize < 1) pagination.PageSize = 10; + + var allCategories = _categoryService.GetAllCategories(); + + var totalRecords = allCategories.Count; + var pagedData = allCategories + .OrderBy(c => c.CategoryId) + .Skip((pagination.PageNumber - 1) * pagination.PageSize) + .Take(pagination.PageSize) + .ToList(); + + return Ok(pagedData); + } + + [HttpGet("{id}")] + public ActionResult GetCategoryById(int id) + { + var result = _categoryService.GetCategoryById(id); + + if (result == null) { return NotFound(); } + + return Ok(result); + } + + [HttpPost] + public ActionResult CreateCategory(CreateCategoryDto category) + { + return Ok(_categoryService.CreateCategory(category)); + } + } +} diff --git a/ECommerceApi.JJHH17/ECommerceApi.JJHH17/Controllers/ProductsController.cs b/ECommerceApi.JJHH17/ECommerceApi.JJHH17/Controllers/ProductsController.cs new file mode 100644 index 00000000..06a324c7 --- /dev/null +++ b/ECommerceApi.JJHH17/ECommerceApi.JJHH17/Controllers/ProductsController.cs @@ -0,0 +1,52 @@ +using ECommerceApi.JJHH17.Models; +using ECommerceApi.JJHH17.Services; +using Microsoft.AspNetCore.Mvc; + +namespace ECommerceApi.JJHH17.Controllers +{ + [ApiController] + [Route("api/[controller]")] + // Example call: http://localhost:5609/api/product/ + public class ProductsController : ControllerBase + { + private readonly IProductService _productService; + public ProductsController(IProductService productService) + { + _productService = productService; + } + + [HttpGet] + public ActionResult> GetAllProducts([FromQuery] Pagination pagination) + { + if (pagination.PageNumber < 1) pagination.PageNumber = 1; + if (pagination.PageSize < 1) pagination.PageSize = 10; + + var allProducts = _productService.GetAllProducts(); + + var totalRecords = allProducts.Count; + var pagedData = allProducts + .OrderBy(c => c.productId) + .Skip((pagination.PageNumber - 1) * pagination.PageSize) + .Take(pagination.PageSize) + .ToList(); + + return Ok(pagedData); + } + + [HttpGet("{id}")] + public ActionResult GetProductById(int id) + { + var result = _productService.GetProductById(id); + + if (result == null) { return NotFound(); } + + return Ok(result); + } + + [HttpPost] + public ActionResult CreateProduct(CreateProductDto product) + { + return Ok(_productService.CreateProduct(product)); + } + } +} diff --git a/ECommerceApi.JJHH17/ECommerceApi.JJHH17/Controllers/SalesController.cs b/ECommerceApi.JJHH17/ECommerceApi.JJHH17/Controllers/SalesController.cs new file mode 100644 index 00000000..af1f1584 --- /dev/null +++ b/ECommerceApi.JJHH17/ECommerceApi.JJHH17/Controllers/SalesController.cs @@ -0,0 +1,79 @@ +using ECommerceApi.JJHH17.Models; +using ECommerceApi.JJHH17.Services; +using Microsoft.AspNetCore.Mvc; + +namespace ECommerceApi.JJHH17.Controllers +{ + [ApiController] + [Route("api/[controller]")] + // Example call: http://localhost:5609/api/sale/ + public class SalesController : ControllerBase + { + private readonly ISaleService _saleService; + public SalesController(ISaleService saleService) + { + _saleService = saleService; + } + + [HttpGet] + public ActionResult> GetAllSales([FromQuery] Pagination pagination) + { + if (pagination.PageNumber < 1) pagination.PageNumber = 1; + if (pagination.PageSize < 1) pagination.PageSize = 10; + + var allSales = _saleService.GetAllSales(); + + var totalRecords = allSales.Count; + var pagedData = allSales + .OrderBy(c => c.SaleId) + .Skip((pagination.PageNumber - 1) * pagination.PageSize) + .Take(pagination.PageSize) + .ToList(); + + return Ok(pagedData); + } + + [HttpGet("{id}")] + public ActionResult GetSaleById(int id) + { + var result = _saleService.GetSaleById(id); + + if (result == null) { return NotFound(); } + + return Ok(result); + } + + [HttpPost] + public ActionResult CreateSale([FromBody] CreateSaleDto sale) + { + try + { + var created = _saleService.CreateSale(sale); + + var dto = new SaleWithProductsDto( + created.SaleId, + created.ItemCount, + created.SalePrice, + created.Products + .OrderBy(p => p.ProductName) + .Select(p => new ProductDto(p.ProductId, p.ProductName, p.Price)) + .ToList() + ); + + return CreatedAtAction(nameof(GetSaleById), new { id = created.SaleId }, dto); + } + catch (ArgumentNullException e) + { + return BadRequest(new { error = e.Message }); + } + catch (ArgumentException ex) + { + return BadRequest(new { error = ex.Message }); + } + catch (InvalidOperationException ex) + { + return NotFound(new { error = ex.Message }); + } + } + } +} diff --git a/ECommerceApi.JJHH17/ECommerceApi.JJHH17/Data/Migrations/20251007201154_InitialCreate.Designer.cs b/ECommerceApi.JJHH17/ECommerceApi.JJHH17/Data/Migrations/20251007201154_InitialCreate.Designer.cs new file mode 100644 index 00000000..6c906d31 --- /dev/null +++ b/ECommerceApi.JJHH17/ECommerceApi.JJHH17/Data/Migrations/20251007201154_InitialCreate.Designer.cs @@ -0,0 +1,165 @@ +// +using ECommerceApi.JJHH17.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace ECommerceApi.JJHH17.Data.Migrations +{ + [DbContext(typeof(ProductsDbContext))] + [Migration("20251007201154_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("ECommerceApi.JJHH17.Models.Category", b => + { + b.Property("CategoryId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("CategoryId")); + + b.Property("CategoryName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("CategoryId"); + + b.ToTable("Categories"); + + b.HasData( + new + { + CategoryId = 1, + CategoryName = "Nike" + }, + new + { + CategoryId = 2, + CategoryName = "Adidas" + }); + }); + + modelBuilder.Entity("ECommerceApi.JJHH17.Models.Product", b => + { + b.Property("ProductId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("ProductId")); + + b.Property("CategoryId") + .HasColumnType("int"); + + b.Property("Price") + .HasColumnType("decimal(18,2)"); + + b.Property("ProductName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("ProductId"); + + b.HasIndex("CategoryId"); + + b.ToTable("Products"); + + b.HasData( + new + { + ProductId = 1, + CategoryId = 1, + Price = 100m, + ProductName = "Jordan" + }, + new + { + ProductId = 2, + CategoryId = 1, + Price = 75m, + ProductName = "Air Force 1" + }, + new + { + ProductId = 3, + CategoryId = 1, + Price = 50m, + ProductName = "Ultra Boost" + }); + }); + + modelBuilder.Entity("ECommerceApi.JJHH17.Models.Sale", b => + { + b.Property("SaleId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("SaleId")); + + b.HasKey("SaleId"); + + b.ToTable("Sales"); + }); + + modelBuilder.Entity("ProductSales", b => + { + b.Property("ProductId") + .HasColumnType("int"); + + b.Property("SaleId") + .HasColumnType("int"); + + b.HasKey("ProductId", "SaleId"); + + b.HasIndex("SaleId"); + + b.ToTable("ProductSales", (string)null); + }); + + modelBuilder.Entity("ECommerceApi.JJHH17.Models.Product", b => + { + b.HasOne("ECommerceApi.JJHH17.Models.Category", "Category") + .WithMany("Products") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Category"); + }); + + modelBuilder.Entity("ProductSales", b => + { + b.HasOne("ECommerceApi.JJHH17.Models.Product", null) + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ECommerceApi.JJHH17.Models.Sale", null) + .WithMany() + .HasForeignKey("SaleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ECommerceApi.JJHH17.Models.Category", b => + { + b.Navigation("Products"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/ECommerceApi.JJHH17/ECommerceApi.JJHH17/Data/Migrations/20251007201154_InitialCreate.cs b/ECommerceApi.JJHH17/ECommerceApi.JJHH17/Data/Migrations/20251007201154_InitialCreate.cs new file mode 100644 index 00000000..9094b84b --- /dev/null +++ b/ECommerceApi.JJHH17/ECommerceApi.JJHH17/Data/Migrations/20251007201154_InitialCreate.cs @@ -0,0 +1,131 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional + +namespace ECommerceApi.JJHH17.Data.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Categories", + columns: table => new + { + CategoryId = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + CategoryName = table.Column(type: "nvarchar(max)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Categories", x => x.CategoryId); + }); + + migrationBuilder.CreateTable( + name: "Sales", + columns: table => new + { + SaleId = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1") + }, + constraints: table => + { + table.PrimaryKey("PK_Sales", x => x.SaleId); + }); + + migrationBuilder.CreateTable( + name: "Products", + columns: table => new + { + ProductId = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + ProductName = table.Column(type: "nvarchar(max)", nullable: false), + Price = table.Column(type: "decimal(18,2)", nullable: false), + CategoryId = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Products", x => x.ProductId); + table.ForeignKey( + name: "FK_Products_Categories_CategoryId", + column: x => x.CategoryId, + principalTable: "Categories", + principalColumn: "CategoryId", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "ProductSales", + columns: table => new + { + ProductId = table.Column(type: "int", nullable: false), + SaleId = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ProductSales", x => new { x.ProductId, x.SaleId }); + table.ForeignKey( + name: "FK_ProductSales_Products_ProductId", + column: x => x.ProductId, + principalTable: "Products", + principalColumn: "ProductId", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_ProductSales_Sales_SaleId", + column: x => x.SaleId, + principalTable: "Sales", + principalColumn: "SaleId", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.InsertData( + table: "Categories", + columns: new[] { "CategoryId", "CategoryName" }, + values: new object[,] + { + { 1, "Nike" }, + { 2, "Adidas" } + }); + + migrationBuilder.InsertData( + table: "Products", + columns: new[] { "ProductId", "CategoryId", "Price", "ProductName" }, + values: new object[,] + { + { 1, 1, 100m, "Jordan" }, + { 2, 1, 75m, "Air Force 1" }, + { 3, 1, 50m, "Ultra Boost" } + }); + + migrationBuilder.CreateIndex( + name: "IX_Products_CategoryId", + table: "Products", + column: "CategoryId"); + + migrationBuilder.CreateIndex( + name: "IX_ProductSales_SaleId", + table: "ProductSales", + column: "SaleId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ProductSales"); + + migrationBuilder.DropTable( + name: "Products"); + + migrationBuilder.DropTable( + name: "Sales"); + + migrationBuilder.DropTable( + name: "Categories"); + } + } +} diff --git a/ECommerceApi.JJHH17/ECommerceApi.JJHH17/Data/Migrations/ProductsDbContextModelSnapshot.cs b/ECommerceApi.JJHH17/ECommerceApi.JJHH17/Data/Migrations/ProductsDbContextModelSnapshot.cs new file mode 100644 index 00000000..5e36b464 --- /dev/null +++ b/ECommerceApi.JJHH17/ECommerceApi.JJHH17/Data/Migrations/ProductsDbContextModelSnapshot.cs @@ -0,0 +1,162 @@ +// +using ECommerceApi.JJHH17.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace ECommerceApi.JJHH17.Data.Migrations +{ + [DbContext(typeof(ProductsDbContext))] + partial class ProductsDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("ECommerceApi.JJHH17.Models.Category", b => + { + b.Property("CategoryId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("CategoryId")); + + b.Property("CategoryName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("CategoryId"); + + b.ToTable("Categories"); + + b.HasData( + new + { + CategoryId = 1, + CategoryName = "Nike" + }, + new + { + CategoryId = 2, + CategoryName = "Adidas" + }); + }); + + modelBuilder.Entity("ECommerceApi.JJHH17.Models.Product", b => + { + b.Property("ProductId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("ProductId")); + + b.Property("CategoryId") + .HasColumnType("int"); + + b.Property("Price") + .HasColumnType("decimal(18,2)"); + + b.Property("ProductName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("ProductId"); + + b.HasIndex("CategoryId"); + + b.ToTable("Products"); + + b.HasData( + new + { + ProductId = 1, + CategoryId = 1, + Price = 100m, + ProductName = "Jordan" + }, + new + { + ProductId = 2, + CategoryId = 1, + Price = 75m, + ProductName = "Air Force 1" + }, + new + { + ProductId = 3, + CategoryId = 1, + Price = 50m, + ProductName = "Ultra Boost" + }); + }); + + modelBuilder.Entity("ECommerceApi.JJHH17.Models.Sale", b => + { + b.Property("SaleId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("SaleId")); + + b.HasKey("SaleId"); + + b.ToTable("Sales"); + }); + + modelBuilder.Entity("ProductSales", b => + { + b.Property("ProductId") + .HasColumnType("int"); + + b.Property("SaleId") + .HasColumnType("int"); + + b.HasKey("ProductId", "SaleId"); + + b.HasIndex("SaleId"); + + b.ToTable("ProductSales", (string)null); + }); + + modelBuilder.Entity("ECommerceApi.JJHH17.Models.Product", b => + { + b.HasOne("ECommerceApi.JJHH17.Models.Category", "Category") + .WithMany("Products") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Category"); + }); + + modelBuilder.Entity("ProductSales", b => + { + b.HasOne("ECommerceApi.JJHH17.Models.Product", null) + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ECommerceApi.JJHH17.Models.Sale", null) + .WithMany() + .HasForeignKey("SaleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ECommerceApi.JJHH17.Models.Category", b => + { + b.Navigation("Products"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/ECommerceApi.JJHH17/ECommerceApi.JJHH17/Data/ProductsDbContext.cs b/ECommerceApi.JJHH17/ECommerceApi.JJHH17/Data/ProductsDbContext.cs new file mode 100644 index 00000000..8e33494f --- /dev/null +++ b/ECommerceApi.JJHH17/ECommerceApi.JJHH17/Data/ProductsDbContext.cs @@ -0,0 +1,53 @@ +using ECommerceApi.JJHH17.Models; +using Microsoft.EntityFrameworkCore; + + +namespace ECommerceApi.JJHH17.Data +{ + public class ProductsDbContext : DbContext + { + public ProductsDbContext(DbContextOptions options) : base(options) { } + + public DbSet Products => Set(); + public DbSet Categories => Set(); + public DbSet Sales => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.Entity() + .Property(e => e.Price) + .HasColumnType("decimal(18,2)"); + + modelBuilder.Entity() + .HasOne(e => e.Category) + .WithMany(e => e.Products) + .HasForeignKey(e => e.CategoryId) + .IsRequired(); + + modelBuilder.Entity() + .HasMany(e => e.Products) + .WithMany(e => e.Sales) + .UsingEntity>( + "ProductSales", + j => j.HasOne().WithMany().HasForeignKey("ProductId"), + j => j.HasOne().WithMany().HasForeignKey("SaleId"), + j => + { + j.HasKey("ProductId", "SaleId"); + j.ToTable("ProductSales"); + }); + + // Optional data seeding + modelBuilder.Entity().HasData( + new Category { CategoryId = 1, CategoryName = "Nike" }, + new Category { CategoryId = 2, CategoryName = "Adidas" }); + + modelBuilder.Entity().HasData( + new Product { ProductId = 1, ProductName = "Jordan", Price = 100m, CategoryId = 1 }, + new Product { ProductId = 2, ProductName = "Air Force 1", Price = 75m, CategoryId = 1 }, + new Product { ProductId = 3, ProductName = "Ultra Boost", Price = 50m, CategoryId = 1 }); + } + } +} \ No newline at end of file diff --git a/ECommerceApi.JJHH17/ECommerceApi.JJHH17/ECommerceApi.JJHH17.csproj b/ECommerceApi.JJHH17/ECommerceApi.JJHH17/ECommerceApi.JJHH17.csproj new file mode 100644 index 00000000..9bc594ee --- /dev/null +++ b/ECommerceApi.JJHH17/ECommerceApi.JJHH17/ECommerceApi.JJHH17.csproj @@ -0,0 +1,23 @@ + + + + net8.0 + enable + enable + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + diff --git a/ECommerceApi.JJHH17/ECommerceApi.JJHH17/ECommerceApi.JJHH17.http b/ECommerceApi.JJHH17/ECommerceApi.JJHH17/ECommerceApi.JJHH17.http new file mode 100644 index 00000000..8d3dea3f --- /dev/null +++ b/ECommerceApi.JJHH17/ECommerceApi.JJHH17/ECommerceApi.JJHH17.http @@ -0,0 +1,6 @@ +@ECommerceApi.JJHH17_HostAddress = http://localhost:5005 + +GET {{ECommerceApi.JJHH17_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/ECommerceApi.JJHH17/ECommerceApi.JJHH17/Models/Category.cs b/ECommerceApi.JJHH17/ECommerceApi.JJHH17/Models/Category.cs new file mode 100644 index 00000000..39ca78f9 --- /dev/null +++ b/ECommerceApi.JJHH17/ECommerceApi.JJHH17/Models/Category.cs @@ -0,0 +1,15 @@ +namespace ECommerceApi.JJHH17.Models +{ + public class Category + { + public int CategoryId { get; set; } + public string CategoryName { get; set; } + public ICollection Products { get; set; } = new List(); + } + + public record CreateCategoryDto(string Name); + + public record ProductDto(int ProductId, string ProductName, decimal Price); + + public record CategoryWithProductsDto(int CategoryId, string CategoryName, IReadOnlyList Products); +} \ No newline at end of file diff --git a/ECommerceApi.JJHH17/ECommerceApi.JJHH17/Models/PagedResponse.cs b/ECommerceApi.JJHH17/ECommerceApi.JJHH17/Models/PagedResponse.cs new file mode 100644 index 00000000..95054f70 --- /dev/null +++ b/ECommerceApi.JJHH17/ECommerceApi.JJHH17/Models/PagedResponse.cs @@ -0,0 +1,18 @@ +namespace ECommerceApi.JJHH17.Models +{ + public class PagedResponse + { + public List Data { get; set; } = new(); + public int PageNumber { get; set; } + public int PageSize { get; set; } + public int TotalRecords { get; set; } + + public PagedResponse(List data, int pageNumber, int pageSize, int totalRecords) + { + this.Data = data; + this.PageNumber = pageNumber; + this.PageSize = pageSize; + this.TotalRecords = totalRecords; + } + } +} \ No newline at end of file diff --git a/ECommerceApi.JJHH17/ECommerceApi.JJHH17/Models/Pagination.cs b/ECommerceApi.JJHH17/ECommerceApi.JJHH17/Models/Pagination.cs new file mode 100644 index 00000000..6fd281e2 --- /dev/null +++ b/ECommerceApi.JJHH17/ECommerceApi.JJHH17/Models/Pagination.cs @@ -0,0 +1,15 @@ +namespace ECommerceApi.JJHH17.Models +{ + public class Pagination + { + private const int MaxPageSize = 50; + public int PageNumber { get; set; } = 1; + + private int _pageSize = 10; + public int PageSize + { + get => _pageSize; + set => _pageSize = (value > MaxPageSize) ? MaxPageSize : value; + } + } +} \ No newline at end of file diff --git a/ECommerceApi.JJHH17/ECommerceApi.JJHH17/Models/Product.cs b/ECommerceApi.JJHH17/ECommerceApi.JJHH17/Models/Product.cs new file mode 100644 index 00000000..aa2256a4 --- /dev/null +++ b/ECommerceApi.JJHH17/ECommerceApi.JJHH17/Models/Product.cs @@ -0,0 +1,17 @@ +namespace ECommerceApi.JJHH17.Models +{ + public class Product + { + public int ProductId { get; set; } + public string ProductName { get; set; } + public decimal Price { get; set; } + + public int CategoryId { get; set; } + public Category Category { get; set; } = null!; + public List Sales { get; } = []; + } + + public record CreateProductDto(string name, decimal price, int categoryId); + + public record GetProductsDto(int productId, string productName, decimal price, int CategoryId, string CategoryName); +} diff --git a/ECommerceApi.JJHH17/ECommerceApi.JJHH17/Models/Sale.cs b/ECommerceApi.JJHH17/ECommerceApi.JJHH17/Models/Sale.cs new file mode 100644 index 00000000..c2251a03 --- /dev/null +++ b/ECommerceApi.JJHH17/ECommerceApi.JJHH17/Models/Sale.cs @@ -0,0 +1,14 @@ +namespace ECommerceApi.JJHH17.Models +{ + public class Sale + { + public int SaleId { get; set; } + public List Products { get; } = []; + public decimal SalePrice => Products.Sum(p => p.Price); + public int ItemCount => Products.Count(); + } + + public record CreateSaleDto(List ProductIds); + + public record SaleWithProductsDto(int SaleId, int ItemCount, decimal SalePrice, List Products); +} diff --git a/ECommerceApi.JJHH17/ECommerceApi.JJHH17/PostmanCollection/ECommerce-API Collection.postman_collection.json b/ECommerceApi.JJHH17/ECommerceApi.JJHH17/PostmanCollection/ECommerce-API Collection.postman_collection.json new file mode 100644 index 00000000..08c9a633 --- /dev/null +++ b/ECommerceApi.JJHH17/ECommerceApi.JJHH17/PostmanCollection/ECommerce-API Collection.postman_collection.json @@ -0,0 +1,296 @@ +{ + "info": { + "_postman_id": "774cbdb7-e2d2-4f7e-9a7b-696f4ae3b86c", + "name": "ECommerce-API Collection", + "description": "A collection used for testing endpoints created in the ECommerce API.\n\nThis project was built following the C# Academy course: [https://www.thecsharpacademy.com/project/18/ecommerce-api](https://www.thecsharpacademy.com/project/18/ecommerce-api)\n\nThe collection contains 3 main endpoints:\n\n- Products\n \n- Categories\n \n- Sales\n \n\nProduct -> Categories - many to one relationship.\n\nProduct -> Sales - many to many relationship.\n\n**Variables**\n\n- You'll see a full list of variables under the \"Variables\" title in Postman.\n \n- This includes the port used during the API's lifecycle, as well as any relevant ID's for endpoints which can be altered at any point.\n \n\nPlease see below \"View Complete Documentation\" on Postman for further endpoint information.", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "_exporter_id": "31055143" + }, + "item": [ + { + "name": "Category", + "item": [ + { + "name": "Get Categories", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "https://localhost:{{port}}/api/Categories/", + "protocol": "https", + "host": [ + "localhost" + ], + "port": "{{port}}", + "path": [ + "api", + "Categories", + "" + ] + }, + "description": "This returns a list of all categories that exist." + }, + "response": [] + }, + { + "name": "Get Category by ID", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "https://localhost:{{port}}/api/Categories/{{categoryId}}", + "protocol": "https", + "host": [ + "localhost" + ], + "port": "{{port}}", + "path": [ + "api", + "Categories", + "{{categoryId}}" + ] + }, + "description": "This returns a category based on its ID, as well as the products that fall under the category." + }, + "response": [] + }, + { + "name": "Add New Category", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"name\": \"Postman Test new\"\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://localhost:{{port}}/api/Categories/", + "protocol": "https", + "host": [ + "localhost" + ], + "port": "{{port}}", + "path": [ + "api", + "Categories", + "" + ] + }, + "description": "This allows you to add a new category - Please use the \"Body - RAW Json\" section of Postman to do this, before sending the request.\n\nThis is a simple format, \"name\": \"string\" as noted in the placeholder value below." + }, + "response": [] + } + ], + "description": "This is used as a parent for Products, each product has one category and a category can have mny products.\n\nThis section contains:\n\n- Fetching all Categories\n \n- Fetching categories from their ID\n \n- Creating a new category\n \n\nPlease note that you can't have duplicate categories in the database, so you'll receive a relevant response if you attempt to create a category that exists." + }, + { + "name": "Product", + "item": [ + { + "name": "Get Products", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "https://localhost:{{port}}/api/Products/", + "protocol": "https", + "host": [ + "localhost" + ], + "port": "{{port}}", + "path": [ + "api", + "Products", + "" + ] + }, + "description": "Returns a list of all products that exist." + }, + "response": [] + }, + { + "name": "Get Product by ID", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "https://localhost:{{port}}/api/Products/{{productId}}", + "protocol": "https", + "host": [ + "localhost" + ], + "port": "{{port}}", + "path": [ + "api", + "Products", + "{{productId}}" + ] + }, + "description": "Returns a product based on its ID." + }, + "response": [] + }, + { + "name": "Add New Product", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"name\": \"New Product Postman\",\r\n \"price\": 20.50,\r\n \"categoryId\": 2\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://localhost:{{port}}/api/Products/", + "protocol": "https", + "host": [ + "localhost" + ], + "port": "{{port}}", + "path": [ + "api", + "Products", + "" + ] + }, + "description": "This allows you to add a new product- Please use the \"Body - RAW Json\" section of Postman to do this, before sending the request.\n\nBelow is an example of the JSON that can be used, which includes a \"name\", \"price\" and \"categoryID\" field.\n\nThe Category ID should be that of the category that you wish to link the product to." + }, + "response": [] + } + ], + "description": "A product is an individual item that belongs to a category as a child item - a product can only belong to a single category, and contains a name, price, and category information.\n\nThis section contains:\n\n- Fetching all Products\n \n- Fetching a product from their ID\n \n- Creating a new product" + }, + { + "name": "Sale", + "item": [ + { + "name": "Get Sales", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "https://localhost:{{port}}/api/Sales/", + "protocol": "https", + "host": [ + "localhost" + ], + "port": "{{port}}", + "path": [ + "api", + "Sales", + "" + ] + }, + "description": "This fetches a list of all sales." + }, + "response": [] + }, + { + "name": "Get Sale by ID", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "https://localhost:{{port}}/api/Sales/{{SaleId}}", + "protocol": "https", + "host": [ + "localhost" + ], + "port": "{{port}}", + "path": [ + "api", + "Sales", + "{{SaleId}}" + ] + }, + "description": "This fetches an individual sale, based on its ID." + }, + "response": [] + }, + { + "name": "Add New Sale", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"productIds\": [\r\n 1\r\n ]\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://localhost:{{port}}/api/Sales/", + "protocol": "https", + "host": [ + "localhost" + ], + "port": "{{port}}", + "path": [ + "api", + "Sales", + "" + ] + }, + "description": "This allows you to add a new sale- Please use the \"Body - RAW Json\" section of Postman to do this, before sending the request.\n\nThe JSON used is essentially a list of product IDs, and can be used by appending a comma between each product ID entered.\n\nBelow is an example use case." + }, + "response": [] + } + ], + "description": "A sale is an event whereby individual products are purchased.\n\nDuring a sale, the client enters the product id's that they wish to purchase, and those products are then added to the sale event.\n\nThe quantity of items and overrall cost of the sale is calculated once the data is sent by the client." + } + ], + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "packages": {}, + "requests": {}, + "exec": [ + "" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "packages": {}, + "requests": {}, + "exec": [ + "" + ] + } + } + ], + "variable": [ + { + "key": "port", + "value": "" + }, + { + "key": "categoryId", + "value": "" + }, + { + "key": "productId", + "value": "" + }, + { + "key": "SaleId", + "value": "" + } + ] +} \ No newline at end of file diff --git a/ECommerceApi.JJHH17/ECommerceApi.JJHH17/Program.cs b/ECommerceApi.JJHH17/ECommerceApi.JJHH17/Program.cs new file mode 100644 index 00000000..488cba37 --- /dev/null +++ b/ECommerceApi.JJHH17/ECommerceApi.JJHH17/Program.cs @@ -0,0 +1,25 @@ +using ECommerceApi.JJHH17.Data; +using ECommerceApi.JJHH17.Services; +using Microsoft.EntityFrameworkCore; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); +builder.Services.AddControllers(); +builder.Services.AddDbContext(opt => opt.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"))); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.MapControllers(); + +app.Run(); \ No newline at end of file diff --git a/ECommerceApi.JJHH17/ECommerceApi.JJHH17/Properties/launchSettings.json b/ECommerceApi.JJHH17/ECommerceApi.JJHH17/Properties/launchSettings.json new file mode 100644 index 00000000..c05dfa80 --- /dev/null +++ b/ECommerceApi.JJHH17/ECommerceApi.JJHH17/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:8562", + "sslPort": 44386 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5005", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7054;http://localhost:5005", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/ECommerceApi.JJHH17/ECommerceApi.JJHH17/Services/CategoryService.cs b/ECommerceApi.JJHH17/ECommerceApi.JJHH17/Services/CategoryService.cs new file mode 100644 index 00000000..e1122887 --- /dev/null +++ b/ECommerceApi.JJHH17/ECommerceApi.JJHH17/Services/CategoryService.cs @@ -0,0 +1,71 @@ +using ECommerceApi.JJHH17.Data; +using ECommerceApi.JJHH17.Models; +using Microsoft.EntityFrameworkCore; + +namespace ECommerceApi.JJHH17.Services +{ + public interface ICategoryService + { + public List GetAllCategories(); + public CategoryWithProductsDto GetCategoryById(int id); + public Category CreateCategory(CreateCategoryDto category); + } + + public class CategoryService : ICategoryService + { + private readonly ProductsDbContext _dbContext; + + public CategoryService(ProductsDbContext dbContext) + { + _dbContext = dbContext; + } + + public List GetAllCategories() + { + return _dbContext.Categories + .AsNoTracking() + .OrderBy(c => c.CategoryName) + .Select(c => new CategoryWithProductsDto( + c.CategoryId, + c.CategoryName, + c.Products + .OrderBy(p => p.ProductName) + .Select(p => new ProductDto(p.ProductId, p.ProductName, p.Price)) + .ToList() + )) + .ToList(); + } + + public CategoryWithProductsDto GetCategoryById(int id) + { + return _dbContext.Categories + .AsNoTracking() + .Where(p => p.CategoryId == id) + .Select(p => new CategoryWithProductsDto( + p.CategoryId, + p.CategoryName, + p.Products + .OrderBy(pr => pr.ProductName) + .Select(pr => new ProductDto(pr.ProductId, pr.ProductName, pr.Price)) + .ToList() + )) + .SingleOrDefault(); + } + + public Category CreateCategory(CreateCategoryDto category) + { + if (string.IsNullOrWhiteSpace(category.Name)) + throw new ArgumentException("Category name is required.", nameof(category)); + + bool exists = _dbContext.Categories.Any(c => c.CategoryName.ToLower() == category.Name.ToLower()); + if (exists) + throw new InvalidOperationException($"Category {category.Name} already exists"); + + var newCategory = new Category { CategoryName = category.Name.Trim() }; + + _dbContext.Categories.Add(newCategory); + _dbContext.SaveChanges(); + return newCategory; + } + } +} diff --git a/ECommerceApi.JJHH17/ECommerceApi.JJHH17/Services/ProductService.cs b/ECommerceApi.JJHH17/ECommerceApi.JJHH17/Services/ProductService.cs new file mode 100644 index 00000000..4bf10c1e --- /dev/null +++ b/ECommerceApi.JJHH17/ECommerceApi.JJHH17/Services/ProductService.cs @@ -0,0 +1,83 @@ +using ECommerceApi.JJHH17.Data; +using ECommerceApi.JJHH17.Models; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.EntityFrameworkCore; +using System.Diagnostics; + +namespace ECommerceApi.JJHH17.Services +{ + public interface IProductService + { + public List GetAllProducts(); + public GetProductsDto? GetProductById(int id); + public GetProductsDto CreateProduct(CreateProductDto product); + } + + public class ProductService : IProductService + { + private readonly ProductsDbContext _dbContext; + + public ProductService(ProductsDbContext dbContext) + { + _dbContext = dbContext; + } + + public GetProductsDto CreateProduct(CreateProductDto product) + { + if (product == null) + throw new ArgumentNullException("Product name is required.", nameof(product)); + + var categoryExists = _dbContext.Categories + .Any(c => c.CategoryId == product.categoryId); + if (!categoryExists) + throw new ArgumentException("Category does not exist", nameof(product.categoryId)); + + var newProduct = new Product { ProductName = product.name.Trim(), Price = product.price, CategoryId = product.categoryId }; + + _dbContext.Products.Add(newProduct); + _dbContext.SaveChanges(); + + return _dbContext.Products + .AsNoTracking() + .Where(p => p.ProductId == newProduct.ProductId) + .Select(p => new GetProductsDto( + p.ProductId, + p.ProductName, + p.Price, + p.CategoryId, + p.Category.CategoryName)) + .Single(); + } + + public List GetAllProducts() + { + return _dbContext.Products + .AsNoTracking() + .Include(p => p.Category) + .Select(p => new GetProductsDto( + p.ProductId, + p.ProductName, + p.Price, + p.CategoryId, + p.Category.CategoryName)) + .ToList(); + } + + public GetProductsDto GetProductById(int id) + { + var productItem = _dbContext.Products.Find(id); + + if (productItem == null) + { + return null; + } + + return _dbContext.Products + .AsNoTracking() + .Where(p => p.ProductId == id) + .Select(p => new GetProductsDto( + p.ProductId, p.ProductName, p.Price, p.CategoryId, p.Category.CategoryName)) + .SingleOrDefault(); + } + } +} diff --git a/ECommerceApi.JJHH17/ECommerceApi.JJHH17/Services/SaleService.cs b/ECommerceApi.JJHH17/ECommerceApi.JJHH17/Services/SaleService.cs new file mode 100644 index 00000000..c3b3248a --- /dev/null +++ b/ECommerceApi.JJHH17/ECommerceApi.JJHH17/Services/SaleService.cs @@ -0,0 +1,89 @@ +using ECommerceApi.JJHH17.Data; +using ECommerceApi.JJHH17.Models; +using Microsoft.EntityFrameworkCore; + +namespace ECommerceApi.JJHH17.Services +{ + public interface ISaleService + { + public List GetAllSales(); + public CategoryWithProductsDto GetSaleById(int id); + public Sale CreateSale(CreateSaleDto sale); + } + + public class SaleService : ISaleService + { + private readonly ProductsDbContext _dbContext; + + public SaleService(ProductsDbContext dbContext) + { + _dbContext = dbContext; + } + + public List GetAllSales() + { + return _dbContext.Sales + .AsNoTracking() + .Include(s => s.Products) + .Select(s => new SaleWithProductsDto( + s.SaleId, + s.ItemCount, + s.SalePrice, + s.Products + .OrderBy(p => p.ProductName) + .Select(p => new ProductDto(p.ProductId, p.ProductName, p.Price)) + .ToList() + )) + .ToList(); + } + + public CategoryWithProductsDto GetSaleById(int id) + { + return _dbContext.Categories + .AsNoTracking() + .Where(p => p.CategoryId == id) + .Select(p => new CategoryWithProductsDto( + p.CategoryId, + p.CategoryName, + p.Products + .OrderBy(pr => pr.ProductName) + .Select(pr => new ProductDto(pr.ProductId, pr.ProductName, pr.Price)) + .ToList() + )) + .SingleOrDefault(); + } + + public Sale CreateSale(CreateSaleDto sale) + { + if (sale is null) + { + throw new ArgumentNullException(nameof(sale)); + } + + if (sale.ProductIds is null || sale.ProductIds.Count == 0) + { + throw new ArgumentException("Provide atleast one product id", nameof(sale)); + } + + var uniqueId = sale.ProductIds.Distinct().ToList(); + var products = _dbContext.Products + .Where(p => uniqueId.Contains(p.ProductId)) + .ToList(); + + var foundId = products.Select(p => p.ProductId).ToHashSet(); + var missing = uniqueId.Where(id => !foundId.Contains(id)).ToList(); + if (missing.Count > 0) + { + throw new InvalidOperationException($"Unknown product ID's {string.Join(", ", missing)}"); + } + + var newSale = new Sale(); + newSale.Products.AddRange(products); + + _dbContext.Sales.Add(newSale); + _dbContext.SaveChanges(); + + return newSale; + } + } +} diff --git a/ECommerceApi.JJHH17/ECommerceApi.JJHH17/appsettings.Development.json b/ECommerceApi.JJHH17/ECommerceApi.JJHH17/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/ECommerceApi.JJHH17/ECommerceApi.JJHH17/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/ECommerceApi.JJHH17/ECommerceApi.JJHH17/appsettings.json b/ECommerceApi.JJHH17/ECommerceApi.JJHH17/appsettings.json new file mode 100644 index 00000000..0966defa --- /dev/null +++ b/ECommerceApi.JJHH17/ECommerceApi.JJHH17/appsettings.json @@ -0,0 +1,12 @@ +{ + "ConnectionStrings": { + "DefaultConnection": "Server=(localdb)\\eCommerceDb;Integrated Security=true;" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/README.md b/README.md new file mode 100644 index 00000000..6ee0fc0c --- /dev/null +++ b/README.md @@ -0,0 +1,56 @@ +# ECommerce API +An ASP.NET and Entity Framework API, built following the C# Academy specifications found on: https://www.thecsharpacademy.com/project/18/ecommerce-api + +This project has a "frontend" in the form of a console application; this repository can be found on: https://github.com/JJHH17/ECommerce-Console + +## Project Overview +This project: +- Use SQL Server for database storage. +- Allows users to Add and Fetch products, categories and sales. +- A single item is a "Product" - a product can have a single category, and a category may have multiple products. +- A sale is a request to order a list of products that exist on the database. +- This project also has a Postman collection which can be found within the "postmanCollection" folder of the project - please install the json file and import it into Postman in order to use it. +- The collection also contains full documentation on the endpoints and supported parameters. + +## Endpoint Structure: +- Product = This is an individual item - it contains an ID, name, price - it also contains a one to many relationship with "Category" and a many to many relationship with "Sale". +- Category = This is the type of item that a product is, such as "Nike" or "Adidas" as a brand of shoe - This has a many to one relationship with "Product". +- Sale = This allows the client to create a list of products to execute a sale on. This has a many to many relationship with Product, and will also calculate the overall quantity of items and overall cost of the transaction once a product list is provided. + +## Technology Used +- ASP.NET +- SQL Server +- Entity Framework +- Postman +- Swagger / OpenAPI + +## Installation steps and requirements +- A localdb instance of SQL Server is needed for this project - I'd recommend following this tutorial for steps on how to create an instance: https://www.youtube.com/watch?v=M5DhHYQlnq8&t=201s&pp=ygUPTE9DQUwgc3Fsc2VydmVy +- Once done, the connection string can be configured within the "DefaultConnection" variable within the projects appsettings.json file. +Once done, run the .sln file and run the project - you'll be directed to Swagger via your default browser where you'll also be able to test the API's endpoints. + +## Additional Details +- The project also supports pagination, as well as data seeding once the database instance is created. +- A console based UI can be found and installed via: https://github.com/JJHH17/ECommerce-Console + +## Endpoints and usage +- Product endpoints (GET, GET ID, POST) - Allows the client to get a list of, a single, or create individual products. +image + +- Sale endpoints (GET, GET ID, POST) - Allows the client to get a list of, a single, or create individual sales. +image + +- Category endpoints (GET, GET ID, POST) - Allows the client to get a list of, a single, or create individual categories. +image + + +## Key takeaways and learnings from project +This was a great and challenging project to complete - initially, I had a few blockers with understanding how to configure many to many (as well as one to many) relationships within Entity Framework and then mapping them for my API endpoints. + +In the end, I was overcomplicating my thoughts and solutions with these, and I decided to create 3 seperate controllers and services to process them, and then mapping each one within the DB Context file - It took around half a week or playing around with different options and solutions to stick to this specific route, although I'm pleased that I did get stuck here, as it was a great learning experienced and allowed me to also lean on Microsoft's documentation more. + +I then set about creating the Postman collection which I have some experience with already, although it was very cool to create a collection pointing towards an API that I had created myself, versus consuming a public or third party API which was the only real prior experience I had before. + +Finally, creating the console application (found via: https://github.com/JJHH17/ECommerce-Console) was also a great experience - again, I've not had massive amounts of experience consuming my own API that I've created, so it was extremely rewarding on that front - I ensured to create the projects separately; previously, I'd mixed my console app with the backend API, which was causing both apps to mix with eachothers logs, so separating these two project should prevent that in the future. + +I'd like to thank, as always, the C# Academy team for the opportunity to work on this project, the Discord community for their inspiration, as well as the team of individuals who review these projects - I've had amazing feedback so far from previous projects which has always been a great and rewarding experience and I look forward to giving back to the same community now that I'm starting to review projects myself.