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;" + } }