diff --git a/EcommerceApi.DiegoPetrola/Context/EcommerceDbContext.cs b/EcommerceApi.DiegoPetrola/Context/EcommerceDbContext.cs new file mode 100644 index 00000000..85c6083c --- /dev/null +++ b/EcommerceApi.DiegoPetrola/Context/EcommerceDbContext.cs @@ -0,0 +1,59 @@ +using EcommerceApi.Models; +using Microsoft.EntityFrameworkCore; + +namespace EcommerceApi.Context; + +public class EcommerceDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Sales { get; set; } + public DbSet SaleItems { get; set; } + public DbSet Products { get; set; } + public DbSet Categories { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.Entity() + .HasMany(s => s.SaleItems) + .WithOne(si => si.Sale) + .HasForeignKey(si => si.SaleId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasOne(p => p.Category) + .WithMany(c => c.Products) + .HasForeignKey(p => p.CategoryId) + .OnDelete(DeleteBehavior.Restrict); + + modelBuilder.Entity() + .HasMany(p => p.SaleItems) + .WithOne(si => si.Product) + .HasForeignKey(si => si.ProductId) + .OnDelete(DeleteBehavior.Restrict); + + modelBuilder.Entity() + .Property(p => p.Price) + .HasPrecision(18, 2); + + modelBuilder.Entity() + .Property(p => p.ProductPrice) + .HasPrecision(18, 2); + + modelBuilder.Entity() + .HasKey(si => new { si.SaleId, si.ProductId }); + + modelBuilder.Entity() + .HasOne(si => si.Product) + .WithMany(p => p.SaleItems) + .IsRequired(false); + + modelBuilder.Entity() + .HasIndex(c => c.Name) + .IsUnique(); + + modelBuilder.Entity().HasQueryFilter(p => !p.IsDeleted); + modelBuilder.Entity().HasQueryFilter(s => !s.IsDeleted); + modelBuilder.Entity().HasQueryFilter(si => !si.Sale.IsDeleted); + } +} \ No newline at end of file diff --git a/EcommerceApi.DiegoPetrola/Controllers/CategoriesController.cs b/EcommerceApi.DiegoPetrola/Controllers/CategoriesController.cs new file mode 100644 index 00000000..6755bd8f --- /dev/null +++ b/EcommerceApi.DiegoPetrola/Controllers/CategoriesController.cs @@ -0,0 +1,47 @@ +using EcommerceApi.Models.DTOs; +using EcommerceApi.Services; +using Microsoft.AspNetCore.Mvc; + +namespace EcommerceApi.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class CategoriesController(CategoriesService service) : ControllerBase +{ + [HttpGet] + public async Task>> GetCategories() + { + var res = await service.GetCategories(); + return this.ToActionResult(res); + } + + [HttpGet("{id}")] + public async Task> GetCategory(int id) + { + var res = await service.GetCategory(id); + return this.ToActionResult(res); + } + + [HttpPost] + public async Task> CreateCategory(CreateCategoryDto dto) + { + var res = await service.CreateCategory(dto); + return this.ToActionResult(res); + } + + [HttpPut("{id}")] + public async Task UpdateCategory(int id, CategoryDto dto) + { + if (dto.Id != id) + return BadRequest(); + var res = await service.UpdateCategory(dto); + return this.ToActionResult(res); + } + + [HttpDelete("{id}")] + public async Task DeleteCategory(int id) + { + var res = await service.DeleteCategory(id); + return this.ToActionResult(res); + } +} diff --git a/EcommerceApi.DiegoPetrola/Controllers/ProductsController.cs b/EcommerceApi.DiegoPetrola/Controllers/ProductsController.cs new file mode 100644 index 00000000..52669067 --- /dev/null +++ b/EcommerceApi.DiegoPetrola/Controllers/ProductsController.cs @@ -0,0 +1,50 @@ +using EcommerceApi.Models.DTOs; +using EcommerceApi.Services; +using EcommerceApi.Utils; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; + +namespace EcommerceApi.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class ProductsController(ProductsService service, IOptions options) : ControllerBase +{ + [HttpGet] + public async Task>> GetProducts([FromQuery] int? pageSize, [FromQuery] int pageNumber = 0) + { + int finalSize = Math.Min(pageSize ?? options.Value.DefaultPageSize, options.Value.MaxPageSize); + var res = await service.GetProducts(pageNumber, finalSize); + return this.ToActionResult(res); + } + + [HttpGet("{id}")] + public async Task> GetProductById(int id) + { + var res = await service.GetProduct(id); + return this.ToActionResult(res); + } + + [HttpPost] + public async Task> CreateProduct(CreateProductDto dto) + { + var res = await service.CreateProduct(dto); + return this.ToActionResult(res); + } + + [HttpDelete("{id}")] + public async Task> DeleteProduct(int id) + { + var res = await service.SoftDeleteProduct(id); + return this.ToActionResult(res); + } + + [HttpPut("{id}")] + public async Task> UpdateProduct(int id, ProductDto dto) + { + if (id != dto.Id) + return BadRequest(); + var res = await service.UpdateProduct(dto); + return this.ToActionResult(res); + } +} diff --git a/EcommerceApi.DiegoPetrola/Controllers/ResultExtensions.cs b/EcommerceApi.DiegoPetrola/Controllers/ResultExtensions.cs new file mode 100644 index 00000000..e614305a --- /dev/null +++ b/EcommerceApi.DiegoPetrola/Controllers/ResultExtensions.cs @@ -0,0 +1,36 @@ +using EcommerceApi.Models; +using EcommerceApi.Models.DTOs; +using EcommerceApi.Results; +using Microsoft.AspNetCore.Mvc; + +namespace EcommerceApi.Controllers; + +public static class ResultExtensions +{ + public static ActionResult ToActionResult(this ControllerBase controller, Result res) + { + if (res.IsSuccess) + return controller.Ok(res.Value); + + return res.Error.ErrorType switch + { + ErrorType.NotFound => controller.NotFound(res.Error.Error), + ErrorType.Invalid => controller.BadRequest(res.Error.Error), + _ => controller.Problem(res.Error.Error) + }; + } + + public static SaleDto ToSaleDto(Sale sale) + { + return new SaleDto( + sale.Id, + sale.SaleDate, + [.. sale.SaleItems.Select(si => new SaleItemDto( + si.ProductId, + si.Product.Name, + si.Quantity, + si.Product.Price))], + sale.SaleItems.Sum(si => si.Quantity * si.Product.Price) + ); + } +} \ No newline at end of file diff --git a/EcommerceApi.DiegoPetrola/Controllers/SalesController.cs b/EcommerceApi.DiegoPetrola/Controllers/SalesController.cs new file mode 100644 index 00000000..7a9c587f --- /dev/null +++ b/EcommerceApi.DiegoPetrola/Controllers/SalesController.cs @@ -0,0 +1,41 @@ +using EcommerceApi.Models.DTOs; +using EcommerceApi.Services; +using EcommerceApi.Utils; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; + +namespace EcommerceApi.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class SalesController(SalesService service, IOptions options) : ControllerBase +{ + [HttpGet("{id}")] + public async Task> GetSale(int id) + { + var res = await service.GetSale(id); + return this.ToActionResult(res); + } + + [HttpGet()] + public async Task>> GetSalePage([FromQuery] int? pageSize, [FromQuery] int pageNumber = 0) + { + int finalSize = Math.Min(pageSize ?? options.Value.DefaultPageSize, options.Value.MaxPageSize); + var res = await service.GetSalesByPage(pageNumber, finalSize); + return this.ToActionResult(res); + } + + [HttpPost] + public async Task> CreateSale(CreateSaleDto dto) + { + var res = await service.CreateSale(dto); + return this.ToActionResult(res); + } + + [HttpDelete("{id}")] + public async Task> DeleteSale(int id) + { + var res = await service.DeleteSale(id); + return this.ToActionResult(res); + } +} diff --git a/EcommerceApi.DiegoPetrola/Ecommerce.postman_collection.json b/EcommerceApi.DiegoPetrola/Ecommerce.postman_collection.json new file mode 100644 index 00000000..02a44e38 --- /dev/null +++ b/EcommerceApi.DiegoPetrola/Ecommerce.postman_collection.json @@ -0,0 +1,691 @@ +{ + "info": { + "_postman_id": "c3c76d52-7143-4d8c-8448-9b97ab2fc5e8", + "name": "Dotnet Ecommerce", + "description": "# 🚀 Get started here\n\nThis template guides you through CRUD operations (GET, POST, PUT, DELETE), variables, and tests.\n\n## 🔖 **How to use this template**\n\n#### **Step 1: Send requests**\n\nRESTful APIs allow you to perform CRUD operations using the POST, GET, PUT, and DELETE HTTP methods.\n\nThis collection contains each of these [request](https://learning.postman.com/docs/sending-requests/requests/) types. Open each request and click \"Send\" to see what happens.\n\n#### **Step 2: View responses**\n\nObserve the response tab for status code (200 OK), response time, and size.\n\n#### **Step 3: Send new Body data**\n\nUpdate or add new data in \"Body\" in the POST request. Typically, Body data is also used in PUT request.\n\n```\n{\n \"name\": \"Add your name in the body\"\n}\n\n ```\n\n#### **Step 4: Update the variable**\n\nVariables enable you to store and reuse values in Postman. We have created a [variable](https://learning.postman.com/docs/sending-requests/variables/) called `base_url` with the sample request [https://postman-api-learner.glitch.me](https://postman-api-learner.glitch.me). Replace it with your API endpoint to customize this collection.\n\n#### **Step 5: Add tests in the \"Scripts\" tab**\n\nAdding tests to your requests can help you confirm that your API is working as expected. You can write test scripts in JavaScript and view the output in the \"Test Results\" tab.\n\n\"\"\n\n## 💪 Pro tips\n\n- Use folders to group related requests and organize the collection.\n \n- Add more [scripts](https://learning.postman.com/docs/writing-scripts/intro-to-scripts/) to verify if the API works as expected and execute workflows.\n \n\n## 💡Related templates\n\n[API testing basics](https://go.postman.co/redirect/workspace?type=personal&collectionTemplateId=e9a37a28-055b-49cd-8c7e-97494a21eb54&sourceTemplateId=ddb19591-3097-41cf-82af-c84273e56719) \n[API documentation](https://go.postman.co/redirect/workspace?type=personal&collectionTemplateId=e9c28f47-1253-44af-a2f3-20dce4da1f18&sourceTemplateId=ddb19591-3097-41cf-82af-c84273e56719) \n[Authorization methods](https://go.postman.co/redirect/workspace?type=personal&collectionTemplateId=31a9a6ed-4cdf-4ced-984c-d12c9aec1c27&sourceTemplateId=ddb19591-3097-41cf-82af-c84273e56719)", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "_exporter_id": "28759469" + }, + "item": [ + { + "name": "Categories", + "item": [ + { + "name": "Get category", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", function () {", + " pm.response.to.have.status(200);", + "});" + ], + "type": "text/javascript", + "packages": {}, + "requests": {} + } + }, + { + "listen": "prerequest", + "script": { + "packages": {}, + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{base_url}}/api/categories", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "categories" + ] + }, + "description": "This is a GET request and it is used to \"get\" data from an endpoint. There is no request body for a GET request, but you can use query parameters to help specify the resource you want data on (e.g., in this request, we have `id=1`).\n\nA successful GET response will have a `200 OK` status, and should include some kind of response body - for example, HTML web content or JSON data." + }, + "response": [] + }, + { + "name": "Post data", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Successful POST request\", function () {", + " pm.expect(pm.response.code).to.be.oneOf([200, 201]);", + "});", + "" + ], + "type": "text/javascript", + "packages": {}, + "requests": {} + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"Name\": \"TestCat123\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{base_url}}/api/categories", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "categories" + ] + }, + "description": "This is a POST request, submitting data to an API via the request body. This request submits JSON data, and the data is reflected in the response.\n\nA successful POST request typically returns a `200 OK` or `201 Created` response code." + }, + "response": [] + }, + { + "name": "Update data", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Successful PUT request\", function () {", + " pm.expect(pm.response.code).to.be.oneOf([200, 201, 204]);", + "});", + "" + ], + "type": "text/javascript", + "packages": {}, + "requests": {} + } + }, + { + "listen": "prerequest", + "script": { + "exec": [ + "" + ], + "type": "text/javascript", + "packages": {}, + "requests": {} + } + } + ], + "request": { + "method": "PUT", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"id\": 1,\n\t\"name\": \"Category 5\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{base_url}}/api/categories/1", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "categories", + "1" + ] + }, + "description": "This is a PUT request and it is used to overwrite an existing piece of data. For instance, after you create an entity with a POST request, you may want to modify that later. You can do that using a PUT request. You typically identify the entity being updated by including an identifier in the URL (eg. `id=1`).\n\nA successful PUT request typically returns a `200 OK`, `201 Created`, or `204 No Content` response code." + }, + "response": [] + }, + { + "name": "Delete data", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Successful DELETE request\", function () {", + " pm.expect(pm.response.code).to.be.oneOf([200, 202, 204]);", + "});", + "" + ], + "type": "text/javascript", + "packages": {}, + "requests": {} + } + }, + { + "listen": "prerequest", + "script": { + "exec": [], + "type": "text/javascript" + } + } + ], + "request": { + "method": "DELETE", + "header": [], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{base_url}}/api/categories/1", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "categories", + "1" + ] + }, + "description": "This is a DELETE request, and it is used to delete data that was previously created via a POST request. You typically identify the entity being updated by including an identifier in the URL (eg. `id=1`).\n\nA successful DELETE request typically returns a `200 OK`, `202 Accepted`, or `204 No Content` response code." + }, + "response": [] + }, + { + "name": "Post data Copy", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Successful POST request\", function () {", + " pm.expect(pm.response.code).to.be.oneOf([200, 201]);", + "});", + "" + ], + "type": "text/javascript", + "packages": {}, + "requests": {} + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"Name\": \"TestCat1\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{base_url}}/api/categories", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "categories" + ] + }, + "description": "This is a POST request, submitting data to an API via the request body. This request submits JSON data, and the data is reflected in the response.\n\nA successful POST request typically returns a `200 OK` or `201 Created` response code." + }, + "response": [] + } + ] + }, + { + "name": "Products", + "item": [ + { + "name": "Get Prod Page", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "method": "GET", + "header": [], + "body": { + "mode": "urlencoded", + "urlencoded": [] + }, + "url": { + "raw": "{{base_url}}/api/products?pageSize=1&pageNumber=0", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "products" + ], + "query": [ + { + "key": "pageSize", + "value": "1" + }, + { + "key": "pageNumber", + "value": "0" + } + ] + } + }, + "response": [] + }, + { + "name": "New Product", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", function () {", + " pm.response.to.have.status(200);", + "});" + ], + "type": "text/javascript", + "packages": {}, + "requests": {} + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"Name\": \"Computer2\",\r\n \"Price\": 150,\r\n \"CategoryId\": 2\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{base_url}}/api/products", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "products" + ] + }, + "description": "This is a GET request and it is used to \"get\" data from an endpoint. There is no request body for a GET request, but you can use query parameters to help specify the resource you want data on (e.g., in this request, we have `id=1`).\n\nA successful GET response will have a `200 OK` status, and should include some kind of response body - for example, HTML web content or JSON data." + }, + "response": [] + }, + { + "name": "New Product Copy", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", function () {", + " pm.response.to.have.status(200);", + "});" + ], + "type": "text/javascript", + "packages": {}, + "requests": {} + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"Name\": \"Computer2\",\r\n \"Price\": 150,\r\n \"CategoryId\": 2\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{base_url}}/api/products", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "products" + ] + }, + "description": "This is a GET request and it is used to \"get\" data from an endpoint. There is no request body for a GET request, but you can use query parameters to help specify the resource you want data on (e.g., in this request, we have `id=1`).\n\nA successful GET response will have a `200 OK` status, and should include some kind of response body - for example, HTML web content or JSON data." + }, + "response": [] + }, + { + "name": "New Product Copy", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", function () {", + " pm.response.to.have.status(200);", + "});" + ], + "type": "text/javascript", + "packages": {}, + "requests": {} + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"Name\": \"Computer3\",\r\n \"Price\": 150,\r\n \"CategoryId\": 2\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{base_url}}/api/products", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "products" + ] + }, + "description": "This is a GET request and it is used to \"get\" data from an endpoint. There is no request body for a GET request, but you can use query parameters to help specify the resource you want data on (e.g., in this request, we have `id=1`).\n\nA successful GET response will have a `200 OK` status, and should include some kind of response body - for example, HTML web content or JSON data." + }, + "response": [] + }, + { + "name": "Get Prod", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{base_url}}/api/products/2", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "products", + "2" + ] + } + }, + "response": [] + }, + { + "name": "Update Prod", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Successful PUT request\", function () {", + " pm.expect(pm.response.code).to.be.oneOf([200, 201, 204]);", + "});", + "" + ], + "type": "text/javascript", + "packages": {}, + "requests": {} + } + }, + { + "listen": "prerequest", + "script": { + "exec": [ + "" + ], + "type": "text/javascript", + "packages": {}, + "requests": {} + } + } + ], + "request": { + "method": "PUT", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"id\": 1,\n \"Name\": \"Computer5\",\n \"Price\": 150,\n \"CategoryId\": 2,\n \"CategoryName\": \"Ca1\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{base_url}}/api/products/1", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "products", + "1" + ] + }, + "description": "This is a PUT request and it is used to overwrite an existing piece of data. For instance, after you create an entity with a POST request, you may want to modify that later. You can do that using a PUT request. You typically identify the entity being updated by including an identifier in the URL (eg. `id=1`).\n\nA successful PUT request typically returns a `200 OK`, `201 Created`, or `204 No Content` response code." + }, + "response": [] + }, + { + "name": "Del Prod", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Successful PUT request\", function () {", + " pm.expect(pm.response.code).to.be.oneOf([200, 201, 204]);", + "});", + "" + ], + "type": "text/javascript", + "packages": {}, + "requests": {} + } + }, + { + "listen": "prerequest", + "script": { + "exec": [ + "" + ], + "type": "text/javascript", + "packages": {}, + "requests": {} + } + } + ], + "request": { + "method": "DELETE", + "header": [], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{base_url}}/api/products/1", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "products", + "1" + ] + }, + "description": "This is a PUT request and it is used to overwrite an existing piece of data. For instance, after you create an entity with a POST request, you may want to modify that later. You can do that using a PUT request. You typically identify the entity being updated by including an identifier in the URL (eg. `id=1`).\n\nA successful PUT request typically returns a `200 OK`, `201 Created`, or `204 No Content` response code." + }, + "response": [] + } + ] + }, + { + "name": "Sale", + "item": [ + { + "name": "New Sale", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"Items\":[\r\n {\r\n \"productId\": 2,\r\n \"quantity\": 2\r\n }\r\n ]\r\n}\r\n", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{base_url}}/api/sales", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "sales" + ] + } + }, + "response": [] + }, + { + "name": "Get Sale", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "method": "GET", + "header": [], + "body": { + "mode": "raw", + "raw": "" + }, + "url": { + "raw": "{{base_url}}/api/sales/3", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "sales", + "3" + ] + } + }, + "response": [] + }, + { + "name": "Sale Page", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "method": "GET", + "header": [], + "body": { + "mode": "raw", + "raw": "" + }, + "url": { + "raw": "{{base_url}}/api/sales", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "sales" + ] + } + }, + "response": [] + }, + { + "name": "Delete Sale", + "request": { + "method": "DELETE", + "header": [], + "body": { + "mode": "raw", + "raw": "" + }, + "url": { + "raw": "{{base_url}}/api/sales/1", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "sales", + "1" + ] + } + }, + "response": [] + } + ] + } + ], + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "requests": {}, + "exec": [ + "" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "requests": {}, + "exec": [ + "" + ] + } + } + ], + "variable": [ + { + "key": "id", + "value": "1" + }, + { + "key": "base_url", + "value": "https://template.postman-echo.com" + } + ] +} \ No newline at end of file diff --git a/EcommerceApi.DiegoPetrola/EcommerceApi.csproj b/EcommerceApi.DiegoPetrola/EcommerceApi.csproj new file mode 100644 index 00000000..570457c6 --- /dev/null +++ b/EcommerceApi.DiegoPetrola/EcommerceApi.csproj @@ -0,0 +1,19 @@ + + + + net10.0 + enable + enable + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/EcommerceApi.DiegoPetrola/EcommerceApi.http b/EcommerceApi.DiegoPetrola/EcommerceApi.http new file mode 100644 index 00000000..0227b5ea --- /dev/null +++ b/EcommerceApi.DiegoPetrola/EcommerceApi.http @@ -0,0 +1,25 @@ +@EcommerceApi_HostAddress = http://localhost:5112 + +GET {{EcommerceApi_HostAddress}}/api/categories/ +Accept: application/json +### GET +GET {{EcommerceApi_HostAddress}}/api/categories/1 +Accept: application/json +### CREATE +POST {{EcommerceApi_HostAddress}}/api/categories +Accept: application/json +Content-Type: application/json + +{ + "Name":"Cat" +} +### GET PRODUCTS PAGE +GET {{EcommerceApi_HostAddress}}/api/products/page/1 +Accept: application/json +### GET SALES PAGE +GET {{EcommerceApi_HostAddress}}/api/sales/page/1 +Accept: application/json +### GET SALES +GET {{EcommerceApi_HostAddress}}/api/sales/0 +Accept: application/json +### \ No newline at end of file diff --git a/EcommerceApi.DiegoPetrola/Migrations/20260216042746_Initial.Designer.cs b/EcommerceApi.DiegoPetrola/Migrations/20260216042746_Initial.Designer.cs new file mode 100644 index 00000000..443691ad --- /dev/null +++ b/EcommerceApi.DiegoPetrola/Migrations/20260216042746_Initial.Designer.cs @@ -0,0 +1,168 @@ +// +using System; +using EcommerceApi.Context; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EcommerceApi.Migrations +{ + [DbContext(typeof(EcommerceDbContext))] + [Migration("20260216042746_Initial")] + partial class Initial + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.3") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("EcommerceApi.Models.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Categories"); + }); + + modelBuilder.Entity("EcommerceApi.Models.Product", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CategoryId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Price") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.ToTable("Products"); + }); + + modelBuilder.Entity("EcommerceApi.Models.Sale", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("SaleDate") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.ToTable("Sales"); + }); + + modelBuilder.Entity("EcommerceApi.Models.SaleItem", b => + { + b.Property("SaleId") + .HasColumnType("int"); + + b.Property("ProductId") + .HasColumnType("int"); + + b.Property("ProductName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ProductPrice") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("Quantity") + .HasColumnType("int"); + + b.HasKey("SaleId", "ProductId"); + + b.HasIndex("ProductId"); + + b.ToTable("SaleItems"); + }); + + modelBuilder.Entity("EcommerceApi.Models.Product", b => + { + b.HasOne("EcommerceApi.Models.Category", "Category") + .WithMany("Products") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Category"); + }); + + modelBuilder.Entity("EcommerceApi.Models.SaleItem", b => + { + b.HasOne("EcommerceApi.Models.Product", "Product") + .WithMany("SaleItems") + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("EcommerceApi.Models.Sale", "Sale") + .WithMany("SaleItems") + .HasForeignKey("SaleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Product"); + + b.Navigation("Sale"); + }); + + modelBuilder.Entity("EcommerceApi.Models.Category", b => + { + b.Navigation("Products"); + }); + + modelBuilder.Entity("EcommerceApi.Models.Product", b => + { + b.Navigation("SaleItems"); + }); + + modelBuilder.Entity("EcommerceApi.Models.Sale", b => + { + b.Navigation("SaleItems"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/EcommerceApi.DiegoPetrola/Migrations/20260216042746_Initial.cs b/EcommerceApi.DiegoPetrola/Migrations/20260216042746_Initial.cs new file mode 100644 index 00000000..a74bdd9c --- /dev/null +++ b/EcommerceApi.DiegoPetrola/Migrations/20260216042746_Initial.cs @@ -0,0 +1,123 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EcommerceApi.Migrations +{ + /// + public partial class Initial : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Categories", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Name = table.Column(type: "nvarchar(450)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Categories", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Sales", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + SaleDate = table.Column(type: "datetime2", nullable: false), + IsDeleted = table.Column(type: "bit", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Sales", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Products", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Name = table.Column(type: "nvarchar(max)", nullable: false), + Price = table.Column(type: "decimal(18,2)", precision: 18, scale: 2, nullable: false), + CategoryId = table.Column(type: "int", nullable: false), + IsDeleted = table.Column(type: "bit", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Products", x => x.Id); + table.ForeignKey( + name: "FK_Products_Categories_CategoryId", + column: x => x.CategoryId, + principalTable: "Categories", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "SaleItems", + columns: table => new + { + ProductId = table.Column(type: "int", nullable: false), + SaleId = table.Column(type: "int", nullable: false), + ProductName = table.Column(type: "nvarchar(max)", nullable: false), + ProductPrice = table.Column(type: "decimal(18,2)", precision: 18, scale: 2, nullable: false), + Quantity = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_SaleItems", x => new { x.SaleId, x.ProductId }); + table.ForeignKey( + name: "FK_SaleItems_Products_ProductId", + column: x => x.ProductId, + principalTable: "Products", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_SaleItems_Sales_SaleId", + column: x => x.SaleId, + principalTable: "Sales", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Categories_Name", + table: "Categories", + column: "Name", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Products_CategoryId", + table: "Products", + column: "CategoryId"); + + migrationBuilder.CreateIndex( + name: "IX_SaleItems_ProductId", + table: "SaleItems", + column: "ProductId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "SaleItems"); + + migrationBuilder.DropTable( + name: "Products"); + + migrationBuilder.DropTable( + name: "Sales"); + + migrationBuilder.DropTable( + name: "Categories"); + } + } +} diff --git a/EcommerceApi.DiegoPetrola/Migrations/EcommerceDbContextModelSnapshot.cs b/EcommerceApi.DiegoPetrola/Migrations/EcommerceDbContextModelSnapshot.cs new file mode 100644 index 00000000..8b733f3c --- /dev/null +++ b/EcommerceApi.DiegoPetrola/Migrations/EcommerceDbContextModelSnapshot.cs @@ -0,0 +1,165 @@ +// +using System; +using EcommerceApi.Context; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EcommerceApi.Migrations +{ + [DbContext(typeof(EcommerceDbContext))] + partial class EcommerceDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.3") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("EcommerceApi.Models.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Categories"); + }); + + modelBuilder.Entity("EcommerceApi.Models.Product", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CategoryId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Price") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.ToTable("Products"); + }); + + modelBuilder.Entity("EcommerceApi.Models.Sale", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("SaleDate") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.ToTable("Sales"); + }); + + modelBuilder.Entity("EcommerceApi.Models.SaleItem", b => + { + b.Property("SaleId") + .HasColumnType("int"); + + b.Property("ProductId") + .HasColumnType("int"); + + b.Property("ProductName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ProductPrice") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("Quantity") + .HasColumnType("int"); + + b.HasKey("SaleId", "ProductId"); + + b.HasIndex("ProductId"); + + b.ToTable("SaleItems"); + }); + + modelBuilder.Entity("EcommerceApi.Models.Product", b => + { + b.HasOne("EcommerceApi.Models.Category", "Category") + .WithMany("Products") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Category"); + }); + + modelBuilder.Entity("EcommerceApi.Models.SaleItem", b => + { + b.HasOne("EcommerceApi.Models.Product", "Product") + .WithMany("SaleItems") + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("EcommerceApi.Models.Sale", "Sale") + .WithMany("SaleItems") + .HasForeignKey("SaleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Product"); + + b.Navigation("Sale"); + }); + + modelBuilder.Entity("EcommerceApi.Models.Category", b => + { + b.Navigation("Products"); + }); + + modelBuilder.Entity("EcommerceApi.Models.Product", b => + { + b.Navigation("SaleItems"); + }); + + modelBuilder.Entity("EcommerceApi.Models.Sale", b => + { + b.Navigation("SaleItems"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/EcommerceApi.DiegoPetrola/Models/Category.cs b/EcommerceApi.DiegoPetrola/Models/Category.cs new file mode 100644 index 00000000..25a726a3 --- /dev/null +++ b/EcommerceApi.DiegoPetrola/Models/Category.cs @@ -0,0 +1,8 @@ +namespace EcommerceApi.Models; + +public class Category +{ + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public ICollection Products { get; set; } = []; +} diff --git a/EcommerceApi.DiegoPetrola/Models/DTOs/CommerceDtos.cs b/EcommerceApi.DiegoPetrola/Models/DTOs/CommerceDtos.cs new file mode 100644 index 00000000..250005ae --- /dev/null +++ b/EcommerceApi.DiegoPetrola/Models/DTOs/CommerceDtos.cs @@ -0,0 +1,10 @@ +namespace EcommerceApi.Models.DTOs; + +public record CategoryDto(int Id, string Name); +public record CreateCategoryDto(string Name); +public record ProductDto(int Id, string Name, decimal Price, int CategoryId, string CategoryName); +public record CreateProductDto(string Name, decimal Price, int CategoryId); +public record SaleItemDto(int? ProductId, string ProductName, int Quantity, decimal UnitPrice); +public record SaleDto(int Id, DateTime SaleDate, List SaleItems, decimal TotalAmount); +public record CreateSaleDto(List Items); +public record CreateSaleItemDto(int ProductId, int Quantity); diff --git a/EcommerceApi.DiegoPetrola/Models/Product.cs b/EcommerceApi.DiegoPetrola/Models/Product.cs new file mode 100644 index 00000000..96cf6783 --- /dev/null +++ b/EcommerceApi.DiegoPetrola/Models/Product.cs @@ -0,0 +1,12 @@ +namespace EcommerceApi.Models; + +public class Product +{ + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public decimal Price { get; set; } + public int CategoryId { get; set; } + public bool IsDeleted { get; set; } + public Category Category { get; set; } = null!; + public ICollection SaleItems { get; set; } = []; +} diff --git a/EcommerceApi.DiegoPetrola/Models/Sale.cs b/EcommerceApi.DiegoPetrola/Models/Sale.cs new file mode 100644 index 00000000..88eda93f --- /dev/null +++ b/EcommerceApi.DiegoPetrola/Models/Sale.cs @@ -0,0 +1,9 @@ +namespace EcommerceApi.Models; + +public class Sale +{ + public int Id { get; set; } + public DateTime SaleDate { get; set; } + public bool IsDeleted { get; set; } + public ICollection SaleItems { get; set; } = []; +} diff --git a/EcommerceApi.DiegoPetrola/Models/SaleItem.cs b/EcommerceApi.DiegoPetrola/Models/SaleItem.cs new file mode 100644 index 00000000..cd1c9709 --- /dev/null +++ b/EcommerceApi.DiegoPetrola/Models/SaleItem.cs @@ -0,0 +1,12 @@ +namespace EcommerceApi.Models; + +public class SaleItem +{ + public int? ProductId { get; set; } + public Product? Product { get; set; } = null!; + public string ProductName { get; set; } = string.Empty; + public decimal ProductPrice { get; set; } + public int SaleId { get; set; } + public Sale Sale { get; set; } = null!; + public int Quantity { get; set; } +} diff --git a/EcommerceApi.DiegoPetrola/Program.cs b/EcommerceApi.DiegoPetrola/Program.cs new file mode 100644 index 00000000..39fc326a --- /dev/null +++ b/EcommerceApi.DiegoPetrola/Program.cs @@ -0,0 +1,27 @@ +using EcommerceApi.Context; +using EcommerceApi.Services; +using EcommerceApi.Utils; +using Microsoft.EntityFrameworkCore; + +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddControllers(); +builder.Services.AddDbContext(options => + options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")) + ); +builder.Services.Configure( + builder.Configuration.GetSection("PaginationSettings")); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddOpenApi(); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) +{ + app.MapOpenApi(); +} + +app.UseHttpsRedirection(); +app.MapControllers(); +app.Run(); diff --git a/EcommerceApi.DiegoPetrola/Properties/launchSettings.json b/EcommerceApi.DiegoPetrola/Properties/launchSettings.json new file mode 100644 index 00000000..7c7d2afa --- /dev/null +++ b/EcommerceApi.DiegoPetrola/Properties/launchSettings.json @@ -0,0 +1,22 @@ +{ + "profiles": { + "http": { + "commandName": "Project", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "dotnetRunMessages": true, + "applicationUrl": "http://localhost:5112" + }, + "https": { + "commandName": "Project", + "workingDirectory": "./", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "dotnetRunMessages": true, + "applicationUrl": "https://localhost:7216;http://localhost:5112" + } + }, + "$schema": "https://json.schemastore.org/launchsettings.json" +} \ No newline at end of file diff --git a/EcommerceApi.DiegoPetrola/Results/Result.cs b/EcommerceApi.DiegoPetrola/Results/Result.cs new file mode 100644 index 00000000..fba7d274 --- /dev/null +++ b/EcommerceApi.DiegoPetrola/Results/Result.cs @@ -0,0 +1,44 @@ +using System.Diagnostics.CodeAnalysis; + +namespace EcommerceApi.Results; + +// This Result pattern is largely based on the one from the article: +// www.red-gate.com/simple-talk/development/dotnet-development/the-result-pattern-in-asp-net-core-minimal-apis/ +public class Result +{ + public T? Value { get; } + public ErrorResponse? Error { get; } + [MemberNotNullWhen(true, nameof(Value))] + [MemberNotNullWhen(false, nameof(Error))] + public bool IsSuccess { get; } + private Result(T value) + { + Value = value; + IsSuccess = true; + } + private Result(ErrorResponse error) + { + Error = error; + IsSuccess = false; + } + public static Result Ok(T value) => new(value); + private static Result Fail(ErrorType type, string error) => new(new ErrorResponse(error, type)); + public static Result NotFound(string error) => Fail(ErrorType.NotFound, error); + public static Result Invalid(string error) => Fail(ErrorType.Invalid, error); + public static Result InternalServerError(string error) => Fail(ErrorType.InternalServerError, error); + public static implicit operator Result(T value) => Ok(value); +} + +public record ErrorResponse(string Error, ErrorType ErrorType); + +public enum ErrorType +{ + NotFound, + Invalid, + Unauthorized, + Forbidden, + Conflict, + InternalServerError +} + + diff --git a/EcommerceApi.DiegoPetrola/Services/CategoriesService.cs b/EcommerceApi.DiegoPetrola/Services/CategoriesService.cs new file mode 100644 index 00000000..223e5e1b --- /dev/null +++ b/EcommerceApi.DiegoPetrola/Services/CategoriesService.cs @@ -0,0 +1,81 @@ +using EcommerceApi.Context; +using EcommerceApi.Models; +using EcommerceApi.Models.DTOs; +using EcommerceApi.Results; +using EcommerceApi.Utils; +using Microsoft.EntityFrameworkCore; + +namespace EcommerceApi.Services; + +public class CategoriesService(EcommerceDbContext context) +{ + public async Task>> GetCategories() + { + var categories = await context.Categories + .Select(c => c.ToDto()) + .ToListAsync(); + + return Result>.Ok(categories); + } + + public async Task> GetCategory(int id) + { + var category = await context.Categories.FindAsync(id); + if (category is null) + return Result.NotFound("Category not found"); + return Result.Ok(category.ToDto()); + } + + public async Task> DeleteCategory(int id) + { + var category = await context.Categories.FindAsync(id); + var products = await context.Products.AnyAsync(p => p.CategoryId == id); + if (products) + return Result.Invalid("Can't delete a category that has products"); + if (category is null) + return Result.NotFound("Category not found"); + try + { + context.Remove(category); + await context.SaveChangesAsync(); + return Result.Ok(null); + } + catch + { + return Result.InternalServerError("Problem on the server"); + } + } + + public async Task> CreateCategory(CreateCategoryDto dto) + { + var category = new Category { Name = dto.Name }; + try + { + context.Categories.Add(category); + await context.SaveChangesAsync(); + } + catch + { + return Result.Invalid("Duplicated category"); + } + + return Result.Ok(category.ToDto()); + } + + public async Task> UpdateCategory(CategoryDto dto) + { + var category = await context.Categories.FindAsync(dto.Id); + if (category is null) + return Result.NotFound("Category not found"); + category.Name = dto.Name; + try + { + await context.SaveChangesAsync(); + return Result.Ok(category.ToDto()); + } + catch + { + return Result.Invalid("Duplicated category"); + } + } +} diff --git a/EcommerceApi.DiegoPetrola/Services/ProductsService.cs b/EcommerceApi.DiegoPetrola/Services/ProductsService.cs new file mode 100644 index 00000000..a266489f --- /dev/null +++ b/EcommerceApi.DiegoPetrola/Services/ProductsService.cs @@ -0,0 +1,99 @@ +using EcommerceApi.Context; +using EcommerceApi.Models; +using EcommerceApi.Models.DTOs; +using EcommerceApi.Results; +using EcommerceApi.Utils; +using Microsoft.EntityFrameworkCore; + +namespace EcommerceApi.Services; + +public class ProductsService(EcommerceDbContext context) +{ + public async Task>> GetProducts(int pageNumber, int pageSize) + { + var products = await context.Products + .Include(p => p.Category) + .Skip(pageSize * pageNumber) + .Take(pageSize) + .Select(p => p.ToDto()) + .ToListAsync(); + + return Result>.Ok(products); + } + + public async Task> GetProduct(int id) + { + var product = await context.Products + .Include(p => p.Category) + .FirstOrDefaultAsync(p => p.Id == id); + + if (product is null) + return Result.NotFound("Product not found"); + + return Result.Ok(product.ToDto()); + } + + public async Task> CreateProduct(CreateProductDto dto) + { + var product = new Product + { + Name = dto.Name, + Price = dto.Price, + CategoryId = dto.CategoryId + }; + + try + { + context.Products.Add(product); + await context.SaveChangesAsync(); + await context.Entry(product) + .Reference(p => p.Category) + .LoadAsync(); + } + catch + { + return Result.Invalid("Invalid product data or duplicate entry"); + } + return Result.Ok(product.ToDto()); + } + + public async Task> UpdateProduct(ProductDto dto) + { + var product = await context.Products.FindAsync(dto.Id); + + if (product is null) + return Result.NotFound("Product not found"); + + try + { + product.CategoryId = dto.CategoryId; + product.Name = dto.Name; + + await context.SaveChangesAsync(); + await context.Entry(product).Reference(p => p.Category).LoadAsync(); + } + catch + { + return Result.Invalid("Something went wrong"); + } + return Result.Ok(product.ToDto()); + } + + public async Task> SoftDeleteProduct(int id) + { + var product = await context.Products.FindAsync(id); + if (product is null) + return Result.NotFound("Product not found"); + + try + { + product.IsDeleted = true; + await context.SaveChangesAsync(); + return Result.Ok(null); + } + catch + { + return Result.InternalServerError("Something went wrong"); + } + } +} diff --git a/EcommerceApi.DiegoPetrola/Services/SalesService.cs b/EcommerceApi.DiegoPetrola/Services/SalesService.cs new file mode 100644 index 00000000..a74ba28f --- /dev/null +++ b/EcommerceApi.DiegoPetrola/Services/SalesService.cs @@ -0,0 +1,92 @@ +using EcommerceApi.Context; +using EcommerceApi.Models; +using EcommerceApi.Models.DTOs; +using EcommerceApi.Results; +using EcommerceApi.Utils; +using Microsoft.EntityFrameworkCore; + +namespace EcommerceApi.Services; + +public class SalesService(EcommerceDbContext context) +{ + public async Task>> GetSalesByPage(int pageNumber, int pageSize) + { + var sales = await context.Sales + .Include(s => s.SaleItems) + .Skip(pageSize * pageNumber) + .Take(pageSize) + .Select(s => s.ToDto()) + .ToListAsync(); + + return Result>.Ok(sales); + } + + public async Task> GetSale(int id) + { + var sale = await context.Sales + .Include(s => s.SaleItems) + .ThenInclude(si => si.Product) + .FirstOrDefaultAsync(s => s.Id == id); + + if (sale is null) return Result.NotFound("Sale not found"); + + return Result.Ok(sale.ToDto()); + } + + public async Task> CreateSale(CreateSaleDto dto) + { + if (dto.Items == null || dto.Items.Count == 0) + return Result.Invalid("Sale must contain at least one item."); + + var sale = new Sale { SaleDate = DateTime.UtcNow }; + var productIds = dto.Items.Select(i => i.ProductId).ToList(); + var products = await context.Products + .Where(p => productIds.Contains(p.Id)) + .ToDictionaryAsync(p => p.Id); + + if (products.Count < productIds.Count) + return Result.Invalid($"Can't make sale of invalid products!"); + + foreach (var item in dto.Items) + { + products.TryGetValue(item.ProductId, out var product); + if (product is null) + return Result.Invalid($"Can't make sale of invalid products!"); + + sale.SaleItems.Add(new SaleItem + { + ProductId = item.ProductId, + Quantity = item.Quantity, + ProductName = product.Name, + ProductPrice = product.Price + }); + } + try + { + context.Sales.Add(sale); + await context.SaveChangesAsync(); + return Result.Ok(sale.ToDto()); + } + catch + { + return Result.InternalServerError("Something went wrong"); + } + } + + public async Task> DeleteSale(int id) + { + var sale = await context.Sales.FindAsync(id); + if (sale is null) + return Result.NotFound("Sale not found"); + try + { + sale.IsDeleted = true; + await context.SaveChangesAsync(); + return Result.Ok(sale.ToDto()); + } + catch + { + return Result.InternalServerError("Something went wrong"); + } + } +} diff --git a/EcommerceApi.DiegoPetrola/Utils/CategoryMapping.cs b/EcommerceApi.DiegoPetrola/Utils/CategoryMapping.cs new file mode 100644 index 00000000..1f2e26cc --- /dev/null +++ b/EcommerceApi.DiegoPetrola/Utils/CategoryMapping.cs @@ -0,0 +1,33 @@ +using EcommerceApi.Models; +using EcommerceApi.Models.DTOs; + +namespace EcommerceApi.Utils; + +public static class CategoryMapping +{ + public static CategoryDto ToDto(this Category category) + { + return new CategoryDto(category.Id, category.Name); + } + + public static ProductDto ToDto(this Product product) + { + return new ProductDto(product.Id, product.Name, product.Price, product.Category.Id, product.Category.Name); + } + + public static SaleItemDto ToDto(this SaleItem saleItem) + { + return new SaleItemDto(saleItem.ProductId, saleItem.ProductName, saleItem.Quantity, saleItem.ProductPrice); + } + + public static SaleDto ToDto(this Sale sale) + { + return new SaleDto( + sale.Id, + sale.SaleDate, + [.. sale.SaleItems.Select(si => si.ToDto())], + sale.SaleItems.Sum(si => si.Quantity * si.ProductPrice) + ); + } + +} \ No newline at end of file diff --git a/EcommerceApi.DiegoPetrola/Utils/PaginationSettings.cs b/EcommerceApi.DiegoPetrola/Utils/PaginationSettings.cs new file mode 100644 index 00000000..1b2ea6c8 --- /dev/null +++ b/EcommerceApi.DiegoPetrola/Utils/PaginationSettings.cs @@ -0,0 +1,7 @@ +namespace EcommerceApi.Utils; + +public class PaginationSettings +{ + public int DefaultPageSize { get; set; } + public int MaxPageSize { get; set; } +} diff --git a/EcommerceApi.DiegoPetrola/appsettings.Development.json b/EcommerceApi.DiegoPetrola/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/EcommerceApi.DiegoPetrola/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/EcommerceApi.DiegoPetrola/appsettings.json b/EcommerceApi.DiegoPetrola/appsettings.json new file mode 100644 index 00000000..bbc17264 --- /dev/null +++ b/EcommerceApi.DiegoPetrola/appsettings.json @@ -0,0 +1,16 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "ConnectionStrings": { + "DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=CommerceDB;Trusted_Connection=True;TrustServerCertificate=True;" + }, + "PaginationSettings": { + "DefaultPageSize": 20, + "MaxPageSize": 100 + } +} diff --git a/EcommerceApi.slnx b/EcommerceApi.slnx new file mode 100644 index 00000000..00d3749f --- /dev/null +++ b/EcommerceApi.slnx @@ -0,0 +1,3 @@ + + + diff --git a/README.md b/README.md new file mode 100644 index 00000000..91120f24 --- /dev/null +++ b/README.md @@ -0,0 +1,35 @@ +# Ecommerce API + +A RESTful API built with **ASP.NET Core** and **Entity Framework Core**, designed to simulate a retail environment. It uses use good development practices such as Result Pattern, Service Layer, Data Transfer Objects (DTOs), Soft Deletes and historical data integrity. + +## Features + +- **Product, Category and Sales Management**: Full CRUD capabilities with relationship validation. +- **Historical Price Integrity**: Implements "snapshotting" logic to ensure past sales records remain accurate even if current product prices change. +- **Soft Deletes**: Implements non-destructive deletion for Products, and Sales to preserve data history, **Categories** can be deleted only there are no Products using them. +- **Pagination**: Endpoint pagination using performant `Skip`/`Take` logic and configurable page size via query parameters that prevent abuse or DDoS. +- **Result Pattern**: Uses a functional `Result` wrapper to handle service-layer errors gracefully without relying on expensive Exception throwing for control flow. + +## Tech Stack + +- **Framework**: .NET 10 (ASP.NET Core Web API) +- **ORM**: Entity Framework Core +- **Database**: SQL Server +- **Documentation**: OpenAPI +- **Architecture**: N-Layer (Controller -> Service -> Data Access) + +## Installation + +1. **Clone the repository** + + ```bash + git clone https://github.com/diegopetrola/CodeReviews.Console.EcommerceApi + cd CodeReviews.Console.EcommerceApi + ``` + +2. **Install libraries**: `dotnet restore` +3. **Update the Database**: `dotnet ef database update` + +4. **Run the project** `dotnet run` + +5. **Test on Postman**: import `Ecommerce.postman_collection.json` on Postman and run collection.