From 041bcd5e2ff55e72d4edf818e738a11759973fd1 Mon Sep 17 00:00:00 2001 From: Martin Lazzeri Date: Fri, 17 Apr 2026 11:24:26 -0300 Subject: [PATCH] Code challenge --- .../Controllers/ProductController.cs | 54 ++- .../Interview.Web/Interview.Web.csproj | 4 + .../Repositories/IProductRepository.cs | 14 + .../Abstract/Services/IProductService.cs | 14 + .../Sparcpoint.Core/DTOs/ProductDTO.cs | 15 + .../Repositories/ProductRepository.cs | 327 ++++++++++++++++++ .../Services/ProductService.cs | 49 +++ .../Sparcpoint.Core/Models/Product.cs | 19 + .../Sparcpoint.Core/Models/ProductCategory.cs | 7 + .../Sparcpoint.Core/Models/ProductMetadata.cs | 8 + .../Sparcpoint.Core/Sparcpoint.Core.csproj | 4 + .../ISqlExecutor.cs | 1 - 12 files changed, 506 insertions(+), 10 deletions(-) create mode 100644 Development Project/Sparcpoint.Core/Abstract/Repositories/IProductRepository.cs create mode 100644 Development Project/Sparcpoint.Core/Abstract/Services/IProductService.cs create mode 100644 Development Project/Sparcpoint.Core/DTOs/ProductDTO.cs create mode 100644 Development Project/Sparcpoint.Core/Implementations/Repositories/ProductRepository.cs create mode 100644 Development Project/Sparcpoint.Core/Implementations/Services/ProductService.cs create mode 100644 Development Project/Sparcpoint.Core/Models/Product.cs create mode 100644 Development Project/Sparcpoint.Core/Models/ProductCategory.cs create mode 100644 Development Project/Sparcpoint.Core/Models/ProductMetadata.cs diff --git a/Development Project/Interview.Web/Controllers/ProductController.cs b/Development Project/Interview.Web/Controllers/ProductController.cs index 267f4ec..33dbc61 100644 --- a/Development Project/Interview.Web/Controllers/ProductController.cs +++ b/Development Project/Interview.Web/Controllers/ProductController.cs @@ -1,19 +1,55 @@ -using Microsoft.AspNetCore.Mvc; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Sparcpoint.Abstract.Services; +using Sparcpoint.DTOs; namespace Interview.Web.Controllers { [Route("api/v1/products")] public class ProductController : Controller - { - // NOTE: Sample Action + { + private readonly IProductService _productService; + + public ProductController(IProductService productService) + { + _productService = productService; + } + [HttpGet] - public Task GetAllProducts() + public async Task GetAllProducts() + { + var products = await _productService.GetAllSync(); + + return Ok(products); + } + + /* + * I assumed that the category already exists in their table + */ + [HttpPost("create")] + public async Task Create([FromBody] ProductDTO product) { - return Task.FromResult((IActionResult)Ok(new object[] { })); + if (product is null) + { + return BadRequest(); + } + + var result = await _productService.Create(product); + + return Ok(result); + } + + [HttpPost("search")] + public async Task SearchProducts([FromBody] ProductDTO request) + { + if (request is null) + { + return BadRequest(); + } + + var productsSearched = await _productService.SearchAsync(request); + + return Ok(productsSearched); } } } diff --git a/Development Project/Interview.Web/Interview.Web.csproj b/Development Project/Interview.Web/Interview.Web.csproj index d1e4d6e..0153765 100644 --- a/Development Project/Interview.Web/Interview.Web.csproj +++ b/Development Project/Interview.Web/Interview.Web.csproj @@ -4,4 +4,8 @@ net8.0 + + + + diff --git a/Development Project/Sparcpoint.Core/Abstract/Repositories/IProductRepository.cs b/Development Project/Sparcpoint.Core/Abstract/Repositories/IProductRepository.cs new file mode 100644 index 0000000..fa7a654 --- /dev/null +++ b/Development Project/Sparcpoint.Core/Abstract/Repositories/IProductRepository.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Sparcpoint.Core.Models; +using Sparcpoint.DTOs; + +namespace Sparcpoint.Abstract.Repositories +{ + public interface IProductRepository + { + Task Create(ProductDTO product); + Task> GetAllAsync(); + Task> SearchAsync(ProductDTO request); + } +} diff --git a/Development Project/Sparcpoint.Core/Abstract/Services/IProductService.cs b/Development Project/Sparcpoint.Core/Abstract/Services/IProductService.cs new file mode 100644 index 0000000..7a3314b --- /dev/null +++ b/Development Project/Sparcpoint.Core/Abstract/Services/IProductService.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Sparcpoint.Core.Models; +using Sparcpoint.DTOs; + +namespace Sparcpoint.Abstract.Services +{ + public interface IProductService + { + Task> GetAllSync(); + Task Create(ProductDTO product); + Task> SearchAsync(ProductDTO request); + } +} \ No newline at end of file diff --git a/Development Project/Sparcpoint.Core/DTOs/ProductDTO.cs b/Development Project/Sparcpoint.Core/DTOs/ProductDTO.cs new file mode 100644 index 0000000..3ef0ea3 --- /dev/null +++ b/Development Project/Sparcpoint.Core/DTOs/ProductDTO.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; +using Sparcpoint.Models; + +namespace Sparcpoint.DTOs +{ + public class ProductDTO + { + public string Name { get; set; } + public string Description { get; set; } + public IList ProductImageUris { get; set; } + public IList ValidSkus { get; set; } + public IEnumerable Categories { get; set; } + public IEnumerable Metadata { get; set; } + } +} diff --git a/Development Project/Sparcpoint.Core/Implementations/Repositories/ProductRepository.cs b/Development Project/Sparcpoint.Core/Implementations/Repositories/ProductRepository.cs new file mode 100644 index 0000000..7815ce9 --- /dev/null +++ b/Development Project/Sparcpoint.Core/Implementations/Repositories/ProductRepository.cs @@ -0,0 +1,327 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Sparcpoint.Abstract.Repositories; +using Sparcpoint.Core.Models; +using Sparcpoint.DTOs; +using Sparcpoint.SqlServer.Abstractions; + +namespace Sparcpoint.Implementations.Repositories +{ + public class ProductRepository : IProductRepository + { + private readonly ISqlExecutor _sqlExecutor; + + public ProductRepository(ISqlExecutor sqlExecutor) + { + _sqlExecutor = sqlExecutor; + } + + /* + * Creates new product + */ + public async Task Create(ProductDTO request) + { + return await _sqlExecutor.ExecuteAsync(async (connection, transaction) => + { + var imageUris = request.ProductImageUris != null + ? string.Join(",", request.ProductImageUris) + : null; + + var skus = request.ValidSkus != null + ? string.Join(",", request.ValidSkus) + : null; + + int productId; + + using (var command = connection.CreateCommand()) + { + command.Transaction = transaction; + command.CommandText = @" + INSERT INTO Products (Name, Description, ProductImageUris, ValidSkus, CreatedTimestamp) + VALUES (@Name, @Description, @ProductImageUris, @ValidSkus, GETDATE()); + + SELECT CAST(SCOPE_IDENTITY() as int); + "; + + var nameParameter = command.CreateParameter(); + nameParameter.ParameterName = "@Name"; + nameParameter.Value = (object)request.Name ?? DBNull.Value; + command.Parameters.Add(nameParameter); + + var descriptionParameter = command.CreateParameter(); + descriptionParameter.ParameterName = "@Description"; + descriptionParameter.Value = (object)request.Description ?? DBNull.Value; + command.Parameters.Add(descriptionParameter); + + var productImageUrisParameter = command.CreateParameter(); + productImageUrisParameter.ParameterName = "@ProductImageUris"; + productImageUrisParameter.Value = (object)imageUris ?? DBNull.Value; + command.Parameters.Add(productImageUrisParameter); + + var validSkusParameter = command.CreateParameter(); + validSkusParameter.ParameterName = "@ValidSkus"; + validSkusParameter.Value = (object)skus ?? DBNull.Value; + command.Parameters.Add(validSkusParameter); + + var result = await ((dynamic)command).ExecuteScalarAsync(); + productId = Convert.ToInt32(result); + } + + if (request.Categories != null) + { + foreach (var category in request.Categories) + { + using (var categoryCommand = connection.CreateCommand()) + { + categoryCommand.Transaction = transaction; + categoryCommand.CommandText = @" + INSERT INTO ProductCategories (ProductId, CategoryId) + VALUES (@ProductId, @CategoryId); + "; + + var productIdParameter = categoryCommand.CreateParameter(); + productIdParameter.ParameterName = "@ProductId"; + productIdParameter.Value = productId; + categoryCommand.Parameters.Add(productIdParameter); + + var categoryIdParameter = categoryCommand.CreateParameter(); + categoryIdParameter.ParameterName = "@CategoryId"; + categoryIdParameter.Value = category.CategoryId; + categoryCommand.Parameters.Add(categoryIdParameter); + + await ((dynamic)categoryCommand).ExecuteNonQueryAsync(); + } + } + } + + if (request.Metadata != null) + { + foreach (var metadata in request.Metadata) + { + using (var metadataCommand = connection.CreateCommand()) + { + metadataCommand.Transaction = transaction; + metadataCommand.CommandText = @" + INSERT INTO ProductMetadata (ProductId, [Key], [Value]) + VALUES (@ProductId, @Key, @Value); + "; + + var productIdParameter = metadataCommand.CreateParameter(); + productIdParameter.ParameterName = "@ProductId"; + productIdParameter.Value = productId; + metadataCommand.Parameters.Add(productIdParameter); + + var keyParameter = metadataCommand.CreateParameter(); + keyParameter.ParameterName = "@Key"; + keyParameter.Value = (object)metadata.Key ?? DBNull.Value; + metadataCommand.Parameters.Add(keyParameter); + + var valueParameter = metadataCommand.CreateParameter(); + valueParameter.ParameterName = "@Value"; + valueParameter.Value = (object)metadata.Value ?? DBNull.Value; + metadataCommand.Parameters.Add(valueParameter); + + await ((dynamic)metadataCommand).ExecuteNonQueryAsync(); + } + } + } + + return new Product + { + InstanceId = productId, + Name = request.Name, + Description = request.Description, + ProductImageUris = request.ProductImageUris, + ValidSkus = request.ValidSkus, + CreatedTimestamp = DateTime.UtcNow, + Categories = request.Categories, + Metadata = request.Metadata + }; + }); + } + + + /* + * Gets all products + */ + public async Task> GetAllAsync() + { + return await _sqlExecutor.ExecuteAsync(async (connection, transaction) => + { + var products = new List(); + + using (var command = connection.CreateCommand()) + { + command.Transaction = transaction; + command.CommandText = @" + SELECT + InstanceId, + Name, + Description, + ProductImageUris, + ValidSkus, + CreatedTimestamp + FROM Products"; + + using (var reader = await ((dynamic)command).ExecuteReaderAsync()) + { + while (reader.Read()) + { + var product = new Product + { + InstanceId = reader.GetInt32(0), + Name = reader.GetString(1), + Description = reader.GetString(2), + ProductImageUris = reader.IsDBNull(3) + ? null + : reader.GetString(3).Split(',', StringSplitOptions.RemoveEmptyEntries), + ValidSkus = reader.IsDBNull(4) + ? null + : reader.GetString(4).Split(',', StringSplitOptions.RemoveEmptyEntries), + CreatedTimestamp = reader.GetDateTime(5) + }; + + products.Add(product); + } + } + } + + return products; + }); + } + + public async Task> SearchAsync(ProductDTO request) + { + return await _sqlExecutor.ExecuteAsync(async (connection, transaction) => + { + var products = new List(); + var sqlBuilder = new System.Text.StringBuilder(); + + sqlBuilder.AppendLine(@" + SELECT DISTINCT + p.InstanceId, + p.Name, + p.Description, + p.ProductImageUris, + p.ValidSkus, + p.CreatedTimestamp + FROM Products p"); + + using (var command = connection.CreateCommand()) + { + command.Transaction = transaction; + + if (!string.IsNullOrWhiteSpace(request.Name)) + { + sqlBuilder.AppendLine("AND p.Name LIKE @Name"); + + var parameter = command.CreateParameter(); + parameter.ParameterName = "@Name"; + parameter.Value = $"%{request.Name}%"; + command.Parameters.Add(parameter); + } + + if (!string.IsNullOrWhiteSpace(request.Description)) + { + sqlBuilder.AppendLine("AND p.Description LIKE @Description"); + + var parameter = command.CreateParameter(); + parameter.ParameterName = "@Description"; + parameter.Value = $"%{request.Description}%"; + command.Parameters.Add(parameter); + } + + if (request.Categories != null) + { + var categories = request.Categories; + + if (categories.Count() > 0) + { + for (int i = 0; i < categories.Count(); i++) + { + var parameterName = $"@CategoryId{i}"; + if (i == 0) + { + sqlBuilder.AppendLine("AND EXISTS ("); + sqlBuilder.AppendLine(" SELECT 1"); + sqlBuilder.AppendLine(" FROM ProductCategories pc"); + sqlBuilder.AppendLine(" WHERE pc.ProductId = p.InstanceId"); + sqlBuilder.Append($" AND pc.CategoryId IN ({parameterName}"); + } + else + { + sqlBuilder.Append($", {parameterName}"); + } + + var parameter = command.CreateParameter(); + parameter.ParameterName = parameterName; + parameter.Value = categories.ElementAt(i); + command.Parameters.Add(parameter); + } + + sqlBuilder.AppendLine("))"); + } + } + + if (request.Metadata != null) + { + var metadataList = request.Metadata.ToList(); + + for (int i = 0; i < metadataList.Count; i++) + { + var keyParameterName = $"@MetaKey{i}"; + var valueParameterName = $"@MetaValue{i}"; + + sqlBuilder.AppendLine($@" + AND EXISTS ( + SELECT 1 + FROM ProductMetadata pm + WHERE pm.ProductId = p.InstanceId + AND pm.[Key] = {keyParameterName} + AND pm.[Value] = {valueParameterName} + )"); + + var keyParameter = command.CreateParameter(); + keyParameter.ParameterName = keyParameterName; + keyParameter.Value = (object)metadataList[i].Key ?? DBNull.Value; + command.Parameters.Add(keyParameter); + + var valueParameter = command.CreateParameter(); + valueParameter.ParameterName = valueParameterName; + valueParameter.Value = (object)metadataList[i].Value ?? DBNull.Value; + command.Parameters.Add(valueParameter); + } + } + + command.CommandText = sqlBuilder.ToString(); + + using (var reader = await ((dynamic)command).ExecuteReaderAsync()) + { + while (reader.Read()) + { + var product = new Product + { + InstanceId = reader.GetInt32(0), + Name = reader.IsDBNull(1) ? null : reader.GetString(1), + Description = reader.IsDBNull(2) ? null : reader.GetString(2), + ProductImageUris = reader.IsDBNull(3) + ? null + : reader.GetString(3).Split(',', StringSplitOptions.RemoveEmptyEntries), + ValidSkus = reader.IsDBNull(4) + ? null + : reader.GetString(4).Split(',', StringSplitOptions.RemoveEmptyEntries), + CreatedTimestamp = reader.GetDateTime(5) + }; + + products.Add(product); + } + } + } + + return products; + }); + } + } +} diff --git a/Development Project/Sparcpoint.Core/Implementations/Services/ProductService.cs b/Development Project/Sparcpoint.Core/Implementations/Services/ProductService.cs new file mode 100644 index 0000000..2a7caba --- /dev/null +++ b/Development Project/Sparcpoint.Core/Implementations/Services/ProductService.cs @@ -0,0 +1,49 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Sparcpoint.Abstract.Repositories; +using Sparcpoint.Abstract.Services; +using Sparcpoint.Core.Models; +using Sparcpoint.DTOs; + +namespace Sparcpoint.Implementations.Services +{ + public class ProductService : IProductService + { + private readonly IProductRepository _productRepository; + + public ProductService(IProductRepository productRepository) + { + _productRepository = productRepository; + } + + /* + * Create new product + */ + public async Task Create(ProductDTO product) + { + var productCreated = await _productRepository.Create(product); + + var toReturn = new ProductDTO + { + Categories = productCreated.Categories + //... + }; + + + return toReturn; + } + + /* + * Gets all products + */ + public async Task> GetAllSync() + { + return await _productRepository.GetAllAsync(); + } + + public Task> SearchAsync(ProductDTO request) + { + return _productRepository.SearchAsync(request); + } + } +} diff --git a/Development Project/Sparcpoint.Core/Models/Product.cs b/Development Project/Sparcpoint.Core/Models/Product.cs new file mode 100644 index 0000000..7f24b62 --- /dev/null +++ b/Development Project/Sparcpoint.Core/Models/Product.cs @@ -0,0 +1,19 @@ +using System; +using System.Collections.Generic; +using Sparcpoint.Models; + +namespace Sparcpoint.Core.Models +{ + public class Product + { + public int InstanceId { get; set; } + public string Name { get; set; } + public string Description { get; set; } + public IList ProductImageUris { get; set; } + public IList ValidSkus { get; set; } + public DateTime CreatedTimestamp { get; set; } + + public IEnumerable Categories { get; set; } + public IEnumerable Metadata { get; set; } + } +} diff --git a/Development Project/Sparcpoint.Core/Models/ProductCategory.cs b/Development Project/Sparcpoint.Core/Models/ProductCategory.cs new file mode 100644 index 0000000..c672a53 --- /dev/null +++ b/Development Project/Sparcpoint.Core/Models/ProductCategory.cs @@ -0,0 +1,7 @@ +namespace Sparcpoint.Models +{ + public class ProductCategory + { + public int CategoryId { get; set; } + } +} \ No newline at end of file diff --git a/Development Project/Sparcpoint.Core/Models/ProductMetadata.cs b/Development Project/Sparcpoint.Core/Models/ProductMetadata.cs new file mode 100644 index 0000000..b18874c --- /dev/null +++ b/Development Project/Sparcpoint.Core/Models/ProductMetadata.cs @@ -0,0 +1,8 @@ +namespace Sparcpoint.Models +{ + public class ProductMetadata + { + public string Key { get; set; } + public string Value { get; set; } + } +} diff --git a/Development Project/Sparcpoint.Core/Sparcpoint.Core.csproj b/Development Project/Sparcpoint.Core/Sparcpoint.Core.csproj index fdceab5..a38f9d6 100644 --- a/Development Project/Sparcpoint.Core/Sparcpoint.Core.csproj +++ b/Development Project/Sparcpoint.Core/Sparcpoint.Core.csproj @@ -18,4 +18,8 @@ + + + + diff --git a/Development Project/Sparcpoint.SqlServer.Abstractions/ISqlExecutor.cs b/Development Project/Sparcpoint.SqlServer.Abstractions/ISqlExecutor.cs index cca814f..86c7146 100644 --- a/Development Project/Sparcpoint.SqlServer.Abstractions/ISqlExecutor.cs +++ b/Development Project/Sparcpoint.SqlServer.Abstractions/ISqlExecutor.cs @@ -9,6 +9,5 @@ public interface ISqlExecutor { Task ExecuteAsync(Func command); Task ExecuteAsync(Func> command); - T Execute(Func command); } }