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