diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 00000000..783e85e3 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,15 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Rider ignored files +/contentModel.xml +/.idea.CodeReviews.Console.EcommerceApi.iml +/modules.xml +/projectSettingsUpdater.xml +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/.idea/encodings.xml b/.idea/encodings.xml new file mode 100644 index 00000000..df87cf95 --- /dev/null +++ b/.idea/encodings.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/.idea/indexLayout.xml b/.idea/indexLayout.xml new file mode 100644 index 00000000..7b08163c --- /dev/null +++ b/.idea/indexLayout.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 00000000..7829b9f9 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,21 @@ + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 00000000..35eb1ddf --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Ecommerce-API/ECommerce.API/Controllers/ItemsController.cs b/Ecommerce-API/ECommerce.API/Controllers/ItemsController.cs new file mode 100644 index 00000000..bc6754e0 --- /dev/null +++ b/Ecommerce-API/ECommerce.API/Controllers/ItemsController.cs @@ -0,0 +1,46 @@ +using ECommerce.API.Interfaces; +using ECommerce.API.Models; +using ECommerce.Shared.Models; +using Microsoft.AspNetCore.Mvc; + +namespace ECommerce.API.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class ItemsController(IItemService itemService) : ControllerBase +{ + [HttpPost] + public async Task PostItem([FromBody] CreateItemDto itemDto) + { + await itemService.PostItemAsync(itemDto); + return Created(); + } + + [HttpGet] + public async Task>> GetItemsAsync([FromQuery] PaginationParams paginationParams) + { + var pagedResponse = await itemService.GetItemsAsync(paginationParams); + if (pagedResponse.TotalRecords == 0) return NotFound(); + return Ok(pagedResponse); + } + + [HttpGet("{id:int}")] + public async Task> GetItemByIdAsync(int id) + { + var itemDto = await itemService.GetItemByIdAsync(id); + + return itemDto is null ? NotFound() : Ok(itemDto); + } + + [HttpDelete("{id:int}")] + public async Task DeleteItemAsync(int id) + { + var success = await itemService.DeleteItemByIdAsync(id); + if (success is null) + return NotFound(); + if (success is false) + return StatusCode(StatusCodes.Status500InternalServerError); + + return NoContent(); + } +} \ No newline at end of file diff --git a/Ecommerce-API/ECommerce.API/Controllers/SalesController.cs b/Ecommerce-API/ECommerce.API/Controllers/SalesController.cs new file mode 100644 index 00000000..04d98223 --- /dev/null +++ b/Ecommerce-API/ECommerce.API/Controllers/SalesController.cs @@ -0,0 +1,43 @@ +using ECommerce.API.Interfaces; +using ECommerce.API.Models; +using ECommerce.Shared.Models; +using Microsoft.AspNetCore.Mvc; + +namespace ECommerce.API.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class SalesController(ISaleService saleService) : ControllerBase +{ + [HttpPost] + public async Task PostSale([FromBody] List saleItems) + { + var success = await saleService.PostSaleAsync(saleItems); + return success switch + { + null => NotFound(), + true => NoContent(), + false => StatusCode(StatusCodes.Status500InternalServerError) + }; + } + + [HttpGet] + public async Task>> GetItemsAsync([FromQuery] PaginationParams paginationParams) + { + var pagedResponse = await saleService.GetSalesAsync(paginationParams); + if (pagedResponse.TotalRecords == 0) return NotFound(); + return Ok(pagedResponse); + } + + [HttpDelete] + public async Task DeleteSaleAsync([FromQuery] int saleId) + { + var success = await saleService.DeleteSaleByIdAsync(saleId); + return success switch + { + null => NotFound(), + true => NoContent(), + false => StatusCode(StatusCodes.Status500InternalServerError) + }; + } +} \ No newline at end of file diff --git a/Ecommerce-API/ECommerce.API/Controllers/TagsController.cs b/Ecommerce-API/ECommerce.API/Controllers/TagsController.cs new file mode 100644 index 00000000..b8d1af05 --- /dev/null +++ b/Ecommerce-API/ECommerce.API/Controllers/TagsController.cs @@ -0,0 +1,52 @@ +using ECommerce.API.Interfaces; +using ECommerce.API.Models; +using ECommerce.Shared.Models; +using Microsoft.AspNetCore.Mvc; + +namespace ECommerce.API.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class TagsController(ITagService tagService) : ControllerBase +{ + [HttpGet] + public async Task GetTags([FromQuery] PaginationParams paginationParams) + { + var pagedResponse = await tagService.GetTagsAsync(paginationParams); + if (pagedResponse.TotalRecords == 0) + return NotFound(); + + return Ok(pagedResponse); + } + + [HttpGet("{name}")] + public async Task> GetTagId(string name) + { + var tag = await tagService.GetTagIdByName(name); + return tag switch + { + null => NotFound(), + _ => Ok(tag) + }; + } + + [HttpPost] + public async Task PostTag([FromBody] CreateTagDto tagDto) + { + await tagService.PostTagAsync(tagDto); + return Created(); + } + + [HttpDelete("{tagId:int}")] + public async Task DeleteTag(int tagId) + { + var success = await tagService.DeleteTagByIdAsync(tagId); + + return success switch + { + null => NotFound(), + true => NoContent(), + false => StatusCode(StatusCodes.Status500InternalServerError) + }; + } +} \ No newline at end of file diff --git a/Ecommerce-API/ECommerce.API/Data/ApiDbContext.cs b/Ecommerce-API/ECommerce.API/Data/ApiDbContext.cs new file mode 100644 index 00000000..2d0645f3 --- /dev/null +++ b/Ecommerce-API/ECommerce.API/Data/ApiDbContext.cs @@ -0,0 +1,50 @@ +using ECommerce.API.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace ECommerce.API.Data; + +public class ApiDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Items { get; set; } + public DbSet Tags { get; set; } + public DbSet Sales { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity() + .HasKey(si => new { si.SaleId, si.ItemId }); + modelBuilder.Entity() + .HasOne(si => si.Sale) + .WithMany(s => s.SoldItems) + .HasForeignKey(si => si.SaleId); + modelBuilder.Entity() + .HasOne(si => si.Item) + .WithMany() + .HasForeignKey(si => si.ItemId); + + modelBuilder.Entity().HasQueryFilter(i => !i.IsDeleted); + modelBuilder.Entity().HasQueryFilter(s => !s.IsDeleted); + modelBuilder.Entity().HasQueryFilter(t => !t.IsDeleted); + modelBuilder.Entity().HasQueryFilter(si => !si.Item.IsDeleted); + + modelBuilder.Entity().Ignore(s => s.TotalPrice); + } + + protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) + { + configurationBuilder + .Properties() + .HaveConversion(); + } +} + +public class ApiDbContextFactory : IDesignTimeDbContextFactory +{ + public ApiDbContext CreateDbContext(string[] args) + { + var optionsBuilder = new DbContextOptionsBuilder(); + optionsBuilder.UseSqlite(DbConfig.GetConnectionString()); + return new ApiDbContext(optionsBuilder.Options); + } +} \ No newline at end of file diff --git a/Ecommerce-API/ECommerce.API/Data/DbConfig.cs b/Ecommerce-API/ECommerce.API/Data/DbConfig.cs new file mode 100644 index 00000000..2cf2563b --- /dev/null +++ b/Ecommerce-API/ECommerce.API/Data/DbConfig.cs @@ -0,0 +1,12 @@ +namespace ECommerce.API.Data; + +public class DbConfig +{ + public static string GetConnectionString() + { + return $"Data Source={Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "ECommerce", + "api.db")}"; + } +} \ No newline at end of file diff --git a/Ecommerce-API/ECommerce.API/Data/DbSeeder.cs b/Ecommerce-API/ECommerce.API/Data/DbSeeder.cs new file mode 100644 index 00000000..4d835e6c --- /dev/null +++ b/Ecommerce-API/ECommerce.API/Data/DbSeeder.cs @@ -0,0 +1,124 @@ +using ECommerce.API.Models; +using ECommerce.Shared; + +namespace ECommerce.API.Data; + +public class DbSeeder +{ + public static async Task SeedItemsAsync(ApiDbContext db) + { + // Tags + var westCoastHipHopTag = new Tag { TagName = "West Coast Hip-Hop" }; + var consciousRapTag = new Tag { TagName = "Conscious Rap" }; + var femaleEmpowermentTag = new Tag { TagName = "Female Empowerment" }; + var heartbreakTag = new Tag { TagName = "Heartbreak" }; + var rnbTag = new Tag { TagName = "R&B" }; + var soulTag = new Tag { TagName = "Soul" }; + var popTag = new Tag { TagName = "Pop" }; + var alternativeTag = new Tag { TagName = "Alternative" }; + var trapTag = new Tag { TagName = "Trap" }; + var melodicRapTag = new Tag { TagName = "Melodic Rap" }; + var indieTag = new Tag { TagName = "Indie" }; + var folkTag = new Tag { TagName = "Folk" }; + var rockTag = new Tag { TagName = "Rock" }; + var eastCoastHipHopTag = new Tag { TagName = "East Coast Hip-Hop" }; + var afrobeatTag = new Tag { TagName = "Afrobeats" }; + + var sampleItems = new List + { + new() + { + Format = ItemFormat.Vinyl, Type = ItemType.Album, Artist = "Kendrick Lamar", + Name = "GNX", Price = 34.99m, Genre = "Hip-Hop", + Tags = new List { westCoastHipHopTag, consciousRapTag } + }, + new() + { + Format = ItemFormat.Vinyl, Type = ItemType.Album, Artist = "Beyoncé", + Name = "Cowboy Carter", Price = 39.99m, Genre = "Country/R&B", + Tags = new List { femaleEmpowermentTag, rnbTag } + }, + new() + { + Format = ItemFormat.Vinyl, Type = ItemType.Album, Artist = "Sabrina Carpenter", + Name = "Short n' Sweet", Price = 29.99m, Genre = "Pop", + Tags = new List { popTag, heartbreakTag } + }, + new() + { + Format = ItemFormat.Vinyl, Type = ItemType.Album, Artist = "Billie Eilish", + Name = "HIT ME HARD AND SOFT", Price = 31.99m, Genre = "Alternative/Pop", + Tags = new List { alternativeTag, popTag } + }, + new() + { + Format = ItemFormat.Vinyl, Type = ItemType.Album, Artist = "Drake", + Name = "For All The Dogs", Price = 34.99m, Genre = "Hip-Hop/R&B", + Tags = new List { trapTag, rnbTag } + }, + new() + { + Format = ItemFormat.Vinyl, Type = ItemType.Album, Artist = "Tyler, the Creator", + Name = "Chromakopia", Price = 36.99m, Genre = "Hip-Hop", + Tags = new List { westCoastHipHopTag, alternativeTag } + }, + new() + { + Format = ItemFormat.Vinyl, Type = ItemType.Album, Artist = "Chappell Roan", + Name = "The Rise and Fall of a Midwest Princess", Price = 29.99m, Genre = "Pop", + Tags = new List { popTag, indieTag } + }, + new() + { + Format = ItemFormat.Vinyl, Type = ItemType.Album, Artist = "Playboi Carti", + Name = "Music", Price = 34.99m, Genre = "Hip-Hop", + Tags = new List { trapTag, melodicRapTag } + }, + new() + { + Format = ItemFormat.Cd, Type = ItemType.Album, Artist = "Gracie Abrams", + Name = "The Secret of Us", Price = 13.99m, Genre = "Indie/Pop", + Tags = new List { indieTag, heartbreakTag } + }, + new() + { + Format = ItemFormat.Vinyl, Type = ItemType.Album, Artist = "Vampire Weekend", + Name = "Only God Was Above Us", Price = 31.99m, Genre = "Indie Rock", + Tags = new List { indieTag, rockTag } + }, + new() + { + Format = ItemFormat.Vinyl, Type = ItemType.Album, Artist = "Doechii", + Name = "Alligator Bites Never Heal", Price = 29.99m, Genre = "Hip-Hop/R&B", + Tags = new List { femaleEmpowermentTag, trapTag } + }, + new() + { + Format = ItemFormat.Vinyl, Type = ItemType.Album, Artist = "Burna Boy", + Name = "I Told Them...", Price = 33.99m, Genre = "Afrobeats", + Tags = new List { afrobeatTag, rnbTag } + }, + new() + { + Format = ItemFormat.Cd, Type = ItemType.Single, Artist = "Kendrick Lamar", + Name = "Not Like Us", Price = 5.99m, Genre = "Hip-Hop", + Tags = new List { westCoastHipHopTag, consciousRapTag } + }, + new() + { + Format = ItemFormat.Vinyl, Type = ItemType.Album, Artist = "Mk.gee", + Name = "Two Star & The Dream Police", Price = 27.99m, Genre = "Alternative/R&B", + Tags = new List { alternativeTag, rnbTag } + }, + new() + { + Format = ItemFormat.Vinyl, Type = ItemType.Album, Artist = "Lauryn Hill", + Name = "The Miseducation of Lauryn Hill", Price = 29.98m, Genre = "Neo Soul", + Tags = new List { femaleEmpowermentTag, soulTag } + } + }; + + db.Items.AddRange(sampleItems); + await db.SaveChangesAsync(); + } +} \ No newline at end of file diff --git a/Ecommerce-API/ECommerce.API/Data/Migrations/20260405031534_InitialCreate.Designer.cs b/Ecommerce-API/ECommerce.API/Data/Migrations/20260405031534_InitialCreate.Designer.cs new file mode 100644 index 00000000..ccbb1d19 --- /dev/null +++ b/Ecommerce-API/ECommerce.API/Data/Migrations/20260405031534_InitialCreate.Designer.cs @@ -0,0 +1,131 @@ +// +using ECommerce.API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace ECommerce.API.Data.Migrations +{ + [DbContext(typeof(ApiDbContext))] + [Migration("20260405031534_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.5"); + + modelBuilder.Entity("ECommerce.API.Models.Item", b => + { + b.Property("ItemId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Artist") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Format") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Genre") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Price") + .HasColumnType("TEXT"); + + b.Property("SaleId") + .HasColumnType("INTEGER"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("ItemId"); + + b.HasIndex("SaleId"); + + b.ToTable("Items"); + }); + + modelBuilder.Entity("ECommerce.API.Models.Sale", b => + { + b.Property("SaleId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.HasKey("SaleId"); + + b.ToTable("Sales"); + }); + + modelBuilder.Entity("ECommerce.API.Models.Tag", b => + { + b.Property("TagId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("TagName") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("TagId"); + + b.ToTable("Tags"); + }); + + modelBuilder.Entity("ItemTag", b => + { + b.Property("ItemsItemId") + .HasColumnType("INTEGER"); + + b.Property("TagsTagId") + .HasColumnType("INTEGER"); + + b.HasKey("ItemsItemId", "TagsTagId"); + + b.HasIndex("TagsTagId"); + + b.ToTable("ItemTag"); + }); + + modelBuilder.Entity("ECommerce.API.Models.Item", b => + { + b.HasOne("ECommerce.API.Models.Sale", null) + .WithMany("SoldItems") + .HasForeignKey("SaleId"); + }); + + modelBuilder.Entity("ItemTag", b => + { + b.HasOne("ECommerce.API.Models.Item", null) + .WithMany() + .HasForeignKey("ItemsItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ECommerce.API.Models.Tag", null) + .WithMany() + .HasForeignKey("TagsTagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ECommerce.API.Models.Sale", b => + { + b.Navigation("SoldItems"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Ecommerce-API/ECommerce.API/Data/Migrations/20260405031534_InitialCreate.cs b/Ecommerce-API/ECommerce.API/Data/Migrations/20260405031534_InitialCreate.cs new file mode 100644 index 00000000..842d7e2e --- /dev/null +++ b/Ecommerce-API/ECommerce.API/Data/Migrations/20260405031534_InitialCreate.cs @@ -0,0 +1,113 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ECommerce.API.Data.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Sales", + columns: table => new + { + SaleId = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true) + }, + constraints: table => + { + table.PrimaryKey("PK_Sales", x => x.SaleId); + }); + + migrationBuilder.CreateTable( + name: "Tags", + columns: table => new + { + TagId = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + TagName = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Tags", x => x.TagId); + }); + + migrationBuilder.CreateTable( + name: "Items", + columns: table => new + { + ItemId = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Format = table.Column(type: "TEXT", nullable: false), + Type = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", nullable: false), + Artist = table.Column(type: "TEXT", nullable: false), + Price = table.Column(type: "TEXT", nullable: false), + Genre = table.Column(type: "TEXT", nullable: false), + SaleId = table.Column(type: "INTEGER", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Items", x => x.ItemId); + table.ForeignKey( + name: "FK_Items_Sales_SaleId", + column: x => x.SaleId, + principalTable: "Sales", + principalColumn: "SaleId"); + }); + + migrationBuilder.CreateTable( + name: "ItemTag", + columns: table => new + { + ItemsItemId = table.Column(type: "INTEGER", nullable: false), + TagsTagId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ItemTag", x => new { x.ItemsItemId, x.TagsTagId }); + table.ForeignKey( + name: "FK_ItemTag_Items_ItemsItemId", + column: x => x.ItemsItemId, + principalTable: "Items", + principalColumn: "ItemId", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_ItemTag_Tags_TagsTagId", + column: x => x.TagsTagId, + principalTable: "Tags", + principalColumn: "TagId", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Items_SaleId", + table: "Items", + column: "SaleId"); + + migrationBuilder.CreateIndex( + name: "IX_ItemTag_TagsTagId", + table: "ItemTag", + column: "TagsTagId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ItemTag"); + + migrationBuilder.DropTable( + name: "Items"); + + migrationBuilder.DropTable( + name: "Tags"); + + migrationBuilder.DropTable( + name: "Sales"); + } + } +} diff --git a/Ecommerce-API/ECommerce.API/Data/Migrations/20260405233352_AddedSoftDeleteInterceptor.Designer.cs b/Ecommerce-API/ECommerce.API/Data/Migrations/20260405233352_AddedSoftDeleteInterceptor.Designer.cs new file mode 100644 index 00000000..89529a44 --- /dev/null +++ b/Ecommerce-API/ECommerce.API/Data/Migrations/20260405233352_AddedSoftDeleteInterceptor.Designer.cs @@ -0,0 +1,138 @@ +// +using System; +using ECommerce.API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace ECommerce.API.Data.Migrations +{ + [DbContext(typeof(ApiDbContext))] + [Migration("20260405233352_AddedSoftDeleteInterceptor")] + partial class AddedSoftDeleteInterceptor + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.5"); + + modelBuilder.Entity("ECommerce.API.Models.Item", b => + { + b.Property("ItemId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Artist") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DeletedOnUtc") + .HasColumnType("TEXT"); + + b.Property("Format") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Genre") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Price") + .HasColumnType("TEXT"); + + b.Property("SaleId") + .HasColumnType("INTEGER"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("ItemId"); + + b.HasIndex("SaleId"); + + b.ToTable("Items"); + }); + + modelBuilder.Entity("ECommerce.API.Models.Sale", b => + { + b.Property("SaleId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.HasKey("SaleId"); + + b.ToTable("Sales"); + }); + + modelBuilder.Entity("ECommerce.API.Models.Tag", b => + { + b.Property("TagId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("TagName") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("TagId"); + + b.ToTable("Tags"); + }); + + modelBuilder.Entity("ItemTag", b => + { + b.Property("ItemsItemId") + .HasColumnType("INTEGER"); + + b.Property("TagsTagId") + .HasColumnType("INTEGER"); + + b.HasKey("ItemsItemId", "TagsTagId"); + + b.HasIndex("TagsTagId"); + + b.ToTable("ItemTag"); + }); + + modelBuilder.Entity("ECommerce.API.Models.Item", b => + { + b.HasOne("ECommerce.API.Models.Sale", null) + .WithMany("SoldItems") + .HasForeignKey("SaleId"); + }); + + modelBuilder.Entity("ItemTag", b => + { + b.HasOne("ECommerce.API.Models.Item", null) + .WithMany() + .HasForeignKey("ItemsItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ECommerce.API.Models.Tag", null) + .WithMany() + .HasForeignKey("TagsTagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ECommerce.API.Models.Sale", b => + { + b.Navigation("SoldItems"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Ecommerce-API/ECommerce.API/Data/Migrations/20260405233352_AddedSoftDeleteInterceptor.cs b/Ecommerce-API/ECommerce.API/Data/Migrations/20260405233352_AddedSoftDeleteInterceptor.cs new file mode 100644 index 00000000..a9395736 --- /dev/null +++ b/Ecommerce-API/ECommerce.API/Data/Migrations/20260405233352_AddedSoftDeleteInterceptor.cs @@ -0,0 +1,40 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ECommerce.API.Data.Migrations +{ + /// + public partial class AddedSoftDeleteInterceptor : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "DeletedOnUtc", + table: "Items", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "IsDeleted", + table: "Items", + type: "INTEGER", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "DeletedOnUtc", + table: "Items"); + + migrationBuilder.DropColumn( + name: "IsDeleted", + table: "Items"); + } + } +} diff --git a/Ecommerce-API/ECommerce.API/Data/Migrations/20260405233944_AddedQueryFiltersForSoftDeletes.Designer.cs b/Ecommerce-API/ECommerce.API/Data/Migrations/20260405233944_AddedQueryFiltersForSoftDeletes.Designer.cs new file mode 100644 index 00000000..86a0b2e4 --- /dev/null +++ b/Ecommerce-API/ECommerce.API/Data/Migrations/20260405233944_AddedQueryFiltersForSoftDeletes.Designer.cs @@ -0,0 +1,150 @@ +// +using System; +using ECommerce.API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace ECommerce.API.Data.Migrations +{ + [DbContext(typeof(ApiDbContext))] + [Migration("20260405233944_AddedQueryFiltersForSoftDeletes")] + partial class AddedQueryFiltersForSoftDeletes + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.5"); + + modelBuilder.Entity("ECommerce.API.Models.Item", b => + { + b.Property("ItemId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Artist") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DeletedOnUtc") + .HasColumnType("TEXT"); + + b.Property("Format") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Genre") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Price") + .HasColumnType("TEXT"); + + b.Property("SaleId") + .HasColumnType("INTEGER"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("ItemId"); + + b.HasIndex("SaleId"); + + b.ToTable("Items"); + }); + + modelBuilder.Entity("ECommerce.API.Models.Sale", b => + { + b.Property("SaleId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DeletedOnUtc") + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.HasKey("SaleId"); + + b.ToTable("Sales"); + }); + + modelBuilder.Entity("ECommerce.API.Models.Tag", b => + { + b.Property("TagId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DeletedOnUtc") + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("TagName") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("TagId"); + + b.ToTable("Tags"); + }); + + modelBuilder.Entity("ItemTag", b => + { + b.Property("ItemsItemId") + .HasColumnType("INTEGER"); + + b.Property("TagsTagId") + .HasColumnType("INTEGER"); + + b.HasKey("ItemsItemId", "TagsTagId"); + + b.HasIndex("TagsTagId"); + + b.ToTable("ItemTag"); + }); + + modelBuilder.Entity("ECommerce.API.Models.Item", b => + { + b.HasOne("ECommerce.API.Models.Sale", null) + .WithMany("SoldItems") + .HasForeignKey("SaleId"); + }); + + modelBuilder.Entity("ItemTag", b => + { + b.HasOne("ECommerce.API.Models.Item", null) + .WithMany() + .HasForeignKey("ItemsItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ECommerce.API.Models.Tag", null) + .WithMany() + .HasForeignKey("TagsTagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ECommerce.API.Models.Sale", b => + { + b.Navigation("SoldItems"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Ecommerce-API/ECommerce.API/Data/Migrations/20260405233944_AddedQueryFiltersForSoftDeletes.cs b/Ecommerce-API/ECommerce.API/Data/Migrations/20260405233944_AddedQueryFiltersForSoftDeletes.cs new file mode 100644 index 00000000..d8cf7416 --- /dev/null +++ b/Ecommerce-API/ECommerce.API/Data/Migrations/20260405233944_AddedQueryFiltersForSoftDeletes.cs @@ -0,0 +1,61 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ECommerce.API.Data.Migrations +{ + /// + public partial class AddedQueryFiltersForSoftDeletes : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "DeletedOnUtc", + table: "Tags", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "IsDeleted", + table: "Tags", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "DeletedOnUtc", + table: "Sales", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "IsDeleted", + table: "Sales", + type: "INTEGER", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "DeletedOnUtc", + table: "Tags"); + + migrationBuilder.DropColumn( + name: "IsDeleted", + table: "Tags"); + + migrationBuilder.DropColumn( + name: "DeletedOnUtc", + table: "Sales"); + + migrationBuilder.DropColumn( + name: "IsDeleted", + table: "Sales"); + } + } +} diff --git a/Ecommerce-API/ECommerce.API/Data/Migrations/20260406001646_ChangedItemSaleRelationship.Designer.cs b/Ecommerce-API/ECommerce.API/Data/Migrations/20260406001646_ChangedItemSaleRelationship.Designer.cs new file mode 100644 index 00000000..6a556e26 --- /dev/null +++ b/Ecommerce-API/ECommerce.API/Data/Migrations/20260406001646_ChangedItemSaleRelationship.Designer.cs @@ -0,0 +1,163 @@ +// +using System; +using ECommerce.API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace ECommerce.API.Data.Migrations +{ + [DbContext(typeof(ApiDbContext))] + [Migration("20260406001646_ChangedItemSaleRelationship")] + partial class ChangedItemSaleRelationship + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.5"); + + modelBuilder.Entity("ECommerce.API.Models.Item", b => + { + b.Property("ItemId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Artist") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DeletedOnUtc") + .HasColumnType("TEXT"); + + b.Property("Format") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Genre") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Price") + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("ItemId"); + + b.ToTable("Items"); + }); + + modelBuilder.Entity("ECommerce.API.Models.Sale", b => + { + b.Property("SaleId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DeletedOnUtc") + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.HasKey("SaleId"); + + b.ToTable("Sales"); + }); + + modelBuilder.Entity("ECommerce.API.Models.Tag", b => + { + b.Property("TagId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DeletedOnUtc") + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("TagName") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("TagId"); + + b.ToTable("Tags"); + }); + + modelBuilder.Entity("ItemSale", b => + { + b.Property("SalesSaleId") + .HasColumnType("INTEGER"); + + b.Property("SoldItemsItemId") + .HasColumnType("INTEGER"); + + b.HasKey("SalesSaleId", "SoldItemsItemId"); + + b.HasIndex("SoldItemsItemId"); + + b.ToTable("ItemSale"); + }); + + modelBuilder.Entity("ItemTag", b => + { + b.Property("ItemsItemId") + .HasColumnType("INTEGER"); + + b.Property("TagsTagId") + .HasColumnType("INTEGER"); + + b.HasKey("ItemsItemId", "TagsTagId"); + + b.HasIndex("TagsTagId"); + + b.ToTable("ItemTag"); + }); + + modelBuilder.Entity("ItemSale", b => + { + b.HasOne("ECommerce.API.Models.Sale", null) + .WithMany() + .HasForeignKey("SalesSaleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ECommerce.API.Models.Item", null) + .WithMany() + .HasForeignKey("SoldItemsItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ItemTag", b => + { + b.HasOne("ECommerce.API.Models.Item", null) + .WithMany() + .HasForeignKey("ItemsItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ECommerce.API.Models.Tag", null) + .WithMany() + .HasForeignKey("TagsTagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Ecommerce-API/ECommerce.API/Data/Migrations/20260406001646_ChangedItemSaleRelationship.cs b/Ecommerce-API/ECommerce.API/Data/Migrations/20260406001646_ChangedItemSaleRelationship.cs new file mode 100644 index 00000000..e3481199 --- /dev/null +++ b/Ecommerce-API/ECommerce.API/Data/Migrations/20260406001646_ChangedItemSaleRelationship.cs @@ -0,0 +1,80 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ECommerce.API.Data.Migrations +{ + /// + public partial class ChangedItemSaleRelationship : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Items_Sales_SaleId", + table: "Items"); + + migrationBuilder.DropIndex( + name: "IX_Items_SaleId", + table: "Items"); + + migrationBuilder.DropColumn( + name: "SaleId", + table: "Items"); + + migrationBuilder.CreateTable( + name: "ItemSale", + columns: table => new + { + SalesSaleId = table.Column(type: "INTEGER", nullable: false), + SoldItemsItemId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ItemSale", x => new { x.SalesSaleId, x.SoldItemsItemId }); + table.ForeignKey( + name: "FK_ItemSale_Items_SoldItemsItemId", + column: x => x.SoldItemsItemId, + principalTable: "Items", + principalColumn: "ItemId", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_ItemSale_Sales_SalesSaleId", + column: x => x.SalesSaleId, + principalTable: "Sales", + principalColumn: "SaleId", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_ItemSale_SoldItemsItemId", + table: "ItemSale", + column: "SoldItemsItemId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ItemSale"); + + migrationBuilder.AddColumn( + name: "SaleId", + table: "Items", + type: "INTEGER", + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_Items_SaleId", + table: "Items", + column: "SaleId"); + + migrationBuilder.AddForeignKey( + name: "FK_Items_Sales_SaleId", + table: "Items", + column: "SaleId", + principalTable: "Sales", + principalColumn: "SaleId"); + } + } +} diff --git a/Ecommerce-API/ECommerce.API/Data/Migrations/20260406012101_AddedDedicatedSaleItemJoinTable.Designer.cs b/Ecommerce-API/ECommerce.API/Data/Migrations/20260406012101_AddedDedicatedSaleItemJoinTable.Designer.cs new file mode 100644 index 00000000..16f5fc5f --- /dev/null +++ b/Ecommerce-API/ECommerce.API/Data/Migrations/20260406012101_AddedDedicatedSaleItemJoinTable.Designer.cs @@ -0,0 +1,192 @@ +// +using System; +using ECommerce.API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace ECommerce.API.Data.Migrations +{ + [DbContext(typeof(ApiDbContext))] + [Migration("20260406012101_AddedDedicatedSaleItemJoinTable")] + partial class AddedDedicatedSaleItemJoinTable + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.5"); + + modelBuilder.Entity("ECommerce.API.Models.Item", b => + { + b.Property("ItemId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Artist") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DeletedOnUtc") + .HasColumnType("TEXT"); + + b.Property("Format") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Genre") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Price") + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("ItemId"); + + b.ToTable("Items"); + }); + + modelBuilder.Entity("ECommerce.API.Models.Sale", b => + { + b.Property("SaleId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DeletedOnUtc") + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("INTEGER"); + + b.HasKey("SaleId"); + + b.HasIndex("ItemId"); + + b.ToTable("Sales"); + }); + + modelBuilder.Entity("ECommerce.API.Models.SaleItem", b => + { + b.Property("SaleId") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("INTEGER"); + + b.Property("Quantity") + .HasColumnType("INTEGER"); + + b.HasKey("SaleId", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("SaleItem"); + }); + + modelBuilder.Entity("ECommerce.API.Models.Tag", b => + { + b.Property("TagId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DeletedOnUtc") + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("TagName") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("TagId"); + + b.ToTable("Tags"); + }); + + modelBuilder.Entity("ItemTag", b => + { + b.Property("ItemsItemId") + .HasColumnType("INTEGER"); + + b.Property("TagsTagId") + .HasColumnType("INTEGER"); + + b.HasKey("ItemsItemId", "TagsTagId"); + + b.HasIndex("TagsTagId"); + + b.ToTable("ItemTag"); + }); + + modelBuilder.Entity("ECommerce.API.Models.Sale", b => + { + b.HasOne("ECommerce.API.Models.Item", null) + .WithMany("Sales") + .HasForeignKey("ItemId"); + }); + + modelBuilder.Entity("ECommerce.API.Models.SaleItem", b => + { + b.HasOne("ECommerce.API.Models.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ECommerce.API.Models.Sale", "Sale") + .WithMany("SoldItems") + .HasForeignKey("SaleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("Sale"); + }); + + modelBuilder.Entity("ItemTag", b => + { + b.HasOne("ECommerce.API.Models.Item", null) + .WithMany() + .HasForeignKey("ItemsItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ECommerce.API.Models.Tag", null) + .WithMany() + .HasForeignKey("TagsTagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ECommerce.API.Models.Item", b => + { + b.Navigation("Sales"); + }); + + modelBuilder.Entity("ECommerce.API.Models.Sale", b => + { + b.Navigation("SoldItems"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Ecommerce-API/ECommerce.API/Data/Migrations/20260406012101_AddedDedicatedSaleItemJoinTable.cs b/Ecommerce-API/ECommerce.API/Data/Migrations/20260406012101_AddedDedicatedSaleItemJoinTable.cs new file mode 100644 index 00000000..e98f2372 --- /dev/null +++ b/Ecommerce-API/ECommerce.API/Data/Migrations/20260406012101_AddedDedicatedSaleItemJoinTable.cs @@ -0,0 +1,113 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ECommerce.API.Data.Migrations +{ + /// + public partial class AddedDedicatedSaleItemJoinTable : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ItemSale"); + + migrationBuilder.AddColumn( + name: "ItemId", + table: "Sales", + type: "INTEGER", + nullable: true); + + migrationBuilder.CreateTable( + name: "SaleItem", + columns: table => new + { + SaleId = table.Column(type: "INTEGER", nullable: false), + ItemId = table.Column(type: "INTEGER", nullable: false), + Quantity = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_SaleItem", x => new { x.SaleId, x.ItemId }); + table.ForeignKey( + name: "FK_SaleItem_Items_ItemId", + column: x => x.ItemId, + principalTable: "Items", + principalColumn: "ItemId", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_SaleItem_Sales_SaleId", + column: x => x.SaleId, + principalTable: "Sales", + principalColumn: "SaleId", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Sales_ItemId", + table: "Sales", + column: "ItemId"); + + migrationBuilder.CreateIndex( + name: "IX_SaleItem_ItemId", + table: "SaleItem", + column: "ItemId"); + + migrationBuilder.AddForeignKey( + name: "FK_Sales_Items_ItemId", + table: "Sales", + column: "ItemId", + principalTable: "Items", + principalColumn: "ItemId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Sales_Items_ItemId", + table: "Sales"); + + migrationBuilder.DropTable( + name: "SaleItem"); + + migrationBuilder.DropIndex( + name: "IX_Sales_ItemId", + table: "Sales"); + + migrationBuilder.DropColumn( + name: "ItemId", + table: "Sales"); + + migrationBuilder.CreateTable( + name: "ItemSale", + columns: table => new + { + SalesSaleId = table.Column(type: "INTEGER", nullable: false), + SoldItemsItemId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ItemSale", x => new { x.SalesSaleId, x.SoldItemsItemId }); + table.ForeignKey( + name: "FK_ItemSale_Items_SoldItemsItemId", + column: x => x.SoldItemsItemId, + principalTable: "Items", + principalColumn: "ItemId", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_ItemSale_Sales_SalesSaleId", + column: x => x.SalesSaleId, + principalTable: "Sales", + principalColumn: "SaleId", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_ItemSale_SoldItemsItemId", + table: "ItemSale", + column: "SoldItemsItemId"); + } + } +} diff --git a/Ecommerce-API/ECommerce.API/Data/Migrations/ApiDbContextModelSnapshot.cs b/Ecommerce-API/ECommerce.API/Data/Migrations/ApiDbContextModelSnapshot.cs new file mode 100644 index 00000000..4da491fb --- /dev/null +++ b/Ecommerce-API/ECommerce.API/Data/Migrations/ApiDbContextModelSnapshot.cs @@ -0,0 +1,189 @@ +// +using System; +using ECommerce.API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace ECommerce.API.Data.Migrations +{ + [DbContext(typeof(ApiDbContext))] + partial class ApiDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.5"); + + modelBuilder.Entity("ECommerce.API.Models.Item", b => + { + b.Property("ItemId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Artist") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DeletedOnUtc") + .HasColumnType("TEXT"); + + b.Property("Format") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Genre") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Price") + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("ItemId"); + + b.ToTable("Items"); + }); + + modelBuilder.Entity("ECommerce.API.Models.Sale", b => + { + b.Property("SaleId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DeletedOnUtc") + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("INTEGER"); + + b.HasKey("SaleId"); + + b.HasIndex("ItemId"); + + b.ToTable("Sales"); + }); + + modelBuilder.Entity("ECommerce.API.Models.SaleItem", b => + { + b.Property("SaleId") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("INTEGER"); + + b.Property("Quantity") + .HasColumnType("INTEGER"); + + b.HasKey("SaleId", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("SaleItem"); + }); + + modelBuilder.Entity("ECommerce.API.Models.Tag", b => + { + b.Property("TagId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DeletedOnUtc") + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("TagName") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("TagId"); + + b.ToTable("Tags"); + }); + + modelBuilder.Entity("ItemTag", b => + { + b.Property("ItemsItemId") + .HasColumnType("INTEGER"); + + b.Property("TagsTagId") + .HasColumnType("INTEGER"); + + b.HasKey("ItemsItemId", "TagsTagId"); + + b.HasIndex("TagsTagId"); + + b.ToTable("ItemTag"); + }); + + modelBuilder.Entity("ECommerce.API.Models.Sale", b => + { + b.HasOne("ECommerce.API.Models.Item", null) + .WithMany("Sales") + .HasForeignKey("ItemId"); + }); + + modelBuilder.Entity("ECommerce.API.Models.SaleItem", b => + { + b.HasOne("ECommerce.API.Models.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ECommerce.API.Models.Sale", "Sale") + .WithMany("SoldItems") + .HasForeignKey("SaleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("Sale"); + }); + + modelBuilder.Entity("ItemTag", b => + { + b.HasOne("ECommerce.API.Models.Item", null) + .WithMany() + .HasForeignKey("ItemsItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ECommerce.API.Models.Tag", null) + .WithMany() + .HasForeignKey("TagsTagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ECommerce.API.Models.Item", b => + { + b.Navigation("Sales"); + }); + + modelBuilder.Entity("ECommerce.API.Models.Sale", b => + { + b.Navigation("SoldItems"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Ecommerce-API/ECommerce.API/Data/SoftDeleteInterceptor.cs b/Ecommerce-API/ECommerce.API/Data/SoftDeleteInterceptor.cs new file mode 100644 index 00000000..1ea665e1 --- /dev/null +++ b/Ecommerce-API/ECommerce.API/Data/SoftDeleteInterceptor.cs @@ -0,0 +1,34 @@ +using ECommerce.API.Interfaces; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; + +namespace ECommerce.API.Data; + +public sealed class SoftDeleteInterceptor : SaveChangesInterceptor +{ + public override ValueTask> SavingChangesAsync( + DbContextEventData eventData, + InterceptionResult result, + CancellationToken cancellationToken = default) + { + if (eventData.Context is null) + return base.SavingChangesAsync( + eventData, result, cancellationToken); + + var entries = + eventData + .Context + .ChangeTracker + .Entries() + .Where(e => e.State == EntityState.Deleted); + + foreach (var softDeletable in entries) + { + softDeletable.State = EntityState.Modified; + softDeletable.Entity.IsDeleted = true; + softDeletable.Entity.DeletedOnUtc = DateTime.UtcNow; + } + + return base.SavingChangesAsync(eventData, result, cancellationToken); + } +} \ No newline at end of file diff --git a/Ecommerce-API/ECommerce.API/ECommerce.API.csproj b/Ecommerce-API/ECommerce.API/ECommerce.API.csproj new file mode 100644 index 00000000..4d3e3246 --- /dev/null +++ b/Ecommerce-API/ECommerce.API/ECommerce.API.csproj @@ -0,0 +1,27 @@ + + + + net10.0 + enable + enable + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + diff --git a/Ecommerce-API/ECommerce.API/ECommerce.API.http b/Ecommerce-API/ECommerce.API/ECommerce.API.http new file mode 100644 index 00000000..4a7794ae --- /dev/null +++ b/Ecommerce-API/ECommerce.API/ECommerce.API.http @@ -0,0 +1,6 @@ +@ECommerce.API_HostAddress = http://localhost:5144 + +GET {{ECommerce.API_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/Ecommerce-API/ECommerce.API/Interfaces/IItemRepository.cs b/Ecommerce-API/ECommerce.API/Interfaces/IItemRepository.cs new file mode 100644 index 00000000..c34592db --- /dev/null +++ b/Ecommerce-API/ECommerce.API/Interfaces/IItemRepository.cs @@ -0,0 +1,11 @@ +using ECommerce.API.Models; + +namespace ECommerce.API.Interfaces; + +public interface IItemRepository +{ + public Task PostItemAsync(Item item); + public IQueryable GetItems(); + public Task GetItemByIdAsync(int id); + public Task DeleteItemAsync(Item item); +} \ No newline at end of file diff --git a/Ecommerce-API/ECommerce.API/Interfaces/IItemService.cs b/Ecommerce-API/ECommerce.API/Interfaces/IItemService.cs new file mode 100644 index 00000000..7e45f66f --- /dev/null +++ b/Ecommerce-API/ECommerce.API/Interfaces/IItemService.cs @@ -0,0 +1,12 @@ +using ECommerce.API.Models; +using ECommerce.Shared.Models; + +namespace ECommerce.API.Interfaces; + +public interface IItemService +{ + public Task PostItemAsync(CreateItemDto itemDto); + public Task> GetItemsAsync(PaginationParams paginationParams); + public Task GetItemByIdAsync(int itemId); + public Task DeleteItemByIdAsync(int itemId); +} \ No newline at end of file diff --git a/Ecommerce-API/ECommerce.API/Interfaces/ISaleRepository.cs b/Ecommerce-API/ECommerce.API/Interfaces/ISaleRepository.cs new file mode 100644 index 00000000..12bd0301 --- /dev/null +++ b/Ecommerce-API/ECommerce.API/Interfaces/ISaleRepository.cs @@ -0,0 +1,11 @@ +using ECommerce.API.Models; + +namespace ECommerce.API.Interfaces; + +public interface ISaleRepository +{ + public Task PostSale(Sale sale); + public IQueryable GetSales(); + public Task GetSaleByIdAsync(int saleId); + public Task DeleteSaleAsync(Sale sale); +} \ No newline at end of file diff --git a/Ecommerce-API/ECommerce.API/Interfaces/ISaleService.cs b/Ecommerce-API/ECommerce.API/Interfaces/ISaleService.cs new file mode 100644 index 00000000..4331cf7a --- /dev/null +++ b/Ecommerce-API/ECommerce.API/Interfaces/ISaleService.cs @@ -0,0 +1,11 @@ +using ECommerce.API.Models; +using ECommerce.Shared.Models; + +namespace ECommerce.API.Interfaces; + +public interface ISaleService +{ + public Task PostSaleAsync(List saleItems); + public Task> GetSalesAsync(PaginationParams paginationParams); + public Task DeleteSaleByIdAsync(int saleId); +} \ No newline at end of file diff --git a/Ecommerce-API/ECommerce.API/Interfaces/ISoftDeletable.cs b/Ecommerce-API/ECommerce.API/Interfaces/ISoftDeletable.cs new file mode 100644 index 00000000..e66351af --- /dev/null +++ b/Ecommerce-API/ECommerce.API/Interfaces/ISoftDeletable.cs @@ -0,0 +1,7 @@ +namespace ECommerce.API.Interfaces; + +public interface ISoftDeletable +{ + public bool IsDeleted { get; set; } + public DateTime? DeletedOnUtc { get; set; } +} \ No newline at end of file diff --git a/Ecommerce-API/ECommerce.API/Interfaces/ITagRepository.cs b/Ecommerce-API/ECommerce.API/Interfaces/ITagRepository.cs new file mode 100644 index 00000000..116e961b --- /dev/null +++ b/Ecommerce-API/ECommerce.API/Interfaces/ITagRepository.cs @@ -0,0 +1,12 @@ +using ECommerce.API.Models; + +namespace ECommerce.API.Interfaces; + +public interface ITagRepository +{ + public IQueryable GetTags(); + public Task GetTagByName(string name); + public Task GetTagById(int id); + public Task PostTagAsync(Tag tag); + public Task DeleteTagAsync(Tag tag); +} \ No newline at end of file diff --git a/Ecommerce-API/ECommerce.API/Interfaces/ITagService.cs b/Ecommerce-API/ECommerce.API/Interfaces/ITagService.cs new file mode 100644 index 00000000..ffb556a1 --- /dev/null +++ b/Ecommerce-API/ECommerce.API/Interfaces/ITagService.cs @@ -0,0 +1,12 @@ +using ECommerce.API.Models; +using ECommerce.Shared.Models; + +namespace ECommerce.API.Interfaces; + +public interface ITagService +{ + public Task> GetTagsAsync(PaginationParams paginationParams); + public Task GetTagIdByName(string name); + public Task PostTagAsync(CreateTagDto tag); + public Task DeleteTagByIdAsync(int id); +} \ No newline at end of file diff --git a/Ecommerce-API/ECommerce.API/Models/Item.cs b/Ecommerce-API/ECommerce.API/Models/Item.cs new file mode 100644 index 00000000..b7644cc1 --- /dev/null +++ b/Ecommerce-API/ECommerce.API/Models/Item.cs @@ -0,0 +1,20 @@ +using ECommerce.API.Interfaces; +using ECommerce.Shared; + +namespace ECommerce.API.Models; + +public class Item : ISoftDeletable +{ + public int ItemId { get; set; } + public required ItemFormat Format { get; set; } + public required ItemType Type { get; set; } + public required string Name { get; set; } + public required string Artist { get; set; } + public required decimal Price { get; set; } + public required string Genre { get; set; } + public List Tags { get; set; } = []; + public List Sales { get; set; } = []; + + public bool IsDeleted { get; set; } + public DateTime? DeletedOnUtc { get; set; } +} \ No newline at end of file diff --git a/Ecommerce-API/ECommerce.API/Models/PaginationParams.cs b/Ecommerce-API/ECommerce.API/Models/PaginationParams.cs new file mode 100644 index 00000000..554f20f5 --- /dev/null +++ b/Ecommerce-API/ECommerce.API/Models/PaginationParams.cs @@ -0,0 +1,19 @@ +namespace ECommerce.API.Models; + +public class PaginationParams +{ + private const int MaxPageSize = 50; + + private int _pageSize = 10; + public int PageNumber { get; set; } = 1; + + public int PageSize + { + get => _pageSize; + set => _pageSize = value > MaxPageSize ? MaxPageSize : value; + } + + public string SearchTerm { get; set; } = string.Empty; + public List SearchTags { get; set; } = []; + public string Genre { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/Ecommerce-API/ECommerce.API/Models/Sale.cs b/Ecommerce-API/ECommerce.API/Models/Sale.cs new file mode 100644 index 00000000..332bcdec --- /dev/null +++ b/Ecommerce-API/ECommerce.API/Models/Sale.cs @@ -0,0 +1,14 @@ +using ECommerce.API.Interfaces; + +namespace ECommerce.API.Models; + +public class Sale : ISoftDeletable +{ + public int SaleId { get; set; } + public List SoldItems { get; set; } = []; + + public decimal TotalPrice => SoldItems.Sum(si => si.Item.Price * si.Quantity); + + public bool IsDeleted { get; set; } + public DateTime? DeletedOnUtc { get; set; } +} \ No newline at end of file diff --git a/Ecommerce-API/ECommerce.API/Models/SaleItem.cs b/Ecommerce-API/ECommerce.API/Models/SaleItem.cs new file mode 100644 index 00000000..02b3a3f1 --- /dev/null +++ b/Ecommerce-API/ECommerce.API/Models/SaleItem.cs @@ -0,0 +1,12 @@ +namespace ECommerce.API.Models; + +public class SaleItem +{ + public int SaleId { get; set; } + public Sale Sale { get; set; } + + public int ItemId { get; set; } + public Item Item { get; set; } + + public int Quantity { get; set; } +} \ No newline at end of file diff --git a/Ecommerce-API/ECommerce.API/Models/Tag.cs b/Ecommerce-API/ECommerce.API/Models/Tag.cs new file mode 100644 index 00000000..d653734a --- /dev/null +++ b/Ecommerce-API/ECommerce.API/Models/Tag.cs @@ -0,0 +1,13 @@ +using ECommerce.API.Interfaces; + +namespace ECommerce.API.Models; + +public class Tag : ISoftDeletable +{ + public int TagId { get; set; } + public required string TagName { get; set; } + public List Items { get; } = []; + + public bool IsDeleted { get; set; } + public DateTime? DeletedOnUtc { get; set; } +} \ No newline at end of file diff --git a/Ecommerce-API/ECommerce.API/Postman/ECommerce Environment.postman_environment.json b/Ecommerce-API/ECommerce.API/Postman/ECommerce Environment.postman_environment.json new file mode 100644 index 00000000..c8cfcf79 --- /dev/null +++ b/Ecommerce-API/ECommerce.API/Postman/ECommerce Environment.postman_environment.json @@ -0,0 +1,16 @@ +{ + "id": "059a1b84-2ce3-4332-ae66-6036a4d61653", + "name": "ECommerce Environment", + "values": [ + { + "key": "BaseUrl", + "value": "", + "type": "default", + "enabled": true + } + ], + "color": null, + "_postman_variable_scope": "environment", + "_postman_exported_at": "2026-04-12T00:32:15.093Z", + "_postman_exported_using": "Postman/12.5.5" +} \ No newline at end of file diff --git a/Ecommerce-API/ECommerce.API/Postman/ECommerceApi.postman_collection.json b/Ecommerce-API/ECommerce.API/Postman/ECommerceApi.postman_collection.json new file mode 100644 index 00000000..665056a3 --- /dev/null +++ b/Ecommerce-API/ECommerce.API/Postman/ECommerceApi.postman_collection.json @@ -0,0 +1,1740 @@ +{ + "info": { + "_postman_id": "95317eb1-2d5f-447c-a306-88b644a584a3", + "name": "ECommerce API", + "description": "This API features 'Items' which are the products being sold, 'Tags' which are the description tags attached to items, and 'Sales' which are the recorded sales. Each category features Get, Post, and Delete requests. Any deletion requests are handled by the API and are turned into soft deletes to keep previous records viable, and to allow the object itself to be restored manually if it needs to be. All soft deleted objects are hidden from any Get requests, or any requests that query the database.\n\nThe base Url for the API is set to localhost port 7204, but can be changed based on individual needs.", + "schema": "https://schema.getpostman.com/json/collection/v2.0.0/collection.json" + }, + "item": [ + { + "name": "Items", + "item": [ + { + "name": "Get Items", + "id": "3796c0ee-a1d2-40a4-a603-afff495b6c1e", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{BaseUrl}}/api/Items", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "api", + "Items" + ], + "query": [ + { + "key": "PageNumber", + "value": "", + "description": "Specifies which page to return, defautls to the first page.", + "type": "text", + "disabled": true + }, + { + "key": "PageSize", + "value": "", + "description": "Defines how many items can be shown on one page, max is 50.", + "type": "text", + "disabled": true + }, + { + "key": "SearchTerm", + "value": "", + "description": "Filters results based on the search term.", + "type": "text", + "disabled": true + }, + { + "key": "Genre", + "value": "", + "description": "Filters results to only show items that match the given genre.", + "type": "text", + "disabled": true + } + ] + }, + "description": "Returns a paginated response of items taken from the database and then paginated and/or filtered according to the parameters passed from the query. If no pagination parameters are passed through the request will always default to the first page, with a page size of 10.\n\nThe search term parameter filters the results based on if the items name or artist fields contain the search term, however it does not support searching by tags. The genre parameter filters results to only contain items that match the parameter.\n\nReturns an expected 200 OK containing the paged response after a successful request, or else a 404 NotFound is expected if no results are returned." + }, + "response": [ + { + "id": "77a63230-08a0-4edd-8b4c-953ed8daed4e", + "name": "200 OK", + "originalRequest": { + "method": "GET", + "header": [], + "url": { + "raw": "{{BaseUrl}}/api/Items", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "api", + "Items" + ], + "query": [ + { + "key": "PageNumber", + "value": null, + "description": "Specifies which page to return, defautls to the first page.", + "type": "text", + "disabled": true + }, + { + "key": "PageSize", + "value": null, + "description": "Defines how many items can be shown on one page, max is 50.", + "type": "text", + "disabled": true + }, + { + "key": "SearchTerm", + "value": null, + "description": "Filters results based on the search term.", + "type": "text", + "disabled": true + }, + { + "key": "Genre", + "value": null, + "description": "Filters results to only show items that match the given genre.", + "type": "text", + "disabled": true + } + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": null, + "header": [ + { + "key": ":status", + "value": 200 + }, + { + "key": "content-type", + "value": "application/json; charset=utf-8" + }, + { + "key": "date", + "value": "Thu, 09 Apr 2026 23:31:56 GMT" + }, + { + "key": "server", + "value": "Kestrel" + } + ], + "cookie": [], + "responseTime": null, + "body": "{\n \"data\": [\n {\n \"itemId\": 1,\n \"format\": \"Vinyl\",\n \"type\": \"Album\",\n \"name\": \"Good Kid, m.A.A.d City (10th Anniversary Edition)\",\n \"artist\": \"Kendrick Lamar\",\n \"price\": 38,\n \"genre\": \"Hip-Hop\",\n \"tags\": [\n {\n \"tagName\": \"West Coast hip-hop\"\n },\n {\n \"tagName\": \"Conscious rap\"\n }\n ]\n },\n {\n \"itemId\": 2,\n \"format\": \"Vinyl\",\n \"type\": \"Album\",\n \"name\": \"The Miseducation of Lauryn Hill\",\n \"artist\": \"Lauryn Hill\",\n \"price\": 29.98,\n \"genre\": \"Neo Soul\",\n \"tags\": [\n {\n \"tagName\": \"Female empowerment\"\n },\n {\n \"tagName\": \"Heartbreak\"\n }\n ]\n }\n ],\n \"pageNumber\": 1,\n \"pageSize\": 10,\n \"totalRecords\": 2\n}" + }, + { + "id": "0f53e687-1ab3-4819-9df5-e18fc5ae666e", + "name": "404 NotFound", + "originalRequest": { + "method": "GET", + "header": [], + "url": { + "raw": "{{BaseUrl}}/api/Items?SearchTerm=404 Not Found", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "api", + "Items" + ], + "query": [ + { + "key": "PageNumber", + "value": null, + "description": "Specifies which page to return, defautls to the first page.", + "type": "text", + "disabled": true + }, + { + "key": "PageSize", + "value": null, + "description": "Defines how many items can be shown on one page, max is 50.", + "type": "text", + "disabled": true + }, + { + "key": "SearchTerm", + "value": "404 Not Found", + "description": "Filters results based on the search term.", + "type": "text" + }, + { + "key": "Genre", + "value": "", + "description": "Filters results to only show items that match the given genre.", + "type": "text", + "disabled": true + } + ] + } + }, + "status": "Not Found", + "code": 404, + "_postman_previewlanguage": null, + "header": [ + { + "key": ":status", + "value": 404 + }, + { + "key": "content-type", + "value": "application/problem+json; charset=utf-8" + }, + { + "key": "date", + "value": "Thu, 09 Apr 2026 23:38:07 GMT" + }, + { + "key": "server", + "value": "Kestrel" + } + ], + "cookie": [], + "responseTime": null, + "body": "{\n \"type\": \"https://tools.ietf.org/html/rfc9110#section-15.5.5\",\n \"title\": \"Not Found\",\n \"status\": 404,\n \"traceId\": \"00-ce9d269cf9708bda6ce731ccd3f514a3-4c3c256d11a85800-00\"\n}" + }, + { + "id": "44229407-ef6b-4c99-bae3-593fed5fa491", + "name": "Filtered response", + "originalRequest": { + "method": "GET", + "header": [], + "url": { + "raw": "{{BaseUrl}}/api/Items?SearchTerm=Kendrick lamar", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "api", + "Items" + ], + "query": [ + { + "key": "PageNumber", + "value": null, + "description": "Specifies which page to return, defautls to the first page.", + "type": "text", + "disabled": true + }, + { + "key": "PageSize", + "value": null, + "description": "Defines how many items can be shown on one page, max is 50.", + "type": "text", + "disabled": true + }, + { + "key": "SearchTerm", + "value": "Kendrick lamar", + "description": "Filters results based on the search term.", + "type": "text" + }, + { + "key": "Genre", + "value": "", + "description": "Filters results to only show items that match the given genre.", + "type": "text", + "disabled": true + } + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": null, + "header": [ + { + "key": ":status", + "value": 200 + }, + { + "key": "content-type", + "value": "application/json; charset=utf-8" + }, + { + "key": "date", + "value": "Thu, 09 Apr 2026 23:37:18 GMT" + }, + { + "key": "server", + "value": "Kestrel" + } + ], + "cookie": [], + "responseTime": null, + "body": "{\n \"data\": [\n {\n \"itemId\": 1,\n \"format\": \"Vinyl\",\n \"type\": \"Album\",\n \"name\": \"Good Kid, m.A.A.d City (10th Anniversary Edition)\",\n \"artist\": \"Kendrick Lamar\",\n \"price\": 38,\n \"genre\": \"Hip-Hop\",\n \"tags\": [\n {\n \"tagName\": \"West Coast hip-hop\"\n },\n {\n \"tagName\": \"Conscious rap\"\n }\n ]\n }\n ],\n \"pageNumber\": 1,\n \"pageSize\": 10,\n \"totalRecords\": 1\n}" + }, + { + "id": "063173b4-628b-4602-92fc-2cb27e711b7e", + "name": "Paginated response", + "originalRequest": { + "method": "GET", + "header": [], + "url": { + "raw": "{{BaseUrl}}/api/Items?PageNumber=2&PageSize=1", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "api", + "Items" + ], + "query": [ + { + "key": "PageNumber", + "value": "2", + "description": "Specifies which page to return, defautls to the first page.", + "type": "text" + }, + { + "key": "PageSize", + "value": "1", + "description": "Defines how many items can be shown on one page, max is 50.", + "type": "text" + }, + { + "key": "SearchTerm", + "value": "", + "description": "Filters results based on the search term.", + "type": "text", + "disabled": true + }, + { + "key": "Genre", + "value": "", + "description": "Filters results to only show items that match the given genre.", + "type": "text", + "disabled": true + } + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": null, + "header": [ + { + "key": ":status", + "value": 200 + }, + { + "key": "content-type", + "value": "application/json; charset=utf-8" + }, + { + "key": "date", + "value": "Thu, 09 Apr 2026 23:39:23 GMT" + }, + { + "key": "server", + "value": "Kestrel" + } + ], + "cookie": [], + "responseTime": null, + "body": "{\n \"data\": [\n {\n \"itemId\": 2,\n \"format\": \"Vinyl\",\n \"type\": \"Album\",\n \"name\": \"The Miseducation of Lauryn Hill\",\n \"artist\": \"Lauryn Hill\",\n \"price\": 29.98,\n \"genre\": \"Neo Soul\",\n \"tags\": [\n {\n \"tagName\": \"Female empowerment\"\n },\n {\n \"tagName\": \"Heartbreak\"\n }\n ]\n }\n ],\n \"pageNumber\": 2,\n \"pageSize\": 1,\n \"totalRecords\": 2\n}" + } + ] + }, + { + "name": "Get Item By ID", + "id": "4ff80a25-4a50-4faa-a9ac-2a49504b7fef", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{BaseUrl}}/api/Items/:id", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "api", + "Items", + ":id" + ], + "variable": [ + { + "key": "id", + "value": "" + } + ] + }, + "description": "Gets a specific item by an item ID. Expects a 200 OK containing the item, or a 404 NotFound response." + }, + "response": [ + { + "id": "de509c46-b4a1-4165-b26b-1daf677455f2", + "name": "200 OK", + "originalRequest": { + "method": "GET", + "header": [], + "url": { + "raw": "{{BaseUrl}}/api/Items/:id", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "api", + "Items", + ":id" + ], + "variable": [ + { + "key": "id", + "value": "1" + } + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": null, + "header": [ + { + "key": ":status", + "value": 200 + }, + { + "key": "content-type", + "value": "application/json; charset=utf-8" + }, + { + "key": "date", + "value": "Thu, 16 Apr 2026 07:54:48 GMT" + }, + { + "key": "server", + "value": "Kestrel" + } + ], + "cookie": [], + "responseTime": null, + "body": "{\n \"itemId\": 0,\n \"format\": \"Vinyl\",\n \"type\": \"Album\",\n \"name\": \"Good Kid, m.A.A.d City (10th Anniversary Edition)\",\n \"artist\": \"Kendrick Lamar\",\n \"price\": 38,\n \"genre\": \"Hip-Hop\",\n \"tags\": [\n {\n \"tagName\": \"West Coast hip-hop\"\n },\n {\n \"tagName\": \"Conscious rap\"\n }\n ]\n}" + }, + { + "id": "2bca7fe6-b00c-4748-b034-4cd2cab4c1c0", + "name": "404 NotFound", + "originalRequest": { + "method": "GET", + "header": [], + "url": { + "raw": "{{BaseUrl}}/api/Items/:id", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "api", + "Items", + ":id" + ], + "variable": [ + { + "key": "id", + "value": "999" + } + ] + } + }, + "status": "Not Found", + "code": 404, + "_postman_previewlanguage": null, + "header": [ + { + "key": ":status", + "value": 404 + }, + { + "key": "content-type", + "value": "application/problem+json; charset=utf-8" + }, + { + "key": "date", + "value": "Thu, 16 Apr 2026 07:55:07 GMT" + }, + { + "key": "server", + "value": "Kestrel" + } + ], + "cookie": [], + "responseTime": null, + "body": "{\n \"type\": \"https://tools.ietf.org/html/rfc9110#section-15.5.5\",\n \"title\": \"Not Found\",\n \"status\": 404,\n \"traceId\": \"00-8f0cd1cfe1d341dba36e132074e81f2f-b60512301a66d50e-00\"\n}" + } + ] + }, + { + "name": "Post Item", + "id": "88dbe39a-d005-4ada-9125-9a66395b4537", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"format\": \"Cd\",\r\n \"type\": \"Album\",\r\n \"name\": \"string\",\r\n \"artist\": \"string\",\r\n \"price\": 0,\r\n \"genre\": \"string\",\r\n \"tags\": [\r\n {\r\n \"tagName\": \"string\"\r\n }\r\n ]\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": "{{BaseUrl}}/api/items", + "description": "Posts an item based on the JSON provided in the body.\n\n- Format can be: Cd, Vinyl, Digital.\n \n- Type can be Album, Single, Mixtape.\n \n- Name, Artist, and Genre are basic strings.\n \n- Price is a decimal type, and supports decimals\n \n- Tags is a list of tags, which are linked to items through a many to many relationship, and contains a TagName variable.\n \n\nReturns an expected 201 Created after a successful creation, or a 400 BadRequest or 500 Internal Server Error status." + }, + "response": [ + { + "id": "e32303ad-eb0a-4574-bd87-3ed14d5cac86", + "name": "201 Created", + "originalRequest": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"format\": \"Cd\",\r\n \"type\": \"Album\",\r\n \"name\": \"The Romantic\",\r\n \"artist\": \"Bruno Mars\",\r\n \"price\": 14.98,\r\n \"genre\": \"R&B\",\r\n \"tags\": [\r\n {\r\n \"tagName\": \"Romantic\"\r\n },\r\n {\r\n \"tagName\": \"Emotional\"\r\n }\r\n ]\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": "{{BaseUrl}}/api/items" + }, + "status": "Created", + "code": 201, + "_postman_previewlanguage": null, + "header": [ + { + "key": ":status", + "value": 201 + }, + { + "key": "date", + "value": "Thu, 09 Apr 2026 23:57:40 GMT" + }, + { + "key": "server", + "value": "Kestrel" + }, + { + "key": "content-length", + "value": "0" + } + ], + "cookie": [], + "responseTime": null, + "body": null + }, + { + "id": "bec668b8-150a-4562-a41c-584785f1df2f", + "name": "400 BadRequest", + "originalRequest": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"format\": \"Live concert\",\r\n \"type\": \"Concert\",\r\n \"name\": \"The Romantic\",\r\n \"artist\": \"Bad Bunny\",\r\n \"price\": 122.80,\r\n \"genre\": \"Reggaeton\",\r\n \"tags\": [\r\n {\r\n \"tagName\": \"Latin Trap\"\r\n },\r\n {\r\n \"tagName\": \"Latin Pop\"\r\n }\r\n ]\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": "{{BaseUrl}}/api/items" + }, + "status": "Bad Request", + "code": 400, + "_postman_previewlanguage": null, + "header": [ + { + "key": ":status", + "value": 400 + }, + { + "key": "content-type", + "value": "application/problem+json; charset=utf-8" + }, + { + "key": "date", + "value": "Fri, 10 Apr 2026 00:04:21 GMT" + }, + { + "key": "server", + "value": "Kestrel" + } + ], + "cookie": [], + "responseTime": null, + "body": "{\n \"type\": \"https://tools.ietf.org/html/rfc9110#section-15.5.1\",\n \"title\": \"One or more validation errors occurred.\",\n \"status\": 400,\n \"errors\": {\n \"itemDto\": [\n \"The itemDto field is required.\"\n ],\n \"$.format\": [\n \"The JSON value could not be converted to ECommerce.Shared.ItemFormat. Path: $.format | LineNumber: 1 | BytePositionInLine: 26.\"\n ]\n },\n \"traceId\": \"00-6b4667926ecdb776119933530b730dde-1db86ba7c6490507-00\"\n}" + } + ] + }, + { + "name": "Delete Item", + "id": "00294314-fb62-4daa-807b-cdef5f52a96b", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "method": "DELETE", + "header": [], + "url": { + "raw": "{{BaseUrl}}/api/Items/:id", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "api", + "Items", + ":id" + ], + "variable": [ + { + "key": "id", + "value": "" + } + ] + }, + "description": "Soft deletes an item, marking it as deleted and hiding it from all get requests while keeping records viable. Takes an item ID integer to define which item to delete.\n\nReturns a 201 NoContent status code upon a successful deletion, or else a 404 NotFound or 500 Internal Server Error status upon an unsuccessful attempt." + }, + "response": [ + { + "id": "ebcd9e84-d6e1-4aba-9179-8e1a2a5a025b", + "name": "204 NoContent", + "originalRequest": { + "method": "DELETE", + "header": [], + "url": { + "raw": "{{BaseUrl}}/api/Items/:id", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "api", + "Items", + ":id" + ], + "variable": [ + { + "key": "id", + "value": "5" + } + ] + } + }, + "status": "No Content", + "code": 204, + "_postman_previewlanguage": null, + "header": [ + { + "key": ":status", + "value": 204 + }, + { + "key": "date", + "value": "Mon, 20 Apr 2026 10:42:08 GMT" + }, + { + "key": "server", + "value": "Kestrel" + } + ], + "cookie": [], + "responseTime": null, + "body": null + }, + { + "id": "399f1977-6902-4957-865c-128e4777f64d", + "name": "404 NotFound", + "originalRequest": { + "method": "DELETE", + "header": [], + "url": { + "raw": "{{BaseUrl}}/api/Items/:id", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "api", + "Items", + ":id" + ], + "variable": [ + { + "key": "id", + "value": "999" + } + ] + } + }, + "status": "Not Found", + "code": 404, + "_postman_previewlanguage": null, + "header": [ + { + "key": ":status", + "value": 404 + }, + { + "key": "content-type", + "value": "application/problem+json; charset=utf-8" + }, + { + "key": "date", + "value": "Mon, 20 Apr 2026 10:42:23 GMT" + }, + { + "key": "server", + "value": "Kestrel" + } + ], + "cookie": [], + "responseTime": null, + "body": "{\n \"type\": \"https://tools.ietf.org/html/rfc9110#section-15.5.5\",\n \"title\": \"Not Found\",\n \"status\": 404,\n \"traceId\": \"00-6d6b78edb93b0b207c91bf694cf3ce52-a21e5d2ba1388128-00\"\n}" + } + ] + } + ], + "id": "df95d7aa-d9a0-499a-9d86-233203865d48", + "description": "This folder stores the requests to the API involving the products of the E-Commerce site, featuring Post, Get, and Delete requests." + }, + { + "name": "Tags", + "item": [ + { + "name": "Get Tag", + "id": "0f8c0c81-d85c-47a3-a148-4eecbc07b44e", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{BaseUrl}}/api/Tags", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "api", + "Tags" + ], + "query": [ + { + "key": "PageNumber", + "value": "", + "description": "Specifies which page to return, defautls to the first page.", + "type": "text", + "disabled": true + }, + { + "key": "PageSize", + "value": "", + "description": "Defines how many items can be shown on one page, max is 50.", + "type": "text", + "disabled": true + }, + { + "key": "SearchTerm", + "value": "", + "description": "Filters results based on the search term.", + "type": "text", + "disabled": true + } + ] + }, + "description": "Returns a paginated response of tags taken from the database and then paginated and/or filtered according to the parameters passed from the query. If no pagination parameters are passed through the request will always default to the first page, with a page size of 10.\n\nThe search term parameter filters the results based on if tag name contains the search term.\n\nExpects a return of 200 OK containing the paged response, or else 404 NotFound if no results we're returned." + }, + "response": [ + { + "id": "7046c183-1c3d-4b52-8100-40f87fc70139", + "name": "200 OK", + "originalRequest": { + "method": "GET", + "header": [], + "url": { + "raw": "{{BaseUrl}}/api/Tags", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "api", + "Tags" + ], + "query": [ + { + "key": "PageNumber", + "value": "", + "description": "Specifies which page to return, defautls to the first page.", + "type": "text", + "disabled": true + }, + { + "key": "PageSize", + "value": "", + "description": "Defines how many items can be shown on one page, max is 50.", + "type": "text", + "disabled": true + }, + { + "key": "SearchTerm", + "value": "", + "description": "Filters results based on the search term.", + "type": "text", + "disabled": true + } + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": null, + "header": [ + { + "key": ":status", + "value": 200 + }, + { + "key": "content-type", + "value": "application/json; charset=utf-8" + }, + { + "key": "date", + "value": "Fri, 10 Apr 2026 00:15:12 GMT" + }, + { + "key": "server", + "value": "Kestrel" + } + ], + "cookie": [], + "responseTime": null, + "body": "{\n \"data\": [\n {\n \"tagName\": \"Conscious rap\"\n },\n {\n \"tagName\": \"Emotional\"\n },\n {\n \"tagName\": \"Female empowerment\"\n },\n {\n \"tagName\": \"Heartbreak\"\n },\n {\n \"tagName\": \"Romantic\"\n },\n {\n \"tagName\": \"West Coast hip-hop\"\n }\n ],\n \"pageNumber\": 1,\n \"pageSize\": 10,\n \"totalRecords\": 6\n}" + }, + { + "id": "fb9cf5df-7277-4f4b-a926-58a609731a57", + "name": "404 NotFound", + "originalRequest": { + "method": "GET", + "header": [], + "url": { + "raw": "{{BaseUrl}}/api/Tags?SearchTerm=404 NotFound", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "api", + "Tags" + ], + "query": [ + { + "key": "PageNumber", + "value": "", + "description": "Specifies which page to return, defautls to the first page.", + "type": "text", + "disabled": true + }, + { + "key": "PageSize", + "value": "", + "description": "Defines how many items can be shown on one page, max is 50.", + "type": "text", + "disabled": true + }, + { + "key": "SearchTerm", + "value": "404 NotFound", + "description": "Filters results based on the search term.", + "type": "text" + } + ] + } + }, + "status": "Not Found", + "code": 404, + "_postman_previewlanguage": null, + "header": [ + { + "key": ":status", + "value": 404 + }, + { + "key": "content-type", + "value": "application/problem+json; charset=utf-8" + }, + { + "key": "date", + "value": "Fri, 10 Apr 2026 00:16:31 GMT" + }, + { + "key": "server", + "value": "Kestrel" + } + ], + "cookie": [], + "responseTime": null, + "body": "{\n \"type\": \"https://tools.ietf.org/html/rfc9110#section-15.5.5\",\n \"title\": \"Not Found\",\n \"status\": 404,\n \"traceId\": \"00-a3e47d2ade03b78de9c94146e7983f1e-fb1420600a7af10a-00\"\n}" + }, + { + "id": "929208a1-7ca7-43dc-8efb-168fb5728199", + "name": "Filtered response", + "originalRequest": { + "method": "GET", + "header": [], + "url": { + "raw": "{{BaseUrl}}/api/Tags?SearchTerm=romantic", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "api", + "Tags" + ], + "query": [ + { + "key": "PageNumber", + "value": "", + "description": "Specifies which page to return, defautls to the first page.", + "type": "text", + "disabled": true + }, + { + "key": "PageSize", + "value": "", + "description": "Defines how many items can be shown on one page, max is 50.", + "type": "text", + "disabled": true + }, + { + "key": "SearchTerm", + "value": "romantic", + "description": "Filters results based on the search term.", + "type": "text" + } + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": null, + "header": [ + { + "key": ":status", + "value": 200 + }, + { + "key": "content-type", + "value": "application/json; charset=utf-8" + }, + { + "key": "date", + "value": "Fri, 10 Apr 2026 00:18:08 GMT" + }, + { + "key": "server", + "value": "Kestrel" + } + ], + "cookie": [], + "responseTime": null, + "body": "{\n \"data\": [\n {\n \"tagName\": \"Romantic\"\n }\n ],\n \"pageNumber\": 1,\n \"pageSize\": 10,\n \"totalRecords\": 1\n}" + }, + { + "id": "6bf97e42-890f-4ac8-9b4c-923e8ae8466e", + "name": "Paginated response", + "originalRequest": { + "method": "GET", + "header": [], + "url": { + "raw": "{{BaseUrl}}/api/Tags?PageNumber=2&PageSize=2", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "api", + "Tags" + ], + "query": [ + { + "key": "PageNumber", + "value": "2", + "description": "Specifies which page to return, defautls to the first page.", + "type": "text" + }, + { + "key": "PageSize", + "value": "2", + "description": "Defines how many items can be shown on one page, max is 50.", + "type": "text" + }, + { + "key": "SearchTerm", + "value": "", + "description": "Filters results based on the search term.", + "type": "text", + "disabled": true + } + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": null, + "header": [ + { + "key": ":status", + "value": 200 + }, + { + "key": "content-type", + "value": "application/json; charset=utf-8" + }, + { + "key": "date", + "value": "Fri, 10 Apr 2026 00:19:10 GMT" + }, + { + "key": "server", + "value": "Kestrel" + } + ], + "cookie": [], + "responseTime": null, + "body": "{\n \"data\": [\n {\n \"tagName\": \"Female empowerment\"\n },\n {\n \"tagName\": \"Heartbreak\"\n }\n ],\n \"pageNumber\": 2,\n \"pageSize\": 2,\n \"totalRecords\": 6\n}" + } + ] + }, + { + "name": "Post Tag", + "id": "a7b59901-dbda-4a05-9643-016abb1b10a1", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"tagName\": \"string\"\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": "{{BaseUrl}}/api/Tags", + "description": "Posts an item based on the JSON provided in the body.\n\n- Tag name is a string and represents the name given to the tag.\n \n\nUpon a successful creation the request expects a return of a 201 Created status code, or possibly a 404 BadRequest." + }, + "response": [ + { + "id": "e1cffd7b-8214-4430-99ee-4c0a47fbbdb2", + "name": "201 Created", + "originalRequest": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"tagName\": \"Test tag\"\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": "{{BaseUrl}}/api/Tags" + }, + "status": "Created", + "code": 201, + "_postman_previewlanguage": null, + "header": [ + { + "key": ":status", + "value": 201 + }, + { + "key": "date", + "value": "Fri, 10 Apr 2026 00:25:30 GMT" + }, + { + "key": "server", + "value": "Kestrel" + }, + { + "key": "content-length", + "value": "0" + } + ], + "cookie": [], + "responseTime": null, + "body": null + } + ] + }, + { + "name": "Delete Tag", + "id": "8b0d2fdc-a006-4866-958e-6bf0a8940dc5", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "method": "DELETE", + "header": [], + "url": { + "raw": "{{BaseUrl}}/api/Tags/:id", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "api", + "Tags", + ":id" + ], + "variable": [ + { + "key": "id", + "value": "" + } + ] + }, + "description": "Soft deletes a tag, hiding it from any Get requests while protecting it within the database and keeping past records viable.\n\nTakes an integer tag ID to determine which object to delete.\n\nExpects a 204 NoContent return upon a successful deletion request, or either a 404 NotFound, or 500 Internal Serverr Error status code upon an unsuccessful attempt." + }, + "response": [ + { + "id": "40e36275-1227-4764-a701-9e6b9c5dc266", + "name": "204 NoContent", + "originalRequest": { + "method": "DELETE", + "header": [], + "url": { + "raw": "{{BaseUrl}}/api/Tags/:id", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "api", + "Tags", + ":id" + ], + "variable": [ + { + "key": "id", + "value": "13" + } + ] + } + }, + "status": "No Content", + "code": 204, + "_postman_previewlanguage": null, + "header": [ + { + "key": ":status", + "value": 204 + }, + { + "key": "date", + "value": "Mon, 20 Apr 2026 11:02:55 GMT" + }, + { + "key": "server", + "value": "Kestrel" + } + ], + "cookie": [], + "responseTime": null, + "body": null + }, + { + "id": "4e407965-00d3-429f-b937-606730a74e31", + "name": "404 NotFound", + "originalRequest": { + "method": "DELETE", + "header": [], + "url": { + "raw": "{{BaseUrl}}/api/Tags/:id", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "api", + "Tags", + ":id" + ], + "variable": [ + { + "key": "id", + "value": "999" + } + ] + } + }, + "status": "Not Found", + "code": 404, + "_postman_previewlanguage": null, + "header": [ + { + "key": ":status", + "value": 404 + }, + { + "key": "content-type", + "value": "application/problem+json; charset=utf-8" + }, + { + "key": "date", + "value": "Mon, 20 Apr 2026 11:03:11 GMT" + }, + { + "key": "server", + "value": "Kestrel" + } + ], + "cookie": [], + "responseTime": null, + "body": "{\n \"type\": \"https://tools.ietf.org/html/rfc9110#section-15.5.5\",\n \"title\": \"Not Found\",\n \"status\": 404,\n \"traceId\": \"00-d2b8b0df0ddab0649ac8a20aac9cd83f-4e0370a2b8aa0df3-00\"\n}" + } + ] + }, + { + "name": "Get Tag ID", + "id": "f38e4fc0-6e1b-4be2-8e8e-f88e36905c19", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{BaseUrl}}/api/tags/:name", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "api", + "tags", + ":name" + ], + "variable": [ + { + "key": "name", + "value": "", + "description": "The name of the tag for which to get the ID" + } + ] + }, + "description": "Returns a tag's ID value after searching the database for the tag via the tag name, passed through as a parameter. Expects a 200 OK containing the ID value, or a 404 NotFound response status code." + }, + "response": [ + { + "id": "e4104432-9171-4fc9-996f-216d2e09b4bd", + "name": "200 OK", + "originalRequest": { + "method": "GET", + "header": [], + "url": { + "raw": "{{BaseUrl}}/api/tags/:name", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "api", + "tags", + ":name" + ], + "variable": [ + { + "key": "name", + "value": "Heartbreak", + "description": "The name of the tag for which to get the ID" + } + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": null, + "header": [ + { + "key": ":status", + "value": 200 + }, + { + "key": "content-type", + "value": "application/json; charset=utf-8" + }, + { + "key": "date", + "value": "Sat, 18 Apr 2026 15:43:23 GMT" + }, + { + "key": "server", + "value": "Kestrel" + } + ], + "cookie": [], + "responseTime": null, + "body": "4" + }, + { + "id": "ab6ed250-2604-4435-a691-283653fe1bcd", + "name": "404 NotFound", + "originalRequest": { + "method": "GET", + "header": [], + "url": { + "raw": "{{BaseUrl}}/api/tags/:name", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "api", + "tags", + ":name" + ], + "variable": [ + { + "key": "name", + "value": "unknown value", + "description": "The name of the tag for which to get the ID" + } + ] + } + }, + "status": "Not Found", + "code": 404, + "_postman_previewlanguage": null, + "header": [ + { + "key": ":status", + "value": 404 + }, + { + "key": "content-type", + "value": "application/problem+json; charset=utf-8" + }, + { + "key": "date", + "value": "Sat, 18 Apr 2026 15:43:47 GMT" + }, + { + "key": "server", + "value": "Kestrel" + } + ], + "cookie": [], + "responseTime": null, + "body": "{\n \"type\": \"https://tools.ietf.org/html/rfc9110#section-15.5.5\",\n \"title\": \"Not Found\",\n \"status\": 404,\n \"traceId\": \"00-7b313b2048c3221dcd548b7e2ec28311-1badc791e401cf13-00\"\n}" + } + ] + } + ], + "id": "3716f07a-32c1-481e-9912-d6251ea9a220", + "description": "This folder contains API requests directed at the tags, including Get, Post, and Delete functions." + }, + { + "name": "Sales", + "item": [ + { + "name": "Get Sales", + "id": "607ef220-0c9c-4df9-b3d8-d53a33e4c3e5", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{BaseUrl}}/api/Sales", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "api", + "Sales" + ], + "query": [ + { + "key": "PageNumber", + "value": "", + "description": "Specifies which page to return, defautls to the first page.", + "type": "text", + "disabled": true + }, + { + "key": "PageSize", + "value": "", + "description": "Defines how many items can be shown on one page, max is 50.", + "type": "text", + "disabled": true + } + ] + }, + "description": "Sends a Get request with the pagination parameters:\n\n- Page Number, determining what page you're requesting.\n \n- Page Size, defines how many items to display per page, with 50 being the max.\n \n\nThis request expects a returned status code of 200 OK, containing the paginated response of sales." + }, + "response": [ + { + "id": "66c42b55-36c3-4aa6-b77a-4985020bf991", + "name": "200 OK", + "originalRequest": { + "method": "GET", + "header": [], + "url": { + "raw": "{{BaseUrl}}/api/Sales", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "api", + "Sales" + ], + "query": [ + { + "key": "PageNumber", + "value": "", + "description": "Specifies which page to return, defautls to the first page.", + "type": "text", + "disabled": true + }, + { + "key": "PageSize", + "value": "", + "description": "Defines how many items can be shown on one page, max is 50.", + "type": "text", + "disabled": true + } + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": null, + "header": [ + { + "key": ":status", + "value": 200 + }, + { + "key": "content-type", + "value": "application/json; charset=utf-8" + }, + { + "key": "date", + "value": "Sun, 12 Apr 2026 00:03:51 GMT" + }, + { + "key": "server", + "value": "Kestrel" + } + ], + "cookie": [], + "responseTime": null, + "body": "{\n \"data\": [\n {\n \"saleId\": 6,\n \"soldItems\": [\n {\n \"quantity\": 1,\n \"item\": {\n \"itemId\": 1,\n \"format\": \"Vinyl\",\n \"type\": \"Album\",\n \"name\": \"Good Kid, m.A.A.d City (10th Anniversary Edition)\",\n \"artist\": \"Kendrick Lamar\",\n \"price\": 38,\n \"genre\": \"Hip-Hop\",\n \"tags\": [\n {\n \"tagName\": \"West Coast hip-hop\"\n },\n {\n \"tagName\": \"Conscious rap\"\n }\n ]\n }\n }\n ],\n \"totalPrice\": 38\n },\n {\n \"saleId\": 7,\n \"soldItems\": [\n {\n \"quantity\": 2,\n \"item\": {\n \"itemId\": 1,\n \"format\": \"Vinyl\",\n \"type\": \"Album\",\n \"name\": \"Good Kid, m.A.A.d City (10th Anniversary Edition)\",\n \"artist\": \"Kendrick Lamar\",\n \"price\": 38,\n \"genre\": \"Hip-Hop\",\n \"tags\": [\n {\n \"tagName\": \"West Coast hip-hop\"\n },\n {\n \"tagName\": \"Conscious rap\"\n }\n ]\n }\n },\n {\n \"quantity\": 1,\n \"item\": {\n \"itemId\": 2,\n \"format\": \"Vinyl\",\n \"type\": \"Album\",\n \"name\": \"The Miseducation of Lauryn Hill\",\n \"artist\": \"Lauryn Hill\",\n \"price\": 29.98,\n \"genre\": \"Neo Soul\",\n \"tags\": [\n {\n \"tagName\": \"Female empowerment\"\n },\n {\n \"tagName\": \"Heartbreak\"\n }\n ]\n }\n }\n ],\n \"totalPrice\": 105.98\n }\n ],\n \"pageNumber\": 1,\n \"pageSize\": 10,\n \"totalRecords\": 2\n}" + }, + { + "id": "702a57ab-a2a3-4c07-9a6c-0b6116b8f3d0", + "name": "Paginated response", + "originalRequest": { + "method": "GET", + "header": [], + "url": { + "raw": "{{BaseUrl}}/api/Sales?PageNumber=2&PageSize=1", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "api", + "Sales" + ], + "query": [ + { + "key": "PageNumber", + "value": "2", + "description": "Specifies which page to return, defautls to the first page.", + "type": "text" + }, + { + "key": "PageSize", + "value": "1", + "description": "Defines how many items can be shown on one page, max is 50.", + "type": "text" + } + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": null, + "header": [ + { + "key": ":status", + "value": 200 + }, + { + "key": "content-type", + "value": "application/json; charset=utf-8" + }, + { + "key": "date", + "value": "Sun, 12 Apr 2026 00:04:15 GMT" + }, + { + "key": "server", + "value": "Kestrel" + } + ], + "cookie": [], + "responseTime": null, + "body": "{\n \"data\": [\n {\n \"saleId\": 7,\n \"soldItems\": [\n {\n \"quantity\": 2,\n \"item\": {\n \"itemId\": 1,\n \"format\": \"Vinyl\",\n \"type\": \"Album\",\n \"name\": \"Good Kid, m.A.A.d City (10th Anniversary Edition)\",\n \"artist\": \"Kendrick Lamar\",\n \"price\": 38,\n \"genre\": \"Hip-Hop\",\n \"tags\": [\n {\n \"tagName\": \"West Coast hip-hop\"\n },\n {\n \"tagName\": \"Conscious rap\"\n }\n ]\n }\n },\n {\n \"quantity\": 1,\n \"item\": {\n \"itemId\": 2,\n \"format\": \"Vinyl\",\n \"type\": \"Album\",\n \"name\": \"The Miseducation of Lauryn Hill\",\n \"artist\": \"Lauryn Hill\",\n \"price\": 29.98,\n \"genre\": \"Neo Soul\",\n \"tags\": [\n {\n \"tagName\": \"Female empowerment\"\n },\n {\n \"tagName\": \"Heartbreak\"\n }\n ]\n }\n }\n ],\n \"totalPrice\": 105.98\n }\n ],\n \"pageNumber\": 2,\n \"pageSize\": 1,\n \"totalRecords\": 2\n}" + } + ] + }, + { + "name": "Post Sale", + "id": "551b6458-b1fc-45e8-bdb4-59132d2c4698", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "[\r\n {\r\n \"itemId\": 0,\r\n \"quantity\": 0\r\n }\r\n]", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": "{{BaseUrl}}/api/Sales", + "description": "This request sends a Post request to the API. The JSON body accepts a list of a CreateSaleItemDto model, which includes the itemId and quantity properties. The itemId property represents the item's ID value within the database, and the quantity property represents the quantity of that item being bought, which allows for multiple purchases of one item within the same sale.\n\nThe json uses a list to allow the passing through of multiple individual items to be stored within one sale record.\n\nThis request expects a returned status code of 204 NoContent after a successful post request, or a 404 NotFound when the item's ID is not found within the database, or has been deleted." + }, + "response": [ + { + "id": "1a05439f-33c3-45b2-b244-09dc622c22a5", + "name": "204 NoContent", + "originalRequest": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "[\r\n {\r\n \"itemId\": 1,\r\n \"quantity\": 2\r\n },\r\n {\r\n \"itemId\": 2,\r\n \"quantity\": 1\r\n }\r\n]", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": "{{BaseUrl}}/api/Sales" + }, + "status": "No Content", + "code": 204, + "_postman_previewlanguage": null, + "header": [ + { + "key": ":status", + "value": 204 + }, + { + "key": "date", + "value": "Sun, 12 Apr 2026 00:15:57 GMT" + }, + { + "key": "server", + "value": "Kestrel" + } + ], + "cookie": [], + "responseTime": null, + "body": null + }, + { + "id": "74975254-996b-42c2-9e92-5a5de31d5682", + "name": "404 NotFound", + "originalRequest": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "[\r\n {\r\n \"itemId\": 0,\r\n \"quantity\": 0\r\n }\r\n]", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": "{{BaseUrl}}/api/Sales" + }, + "status": "Not Found", + "code": 404, + "_postman_previewlanguage": null, + "header": [ + { + "key": ":status", + "value": 404 + }, + { + "key": "content-type", + "value": "application/problem+json; charset=utf-8" + }, + { + "key": "date", + "value": "Sun, 12 Apr 2026 00:16:53 GMT" + }, + { + "key": "server", + "value": "Kestrel" + } + ], + "cookie": [], + "responseTime": null, + "body": "{\n \"type\": \"https://tools.ietf.org/html/rfc9110#section-15.5.5\",\n \"title\": \"Not Found\",\n \"status\": 404,\n \"traceId\": \"00-f19ffa5ce3b0f11099c088c8785af36c-b255750a4335c961-00\"\n}" + } + ] + }, + { + "name": "Delete Sale", + "id": "42d72fd9-44b0-4b32-8f15-555dd63e993a", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "method": "DELETE", + "header": [], + "url": { + "raw": "{{BaseUrl}}/api/Sales?saleId=999", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "api", + "Sales" + ], + "query": [ + { + "key": "saleId", + "value": "999", + "description": "Dictates which item will be deleted" + } + ] + }, + "description": "Sends a Delete request to the API using the sale ID passed through the parameters. All deletions are soft deletes for record viability preservation.\n\nThis request expects a returned status code of 204 NoContent after a successful deletion, or a 404 NotFound if the ID is not in the database, or has already been deleted." + }, + "response": [ + { + "id": "1732ce3c-c762-4b1b-b719-bd61f00eee9c", + "name": "204 NoContent", + "originalRequest": { + "method": "DELETE", + "header": [], + "url": { + "raw": "{{BaseUrl}}/api/Sales?saleId=9", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "api", + "Sales" + ], + "query": [ + { + "key": "saleId", + "value": "9", + "description": "Dictates which item will be deleted" + } + ] + } + }, + "status": "No Content", + "code": 204, + "_postman_previewlanguage": null, + "header": [ + { + "key": ":status", + "value": 204 + }, + { + "key": "date", + "value": "Sun, 12 Apr 2026 00:21:12 GMT" + }, + { + "key": "server", + "value": "Kestrel" + } + ], + "cookie": [], + "responseTime": null, + "body": null + }, + { + "id": "700c3b64-7b77-4719-957a-90712ebaaa25", + "name": "404 NotFound", + "originalRequest": { + "method": "DELETE", + "header": [], + "url": { + "raw": "{{BaseUrl}}/api/Sales?saleId=999", + "host": [ + "{{BaseUrl}}" + ], + "path": [ + "api", + "Sales" + ], + "query": [ + { + "key": "saleId", + "value": "999", + "description": "Dictates which item will be deleted" + } + ] + } + }, + "status": "Not Found", + "code": 404, + "_postman_previewlanguage": null, + "header": [ + { + "key": ":status", + "value": 404 + }, + { + "key": "content-type", + "value": "application/problem+json; charset=utf-8" + }, + { + "key": "date", + "value": "Sun, 12 Apr 2026 00:22:29 GMT" + }, + { + "key": "server", + "value": "Kestrel" + } + ], + "cookie": [], + "responseTime": null, + "body": "{\n \"type\": \"https://tools.ietf.org/html/rfc9110#section-15.5.5\",\n \"title\": \"Not Found\",\n \"status\": 404,\n \"traceId\": \"00-38c7136fd6bc7934c58979fb00285b21-12953d64324a3541-00\"\n}" + } + ] + } + ], + "id": "762e118d-bb66-43d8-98ca-841ce19e4c3d", + "description": "This folder contains all the API requests pertaining to the E-Commerce site's sales, including Get, Post, and Delete requests." + } + ] +} \ No newline at end of file diff --git a/Ecommerce-API/ECommerce.API/Program.cs b/Ecommerce-API/ECommerce.API/Program.cs new file mode 100644 index 00000000..84c67834 --- /dev/null +++ b/Ecommerce-API/ECommerce.API/Program.cs @@ -0,0 +1,64 @@ +using System.Text.Json.Serialization; +using ECommerce.API.Data; +using ECommerce.API.Interfaces; +using ECommerce.API.Repositories; +using ECommerce.API.Services; +using Microsoft.EntityFrameworkCore; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddControllers() + .AddJsonOptions(options => { options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); }); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +builder.Services.AddSingleton(); + +builder.Services.AddDbContext((sp, options) => + options + .UseSqlite(DbConfig.GetConnectionString()) + .AddInterceptors(sp.GetRequiredService())); + +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +using (var scope = app.Services.CreateScope()) +{ + var db = scope.ServiceProvider.GetRequiredService(); + + var dbDirectory = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "ECommerce"); + Directory.CreateDirectory(dbDirectory); + + var isFirstRun = !await db.Database.CanConnectAsync(); + + await db.Database.MigrateAsync(); + + if (isFirstRun) await DbSeeder.SeedItemsAsync(db); +} + +app.UseHttpsRedirection(); +app.UseAuthorization(); +app.MapControllers(); +app.UseExceptionHandler(error => error.Run(async context => +{ + context.Response.StatusCode = 500; + await context.Response.WriteAsync("An unexpected exception occurred during runtime."); +})); + +app.Run(); \ No newline at end of file diff --git a/Ecommerce-API/ECommerce.API/Properties/launchSettings.json b/Ecommerce-API/ECommerce.API/Properties/launchSettings.json new file mode 100644 index 00000000..0acbdeab --- /dev/null +++ b/Ecommerce-API/ECommerce.API/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5144", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7204;http://localhost:5144", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/Ecommerce-API/ECommerce.API/Repositories/ItemRepository.cs b/Ecommerce-API/ECommerce.API/Repositories/ItemRepository.cs new file mode 100644 index 00000000..3e326eb2 --- /dev/null +++ b/Ecommerce-API/ECommerce.API/Repositories/ItemRepository.cs @@ -0,0 +1,34 @@ +using ECommerce.API.Data; +using ECommerce.API.Interfaces; +using ECommerce.API.Models; +using Microsoft.EntityFrameworkCore; + +namespace ECommerce.API.Repositories; + +public class ItemRepository(ApiDbContext db) : IItemRepository +{ + public async Task PostItemAsync(Item item) + { + db.Items.Add(item); + await db.SaveChangesAsync(); + } + + public IQueryable GetItems() + { + return db.Items + .Include(i => i.Tags) + .OrderBy(i => i.ItemId) + .AsQueryable(); + } + + public async Task GetItemByIdAsync(int id) + { + return await db.Items.FindAsync(id); + } + + public async Task DeleteItemAsync(Item item) + { + db.Items.Remove(item); + await db.SaveChangesAsync(); + } +} \ No newline at end of file diff --git a/Ecommerce-API/ECommerce.API/Repositories/SaleRepository.cs b/Ecommerce-API/ECommerce.API/Repositories/SaleRepository.cs new file mode 100644 index 00000000..319d952a --- /dev/null +++ b/Ecommerce-API/ECommerce.API/Repositories/SaleRepository.cs @@ -0,0 +1,34 @@ +using ECommerce.API.Data; +using ECommerce.API.Interfaces; +using ECommerce.API.Models; +using Microsoft.EntityFrameworkCore; + +namespace ECommerce.API.Repositories; + +public class SaleRepository(ApiDbContext db) : ISaleRepository +{ + public async Task PostSale(Sale sale) + { + db.Sales.Add(sale); + await db.SaveChangesAsync(); + } + + public IQueryable GetSales() + { + return db.Sales + .Include(s => s.SoldItems) + .OrderBy(s => s.SaleId) + .AsQueryable(); + } + + public async Task GetSaleByIdAsync(int saleId) + { + return await db.Sales.FindAsync(saleId); + } + + public async Task DeleteSaleAsync(Sale sale) + { + db.Sales.Remove(sale); + await db.SaveChangesAsync(); + } +} \ No newline at end of file diff --git a/Ecommerce-API/ECommerce.API/Repositories/TagRepository.cs b/Ecommerce-API/ECommerce.API/Repositories/TagRepository.cs new file mode 100644 index 00000000..fa896196 --- /dev/null +++ b/Ecommerce-API/ECommerce.API/Repositories/TagRepository.cs @@ -0,0 +1,38 @@ +using ECommerce.API.Data; +using ECommerce.API.Interfaces; +using ECommerce.API.Models; +using Microsoft.EntityFrameworkCore; + +namespace ECommerce.API.Repositories; + +public class TagRepository(ApiDbContext db) : ITagRepository +{ + public IQueryable GetTags() + { + return db.Tags + .OrderBy(t => t.TagId) + .AsQueryable(); + } + + public async Task GetTagByName(string name) + { + return await db.Tags.FirstOrDefaultAsync(t => t.TagName.ToLower().Trim() == name.ToLower().Trim()); + } + + public async Task GetTagById(int id) + { + return await db.Tags.FirstOrDefaultAsync(t => t.TagId == id); + } + + public async Task PostTagAsync(Tag tag) + { + db.Tags.Add(tag); + await db.SaveChangesAsync(); + } + + public async Task DeleteTagAsync(Tag tag) + { + db.Tags.Remove(tag); + await db.SaveChangesAsync(); + } +} \ No newline at end of file diff --git a/Ecommerce-API/ECommerce.API/Services/ItemService.cs b/Ecommerce-API/ECommerce.API/Services/ItemService.cs new file mode 100644 index 00000000..dd201c82 --- /dev/null +++ b/Ecommerce-API/ECommerce.API/Services/ItemService.cs @@ -0,0 +1,106 @@ +using ECommerce.API.Interfaces; +using ECommerce.API.Models; +using ECommerce.Shared.Models; +using Microsoft.EntityFrameworkCore; + +namespace ECommerce.API.Services; + +public class ItemService(IItemRepository repo, ITagRepository tagRepo) : IItemService +{ + public async Task PostItemAsync(CreateItemDto itemDto) + { + var tags = new List(); + + foreach (var tagDto in itemDto.Tags) + { + var existingTag = await tagRepo.GetTagByName(tagDto.TagName); + tags.Add(existingTag ?? new Tag { TagName = tagDto.TagName }); + } + + var item = new Item + { + Format = itemDto.Format, + Type = itemDto.Type, + Name = itemDto.Name, + Artist = itemDto.Artist, + Genre = itemDto.Genre, + Tags = tags, + Price = itemDto.Price + }; + + await repo.PostItemAsync(item); + } + + public async Task> GetItemsAsync(PaginationParams paginationParams) + { + var query = repo.GetItems(); + + if (!string.IsNullOrEmpty(paginationParams.SearchTerm)) + query = query.Where(i => i.Name.ToLower().Contains(paginationParams.SearchTerm.ToLower()) + || i.Artist.ToLower().Contains(paginationParams.SearchTerm.ToLower())); + + if (!string.IsNullOrEmpty(paginationParams.Genre)) + query = query.Where(i => i.Genre.ToLower() == paginationParams.Genre.ToLower()); + + if (paginationParams.SearchTags.Count > 0) + query = query.Where(i => i.Tags.Any(t => paginationParams.SearchTags.Contains(t.TagName))); + + var totalRecords = await query.CountAsync(); + var totalPages = (int)Math.Ceiling((double)totalRecords / paginationParams.PageSize); + var items = await query + .Skip((paginationParams.PageNumber - 1) * paginationParams.PageSize) + .Take(paginationParams.PageSize) + .Select(i => new ItemDto + { + ItemId = i.ItemId, + Format = i.Format, + Type = i.Type, + Artist = i.Artist, + Name = i.Name, + Genre = i.Genre, + Price = i.Price, + Tags = i.Tags.Select(t => new TagDto { TagName = t.TagName }).ToList() + }) + .ToListAsync(); + + return new PagedResponse(items, paginationParams.PageNumber, paginationParams.PageSize, totalRecords, totalPages); + } + + public async Task GetItemByIdAsync(int id) + { + var item = await repo.GetItemByIdAsync(id); + if (item is null) return null; + + return new ItemDto + { + Format = item.Format, + Type = item.Type, + Artist = item.Artist, + Name = item.Name, + Genre = item.Genre, + Price = item.Price, + Tags = item.Tags.Select(t => new TagDto { TagName = t.TagName }).ToList() + }; + } + + /// + /// Deletes an item by ID + /// + /// + /// True after a successful deletion, false upon an unsuccessful attempt, or null upon a NotFound error. + public async Task DeleteItemByIdAsync(int itemId) + { + var item = await repo.GetItemByIdAsync(itemId); + if (item is null) + return null; + try + { + await repo.DeleteItemAsync(item); + return true; + } + catch (Exception) + { + return false; + } + } +} \ No newline at end of file diff --git a/Ecommerce-API/ECommerce.API/Services/SaleService.cs b/Ecommerce-API/ECommerce.API/Services/SaleService.cs new file mode 100644 index 00000000..a89899db --- /dev/null +++ b/Ecommerce-API/ECommerce.API/Services/SaleService.cs @@ -0,0 +1,101 @@ +using ECommerce.API.Interfaces; +using ECommerce.API.Models; +using ECommerce.Shared.Models; +using Microsoft.EntityFrameworkCore; + +namespace ECommerce.API.Services; + +public class SaleService(ISaleRepository repo, IItemRepository itemRepo) : ISaleService +{ + /// + /// Posts a sale async + /// + /// True upon a successful post, false upon an unsuccessful post, and null upon a NotFound error. + public async Task PostSaleAsync(List saleItems) + { + var soldItems = new List(); + foreach (var saleItemDto in saleItems) + { + var item = await itemRepo.GetItemByIdAsync(saleItemDto.ItemId); + if (item is null) + return null; + + soldItems.Add(new SaleItem + { + Item = item, + Quantity = saleItemDto.Quantity + }); + } + + var sale = new Sale + { + SoldItems = soldItems + }; + + try + { + await repo.PostSale(sale); + return true; + } + catch (Exception) + { + return false; + } + } + + public async Task> GetSalesAsync(PaginationParams paginationParams) + { + var query = repo.GetSales(); + var totalRecords = await query.CountAsync(); + var totalPages = (int)Math.Ceiling((double)totalRecords / paginationParams.PageSize); + var sales = await query + .Skip((paginationParams.PageNumber - 1) * paginationParams.PageSize) + .Take(paginationParams.PageSize) + .Select(s => new SaleDto + { + SaleId = s.SaleId, + SoldItems = s.SoldItems.Select(si => new SaleItemDto + { + Quantity = si.Quantity, + Item = new ItemDto + { + ItemId = si.Item.ItemId, + Format = si.Item.Format, + Type = si.Item.Type, + Name = si.Item.Name, + Artist = si.Item.Artist, + Genre = si.Item.Genre, + Price = si.Item.Price, + Tags = si.Item.Tags.Select(t => new TagDto { TagName = t.TagName }).ToList() + } + }).ToList() + }).ToListAsync(); + + return new PagedResponse + (sales, paginationParams.PageNumber, paginationParams.PageSize, totalRecords, totalPages); + } + + /// + /// Deletes a sale asynchronously by ID + /// + /// + /// True upon a successful deletion, false upon an unsuccessful deletion attempt, + /// or null upon a NotFound exception + /// + public async Task DeleteSaleByIdAsync(int saleId) + { + var sale = await repo.GetSaleByIdAsync(saleId); + if (sale is null) + return null; + + try + { + await repo.DeleteSaleAsync(sale); + return true; + } + catch (Exception) + { + return false; + } + } +} \ No newline at end of file diff --git a/Ecommerce-API/ECommerce.API/Services/TagService.cs b/Ecommerce-API/ECommerce.API/Services/TagService.cs new file mode 100644 index 00000000..e8abe763 --- /dev/null +++ b/Ecommerce-API/ECommerce.API/Services/TagService.cs @@ -0,0 +1,71 @@ +using ECommerce.API.Interfaces; +using ECommerce.API.Models; +using ECommerce.Shared.Models; +using Microsoft.EntityFrameworkCore; + +namespace ECommerce.API.Services; + +public class TagService(ITagRepository repo) : ITagService +{ + public async Task> GetTagsAsync(PaginationParams paginationParams) + { + var query = repo.GetTags(); + + if (!string.IsNullOrEmpty(paginationParams.SearchTerm)) + query = query.Where(t => t.TagName.ToLower().Contains(paginationParams.SearchTerm.ToLower())); + + var totalRecords = await query.CountAsync(); + var totalPages = (int)Math.Ceiling((double)totalRecords / paginationParams.PageSize); + var tags = await query + .Skip((paginationParams.PageNumber - 1) * paginationParams.PageSize) + .Take(paginationParams.PageSize) + .Select(t => new TagDto + { + TagName = t.TagName + }).ToListAsync(); + + return new PagedResponse(tags, paginationParams.PageNumber, paginationParams.PageSize, totalRecords, totalPages); + } + + public async Task GetTagIdByName(string name) + { + var tag = await repo.GetTagByName(name); + return tag?.TagId; + } + + public async Task PostTagAsync(CreateTagDto tagDto) + { + if (await repo.GetTagByName(tagDto.TagName) is not null) return; + + var tag = new Tag + { + TagName = tagDto.TagName + }; + + await repo.PostTagAsync(tag); + } + + /// + /// Deletes a tag asynchronously + /// + /// + /// true upon a successful deletion, false upon an unsuccessful attempt, and null + /// upon a NotFound exception. + /// + public async Task DeleteTagByIdAsync(int id) + { + var tag = await repo.GetTagById(id); + if (tag is null) + return null; + + try + { + await repo.DeleteTagAsync(tag); + return true; + } + catch (Exception) + { + return false; + } + } +} \ No newline at end of file diff --git a/Ecommerce-API/ECommerce.API/appsettings.Development.json b/Ecommerce-API/ECommerce.API/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/Ecommerce-API/ECommerce.API/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/Ecommerce-API/ECommerce.API/appsettings.json b/Ecommerce-API/ECommerce.API/appsettings.json new file mode 100644 index 00000000..10f68b8c --- /dev/null +++ b/Ecommerce-API/ECommerce.API/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/Ecommerce-API/ECommerce.Shared/ECommerce.Shared.csproj b/Ecommerce-API/ECommerce.Shared/ECommerce.Shared.csproj new file mode 100644 index 00000000..237d6616 --- /dev/null +++ b/Ecommerce-API/ECommerce.Shared/ECommerce.Shared.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/Ecommerce-API/ECommerce.Shared/Enums.cs b/Ecommerce-API/ECommerce.Shared/Enums.cs new file mode 100644 index 00000000..27511e35 --- /dev/null +++ b/Ecommerce-API/ECommerce.Shared/Enums.cs @@ -0,0 +1,15 @@ +namespace ECommerce.Shared; + +public enum ItemFormat +{ + Cd, + Vinyl, + Digital +} + +public enum ItemType +{ + Album, + Single, + Mixtape +} \ No newline at end of file diff --git a/Ecommerce-API/ECommerce.Shared/Models/CreateItemDto.cs b/Ecommerce-API/ECommerce.Shared/Models/CreateItemDto.cs new file mode 100644 index 00000000..e03bf48d --- /dev/null +++ b/Ecommerce-API/ECommerce.Shared/Models/CreateItemDto.cs @@ -0,0 +1,12 @@ +namespace ECommerce.Shared.Models; + +public class CreateItemDto +{ + public required ItemFormat Format { get; set; } + public required ItemType Type { get; set; } + public required string Name { get; set; } + public required string Artist { get; set; } + public required decimal Price { get; set; } + public required string Genre { get; set; } + public List Tags { get; set; } = []; +} \ No newline at end of file diff --git a/Ecommerce-API/ECommerce.Shared/Models/CreateSaleItemDto.cs b/Ecommerce-API/ECommerce.Shared/Models/CreateSaleItemDto.cs new file mode 100644 index 00000000..f2c48f40 --- /dev/null +++ b/Ecommerce-API/ECommerce.Shared/Models/CreateSaleItemDto.cs @@ -0,0 +1,13 @@ +namespace ECommerce.Shared.Models; + +public class CreateSaleItemDto +{ + public int ItemId { get; set; } + public int Quantity { get; set; } +} + +public class SaleItemDto +{ + public int Quantity { get; set; } + public ItemDto Item { get; set; } +} \ No newline at end of file diff --git a/Ecommerce-API/ECommerce.Shared/Models/CreateTagDto.cs b/Ecommerce-API/ECommerce.Shared/Models/CreateTagDto.cs new file mode 100644 index 00000000..6d73290e --- /dev/null +++ b/Ecommerce-API/ECommerce.Shared/Models/CreateTagDto.cs @@ -0,0 +1,6 @@ +namespace ECommerce.Shared.Models; + +public class CreateTagDto +{ + public required string TagName { get; set; } +} \ No newline at end of file diff --git a/Ecommerce-API/ECommerce.Shared/Models/ItemDto.cs b/Ecommerce-API/ECommerce.Shared/Models/ItemDto.cs new file mode 100644 index 00000000..9075249c --- /dev/null +++ b/Ecommerce-API/ECommerce.Shared/Models/ItemDto.cs @@ -0,0 +1,13 @@ +namespace ECommerce.Shared.Models; + +public class ItemDto +{ + public int ItemId { get; set; } + public required ItemFormat Format { get; set; } + public required ItemType Type { get; set; } + public required string Name { get; set; } + public required string Artist { get; set; } + public required decimal Price { get; set; } + public required string Genre { get; set; } + public List Tags { get; set; } = []; +} \ No newline at end of file diff --git a/Ecommerce-API/ECommerce.Shared/Models/PagedResponse.cs b/Ecommerce-API/ECommerce.Shared/Models/PagedResponse.cs new file mode 100644 index 00000000..1d7ea0cd --- /dev/null +++ b/Ecommerce-API/ECommerce.Shared/Models/PagedResponse.cs @@ -0,0 +1,19 @@ +namespace ECommerce.Shared.Models; + +public class PagedResponse +{ + public PagedResponse(List data, int pageNumber, int pageSize, int totalRecords, int totalPages) + { + Data = data; + PageNumber = pageNumber; + PageSize = pageSize; + TotalRecords = totalRecords; + TotalPages = totalPages; + } + + public List Data { get; set; } + public int PageNumber { get; set; } + public int PageSize { get; set; } + public int TotalRecords { get; set; } + public int TotalPages { get; set; } +} \ No newline at end of file diff --git a/Ecommerce-API/ECommerce.Shared/Models/SaleDto.cs b/Ecommerce-API/ECommerce.Shared/Models/SaleDto.cs new file mode 100644 index 00000000..596f31a5 --- /dev/null +++ b/Ecommerce-API/ECommerce.Shared/Models/SaleDto.cs @@ -0,0 +1,9 @@ +namespace ECommerce.Shared.Models; + +public class SaleDto +{ + public int SaleId { get; set; } + public List SoldItems { get; set; } = []; + + public decimal TotalPrice => SoldItems.Sum(si => si.Item.Price * si.Quantity); +} \ No newline at end of file diff --git a/Ecommerce-API/ECommerce.Shared/Models/TagDto.cs b/Ecommerce-API/ECommerce.Shared/Models/TagDto.cs new file mode 100644 index 00000000..d8fc4854 --- /dev/null +++ b/Ecommerce-API/ECommerce.Shared/Models/TagDto.cs @@ -0,0 +1,6 @@ +namespace ECommerce.Shared.Models; + +public class TagDto +{ + public required string TagName { get; set; } +} \ No newline at end of file diff --git a/Ecommerce-API/ECommerce.UI/Configuration/ApiSettings.cs b/Ecommerce-API/ECommerce.UI/Configuration/ApiSettings.cs new file mode 100644 index 00000000..4b832383 --- /dev/null +++ b/Ecommerce-API/ECommerce.UI/Configuration/ApiSettings.cs @@ -0,0 +1,53 @@ +using System.Text.Json; + +namespace ECommerce.UI.Configuration; + +internal class ApiSettings +{ + internal static string BaseUrl { get; } = GetBaseUri(); + + //------- Helper Methods ------- + private static string GetBaseUri() + { + var solutionRoot = GetSolutionDirectory(); + var launchSettingsPath = Path.Combine( + solutionRoot, + "ECommerce.API", + "Properties", + "launchSettings.json"); + + if (File.Exists(launchSettingsPath)) + { + var json = File.ReadAllText(launchSettingsPath); + using var doc = JsonDocument.Parse(json); + var uri = doc.RootElement + .GetProperty("profiles") + .GetProperty("http") + .GetProperty("applicationUrl") + .GetString(); + + if (string.IsNullOrEmpty(uri)) + throw new Exception("Could not find the application url within the Api's launchSettings.json profile"); + + var baseUrl = uri.Split(';').First(); + return $"{baseUrl}/api/"; + } + + throw new Exception("Could not find the Api's launchSettings.json profile"); + } + + private static string GetSolutionDirectory() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + + while (directory != null) + { + if (directory.GetFiles("*.sln").Any()) + return directory.FullName; + + directory = directory.Parent; + } + + throw new DirectoryNotFoundException("Could not find the solution directory"); + } +} \ No newline at end of file diff --git a/Ecommerce-API/ECommerce.UI/Configuration/ApiUris.cs b/Ecommerce-API/ECommerce.UI/Configuration/ApiUris.cs new file mode 100644 index 00000000..90a13f9b --- /dev/null +++ b/Ecommerce-API/ECommerce.UI/Configuration/ApiUris.cs @@ -0,0 +1,8 @@ +namespace ECommerce.UI.Configuration; + +internal class ApiUris +{ + internal static readonly string ItemRequestUri = $"{ApiSettings.BaseUrl}items"; + internal static readonly string TagRequestUri = $"{ApiSettings.BaseUrl}tags"; + internal static readonly string SaleRequestUri = $"{ApiSettings.BaseUrl}sales"; +} \ No newline at end of file diff --git a/Ecommerce-API/ECommerce.UI/Configuration/AppSettings.cs b/Ecommerce-API/ECommerce.UI/Configuration/AppSettings.cs new file mode 100644 index 00000000..638294e2 --- /dev/null +++ b/Ecommerce-API/ECommerce.UI/Configuration/AppSettings.cs @@ -0,0 +1,8 @@ +namespace ECommerce.UI.Configuration; + +internal class AppSettings +{ + internal static readonly string AppDataPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "ECommerce"); +} \ No newline at end of file diff --git a/Ecommerce-API/ECommerce.UI/ECommerce.UI.csproj b/Ecommerce-API/ECommerce.UI/ECommerce.UI.csproj new file mode 100644 index 00000000..3e82e925 --- /dev/null +++ b/Ecommerce-API/ECommerce.UI/ECommerce.UI.csproj @@ -0,0 +1,22 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + + + + + + + + + diff --git a/Ecommerce-API/ECommerce.UI/Enums/EnumExtender.cs b/Ecommerce-API/ECommerce.UI/Enums/EnumExtender.cs new file mode 100644 index 00000000..84577e3c --- /dev/null +++ b/Ecommerce-API/ECommerce.UI/Enums/EnumExtender.cs @@ -0,0 +1,14 @@ +using System.Text.RegularExpressions; + +namespace ECommerce.UI.Enums; + +internal static class EnumExtender +{ + internal static string ToDisplayString(this Enum value) + { + return Regex.Replace( + value.ToString(), + "([a-z])([A-Z])", + "$1 $2"); + } +} \ No newline at end of file diff --git a/Ecommerce-API/ECommerce.UI/Enums/Enums.cs b/Ecommerce-API/ECommerce.UI/Enums/Enums.cs new file mode 100644 index 00000000..877d8044 --- /dev/null +++ b/Ecommerce-API/ECommerce.UI/Enums/Enums.cs @@ -0,0 +1,99 @@ +namespace ECommerce.UI.Enums; + +//------- Administrator Ui Enums ------- +internal enum AdminMainMenu +{ + ManageProducts, + ManageProductTags, + ManageSales, + EnterTestingEnvironment, + ExitApplication +} + +internal enum ManageProductsMenuOption +{ + ReviewProducts, + SearchProducts, + CreateNewProduct, + DeleteProduct, + Back +} + +internal enum PaginationController +{ + LastPage, + NextPage, + Back +} + +internal enum PaginationControllerWithSelection +{ + LastPage, + NextPage, + SelectProduct, + Back +} + +internal enum SearchController +{ + SearchByTerm, + FilterByTags, + FilterByGenre, +} + +internal enum SearchTagsController +{ + + SearchForSpecificTag, + BrowseAllTags, + Back +} + +internal enum TagAdditionMethodForItem +{ + SearchForAnExistingTag, + CreateNewTag, + CreateWithoutTags +} + +internal enum ManageProductTagsMenu +{ + ReviewTags, + SearchTags, + CreateNewTag, + DeleteTag, + Back +} + +internal enum ManageSalesMenu +{ + ReviewSales, + CreateNewSale, + DeleteSale, + Back +} + +//------- Testing Ui Enums ------- +internal enum TestingMenuOption +{ + BrowseProducts, + SearchProducts, + Checkout, + ExitTestingEnvironment +} + +internal enum PaginationControllerWithAddToCart +{ + LastPage, + NextPage, + AddToCart, + Back +} + +internal enum CheckoutMenu +{ + CheckoutItems, + RemoveItem, + ClearAllItems, + Back +} \ No newline at end of file diff --git a/Ecommerce-API/ECommerce.UI/Helpers/DisplayHelper.cs b/Ecommerce-API/ECommerce.UI/Helpers/DisplayHelper.cs new file mode 100644 index 00000000..0ab77350 --- /dev/null +++ b/Ecommerce-API/ECommerce.UI/Helpers/DisplayHelper.cs @@ -0,0 +1,144 @@ +using ECommerce.UI.Enums; +using Spectre.Console; +using Spectre.Console.Rendering; + +namespace ECommerce.UI.Helpers; + +internal class DisplayHelper +{ + //------- Colors ------- + internal const string White = "#f1f1f1"; + internal const string Grey = "#8c8e8f"; + internal const string Green = "#32aa3b"; + private const string Red = "#cd2d2d"; + internal const string Yellow = "#e2b929"; + private const string Error = "#870c00"; + + //------- Basic Outputs ------- + internal static void DisplayMessage(string message, bool writeLine = true) + { + if (writeLine) + AnsiConsole.MarkupLine($"[{White}]{message}[/]"); + else + AnsiConsole.Markup($"[{White}]{message}[/]"); + } + + internal static void DisplayRows(List rows, bool writeLine = true) + { + var rowsLayout = new Rows(rows); + AnsiConsole.Write(rowsLayout); + } + + internal static void DisplayTable(Table table) + { + table.SimpleBorder(); + table.BorderColor(Color.White); + + AnsiConsole.Write(table); + } + + internal static void DisplayInfo(string info, bool writeLine = true) + { + if (writeLine) + AnsiConsole.MarkupLine($"[{Grey}]{info}[/]"); + else + AnsiConsole.Markup($"[{Grey}]{info}[/]"); + } + + internal static void DisplaySuccess(string message, bool writeLine = true) + { + if (writeLine) + AnsiConsole.MarkupLine($"[{Green}]{message}[/]"); + else + AnsiConsole.Markup($"[{Green}]{message}[/]"); + } + + internal static void DisplayUrgent(string message, bool writeLine = true) + { + if (writeLine) + AnsiConsole.MarkupLine($"[{Red}]{message}[/]"); + else + AnsiConsole.Markup($"[{Red}]{message}[/]"); + } + + internal static void DisplayWarning(string message, bool writeLine = true) + { + if (writeLine) + AnsiConsole.MarkupLine($"[{Yellow}]{message}[/]"); + else + AnsiConsole.Markup($"[{Yellow}]{message}[/]"); + } + + internal static void DisplayError(string message, bool writeLine = true) + { + if (writeLine) + AnsiConsole.MarkupLine($"[{Error}]{message}[/]"); + else + AnsiConsole.Markup($"[{Error}]{message}[/]"); + } + + //------- Menus & Prompts ------- + internal static T DisplayMenu(string? title = null) where T : Enum + { + return DisplayMenu(Enum.GetValues(typeof(T)).Cast(), title); + } + + internal static T DisplayMenu(IEnumerable choices, string? title = null) where T : Enum + { + return AnsiConsole.Prompt( + new SelectionPrompt() + .Title(title ?? "Please Select An Option:") + .HighlightStyle(Style.Parse("darkviolet")) + .AddChoices(choices) + .UseConverter(e => e.ToDisplayString()) + ); + } + + internal static string DisplayPrompt(List choiceList, string? title = null) + { + var choice = AnsiConsole.Prompt( + new SelectionPrompt() + .Title(title ?? "Please Select An Option:") + .HighlightStyle(Style.Parse("darkviolet")) + .AddChoices(choiceList)); + + return choice; + } + + internal static List DisplayMultiPrompt(List choiceList, string? title = null, + bool requireChoice = true) + { + var prompt = new MultiSelectionPrompt() + .Title(title ?? "Please Select An Option:") + .HighlightStyle(Style.Parse("darkviolet")) + .InstructionsText($"[{Grey}]Press[/] [{White}][/] to Toggle, and [{White}][/] to Confirm") + .AddChoices(choiceList); + + if (requireChoice) + prompt.Required(); + else + prompt.NotRequired(); + + return AnsiConsole.Prompt(prompt); + } + + internal static string DisplayQuestion(string question) + { + var response = AnsiConsole.Ask($"[{White}]{question}[/]"); + return response; + } + + internal static async Task DisplaySpinner(string waitMessage, int waitTimeInMs = 3000) + { + await AnsiConsole.Status() + .Spinner(Spinner.Known.Star) + .StartAsync($"[{White}]{waitMessage}[/]", async ctx => { await Task.Delay(waitTimeInMs); }); + } + + internal static async Task DisplaySpinnerForTask(string waitMessage, Task task) + { + await AnsiConsole.Status() + .Spinner(Spinner.Known.Star) + .StartAsync($"[{White}]{waitMessage}[/]", async ctx => { await task; }); + } +} \ No newline at end of file diff --git a/Ecommerce-API/ECommerce.UI/Helpers/UiHelper.cs b/Ecommerce-API/ECommerce.UI/Helpers/UiHelper.cs new file mode 100644 index 00000000..33b34ff8 --- /dev/null +++ b/Ecommerce-API/ECommerce.UI/Helpers/UiHelper.cs @@ -0,0 +1,215 @@ +using ECommerce.Shared.Models; +using ECommerce.UI.Enums; +using ECommerce.UI.Interfaces; +using Spectre.Console; +using Spectre.Console.Rendering; +using static ECommerce.UI.Helpers.DisplayHelper; + +namespace ECommerce.UI.Helpers; + +internal class UiHelper(ITagService tagService) +{ + internal static Table BuildItemTable(PagedResponse response) + { + var table = new Table().ShowRowSeparators(); + + table.AddColumn($"[{White}]Title[/]"); + table.AddColumn($"[{White}]Artist[/]"); + table.AddColumn($"[{White}]Type / Format[/]"); + table.AddColumn($"[{White}]Genre[/]"); + table.AddColumn($"[{White}]Tags[/]"); + table.AddColumn($"[{White}]Price[/]"); + table.AddColumn($"[{White}]Item ID[/]"); + + foreach (var item in response.Data) + { + var tags = string.Join(", ", item.Tags.Select(t => t.TagName)); + table.AddRow(item.Name, item.Artist, $"{item.Type} / {item.Format}", item.Genre, tags, $"{item.Price}", $"{item.ItemId}"); + } + + return table; + } + + internal Table BuildTagTable(PagedResponse response) + { + var table = new Table(); + + table.AddColumn($"[{White}]Title[/]"); + + foreach (var tag in response.Data) + { + table.AddRow(tag.TagName); + } + + return table; + } + + internal static Table BuildSaleTable(PagedResponse response) + { + var table = new Table(); + + table.AddColumn($"[{White}]Sale ID[/]", col => col.Centered()); + table.AddColumn($"[{White}]Sold Items[/]", col => col.Centered()); + table.AddColumn($"[{White}]Total Price[/]", col => col.Centered()); + + foreach (var sale in response.Data) + { + var soldItems = string.Join(", ", sale.SoldItems.Select(s => $"{s.Item.Name} x {s.Quantity}")); + table.AddRow($"{sale.SaleId}", soldItems, $"{sale.TotalPrice}"); + } + + return table; + } + + /// + /// Displays a caught exception to the user based on the type of exception caught. + /// Handles HttpResponseExceptions for status codes 404, 400-499, and 500+. + /// Handles ArgumentNull and generic Argument exceptions. + /// All other exceptions are labelled with a generic 'unexpected exception' label. + /// + /// Caught exception + internal static void DisplayCaughtException(Exception ex) + { + Console.Clear(); + + if (ex is HttpRequestException { StatusCode: not null } exception) + { + switch ((int)exception.StatusCode) + { + case var code when code is 404: + DisplayWarning("The content you requested could not be found, please check that the " + + "content you want exists and is accessible. | 404 Not Found"); + break; + + case var code when code is >= 400 and < 500: + DisplayWarning("A client side error has occurred while processing your request, " + + "please check that you are sending a good request to the API containing valid details. | 4xx"); + break; + + case var code when code is > 500: + DisplayWarning("A server side error has occurred while processing your request, " + + "please check that the API is working and all entered details are correct. | 5xx"); + break; + default: + DisplayWarning("An unknown error has occurred while attempting to interact with the API. | ???"); + break; + } + } + else if (ex is ArgumentException argException) + { + if (argException is ArgumentNullException) + DisplayWarning("One or more of the arguments you have entered was null, " + + "please try again with non-null details."); + else + DisplayWarning("An error has occurred with one or more of the arguments you have entered, " + + "please check that any details you enter are correct before trying again."); + } + else + { + DisplayWarning("An unexpected error has occurred during runtime, " + + "please retry later or report the problem if it persists."); + } + + WaitForUser(); + } + + internal static void WaitForUser() + { + DisplayMessage("Please press enter to continue."); + Console.ReadLine(); + } + + /// + /// Gets an argument from the user + /// + /// Display prompt shown when prompting the user + /// Any extra instructions, E.G asking for a specific format. + /// null if the user wishes to exit the process, or else the value returned form the prompt. + internal static string? GetArgument(string prompt, string? instructions = null) + { + const string backOption = "back"; + + Console.Clear(); + DisplayInfo("Enter 'Back' to leave this menu."); + if (instructions != null) DisplayInfo(instructions); + + var value = DisplayQuestion(prompt); + + return string.Equals(value, backOption, StringComparison.OrdinalIgnoreCase) ? null : value; + } + + /// + /// Displays a pagination menu, handling edge cases for first and last pages + /// + /// + /// + /// The chosen PaginationController selection + internal static PaginationController DisplayPaginationController(int pageNumber, int totalPages) + { + IEnumerable options; + if (totalPages == 1) + { + options = Enum.GetValues(typeof(PaginationController)) + .Cast() + .Where(p => p == PaginationController.Back) + .ToList(); + } + else if (pageNumber >= totalPages && totalPages > 1) + { + options = Enum.GetValues(typeof(PaginationController)) + .Cast() + .Where(p => p != PaginationController.NextPage) + .ToList(); + } + else if (pageNumber <= 1) + { + options = Enum.GetValues(typeof(PaginationController)) + .Cast() + .Where(p => p != PaginationController.LastPage) + .ToList(); + } + else + { + options = Enum.GetValues(typeof(PaginationController)) + .Cast() + .ToList(); + } + + return DisplayMenu(options); + } + + internal static PaginationControllerWithSelection DisplayPaginationControllerWithSelectionOption(int pageNumber, + int totalPages) + { + IEnumerable options; + if (totalPages == 1) + { + options = Enum.GetValues(typeof(PaginationControllerWithSelection)) + .Cast() + .Where(p => p == PaginationControllerWithSelection.Back || p == PaginationControllerWithSelection.SelectProduct) + .ToList(); + } + else if (pageNumber >= totalPages && totalPages > 1) + { + options = Enum.GetValues(typeof(PaginationControllerWithSelection)) + .Cast() + .Where(p => p != PaginationControllerWithSelection.NextPage) + .ToList(); + } + else if (pageNumber <= 1) + { + options = Enum.GetValues(typeof(PaginationControllerWithSelection)) + .Cast() + .Where(p => p != PaginationControllerWithSelection.LastPage) + .ToList(); + } + else + { + options = Enum.GetValues(typeof(PaginationControllerWithSelection)) + .Cast() + .ToList(); + } + + return DisplayMenu(options); + } +} \ No newline at end of file diff --git a/Ecommerce-API/ECommerce.UI/Helpers/Utils.cs b/Ecommerce-API/ECommerce.UI/Helpers/Utils.cs new file mode 100644 index 00000000..9545e137 --- /dev/null +++ b/Ecommerce-API/ECommerce.UI/Helpers/Utils.cs @@ -0,0 +1,39 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using ECommerce.Shared.Models; + +namespace ECommerce.UI.Helpers; + +internal static class Utils +{ + internal static string FormatQueryWithPaginationParams(string baseUrl, int pageNumber, int pageSize, + string? searchTerm, string? searchGenre, List? searchTags) + { + var sb = new StringBuilder(); + sb.Append($"{baseUrl}?PageNumber={pageNumber}&PageSize={pageSize}"); + + if (!string.IsNullOrWhiteSpace(searchTerm)) + sb.Append($"&SearchTerm={searchTerm}"); + if (!string.IsNullOrWhiteSpace(searchGenre)) + sb.Append($"&Genre={searchGenre}"); + if (searchTags != null && searchTags.Any()) + { + for (var i = 0; i < searchTags.Count; i++) + { + sb.Append($"&SearchTags={searchTags[i].TagName}"); + } + } + + return sb.ToString(); + } + + internal static JsonSerializerOptions GetJsonSerializerOptions() + { + return new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + Converters = { new JsonStringEnumConverter() } + }; + } +} \ No newline at end of file diff --git a/Ecommerce-API/ECommerce.UI/Interfaces/IApiService.cs b/Ecommerce-API/ECommerce.UI/Interfaces/IApiService.cs new file mode 100644 index 00000000..3a3bf538 --- /dev/null +++ b/Ecommerce-API/ECommerce.UI/Interfaces/IApiService.cs @@ -0,0 +1,8 @@ +namespace ECommerce.UI.Interfaces; + +public interface IApiService +{ + public Task GetAsync(string path); + public Task PostAsync(string path, T body); + public Task DeleteAsync(string path); +} \ No newline at end of file diff --git a/Ecommerce-API/ECommerce.UI/Interfaces/ICartService.cs b/Ecommerce-API/ECommerce.UI/Interfaces/ICartService.cs new file mode 100644 index 00000000..2b8f2eff --- /dev/null +++ b/Ecommerce-API/ECommerce.UI/Interfaces/ICartService.cs @@ -0,0 +1,11 @@ +using ECommerce.Shared.Models; + +namespace ECommerce.UI.Interfaces; + +public interface ICartService +{ + public Task AddToCartAsync(ItemDto item); + public Task> GetCartAsync(); + public Task RemoveFromCartAsync(int itemId); + public void ClearCart(); +} \ No newline at end of file diff --git a/Ecommerce-API/ECommerce.UI/Interfaces/IItemService.cs b/Ecommerce-API/ECommerce.UI/Interfaces/IItemService.cs new file mode 100644 index 00000000..72273e8d --- /dev/null +++ b/Ecommerce-API/ECommerce.UI/Interfaces/IItemService.cs @@ -0,0 +1,27 @@ +using ECommerce.Shared; +using ECommerce.Shared.Models; + +namespace ECommerce.UI.Interfaces; + +public interface IItemService +{ + public Task> GetItemsAsync(int pageNumber = 1, int pageSize = 10, + string? searchTerm = null, string? searchGenre = null, List? tags = null); + + public Task GetItemByIdAsync(int id); + public Task DeleteItemAsync(int id); + + /// + /// Posts an item asynchronously. + /// + /// Chosen ItemFormat format + /// Chosen ItemType type + /// Product's title + /// Artist's name + /// Item price + /// Item's genre + /// A string of any tags, separated by commas + /// + public Task PostItemAsync(ItemFormat format, ItemType type, string title, string artist, + decimal price, string genre, string tags); +} \ No newline at end of file diff --git a/Ecommerce-API/ECommerce.UI/Interfaces/ISaleService.cs b/Ecommerce-API/ECommerce.UI/Interfaces/ISaleService.cs new file mode 100644 index 00000000..4cf1ae34 --- /dev/null +++ b/Ecommerce-API/ECommerce.UI/Interfaces/ISaleService.cs @@ -0,0 +1,17 @@ +using ECommerce.Shared.Models; + +namespace ECommerce.UI.Interfaces; + +public interface ISaleService +{ + public Task> GetSalesAsync(int pageNumber = 1, int pageSize = 10); + + /// + /// Posts a new sale to the API asynchronously + /// + /// An item ID - quantity pair for each item in the sale + /// + public Task PostSaleAsync(Dictionary itemIdQuantityPairs); + + public Task DeleteSaleAsync(int id); +} \ No newline at end of file diff --git a/Ecommerce-API/ECommerce.UI/Interfaces/ITagService.cs b/Ecommerce-API/ECommerce.UI/Interfaces/ITagService.cs new file mode 100644 index 00000000..89445c45 --- /dev/null +++ b/Ecommerce-API/ECommerce.UI/Interfaces/ITagService.cs @@ -0,0 +1,15 @@ +using ECommerce.Shared.Models; + +namespace ECommerce.UI.Interfaces; + +public interface ITagService +{ + public Task> GetTagsAsync(int pageNumber = 1, int pageSize = 10, + string? searchTerm = null); + + public Task GetTagIdByNameAsync(string tagName); + + public Task PostTagAsync(string tagName); + + public Task DeleteTagAsync(int tagId); +} \ No newline at end of file diff --git a/Ecommerce-API/ECommerce.UI/Interfaces/IVerificationService.cs b/Ecommerce-API/ECommerce.UI/Interfaces/IVerificationService.cs new file mode 100644 index 00000000..efc0f90f --- /dev/null +++ b/Ecommerce-API/ECommerce.UI/Interfaces/IVerificationService.cs @@ -0,0 +1,11 @@ +using ECommerce.Shared; + +namespace ECommerce.UI.Interfaces; + +public interface IVerificationService +{ + public bool TryParseItemFormat(string input, out ItemFormat itemFormat); + public bool TryParseItemType(string input, out ItemType itemType); + public bool TryValidateItemPrice(string input, out decimal itemPrice, out string? errorMessage); + public bool TryParseValidQuantity(string quantity, out int parsedQuantity); +} \ No newline at end of file diff --git a/Ecommerce-API/ECommerce.UI/Program.cs b/Ecommerce-API/ECommerce.UI/Program.cs new file mode 100644 index 00000000..afd10b1b --- /dev/null +++ b/Ecommerce-API/ECommerce.UI/Program.cs @@ -0,0 +1,53 @@ +using ECommerce.UI.Configuration; +using ECommerce.UI.Interfaces; +using ECommerce.UI.Services; +using ECommerce.UI.UserInterface.AdministratorUi; +using ECommerce.UI.UserInterface.TestingUi; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +var builder = Host.CreateApplicationBuilder(args); + +builder.Services.AddTransient(); +builder.Services.AddTransient(); +builder.Services.AddTransient(); +builder.Services.AddTransient(); +builder.Services.AddTransient(); + +builder.Services.AddTransient(); + +builder.Services.AddTransient(); +builder.Services.AddTransient(); +builder.Services.AddTransient(); + +builder.Services.AddTransient(); +builder.Services.AddTransient(); + +builder.Services.AddTransient(); +builder.Services.AddTransient(); + +builder.Services.AddHttpClient(ApiSettings.BaseUrl, + client => client.BaseAddress = new Uri(ApiSettings.BaseUrl)); +builder.Services.AddHostedService(); + +builder.Logging.SetMinimumLevel(LogLevel.Warning); + +var app = builder.Build(); + +await app.RunAsync(); + +internal class Worker : BackgroundService +{ + private readonly AdministratorUi _adminUi; + + public Worker(AdministratorUi adminUi) + { + _adminUi = adminUi; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + await _adminUi.MainMenu(); + } +} \ No newline at end of file diff --git a/Ecommerce-API/ECommerce.UI/Services/ApiService.cs b/Ecommerce-API/ECommerce.UI/Services/ApiService.cs new file mode 100644 index 00000000..6d4a5016 --- /dev/null +++ b/Ecommerce-API/ECommerce.UI/Services/ApiService.cs @@ -0,0 +1,72 @@ +using System.Net.Http.Json; +using ECommerce.UI.Configuration; +using ECommerce.UI.Interfaces; + +namespace ECommerce.UI.Services; + +public class ApiService(IHttpClientFactory clientFactory) : IApiService +{ + private static readonly string BaseUrl = ApiSettings.BaseUrl; + + public Task GetAsync(string path) + { + return SendGetRequest(path); + } + + + public Task PostAsync(string path, T body) + { + return SendPostRequest(path, body); + } + + public async Task DeleteAsync(string path) + { + await SendDeleteRequest(path); + } + + + //------- Helper Methods ------- + private async Task SendGetRequest(string path) + { + var client = clientFactory.CreateClient(BaseUrl); + var response = await client.GetAsync(path); + + return await HandleResponse(response); + } + + private async Task SendPostRequest(string path, T body) + { + var client = clientFactory.CreateClient(BaseUrl); + var response = await client.PostAsJsonAsync(path, body); + await HandleResponse(response); + } + + private async Task SendDeleteRequest(string path) + { + var client = clientFactory.CreateClient(BaseUrl); + var response = await client.DeleteAsync(path); + + await HandleResponse(response); + } + + private static async Task HandleResponse(HttpResponseMessage response) + { + if (response.IsSuccessStatusCode) return await response.Content.ReadAsStringAsync(); + + switch ((int)response.StatusCode) + { + case var code when code >= 400 && code < 500: + throw new HttpRequestException($"Client side error occurred, status code: {code}", null, + response.StatusCode); + + case var code when code >= 500: + throw new HttpRequestException($"Server side error occurred, status code: {code}", null, + response.StatusCode); + + default: + throw new HttpRequestException( + $"An unexpected error occurred with the status code: {response.StatusCode}", + null, response.StatusCode); + } + } +} \ No newline at end of file diff --git a/Ecommerce-API/ECommerce.UI/Services/CartService.cs b/Ecommerce-API/ECommerce.UI/Services/CartService.cs new file mode 100644 index 00000000..f075dce1 --- /dev/null +++ b/Ecommerce-API/ECommerce.UI/Services/CartService.cs @@ -0,0 +1,50 @@ +using System.Text.Json; +using ECommerce.Shared.Models; +using ECommerce.UI.Configuration; +using ECommerce.UI.Helpers; +using ECommerce.UI.Interfaces; + +namespace ECommerce.UI.Services; + +public class CartService : ICartService +{ + private readonly string _cartPath = Path.Combine(AppSettings.AppDataPath, "userCart.json"); + + public async Task AddToCartAsync(ItemDto item) + { + var cart = await GetCartAsync(); + cart.Add(item); + await SaveCartAsync(cart); + } + + public async Task> GetCartAsync() + { + if (!File.Exists(_cartPath)) + return new List(); + + var json = await File.ReadAllTextAsync(_cartPath); + return JsonSerializer.Deserialize>(json, Utils.GetJsonSerializerOptions()) ?? new List(); + } + + public async Task RemoveFromCartAsync(int itemId) + { + var cart = await GetCartAsync(); + cart.RemoveAll(i => i.ItemId == itemId); + await SaveCartAsync(cart); + } + + public void ClearCart() + { + if (File.Exists(_cartPath)) + File.Delete(_cartPath); + } + + private async Task SaveCartAsync(List items) + { + var directory = Path.GetDirectoryName(_cartPath); + Directory.CreateDirectory(directory!); + + var json = JsonSerializer.Serialize(items, Utils.GetJsonSerializerOptions()); + await File.WriteAllTextAsync(_cartPath, json); + } +} \ No newline at end of file diff --git a/Ecommerce-API/ECommerce.UI/Services/ItemService.cs b/Ecommerce-API/ECommerce.UI/Services/ItemService.cs new file mode 100644 index 00000000..9d3bc1cd --- /dev/null +++ b/Ecommerce-API/ECommerce.UI/Services/ItemService.cs @@ -0,0 +1,56 @@ +using System.Text.Json; +using ECommerce.Shared; +using ECommerce.Shared.Models; +using ECommerce.UI.Configuration; +using ECommerce.UI.Helpers; +using ECommerce.UI.Interfaces; + +namespace ECommerce.UI.Services; + +public class ItemService(IApiService apiService) : IItemService +{ + public async Task> GetItemsAsync(int pageNumber = 1, int pageSize = 10, + string? searchTerm = null, string? searchGenre = null, List? searchTags = null) + { + var requestUrl = Utils.FormatQueryWithPaginationParams(ApiUris.ItemRequestUri, + pageNumber, pageSize, searchTerm, searchGenre, searchTags); + + var rawJson = await apiService.GetAsync(requestUrl); + return JsonSerializer.Deserialize>(rawJson, Utils.GetJsonSerializerOptions())!; + } + + public async Task GetItemByIdAsync(int id) + { + var rawJson = await apiService.GetAsync($"{ApiUris.ItemRequestUri}/{id}"); + + return JsonSerializer.Deserialize(rawJson, Utils.GetJsonSerializerOptions())!; + } + + public async Task DeleteItemAsync(int id) + { + await apiService.DeleteAsync($"{ApiUris.ItemRequestUri}/{id}"); + } + + public async Task PostItemAsync(ItemFormat format, ItemType type, string title, string artist, decimal price, + string genre, + string tags) + { + List tagDtos = []; + + var tagArray = tags.Split(','); + foreach (var tag in tagArray) tagDtos.Add(new TagDto { TagName = tag }); + + var itemDto = new CreateItemDto + { + Format = format, + Type = type, + Name = title, + Artist = artist, + Price = price, + Genre = genre, + Tags = tagDtos + }; + + await apiService.PostAsync(ApiUris.ItemRequestUri, itemDto); + } +} \ No newline at end of file diff --git a/Ecommerce-API/ECommerce.UI/Services/SaleService.cs b/Ecommerce-API/ECommerce.UI/Services/SaleService.cs new file mode 100644 index 00000000..68203647 --- /dev/null +++ b/Ecommerce-API/ECommerce.UI/Services/SaleService.cs @@ -0,0 +1,38 @@ +using System.Text.Json; +using ECommerce.Shared.Models; +using ECommerce.UI.Configuration; +using ECommerce.UI.Helpers; +using ECommerce.UI.Interfaces; + +namespace ECommerce.UI.Services; + +public class SaleService(IApiService apiService, IItemService itemService) : ISaleService +{ + public async Task> GetSalesAsync(int pageNumber = 1, int pageSize = 10) + { + var requestUrl = + Utils.FormatQueryWithPaginationParams(ApiUris.SaleRequestUri, pageNumber, pageSize, null, null, null); + var rawJson = await apiService.GetAsync(requestUrl); + + return JsonSerializer.Deserialize>(rawJson, Utils.GetJsonSerializerOptions())!; + } + + public async Task PostSaleAsync(Dictionary itemIdQuantityPairs) + { + List saleItems = []; + foreach (var pair in itemIdQuantityPairs) + { + var item = await itemService.GetItemByIdAsync(pair.Key); + if (item is null) throw new ArgumentException($"No item found with the ID {pair.Key}"); + + saleItems.Add(new CreateSaleItemDto { ItemId = pair.Key, Quantity = pair.Value }); + } + + await apiService.PostAsync(ApiUris.SaleRequestUri, saleItems); + } + + public async Task DeleteSaleAsync(int id) + { + await apiService.DeleteAsync($"{ApiUris.SaleRequestUri}?saleId={id}"); + } +} \ No newline at end of file diff --git a/Ecommerce-API/ECommerce.UI/Services/TagService.cs b/Ecommerce-API/ECommerce.UI/Services/TagService.cs new file mode 100644 index 00000000..5a420925 --- /dev/null +++ b/Ecommerce-API/ECommerce.UI/Services/TagService.cs @@ -0,0 +1,43 @@ +using System.Text.Json; +using ECommerce.Shared.Models; +using ECommerce.UI.Configuration; +using ECommerce.UI.Helpers; +using ECommerce.UI.Interfaces; + +namespace ECommerce.UI.Services; + +public class TagService(IApiService apiService) : ITagService +{ + public async Task> GetTagsAsync(int pageNumber = 1, int pageSize = 10, + string? searchTerm = null) + { + var requestUrl = Utils.FormatQueryWithPaginationParams(ApiUris.TagRequestUri, + pageNumber, pageSize, searchTerm, null, null); + + var rawJson = await apiService.GetAsync(requestUrl); + return JsonSerializer.Deserialize>(rawJson, Utils.GetJsonSerializerOptions())!; + } + + public async Task GetTagIdByNameAsync(string tagName) + { + var requestUrl = $"{ApiUris.TagRequestUri}/{tagName}"; + + var rawJson = await apiService.GetAsync(requestUrl); + return JsonSerializer.Deserialize(rawJson, Utils.GetJsonSerializerOptions()); + } + + public async Task PostTagAsync(string tagName) + { + var tagDto = new CreateTagDto + { + TagName = tagName + }; + + await apiService.PostAsync(ApiUris.TagRequestUri, tagDto); + } + + public async Task DeleteTagAsync(int tagId) + { + await apiService.DeleteAsync($"{ApiUris.TagRequestUri}/{tagId}"); + } +} \ No newline at end of file diff --git a/Ecommerce-API/ECommerce.UI/Services/VerificationService.cs b/Ecommerce-API/ECommerce.UI/Services/VerificationService.cs new file mode 100644 index 00000000..eec5fa4d --- /dev/null +++ b/Ecommerce-API/ECommerce.UI/Services/VerificationService.cs @@ -0,0 +1,40 @@ +using ECommerce.Shared; +using ECommerce.UI.Interfaces; + +namespace ECommerce.UI.Services; + +public class VerificationService : IVerificationService +{ + public bool TryValidateItemPrice(string input, out decimal itemPrice, out string? errorMessage) + { + if (!decimal.TryParse(input, out itemPrice)) + { + errorMessage = "Could not parse item price into a number."; + return false; + } + + if (itemPrice <= 0) + { + errorMessage = "Item price must be greater than or equal to 1."; + return false; + } + + errorMessage = null; + return true; + } + + public bool TryParseValidQuantity(string quantity, out int parsedQuantity) + { + return int.TryParse(quantity, out parsedQuantity) && parsedQuantity > 0; + } + + public bool TryParseItemFormat(string input, out ItemFormat itemFormat) + { + return Enum.TryParse(input, true, out itemFormat); + } + + public bool TryParseItemType(string input, out ItemType itemType) + { + return Enum.TryParse(input, true, out itemType); + } +} \ No newline at end of file diff --git a/Ecommerce-API/ECommerce.UI/UserInterface/AdministratorUi/AdministratorUi.cs b/Ecommerce-API/ECommerce.UI/UserInterface/AdministratorUi/AdministratorUi.cs new file mode 100644 index 00000000..a073956c --- /dev/null +++ b/Ecommerce-API/ECommerce.UI/UserInterface/AdministratorUi/AdministratorUi.cs @@ -0,0 +1,55 @@ +using ECommerce.UI.Enums; +using ECommerce.UI.Helpers; +using static ECommerce.UI.Helpers.DisplayHelper; + +namespace ECommerce.UI.UserInterface.AdministratorUi; + +internal class AdministratorUi( + ManageProductsUi manageProductsUi, + ManageProductTagsUi manageProductTagsUi, + ManageSalesUi manageSalesUi, + TestingUi.TestingUi testingUi) +{ + //------- Main Menu Methods ------- + internal async Task MainMenu() + { + while (true) + try + { + Console.Clear(); + var option = DisplayMenu(); + await HandleMainMenuOption(option); + } + catch (Exception ex) + { + UiHelper.DisplayCaughtException(ex); + } + } + + private async Task HandleMainMenuOption(AdminMainMenu option) + { + switch (option) + { + case AdminMainMenu.ManageProducts: + await manageProductsUi.ManageProductsMenu(); + break; + case AdminMainMenu.ManageProductTags: + await manageProductTagsUi.ManageProductTags(); + break; + case AdminMainMenu.ManageSales: + await manageSalesUi.ManageSales(); + break; + case AdminMainMenu.EnterTestingEnvironment: + await testingUi.TestingMenu(); + break; + case AdminMainMenu.ExitApplication: + ExitApplication(); + break; + } + } + + private static void ExitApplication() + { + Environment.Exit(0); + } +} \ No newline at end of file diff --git a/Ecommerce-API/ECommerce.UI/UserInterface/AdministratorUi/ManageProductTagsUi.cs b/Ecommerce-API/ECommerce.UI/UserInterface/AdministratorUi/ManageProductTagsUi.cs new file mode 100644 index 00000000..e0faa48a --- /dev/null +++ b/Ecommerce-API/ECommerce.UI/UserInterface/AdministratorUi/ManageProductTagsUi.cs @@ -0,0 +1,234 @@ +using System.Net; +using ECommerce.Shared.Models; +using ECommerce.UI.Enums; +using ECommerce.UI.Helpers; +using ECommerce.UI.Interfaces; +using Spectre.Console; +using static ECommerce.UI.Helpers.DisplayHelper; + +namespace ECommerce.UI.UserInterface.AdministratorUi; + +internal class ManageProductTagsUi(ITagService tagService) +{ + private readonly UiHelper _uiHelper = new(tagService); + + //------- Menu Methods ------- + internal async Task ManageProductTags() + { + while (true) + { + Console.Clear(); + + var option = DisplayMenu(); + + switch (option) + { + case ManageProductTagsMenu.ReviewTags: + await ReviewTags(); + break; + case ManageProductTagsMenu.SearchTags: + await SearchTags(); + break; + case ManageProductTagsMenu.CreateNewTag: + await CreateNewTag(); + break; + case ManageProductTagsMenu.DeleteTag: + await DeleteTag(); + break; + case ManageProductTagsMenu.Back: + return; + } + } + } + + //------- CRUD Menus ------- + /// + /// Presents a list of tags to the user and handles pagination + /// + /// optional search term to filter results + /// returns a list of tags selected by the user if set to true + internal async Task?> ReviewTags(string? searchTerm = null, bool returnTagSelection = false) + { + var pageNumber = 1; + while (true) + try + { + Console.Clear(); + var response = await tagService.GetTagsAsync(pageNumber, searchTerm: searchTerm); + + if (!returnTagSelection) + { + var table = _uiHelper.BuildTagTable(response); + DisplayTable(table); + } + else + { + var selection = DisplayMultiPrompt(response.Data.Select(t => t.TagName).ToList()); + var selectedTags = response.Data + .Where(t => selection.Contains(t.TagName)) + .ToList(); + return selectedTags; + } + + var option = UiHelper.DisplayPaginationController(response.PageNumber, response.TotalPages); + switch (option) + { + case PaginationController.LastPage: + pageNumber = pageNumber == 1 ? 1 : pageNumber - 1; + break; + case PaginationController.NextPage: + pageNumber += 1; + break; + case PaginationController.Back: + return null; + } + } + catch (HttpRequestException ex) + { + if (ex.StatusCode == HttpStatusCode.NotFound) + { + DisplayWarning("No results were found when searching, please try a different search or make sure the database isn't empty."); + UiHelper.WaitForUser(); + return null; + } + UiHelper.DisplayCaughtException(ex); + return null; + } + } + + internal async Task?> SelectTag(bool allowMultiSelection = false) + { + var pageNumber = 1; + while (true) + { + try + { + var response = await tagService.GetTagsAsync(); + + var table = _uiHelper.BuildTagTable(response); + DisplayTable(table); + + var option = UiHelper.DisplayPaginationControllerWithSelectionOption(response.PageNumber, response.TotalPages); + switch (option) + { + case PaginationControllerWithSelection.LastPage: + pageNumber = pageNumber == 1 ? 1 : pageNumber - 1; + continue; + case PaginationControllerWithSelection.NextPage: + pageNumber++; + continue; + case PaginationControllerWithSelection.SelectProduct: + break; + case PaginationControllerWithSelection.Back: + return null; + } + + Dictionary dictionary = new(); + foreach (var tag in response.Data) + { + dictionary.Add(tag.TagName, tag); + } + + if (allowMultiSelection) + { + var selections = DisplayMultiPrompt(dictionary.Keys.ToList(), requireChoice: true); + return dictionary.Where(v => selections.Contains(v.Key)).Select(v => v.Value).ToList(); + } + + var selection = DisplayPrompt(dictionary.Keys.ToList()); + return new List {dictionary[selection]}; + } + catch (HttpRequestException ex) + { + if (ex.StatusCode == HttpStatusCode.NotFound) + { + DisplayWarning("No results were found when searching, please try a different search or make sure the database isn't empty."); + UiHelper.WaitForUser(); + return null; + } + UiHelper.DisplayCaughtException(ex); + return null; + } + } + } + + /// + /// Searches the database for tags matching a search term + /// + /// Optional parameter to return a list of tags selected from search + /// + internal async Task?> SearchTags(bool returnSearchTags = false) + { + while (true) + { + List? selectedTags = null; + Console.Clear(); + + var searchTerm = UiHelper.GetArgument("Please enter a term to search by:"); + if (searchTerm is null) return null; + + if (returnSearchTags) + { + selectedTags = await SelectTag(allowMultiSelection: true); + } + else + { + selectedTags = await ReviewTags(searchTerm); + } + + if (!await AnsiConsole.ConfirmAsync("Would you like to perform another search?")) + return returnSearchTags ? selectedTags : null; + } + } + + private async Task CreateNewTag() + { + while (true) + { + var name = UiHelper.GetArgument("Please enter a name for the new tag:"); + if (name is null) return; + + if (!await AnsiConsole.ConfirmAsync($"are you sure you want to create a new tag with the name {name}?")) + continue; + + try + { + await tagService.PostTagAsync(name); + DisplaySuccess("Successfully created a new tag"); + UiHelper.WaitForUser(); + } + catch (HttpRequestException ex) + { + UiHelper.DisplayCaughtException(ex); + } + + return; + } + } + + private async Task DeleteTag() + { + while (true) + { + var tags = await SelectTag(); + if (tags is null) return; + var tagId = await tagService.GetTagIdByNameAsync(tags[0].TagName); + + if (!await AnsiConsole.ConfirmAsync($"Are you sure you want to delete this tag?")) + continue; + + try + { + await tagService.DeleteTagAsync(tagId); + DisplaySuccess("Successfully deleted tag."); + UiHelper.WaitForUser(); + } + catch (HttpRequestException ex) + { + UiHelper.DisplayCaughtException(ex); + } + + return; + } + } +} \ No newline at end of file diff --git a/Ecommerce-API/ECommerce.UI/UserInterface/AdministratorUi/ManageProductsUi.cs b/Ecommerce-API/ECommerce.UI/UserInterface/AdministratorUi/ManageProductsUi.cs new file mode 100644 index 00000000..27294faa --- /dev/null +++ b/Ecommerce-API/ECommerce.UI/UserInterface/AdministratorUi/ManageProductsUi.cs @@ -0,0 +1,318 @@ +using System.Net; +using System.Text; +using ECommerce.Shared; +using ECommerce.Shared.Models; +using ECommerce.UI.Enums; +using ECommerce.UI.Helpers; +using ECommerce.UI.Interfaces; +using Spectre.Console; +using static ECommerce.UI.Helpers.DisplayHelper; + +namespace ECommerce.UI.UserInterface.AdministratorUi; + +internal class ManageProductsUi(IItemService itemService, IVerificationService verificationService, ManageProductTagsUi tagsMenu) +{ + //------- Menu Methods ------- + internal async Task ManageProductsMenu() + { + while (true) + { + Console.Clear(); + var option = DisplayMenu(); + + switch (option) + { + case ManageProductsMenuOption.ReviewProducts: + await ReviewProductsMenu(); + break; + case ManageProductsMenuOption.SearchProducts: + await SearchProducts(); + break; + case ManageProductsMenuOption.CreateNewProduct: + await CreateProduct(); + break; + case ManageProductsMenuOption.DeleteProduct: + await DeleteProduct(); + break; + case ManageProductsMenuOption.Back: + return; + } + } + } + + //------- CRUD Menus ------- + /// + /// Presents a list of products to the user and handles pagination + /// + /// optional search term to filter results + /// optional genre filter + /// options tag filter + private async Task ReviewProductsMenu(string? searchTerm = null, string? searchGenre = null, List? searchTags = null) + { + var pageNumber = 1; + while (true) + try + { + Console.Clear(); + var response = await itemService.GetItemsAsync(pageNumber, searchTerm: searchTerm, searchGenre: searchGenre, tags: searchTags); + + var table = UiHelper.BuildItemTable(response); + DisplayTable(table); + + var option = UiHelper.DisplayPaginationController(response.PageNumber, response.TotalPages); + switch (option) + { + case PaginationController.LastPage: + pageNumber = pageNumber == 1 ? 1 : pageNumber - 1; + break; + case PaginationController.NextPage: + pageNumber += 1; + break; + case PaginationController.Back: + return; + } + } + catch (HttpRequestException ex) + { + if (ex.StatusCode == HttpStatusCode.NotFound) + { + DisplayWarning("No results were found when searching, please try a different search or make sure the database isn't empty."); + UiHelper.WaitForUser(); + return; + } + UiHelper.DisplayCaughtException(ex); + return; + } + } + + private async Task SelectProduct(string? searchTerm = null, string? searchGenre = null, + List? searchTags = null) + { + var pageNumber = 1; + while (true) + { + try + { + Console.Clear(); + var response = await itemService.GetItemsAsync(pageNumber, searchTerm: searchTerm, + searchGenre: searchGenre, tags: searchTags); + + var table = UiHelper.BuildItemTable(response); + DisplayTable(table); + + var option = UiHelper.DisplayPaginationControllerWithSelectionOption(response.PageNumber, response.TotalPages); + switch (option) + { + case PaginationControllerWithSelection.LastPage: + pageNumber = pageNumber == 1 ? 1 : pageNumber - 1; + continue; + case PaginationControllerWithSelection.NextPage: + pageNumber += 1; + continue; + case PaginationControllerWithSelection.SelectProduct: + break; + case PaginationControllerWithSelection.Back: + return null; + } + + Dictionary dictionary = new(); + foreach (var item in response.Data) + { + dictionary.Add($"{item.Name} - {item.Artist} | {item.Price}", item); + } + + var selection = DisplayPrompt(dictionary.Keys.ToList()); + return dictionary[selection]; + } + catch (HttpRequestException ex) + { + if (ex.StatusCode == HttpStatusCode.NotFound) + { + DisplayWarning("No results were found when searching, please try a different search or make sure the database isn't empty."); + UiHelper.WaitForUser(); + return null; + } + UiHelper.DisplayCaughtException(ex); + return null; + } + } + } + + /// + /// + /// + /// Decides if the method should return the selected product or not + /// The selected product, or null if returnProduct is set to false + private async Task SearchProducts(bool returnProduct = false) + { + while (true) + { + Console.Clear(); + + var enumNames = Enum.GetNames().ToList(); + var options = DisplayMultiPrompt(enumNames, requireChoice: false); + + List selectedFilters; + try + { + selectedFilters = options + .Select(s => Enum.Parse(s)) + .ToList(); + } + catch (Exception) + { + DisplayWarning("Please select to either search by a given search term, or filter by genre."); + UiHelper.WaitForUser(); + continue; + } + + string? searchTerm = null; + string? searchGenre = null; + List? searchTags = null; + + if (selectedFilters.Contains(SearchController.SearchByTerm)) + { + Console.Clear(); + searchTerm = DisplayQuestion("Please enter a term to search for:"); + } + + if (selectedFilters.Contains(SearchController.FilterByTags)) + { + switch (DisplayMenu()) + { + case SearchTagsController.SearchForSpecificTag: + searchTags = await tagsMenu.SearchTags(true); + break; + + case SearchTagsController.BrowseAllTags: + searchTags = await tagsMenu.SelectTag(allowMultiSelection: true); + break; + + case SearchTagsController.Back: + break; + } + + if (searchTags is null || searchTags.Count == 0) return null; + } + + if (selectedFilters.Contains(SearchController.FilterByGenre)) + { + Console.Clear(); + searchGenre = DisplayQuestion("Please enter a genre to filter by:"); + } + + var item = await SelectProduct(searchTerm, searchGenre, searchTags); + if (returnProduct) return item; + + if (!await AnsiConsole.ConfirmAsync("Would you like to perform another search?")) + return null; + } + } + + private async Task CreateProduct() + { + ItemFormat format; + ItemType type; + decimal price; + var enteredDetails = new StringBuilder(); + + var title = UiHelper.GetArgument("Please enter the item title:"); + if (title is null) return; + enteredDetails.Append($"Title: {title} "); + + var artist = UiHelper.GetArgument("Please enter the artist's name:", enteredDetails.ToString()); + if (artist is null) return; + enteredDetails.Append($"Artist: {artist} "); + + var genre = UiHelper.GetArgument("Please enter the genre:", enteredDetails.ToString()); + if (genre is null) return; + enteredDetails.Append($"Genre: {genre} "); + + var tagOption = DisplayMenu(); + List? selectedTags = null; + + switch (tagOption) + { + case TagAdditionMethodForItem.SearchForAnExistingTag: + selectedTags = await tagsMenu.SearchTags(returnSearchTags: true); + break; + case TagAdditionMethodForItem.CreateNewTag: + var response = UiHelper.GetArgument("Please enter tag names separated by a ','"); + if (response is null) return; + selectedTags = response.Split(',') + .Select(name => new TagDto { TagName = name.Trim() }) + .ToList(); + break; + case TagAdditionMethodForItem.CreateWithoutTags: + break; + } + + string tags = selectedTags is not null + ? string.Join(", ", selectedTags.Select(t => t.TagName)) + : string.Empty; + if (string.IsNullOrWhiteSpace(tags)) tags = "No Tags"; + enteredDetails.Append($"Tags: {tags} "); + + format = DisplayMenu(); + enteredDetails.Append($"Format: {format} "); + + type = DisplayMenu(); + enteredDetails.Append($"Type: {type} "); + + while (true) + { + var unparsedPrice = UiHelper.GetArgument("Please enter the item's price:", enteredDetails.ToString()); + if (unparsedPrice is null) return; + + if (!verificationService.TryValidateItemPrice(unparsedPrice, out price, out var errorMessage)) + { + DisplayWarning(errorMessage ?? "Please enter a valid price for this product."); + UiHelper.WaitForUser(); + continue; + } + + break; + } + + try + { + await itemService.PostItemAsync(format, type, title, artist, price, genre, tags); + DisplaySuccess("Successfully created product."); + UiHelper.WaitForUser(); + } + catch (HttpRequestException ex) + { + UiHelper.DisplayCaughtException(ex); + } + } + + private async Task DeleteProduct() + { + while (true) + { + try + { + var item = await SearchProducts(returnProduct: true); + if (item is null) return; + + if (await AnsiConsole.ConfirmAsync("Are you sure you want to delete this item?")) + { + await itemService.DeleteItemAsync(item.ItemId); + DisplaySuccess("Successfully deleted product."); + } + else + { + DisplaySuccess("Deletion was cancelled.."); + } + + UiHelper.WaitForUser(); + return; + } + catch (Exception ex) + { + UiHelper.DisplayCaughtException(ex); + return; + } + } + } +} \ No newline at end of file diff --git a/Ecommerce-API/ECommerce.UI/UserInterface/AdministratorUi/ManageSalesUi.cs b/Ecommerce-API/ECommerce.UI/UserInterface/AdministratorUi/ManageSalesUi.cs new file mode 100644 index 00000000..45398df4 --- /dev/null +++ b/Ecommerce-API/ECommerce.UI/UserInterface/AdministratorUi/ManageSalesUi.cs @@ -0,0 +1,193 @@ +using System.Net; +using ECommerce.Shared.Models; +using ECommerce.UI.Enums; +using ECommerce.UI.Helpers; +using ECommerce.UI.Interfaces; +using Spectre.Console; +using static ECommerce.UI.Helpers.DisplayHelper; + +namespace ECommerce.UI.UserInterface.AdministratorUi; + +internal class ManageSalesUi(ISaleService saleService, IVerificationService verificationService) +{ + //------- Menu Methods ------- + internal async Task ManageSales() + { + while (true) + { + Console.Clear(); + var option = DisplayMenu(); + + switch (option) + { + case ManageSalesMenu.ReviewSales: + await ReviewSales(); + break; + case ManageSalesMenu.CreateNewSale: + await CreateNewSale(); + break; + case ManageSalesMenu.DeleteSale: + await DeleteSale(); + break; + case ManageSalesMenu.Back: + return; + } + } + } + + //------- CRUD Menus ------- + private async Task ReviewSales() + { + var pageNumber = 1; + while (true) + try + { + Console.Clear(); + var response = await saleService.GetSalesAsync(pageNumber); + var table = UiHelper.BuildSaleTable(response); + + DisplayTable(table); + + var option = UiHelper.DisplayPaginationController(response.PageNumber, response.TotalPages); + switch (option) + { + case PaginationController.LastPage: + pageNumber = pageNumber == 1 ? 1 : pageNumber - 1; + break; + case PaginationController.NextPage: + pageNumber += 1; + break; + case PaginationController.Back: + return; + } + } + catch (HttpRequestException ex) + { + if (ex.StatusCode == HttpStatusCode.NotFound) + { + DisplayWarning("No results were found when searching, please try a different search or make sure the database isn't empty."); + UiHelper.WaitForUser(); + return; + } + UiHelper.DisplayCaughtException(ex); + return; + } + } + + private async Task SelectSale() + { + var pageNumber = 1; + while (true) + { + try + { + var response = await saleService.GetSalesAsync(pageNumber); + + var table = UiHelper.BuildSaleTable(response); + DisplayTable(table); + + var option = UiHelper.DisplayPaginationControllerWithSelectionOption(response.PageNumber, response.TotalPages); + switch (option) + { + case PaginationControllerWithSelection.LastPage: + pageNumber = pageNumber == 1 ? 1 : pageNumber - 1; + continue; + case PaginationControllerWithSelection.NextPage: + pageNumber++; + continue; + case PaginationControllerWithSelection.SelectProduct: + break; + case PaginationControllerWithSelection.Back: + return null; + } + + Dictionary dictionary = new(); + foreach (var sale in response.Data) + { + dictionary.Add($"Sale ID {sale.SaleId} | Price {sale.TotalPrice}", sale); + } + + var selection = DisplayPrompt(dictionary.Keys.ToList()); + return dictionary[selection]; + } + catch (HttpRequestException ex) + { + if (ex.StatusCode == HttpStatusCode.NotFound) + { + DisplayWarning("No results were found when searching, please try a different search or make sure the database isn't empty."); + UiHelper.WaitForUser(); + return null; + } + UiHelper.DisplayCaughtException(ex); + return null; + } + } + } + + private async Task CreateNewSale() + { + Dictionary idQuantityPair = []; + while (true) + { + Console.Clear(); + var id = UiHelper.GetArgument("Please enter the ID of the sold item:"); + if (id is null) return; + + var quantity = UiHelper.GetArgument("Please enter the quantity of the sold item:"); + if (quantity is null) return; + + if (!verificationService.TryParseValidQuantity(quantity, out var parsedQuantity) + || !int.TryParse(id, out var parsedId)) + { + DisplayWarning("Please enter a valid number that is greater than or equal to 1."); + UiHelper.WaitForUser(); + continue; + } + + idQuantityPair.Add(parsedId, parsedQuantity); + + if (!await AnsiConsole.ConfirmAsync("Would you like to add another item to the sale?")) + break; + } + + try + { + await saleService.PostSaleAsync(idQuantityPair); + DisplaySuccess("Successfully created a new sale record."); + UiHelper.WaitForUser(); + } + catch (HttpRequestException ex) + { + UiHelper.DisplayCaughtException(ex); + } + } + + private async Task DeleteSale() + { + while (true) + { + var sale = await SelectSale(); + if (sale is null) return; + + if (!await AnsiConsole.ConfirmAsync($"Are you sure you want to delete this sale?")) + { + DisplaySuccess("Deletion was cancelled."); + UiHelper.WaitForUser(); + return; + } + + try + { + await saleService.DeleteSaleAsync(sale.SaleId); + DisplaySuccess("Successfully deleted the sale."); + UiHelper.WaitForUser(); + } + catch (HttpRequestException ex) + { + UiHelper.DisplayCaughtException(ex); + } + + return; + } + } +} \ No newline at end of file diff --git a/Ecommerce-API/ECommerce.UI/UserInterface/TestingUi/CheckoutUi.cs b/Ecommerce-API/ECommerce.UI/UserInterface/TestingUi/CheckoutUi.cs new file mode 100644 index 00000000..ae7b3317 --- /dev/null +++ b/Ecommerce-API/ECommerce.UI/UserInterface/TestingUi/CheckoutUi.cs @@ -0,0 +1,140 @@ +using ECommerce.Shared.Models; +using ECommerce.UI.Enums; +using ECommerce.UI.Helpers; +using ECommerce.UI.Interfaces; +using Spectre.Console; +using Spectre.Console.Rendering; +using static ECommerce.UI.Helpers.DisplayHelper; + +namespace ECommerce.UI.UserInterface.TestingUi; + +internal class CheckoutUi(ICartService cartService) +{ + internal async Task AddItemToCartAsync(PagedResponse response) + { + Console.Clear(); + + Dictionary items = new(); + foreach (var item in response.Data) items.Add($"{item.Name} - {item.Artist} - ${item.Price}", item); + + var options = DisplayMultiPrompt(items.Keys.ToList(), requireChoice: false); + + if (options.Count > 0) + foreach (var option in options) + await cartService.AddToCartAsync(items[option]); + + DisplaySuccess("Successfully added items to cart."); + UiHelper.WaitForUser(); + } + + internal async Task CheckoutMenu() + { + while (true) + { + Console.Clear(); + + var cart = await cartService.GetCartAsync(); + + DisplayMessage("Current cart:"); + + if (cart.Count == 0) + { + DisplayWarning("There are no items in your cart, please add some and try again."); + UiHelper.WaitForUser(); + return; + } + + DisplayRows(BuildItemDtoRenderable(cart)); + + var option = DisplayMenu(); + + switch (option) + { + case Enums.CheckoutMenu.CheckoutItems: + await CheckOut(); + break; + case Enums.CheckoutMenu.RemoveItem: + await RemoveItemsAsync(cart); + break; + case Enums.CheckoutMenu.ClearAllItems: + await ClearCartAsync(); + break; + case Enums.CheckoutMenu.Back: + return; + } + } + } + + private async Task RemoveItemsAsync(List items) + { + var itemList = BuildItemDtoStringList(items); + Dictionary itemDictionary = new(); + + for (var i = 0; i < items.Count; i++) itemDictionary.Add(itemList[i], items[i]); + + var options = DisplayMultiPrompt(itemDictionary.Keys.ToList(), "Please choose the item(s) to remove:", false); + + foreach (var option in options) await cartService.RemoveFromCartAsync(itemDictionary[option].ItemId); + + DisplaySuccess("Successfully removed items from cart."); + UiHelper.WaitForUser(); + } + + private async Task ClearCartAsync() + { + if (await AnsiConsole.ConfirmAsync("Are you sure you want to delete all items in your cart?")) + { + cartService.ClearCart(); + DisplaySuccess("Successfully deleted the contents of your cart."); + } + else + { + DisplaySuccess("Deletion was cancelled."); + } + + UiHelper.WaitForUser(); + } + + private async Task CheckOut() + { + const string fakeCardDetails = "************1112, expires: 12/2027"; + const string fakeEmailAddress = "******@gmail.com"; + + Console.Clear(); + DisplayWarning( + "No checkout process has currently been built, this is only a replication of what a checkout process might look like after development. Checking out will clear your cart afterwards."); + + if (await AnsiConsole.ConfirmAsync($"Would you like to checkout with the card: {fakeCardDetails}?")) + { + //await cartService.CheckoutCart(CardDetails card) + + cartService.ClearCart(); + + DisplaySuccess( + $"Cart was successfully checked out, please check for an order confirmation email sent to your email: {fakeEmailAddress}"); + } + + UiHelper.WaitForUser(); + } + + //------- Helper Methods ------- + private List BuildItemDtoRenderable(List items) + { + List iRenderables = []; + + foreach (var item in items) + iRenderables.Add(new Markup($"[{White}]{item.Name} - {item.Artist}[/][{Grey}] - {item.Price}[/]")); + + return iRenderables; + } + + private List BuildItemDtoStringList(List items) + { + List itemsAsStrings = []; + + foreach (var item in items) + itemsAsStrings.Add($"[{White}]{item.Name} - {item.Artist}[/][{Grey}] - {item.Price}[/]"); + + return itemsAsStrings; + } +} \ No newline at end of file diff --git a/Ecommerce-API/ECommerce.UI/UserInterface/TestingUi/ProductTestUi.cs b/Ecommerce-API/ECommerce.UI/UserInterface/TestingUi/ProductTestUi.cs new file mode 100644 index 00000000..83f7c2e1 --- /dev/null +++ b/Ecommerce-API/ECommerce.UI/UserInterface/TestingUi/ProductTestUi.cs @@ -0,0 +1,97 @@ +using ECommerce.UI.Enums; +using ECommerce.UI.Helpers; +using ECommerce.UI.Interfaces; +using Spectre.Console; +using static ECommerce.UI.Helpers.DisplayHelper; + +namespace ECommerce.UI.UserInterface.TestingUi; + +internal class ProductTestUi(IItemService itemService, CheckoutUi checkoutUi) +{ + /// + /// Presents a list of products to the user and handles pagination + /// + /// optional search term to filter results + /// optional genre filter + internal async Task ReviewProductsMenu(string? searchTerm = null, string? searchGenre = null) + { + var pageNumber = 1; + while (true) + try + { + Console.Clear(); + var response = + await itemService.GetItemsAsync(pageNumber, searchTerm: searchTerm, searchGenre: searchGenre); + var table = UiHelper.BuildItemTable(response); + + + DisplayTable(table); + + var option = DisplayMenu(); + switch (option) + { + case PaginationControllerWithAddToCart.LastPage: + pageNumber = pageNumber == 1 ? 1 : pageNumber - 1; + break; + case PaginationControllerWithAddToCart.NextPage: + pageNumber += 1; + break; + case PaginationControllerWithAddToCart.AddToCart: + await checkoutUi.AddItemToCartAsync(response); + break; + case PaginationControllerWithAddToCart.Back: + return; + } + } + catch (HttpRequestException ex) + { + UiHelper.DisplayCaughtException(ex); + return; + } + } + + internal async Task SearchProducts() + { + while (true) + { + Console.Clear(); + + var enumNames = Enum.GetNames().ToList(); + var options = DisplayMultiPrompt(enumNames, requireChoice: false); + + List selectedFilters; + try + { + selectedFilters = options + .Select(s => Enum.Parse(s)) + .ToList(); + } + catch (Exception ex) + { + DisplayWarning("Please select to either search by a given search term, or filter by genre."); + UiHelper.WaitForUser(); + continue; + } + + string? searchTerm = null; + string? searchGenre = null; + + if (selectedFilters.Contains(SearchController.SearchByTerm)) + { + Console.Clear(); + searchTerm = DisplayQuestion("Please enter a term to search for:"); + } + + if (selectedFilters.Contains(SearchController.FilterByGenre)) + { + Console.Clear(); + searchGenre = DisplayQuestion("Please enter a genre to filter by:"); + } + + await ReviewProductsMenu(searchTerm, searchGenre); + + if (!await AnsiConsole.ConfirmAsync("Would you like to perform another search?")) + return; + } + } +} \ No newline at end of file diff --git a/Ecommerce-API/ECommerce.UI/UserInterface/TestingUi/TestingUi.cs b/Ecommerce-API/ECommerce.UI/UserInterface/TestingUi/TestingUi.cs new file mode 100644 index 00000000..a356d6d3 --- /dev/null +++ b/Ecommerce-API/ECommerce.UI/UserInterface/TestingUi/TestingUi.cs @@ -0,0 +1,32 @@ +using ECommerce.UI.Enums; +using static ECommerce.UI.Helpers.DisplayHelper; + +namespace ECommerce.UI.UserInterface.TestingUi; + +internal class TestingUi(ProductTestUi testingUi, CheckoutUi checkoutUi) +{ + internal async Task TestingMenu() + { + while (true) + { + Console.Clear(); + + var option = DisplayMenu(); + + switch (option) + { + case TestingMenuOption.BrowseProducts: + await testingUi.ReviewProductsMenu(); + break; + case TestingMenuOption.SearchProducts: + await testingUi.SearchProducts(); + break; + case TestingMenuOption.Checkout: + await checkoutUi.CheckoutMenu(); + break; + case TestingMenuOption.ExitTestingEnvironment: + return; + } + } + } +} \ No newline at end of file diff --git a/Ecommerce-API/ECommerce.sln b/Ecommerce-API/ECommerce.sln new file mode 100644 index 00000000..54269f1d --- /dev/null +++ b/Ecommerce-API/ECommerce.sln @@ -0,0 +1,28 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ECommerce.API", "ECommerce.API\ECommerce.API.csproj", "{DEEE5048-EF7D-43A1-8EDC-B52D6E36BEC1}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ECommerce.Shared", "ECommerce.Shared\ECommerce.Shared.csproj", "{47FDE9A9-4A2B-43C9-9FF3-FDEF59DA613B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ECommerce.UI", "ECommerce.UI\ECommerce.UI.csproj", "{5044DE32-B8B1-4676-81E5-16BAED50517A}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {DEEE5048-EF7D-43A1-8EDC-B52D6E36BEC1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DEEE5048-EF7D-43A1-8EDC-B52D6E36BEC1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DEEE5048-EF7D-43A1-8EDC-B52D6E36BEC1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DEEE5048-EF7D-43A1-8EDC-B52D6E36BEC1}.Release|Any CPU.Build.0 = Release|Any CPU + {47FDE9A9-4A2B-43C9-9FF3-FDEF59DA613B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {47FDE9A9-4A2B-43C9-9FF3-FDEF59DA613B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {47FDE9A9-4A2B-43C9-9FF3-FDEF59DA613B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {47FDE9A9-4A2B-43C9-9FF3-FDEF59DA613B}.Release|Any CPU.Build.0 = Release|Any CPU + {5044DE32-B8B1-4676-81E5-16BAED50517A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5044DE32-B8B1-4676-81E5-16BAED50517A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5044DE32-B8B1-4676-81E5-16BAED50517A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5044DE32-B8B1-4676-81E5-16BAED50517A}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal