From af285a96b4fbd750a4a11cbe301fbba72601bbdb Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 7 Apr 2026 06:40:58 +0000 Subject: [PATCH] feat: Implement Inventory (Product) microservice with full CRUD, Helm chart, ArgoCD, and CI/CD - Product.Domain: Product and ProductCategory entities, IProductRepository interface - Product.Infrastructure: ProductDbContext with model configuration, ProductRepository - Product.API: Full CRUD controllers for Product and ProductCategory, DTOs - YARP Gateway: Added /api/inventory/* route - Helm chart: deployment, service, HPA, NetworkPolicy, ServiceMonitor, ConfigMap, Secret - ArgoCD: dev (auto-sync) and staging (manual sync) application manifests - GitHub Actions: CI/CD pipeline for build, docker, helm-lint, deploy - Shared Contracts: ProductCreated/Updated/Deleted and StockChanged events --- .github/workflows/inventory-service-ci.yml | 112 +++++++++++++++ deploy/argocd/dev/inventory-service-app.yaml | 23 ++++ .../argocd/staging/inventory-service-app.yaml | 20 +++ deploy/helm/inventory-service/Chart.yaml | 6 + .../templates/configmap.yaml | 10 ++ .../templates/deployment.yaml | 59 ++++++++ .../helm/inventory-service/templates/hpa.yaml | 22 +++ .../templates/networkpolicy.yaml | 44 ++++++ .../inventory-service/templates/secret.yaml | 9 ++ .../inventory-service/templates/service.yaml | 15 +++ .../templates/servicemonitor.yaml | 16 +++ deploy/helm/inventory-service/values-dev.yaml | 22 +++ .../inventory-service/values-staging.yaml | 25 ++++ deploy/helm/inventory-service/values.yaml | 52 +++++++ src/ApiGateway/appsettings.json | 5 + .../Controllers/ProductCategoryController.cs | 111 +++++++++++++++ .../Controllers/ProductController.cs | 127 +++++++++++++++++- .../Product.API/DTOs/ProductCategoryDto.cs | 23 ++++ .../Product/Product.API/DTOs/ProductDto.cs | 45 +++++++ src/Services/Product/Product.API/Program.cs | 11 ++ .../Product.Domain/Entities/Product.cs | 27 ++++ .../Entities/ProductCategory.cs | 16 +++ .../Interfaces/IProductRepository.cs | 17 +++ .../Data/ProductDbContext.cs | 34 ++++- .../Repositories/ProductRepository.cs | 95 +++++++++++++ .../Events/ProductCreatedEvent.cs | 13 ++ .../Events/ProductDeletedEvent.cs | 10 ++ .../Events/ProductUpdatedEvent.cs | 14 ++ .../Events/StockChangedEvent.cs | 13 ++ 29 files changed, 988 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/inventory-service-ci.yml create mode 100644 deploy/argocd/dev/inventory-service-app.yaml create mode 100644 deploy/argocd/staging/inventory-service-app.yaml create mode 100644 deploy/helm/inventory-service/Chart.yaml create mode 100644 deploy/helm/inventory-service/templates/configmap.yaml create mode 100644 deploy/helm/inventory-service/templates/deployment.yaml create mode 100644 deploy/helm/inventory-service/templates/hpa.yaml create mode 100644 deploy/helm/inventory-service/templates/networkpolicy.yaml create mode 100644 deploy/helm/inventory-service/templates/secret.yaml create mode 100644 deploy/helm/inventory-service/templates/service.yaml create mode 100644 deploy/helm/inventory-service/templates/servicemonitor.yaml create mode 100644 deploy/helm/inventory-service/values-dev.yaml create mode 100644 deploy/helm/inventory-service/values-staging.yaml create mode 100644 deploy/helm/inventory-service/values.yaml create mode 100644 src/Services/Product/Product.API/Controllers/ProductCategoryController.cs create mode 100644 src/Services/Product/Product.API/DTOs/ProductCategoryDto.cs create mode 100644 src/Services/Product/Product.API/DTOs/ProductDto.cs create mode 100644 src/Services/Product/Product.Domain/Entities/Product.cs create mode 100644 src/Services/Product/Product.Domain/Entities/ProductCategory.cs create mode 100644 src/Services/Product/Product.Domain/Interfaces/IProductRepository.cs create mode 100644 src/Services/Product/Product.Infrastructure/Repositories/ProductRepository.cs create mode 100644 src/Shared/Shared.Contracts/Events/ProductCreatedEvent.cs create mode 100644 src/Shared/Shared.Contracts/Events/ProductDeletedEvent.cs create mode 100644 src/Shared/Shared.Contracts/Events/ProductUpdatedEvent.cs create mode 100644 src/Shared/Shared.Contracts/Events/StockChangedEvent.cs diff --git a/.github/workflows/inventory-service-ci.yml b/.github/workflows/inventory-service-ci.yml new file mode 100644 index 0000000..dbda7bd --- /dev/null +++ b/.github/workflows/inventory-service-ci.yml @@ -0,0 +1,112 @@ +name: Inventory Service CI/CD + +on: + push: + branches: [main] + paths: + - 'src/Services/Product/**' + - 'src/Shared/**' + - 'deploy/helm/inventory-service/**' + pull_request: + branches: [main] + paths: + - 'src/Services/Product/**' + - 'src/Shared/**' + - 'deploy/helm/inventory-service/**' + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository_owner }}/inventory-service + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Restore dependencies + run: dotnet restore src/Services/Product/Product.API/Product.API.csproj + + - name: Build + run: dotnet build src/Services/Product/Product.API/Product.API.csproj --no-restore --configuration Release + + - name: Test + run: | + if [ -d "src/Services/Product/Product.Tests" ]; then + dotnet test src/Services/Product/Product.Tests --no-build --configuration Release + else + echo "No test project found, skipping tests" + fi + + docker: + runs-on: ubuntu-latest + needs: build + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + + - name: Log in to Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: ./src + file: ./src/Services/Product/Product.API/Dockerfile + push: true + tags: | + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest + + helm-lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Helm + uses: azure/setup-helm@v3 + with: + version: v3.13.0 + + - name: Lint Helm chart + run: helm lint deploy/helm/inventory-service + + deploy-dev: + runs-on: ubuntu-latest + needs: [build, docker] + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + environment: dev + steps: + - uses: actions/checkout@v4 + + - name: Deploy to dev + run: | + echo "Triggering ArgoCD sync for inventory-service-dev" + # ArgoCD CLI sync would go here + # argocd app sync inventory-service-dev --server $ARGOCD_SERVER + + deploy-staging: + runs-on: ubuntu-latest + needs: [build, docker, deploy-dev] + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + environment: staging + steps: + - uses: actions/checkout@v4 + + - name: Deploy to staging + run: | + echo "Triggering ArgoCD sync for inventory-service-staging" + # ArgoCD CLI sync would go here + # argocd app sync inventory-service-staging --server $ARGOCD_SERVER diff --git a/deploy/argocd/dev/inventory-service-app.yaml b/deploy/argocd/dev/inventory-service-app.yaml new file mode 100644 index 0000000..6d49d7f --- /dev/null +++ b/deploy/argocd/dev/inventory-service-app.yaml @@ -0,0 +1,23 @@ +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: inventory-service-dev + namespace: argocd +spec: + project: default + source: + repoURL: https://github.com/Cognition-Partner-Workshops/app_dotnet_angular_containerized_decomposition_microservices + targetRevision: main + path: deploy/helm/inventory-service + helm: + valueFiles: + - values-dev.yaml + destination: + server: https://kubernetes.default.svc + namespace: dev + syncPolicy: + automated: + prune: true + selfHeal: true + syncOptions: + - CreateNamespace=true diff --git a/deploy/argocd/staging/inventory-service-app.yaml b/deploy/argocd/staging/inventory-service-app.yaml new file mode 100644 index 0000000..3c18f69 --- /dev/null +++ b/deploy/argocd/staging/inventory-service-app.yaml @@ -0,0 +1,20 @@ +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: inventory-service-staging + namespace: argocd +spec: + project: default + source: + repoURL: https://github.com/Cognition-Partner-Workshops/app_dotnet_angular_containerized_decomposition_microservices + targetRevision: main + path: deploy/helm/inventory-service + helm: + valueFiles: + - values-staging.yaml + destination: + server: https://kubernetes.default.svc + namespace: staging + syncPolicy: + syncOptions: + - CreateNamespace=true diff --git a/deploy/helm/inventory-service/Chart.yaml b/deploy/helm/inventory-service/Chart.yaml new file mode 100644 index 0000000..99a406a --- /dev/null +++ b/deploy/helm/inventory-service/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: inventory-service +description: Helm chart for the Inventory (Product) microservice +type: application +version: 0.1.0 +appVersion: "1.0.0" diff --git a/deploy/helm/inventory-service/templates/configmap.yaml b/deploy/helm/inventory-service/templates/configmap.yaml new file mode 100644 index 0000000..83cde8f --- /dev/null +++ b/deploy/helm/inventory-service/templates/configmap.yaml @@ -0,0 +1,10 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "inventory-service.fullname" . }}-config + labels: + app: {{ include "inventory-service.name" . }} +data: + ASPNETCORE_ENVIRONMENT: {{ .Values.env.ASPNETCORE_ENVIRONMENT | quote }} + ASPNETCORE_URLS: {{ .Values.env.ASPNETCORE_URLS | quote }} + Logging__LogLevel__Default: {{ .Values.logging.level | quote }} diff --git a/deploy/helm/inventory-service/templates/deployment.yaml b/deploy/helm/inventory-service/templates/deployment.yaml new file mode 100644 index 0000000..fc07f93 --- /dev/null +++ b/deploy/helm/inventory-service/templates/deployment.yaml @@ -0,0 +1,59 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "inventory-service.fullname" . }} + labels: + app: {{ include "inventory-service.name" . }} + chart: {{ .Chart.Name }}-{{ .Chart.Version }} +spec: + {{- if not .Values.autoscaling.enabled }} + replicas: {{ .Values.replicaCount }} + {{- end }} + selector: + matchLabels: + app: {{ include "inventory-service.name" . }} + template: + metadata: + labels: + app: {{ include "inventory-service.name" . }} + spec: + containers: + - name: {{ .Chart.Name }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: {{ .Values.service.port }} + protocol: TCP + envFrom: + - configMapRef: + name: {{ include "inventory-service.fullname" . }}-config + - secretRef: + name: {{ include "inventory-service.fullname" . }}-secret + livenessProbe: + httpGet: + path: /healthz + port: http + initialDelaySeconds: 15 + periodSeconds: 20 + readinessProbe: + httpGet: + path: /healthz + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + resources: + {{- toYaml .Values.resources | nindent 12 }} + +{{- define "inventory-service.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- define "inventory-service.fullname" -}} +{{- if .Values.fullnameOverride -}} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- $name := default .Chart.Name .Values.nameOverride -}} +{{- printf "%s" $name | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} diff --git a/deploy/helm/inventory-service/templates/hpa.yaml b/deploy/helm/inventory-service/templates/hpa.yaml new file mode 100644 index 0000000..015011c --- /dev/null +++ b/deploy/helm/inventory-service/templates/hpa.yaml @@ -0,0 +1,22 @@ +{{- if .Values.autoscaling.enabled }} +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "inventory-service.fullname" . }} + labels: + app: {{ include "inventory-service.name" . }} +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include "inventory-service.fullname" . }} + minReplicas: {{ .Values.autoscaling.minReplicas }} + maxReplicas: {{ .Values.autoscaling.maxReplicas }} + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} +{{- end }} diff --git a/deploy/helm/inventory-service/templates/networkpolicy.yaml b/deploy/helm/inventory-service/templates/networkpolicy.yaml new file mode 100644 index 0000000..214e938 --- /dev/null +++ b/deploy/helm/inventory-service/templates/networkpolicy.yaml @@ -0,0 +1,44 @@ +{{- if .Values.networkPolicy.enabled }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ include "inventory-service.fullname" . }} + labels: + app: {{ include "inventory-service.name" . }} +spec: + podSelector: + matchLabels: + app: {{ include "inventory-service.name" . }} + policyTypes: + - Ingress + - Egress + ingress: + - from: + - podSelector: + matchLabels: + app: api-gateway + ports: + - protocol: TCP + port: {{ .Values.service.port }} + egress: + - to: + - podSelector: + matchLabels: + app: postgresql + ports: + - protocol: TCP + port: {{ .Values.postgresql.port }} + - to: + - podSelector: + matchLabels: + app: rabbitmq + ports: + - protocol: TCP + port: {{ .Values.rabbitmq.port }} + - to: [] + ports: + - protocol: TCP + port: 53 + - protocol: UDP + port: 53 +{{- end }} diff --git a/deploy/helm/inventory-service/templates/secret.yaml b/deploy/helm/inventory-service/templates/secret.yaml new file mode 100644 index 0000000..884ef80 --- /dev/null +++ b/deploy/helm/inventory-service/templates/secret.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "inventory-service.fullname" . }}-secret + labels: + app: {{ include "inventory-service.name" . }} +type: Opaque +data: + ConnectionStrings__DefaultConnection: {{ printf "Host=%s;Port=%s;Database=%s;Username=%s;Password=changeme" .Values.postgresql.host (toString .Values.postgresql.port) .Values.postgresql.database .Values.postgresql.username | b64enc | quote }} diff --git a/deploy/helm/inventory-service/templates/service.yaml b/deploy/helm/inventory-service/templates/service.yaml new file mode 100644 index 0000000..d9dd12e --- /dev/null +++ b/deploy/helm/inventory-service/templates/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "inventory-service.fullname" . }} + labels: + app: {{ include "inventory-service.name" . }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + app: {{ include "inventory-service.name" . }} diff --git a/deploy/helm/inventory-service/templates/servicemonitor.yaml b/deploy/helm/inventory-service/templates/servicemonitor.yaml new file mode 100644 index 0000000..1b27f47 --- /dev/null +++ b/deploy/helm/inventory-service/templates/servicemonitor.yaml @@ -0,0 +1,16 @@ +{{- if .Values.serviceMonitor.enabled }} +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: {{ include "inventory-service.fullname" . }} + labels: + app: {{ include "inventory-service.name" . }} +spec: + selector: + matchLabels: + app: {{ include "inventory-service.name" . }} + endpoints: + - port: http + path: {{ .Values.serviceMonitor.path }} + interval: {{ .Values.serviceMonitor.interval }} +{{- end }} diff --git a/deploy/helm/inventory-service/values-dev.yaml b/deploy/helm/inventory-service/values-dev.yaml new file mode 100644 index 0000000..c57d149 --- /dev/null +++ b/deploy/helm/inventory-service/values-dev.yaml @@ -0,0 +1,22 @@ +replicaCount: 1 + +image: + tag: "latest" + +env: + ASPNETCORE_ENVIRONMENT: Development + ASPNETCORE_URLS: "http://+:5004" + +logging: + level: Debug + +autoscaling: + enabled: false + +resources: + limits: + cpu: 250m + memory: 256Mi + requests: + cpu: 50m + memory: 64Mi diff --git a/deploy/helm/inventory-service/values-staging.yaml b/deploy/helm/inventory-service/values-staging.yaml new file mode 100644 index 0000000..af73647 --- /dev/null +++ b/deploy/helm/inventory-service/values-staging.yaml @@ -0,0 +1,25 @@ +replicaCount: 2 + +image: + tag: "latest" + +env: + ASPNETCORE_ENVIRONMENT: Staging + ASPNETCORE_URLS: "http://+:5004" + +logging: + level: Information + +autoscaling: + enabled: true + minReplicas: 2 + maxReplicas: 5 + targetCPUUtilizationPercentage: 70 + +resources: + limits: + cpu: 500m + memory: 512Mi + requests: + cpu: 100m + memory: 128Mi diff --git a/deploy/helm/inventory-service/values.yaml b/deploy/helm/inventory-service/values.yaml new file mode 100644 index 0000000..b0f8304 --- /dev/null +++ b/deploy/helm/inventory-service/values.yaml @@ -0,0 +1,52 @@ +replicaCount: 2 + +image: + repository: ghcr.io/cognition-partner-workshops/inventory-service + pullPolicy: IfNotPresent + tag: "latest" + +nameOverride: "" +fullnameOverride: "" + +service: + type: ClusterIP + port: 5004 + +resources: + limits: + cpu: 500m + memory: 512Mi + requests: + cpu: 100m + memory: 128Mi + +autoscaling: + enabled: true + minReplicas: 2 + maxReplicas: 10 + targetCPUUtilizationPercentage: 70 + +env: + ASPNETCORE_ENVIRONMENT: Production + ASPNETCORE_URLS: "http://+:5004" + +logging: + level: Information + +serviceMonitor: + enabled: true + interval: 30s + path: /metrics + +networkPolicy: + enabled: true + +postgresql: + host: postgresql + port: 5432 + database: inventorydb + username: postgres + +rabbitmq: + host: rabbitmq + port: 5672 diff --git a/src/ApiGateway/appsettings.json b/src/ApiGateway/appsettings.json index 74c7ae9..f1a6d0a 100644 --- a/src/ApiGateway/appsettings.json +++ b/src/ApiGateway/appsettings.json @@ -28,6 +28,11 @@ "Match": { "Path": "/api/products/{**catch-all}" }, "Transforms": [{ "PathRemovePrefix": "/api/products" }] }, + "inventory-route": { + "ClusterId": "product-cluster", + "Match": { "Path": "/api/inventory/{**catch-all}" }, + "Transforms": [{ "PathRemovePrefix": "/api/inventory" }] + }, "notification-route": { "ClusterId": "notification-cluster", "Match": { "Path": "/api/notifications/{**catch-all}" }, diff --git a/src/Services/Product/Product.API/Controllers/ProductCategoryController.cs b/src/Services/Product/Product.API/Controllers/ProductCategoryController.cs new file mode 100644 index 0000000..151f3ae --- /dev/null +++ b/src/Services/Product/Product.API/Controllers/ProductCategoryController.cs @@ -0,0 +1,111 @@ +using Microsoft.AspNetCore.Mvc; +using Product.API.DTOs; +using Product.Domain.Entities; +using Product.Domain.Interfaces; + +namespace Product.API.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class ProductCategoryController : ControllerBase +{ + private readonly IProductRepository _repository; + private readonly ILogger _logger; + + public ProductCategoryController(IProductRepository repository, ILogger logger) + { + _repository = repository; + _logger = logger; + } + + [HttpGet] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + public async Task GetAll() + { + var categories = await _repository.GetAllCategoriesAsync(); + var dtos = categories.Select(c => new ProductCategoryDto + { + Id = c.Id, + Name = c.Name, + Description = c.Description, + Icon = c.Icon + }); + return Ok(dtos); + } + + [HttpGet("{id}")] + [ProducesResponseType(typeof(ProductCategoryDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetById(int id) + { + var category = await _repository.GetCategoryByIdAsync(id); + if (category == null) + return NotFound(); + + return Ok(new ProductCategoryDto + { + Id = category.Id, + Name = category.Name, + Description = category.Description, + Icon = category.Icon + }); + } + + [HttpPost] + [ProducesResponseType(typeof(ProductCategoryDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task Create([FromBody] CreateProductCategoryDto dto) + { + var category = new ProductCategory + { + Name = dto.Name, + Description = dto.Description, + Icon = dto.Icon + }; + + var created = await _repository.AddCategoryAsync(category); + _logger.LogInformation("ProductCategory {CategoryId} created: {CategoryName}", created.Id, created.Name); + + return CreatedAtAction(nameof(GetById), new { id = created.Id }, new ProductCategoryDto + { + Id = created.Id, + Name = created.Name, + Description = created.Description, + Icon = created.Icon + }); + } + + [HttpPut("{id}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task Update(int id, [FromBody] UpdateProductCategoryDto dto) + { + var existing = await _repository.GetCategoryByIdAsync(id); + if (existing == null) + return NotFound(); + + existing.Name = dto.Name; + existing.Description = dto.Description; + existing.Icon = dto.Icon; + + await _repository.UpdateCategoryAsync(existing); + _logger.LogInformation("ProductCategory {CategoryId} updated: {CategoryName}", id, dto.Name); + + return NoContent(); + } + + [HttpDelete("{id}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task Delete(int id) + { + var existing = await _repository.GetCategoryByIdAsync(id); + if (existing == null) + return NotFound(); + + await _repository.DeleteCategoryAsync(id); + _logger.LogInformation("ProductCategory {CategoryId} deleted", id); + + return NoContent(); + } +} diff --git a/src/Services/Product/Product.API/Controllers/ProductController.cs b/src/Services/Product/Product.API/Controllers/ProductController.cs index 710c58f..4922b0b 100644 --- a/src/Services/Product/Product.API/Controllers/ProductController.cs +++ b/src/Services/Product/Product.API/Controllers/ProductController.cs @@ -1,4 +1,6 @@ using Microsoft.AspNetCore.Mvc; +using Product.API.DTOs; +using Product.Domain.Interfaces; namespace Product.API.Controllers; @@ -6,24 +8,135 @@ namespace Product.API.Controllers; [Route("api/[controller]")] public class ProductController : ControllerBase { + private readonly IProductRepository _repository; private readonly ILogger _logger; - public ProductController(ILogger logger) + public ProductController(IProductRepository repository, ILogger logger) { + _repository = repository; _logger = logger; } [HttpGet] - public IActionResult GetAll() + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + public async Task GetAll() { - // TODO: Implement — migrate logic from monolith's ProductController - return Ok(new { service = "Product", status = "scaffold" }); + var products = await _repository.GetAllAsync(); + var dtos = products.Select(p => MapToDto(p)); + return Ok(dtos); } [HttpGet("{id}")] - public IActionResult GetById(int id) + [ProducesResponseType(typeof(ProductDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetById(int id) { - // TODO: Implement — migrate logic from monolith - return Ok(new { service = "Product", id }); + var product = await _repository.GetByIdAsync(id); + if (product == null) + return NotFound(); + + return Ok(MapToDto(product)); + } + + [HttpPost] + [ProducesResponseType(typeof(ProductDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task Create([FromBody] CreateProductDto dto) + { + var product = new Domain.Entities.Product + { + Name = dto.Name, + Description = dto.Description, + Icon = dto.Icon, + BuyingPrice = dto.BuyingPrice, + SellingPrice = dto.SellingPrice, + UnitsInStock = dto.UnitsInStock, + IsActive = dto.IsActive, + IsDiscontinued = dto.IsDiscontinued, + ProductCategoryId = dto.ProductCategoryId, + ParentId = dto.ParentId + }; + + var created = await _repository.AddAsync(product); + _logger.LogInformation("Product {ProductId} created: {ProductName}", created.Id, created.Name); + + // Reload with category + var result = await _repository.GetByIdAsync(created.Id); + return CreatedAtAction(nameof(GetById), new { id = created.Id }, MapToDto(result!)); + } + + [HttpPut("{id}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task Update(int id, [FromBody] UpdateProductDto dto) + { + var existing = await _repository.GetByIdAsync(id); + if (existing == null) + return NotFound(); + + existing.Name = dto.Name; + existing.Description = dto.Description; + existing.Icon = dto.Icon; + existing.BuyingPrice = dto.BuyingPrice; + existing.SellingPrice = dto.SellingPrice; + existing.UnitsInStock = dto.UnitsInStock; + existing.IsActive = dto.IsActive; + existing.IsDiscontinued = dto.IsDiscontinued; + existing.ProductCategoryId = dto.ProductCategoryId; + existing.ParentId = dto.ParentId; + + await _repository.UpdateAsync(existing); + _logger.LogInformation("Product {ProductId} updated: {ProductName}", id, dto.Name); + + return NoContent(); + } + + [HttpDelete("{id}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task Delete(int id) + { + var existing = await _repository.GetByIdAsync(id); + if (existing == null) + return NotFound(); + + await _repository.DeleteAsync(id); + _logger.LogInformation("Product {ProductId} deleted", id); + + return NoContent(); + } + + [HttpGet("categories")] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + public async Task GetCategories() + { + var categories = await _repository.GetAllCategoriesAsync(); + var dtos = categories.Select(c => new ProductCategoryDto + { + Id = c.Id, + Name = c.Name, + Description = c.Description, + Icon = c.Icon + }); + return Ok(dtos); + } + + private static ProductDto MapToDto(Domain.Entities.Product p) + { + return new ProductDto + { + Id = p.Id, + Name = p.Name, + Description = p.Description, + Icon = p.Icon, + BuyingPrice = p.BuyingPrice, + SellingPrice = p.SellingPrice, + UnitsInStock = p.UnitsInStock, + IsActive = p.IsActive, + IsDiscontinued = p.IsDiscontinued, + ProductCategoryId = p.ProductCategoryId, + ProductCategoryName = p.ProductCategory?.Name, + ParentId = p.ParentId + }; } } diff --git a/src/Services/Product/Product.API/DTOs/ProductCategoryDto.cs b/src/Services/Product/Product.API/DTOs/ProductCategoryDto.cs new file mode 100644 index 0000000..4e641e6 --- /dev/null +++ b/src/Services/Product/Product.API/DTOs/ProductCategoryDto.cs @@ -0,0 +1,23 @@ +namespace Product.API.DTOs; + +public class ProductCategoryDto +{ + public int Id { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public string? Icon { get; set; } +} + +public class CreateProductCategoryDto +{ + public required string Name { get; set; } + public string? Description { get; set; } + public string? Icon { get; set; } +} + +public class UpdateProductCategoryDto +{ + public required string Name { get; set; } + public string? Description { get; set; } + public string? Icon { get; set; } +} diff --git a/src/Services/Product/Product.API/DTOs/ProductDto.cs b/src/Services/Product/Product.API/DTOs/ProductDto.cs new file mode 100644 index 0000000..5e719c0 --- /dev/null +++ b/src/Services/Product/Product.API/DTOs/ProductDto.cs @@ -0,0 +1,45 @@ +namespace Product.API.DTOs; + +public class ProductDto +{ + public int Id { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public string? Icon { get; set; } + public decimal BuyingPrice { get; set; } + public decimal SellingPrice { get; set; } + public int UnitsInStock { get; set; } + public bool IsActive { get; set; } + public bool IsDiscontinued { get; set; } + public int ProductCategoryId { get; set; } + public string? ProductCategoryName { get; set; } + public int? ParentId { get; set; } +} + +public class CreateProductDto +{ + public required string Name { get; set; } + public string? Description { get; set; } + public string? Icon { get; set; } + public decimal BuyingPrice { get; set; } + public decimal SellingPrice { get; set; } + public int UnitsInStock { get; set; } + public bool IsActive { get; set; } + public bool IsDiscontinued { get; set; } + public int ProductCategoryId { get; set; } + public int? ParentId { get; set; } +} + +public class UpdateProductDto +{ + public required string Name { get; set; } + public string? Description { get; set; } + public string? Icon { get; set; } + public decimal BuyingPrice { get; set; } + public decimal SellingPrice { get; set; } + public int UnitsInStock { get; set; } + public bool IsActive { get; set; } + public bool IsDiscontinued { get; set; } + public int ProductCategoryId { get; set; } + public int? ParentId { get; set; } +} diff --git a/src/Services/Product/Product.API/Program.cs b/src/Services/Product/Product.API/Program.cs index 73146ef..60ad6df 100644 --- a/src/Services/Product/Product.API/Program.cs +++ b/src/Services/Product/Product.API/Program.cs @@ -1,4 +1,6 @@ using Product.Infrastructure.Data; +using Product.Infrastructure.Repositories; +using Product.Domain.Interfaces; using Microsoft.EntityFrameworkCore; var builder = WebApplication.CreateBuilder(args); @@ -11,6 +13,8 @@ builder.Services.AddDbContext(options => options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"))); +builder.Services.AddScoped(); + var app = builder.Build(); if (app.Environment.IsDevelopment()) @@ -22,4 +26,11 @@ app.MapControllers(); app.MapHealthChecks("/healthz"); +// Ensure database is created and migrations applied +using (var scope = app.Services.CreateScope()) +{ + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.Migrate(); +} + app.Run(); diff --git a/src/Services/Product/Product.Domain/Entities/Product.cs b/src/Services/Product/Product.Domain/Entities/Product.cs new file mode 100644 index 0000000..8d25f5a --- /dev/null +++ b/src/Services/Product/Product.Domain/Entities/Product.cs @@ -0,0 +1,27 @@ +namespace Product.Domain.Entities; + +public class Product +{ + public int Id { get; set; } + public required string Name { get; set; } + public string? Description { get; set; } + public string? Icon { get; set; } + public decimal BuyingPrice { get; set; } + public decimal SellingPrice { get; set; } + public int UnitsInStock { get; set; } + public bool IsActive { get; set; } + public bool IsDiscontinued { get; set; } + + public int ProductCategoryId { get; set; } + public ProductCategory? ProductCategory { get; set; } + + public int? ParentId { get; set; } + public Product? Parent { get; set; } + + public ICollection Children { get; set; } = []; + + public DateTime CreatedDate { get; set; } + public DateTime UpdatedDate { get; set; } + public string? CreatedBy { get; set; } + public string? UpdatedBy { get; set; } +} diff --git a/src/Services/Product/Product.Domain/Entities/ProductCategory.cs b/src/Services/Product/Product.Domain/Entities/ProductCategory.cs new file mode 100644 index 0000000..96ac616 --- /dev/null +++ b/src/Services/Product/Product.Domain/Entities/ProductCategory.cs @@ -0,0 +1,16 @@ +namespace Product.Domain.Entities; + +public class ProductCategory +{ + public int Id { get; set; } + public required string Name { get; set; } + public string? Description { get; set; } + public string? Icon { get; set; } + + public ICollection Products { get; set; } = []; + + public DateTime CreatedDate { get; set; } + public DateTime UpdatedDate { get; set; } + public string? CreatedBy { get; set; } + public string? UpdatedBy { get; set; } +} diff --git a/src/Services/Product/Product.Domain/Interfaces/IProductRepository.cs b/src/Services/Product/Product.Domain/Interfaces/IProductRepository.cs new file mode 100644 index 0000000..08b819d --- /dev/null +++ b/src/Services/Product/Product.Domain/Interfaces/IProductRepository.cs @@ -0,0 +1,17 @@ +using Product.Domain.Entities; + +namespace Product.Domain.Interfaces; + +public interface IProductRepository +{ + Task> GetAllAsync(); + Task GetByIdAsync(int id); + Task AddAsync(Entities.Product product); + Task UpdateAsync(Entities.Product product); + Task DeleteAsync(int id); + Task> GetAllCategoriesAsync(); + Task GetCategoryByIdAsync(int id); + Task AddCategoryAsync(ProductCategory category); + Task UpdateCategoryAsync(ProductCategory category); + Task DeleteCategoryAsync(int id); +} diff --git a/src/Services/Product/Product.Infrastructure/Data/ProductDbContext.cs b/src/Services/Product/Product.Infrastructure/Data/ProductDbContext.cs index 37cfb81..79ceb81 100644 --- a/src/Services/Product/Product.Infrastructure/Data/ProductDbContext.cs +++ b/src/Services/Product/Product.Infrastructure/Data/ProductDbContext.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using Product.Domain.Entities; namespace Product.Infrastructure.Data; @@ -8,9 +9,40 @@ public ProductDbContext(DbContextOptions options) : base(optio { } + public DbSet Products => Set(); + public DbSet ProductCategories => Set(); + protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); - // TODO: Configure entity mappings migrated from monolith + const string priceDecimalType = "decimal(18,2)"; + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.Name).IsRequired().HasMaxLength(100); + entity.Property(e => e.Description).HasMaxLength(500); + entity.ToTable("ProductCategories"); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.Name).IsRequired().HasMaxLength(100); + entity.HasIndex(e => e.Name); + entity.Property(e => e.Description).HasMaxLength(500); + entity.Property(e => e.Icon).IsUnicode(false).HasMaxLength(256); + entity.Property(e => e.BuyingPrice).HasColumnType(priceDecimalType); + entity.Property(e => e.SellingPrice).HasColumnType(priceDecimalType); + entity.HasOne(e => e.Parent) + .WithMany(e => e.Children) + .HasForeignKey(e => e.ParentId) + .OnDelete(DeleteBehavior.Restrict); + entity.HasOne(e => e.ProductCategory) + .WithMany(e => e.Products) + .HasForeignKey(e => e.ProductCategoryId) + .OnDelete(DeleteBehavior.Restrict); + entity.ToTable("Products"); + }); } } diff --git a/src/Services/Product/Product.Infrastructure/Repositories/ProductRepository.cs b/src/Services/Product/Product.Infrastructure/Repositories/ProductRepository.cs new file mode 100644 index 0000000..142da12 --- /dev/null +++ b/src/Services/Product/Product.Infrastructure/Repositories/ProductRepository.cs @@ -0,0 +1,95 @@ +using Microsoft.EntityFrameworkCore; +using Product.Domain.Interfaces; +using Product.Domain.Entities; +using Product.Infrastructure.Data; + +namespace Product.Infrastructure.Repositories; + +public class ProductRepository : IProductRepository +{ + private readonly ProductDbContext _context; + + public ProductRepository(ProductDbContext context) + { + _context = context; + } + + public async Task> GetAllAsync() + { + return await _context.Products + .Include(p => p.ProductCategory) + .OrderBy(p => p.Name) + .ToListAsync(); + } + + public async Task GetByIdAsync(int id) + { + return await _context.Products + .Include(p => p.ProductCategory) + .FirstOrDefaultAsync(p => p.Id == id); + } + + public async Task AddAsync(Domain.Entities.Product product) + { + product.CreatedDate = DateTime.UtcNow; + product.UpdatedDate = DateTime.UtcNow; + _context.Products.Add(product); + await _context.SaveChangesAsync(); + return product; + } + + public async Task UpdateAsync(Domain.Entities.Product product) + { + product.UpdatedDate = DateTime.UtcNow; + _context.Products.Update(product); + await _context.SaveChangesAsync(); + } + + public async Task DeleteAsync(int id) + { + var product = await _context.Products.FindAsync(id); + if (product != null) + { + _context.Products.Remove(product); + await _context.SaveChangesAsync(); + } + } + + public async Task> GetAllCategoriesAsync() + { + return await _context.ProductCategories + .OrderBy(c => c.Name) + .ToListAsync(); + } + + public async Task GetCategoryByIdAsync(int id) + { + return await _context.ProductCategories.FindAsync(id); + } + + public async Task AddCategoryAsync(ProductCategory category) + { + category.CreatedDate = DateTime.UtcNow; + category.UpdatedDate = DateTime.UtcNow; + _context.ProductCategories.Add(category); + await _context.SaveChangesAsync(); + return category; + } + + public async Task UpdateCategoryAsync(ProductCategory category) + { + category.UpdatedDate = DateTime.UtcNow; + _context.ProductCategories.Update(category); + await _context.SaveChangesAsync(); + } + + public async Task DeleteCategoryAsync(int id) + { + var category = await _context.ProductCategories.FindAsync(id); + if (category != null) + { + _context.ProductCategories.Remove(category); + await _context.SaveChangesAsync(); + } + } +} diff --git a/src/Shared/Shared.Contracts/Events/ProductCreatedEvent.cs b/src/Shared/Shared.Contracts/Events/ProductCreatedEvent.cs new file mode 100644 index 0000000..ad31154 --- /dev/null +++ b/src/Shared/Shared.Contracts/Events/ProductCreatedEvent.cs @@ -0,0 +1,13 @@ +namespace Shared.Contracts.Events; + +/// +/// Integration event published when a new product is created in the Inventory service. +/// +public record ProductCreatedEvent( + int ProductId, + string Name, + decimal SellingPrice, + int UnitsInStock, + int ProductCategoryId, + DateTime CreatedAt +); diff --git a/src/Shared/Shared.Contracts/Events/ProductDeletedEvent.cs b/src/Shared/Shared.Contracts/Events/ProductDeletedEvent.cs new file mode 100644 index 0000000..a35a8e2 --- /dev/null +++ b/src/Shared/Shared.Contracts/Events/ProductDeletedEvent.cs @@ -0,0 +1,10 @@ +namespace Shared.Contracts.Events; + +/// +/// Integration event published when a product is deleted from the Inventory service. +/// +public record ProductDeletedEvent( + int ProductId, + string Name, + DateTime DeletedAt +); diff --git a/src/Shared/Shared.Contracts/Events/ProductUpdatedEvent.cs b/src/Shared/Shared.Contracts/Events/ProductUpdatedEvent.cs new file mode 100644 index 0000000..6bfbd9c --- /dev/null +++ b/src/Shared/Shared.Contracts/Events/ProductUpdatedEvent.cs @@ -0,0 +1,14 @@ +namespace Shared.Contracts.Events; + +/// +/// Integration event published when a product is updated in the Inventory service. +/// +public record ProductUpdatedEvent( + int ProductId, + string Name, + decimal SellingPrice, + int UnitsInStock, + bool IsActive, + bool IsDiscontinued, + DateTime UpdatedAt +); diff --git a/src/Shared/Shared.Contracts/Events/StockChangedEvent.cs b/src/Shared/Shared.Contracts/Events/StockChangedEvent.cs new file mode 100644 index 0000000..e23c8a9 --- /dev/null +++ b/src/Shared/Shared.Contracts/Events/StockChangedEvent.cs @@ -0,0 +1,13 @@ +namespace Shared.Contracts.Events; + +/// +/// Integration event published when product stock levels change. +/// Consumed by Order service for availability checks. +/// +public record StockChangedEvent( + int ProductId, + string ProductName, + int PreviousStock, + int NewStock, + DateTime ChangedAt +);