diff --git a/EcommerceApi/ECommerce API.postman_collection.json b/EcommerceApi/ECommerce API.postman_collection.json new file mode 100644 index 00000000..25214fbf --- /dev/null +++ b/EcommerceApi/ECommerce API.postman_collection.json @@ -0,0 +1,886 @@ +{ + "info": { + "_postman_id": "55790843-a96f-4c97-b21d-1b3b07b830a6", + "name": "ECommerce API", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "_exporter_id": "31837703" + }, + "item": [ + { + "name": "Categories", + "item": [ + { + "name": "Create", + "item": [ + { + "name": "Valid Create Category", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Electronics\",\n \"description\": \"Smartphones, laptops, and gadgets.\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/api/{{apiVersion}}/category", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "{{apiVersion}}", + "category" + ] + } + }, + "response": [] + }, + { + "name": "Invalid Create Category", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"\",\n \"description\": \"Smartphones, laptops, and gadgets.\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/api/{{apiVersion}}/category", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "{{apiVersion}}", + "category" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Update", + "item": [ + { + "name": "Valid Update Category", + "request": { + "method": "PUT", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Electronics\",\n \"description\": \"Smartphones, laptops, TVs\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/api/{{apiVersion}}/category/3", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "{{apiVersion}}", + "category", + "3" + ] + } + }, + "response": [] + }, + { + "name": "Invalid Update Category", + "request": { + "method": "PUT", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"\",\n \"description\": \"Smartphones, laptops, TVs\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/api/{{apiVersion}}/category/3", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "{{apiVersion}}", + "category", + "3" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Get All Categories", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Get All Categories: response is array or paginated object', function () {", + " const json = pm.response.json();", + " const isArray = Array.isArray(json);", + " const hasItemsArray = json && typeof json === 'object' && (Array.isArray(json.items) || Array.isArray(json.data) || Array.isArray(json.results));", + " pm.expect(isArray || hasItemsArray, 'array or object with items/data/results array').to.equal(true);", + "})", + "", + "pm.test('Get All Categories: pagination fields are valid when present', function () {", + " const json = pm.response.json();", + " if (!json || typeof json !== 'object' || Array.isArray(json))", + " return;", + " const numFields = [", + " 'pageNumber',", + " 'pageSize',", + " 'totalCount',", + " 'total',", + " 'count'", + " ];", + " numFields.forEach(f => {", + " if (Object.prototype.hasOwnProperty.call(json, f)) {", + " pm.expect(json[f], `${ f } should be a number`).to.be.a('number');", + " }", + " });", + "})", + "", + "pm.test('Get All Categories: response is array or paginated object', function () {", + " const json = pm.response.json();", + " const isArray = Array.isArray(json);", + " const hasItemsArray = json && typeof json === 'object' && (Array.isArray(json.items) || Array.isArray(json.data) || Array.isArray(json.results));", + " pm.expect(isArray || hasItemsArray, 'array or object with items/data/results array').to.equal(true);", + "})", + "", + "pm.test('Get All Categories: pagination fields are valid when present', function () {", + " const json = pm.response.json();", + " if (!json || typeof json !== 'object' || Array.isArray(json))", + " return;", + " const numFields = [", + " 'pageNumber',", + " 'pageSize',", + " 'totalCount',", + " 'total',", + " 'count'", + " ];", + " numFields.forEach(f => {", + " if (Object.prototype.hasOwnProperty.call(json, f)) {", + " pm.expect(json[f], `${ f } should be a number`).to.be.a('number');", + " }", + " });", + "})", + "", + "pm.test('Get All Categories: response is array or paginated object', function () {", + " const json = pm.response.json();", + " const isArray = Array.isArray(json);", + " const hasItemsArray = json && typeof json === 'object' && (Array.isArray(json.items) || Array.isArray(json.data) || Array.isArray(json.results));", + " pm.expect(isArray || hasItemsArray, 'array or object with items/data/results array').to.equal(true);", + "})", + "", + "pm.test('Get All Categories: pagination fields are valid when present', function () {", + " const json = pm.response.json();", + " if (!json || typeof json !== 'object' || Array.isArray(json))", + " return;", + " const numFields = [", + " 'pageNumber',", + " 'pageSize',", + " 'totalCount',", + " 'total',", + " 'count'", + " ];", + " numFields.forEach(f => {", + " if (Object.prototype.hasOwnProperty.call(json, f)) {", + " pm.expect(json[f], `${ f } should be a number`).to.be.a('number');", + " }", + " });", + "})" + ], + "type": "text/javascript", + "packages": {}, + "requests": {} + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/{{apiVersion}}/category", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "{{apiVersion}}", + "category" + ] + } + }, + "response": [] + }, + { + "name": "Get Category", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/{{apiVersion}}/category/1", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "{{apiVersion}}", + "category", + "1" + ] + } + }, + "response": [] + }, + { + "name": "Delete Category", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Delete Category: status is 200 or 204', function () {", + " pm.expect(pm.response.code).to.be.oneOf([", + " 200,", + " 204", + " ]);", + "})", + "", + "pm.test('Delete Category: status is 200 or 204', function () {", + " pm.expect(pm.response.code).to.be.oneOf([", + " 200,", + " 204", + " ]);", + "})", + "", + "pm.test('Delete Category: status is 200 or 204', function () {", + " pm.expect(pm.response.code).to.be.oneOf([", + " 200,", + " 204", + " ]);", + "})" + ] + } + } + ], + "request": { + "method": "DELETE", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/{{apiVersion}}/category/2", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "{{apiVersion}}", + "category", + "2" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Products", + "item": [ + { + "name": "Create", + "item": [ + { + "name": "Valid Create Product", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Orange Juice\",\n \"price\": 15,\n \"quantity\": 55,\n \"discription\": \"\",\n \"imageUrl\": \"\",\n \"categoryId\": 4\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/api/{{apiVersion}}/product", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "{{apiVersion}}", + "product" + ] + } + }, + "response": [] + }, + { + "name": "Invalid Create Product", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"\",\n \"price\": 0,\n \"quantity\": -5,\n \"discription\": \"\",\n \"imageUrl\": \"link\",\n \"categoryId\": 0\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/api/{{apiVersion}}/product", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "{{apiVersion}}", + "product" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Update", + "item": [ + { + "name": "Valid Update Product", + "request": { + "method": "PUT", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Orange Juice\",\n \"price\": 15,\n \"quantity\": 55,\n \"discription\": \"\",\n \"imageUrl\": \"\",\n \"categoryId\": 4\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/api/{{apiVersion}}/product/5", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "{{apiVersion}}", + "product", + "5" + ] + } + }, + "response": [] + }, + { + "name": "Invalid Update Product", + "request": { + "method": "PUT", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"\",\n \"price\": 0,\n \"quantity\": -5,\n \"discription\": \"\",\n \"imageUrl\": \"link\",\n \"categoryId\": 0\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/api/{{apiVersion}}/product/1", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "{{apiVersion}}", + "product", + "1" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Get All Products", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Get All Products: response is array or paginated object', function () {", + " const json = pm.response.json();", + " const isArray = Array.isArray(json);", + " const hasItemsArray = json && typeof json === 'object' && (Array.isArray(json.items) || Array.isArray(json.data) || Array.isArray(json.results));", + " pm.expect(isArray || hasItemsArray, 'array or object with items/data/results array').to.equal(true);", + "})", + "", + "pm.test('Get All Products: pagination fields are valid when present', function () {", + " const json = pm.response.json();", + " if (!json || typeof json !== 'object' || Array.isArray(json))", + " return;", + " const numFields = [", + " 'pageNumber',", + " 'pageSize',", + " 'totalCount',", + " 'total',", + " 'count'", + " ];", + " numFields.forEach(f => {", + " if (Object.prototype.hasOwnProperty.call(json, f)) {", + " pm.expect(json[f], `${ f } should be a number`).to.be.a('number');", + " }", + " });", + "})", + "", + "pm.test('Get All Products: response is array or paginated object', function () {", + " const json = pm.response.json();", + " const isArray = Array.isArray(json);", + " const hasItemsArray = json && typeof json === 'object' && (Array.isArray(json.items) || Array.isArray(json.data) || Array.isArray(json.results));", + " pm.expect(isArray || hasItemsArray, 'array or object with items/data/results array').to.equal(true);", + "})", + "", + "pm.test('Get All Products: pagination fields are valid when present', function () {", + " const json = pm.response.json();", + " if (!json || typeof json !== 'object' || Array.isArray(json))", + " return;", + " const numFields = [", + " 'pageNumber',", + " 'pageSize',", + " 'totalCount',", + " 'total',", + " 'count'", + " ];", + " numFields.forEach(f => {", + " if (Object.prototype.hasOwnProperty.call(json, f)) {", + " pm.expect(json[f], `${ f } should be a number`).to.be.a('number');", + " }", + " });", + "})" + ], + "type": "text/javascript", + "packages": {}, + "requests": {} + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/{{apiVersion}}/product?pageNumber=1&pageSize=2", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "{{apiVersion}}", + "product" + ], + "query": [ + { + "key": "pageNumber", + "value": "1" + }, + { + "key": "pageSize", + "value": "2" + } + ] + } + }, + "response": [] + }, + { + "name": "Get a Product", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/{{apiVersion}}/product/1", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "{{apiVersion}}", + "product", + "1" + ] + } + }, + "response": [] + }, + { + "name": "Delete Product", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Delete Product: status is 200 or 204', function () {", + " pm.expect(pm.response.code).to.be.oneOf([", + " 200,", + " 204", + " ]);", + "})", + "", + "pm.test('Delete Product: status is 200 or 204', function () {", + " pm.expect(pm.response.code).to.be.oneOf([", + " 200,", + " 204", + " ]);", + "})" + ] + } + } + ], + "request": { + "method": "DELETE", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/{{apiVersion}}/product/3", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "{{apiVersion}}", + "product", + "3" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Sales", + "item": [ + { + "name": "Create", + "item": [ + { + "name": "Valid Create Sale", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"items\": [\n {\n \"productId\": 1,\n \"quantity\": 1\n },\n {\n \"productId\": 2,\n \"quantity\": 4\n }\n ]\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/api/{{apiVersion}}/sale", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "{{apiVersion}}", + "sale" + ] + } + }, + "response": [] + }, + { + "name": "Invalid Create Sale", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"items\": [\n {\n \"productId\": 0,\n \"quantity\": 1\n },\n {\n \"productId\": 2,\n \"quantity\": 0\n },\n {\n \"productId\": 3,\n \"quantity\": -1\n }\n ]\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/api/{{apiVersion}}/sale", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "{{apiVersion}}", + "sale" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Get All Sales", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Get All Sales: response is array or paginated object', function () {", + " const json = pm.response.json();", + " const isArray = Array.isArray(json);", + " const hasItemsArray = json && typeof json === 'object' && (Array.isArray(json.items) || Array.isArray(json.data) || Array.isArray(json.results));", + " pm.expect(isArray || hasItemsArray, 'array or object with items/data/results array').to.equal(true);", + "})", + "", + "pm.test('Get All Sales: pagination fields are valid when present', function () {", + " const json = pm.response.json();", + " if (!json || typeof json !== 'object' || Array.isArray(json))", + " return;", + " const numFields = [", + " 'pageNumber',", + " 'pageSize',", + " 'totalCount',", + " 'total',", + " 'count'", + " ];", + " numFields.forEach(f => {", + " if (Object.prototype.hasOwnProperty.call(json, f)) {", + " pm.expect(json[f], `${ f } should be a number`).to.be.a('number');", + " }", + " });", + "})", + "", + "pm.test('Get All Sales: response is array or paginated object', function () {", + " const json = pm.response.json();", + " const isArray = Array.isArray(json);", + " const hasItemsArray = json && typeof json === 'object' && (Array.isArray(json.items) || Array.isArray(json.data) || Array.isArray(json.results));", + " pm.expect(isArray || hasItemsArray, 'array or object with items/data/results array').to.equal(true);", + "})", + "", + "pm.test('Get All Sales: pagination fields are valid when present', function () {", + " const json = pm.response.json();", + " if (!json || typeof json !== 'object' || Array.isArray(json))", + " return;", + " const numFields = [", + " 'pageNumber',", + " 'pageSize',", + " 'totalCount',", + " 'total',", + " 'count'", + " ];", + " numFields.forEach(f => {", + " if (Object.prototype.hasOwnProperty.call(json, f)) {", + " pm.expect(json[f], `${ f } should be a number`).to.be.a('number');", + " }", + " });", + "})" + ] + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/{{apiVersion}}/sale?pageNumber=1&pageSize=2", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "{{apiVersion}}", + "sale" + ], + "query": [ + { + "key": "pageNumber", + "value": "1" + }, + { + "key": "pageSize", + "value": "2" + } + ] + } + }, + "response": [] + }, + { + "name": "Get a sale", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/{{apiVersion}}/sale/1", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "{{apiVersion}}", + "sale", + "1" + ] + } + }, + "response": [] + } + ] + } + ], + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "packages": {}, + "requests": {}, + "exec": [ + "" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "requests": {}, + "exec": [ + "// -------- Baseline collection-level tests & helpers --------", + "// These tests run after every request in this collection.", + "", + "pm.test(\"Response time is under 2000ms\", function () {", + " pm.expect(pm.response.responseTime).to.be.below(2000);", + "});", + "", + "pm.test(\"Status code is successful (2xx)\", function () {", + " pm.expect(pm.response.code).to.be.within(200, 299);", + "});", + "", + "// Content-Type checks (only when response has a body)", + "pm.test(\"Content-Type is JSON when body is present\", function () {", + " const hasBody = pm.response.text() && pm.response.text().trim().length > 0;", + " if (!hasBody) return;", + "", + " pm.response.to.have.header(\"Content-Type\");", + " const ct = (pm.response.headers.get(\"Content-Type\") || \"\").toLowerCase();", + " pm.expect(ct).to.include(\"application/json\");", + "});", + "", + "// Basic JSON parse check (only when body is present)", + "pm.test(\"Response body is valid JSON when present\", function () {", + " const hasBody = pm.response.text() && pm.response.text().trim().length > 0;", + " if (!hasBody) return;", + "", + " pm.response.to.be.json;", + "});", + "", + "// Helper: get path id from the current request URL, if any.", + "function getLastPathId() {", + " try {", + " const url = pm.request.url;", + " const segments = (url.path || []).filter(Boolean);", + " const last = segments[segments.length - 1];", + " if (last && /^\\d+$/.test(last)) return parseInt(last, 10);", + " // handle templated ids like {{productId}}", + " if (last && last.includes(\"{{\") && last.includes(\"}}\")) {", + " const varName = last.replace(/\\{\\{|\\}\\}/g, \"\").trim();", + " const v = pm.collectionVariables.get(varName) || pm.environment.get(varName) || pm.globals.get(varName);", + " if (v && /^\\d+$/.test(String(v))) return parseInt(v, 10);", + " }", + " } catch (e) {}", + " return null;", + "}", + "", + "// Helper: try to pull an id from common response shapes.", + "function extractId(json) {", + " if (!json || typeof json !== \"object\") return null;", + " if (typeof json.id === \"number\") return json.id;", + " if (typeof json.productId === \"number\") return json.productId;", + " if (typeof json.categoryId === \"number\") return json.categoryId;", + " if (typeof json.saleId === \"number\") return json.saleId;", + " if (json.data && typeof json.data === \"object\" && typeof json.data.id === \"number\") return json.data.id;", + " return null;", + "}", + "", + "// Expose helpers to request-level tests (by attaching to globals)", + "// Note: Postman sandbox doesn't support true exports; attach to pm.", + "pm.collectionVariables.set(\"_lastPathId\", String(getLastPathId() ?? \"\"));", + "", + "// Also keep the last extracted id available for request-level scripts to use if desired.", + "try {", + " const hasBody = pm.response.text() && pm.response.text().trim().length > 0;", + " if (hasBody && pm.response.headers.get(\"Content-Type\")?.toLowerCase().includes(\"application/json\")) {", + " const json = pm.response.json();", + " const extracted = extractId(json);", + " if (typeof extracted === \"number\") {", + " pm.collectionVariables.set(\"_lastExtractedId\", String(extracted));", + " }", + " }", + "} catch (e) {", + " // ignore", + "}", + "" + ] + } + } + ], + "variable": [ + { + "key": "baseUrl", + "value": "" + }, + { + "key": "_lastPathId", + "value": "" + }, + { + "key": "_lastExtractedId", + "value": "" + }, + { + "key": "apiVersion", + "value": "", + "type": "default" + } + ] +} diff --git a/EcommerceApi/Ecommerce.Core/DTOs/Auth/AuthResponseDto.cs b/EcommerceApi/Ecommerce.Core/DTOs/Auth/AuthResponseDto.cs new file mode 100644 index 00000000..adfbf556 --- /dev/null +++ b/EcommerceApi/Ecommerce.Core/DTOs/Auth/AuthResponseDto.cs @@ -0,0 +1,3 @@ +namespace Ecommerce.Core.DTOs.Auth; + +public record AuthResponseDto(string Token, DateTime Expires); \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/DTOs/Auth/LoginDto.cs b/EcommerceApi/Ecommerce.Core/DTOs/Auth/LoginDto.cs new file mode 100644 index 00000000..7de485ea --- /dev/null +++ b/EcommerceApi/Ecommerce.Core/DTOs/Auth/LoginDto.cs @@ -0,0 +1,7 @@ +namespace Ecommerce.Core.DTOs.Auth; + +public record LoginDto +{ + public string Email { get; init; } = string.Empty; + public string Password { get; init; } = string.Empty; +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/DTOs/Auth/RegisterDto.cs b/EcommerceApi/Ecommerce.Core/DTOs/Auth/RegisterDto.cs new file mode 100644 index 00000000..e4d0a29f --- /dev/null +++ b/EcommerceApi/Ecommerce.Core/DTOs/Auth/RegisterDto.cs @@ -0,0 +1,10 @@ +namespace Ecommerce.Core.DTOs.Auth; + +public record RegisterDto +{ + public string Username { get; init; } = string.Empty; + public string Email { get; init; } = string.Empty; + public string Password { get; init; } = string.Empty; + public string? Address { get; init; } + public string? PhoneNumber { get; init; } +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/DTOs/Category/CategoryDto.cs b/EcommerceApi/Ecommerce.Core/DTOs/Category/CategoryDto.cs new file mode 100644 index 00000000..c9296e53 --- /dev/null +++ b/EcommerceApi/Ecommerce.Core/DTOs/Category/CategoryDto.cs @@ -0,0 +1,8 @@ +namespace Ecommerce.Core.DTOs.Category; + +public record CategoryDto +{ + public int Id { get; init; } + public string Name { get; init; } = string.Empty; + public string? Description { get; init; } +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/DTOs/Category/CreateCategoryDto.cs b/EcommerceApi/Ecommerce.Core/DTOs/Category/CreateCategoryDto.cs new file mode 100644 index 00000000..389ebbe1 --- /dev/null +++ b/EcommerceApi/Ecommerce.Core/DTOs/Category/CreateCategoryDto.cs @@ -0,0 +1,7 @@ +namespace Ecommerce.Core.DTOs.Category; + +public record CreateCategoryDto +{ + public string Name { get; init; } = string.Empty; + public string? Description { get; init; } +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/DTOs/Category/UpdateCategoryDto.cs b/EcommerceApi/Ecommerce.Core/DTOs/Category/UpdateCategoryDto.cs new file mode 100644 index 00000000..49256761 --- /dev/null +++ b/EcommerceApi/Ecommerce.Core/DTOs/Category/UpdateCategoryDto.cs @@ -0,0 +1,7 @@ +namespace Ecommerce.Core.DTOs.Category; + +public record UpdateCategoryDto +{ + public string Name { get; init; } = string.Empty; + public string? Description { get; init; } +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/DTOs/Product/CreateProductDto.cs b/EcommerceApi/Ecommerce.Core/DTOs/Product/CreateProductDto.cs new file mode 100644 index 00000000..25b1f5e9 --- /dev/null +++ b/EcommerceApi/Ecommerce.Core/DTOs/Product/CreateProductDto.cs @@ -0,0 +1,11 @@ +namespace Ecommerce.Core.DTOs.Product; + +public record CreateProductDto +{ + public string Name { get; init; } = string.Empty; + public decimal Price { get; init; } + public int Quantity { get; init; } = 1; + public string? Description { get; init; } + public string? ImageUrl { get; init; } + public int CategoryId { get; init; } +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/DTOs/Product/ProductDto.cs b/EcommerceApi/Ecommerce.Core/DTOs/Product/ProductDto.cs new file mode 100644 index 00000000..76cf9645 --- /dev/null +++ b/EcommerceApi/Ecommerce.Core/DTOs/Product/ProductDto.cs @@ -0,0 +1,13 @@ +namespace Ecommerce.Core.DTOs.Product; + +public record ProductDto +{ + public int Id { get; init; } + public string Name { get; init; } = string.Empty; + public decimal Price { get; init; } + public int Quantity { get; init; } + public string? Description { get; init; } + public string? ImageUrl { get; init; } + public int CategoryId { get; init; } + public string CategoryName { get; init; } = string.Empty; +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/DTOs/Product/UpdateProductDto.cs b/EcommerceApi/Ecommerce.Core/DTOs/Product/UpdateProductDto.cs new file mode 100644 index 00000000..a105d551 --- /dev/null +++ b/EcommerceApi/Ecommerce.Core/DTOs/Product/UpdateProductDto.cs @@ -0,0 +1,11 @@ +namespace Ecommerce.Core.DTOs.Product; + +public record UpdateProductDto +{ + public string Name { get; init; } = string.Empty; + public decimal Price { get; init; } + public int Quantity { get; init; } + public string? Description { get; init; } + public string? ImageUrl { get; init; } + public int CategoryId { get; init; } +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/DTOs/Sale/CreateSaleDto.cs b/EcommerceApi/Ecommerce.Core/DTOs/Sale/CreateSaleDto.cs new file mode 100644 index 00000000..cc867d5d --- /dev/null +++ b/EcommerceApi/Ecommerce.Core/DTOs/Sale/CreateSaleDto.cs @@ -0,0 +1,12 @@ +namespace Ecommerce.Core.DTOs.Sale; + +public record CreateSaleDto +{ + public List Items { get; init; } = new(); +} + +public record CreateSaleItemDto +{ + public int ProductId { get; init; } + public int Quantity { get; init; } +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/DTOs/Sale/SaleDto.cs b/EcommerceApi/Ecommerce.Core/DTOs/Sale/SaleDto.cs new file mode 100644 index 00000000..b8dd6af9 --- /dev/null +++ b/EcommerceApi/Ecommerce.Core/DTOs/Sale/SaleDto.cs @@ -0,0 +1,17 @@ +namespace Ecommerce.Core.DTOs.Sale; + +public record SaleDto +{ + public int Id { get; init; } + public DateTime CreationDate { get; init; } + public decimal TotalPrice { get; init; } + public List Items { get; init; } = new(); +} + +public record SaleItemDto +{ + public int ProductId { get; init; } + public string? ProductName { get; init; } = string.Empty; + public decimal UnitPrice { get; init; } + public int Quantity { get; init; } +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/Ecommerce.Core.csproj b/EcommerceApi/Ecommerce.Core/Ecommerce.Core.csproj new file mode 100644 index 00000000..226ca994 --- /dev/null +++ b/EcommerceApi/Ecommerce.Core/Ecommerce.Core.csproj @@ -0,0 +1,19 @@ + + + + net10.0 + enable + enable + + + + + + + + + ..\..\..\..\..\..\..\..\..\..\home\basem\.nuget\packages\microsoft.extensions.identity.stores\10.0.3\lib\net10.0\Microsoft.Extensions.Identity.Stores.dll + + + + diff --git a/EcommerceApi/Ecommerce.Core/Interfaces/Common/IBaseEntity.cs b/EcommerceApi/Ecommerce.Core/Interfaces/Common/IBaseEntity.cs new file mode 100644 index 00000000..6311f46b --- /dev/null +++ b/EcommerceApi/Ecommerce.Core/Interfaces/Common/IBaseEntity.cs @@ -0,0 +1,6 @@ +namespace Ecommerce.Core.Interfaces.Common; + +public interface IBaseEntity +{ + int Id { get; set; } +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/Interfaces/Common/ISoftDeletable.cs b/EcommerceApi/Ecommerce.Core/Interfaces/Common/ISoftDeletable.cs new file mode 100644 index 00000000..2741b83a --- /dev/null +++ b/EcommerceApi/Ecommerce.Core/Interfaces/Common/ISoftDeletable.cs @@ -0,0 +1,7 @@ +namespace Ecommerce.Core.Interfaces.Common; + +public interface ISoftDeletable +{ + bool IsDeleted { get; set; } + DateTime? DeletedAt { get; set; } +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/Interfaces/Repositories/ICategoryRepository.cs b/EcommerceApi/Ecommerce.Core/Interfaces/Repositories/ICategoryRepository.cs new file mode 100644 index 00000000..7f30b13d --- /dev/null +++ b/EcommerceApi/Ecommerce.Core/Interfaces/Repositories/ICategoryRepository.cs @@ -0,0 +1,7 @@ +using Ecommerce.Core.Models; + +namespace Ecommerce.Core.Interfaces.Repositories; + +public interface ICategoryRepository : IGenericRepository +{ +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/Interfaces/Repositories/IGenericRepository.cs b/EcommerceApi/Ecommerce.Core/Interfaces/Repositories/IGenericRepository.cs new file mode 100644 index 00000000..1936b513 --- /dev/null +++ b/EcommerceApi/Ecommerce.Core/Interfaces/Repositories/IGenericRepository.cs @@ -0,0 +1,16 @@ +using System.Linq.Expressions; +using Ecommerce.Core.Interfaces.Common; +using Ecommerce.Core.Utilities; + +namespace Ecommerce.Core.Interfaces.Repositories; + +public interface IGenericRepository where T : class +{ + Task> GetAllAsync(params Expression>[] includeProperties); + Task> GetAllAsync(PaginationParams paginationParams, params Expression>[] includeProperties); + Task> FindAsync(Expression> predicate, params Expression>[] includeProperties); + Task GetByIdAsync(int id, params Expression>[] includes); + void Add(T entity); + void Update(T entity); + void Delete(ISoftDeletable entity); +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/Interfaces/Repositories/IProductRepository.cs b/EcommerceApi/Ecommerce.Core/Interfaces/Repositories/IProductRepository.cs new file mode 100644 index 00000000..fc1cb2e9 --- /dev/null +++ b/EcommerceApi/Ecommerce.Core/Interfaces/Repositories/IProductRepository.cs @@ -0,0 +1,7 @@ +using Ecommerce.Core.Models; + +namespace Ecommerce.Core.Interfaces.Repositories; + +public interface IProductRepository : IGenericRepository +{ +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/Interfaces/Repositories/ISaleRepository.cs b/EcommerceApi/Ecommerce.Core/Interfaces/Repositories/ISaleRepository.cs new file mode 100644 index 00000000..c70422dd --- /dev/null +++ b/EcommerceApi/Ecommerce.Core/Interfaces/Repositories/ISaleRepository.cs @@ -0,0 +1,8 @@ +using Ecommerce.Core.Models; + +namespace Ecommerce.Core.Interfaces.Repositories; + +public interface ISaleRepository : IGenericRepository +{ + Task GetSaleWithItemsAsync(int id); +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/Interfaces/Repositories/IUnitOfWork.cs b/EcommerceApi/Ecommerce.Core/Interfaces/Repositories/IUnitOfWork.cs new file mode 100644 index 00000000..e48c37dd --- /dev/null +++ b/EcommerceApi/Ecommerce.Core/Interfaces/Repositories/IUnitOfWork.cs @@ -0,0 +1,13 @@ +using Ecommerce.Core.Models; + +namespace Ecommerce.Core.Interfaces.Repositories; + +public interface IUnitOfWork +{ + IProductRepository Products { get; } + ICategoryRepository Categories { get; } + ISaleRepository Sales { get; } + + Task CompleteAsync(); + void Dispose(); +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/Interfaces/Services/IAuthService.cs b/EcommerceApi/Ecommerce.Core/Interfaces/Services/IAuthService.cs new file mode 100644 index 00000000..444d4e0e --- /dev/null +++ b/EcommerceApi/Ecommerce.Core/Interfaces/Services/IAuthService.cs @@ -0,0 +1,10 @@ +using Ecommerce.Core.DTOs.Auth; +using Ecommerce.Core.Utilities; + +namespace Ecommerce.Core.Interfaces.Services; + +public interface IAuthService +{ + Task> Register(RegisterDto registerDto); + Task> Login(LoginDto loginDto); +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/Interfaces/Services/ICategoryService.cs b/EcommerceApi/Ecommerce.Core/Interfaces/Services/ICategoryService.cs new file mode 100644 index 00000000..eb627aab --- /dev/null +++ b/EcommerceApi/Ecommerce.Core/Interfaces/Services/ICategoryService.cs @@ -0,0 +1,13 @@ +using Ecommerce.Core.DTOs.Category; +using Ecommerce.Core.Utilities; + +namespace Ecommerce.Core.Interfaces.Services; + +public interface ICategoryService +{ + Task> CreateCategoryAsync(CreateCategoryDto category); + Task> GetCategoryAsync(int id); + Task>> GetAllCategoriesAsync(); + Task> UpdateCategoryAsync(int id, UpdateCategoryDto category); + Task> DeleteCategoryAsync(int id); +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/Interfaces/Services/IProductService.cs b/EcommerceApi/Ecommerce.Core/Interfaces/Services/IProductService.cs new file mode 100644 index 00000000..59684b4c --- /dev/null +++ b/EcommerceApi/Ecommerce.Core/Interfaces/Services/IProductService.cs @@ -0,0 +1,14 @@ +using Ecommerce.Core.DTOs; +using Ecommerce.Core.DTOs.Product; +using Ecommerce.Core.Utilities; + +namespace Ecommerce.Core.Interfaces.Services; + +public interface IProductService +{ + Task> CreateProductAsync(CreateProductDto product); + Task> GetProductAsync(int id); + Task>> GetAllProductsAsync(PaginationParams paginationParams); + Task> UpdateProductAsync(int id, UpdateProductDto product); + Task> DeleteProductAsync(int id); +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/Interfaces/Services/ISaleService.cs b/EcommerceApi/Ecommerce.Core/Interfaces/Services/ISaleService.cs new file mode 100644 index 00000000..77df451c --- /dev/null +++ b/EcommerceApi/Ecommerce.Core/Interfaces/Services/ISaleService.cs @@ -0,0 +1,11 @@ +using Ecommerce.Core.DTOs.Sale; +using Ecommerce.Core.Utilities; + +namespace Ecommerce.Core.Interfaces.Services; + +public interface ISaleService +{ + Task> CreateSaleAsync(CreateSaleDto sale); + Task> GetSaleAsync(int id); + Task>> GetAllSalesAsync(PaginationParams paginationParams); +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/Models/ApplicationUser.cs b/EcommerceApi/Ecommerce.Core/Models/ApplicationUser.cs new file mode 100644 index 00000000..e8eab69b --- /dev/null +++ b/EcommerceApi/Ecommerce.Core/Models/ApplicationUser.cs @@ -0,0 +1,8 @@ +using Microsoft.AspNetCore.Identity; + +namespace Ecommerce.Core.Models; + +public class ApplicationUser : IdentityUser +{ + public string? Address { get; set; } +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/Models/Category.cs b/EcommerceApi/Ecommerce.Core/Models/Category.cs new file mode 100644 index 00000000..384c3077 --- /dev/null +++ b/EcommerceApi/Ecommerce.Core/Models/Category.cs @@ -0,0 +1,17 @@ +using Ecommerce.Core.Interfaces.Common; + +namespace Ecommerce.Core.Models; + +public class Category : IBaseEntity, ISoftDeletable +{ + public const int MaxNameLength = 50; + public const int MaxDescriptionLength = 250; + + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public string? Description { get; set; } + public ICollection Products { get; set; } = new List(); + + public bool IsDeleted { get; set; } + public DateTime? DeletedAt { get; set; } +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/Models/Product.cs b/EcommerceApi/Ecommerce.Core/Models/Product.cs new file mode 100644 index 00000000..48977865 --- /dev/null +++ b/EcommerceApi/Ecommerce.Core/Models/Product.cs @@ -0,0 +1,21 @@ +using Ecommerce.Core.Interfaces.Common; + +namespace Ecommerce.Core.Models; + +public class Product : IBaseEntity, ISoftDeletable +{ + public const int MaxNameLength = 50; + public const int MaxDescriptionLength = 250; + + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public decimal Price { get; set; } + public int Quantity { get; set; } + public string? Description { get; set; } + public string? ImageUrl { get; set; } + public int CategoryId { get; set; } + public Category? Category { get; set; } + + public bool IsDeleted { get; set; } + public DateTime? DeletedAt { get; set; } +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/Models/Sale.cs b/EcommerceApi/Ecommerce.Core/Models/Sale.cs new file mode 100644 index 00000000..fc24ad88 --- /dev/null +++ b/EcommerceApi/Ecommerce.Core/Models/Sale.cs @@ -0,0 +1,14 @@ +using Ecommerce.Core.Interfaces.Common; + +namespace Ecommerce.Core.Models; + +public class Sale : IBaseEntity, ISoftDeletable +{ + public int Id { get; set; } + public DateTime CreationDate { get; init; } + public decimal TotalPrice { get; init; } + public ICollection Items { get; init; } = new List(); + + public bool IsDeleted { get; set; } + public DateTime? DeletedAt { get; set; } +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/Models/SaleItem.cs b/EcommerceApi/Ecommerce.Core/Models/SaleItem.cs new file mode 100644 index 00000000..70dd78de --- /dev/null +++ b/EcommerceApi/Ecommerce.Core/Models/SaleItem.cs @@ -0,0 +1,11 @@ +namespace Ecommerce.Core.Models; + +public class SaleItem +{ + public int ProductId { get; set; } + public Product? Product { get; set; } + public int SaleId { get; set; } + public Sale? Sale { get; set; } + public decimal UnitPriceAtTimeOfSale { get; set; } + public int Quantity { get; set; } +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/Utilities/PaginationParams.cs b/EcommerceApi/Ecommerce.Core/Utilities/PaginationParams.cs new file mode 100644 index 00000000..8a4fed79 --- /dev/null +++ b/EcommerceApi/Ecommerce.Core/Utilities/PaginationParams.cs @@ -0,0 +1,20 @@ +namespace Ecommerce.Core.Utilities; + +public record PaginationParams +{ + private const int DefaultPageSize = 50; + + public int PageNumber + { + get; + init => + field = value > 0 ? value : 1; + } = 1; + + public int PageSize + { + get; + init => + field = value > DefaultPageSize ? DefaultPageSize : value; + } = 10; +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/Utilities/Result.cs b/EcommerceApi/Ecommerce.Core/Utilities/Result.cs new file mode 100644 index 00000000..a6a4dfd7 --- /dev/null +++ b/EcommerceApi/Ecommerce.Core/Utilities/Result.cs @@ -0,0 +1,18 @@ +namespace Ecommerce.Core.Utilities; + +public record Result +{ + public bool IsSuccess { get; private init; } + public T? Data { get; private init; } + public string? Message { get; private init; } + + public static Result Success(T data) + { + return new Result { IsSuccess = true, Data = data }; + } + + public static Result Fail(string message) + { + return new Result { IsSuccess = false, Message = message }; + } +} diff --git a/EcommerceApi/Ecommerce.Core/Validators/Auth/LoginDtoValidator.cs b/EcommerceApi/Ecommerce.Core/Validators/Auth/LoginDtoValidator.cs new file mode 100644 index 00000000..bb3cbdd0 --- /dev/null +++ b/EcommerceApi/Ecommerce.Core/Validators/Auth/LoginDtoValidator.cs @@ -0,0 +1,22 @@ +using Ecommerce.Core.DTOs.Auth; +using FluentValidation; + +namespace Ecommerce.Core.Validators.Auth; + +public class LoginDtoValidator : AbstractValidator +{ + public LoginDtoValidator() + { + RuleFor(x => x.Email) + .NotEmpty() + .WithMessage("Email is required.") + .EmailAddress() + .WithMessage("Invalid email format."); + + RuleFor(x => x.Password) + .NotEmpty() + .WithMessage("Password is required.") + .Matches(@"^(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$") + .WithMessage("Password must contain at least 8 characters, one uppercase letter, one number, and one special character."); + } +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/Validators/Auth/RegisterDtoValidator.cs b/EcommerceApi/Ecommerce.Core/Validators/Auth/RegisterDtoValidator.cs new file mode 100644 index 00000000..de8633ad --- /dev/null +++ b/EcommerceApi/Ecommerce.Core/Validators/Auth/RegisterDtoValidator.cs @@ -0,0 +1,31 @@ +using Ecommerce.Core.DTOs.Auth; +using FluentValidation; + +namespace Ecommerce.Core.Validators.Auth; + +public class RegisterDtoValidator : AbstractValidator +{ + public RegisterDtoValidator() + { + RuleFor(x => x.Username) + .NotEmpty() + .WithMessage("Username is required."); + + RuleFor(x => x.Email) + .NotEmpty() + .WithMessage("Email is required.") + .EmailAddress() + .WithMessage("Invalid email format."); + + RuleFor(x => x.Password) + .NotEmpty() + .WithMessage("Password is required.") + .Matches(@"^(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$") + .WithMessage("Password must contain at least 8 characters, one uppercase letter, one number, and one special character."); + + RuleFor(x => x.PhoneNumber) + .Matches(@"^01[0125][0-9]{8}$") + .When(x => !string.IsNullOrEmpty(x.PhoneNumber)) + .WithMessage("Invalid phone number format."); + } +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/Validators/Categories/CreateCategoryDtoValidator.cs b/EcommerceApi/Ecommerce.Core/Validators/Categories/CreateCategoryDtoValidator.cs new file mode 100644 index 00000000..50302bd5 --- /dev/null +++ b/EcommerceApi/Ecommerce.Core/Validators/Categories/CreateCategoryDtoValidator.cs @@ -0,0 +1,21 @@ +using Ecommerce.Core.DTOs.Category; +using Ecommerce.Core.Models; +using FluentValidation; + +namespace Ecommerce.Core.Validators.Categories; + +public class CreateCategoryDtoValidator : AbstractValidator +{ + public CreateCategoryDtoValidator() + { + RuleFor(x => x.Name) + .NotEmpty() + .WithMessage("Category name is required.") + .MaximumLength(Category.MaxNameLength) + .WithMessage($"Category name cannot exceed {Category.MaxNameLength} characters."); + + RuleFor(x => x.Description) + .MaximumLength(Category.MaxDescriptionLength) + .WithMessage($"Category description cannot exceed {Category.MaxDescriptionLength} characters."); + } +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/Validators/Categories/UpdateCategoryDtoValidator.cs b/EcommerceApi/Ecommerce.Core/Validators/Categories/UpdateCategoryDtoValidator.cs new file mode 100644 index 00000000..c586ec04 --- /dev/null +++ b/EcommerceApi/Ecommerce.Core/Validators/Categories/UpdateCategoryDtoValidator.cs @@ -0,0 +1,21 @@ +using Ecommerce.Core.DTOs.Category; +using Ecommerce.Core.Models; +using FluentValidation; + +namespace Ecommerce.Core.Validators.Categories; + +public class UpdateCategoryDtoValidator : AbstractValidator +{ + public UpdateCategoryDtoValidator() + { + RuleFor(x => x.Name) + .NotEmpty() + .WithMessage("Category name is required.") + .MaximumLength(Category.MaxNameLength) + .WithMessage($"Category name cannot exceed {Category.MaxNameLength} characters."); + + RuleFor(x => x.Description) + .MaximumLength(Category.MaxDescriptionLength) + .WithMessage($"Category description cannot exceed {Category.MaxDescriptionLength} characters."); + } +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/Validators/Products/CreateProductDtoValidator.cs b/EcommerceApi/Ecommerce.Core/Validators/Products/CreateProductDtoValidator.cs new file mode 100644 index 00000000..de906f9c --- /dev/null +++ b/EcommerceApi/Ecommerce.Core/Validators/Products/CreateProductDtoValidator.cs @@ -0,0 +1,39 @@ +using Ecommerce.Core.DTOs.Product; +using Ecommerce.Core.Models; +using FluentValidation; + +namespace Ecommerce.Core.Validators.Products; + +public class CreateProductDtoValidator : AbstractValidator +{ + public CreateProductDtoValidator() + { + RuleFor(x => x.Name) + .NotEmpty() + .WithMessage("Product name is required.") + .MaximumLength(Product.MaxNameLength) + .WithMessage($"Product name cannot exceed {Product.MaxNameLength} characters."); + + RuleFor(x => x.Description) + .MaximumLength(Product.MaxDescriptionLength) + .WithMessage($"Product description cannot exceed {Product.MaxDescriptionLength} characters."); + + RuleFor(x => x.Price) + .GreaterThan(0) + .WithMessage("Price must be greater than zero."); + + RuleFor(x => x.CategoryId) + .GreaterThan(0) + .WithMessage("A valid Category ID is required."); + + RuleFor(x => x.ImageUrl) + .Must(url => Uri.IsWellFormedUriString(url, UriKind.Absolute)) + .When(x => !string.IsNullOrEmpty(x.ImageUrl)) + .WithMessage("If an Image URL is provided, it must be a valid web address."); + + RuleFor(x => x.Quantity) + .GreaterThanOrEqualTo(0) + .WithMessage("Quantity must be greater than or equal to zero."); + + } +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/Validators/Products/UpdateProductDtoValidation.cs b/EcommerceApi/Ecommerce.Core/Validators/Products/UpdateProductDtoValidation.cs new file mode 100644 index 00000000..daf0ee0e --- /dev/null +++ b/EcommerceApi/Ecommerce.Core/Validators/Products/UpdateProductDtoValidation.cs @@ -0,0 +1,38 @@ +using Ecommerce.Core.DTOs.Product; +using Ecommerce.Core.Models; +using FluentValidation; + +namespace Ecommerce.Core.Validators.Products; + +public class UpdateProductDtoValidation : AbstractValidator +{ + public UpdateProductDtoValidation() + { + RuleFor(x => x.Name) + .NotEmpty() + .WithMessage("Product name is required.") + .MaximumLength(Product.MaxNameLength) + .WithMessage($"Product name cannot exceed {Product.MaxNameLength} characters."); + + RuleFor(x => x.Description) + .MaximumLength(Product.MaxDescriptionLength) + .WithMessage($"Product description cannot exceed {Product.MaxDescriptionLength} characters."); + + RuleFor(x => x.Price) + .GreaterThan(0) + .WithMessage("Price must be greater than zero."); + + RuleFor(x => x.Quantity) + .GreaterThanOrEqualTo(0) + .WithMessage("Quantity must be greater than or equal to zero."); + + RuleFor(x => x.CategoryId) + .GreaterThan(0) + .WithMessage("A valid Category ID is required."); + + RuleFor(x => x.ImageUrl) + .Must(url => Uri.IsWellFormedUriString(url, UriKind.Absolute)) + .When(x => !string.IsNullOrEmpty(x.ImageUrl)) + .WithMessage("If an Image URL is provided, it must be a valid web address."); + } +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/Validators/Sales/CreateSaleDtoValidator.cs b/EcommerceApi/Ecommerce.Core/Validators/Sales/CreateSaleDtoValidator.cs new file mode 100644 index 00000000..9621fd4c --- /dev/null +++ b/EcommerceApi/Ecommerce.Core/Validators/Sales/CreateSaleDtoValidator.cs @@ -0,0 +1,29 @@ +using Ecommerce.Core.DTOs.Sale; +using FluentValidation; + +namespace Ecommerce.Core.Validators.Sales; + +public class CreateSaleDtoValidator : AbstractValidator +{ + public CreateSaleDtoValidator() + { + RuleFor(x => x.Items) + .NotEmpty() + .WithMessage("Sale must have at least one item."); + RuleForEach(x => x.Items) + .SetValidator(new CreateSaleItemDtoValidator()); + } +} + +public class CreateSaleItemDtoValidator : AbstractValidator +{ + public CreateSaleItemDtoValidator() + { + RuleFor(x => x.ProductId) + .GreaterThan(0) + .WithMessage("Product Id must be greater than zero."); + RuleFor(x => x.Quantity) + .GreaterThan(0) + .WithMessage("Quantity must be greater than zero."); + } +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Data/AppDbContext.cs b/EcommerceApi/Ecommerce.Data/AppDbContext.cs new file mode 100644 index 00000000..9582ff3c --- /dev/null +++ b/EcommerceApi/Ecommerce.Data/AppDbContext.cs @@ -0,0 +1,47 @@ +using Ecommerce.Core.Models; +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; + + +namespace Ecommerce.Data; + +public class AppDbContext(DbContextOptions options) : IdentityDbContext(options) + +{ + public DbSet Products { get; set; } + + public DbSet Categories { get; set; } + + public DbSet Sales { get; set; } + + public DbSet SaleItems { get; set; } + + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + modelBuilder + .Entity() + .HasQueryFilter(p => !p.IsDeleted) + .Property(p => p.Price) + .HasPrecision(18, 2); + + modelBuilder + .Entity() + .HasQueryFilter(p => !p.IsDeleted) + .Property(s => s.TotalPrice) + .HasPrecision(18, 2); + + modelBuilder.Entity() + .HasQueryFilter(p => !p.IsDeleted); + + modelBuilder + .Entity() + .HasKey(si => new { si.SaleId, si.ProductId }); + + modelBuilder + .Entity() + .Property(si => si.UnitPriceAtTimeOfSale) + .HasPrecision(18, 2); + } +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Data/Ecommerce.Data.csproj b/EcommerceApi/Ecommerce.Data/Ecommerce.Data.csproj new file mode 100644 index 00000000..f5e2bf03 --- /dev/null +++ b/EcommerceApi/Ecommerce.Data/Ecommerce.Data.csproj @@ -0,0 +1,23 @@ + + + + net10.0 + enable + enable + bin/ + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/EcommerceApi/Ecommerce.Data/Migrations/20260223013358_Initial.Designer.cs b/EcommerceApi/Ecommerce.Data/Migrations/20260223013358_Initial.Designer.cs new file mode 100644 index 00000000..34be7fdb --- /dev/null +++ b/EcommerceApi/Ecommerce.Data/Migrations/20260223013358_Initial.Designer.cs @@ -0,0 +1,164 @@ +// +using System; +using Ecommerce.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Ecommerce.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260223013358_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("Ecommerce.Core.Models.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Categories"); + }); + + modelBuilder.Entity("Ecommerce.Core.Models.Product", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CategoryId") + .HasColumnType("int"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("ImageUrl") + .HasColumnType("nvarchar(max)"); + + 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("Ecommerce.Core.Models.Sale", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreationDate") + .HasColumnType("datetime2"); + + b.Property("TotalPrice") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.HasKey("Id"); + + b.ToTable("Sales"); + }); + + modelBuilder.Entity("Ecommerce.Core.Models.SaleItem", b => + { + b.Property("SaleId") + .HasColumnType("int"); + + b.Property("ProductId") + .HasColumnType("int"); + + b.Property("Quantity") + .HasColumnType("int"); + + b.Property("UnitPriceAtTimeOfSale") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.HasKey("SaleId", "ProductId"); + + b.HasIndex("ProductId"); + + b.ToTable("SaleItems"); + }); + + modelBuilder.Entity("Ecommerce.Core.Models.Product", b => + { + b.HasOne("Ecommerce.Core.Models.Category", "Category") + .WithMany("Products") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Category"); + }); + + modelBuilder.Entity("Ecommerce.Core.Models.SaleItem", b => + { + b.HasOne("Ecommerce.Core.Models.Product", "Product") + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Ecommerce.Core.Models.Sale", "Sale") + .WithMany("Items") + .HasForeignKey("SaleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Product"); + + b.Navigation("Sale"); + }); + + modelBuilder.Entity("Ecommerce.Core.Models.Category", b => + { + b.Navigation("Products"); + }); + + modelBuilder.Entity("Ecommerce.Core.Models.Sale", b => + { + b.Navigation("Items"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/EcommerceApi/Ecommerce.Data/Migrations/20260223013358_Initial.cs b/EcommerceApi/Ecommerce.Data/Migrations/20260223013358_Initial.cs new file mode 100644 index 00000000..3be978c2 --- /dev/null +++ b/EcommerceApi/Ecommerce.Data/Migrations/20260223013358_Initial.cs @@ -0,0 +1,118 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Ecommerce.Data.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(max)", nullable: false), + Description = table.Column(type: "nvarchar(max)", nullable: true) + }, + 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"), + CreationDate = table.Column(type: "datetime2", nullable: false), + TotalPrice = table.Column(type: "decimal(18,2)", precision: 18, scale: 2, 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), + Description = table.Column(type: "nvarchar(max)", nullable: true), + ImageUrl = table.Column(type: "nvarchar(max)", nullable: true), + CategoryId = table.Column(type: "int", 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.Cascade); + }); + + migrationBuilder.CreateTable( + name: "SaleItems", + columns: table => new + { + ProductId = table.Column(type: "int", nullable: false), + SaleId = table.Column(type: "int", nullable: false), + UnitPriceAtTimeOfSale = 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.Cascade); + table.ForeignKey( + name: "FK_SaleItems_Sales_SaleId", + column: x => x.SaleId, + principalTable: "Sales", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + 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/Ecommerce.Data/Migrations/AppDbContextModelSnapshot.cs b/EcommerceApi/Ecommerce.Data/Migrations/AppDbContextModelSnapshot.cs new file mode 100644 index 00000000..334afce9 --- /dev/null +++ b/EcommerceApi/Ecommerce.Data/Migrations/AppDbContextModelSnapshot.cs @@ -0,0 +1,434 @@ +// +using System; +using Ecommerce.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Ecommerce.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + partial class AppDbContextModelSnapshot : 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("Ecommerce.Core.Models.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("Address") + .HasColumnType("nvarchar(max)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Ecommerce.Core.Models.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Categories"); + }); + + modelBuilder.Entity("Ecommerce.Core.Models.Product", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CategoryId") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("ImageUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Price") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("Quantity") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.ToTable("Products"); + }); + + modelBuilder.Entity("Ecommerce.Core.Models.Sale", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreationDate") + .HasColumnType("datetime2"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("TotalPrice") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.HasKey("Id"); + + b.ToTable("Sales"); + }); + + modelBuilder.Entity("Ecommerce.Core.Models.SaleItem", b => + { + b.Property("SaleId") + .HasColumnType("int"); + + b.Property("ProductId") + .HasColumnType("int"); + + b.Property("Quantity") + .HasColumnType("int"); + + b.Property("UnitPriceAtTimeOfSale") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.HasKey("SaleId", "ProductId"); + + b.HasIndex("ProductId"); + + b.ToTable("SaleItems"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("RoleId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Ecommerce.Core.Models.Product", b => + { + b.HasOne("Ecommerce.Core.Models.Category", "Category") + .WithMany("Products") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Category"); + }); + + modelBuilder.Entity("Ecommerce.Core.Models.SaleItem", b => + { + b.HasOne("Ecommerce.Core.Models.Product", "Product") + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Ecommerce.Core.Models.Sale", "Sale") + .WithMany("Items") + .HasForeignKey("SaleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Product"); + + b.Navigation("Sale"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Ecommerce.Core.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Ecommerce.Core.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Ecommerce.Core.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Ecommerce.Core.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Ecommerce.Core.Models.Category", b => + { + b.Navigation("Products"); + }); + + modelBuilder.Entity("Ecommerce.Core.Models.Sale", b => + { + b.Navigation("Items"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/EcommerceApi/Ecommerce.Data/Repositories/CategoryRepository.cs b/EcommerceApi/Ecommerce.Data/Repositories/CategoryRepository.cs new file mode 100644 index 00000000..d6b33bad --- /dev/null +++ b/EcommerceApi/Ecommerce.Data/Repositories/CategoryRepository.cs @@ -0,0 +1,8 @@ +using Ecommerce.Core.Interfaces.Repositories; +using Ecommerce.Core.Models; + +namespace Ecommerce.Data.Repositories; + +public class CategoryRepository(AppDbContext context) : GenericRepository(context), ICategoryRepository +{ +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Data/Repositories/GenericRepository.cs b/EcommerceApi/Ecommerce.Data/Repositories/GenericRepository.cs new file mode 100644 index 00000000..376cac74 --- /dev/null +++ b/EcommerceApi/Ecommerce.Data/Repositories/GenericRepository.cs @@ -0,0 +1,74 @@ +using System.Linq.Expressions; +using Ecommerce.Core.Interfaces.Common; +using Ecommerce.Core.Interfaces.Repositories; +using Ecommerce.Core.Utilities; +using Microsoft.EntityFrameworkCore; + +namespace Ecommerce.Data.Repositories; + +public class GenericRepository(AppDbContext context) : IGenericRepository + where T : class, IBaseEntity +{ + private readonly AppDbContext _context = context; + + public async Task> GetAllAsync(params Expression>[] includeProperties) + { + if (includeProperties.Length == 0) + return await _context.Set().ToListAsync(); + + var query = _context.Set().AsQueryable(); + foreach (var includeProperty in includeProperties) + query = query.Include(includeProperty); + return await query.ToListAsync(); + } + + public async Task> GetAllAsync(PaginationParams paginationParams, params Expression>[] includeProperties) + { + var query = _context.Set().AsQueryable(); + if (includeProperties.Length > 0) + { + foreach (var includeProperty in includeProperties) + query = query.Include(includeProperty); + } + return await query.Skip((paginationParams.PageNumber - 1) * paginationParams.PageSize) + .Take(paginationParams.PageSize) + .ToListAsync(); + } + + public async Task> FindAsync(Expression> predicate, params Expression>[] includeProperties) + { + var query = _context.Set().AsQueryable(); + if (includeProperties.Length > 0) + { + foreach (var includeProperty in includeProperties) + query = query.Include(includeProperty); + } + return await query.Where(predicate).ToListAsync(); + } + + public async Task GetByIdAsync(int id, params Expression>[] includeProperties) + { + if (includeProperties.Length == 0) return await _context.Set().FindAsync(id); + var query = _context.Set().AsQueryable(); + foreach (var includeProperty in includeProperties) + query = query.Include(includeProperty); + return await query.FirstOrDefaultAsync(e => e.Id == id); + } + + public void Add(T entity) + { + _context.Add(entity); + } + + public void Update(T entity) + { + _context.Update(entity); + } + + public void Delete(ISoftDeletable entity) + { + entity.IsDeleted = true; + entity.DeletedAt = DateTime.UtcNow; + _context.Update(entity); + } +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Data/Repositories/ProductRepository.cs b/EcommerceApi/Ecommerce.Data/Repositories/ProductRepository.cs new file mode 100644 index 00000000..7a176494 --- /dev/null +++ b/EcommerceApi/Ecommerce.Data/Repositories/ProductRepository.cs @@ -0,0 +1,8 @@ +using Ecommerce.Core.Interfaces.Repositories; +using Ecommerce.Core.Models; + +namespace Ecommerce.Data.Repositories; + +public class ProductRepository(AppDbContext context) : GenericRepository(context), IProductRepository +{ +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Data/Repositories/SaleRepository.cs b/EcommerceApi/Ecommerce.Data/Repositories/SaleRepository.cs new file mode 100644 index 00000000..ca16775e --- /dev/null +++ b/EcommerceApi/Ecommerce.Data/Repositories/SaleRepository.cs @@ -0,0 +1,18 @@ +using Ecommerce.Core.Interfaces.Repositories; +using Ecommerce.Core.Models; +using Microsoft.EntityFrameworkCore; + +namespace Ecommerce.Data.Repositories; + +public class SaleRepository(AppDbContext context) : GenericRepository(context), ISaleRepository +{ + private readonly AppDbContext _context = context; + public async Task GetSaleWithItemsAsync(int id) + { + return await _context.Sales + .Include(s => s.Items) + .ThenInclude(si => si.Product) + .IgnoreQueryFilters() + .FirstOrDefaultAsync(s => s.Id == id); + } +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Data/Repositories/UnitOfWork.cs b/EcommerceApi/Ecommerce.Data/Repositories/UnitOfWork.cs new file mode 100644 index 00000000..7ba86f5f --- /dev/null +++ b/EcommerceApi/Ecommerce.Data/Repositories/UnitOfWork.cs @@ -0,0 +1,22 @@ +using Ecommerce.Core.Interfaces.Repositories; + +namespace Ecommerce.Data.Repositories; + +public class UnitOfWork(AppDbContext context) : IUnitOfWork +{ + private IProductRepository? _products; + private ICategoryRepository? _categories; + private ISaleRepository? _sales; + public IProductRepository Products => _products ??= new ProductRepository(context); + public ICategoryRepository Categories => _categories ??= new CategoryRepository(context); + public ISaleRepository Sales => _sales ??= new SaleRepository(context); + public async Task CompleteAsync() + { + return await context.SaveChangesAsync(); + } + + public void Dispose() + { + context.Dispose(); + } +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Services/AuthService.cs b/EcommerceApi/Ecommerce.Services/AuthService.cs new file mode 100644 index 00000000..a85b4206 --- /dev/null +++ b/EcommerceApi/Ecommerce.Services/AuthService.cs @@ -0,0 +1,80 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using Ecommerce.Core.DTOs.Auth; +using Ecommerce.Core.Interfaces.Services; +using Ecommerce.Core.Models; +using Ecommerce.Core.Utilities; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Configuration; +using Microsoft.IdentityModel.Tokens; + +namespace Ecommerce.Services; + +public class AuthService( + UserManager userManager, + IConfiguration configuration + ) : IAuthService +{ + public async Task> Register(RegisterDto registerDto) + { + ApplicationUser user = new() + { + UserName = registerDto.Username, + Email = registerDto.Email + }; + var result = await userManager.CreateAsync(user, registerDto.Password); + if (!result.Succeeded) + { + var errors = string.Join("\n", result.Errors.Select(x => $"{x.Code} => {x.Description}")); + return Result.Fail(errors); + } + + return Result.Success("User created successfully"); + } + + public async Task> Login(LoginDto loginDto) + { + var user = await userManager.FindByEmailAsync(loginDto.Email); + if (user is null || !await userManager.CheckPasswordAsync(user, loginDto.Password)) + return Result.Fail("Invalid email or password"); + + var userRoles = await userManager.GetRolesAsync(user); + + var userClaims = new List() + { + new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), + new(JwtRegisteredClaimNames.Sub, user.Id), + new(JwtRegisteredClaimNames.Email, user.Email!), + new(JwtRegisteredClaimNames.Name, user.UserName!) + }; + + foreach (var role in userRoles) + userClaims.Add(new Claim(ClaimTypes.Role, role)); + + var token = GenerateJwt(userClaims); + + return Result.Success( + new AuthResponseDto( + new JwtSecurityTokenHandler().WriteToken(token), + token.ValidTo + ) + ); + + JwtSecurityToken GenerateJwt(List claims) + { + SymmetricSecurityKey authSecret = new(Encoding.UTF8.GetBytes(configuration["Jwt:Secret"]!)); + + var signingCredentials = + new SigningCredentials(authSecret, SecurityAlgorithms.HmacSha256); + + return new JwtSecurityToken( + issuer: configuration["Jwt:Issuer"], + audience: configuration["Jwt:Audience"], + claims: claims, + expires: DateTime.Now.AddHours(configuration.GetValue("Jwt:ExpirationInHours")), + signingCredentials: signingCredentials + ); + } + } +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Services/CategoryService.cs b/EcommerceApi/Ecommerce.Services/CategoryService.cs new file mode 100644 index 00000000..18857fa7 --- /dev/null +++ b/EcommerceApi/Ecommerce.Services/CategoryService.cs @@ -0,0 +1,97 @@ +using Ecommerce.Core.DTOs.Category; +using Ecommerce.Core.Interfaces.Repositories; +using Ecommerce.Core.Interfaces.Services; +using Ecommerce.Core.Models; +using Ecommerce.Core.Utilities; + +namespace Ecommerce.Services; + +public class CategoryService(IUnitOfWork unitOfWork) : ICategoryService +{ + private readonly IUnitOfWork _unitOfWork = unitOfWork; + public async Task> CreateCategoryAsync(CreateCategoryDto category) + { + Category newCategory = new() + { + Name = category.Name, + Description = category.Description + }; + _unitOfWork.Categories.Add(newCategory); + int rowsAffected = await _unitOfWork.CompleteAsync(); + if (rowsAffected == 0) + { + return Result.Fail("Failed to create category"); + } + + CategoryDto dto = new() + { + Id = newCategory.Id, + Name = newCategory.Name, + Description = newCategory.Description + }; + return Result.Success(dto); + } + + public async Task> GetCategoryAsync(int id) + { + var category = await _unitOfWork.Categories.GetByIdAsync(id); + if (category is null) + { + return Result.Fail($"Category with Id {id} was not found"); + } + + CategoryDto dto = new() + { + Id = category.Id, + Name = category.Name, + Description = category.Description + }; + return Result.Success(dto); + } + + public async Task>> GetAllCategoriesAsync() + { + var categories = await _unitOfWork.Categories.GetAllAsync(); + return Result>.Success( + categories.Select(c => new CategoryDto() + { + Id= c.Id, + Name = c.Name, + Description = c.Description + }) + ); + } + + public async Task> UpdateCategoryAsync(int id, UpdateCategoryDto category) + { + var categoryFromDb = await _unitOfWork.Categories.GetByIdAsync(id); + if(categoryFromDb is null) + return Result.Fail($"Category with Id {id} was not found"); + categoryFromDb.Name = category.Name; + categoryFromDb.Description = category.Description; + + await _unitOfWork.CompleteAsync(); + + return Result.Success(new CategoryDto() + { + Id = categoryFromDb.Id, + Name = categoryFromDb.Name, + Description = categoryFromDb.Description + }); + } + + public async Task> DeleteCategoryAsync(int id) + { + var categoryFromDb = await _unitOfWork.Categories.GetByIdAsync(id); + if(categoryFromDb is null) + return Result.Fail($"Category with Id {id} was not found"); + + _unitOfWork.Categories.Delete(categoryFromDb); + var rowsAffected = await _unitOfWork.CompleteAsync(); + if (rowsAffected == 0) + { + return Result.Fail("Failed to delete category"); + } + return Result.Success(true); + } +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Services/Ecommerce.Services.csproj b/EcommerceApi/Ecommerce.Services/Ecommerce.Services.csproj new file mode 100644 index 00000000..5652b597 --- /dev/null +++ b/EcommerceApi/Ecommerce.Services/Ecommerce.Services.csproj @@ -0,0 +1,35 @@ + + + + net10.0 + enable + enable + + + + + + + + + ..\..\..\..\..\..\..\..\..\..\home\basem\.nuget\packages\microsoft.extensions.configuration.abstractions\10.0.3\lib\net10.0\Microsoft.Extensions.Configuration.Abstractions.dll + + + ..\..\..\..\..\..\..\..\..\..\home\basem\.nuget\packages\microsoft.extensions.identity.core\10.0.3\lib\net10.0\Microsoft.Extensions.Identity.Core.dll + + + ..\..\..\..\..\..\..\..\..\..\home\basem\.nuget\packages\microsoft.extensions.identity.stores\10.0.3\lib\net10.0\Microsoft.Extensions.Identity.Stores.dll + + + ..\..\..\..\..\..\..\..\..\..\home\basem\.nuget\packages\microsoft.identitymodel.tokens\7.7.1\lib\net8.0\Microsoft.IdentityModel.Tokens.dll + + + ..\..\..\..\..\..\..\..\..\..\home\basem\.nuget\packages\system.identitymodel.tokens.jwt\7.7.1\lib\net8.0\System.IdentityModel.Tokens.Jwt.dll + + + + + + + + diff --git a/EcommerceApi/Ecommerce.Services/ProductService.cs b/EcommerceApi/Ecommerce.Services/ProductService.cs new file mode 100644 index 00000000..d777b185 --- /dev/null +++ b/EcommerceApi/Ecommerce.Services/ProductService.cs @@ -0,0 +1,134 @@ +using Ecommerce.Core.DTOs.Product; +using Ecommerce.Core.Interfaces.Repositories; +using Ecommerce.Core.Interfaces.Services; +using Ecommerce.Core.Models; +using Ecommerce.Core.Utilities; + +namespace Ecommerce.Services; + +public class ProductService(IUnitOfWork unitOfWork) : IProductService +{ + private readonly IUnitOfWork _unitOfWork = unitOfWork; + + public async Task> CreateProductAsync(CreateProductDto product) + { + var category = await _unitOfWork.Categories.GetByIdAsync(product.CategoryId); + if (category is null) + { + return Result.Fail($"Category with Id {product.CategoryId} was not found"); + } + + Product newProduct = new() + { + Name = product.Name, + Description = product.Description, + ImageUrl = product.ImageUrl, + Quantity = product.Quantity, + Price = product.Price, + CategoryId = product.CategoryId + }; + + _unitOfWork.Products.Add(newProduct); + int rowsAffected = await _unitOfWork.CompleteAsync(); + if (rowsAffected == 0) + { + return Result.Fail("Failed to create product"); + } + ProductDto dto = new() + { + Id = newProduct.Id, + Name = newProduct.Name, + ImageUrl = newProduct.ImageUrl, + Quantity = newProduct.Quantity, + Description = newProduct.Description, + Price = newProduct.Price, + CategoryId = newProduct.CategoryId, + CategoryName = category.Name + }; + return Result.Success(dto); + } + + public async Task> GetProductAsync(int id) + { + var product = await _unitOfWork.Products.GetByIdAsync(id, p => p.Category); + if (product is null) + { + return Result.Fail($"Product with Id {id} was not found"); + } + + ProductDto dto = new() + { + Id = product.Id, + Name = product.Name, + ImageUrl = product.ImageUrl, + Quantity = product.Quantity, + Description = product.Description, + Price = product.Price, + CategoryId = product.CategoryId, + CategoryName = product.Category?.Name ?? string.Empty + }; + return Result.Success(dto); + } + + public async Task>> GetAllProductsAsync(PaginationParams paginationParams) + { + var products = await _unitOfWork.Products.GetAllAsync(paginationParams, p => p.Category); + return Result>.Success(products.Select(p => new ProductDto() + { + Id = p.Id, + Name = p.Name, + ImageUrl = p.ImageUrl, + Quantity = p.Quantity, + Description = p.Description, + Price = p.Price, + CategoryId = p.CategoryId, + CategoryName = p.Category?.Name ?? string.Empty + })); + } + + public async Task> UpdateProductAsync(int id, UpdateProductDto product) + { + var category = await _unitOfWork.Categories.GetByIdAsync(product.CategoryId); + if (category is null) + return Result.Fail($"Category with Id {id} was not found"); + + var productFromDb = await _unitOfWork.Products.GetByIdAsync(id); + if(productFromDb is null) + return Result.Fail($"Product with Id {id} was not found"); + + productFromDb.Name = product.Name; + productFromDb.Description = product.Description; + productFromDb.Price = product.Price; + productFromDb.Quantity = product.Quantity; + productFromDb.ImageUrl = product.ImageUrl; + productFromDb.CategoryId = product.CategoryId; + + await _unitOfWork.CompleteAsync(); + + return Result.Success(new ProductDto() + { + Id = productFromDb.Id, + Name = productFromDb.Name, + ImageUrl = productFromDb.ImageUrl, + Quantity = productFromDb.Quantity, + Description = productFromDb.Description, + Price = productFromDb.Price, + CategoryId = productFromDb.CategoryId, + CategoryName = category.Name + }); + } + + public async Task> DeleteProductAsync(int id) + { + var product = await _unitOfWork.Products.GetByIdAsync(id); + if(product is null) + return Result.Fail($"Product with Id {id} was not found"); + _unitOfWork.Products.Delete(product); + var rowsAffected = await _unitOfWork.CompleteAsync(); + if (rowsAffected == 0) + { + return Result.Fail("Failed to delete product"); + } + return Result.Success(true); + } +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Services/SaleService.cs b/EcommerceApi/Ecommerce.Services/SaleService.cs new file mode 100644 index 00000000..2a5b22dc --- /dev/null +++ b/EcommerceApi/Ecommerce.Services/SaleService.cs @@ -0,0 +1,101 @@ +using Ecommerce.Core.DTOs.Sale; +using Ecommerce.Core.Interfaces.Repositories; +using Ecommerce.Core.Interfaces.Services; +using Ecommerce.Core.Models; +using Ecommerce.Core.Utilities; + +namespace Ecommerce.Services; + +public class SaleService(IUnitOfWork unitOfWork) : ISaleService +{ + private readonly IUnitOfWork _unitOfWork = unitOfWork; + + public async Task> CreateSaleAsync(CreateSaleDto sale) + { + if (sale.Items.Count == 0) + return Result.Fail("Sale must have at least one item"); + var productIds = sale.Items.Select(i => i.ProductId).ToList(); + var productsFromDb = await _unitOfWork.Products.FindAsync(p => productIds.Contains(p.Id)); + var saleItems = new List(); + foreach (var saleItem in sale.Items) + { + var product = productsFromDb.FirstOrDefault(p => p.Id == saleItem.ProductId); + if (product is null) + return Result.Fail($"Product with Id {saleItem.ProductId} was not found"); + if (product.Quantity < saleItem.Quantity || product.Quantity == 0) + return Result.Fail($"Insufficient stock for product {product.Name}"); + + product.Quantity -= saleItem.Quantity; + saleItems.Add(new SaleItem() + { + ProductId = product.Id, + Product = product, + Quantity = saleItem.Quantity, + UnitPriceAtTimeOfSale = product.Price + }); + } + + var saleEntity = new Sale() + { + CreationDate = DateTime.UtcNow, + Items = saleItems, + TotalPrice = saleItems.Sum(i => i.Quantity * i.UnitPriceAtTimeOfSale) + }; + + _unitOfWork.Sales.Add(saleEntity); + + var rowsAffected = await _unitOfWork.CompleteAsync(); + if (rowsAffected == 0) + { + return Result.Fail("Failed to create sale"); + } + + var saleDto = new SaleDto() + { + CreationDate = saleEntity.CreationDate, + TotalPrice = saleEntity.TotalPrice, + Id = saleEntity.Id, + Items = saleItems.Select(si => new SaleItemDto() + { + ProductId = si.ProductId, + ProductName = si.Product?.Name, + Quantity = si.Quantity, + UnitPrice = si.UnitPriceAtTimeOfSale + }).ToList() + }; + return Result.Success(saleDto); + } + + public async Task> GetSaleAsync(int id) + { + var sale = await _unitOfWork.Sales.GetSaleWithItemsAsync(id); + if (sale is null) + return Result.Fail($"Sale with Id {id} was not found"); + SaleDto dto = new() + { + CreationDate = sale.CreationDate, + Id = sale.Id, + Items = sale.Items?.Select(si => new SaleItemDto() + { + ProductId = si.ProductId, + ProductName = si.Product?.Name ?? string.Empty, + Quantity = si.Quantity, + UnitPrice = si.UnitPriceAtTimeOfSale + }).ToList() ?? new List(), + TotalPrice = sale.TotalPrice + }; + return Result.Success(dto); + } + + public async Task>> GetAllSalesAsync(PaginationParams paginationParams) + { + var sales = await _unitOfWork.Sales.GetAllAsync(paginationParams); + var salesDto = sales.Select(s => new SaleDto() + { + CreationDate = s.CreationDate, + Id = s.Id, + TotalPrice = s.TotalPrice, + }).ToList(); + return Result>.Success(salesDto); + } +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Web/Controllers/AuthController.cs b/EcommerceApi/Ecommerce.Web/Controllers/AuthController.cs new file mode 100644 index 00000000..ec91dbdc --- /dev/null +++ b/EcommerceApi/Ecommerce.Web/Controllers/AuthController.cs @@ -0,0 +1,53 @@ +using Asp.Versioning; +using Ecommerce.Core.DTOs.Auth; +using Ecommerce.Core.Interfaces.Services; +using Ecommerce.Core.Models; +using FluentValidation; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; + +namespace Ecommerce.Web.Controllers +{ + [Route("api/v{v:apiVersion}/[controller]")] + [ApiController] + public class AuthController( + IValidator registerValidator, + IValidator loginValidator, + UserManager userManager, + IAuthService authService + ) : ControllerBase + { + [HttpPost("Register")] + public async Task Register(RegisterDto registerDto) + { + var validationResult = await registerValidator.ValidateAsync(registerDto); + if (!validationResult.IsValid) + { + var errors = validationResult.Errors.Select(x => $"{x.PropertyName} => {x.ErrorMessage}").ToList(); + return BadRequest(new { Errors = errors }); + } + + var result = await authService.Register(registerDto); + if (!result.IsSuccess) + return BadRequest(new { Error = result.Message }); + return Ok(result.Data); + } + + [HttpPost("Login")] + public async Task Login(LoginDto loginDto) + { + var validationResult = await loginValidator.ValidateAsync(loginDto); + if (!validationResult.IsValid) + { + var errors = validationResult.Errors.Select(x => $"{x.PropertyName} => {x.ErrorMessage}").ToList(); + return BadRequest(new { Errors = errors }); + } + + var result = await authService.Login(loginDto); + if (result.IsSuccess) + return Ok(result.Data); + + return Unauthorized(new { Error = result.Message }); + } + } +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Web/Controllers/CategoryController.cs b/EcommerceApi/Ecommerce.Web/Controllers/CategoryController.cs new file mode 100644 index 00000000..c5614da5 --- /dev/null +++ b/EcommerceApi/Ecommerce.Web/Controllers/CategoryController.cs @@ -0,0 +1,68 @@ +using Asp.Versioning; +using Ecommerce.Core.DTOs.Category; +using Ecommerce.Core.Interfaces.Services; +using FluentValidation; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Ecommerce.Web.Controllers; + +[ApiController] +[Route("api/v{v:apiVersion}/[controller]")] +public class CategoryController(ICategoryService categoryService, IValidator createValidator, IValidator updateValidator) : ControllerBase +{ + private readonly ICategoryService _categoryService = categoryService; + private readonly IValidator _createValidator = createValidator; + private readonly IValidator _updateValidator = updateValidator; + + + [HttpGet] + public async Task GetAll() + { + var categories = await _categoryService.GetAllCategoriesAsync(); + return Ok(categories.Data); + } + + [HttpGet("{id:int}")] + public async Task GetById(int id) + { + var result = await _categoryService.GetCategoryAsync(id); + return result.IsSuccess ? Ok(result.Data) : NotFound(result.Message); + } + + [Authorize(Roles = "Admin")] + [HttpPost] + public async Task Create(CreateCategoryDto category) + { + var validationResult = await _createValidator.ValidateAsync(category); + if (!validationResult.IsValid) + { + var errors = validationResult.Errors.Select(x => $"{x.PropertyName} => {x.ErrorMessage}").ToList(); + return BadRequest(new {Errors = errors}); + } + var result = await _categoryService.CreateCategoryAsync(category); + return result.IsSuccess ? Ok(result.Data) : BadRequest(result.Message); + } + + [Authorize(Roles = "Admin")] + [HttpPut("{id:int}")] + public async Task Update(int id, UpdateCategoryDto category) + { + var validationResult = await _updateValidator.ValidateAsync(category); + if (!validationResult.IsValid) + { + var errors = validationResult.Errors.Select(x => $"{x.PropertyName} => {x.ErrorMessage}").ToList(); + return BadRequest(new {Errors = errors}); + } + var result = await _categoryService.UpdateCategoryAsync(id, category); + return result.IsSuccess ? Ok(result.Data) : BadRequest(result.Message); + } + + [Authorize(Roles = "Admin")] + [HttpDelete("{id:int}")] + public async Task Delete(int id) + { + var result = await _categoryService.DeleteCategoryAsync(id); + return result.IsSuccess ? NoContent() : BadRequest(result.Message); + } +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Web/Controllers/ProductController.cs b/EcommerceApi/Ecommerce.Web/Controllers/ProductController.cs new file mode 100644 index 00000000..ea64baed --- /dev/null +++ b/EcommerceApi/Ecommerce.Web/Controllers/ProductController.cs @@ -0,0 +1,69 @@ +using Asp.Versioning; +using Ecommerce.Core.DTOs.Product; +using Ecommerce.Core.Interfaces.Services; +using Ecommerce.Core.Utilities; +using FluentValidation; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Ecommerce.Web.Controllers; + +[ApiController] +[Route("api/v{v:apiVersion}/[controller]")] +public class ProductController(IProductService productService, IValidator createValidator, IValidator updateValidator) : ControllerBase +{ + private readonly IProductService _productService = productService; + private readonly IValidator _createValidator = createValidator; + private readonly IValidator _updateValidator = updateValidator; + + [HttpGet] + public async Task GetAll([FromQuery] PaginationParams paginationParams) + { + var products = await _productService.GetAllProductsAsync(paginationParams); + return Ok(products.Data); + } + + [HttpGet("{id:int}")] + public async Task GetById(int id) + { + var product = await _productService.GetProductAsync(id); + return product.IsSuccess ? Ok(product.Data) : NotFound(product.Message); + } + + [Authorize(Roles = "Admin")] + [HttpPost] + public async Task Create(CreateProductDto product) + { + var validationResult = await _createValidator.ValidateAsync(product); + if (!validationResult.IsValid) + { + var errors = validationResult.Errors.Select(x => $"{x.PropertyName} => {x.ErrorMessage}").ToList(); + return BadRequest(new {Errors = errors}); + } + + var result = await _productService.CreateProductAsync(product); + return result.IsSuccess ? Ok(result.Data) : BadRequest(result.Message); + } + + [Authorize(Roles = "Admin")] + [HttpDelete("{id:int}")] + public async Task Delete(int id) + { + var result = await _productService.DeleteProductAsync(id); + return result.IsSuccess ? NoContent() : BadRequest(result.Message); + } + + [Authorize(Roles = "Admin")] + [HttpPut("{id:int}")] + public async Task Update(int id, UpdateProductDto product) + { + var validationResult = await _updateValidator.ValidateAsync(product); + if (!validationResult.IsValid) + { + var errors = validationResult.Errors.Select(x => $"{x.PropertyName} => {x.ErrorMessage}").ToList(); + return BadRequest(new {Errors = errors}); + } + var result = await _productService.UpdateProductAsync(id, product); + return result.IsSuccess ? Ok(result.Data) : BadRequest(result.Message); + } +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Web/Controllers/SaleController.cs b/EcommerceApi/Ecommerce.Web/Controllers/SaleController.cs new file mode 100644 index 00000000..bad722ee --- /dev/null +++ b/EcommerceApi/Ecommerce.Web/Controllers/SaleController.cs @@ -0,0 +1,46 @@ +using Asp.Versioning; +using Ecommerce.Core.DTOs.Sale; +using Ecommerce.Core.Interfaces.Services; +using Ecommerce.Core.Utilities; +using FluentValidation; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Ecommerce.Web.Controllers; + +[ApiController] +[Route("api/v{v:apiVersion}/[controller]")] +public class SaleController(ISaleService saleService, IValidator createValidator) : ControllerBase +{ + private readonly ISaleService _saleService = saleService; + private readonly IValidator _createValidator = createValidator; + + [Authorize(Roles = "Admin")] + [HttpGet] + public async Task GetAll([FromQuery] PaginationParams paginationParams) + { + var sales = await _saleService.GetAllSalesAsync(paginationParams); + return Ok(sales.Data); + } + + [HttpGet("{id:int}")] + public async Task GetById(int id) + { + var sale = await _saleService.GetSaleAsync(id); + return sale.IsSuccess ? Ok(sale.Data) : NotFound(sale.Message); + } + + [Authorize] + [HttpPost] + public async Task Create(CreateSaleDto sale) + { + var validationResult = await _createValidator.ValidateAsync(sale); + if (!validationResult.IsValid) + { + var errors = validationResult.Errors.Select(x => $"{x.PropertyName} => {x.ErrorMessage}").ToList(); + return BadRequest(new {Errors = errors}); + } + var result = await _saleService.CreateSaleAsync(sale); + return result.IsSuccess ? Ok(result.Data) : BadRequest(result.Message); + } +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Web/Ecommerce.Web.csproj b/EcommerceApi/Ecommerce.Web/Ecommerce.Web.csproj new file mode 100644 index 00000000..3d390119 --- /dev/null +++ b/EcommerceApi/Ecommerce.Web/Ecommerce.Web.csproj @@ -0,0 +1,30 @@ + + + + net10.0 + enable + enable + Ecommerce.Web + bin/ + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + diff --git a/EcommerceApi/Ecommerce.Web/Extensions/GlobalExceptionHandler.cs b/EcommerceApi/Ecommerce.Web/Extensions/GlobalExceptionHandler.cs new file mode 100644 index 00000000..e0135a66 --- /dev/null +++ b/EcommerceApi/Ecommerce.Web/Extensions/GlobalExceptionHandler.cs @@ -0,0 +1,36 @@ +using System.Net; +using Microsoft.AspNetCore.Diagnostics; + +namespace Ecommerce.Web.Extensions; + +public class GlobalExceptionHandler(IProblemDetailsService problemDetailsService) : IExceptionHandler +{ + public async ValueTask TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken) + { + var statusCode = exception switch + { + ApplicationException => (int)HttpStatusCode.BadRequest, + _ => (int)HttpStatusCode.InternalServerError + }; + + httpContext.Response.StatusCode = statusCode; + httpContext.Response.ContentType = "application/json"; + + var safeErrorMessage = statusCode == (int)HttpStatusCode.InternalServerError + ? "An unexpected error occurred on the server. Please try again later." + : exception.Message; + + return await problemDetailsService.TryWriteAsync(new ProblemDetailsContext() + { + HttpContext = httpContext, + Exception = exception, + ProblemDetails = new() + { + Status = statusCode, + Type = exception.GetType().Name, + Title = "Internal Server Error", + Detail = safeErrorMessage + } + }); + } +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Web/Program.cs b/EcommerceApi/Ecommerce.Web/Program.cs new file mode 100644 index 00000000..00854661 --- /dev/null +++ b/EcommerceApi/Ecommerce.Web/Program.cs @@ -0,0 +1,84 @@ +using System.Text; +using Asp.Versioning; +using Ecommerce.Core.Interfaces.Repositories; +using Ecommerce.Core.Interfaces.Services; +using Ecommerce.Core.Models; +using Ecommerce.Core.Validators.Products; +using Ecommerce.Data; +using Ecommerce.Data.Repositories; +using Ecommerce.Services; +using Ecommerce.Web.Extensions; +using FluentValidation; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.IdentityModel.Tokens; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddProblemDetails(configure => +{ + configure.CustomizeProblemDetails = context => + { + context.ProblemDetails.Extensions.TryAdd("requestId", context.HttpContext.TraceIdentifier); + }; +}); +builder.Services.AddExceptionHandler(); + +builder.Services.AddDbContext(options => + options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")) +); +builder.Services.AddIdentity() + .AddEntityFrameworkStores(); + +builder.Services.AddAuthentication(options => +{ + options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme; +}).AddJwtBearer(options => +{ + options.TokenValidationParameters.ValidIssuer = builder.Configuration["Jwt:Issuer"]; + options.TokenValidationParameters.ValidAudience = builder.Configuration["Jwt:Audience"]; + options.TokenValidationParameters.IssuerSigningKey = + new SymmetricSecurityKey(Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Secret"]!)); +}); + +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +builder.Services.AddValidatorsFromAssemblyContaining(); + +builder.Services.AddApiVersioning(options => + { + options.DefaultApiVersion = new ApiVersion(1); + options.ReportApiVersions = true; + options.AssumeDefaultVersionWhenUnspecified = true; + options.ApiVersionReader = ApiVersionReader.Combine( + new UrlSegmentApiVersionReader(), + new HeaderApiVersionReader("X-Api-Version")); + }) + .AddMvc(); + +builder.Services.AddControllers(); +builder.Services.AddOpenApi(); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. + +app.UseExceptionHandler(); + +if (app.Environment.IsDevelopment()) +{ + app.MapOpenApi(); +} + +app.UseAuthorization(); + +app.MapControllers(); + +app.Run(); \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Web/Properties/launchSettings.json b/EcommerceApi/Ecommerce.Web/Properties/launchSettings.json new file mode 100644 index 00000000..22822427 --- /dev/null +++ b/EcommerceApi/Ecommerce.Web/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5248", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/EcommerceApi/Ecommerce.Web/appsettings.json b/EcommerceApi/Ecommerce.Web/appsettings.json new file mode 100644 index 00000000..76146697 --- /dev/null +++ b/EcommerceApi/Ecommerce.Web/appsettings.json @@ -0,0 +1,17 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "ConnectionStrings": { + "DefaultConnection": "Server=.;Database=ecommerceDb;user id=DbUsername;password=DbPassword;Encrypt=true;TrustServerCertificate=true;" + }, + "Jwt": { + "Secret": "Set_a_32_Character_Secret_Key_Here", + "ExpirationInHours": 1, + "Issuer": "IssuerUrl", + "Audience": "AudienceUrl" + } +} diff --git a/EcommerceApi/Ecommerce.sln b/EcommerceApi/Ecommerce.sln new file mode 100644 index 00000000..348daeea --- /dev/null +++ b/EcommerceApi/Ecommerce.sln @@ -0,0 +1,34 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ecommerce.Web", "Ecommerce.Web\Ecommerce.Web.csproj", "{979046FC-E2EC-4F6B-96FF-A85CE9B986B2}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ecommerce.Core", "Ecommerce.Core\Ecommerce.Core.csproj", "{0DB2EFAE-16FB-498E-8E74-080596BEDBB2}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ecommerce.Data", "Ecommerce.Data\Ecommerce.Data.csproj", "{0C65FD30-B3EF-4FB8-A8B8-7C369B2052D3}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ecommerce.Services", "Ecommerce.Services\Ecommerce.Services.csproj", "{CD7B41D5-8906-4BAF-BF04-02304FA819B1}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {979046FC-E2EC-4F6B-96FF-A85CE9B986B2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {979046FC-E2EC-4F6B-96FF-A85CE9B986B2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {979046FC-E2EC-4F6B-96FF-A85CE9B986B2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {979046FC-E2EC-4F6B-96FF-A85CE9B986B2}.Release|Any CPU.Build.0 = Release|Any CPU + {0DB2EFAE-16FB-498E-8E74-080596BEDBB2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0DB2EFAE-16FB-498E-8E74-080596BEDBB2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0DB2EFAE-16FB-498E-8E74-080596BEDBB2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0DB2EFAE-16FB-498E-8E74-080596BEDBB2}.Release|Any CPU.Build.0 = Release|Any CPU + {0C65FD30-B3EF-4FB8-A8B8-7C369B2052D3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0C65FD30-B3EF-4FB8-A8B8-7C369B2052D3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0C65FD30-B3EF-4FB8-A8B8-7C369B2052D3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0C65FD30-B3EF-4FB8-A8B8-7C369B2052D3}.Release|Any CPU.Build.0 = Release|Any CPU + {CD7B41D5-8906-4BAF-BF04-02304FA819B1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CD7B41D5-8906-4BAF-BF04-02304FA819B1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CD7B41D5-8906-4BAF-BF04-02304FA819B1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CD7B41D5-8906-4BAF-BF04-02304FA819B1}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/README.md b/README.md new file mode 100644 index 00000000..4c8bbd2b --- /dev/null +++ b/README.md @@ -0,0 +1,313 @@ +# E-commerce API + +A RESTful API built with ASP.NET Core for managing an e-commerce system with products, categories, and sales tracking. + +## Features + +- **Product Management**: CRUD operations for products with inventory tracking +- **Category Management**: Organize products into categories +- **Sales Processing**: Create and track sales with automatic inventory deduction +- **Authentication & Authorization:** Secure, stateless user authentication utilizing ASP.NET Core Identity and JSON Web Tokens (JWT). + Implements role-based access control to lock down administrative endpoints while safely exposing public browsing routes. +- **Soft Delete**: Entities are soft-deleted, allowing data recovery +- **Pagination**: Efficient data retrieval with pagination support +- **Result Pattern**: Consistent error handling and response formatting +- **Global Exception Handling**: Middleware for centralized error handling across all requests +- **Input Validation**: Request validation using FluentValidation with descriptive error messages +- **API Versioning**: URL segment versioning via Asp.Versioning (current version: v1) + +## Architecture + +This project follows **Clean Architecture** principles with clear separation of concerns: + +``` +EcommerceApi/ +├── Ecommerce.Core/ # Domain layer (Models, DTOs, Interfaces) +│ ├── Models/ # Domain entities +│ ├── DTOs/ # Data Transfer Objects +│ ├── Interfaces/ # Service and repository contracts +│ ├── Utilities/ # Shared utilities (Result, Pagination) +| └── Validators/ # FluentValidation validators +├── Ecommerce.Data/ # Data access layer +│ ├── Repositories/ # Repository implementations +│ ├── Migrations/ # EF Core migrations +│ └── AppDbContext.cs # Database context +├── Ecommerce.Services/ # Business logic layer +│ ├── CategoryService.cs +│ ├── ProductService.cs +│ └── SaleService.cs +└── Ecommerce.Web/ # Presentation layer (API) + ├── Controllers/ # API controllers + ├── Extensions/ # Global Exception handler + └── Program.cs # Application entry point +``` + +## Technologies + +- **.NET 10.0** +- **ASP.NET Core Web API** +- **Entity Framework Core** with SQL Server +- **Repository Pattern** with Unit of Work +- **Dependency Injection** +- **OpenAPI/Postman** for API documentation +- **FluentValidation** for input validation +- **Asp.Versioning** for API versioning +- **Json Web Tokens** for token-based Authentication and Authorization + +## Getting Started + +### Prerequisites + +- [.NET 10 SDK](https://dotnet.microsoft.com/download) +- SQL Server (LocalDB, Express, or full version) +- Visual Studio 2022 / JetBrains Rider / VS Code + +### Installation + +1. **Clone the repository** + ```bash + git clone + cd EcommerceApi + ``` + +2. **Configure the database connection** + + Update the connection string in `Ecommerce.Web/appsettings.json`: + ```json + { + "ConnectionStrings": { + "DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=EcommerceDb;Trusted_Connection=true;" + } + } + ``` + +3. **Apply database migrations** + ```bash + cd Ecommerce.Web + dotnet ef database update + ``` + +4. **Run the application** + ```bash + dotnet run --project Ecommerce.Web + ``` + +5. **Access the API** + - API: `http://localhost:5248` + - OpenAPI UI: Navigate to `/openapi/v1.json` (in development mode) + +## API Endpoints + +### Categories + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/v1/category` | Get all categories | +| GET | `/api/v1/category/{id}` | Get category by ID | +| POST | `/api/v1/category` | Create a new category | +| PUT | `/api/v1/category/{id}` | Update a category | +| DELETE | `/api/v1/category/{id}` | Delete a category (soft delete) | + +### Products + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/v1/product?pageNumber=1&pageSize=10` | Get all products (paginated) | +| GET | `/api/v1/product/{id}` | Get product by ID | +| POST | `/api/v1/product` | Create a new product | +| PUT | `/api/v1/product/{id}` | Update a product | +| DELETE | `/api/v1/product/{id}` | Delete a product (soft delete) | + +### Sales + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/v1/sale?pageNumber=1&pageSize=10` | Get all sales (paginated) | +| GET | `/api/v1/sale/{id}` | Get sale by ID with items | +| POST | `/api/v1/sale` | Create a new sale | + +## API Usage Examples + +### Create a Category + +```http +POST /api/v1/category +Content-Type: application/json + +{ + "name": "Electronics", + "description": "Electronic devices and gadgets" +} +``` + +### Create a Product + +```http +POST /api/v1/product +Content-Type: application/json + +{ + "name": "Laptop", + "description": "High-performance laptop", + "price": 999.99, + "quantity": 50, + "imageUrl": "https://example.com/laptop.jpg", + "categoryId": 1 +} +``` + +### Create a Sale + +```http +POST /api/v1/sale +Content-Type: application/json + +{ + "items": [ + { + "productId": 1, + "quantity": 2 + }, + { + "productId": 2, + "quantity": 1 + } + ] +} +``` + +**Note**: Creating a sale automatically: +- Validates product availability +- Deducts inventory quantities +- Captures prices at time of sale +- Calculates total price + +### Pagination + +Products and Sales support pagination: + +```http +GET /api/v1/product?pageNumber=2&pageSize=20 +``` + +## Database Schema + +### Core Entities + +**Category** +- `Id` (int, PK) +- `Name` (string, required) +- `Description` (string, nullable) +- `IsDeleted` (bool) +- `DeletedAt` (DateTime?) + +**Product** +- `Id` (int, PK) +- `Name` (string, required) +- `Price` (decimal(18,2)) +- `Quantity` (int) +- `Description` (string, nullable) +- `ImageUrl` (string, nullable) +- `CategoryId` (int, FK) +- `IsDeleted` (bool) +- `DeletedAt` (DateTime?) + +**Sale** +- `Id` (int, PK) +- `CreationDate` (DateTime) +- `TotalPrice` (decimal(18,2)) +- `IsDeleted` (bool) +- `DeletedAt` (DateTime?) + +**SaleItem** (Many-to-Many) +- `SaleId` (int, PK, FK) +- `ProductId` (int, PK, FK) +- `Quantity` (int) +- `UnitPriceAtTimeOfSale` (decimal(18,2)) + +## Design Patterns + +- **Repository Pattern**: Abstracts data access logic +- **Unit of Work**: Manages transactions across repositories +- **Result Pattern**: Standardized success/failure responses +- **Dependency Injection**: Loose coupling between layers +- **Soft Delete**: Data preservation with `ISoftDeletable` interface + +## Development + +### Project Structure + +- **Ecommerce.Core**: Contains business entities, DTOs, and interfaces (no dependencies) +- **Ecommerce.Data**: Implements data access using EF Core +- **Ecommerce.Services**: Implements business logic +- **Ecommerce.Web**: ASP.NET Core Web API project + +### Adding Migrations + +```bash +dotnet ef migrations add MigrationName --project Ecommerce.Data --startup-project Ecommerce.Web +``` + +### Applying Migrations + +```bash +dotnet ef database update --project Ecommerce.Data --startup-project Ecommerce.Web +``` + +## Error Handling + +The API uses a custom `Result` pattern for consistent error responses: + +**Success Response:** +```json +{ + "data": { ... }, + "isSuccess": true, + "message": null +} +``` + +**Error Response:** +```json +{ + "data": null, + "isSuccess": false, + "message": "Error description" +} +``` + +## Validation + +Input validation is handled by FluentValidation and covers: + +- Required fields (e.g. product name, category name) +- Positive quantity values +- Price must be greater than zero +- Category existence when creating/updating products +- Product existence and stock availability when creating sales + +## API Versioning + +This API uses **URL segment versioning** via the `Asp.Versioning` package. The version is embedded directly in the route: + +``` +/api/v{version}/{resource} +``` + +**Current version:** `v1` + +All endpoints are prefixed with `/api/v1/`. When new versions are introduced, older versions remain accessible at their original paths to avoid breaking changes. + +## Future Enhancements + +- [ ] Implement logging +- [ ] Add unit and integration tests +- [ ] Implement caching for frequently accessed data +- [ ] Implement rate limiting +- [ ] Add CORS configuration +- [ ] Add filtering and sorting capabilities +- [ ] Implement search functionality + +## Contributing + +This is a study project from [The C# Academy](https://thecsharpacademy.com/). Feel free to fork and experiment!