diff --git a/Development Project/.idea/.idea.Development Project/.idea/.gitignore b/Development Project/.idea/.idea.Development Project/.idea/.gitignore
new file mode 100644
index 0000000..f5bfd03
--- /dev/null
+++ b/Development Project/.idea/.idea.Development Project/.idea/.gitignore
@@ -0,0 +1,15 @@
+# Default ignored files
+/shelf/
+/workspace.xml
+# Rider ignored files
+/contentModel.xml
+/projectSettingsUpdater.xml
+/modules.xml
+/.idea.Development Project.iml
+# 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/Development Project/.idea/.idea.Development Project/.idea/encodings.xml b/Development Project/.idea/.idea.Development Project/.idea/encodings.xml
new file mode 100644
index 0000000..df87cf9
--- /dev/null
+++ b/Development Project/.idea/.idea.Development Project/.idea/encodings.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/Development Project/.idea/.idea.Development Project/.idea/indexLayout.xml b/Development Project/.idea/.idea.Development Project/.idea/indexLayout.xml
new file mode 100644
index 0000000..641ae66
--- /dev/null
+++ b/Development Project/.idea/.idea.Development Project/.idea/indexLayout.xml
@@ -0,0 +1,10 @@
+
+
+
+
+ ../../Development-Project-CSharp
+
+
+
+
+
\ No newline at end of file
diff --git a/Development Project/.idea/.idea.Development Project/.idea/sqldialects.xml b/Development Project/.idea/.idea.Development Project/.idea/sqldialects.xml
new file mode 100644
index 0000000..52edbae
--- /dev/null
+++ b/Development Project/.idea/.idea.Development Project/.idea/sqldialects.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Development Project/.idea/.idea.Development Project/.idea/vcs.xml b/Development Project/.idea/.idea.Development Project/.idea/vcs.xml
new file mode 100644
index 0000000..62bd7a0
--- /dev/null
+++ b/Development Project/.idea/.idea.Development Project/.idea/vcs.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Development Project/Development Project.sln b/Development Project/Development Project.sln
index 637bfd9..1e6fefb 100644
--- a/Development Project/Development Project.sln
+++ b/Development Project/Development Project.sln
@@ -11,6 +11,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sparcpoint.Core", "Sparcpoi
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sparcpoint.SqlServer.Abstractions", "Sparcpoint.SqlServer.Abstractions\Sparcpoint.SqlServer.Abstractions.csproj", "{4E72CD4A-C9FE-4A04-9F07-A770E3AD7297}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Interview.Web.Tests", "Interview.Web.Tests\Interview.Web.Tests.csproj", "{C8F425F6-5E77-4783-9D2B-5E5EB29935E3}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -55,6 +57,14 @@ Global
{4E72CD4A-C9FE-4A04-9F07-A770E3AD7297}.Release|Any CPU.Build.0 = Release|Any CPU
{4E72CD4A-C9FE-4A04-9F07-A770E3AD7297}.Release|x86.ActiveCfg = Release|Any CPU
{4E72CD4A-C9FE-4A04-9F07-A770E3AD7297}.Release|x86.Build.0 = Release|Any CPU
+ {C8F425F6-5E77-4783-9D2B-5E5EB29935E3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {C8F425F6-5E77-4783-9D2B-5E5EB29935E3}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {C8F425F6-5E77-4783-9D2B-5E5EB29935E3}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {C8F425F6-5E77-4783-9D2B-5E5EB29935E3}.Debug|x86.Build.0 = Debug|Any CPU
+ {C8F425F6-5E77-4783-9D2B-5E5EB29935E3}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {C8F425F6-5E77-4783-9D2B-5E5EB29935E3}.Release|Any CPU.Build.0 = Release|Any CPU
+ {C8F425F6-5E77-4783-9D2B-5E5EB29935E3}.Release|x86.ActiveCfg = Release|Any CPU
+ {C8F425F6-5E77-4783-9D2B-5E5EB29935E3}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/Development Project/Interview.Web.Tests/GlobalUsings.cs b/Development Project/Interview.Web.Tests/GlobalUsings.cs
new file mode 100644
index 0000000..8c927eb
--- /dev/null
+++ b/Development Project/Interview.Web.Tests/GlobalUsings.cs
@@ -0,0 +1 @@
+global using Xunit;
\ No newline at end of file
diff --git a/Development Project/Interview.Web.Tests/Interview.Web.Tests.csproj b/Development Project/Interview.Web.Tests/Interview.Web.Tests.csproj
new file mode 100644
index 0000000..4605f45
--- /dev/null
+++ b/Development Project/Interview.Web.Tests/Interview.Web.Tests.csproj
@@ -0,0 +1,30 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+ false
+ true
+
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+
diff --git a/Development Project/Interview.Web.Tests/Services/InventoryServiceTests.cs b/Development Project/Interview.Web.Tests/Services/InventoryServiceTests.cs
new file mode 100644
index 0000000..06b9fdb
--- /dev/null
+++ b/Development Project/Interview.Web.Tests/Services/InventoryServiceTests.cs
@@ -0,0 +1,99 @@
+using System;
+using System.Threading.Tasks;
+using Interview.Web.Models;
+using Interview.Web.Repositories.Interfaces;
+using Interview.Web.Services.Implementations;
+using Moq;
+using Xunit;
+
+namespace Interview.Web.Tests.Services;
+
+public class InventoryServiceTests
+{
+ private readonly Mock _repositoryMock;
+ private readonly InventoryService _sut;
+
+ public InventoryServiceTests()
+ {
+ _repositoryMock = new Mock();
+ _sut = new InventoryService(_repositoryMock.Object);
+ }
+
+ #region AddAsync
+
+ [Fact]
+ public async Task AddAsync_ValidRequest_DelegatesToRepository()
+ {
+ var request = new AddInventoryRequest { ProductInstanceId = 1, Quantity = 10 };
+ _repositoryMock.Setup(r => r.AddAsync(request)).ReturnsAsync(1);
+
+ var id = await _sut.AddAsync(request);
+
+ Assert.Equal(1, id);
+ _repositoryMock.Verify(r => r.AddAsync(request), Times.Once);
+ }
+
+ [Fact]
+ public async Task AddAsync_NullRequest_ThrowsArgumentNullException()
+ {
+ await Assert.ThrowsAsync(() => _sut.AddAsync(null));
+ }
+
+ [Fact]
+ public async Task AddAsync_ZeroQuantity_ThrowsArgumentException()
+ {
+ var request = new AddInventoryRequest { ProductInstanceId = 1, Quantity = 0 };
+
+ await Assert.ThrowsAsync(() => _sut.AddAsync(request));
+ }
+
+ [Fact]
+ public async Task AddAsync_InvalidProductInstanceId_ThrowsArgumentException()
+ {
+ var request = new AddInventoryRequest { ProductInstanceId = 0, Quantity = 10 };
+
+ await Assert.ThrowsAsync(() => _sut.AddAsync(request));
+ }
+
+ #endregion
+
+ #region RemoveTransactionAsync
+
+ [Fact]
+ public async Task RemoveTransactionAsync_ValidId_DelegatesToRepository()
+ {
+ await _sut.RemoveTransactionAsync(1);
+
+ _repositoryMock.Verify(r => r.RemoveTransactionAsync(1), Times.Once);
+ }
+
+ [Fact]
+ public async Task RemoveTransactionAsync_InvalidId_ThrowsArgumentException()
+ {
+ await Assert.ThrowsAsync(() => _sut.RemoveTransactionAsync(0));
+ }
+
+ #endregion
+
+ #region GetCountAsync
+
+ [Fact]
+ public async Task GetCountAsync_ValidRequest_DelegatesToRepository()
+ {
+ var request = new InventoryCountRequest { ProductInstanceId = 1 };
+ _repositoryMock.Setup(r => r.GetCountAsync(request)).ReturnsAsync(42m);
+
+ var count = await _sut.GetCountAsync(request);
+
+ Assert.Equal(42m, count);
+ _repositoryMock.Verify(r => r.GetCountAsync(request), Times.Once);
+ }
+
+ [Fact]
+ public async Task GetCountAsync_NullRequest_ThrowsArgumentNullException()
+ {
+ await Assert.ThrowsAsync(() => _sut.GetCountAsync(null));
+ }
+
+ #endregion
+}
diff --git a/Development Project/Interview.Web.Tests/Services/ProductsServiceTests.cs b/Development Project/Interview.Web.Tests/Services/ProductsServiceTests.cs
new file mode 100644
index 0000000..fce9893
--- /dev/null
+++ b/Development Project/Interview.Web.Tests/Services/ProductsServiceTests.cs
@@ -0,0 +1,111 @@
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Interview.Web.Models;
+using Interview.Web.Repositories.Interfaces;
+using Interview.Web.Services.Implementations;
+using Moq;
+using Xunit;
+
+namespace Interview.Web.Tests.Services;
+
+public class ProductsServiceTests
+{
+ private readonly Mock _repositoryMock;
+ private readonly ProductsService _sut;
+
+ public ProductsServiceTests()
+ {
+ _repositoryMock = new Mock();
+ _sut = new ProductsService(_repositoryMock.Object);
+ }
+
+ #region SearchAsync
+
+ [Fact]
+ public async Task SearchAsync_WithNoFilters_ReturnsAllProducts()
+ {
+ var expected = new List { new ProductResponse { InstanceId = 1, Name = "Widget" } };
+ _repositoryMock.Setup(r => r.SearchAsync(It.IsAny()))
+ .ReturnsAsync(expected);
+
+ var result = await _sut.SearchAsync(new ProductSearchRequest());
+
+ Assert.Equal(expected, result);
+ }
+
+ [Fact]
+ public async Task SearchAsync_DelegatesToRepository()
+ {
+ var request = new ProductSearchRequest { Name = "Widget" };
+ _repositoryMock.Setup(r => r.SearchAsync(request)).ReturnsAsync(new List());
+
+ await _sut.SearchAsync(request);
+
+ _repositoryMock.Verify(r => r.SearchAsync(request), Times.Once);
+ }
+
+ #endregion
+
+ #region CreateAsync
+
+ [Fact]
+ public async Task CreateAsync_ValidRequest_DelegatesToRepository()
+ {
+ var request = new CreateProductRequest { Name = "Widget", Description = "A widget" };
+ _repositoryMock.Setup(r => r.CreateAsync(request)).ReturnsAsync(1);
+
+ var id = await _sut.CreateAsync(request);
+
+ Assert.Equal(1, id);
+ _repositoryMock.Verify(r => r.CreateAsync(request), Times.Once);
+ }
+
+ [Fact]
+ public async Task CreateAsync_NullRequest_ThrowsArgumentNullException()
+ {
+ await Assert.ThrowsAsync(() => _sut.CreateAsync(null));
+ }
+
+ [Fact]
+ public async Task CreateAsync_NullName_ThrowsArgumentException()
+ {
+ var request = new CreateProductRequest { Name = null, Description = "A widget" };
+
+ await Assert.ThrowsAsync(() => _sut.CreateAsync(request));
+ }
+
+ [Fact]
+ public async Task CreateAsync_WhitespaceName_ThrowsArgumentException()
+ {
+ var request = new CreateProductRequest { Name = " ", Description = "A widget" };
+
+ await Assert.ThrowsAsync(() => _sut.CreateAsync(request));
+ }
+
+ [Fact]
+ public async Task CreateAsync_NameExceeds256Chars_ThrowsArgumentException()
+ {
+ var request = new CreateProductRequest
+ {
+ Name = new string('a', 257),
+ Description = "A widget"
+ };
+
+ await Assert.ThrowsAsync(() => _sut.CreateAsync(request));
+ }
+
+ [Fact]
+ public async Task CreateAsync_DescriptionExceeds256Chars_ThrowsArgumentException()
+ {
+ var request = new CreateProductRequest
+ {
+ Name = "Widget",
+ Description = new string('a', 257)
+ };
+
+ await Assert.ThrowsAsync(() => _sut.CreateAsync(request));
+ }
+
+ #endregion
+}
diff --git a/Development Project/Interview.Web/Controllers/InventoryController.cs b/Development Project/Interview.Web/Controllers/InventoryController.cs
new file mode 100644
index 0000000..9fcdc2d
--- /dev/null
+++ b/Development Project/Interview.Web/Controllers/InventoryController.cs
@@ -0,0 +1,72 @@
+using System.Threading.Tasks;
+using Interview.Web.Models;
+using Interview.Web.Services.Interfaces;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Interview.Web.Controllers;
+
+[ApiController]
+[Route("api/v1/inventory")]
+public class InventoryController : ControllerBase
+{
+ private readonly IInventoryService _inventoryService;
+
+ public InventoryController(IInventoryService inventoryService)
+ {
+ _inventoryService = inventoryService;
+ }
+
+ /// Add an inventory transaction.
+ ///
+ /// Positive Quantity adds stock, negative removes it.
+ /// Each call creates a transaction row that can be undone individually via DELETE.
+ /// TypeCategory is optional — use it to label the transaction e.g. "RECEIVE", "SALE".
+ ///
+ /// ProductInstanceId, Quantity, and optional TypeCategory.
+ /// The TransactionId of the new transaction.
+ /// Transaction created.
+ /// Quantity cannot be zero. ProductInstanceId must be positive.
+ [HttpPost]
+ [ProducesResponseType(typeof(object), 200)]
+ [ProducesResponseType(400)]
+ public async Task AddInventory([FromBody] AddInventoryRequest request)
+ {
+ var transactionId = await _inventoryService.AddAsync(request);
+ // EVAL: Returning the TransactionId lets the caller reference this transaction for DELETE.
+ return Ok(new { TransactionId = transactionId });
+ }
+
+ /// Remove (undo) a transaction.
+ ///
+ /// Permanently deletes the transaction row. The inventory count updates immediately.
+ ///
+ /// The transaction to remove.
+ /// Transaction removed.
+ /// TransactionId must be a positive integer.
+ [HttpDelete("{transactionId}")]
+ [ProducesResponseType(204)]
+ [ProducesResponseType(400)]
+ public async Task RemoveTransaction(int transactionId)
+ {
+ await _inventoryService.RemoveTransactionAsync(transactionId);
+ // EVAL: 204 No Content — correct response for a DELETE with no body.
+ return NoContent();
+ }
+
+ /// Get total inventory count.
+ ///
+ /// Filter by ProductInstanceId for a single product, or by AttributeKey/AttributeValue
+ /// to aggregate across all products with that metadata (e.g. all red products).
+ /// No filters returns the total across everything.
+ ///
+ /// ProductInstanceId, AttributeKey, AttributeValue — all optional.
+ /// Total inventory count.
+ /// Returns the count.
+ [HttpGet("count")]
+ [ProducesResponseType(typeof(object), 200)]
+ public async Task GetCount([FromQuery] InventoryCountRequest request)
+ {
+ var count = await _inventoryService.GetCountAsync(request);
+ return Ok(new { Count = count });
+ }
+}
diff --git a/Development Project/Interview.Web/Controllers/ProductController.cs b/Development Project/Interview.Web/Controllers/ProductController.cs
index 267f4ec..c2f8b82 100644
--- a/Development Project/Interview.Web/Controllers/ProductController.cs
+++ b/Development Project/Interview.Web/Controllers/ProductController.cs
@@ -1,19 +1,53 @@
-using Microsoft.AspNetCore.Mvc;
-using System;
-using System.Collections.Generic;
-using System.Linq;
using System.Threading.Tasks;
+using Interview.Web.Models;
+using Interview.Web.Services.Interfaces;
+using Microsoft.AspNetCore.Mvc;
-namespace Interview.Web.Controllers
+namespace Interview.Web.Controllers;
+
+[ApiController]
+[Route("api/v1/products")]
+public class ProductController : ControllerBase
{
- [Route("api/v1/products")]
- public class ProductController : Controller
+ private readonly IProductsService _productsService;
+
+ public ProductController(IProductsService productsService)
+ {
+ _productsService = productsService;
+ }
+
+ /// Add a new product.
+ ///
+ /// Products are permanent — they cannot be deleted once created.
+ /// Pass arbitrary metadata in Attributes and assign CategoryIds to categorize the product.
+ ///
+ /// Name, description, SKUs, image URIs, attributes, and category IDs.
+ /// The InstanceId of the new product.
+ /// Product created.
+ /// Name is required and cannot exceed 256 characters.
+ [HttpPost]
+ [ProducesResponseType(typeof(object), 201)]
+ [ProducesResponseType(400)]
+ public async Task CreateProduct([FromBody] CreateProductRequest request)
+ {
+ var id = await _productsService.CreateAsync(request);
+ // EVAL: CreatedAtAction returns 201 and sets the Location header to the GET endpoint.
+ return CreatedAtAction(nameof(GetProducts), null, new { InstanceId = id });
+ }
+
+ /// Search for products.
+ ///
+ /// All filters are optional — omitting them returns everything.
+ /// Multiple filters are combined with AND.
+ ///
+ /// Name, Description, AttributeKey/AttributeValue, CategoryIds.
+ /// Products matching the filters.
+ /// Returns matching products.
+ [HttpGet]
+ [ProducesResponseType(typeof(System.Collections.Generic.IEnumerable), 200)]
+ public async Task GetProducts([FromQuery] ProductSearchRequest request)
{
- // NOTE: Sample Action
- [HttpGet]
- public Task GetAllProducts()
- {
- return Task.FromResult((IActionResult)Ok(new object[] { }));
- }
+ var products = await _productsService.SearchAsync(request);
+ return Ok(products);
}
}
diff --git a/Development Project/Interview.Web/Interview.Web.csproj b/Development Project/Interview.Web/Interview.Web.csproj
index d1e4d6e..2ca5035 100644
--- a/Development Project/Interview.Web/Interview.Web.csproj
+++ b/Development Project/Interview.Web/Interview.Web.csproj
@@ -2,6 +2,18 @@
net8.0
+ true
+ $(NoWarn);1591
+
+
+
+
+
+
+
+
+
+
diff --git a/Development Project/Interview.Web/Models/AddInventoryRequest.cs b/Development Project/Interview.Web/Models/AddInventoryRequest.cs
new file mode 100644
index 0000000..b596cb3
--- /dev/null
+++ b/Development Project/Interview.Web/Models/AddInventoryRequest.cs
@@ -0,0 +1,12 @@
+namespace Interview.Web.Models;
+
+public class AddInventoryRequest
+{
+ public int ProductInstanceId { get; set; }
+
+ // EVAL: Decimal matches DECIMAL(19,6) in the DB — supports fractional units (weight, volume).
+ public decimal Quantity { get; set; }
+
+ // EVAL: Optional label for the transaction type e.g. "RECEIVE", "SALE", "ADJUSTMENT".
+ public string TypeCategory { get; set; }
+}
diff --git a/Development Project/Interview.Web/Models/CreateProductRequest.cs b/Development Project/Interview.Web/Models/CreateProductRequest.cs
new file mode 100644
index 0000000..a293c15
--- /dev/null
+++ b/Development Project/Interview.Web/Models/CreateProductRequest.cs
@@ -0,0 +1,16 @@
+using System.Collections.Generic;
+
+namespace Interview.Web.Models;
+
+public class CreateProductRequest
+{
+ public string Name { get; set; }
+ public string Description { get; set; }
+ public string[] ValidSkus { get; set; }
+ public string[] ProductImageUris { get; set; }
+
+ // EVAL: Key/value pairs map to Instances.ProductAttributes rows — one row per entry.
+ public Dictionary Attributes { get; set; }
+
+ public int[] CategoryIds { get; set; }
+}
diff --git a/Development Project/Interview.Web/Models/InventoryCountRequest.cs b/Development Project/Interview.Web/Models/InventoryCountRequest.cs
new file mode 100644
index 0000000..18ef0bd
--- /dev/null
+++ b/Development Project/Interview.Web/Models/InventoryCountRequest.cs
@@ -0,0 +1,10 @@
+namespace Interview.Web.Models;
+
+public class InventoryCountRequest
+{
+ // EVAL: Filter by product ID for a specific count, or by attribute key/value to
+ // aggregate across all matching products (e.g. total stock of all red products).
+ public int? ProductInstanceId { get; set; }
+ public string AttributeKey { get; set; }
+ public string AttributeValue { get; set; }
+}
diff --git a/Development Project/Interview.Web/Models/InventoryTransaction.cs b/Development Project/Interview.Web/Models/InventoryTransaction.cs
new file mode 100644
index 0000000..8259668
--- /dev/null
+++ b/Development Project/Interview.Web/Models/InventoryTransaction.cs
@@ -0,0 +1,13 @@
+using System;
+
+namespace Interview.Web.Models;
+
+public class InventoryTransaction
+{
+ public int TransactionId { get; set; }
+ public int ProductInstanceId { get; set; }
+ public decimal Quantity { get; set; }
+ public DateTime StartedTimestamp { get; set; }
+ public DateTime? CompletedTimestamp { get; set; }
+ public string TypeCategory { get; set; }
+}
diff --git a/Development Project/Interview.Web/Models/Product.cs b/Development Project/Interview.Web/Models/Product.cs
new file mode 100644
index 0000000..291ecd9
--- /dev/null
+++ b/Development Project/Interview.Web/Models/Product.cs
@@ -0,0 +1,13 @@
+using System;
+
+namespace Interview.Web.Models;
+
+public class Product
+{
+ public int InstanceId { get; set; }
+ public string Name { get; set; }
+ public string Description { get; set; }
+ public string ValidSkus { get; set; }
+ public string ProductImageUris { get; set; }
+ public DateTime CreatedTimestamp { get; set; }
+}
\ No newline at end of file
diff --git a/Development Project/Interview.Web/Models/ProductResponse.cs b/Development Project/Interview.Web/Models/ProductResponse.cs
new file mode 100644
index 0000000..dbf60c6
--- /dev/null
+++ b/Development Project/Interview.Web/Models/ProductResponse.cs
@@ -0,0 +1,15 @@
+using System;
+using System.Collections.Generic;
+
+namespace Interview.Web.Models;
+
+public class ProductResponse
+{
+ public int InstanceId { get; set; }
+ public string Name { get; set; }
+ public string Description { get; set; }
+ public string[] ValidSkus { get; set; }
+ public string[] ProductImageUris { get; set; }
+ public DateTime CreatedTimestamp { get; set; }
+ public Dictionary Attributes { get; set; }
+}
\ No newline at end of file
diff --git a/Development Project/Interview.Web/Models/ProductSearchRequest.cs b/Development Project/Interview.Web/Models/ProductSearchRequest.cs
new file mode 100644
index 0000000..be506ff
--- /dev/null
+++ b/Development Project/Interview.Web/Models/ProductSearchRequest.cs
@@ -0,0 +1,16 @@
+namespace Interview.Web.Models;
+
+public class ProductSearchRequest
+{
+ public string Name { get; set; }
+ public string Description { get; set; }
+
+ // EVAL: Dictionary doesn't bind from [FromQuery] so flat strings are used
+ // instead. To support multiple attribute filters, these could become string[] AttributeKeys
+ // and string[] AttributeValues using repeated params (?attributeKeys=color&attributeKeys=brand)
+ // without breaking existing callers.
+ public string AttributeKey { get; set; }
+ public string AttributeValue { get; set; }
+
+ public int[] CategoryIds { get; set; }
+}
diff --git a/Development Project/Interview.Web/Properties/launchSettings.json b/Development Project/Interview.Web/Properties/launchSettings.json
index 4dcb6fa..60cf853 100644
--- a/Development Project/Interview.Web/Properties/launchSettings.json
+++ b/Development Project/Interview.Web/Properties/launchSettings.json
@@ -10,14 +10,15 @@
"profiles": {
"IIS Express": {
"commandName": "IISExpress",
+ "launchUrl": "https://localhost:5001/swagger",
"launchBrowser": true,
- "launchUrl": "api/v1/products",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"Interview.Web": {
"commandName": "Project",
+ "launchUrl": "https://localhost:5001/swagger",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
diff --git a/Development Project/Interview.Web/Repositories/Implementations/InventoryRepository.cs b/Development Project/Interview.Web/Repositories/Implementations/InventoryRepository.cs
new file mode 100644
index 0000000..bb554d5
--- /dev/null
+++ b/Development Project/Interview.Web/Repositories/Implementations/InventoryRepository.cs
@@ -0,0 +1,86 @@
+using System.Threading.Tasks;
+using Dapper;
+using Interview.Web.Models;
+using Interview.Web.Repositories.Interfaces;
+using Sparcpoint.SqlServer.Abstractions;
+
+namespace Interview.Web.Repositories.Implementations;
+
+public class InventoryRepository : IInventoryRepository
+{
+ private readonly ISqlExecutor _sqlExecutor;
+
+ public InventoryRepository(ISqlExecutor sqlExecutor)
+ {
+ _sqlExecutor = sqlExecutor;
+ }
+
+ public async Task AddAsync(AddInventoryRequest request)
+ {
+ return await _sqlExecutor.ExecuteAsync(async (conn, trans) =>
+ {
+ const string sql = @"
+ INSERT INTO Transactions.InventoryTransactions
+ (ProductInstanceId, Quantity, TypeCategory)
+ VALUES
+ (@ProductInstanceId, @Quantity, @TypeCategory);
+ SELECT CAST(SCOPE_IDENTITY() AS INT);";
+
+ return await conn.ExecuteScalarAsync(sql, new
+ {
+ request.ProductInstanceId,
+ request.Quantity,
+ request.TypeCategory
+ }, trans);
+ });
+ }
+
+ public async Task RemoveTransactionAsync(int transactionId)
+ {
+ await _sqlExecutor.ExecuteAsync(async (conn, trans) =>
+ {
+ // EVAL: Hard delete is the undo mechanism — removing the row immediately
+ // corrects the inventory count since the SUM query only touches existing rows.
+ const string sql = @"
+ DELETE FROM Transactions.InventoryTransactions
+ WHERE TransactionId = @TransactionId";
+
+ await conn.ExecuteAsync(sql, new { TransactionId = transactionId }, trans);
+ });
+ }
+
+ public async Task GetCountAsync(InventoryCountRequest request)
+ {
+ return await _sqlExecutor.ExecuteAsync(async (conn, trans) =>
+ {
+ // EVAL: ISNULL guards against SUM returning NULL on an empty set.
+ const string baseSelect = @"
+ SELECT ISNULL(SUM(t.Quantity), 0)
+ FROM Transactions.InventoryTransactions t";
+
+ var query = SqlServerQueryProvider.Empty;
+ query.SetTargetTableAlias("t");
+
+ if (request.ProductInstanceId.HasValue)
+ query.WhereEquals("ProductInstanceId", "ProductInstanceId", request.ProductInstanceId.Value);
+
+ // EVAL: EXISTS lets callers aggregate stock across all products sharing an attribute
+ // (e.g. total units of all red products) without needing to know their IDs.
+ if (!string.IsNullOrWhiteSpace(request.AttributeKey) && !string.IsNullOrWhiteSpace(request.AttributeValue))
+ {
+ query.Where($@"EXISTS (
+ SELECT 1 FROM Instances.ProductAttributes pa
+ WHERE pa.InstanceId = t.ProductInstanceId
+ AND pa.[Key] = @AttributeKey
+ AND pa.[Value] = @AttributeValue)");
+
+ query.AddParameter("@AttributeKey", request.AttributeKey);
+ query.AddParameter("@AttributeValue", request.AttributeValue);
+ }
+
+ var sql = $"{baseSelect} {query.WhereClause}";
+
+ return await conn.ExecuteScalarAsync(sql, query.Parameters, trans);
+ });
+ }
+}
diff --git a/Development Project/Interview.Web/Repositories/Implementations/ProductRepository.cs b/Development Project/Interview.Web/Repositories/Implementations/ProductRepository.cs
new file mode 100644
index 0000000..6ee57f1
--- /dev/null
+++ b/Development Project/Interview.Web/Repositories/Implementations/ProductRepository.cs
@@ -0,0 +1,171 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using Dapper;
+using Interview.Web.Models;
+using Interview.Web.Repositories.Interfaces;
+using Sparcpoint.SqlServer.Abstractions;
+
+namespace Interview.Web.Repositories.Implementations;
+
+public class ProductRepository : IProductRepository
+{
+ private readonly ISqlExecutor _sqlExecutor;
+
+ public ProductRepository(ISqlExecutor sqlExecutor)
+ {
+ _sqlExecutor = sqlExecutor;
+ }
+
+ public async Task CreateAsync(CreateProductRequest request)
+ {
+ return await _sqlExecutor.ExecuteAsync(async (conn, trans) =>
+ {
+ // EVAL: SCOPE_IDENTITY() is scoped to the current connection, so concurrent
+ // inserts on other connections won't affect the returned id.
+ const string insertProduct = @"
+ INSERT INTO Instances.Products (Name, Description, ValidSkus, ProductImageUris)
+ VALUES (@Name, @Description, @ValidSkus, @ProductImageUris);
+ SELECT CAST(SCOPE_IDENTITY() AS INT);";
+
+ var id = await conn.ExecuteScalarAsync(insertProduct, new
+ {
+ request.Name,
+ Description = request.Description ?? string.Empty,
+ // EVAL: SKUs and image URIs are stored comma-separated to match the VARCHAR(MAX)
+ // columns — no additional tables needed.
+ ValidSkus = string.Join(",", request.ValidSkus ?? Array.Empty()),
+ ProductImageUris = string.Join(",", request.ProductImageUris ?? Array.Empty())
+ }, trans);
+
+ if (request.Attributes != null && request.Attributes.Any())
+ {
+ const string insertAttribute = @"
+ INSERT INTO Instances.ProductAttributes (InstanceId, [Key], [Value])
+ VALUES (@InstanceId, @Key, @Value);";
+
+ foreach (var attr in request.Attributes)
+ {
+ await conn.ExecuteAsync(insertAttribute, new
+ {
+ InstanceId = id,
+ Key = attr.Key,
+ Value = attr.Value
+ }, trans);
+ }
+ }
+
+ if (request.CategoryIds != null && request.CategoryIds.Any())
+ {
+ const string insertCategory = @"
+ INSERT INTO Instances.ProductCategories (InstanceId, CategoryInstanceId)
+ VALUES (@InstanceId, @CategoryInstanceId);";
+
+ foreach (var categoryId in request.CategoryIds)
+ {
+ await conn.ExecuteAsync(insertCategory, new
+ {
+ InstanceId = id,
+ CategoryInstanceId = categoryId
+ }, trans);
+ }
+ }
+
+ return id;
+ });
+ }
+
+ public async Task> SearchAsync(ProductSearchRequest request)
+ {
+ return await _sqlExecutor.ExecuteAsync(async (conn, trans) =>
+ {
+ // EVAL: WhereClause returns empty string when no filters are set,
+ // so the query naturally returns all products without a separate branch.
+ var query = SqlServerQueryProvider.Empty;
+ query.SetTargetTableAlias("p");
+
+ if (!string.IsNullOrWhiteSpace(request?.Name))
+ query.WhereEquals("Name", "Name", request.Name);
+
+ if (!string.IsNullOrWhiteSpace(request?.Description))
+ query.WhereEquals("Description", "Description", request.Description);
+
+ // EVAL: EXISTS avoids row duplication that a JOIN would cause when a product
+ // has multiple attributes, and short-circuits on the first match.
+ if (!string.IsNullOrWhiteSpace(request?.AttributeKey) && !string.IsNullOrWhiteSpace(request?.AttributeValue))
+ {
+ query.Where($@"EXISTS (
+ SELECT 1 FROM Instances.ProductAttributes pa
+ WHERE pa.InstanceId = p.InstanceId
+ AND pa.[Key] = @AttributeKey
+ AND pa.[Value] = @AttributeValue)");
+
+ query.AddParameter("@AttributeKey", request.AttributeKey);
+ query.AddParameter("@AttributeValue", request.AttributeValue);
+ }
+
+ // EVAL: IN subquery returns products in any of the requested categories
+ // without duplicating rows the way a JOIN would.
+ if (request?.CategoryIds != null && request.CategoryIds.Any())
+ {
+ var categoryIdList = string.Join(",", request.CategoryIds);
+ query.WhereIn("InstanceId", $"(SELECT InstanceId FROM Instances.ProductCategories WHERE CategoryInstanceId IN ({categoryIdList}))");
+ }
+
+ var sql = $"SELECT * FROM Instances.Products p {query.WhereClause}";
+
+ var products = await conn.QueryAsync(sql, query.Parameters, trans);
+
+ // EVAL: N+1 query per product — acceptable here, but in production this would
+ // be replaced with a single JOIN query grouped by InstanceId.
+ var responses = new List();
+ foreach (var product in products)
+ {
+ var attributes = await LoadAttributesAsync(conn, trans, product.InstanceId);
+ responses.Add(MapToResponse(product, attributes));
+ }
+
+ return responses;
+ });
+ }
+
+ private static async Task> LoadAttributesAsync(
+ System.Data.IDbConnection conn,
+ System.Data.IDbTransaction trans,
+ int instanceId)
+ {
+ const string sql = @"
+ SELECT [Key], [Value]
+ FROM Instances.ProductAttributes
+ WHERE InstanceId = @InstanceId";
+
+ var rows = await conn.QueryAsync<(string Key, string Value)>(sql, new { InstanceId = instanceId }, trans);
+ return rows.ToDictionary(r => r.Key, r => r.Value);
+ }
+
+ // EVAL: Keeps the DB model decoupled from the API contract — schema changes stay
+ // contained here and don't ripple up to services or controllers.
+ private static ProductResponse MapToResponse(Product product, Dictionary attributes)
+ {
+ return new ProductResponse
+ {
+ InstanceId = product.InstanceId,
+ Name = product.Name,
+ Description = product.Description,
+ ValidSkus = SplitCsv(product.ValidSkus),
+ ProductImageUris = SplitCsv(product.ProductImageUris),
+ CreatedTimestamp = product.CreatedTimestamp,
+ Attributes = attributes
+ };
+ }
+
+ // EVAL: Guards against returning [""] when the stored value is empty or null.
+ private static string[] SplitCsv(string value)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ return Array.Empty();
+
+ return value.Split(',', StringSplitOptions.RemoveEmptyEntries);
+ }
+}
diff --git a/Development Project/Interview.Web/Repositories/Interfaces/IInventoryRepository.cs b/Development Project/Interview.Web/Repositories/Interfaces/IInventoryRepository.cs
new file mode 100644
index 0000000..eec0bb6
--- /dev/null
+++ b/Development Project/Interview.Web/Repositories/Interfaces/IInventoryRepository.cs
@@ -0,0 +1,11 @@
+using System.Threading.Tasks;
+using Interview.Web.Models;
+
+namespace Interview.Web.Repositories.Interfaces;
+
+public interface IInventoryRepository
+{
+ Task AddAsync(AddInventoryRequest request);
+ Task RemoveTransactionAsync(int transactionId);
+ Task GetCountAsync(InventoryCountRequest request);
+}
diff --git a/Development Project/Interview.Web/Repositories/Interfaces/IProductRepository.cs b/Development Project/Interview.Web/Repositories/Interfaces/IProductRepository.cs
new file mode 100644
index 0000000..c215551
--- /dev/null
+++ b/Development Project/Interview.Web/Repositories/Interfaces/IProductRepository.cs
@@ -0,0 +1,11 @@
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Interview.Web.Models;
+
+namespace Interview.Web.Repositories.Interfaces;
+
+public interface IProductRepository
+{
+ Task CreateAsync(CreateProductRequest request);
+ Task> SearchAsync(ProductSearchRequest request);
+}
diff --git a/Development Project/Interview.Web/Services/Implementations/InventoryService.cs b/Development Project/Interview.Web/Services/Implementations/InventoryService.cs
new file mode 100644
index 0000000..93a41c3
--- /dev/null
+++ b/Development Project/Interview.Web/Services/Implementations/InventoryService.cs
@@ -0,0 +1,49 @@
+using System.Threading.Tasks;
+using Interview.Web.Models;
+using Interview.Web.Repositories.Interfaces;
+using Interview.Web.Services.Interfaces;
+using Sparcpoint;
+
+namespace Interview.Web.Services.Implementations;
+
+public class InventoryService : IInventoryService
+{
+ private readonly IInventoryRepository _inventoryRepository;
+
+ public InventoryService(IInventoryRepository inventoryRepository)
+ {
+ _inventoryRepository = inventoryRepository;
+ }
+
+ public async Task AddAsync(AddInventoryRequest request)
+ {
+ PreConditions.ParameterNotNull(request, nameof(request));
+
+ // EVAL: Identity columns start at 1 — zero or negative can't reference a real row.
+ if (request.ProductInstanceId <= 0)
+ throw new System.ArgumentException("ProductInstanceId must be a positive integer.", nameof(request.ProductInstanceId));
+
+ // EVAL: Zero quantity has no effect and is almost always a caller mistake.
+ // Negatives are allowed — that's how stock removal is recorded.
+ if (request.Quantity == 0)
+ throw new System.ArgumentException("Quantity cannot be zero.", nameof(request.Quantity));
+
+ return await _inventoryRepository.AddAsync(request);
+ }
+
+ public async Task RemoveTransactionAsync(int transactionId)
+ {
+ // EVAL: Same positive-integer rule as ProductInstanceId.
+ if (transactionId <= 0)
+ throw new System.ArgumentException("TransactionId must be a positive integer.", nameof(transactionId));
+
+ await _inventoryRepository.RemoveTransactionAsync(transactionId);
+ }
+
+ public async Task GetCountAsync(InventoryCountRequest request)
+ {
+ PreConditions.ParameterNotNull(request, nameof(request));
+
+ return await _inventoryRepository.GetCountAsync(request);
+ }
+}
diff --git a/Development Project/Interview.Web/Services/Implementations/ProductsService.cs b/Development Project/Interview.Web/Services/Implementations/ProductsService.cs
new file mode 100644
index 0000000..88763d3
--- /dev/null
+++ b/Development Project/Interview.Web/Services/Implementations/ProductsService.cs
@@ -0,0 +1,40 @@
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Interview.Web.Models;
+using Interview.Web.Repositories.Interfaces;
+using Interview.Web.Services.Interfaces;
+using Sparcpoint;
+
+namespace Interview.Web.Services.Implementations;
+
+public class ProductsService : IProductsService
+{
+ private readonly IProductRepository _productRepository;
+
+ public ProductsService(IProductRepository productRepository)
+ {
+ _productRepository = productRepository;
+ }
+
+ public async Task CreateAsync(CreateProductRequest request)
+ {
+ // EVAL: PreConditions from Sparcpoint.Core keeps guard clauses consistent across services.
+ PreConditions.ParameterNotNull(request, nameof(request));
+ PreConditions.StringNotNullOrWhitespace(request.Name, nameof(request.Name));
+
+ if (request.Name.Length > 256)
+ throw new System.ArgumentException("Name cannot exceed 256 characters.", nameof(request.Name));
+
+ if (!string.IsNullOrWhiteSpace(request.Description) && request.Description.Length > 256)
+ throw new System.ArgumentException("Description cannot exceed 256 characters.", nameof(request.Description));
+
+ return await _productRepository.CreateAsync(request);
+ }
+
+ public async Task> SearchAsync(ProductSearchRequest request)
+ {
+ // EVAL: Empty request returns all products — the repository only adds WHERE clauses
+ // for fields that have values, so no special "get all" branch is needed.
+ return await _productRepository.SearchAsync(request);
+ }
+}
diff --git a/Development Project/Interview.Web/Services/Interfaces/IInventoryService.cs b/Development Project/Interview.Web/Services/Interfaces/IInventoryService.cs
new file mode 100644
index 0000000..2aacda6
--- /dev/null
+++ b/Development Project/Interview.Web/Services/Interfaces/IInventoryService.cs
@@ -0,0 +1,11 @@
+using System.Threading.Tasks;
+using Interview.Web.Models;
+
+namespace Interview.Web.Services.Interfaces;
+
+public interface IInventoryService
+{
+ Task AddAsync(AddInventoryRequest request);
+ Task RemoveTransactionAsync(int transactionId);
+ Task GetCountAsync(InventoryCountRequest request);
+}
diff --git a/Development Project/Interview.Web/Services/Interfaces/IProductsService.cs b/Development Project/Interview.Web/Services/Interfaces/IProductsService.cs
new file mode 100644
index 0000000..6d2403b
--- /dev/null
+++ b/Development Project/Interview.Web/Services/Interfaces/IProductsService.cs
@@ -0,0 +1,11 @@
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Interview.Web.Models;
+
+namespace Interview.Web.Services.Interfaces;
+
+public interface IProductsService
+{
+ Task CreateAsync(CreateProductRequest request);
+ Task> SearchAsync(ProductSearchRequest request);
+}
diff --git a/Development Project/Interview.Web/Startup.cs b/Development Project/Interview.Web/Startup.cs
index 56452f2..420a50e 100644
--- a/Development Project/Interview.Web/Startup.cs
+++ b/Development Project/Interview.Web/Startup.cs
@@ -1,56 +1,72 @@
+using Interview.Web.Repositories.Implementations;
+using Interview.Web.Repositories.Interfaces;
+using Interview.Web.Services.Implementations;
+using Interview.Web.Services.Interfaces;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
-using Microsoft.AspNetCore.HttpsPolicy;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Threading.Tasks;
+using Sparcpoint.SqlServer.Abstractions;
-namespace Interview.Web
+namespace Interview.Web;
+
+public class Startup
{
- public class Startup
+ public Startup(IConfiguration configuration)
{
- public Startup(IConfiguration configuration)
- {
- Configuration = configuration;
- }
-
- public IConfiguration Configuration { get; }
+ Configuration = configuration;
+ }
- // This method gets called by the runtime. Use this method to add services to the container.
- public void ConfigureServices(IServiceCollection services)
- {
- services.AddControllers();
- }
+ public IConfiguration Configuration { get; }
- // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
- public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
+ public void ConfigureServices(IServiceCollection services)
+ {
+ services.AddControllers();
+ services.AddSwaggerGen(c =>
{
- if (env.IsDevelopment())
- {
- app.UseDeveloperExceptionPage();
- }
- else
- {
- app.UseExceptionHandler("/Error");
- // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
- app.UseHsts();
- }
+ // EVAL: Wires /// XML comments to Swagger UI so endpoint docs appear at runtime.
+ var xmlFile = $"{System.Reflection.Assembly.GetExecutingAssembly().GetName().Name}.xml";
+ var xmlPath = System.IO.Path.Combine(System.AppContext.BaseDirectory, xmlFile);
+ c.IncludeXmlComments(xmlPath);
+ });
- app.UseHttpsRedirection();
- app.UseStaticFiles();
+ // EVAL: Connection string is config-driven — no code changes needed per environment.
+ var sqlOptions = Configuration.GetSection("SqlServer").Get();
+ services.AddSingleton(new SqlServerExecutor(sqlOptions.ConnectionString));
- app.UseRouting();
-
- app.UseAuthorization();
+ // EVAL: Scoped gives each request its own instance — right for DB-backed services.
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ }
- app.UseEndpoints(endpoints =>
+ public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
+ {
+ if (env.IsDevelopment())
+ {
+ app.UseDeveloperExceptionPage();
+ app.UseSwagger();
+ app.UseSwaggerUI(c =>
{
- endpoints.MapControllers();
+ c.SwaggerEndpoint("/swagger/v1/swagger.json", "Interview API v1");
});
}
+ else
+ {
+ app.UseExceptionHandler("/Error");
+ app.UseHsts();
+ }
+
+ app.UseHttpsRedirection();
+ app.UseStaticFiles();
+ app.UseRouting();
+ app.UseAuthorization();
+
+ app.UseEndpoints(endpoints =>
+ {
+ endpoints.MapControllers();
+ });
}
}
diff --git a/Development Project/Interview.Web/appsettings.json b/Development Project/Interview.Web/appsettings.json
index d9d9a9b..07c9495 100644
--- a/Development Project/Interview.Web/appsettings.json
+++ b/Development Project/Interview.Web/appsettings.json
@@ -6,5 +6,8 @@
"Microsoft.Hosting.Lifetime": "Information"
}
},
- "AllowedHosts": "*"
+ "AllowedHosts": "*",
+ "SqlServer": {
+ "ConnectionString": "Server=localhost;Database=SparcpointInventory;Trusted_Connection=True;"
+ }
}