# CatalogService - Service Documentation

> Auto-generated from source code audit on 2026-03-13.

## Overview

**CatalogService** is a product catalog microservice supporting multi-vertical (Retail, F&B, Spa/Salon) polymorphic products and hierarchical categories. It provides CRUD operations for products and categories, scoped per shop (multi-tenant). Products use a type-discriminator pattern (`ProductType` DDD Enumeration) with JSONB `attributes` for type-specific data.

- **Port**: 5016 (Development)
- **Database**: PostgreSQL (`catalog_service` on Neon)
- **Architecture**: Clean Architecture + CQRS (MediatR)
- **Multi-tenancy**: EF Core global query filters (shop-level) + PostgreSQL RLS session variables
- **Auth**: JWT Bearer via IAM IdentityServer OIDC discovery
- **API Version**: v1 (URL segment + `X-Api-Version` header)

---

## API Endpoints

### Products (`ProductsController`)

| Method | Route | Description | Auth | Request | Response |
|--------|-------|-------------|------|---------|----------|
| GET | `/api/v1/products?shopId=&isActive=&type=&categoryId=&page=&pageSize=` | Get products with filtering + pagination | No | Query params | `PagedResult<ProductDto>` |
| GET | `/api/v1/shops/{shopId}/products` | Get shop products (RESTful alias) | No | Path + query params | `PagedResult<ProductDto>` |
| GET | `/api/v1/products/lookup?shopId=&barcode=` | Lookup product by barcode/SKU (POS scanner) | No | Query params | `{ success, data: ProductDto }` or 404 |
| GET | `/api/v1/products/{id}` | Get product by ID | No | Path param | `ProductDto` or 404 |
| POST | `/api/v1/products` | Create product | No | `CreateProductCommand` body | 201 + `Guid` |
| PUT | `/api/v1/products/{id}` | Update product | No | `UpdateProductCommand` body | 204 |
| DELETE | `/api/v1/products/{id}` | Soft-delete (deactivate) product | No | Path param | 204 |

### Categories (`CategoriesController`)

| Method | Route | Description | Auth | Request | Response |
|--------|-------|-------------|------|---------|----------|
| GET | `/api/v1/categories?shopId=&parentId=` | Get categories (hierarchical) | No | Query params | `List<CategoryDto>` |
| GET | `/api/v1/shops/{shopId}/categories` | Get shop categories (RESTful alias) | No | Path + query params | `List<CategoryDto>` |
| POST | `/api/v1/categories` | Create category | No | `CreateCategoryCommand` body | 201 + `Guid` |
| PUT | `/api/v1/categories/{categoryId}` | Update category | No | `UpdateCategoryCommand` body | 200 or 404 |
| DELETE | `/api/v1/categories/{categoryId}` | Soft-delete (deactivate) category | No | Path param | 200 or 404 |

### Health Checks

| Route | Description |
|-------|-------------|
| `/health` | Full health check (includes PostgreSQL) |
| `/health/live` | Liveness probe (app running) |
| `/health/ready` | Readiness probe (PostgreSQL reachable) |

---

## Commands

### CreateProductCommand
- **Input**: `ShopId` (Guid), `Name` (string), `Description` (string?), `Price` (decimal), `Type` (string: Physical/Service/PreparedFood), `Attributes` (Dictionary?), `Sku` (string?), `ImageUrl` (string?), `CategoryId` (Guid?)
- **Returns**: `Guid` (new product ID)
- **Logic**: Resolves `ProductType` from Enumeration, converts attributes dict to `JsonDocument`, creates `Product` aggregate, optionally sets image, saves via repository + UnitOfWork
- **Domain Event**: `ProductCreatedDomainEvent` raised in constructor
- **Validator**: `CreateProductCommandValidator`
  - ShopId: NotEmpty
  - Name: NotEmpty, MaxLength(200)
  - Description: MaxLength(2000) when not null
  - Price: >= 0
  - Type: NotEmpty, must be Physical/Service/PreparedFood
  - Sku: MaxLength(100) when not null
  - ImageUrl: MaxLength(2048) when not null

### UpdateProductCommand
- **Input**: `ProductId` (Guid), `Name` (string), `Description` (string?), `Price` (decimal), `Attributes` (Dictionary?), `ImageUrl` (string?), `CategoryId` (Guid?)
- **Returns**: `bool`
- **Logic**: Loads product by ID (throws `DomainException` if not found), calls `UpdateInfo()`, `SetCategory()`, optionally `UpdateAttributes()` and `UpdateImage()`, saves via repository + UnitOfWork
- **Validator**: `UpdateProductCommandValidator`
  - ProductId: NotEmpty
  - Name: NotEmpty, MaxLength(200)
  - Description: MaxLength(2000) when not null
  - Price: >= 0
  - ImageUrl: MaxLength(2048) when not null

### DeleteProductCommand
- **Input**: `ProductId` (Guid)
- **Returns**: `bool`
- **Logic**: Loads product by ID (throws `DomainException` if not found), calls `Deactivate()` (soft delete), saves via repository + UnitOfWork
- **Validator**: `DeleteProductCommandValidator` - ProductId: NotEmpty

### CreateCategoryCommand
- **Input**: `ShopId` (Guid), `Name` (string), `Description` (string?), `ParentId` (Guid?), `DisplayOrder` (int), `ImageUrl` (string?)
- **Returns**: `Guid` (new category ID)
- **Logic**: Creates `Category` entity, optionally sets image, saves directly via `CatalogContext`
- **Validator**: `CreateCategoryCommandValidator`
  - ShopId: NotEmpty
  - Name: NotEmpty, MaxLength(200)
  - Description: MaxLength(1000) when not null
  - DisplayOrder: >= 0
  - ImageUrl: MaxLength(2048) when not null

### UpdateCategoryCommand
- **Input**: `CategoryId` (Guid), `Name` (string), `Description` (string?), `DisplayOrder` (int), `ImageUrl` (string?)
- **Returns**: `bool`
- **Logic**: Loads category from context, calls `UpdateInfo()`, optionally `UpdateImage()`, saves via context
- **Validator**: `UpdateCategoryCommandValidator`
  - CategoryId: NotEmpty
  - Name: NotEmpty, MaxLength(200)
  - Description: MaxLength(1000) when not null
  - DisplayOrder: >= 0
  - ImageUrl: MaxLength(2048) when not null

### DeleteCategoryCommand
- **Input**: `CategoryId` (Guid)
- **Returns**: `bool`
- **Logic**: Loads category from context, calls `Deactivate()` (soft delete), saves via context
- **Validator**: `DeleteCategoryCommandValidator` - CategoryId: NotEmpty

---

## Queries

### GetProductsQuery
- **Input**: `ShopId` (Guid), `IsActive` (bool?), `Type` (string?), `CategoryId` (Guid?), `Page` (int, default 1), `PageSize` (int, default 20)
- **Returns**: `PagedResult<ProductDto>`
- **Logic**: Filters by ShopId (required), then optionally by IsActive, TypeId (resolved from Enumeration name), CategoryId. Orders by Name. Resolves type name from `Enumeration.GetAll<ProductType>()` and category name from batch lookup. Server-side pagination via Skip/Take.

### GetProductByIdQuery
- **Input**: `ProductId` (Guid)
- **Returns**: `ProductDto?` (null if not found)
- **Logic**: Finds product by ID, resolves type name from Enumeration, resolves category name if CategoryId exists.

### GetProductByBarcodeQuery
- **Input**: `ShopId` (Guid), `Barcode` (string)
- **Returns**: `ProductDto?` (null if not found)
- **Logic**: Uses `IProductRepository.GetByBarcodeOrSkuAsync()` to find active product matching barcode or SKU within shop. Resolves type name from Enumeration.

### GetCategoriesQuery
- **Input**: `ShopId` (Guid), `ParentId` (Guid?)
- **Returns**: `List<CategoryDto>`
- **Logic**: Filters by ShopId. If ParentId provided, returns children; otherwise returns root categories (ParentId == null). Orders by DisplayOrder then Name.

---

## Domain Model

### Product (Aggregate Root)

**Entity**: `CatalogService.Domain.AggregatesModel.ProductAggregate.Product` extends `Entity`, implements `IAggregateRoot`

**Private Fields / Public Getters**:
| Field | Type | Description |
|-------|------|-------------|
| `_shopId` / `ShopId` | `Guid` | Owning shop ID |
| `_name` / `Name` | `string` | Product name |
| `_description` / `Description` | `string?` | Product description |
| `_price` / `Price` | `decimal` | Product price |
| `TypeId` | `int` | ProductType enumeration ID (private set) |
| `_attributes` / `Attributes` | `JsonDocument?` | Type-specific attributes (JSONB) |
| `_imageUrl` / `ImageUrl` | `string?` | Image URL |
| `_sku` / `Sku` | `string?` | Stock Keeping Unit |
| `_barcode` / `Barcode` | `string?` | Barcode (EAN-13, UPC) for POS scanner |
| `_categoryId` / `CategoryId` | `Guid?` | Category FK |
| `_isActive` / `IsActive` | `bool` | Active status (default true) |
| `_createdAt` / `CreatedAt` | `DateTime` | Creation timestamp (UTC) |
| `_updatedAt` / `UpdatedAt` | `DateTime?` | Last update timestamp (UTC) |

**Constructor Validation**: ShopId not empty, Name not blank, Price >= 0, Type not null. Raises `ProductCreatedDomainEvent`.

**Behavior Methods**:
- `UpdateInfo(name, description, price)` - validates name/price
- `UpdateAttributes(JsonDocument?)` - replaces JSONB attributes
- `UpdateImage(string?)` - sets image URL
- `SetCategory(Guid?)` - sets category reference
- `UpdateBarcode(string?)` - sets barcode value
- `UpdateSku(string?)` - sets SKU value
- `Activate()` - throws if already active
- `Deactivate()` - throws if already inactive

### Category (Entity, not Aggregate Root)

**Entity**: `CatalogService.Domain.AggregatesModel.ProductAggregate.Category` extends `Entity`

**Private Fields / Public Getters**:
| Field | Type | Description |
|-------|------|-------------|
| `_shopId` / `ShopId` | `Guid` | Owning shop ID |
| `_name` / `Name` | `string` | Category name |
| `_description` / `Description` | `string?` | Description |
| `_parentId` / `ParentId` | `Guid?` | Parent category (hierarchical) |
| `_displayOrder` / `DisplayOrder` | `int` | Sort order |
| `_imageUrl` / `ImageUrl` | `string?` | Image URL |
| `_isActive` / `IsActive` | `bool` | Active status (default true) |
| `_createdAt` / `CreatedAt` | `DateTime` | Creation timestamp (UTC) |
| `_updatedAt` / `UpdatedAt` | `DateTime?` | Last update timestamp (UTC) |

**Constructor Validation**: ShopId not empty, Name not blank.

**Behavior Methods**:
- `UpdateInfo(name, description, displayOrder)` - validates name
- `UpdateImage(string?)` - sets image URL
- `UpdateParent(Guid?)` - throws if self-referencing
- `Activate()` / `Deactivate()` - toggle active state

### ProductType (DDD Enumeration)

Type-safe enum resolved in-memory (not via EF Core navigation):
| Id | Name | Description |
|----|------|-------------|
| 1 | Physical | Retail products requiring inventory |
| 2 | Service | Spa/Salon services requiring booking |
| 3 | PreparedFood | F&B products requiring kitchen |

### Domain Events

| Event | Trigger |
|-------|---------|
| `ProductCreatedDomainEvent(Product)` | Product constructor |

---

## Database Schema

### Table: `products`

| Column | Type | Nullable | Default | Description |
|--------|------|----------|---------|-------------|
| `id` | uuid | NO | - | PK, app-generated |
| `shop_id` | uuid | NO | - | Owning shop |
| `name` | varchar(255) | NO | - | Product name |
| `description` | varchar(2000) | YES | - | Description |
| `price` | decimal(18,2) | NO | - | Price |
| `type_id` | integer | NO | - | ProductType enum ID |
| `attributes` | jsonb | YES | - | Type-specific attributes |
| `image_url` | varchar(500) | YES | - | Image URL |
| `sku` | varchar(100) | YES | - | Stock Keeping Unit |
| `barcode` | varchar(100) | YES | - | Barcode/EAN-13/UPC |
| `category_id` | uuid | YES | - | Category FK (no DB constraint) |
| `is_active` | boolean | NO | true | Soft delete flag |
| `created_at` | timestamptz | NO | - | Creation time |
| `updated_at` | timestamptz | YES | - | Last update time |

**Indexes**:
- `ix_products_shop_id` (shop_id)
- `ix_products_type_id` (type_id)
- `ix_products_sku` (sku)
- `ix_products_barcode` (barcode)
- `ix_products_is_active` (is_active)
- `ix_products_category_id` (category_id)

### Table: `categories`

| Column | Type | Nullable | Default | Description |
|--------|------|----------|---------|-------------|
| `id` | uuid | NO | - | PK, app-generated |
| `shop_id` | uuid | NO | - | Owning shop |
| `name` | varchar(200) | NO | - | Category name |
| `description` | varchar(1000) | YES | - | Description |
| `parent_id` | uuid | YES | - | Parent category (self-ref) |
| `display_order` | integer | NO | 0 | Sort order |
| `image_url` | varchar(500) | YES | - | Image URL |
| `is_active` | boolean | NO | true | Soft delete flag |
| `created_at` | timestamptz | NO | - | Creation time |
| `updated_at` | timestamptz | YES | - | Last update time |

**Indexes**:
- `ix_categories_shop_id` (shop_id)
- `ix_categories_parent_id` (parent_id)
- `ix_categories_display_order` (display_order)

### Table: `product_types` (seed-only, dropped in PhaseTwo migration)

Initially created with seed data (Physical=1, Service=2, PreparedFood=3) in `InitialCatalog` migration. Dropped in `PhaseTwo` migration — type is now resolved in-memory via `Enumeration` pattern. The `type_id` column in `products` remains as a plain integer (no FK constraint).

### Migrations

| Migration | Date | Changes |
|-----------|------|---------|
| `20260117173734_InitialCatalog` | 2026-01-17 | Create `products`, `categories`, `product_types` tables with indexes and seed data |
| `20260306175523_PhaseTwo` | 2026-03-06 | Drop `product_types` table, add `barcode` + `category_id` to products, add `image_url` to categories, add indexes |

---

## Multi-Tenancy

The service implements shop-level tenant isolation via two layers:

1. **EF Core Global Query Filters**: Both `Product` and `Category` entities have query filters that restrict results to the current shop, extracted from JWT claims (`shop_id` claim) or `X-Shop-Id` header. Bypassed for admin/system roles and service-to-service calls (`X-Service-Call: internal`).

2. **PostgreSQL RLS Session Variables**: `TenantMiddleware` sets `SET LOCAL app.current_shop_id` and `app.current_merchant_id` on the Npgsql connection for defense-in-depth (requires RLS policies configured at DB level).

**Tenant Provider Chain**: `HttpContextTenantProvider` (API layer) -> `CatalogTenantProviderAdapter` -> `ICatalogTenantProvider` (Infrastructure layer, consumed by `CatalogContext`).

---

## MediatR Pipeline

Request flow through behaviors (in order):
1. **LoggingBehavior** - Logs request name, elapsed time, errors (Stopwatch)
2. **ValidatorBehavior** - Runs all FluentValidation validators, throws `ValidationException` on failure
3. **TransactionBehavior** - Wraps Commands in DB transaction (skips Queries by name suffix `"Query"`), uses `ExecutionStrategy` for retry-on-failure

---

## Repository

### IProductRepository

| Method | Returns | Description |
|--------|---------|-------------|
| `Add(Product)` | `Product` | Add new product |
| `Update(Product)` | `void` | Mark product as modified |
| `GetByIdAsync(Guid, CancellationToken)` | `Product?` | Get by ID |
| `GetByShopIdAsync(Guid, CancellationToken)` | `IEnumerable<Product>` | Get all by shop |
| `GetByTypeAsync(Guid, ProductType, CancellationToken)` | `IEnumerable<Product>` | Get by shop + type |
| `GetByBarcodeOrSkuAsync(Guid, string, CancellationToken)` | `Product?` | Lookup active product by barcode or SKU in shop |

**Implementation**: `ProductRepository` uses `CatalogContext`. UnitOfWork exposed via context.

**Note**: Category CRUD handlers use `CatalogContext` directly (no separate `ICategoryRepository`).

---

## DTOs

### ProductDto
`Id`, `ShopId`, `Name`, `Description`, `Price`, `Type` (resolved name), `Attributes` (Dictionary), `ImageUrl`, `Sku`, `Barcode`, `CategoryId`, `CategoryName` (resolved), `IsActive`, `CreatedAt`, `UpdatedAt`

### CategoryDto
`Id`, `ShopId`, `Name`, `Description`, `ParentId`, `DisplayOrder`, `ImageUrl`, `IsActive`, `CreatedAt`, `UpdatedAt`

### PagedResult\<T>
`Items` (IReadOnlyList\<T>), `TotalCount`, `Page`, `PageSize`, `TotalPages` (computed), `HasPrevious` (computed), `HasNext` (computed)

---

## Dependencies (NuGet)

| Package | Version | Purpose |
|---------|---------|---------|
| MediatR | 12.4.1 | CQRS pipeline |
| FluentValidation | 11.11.0 | Command validation |
| FluentValidation.DependencyInjectionExtensions | 11.11.0 | Auto-registration |
| Microsoft.EntityFrameworkCore.Design | 10.0.0 | EF migrations tooling |
| Microsoft.AspNetCore.Authentication.JwtBearer | 10.0.1 | JWT auth |
| Swashbuckle.AspNetCore | 7.2.0 | Swagger/OpenAPI |
| Asp.Versioning.Mvc | 8.1.0 | API versioning |
| Asp.Versioning.Mvc.ApiExplorer | 8.1.0 | Version discovery |
| AspNetCore.HealthChecks.NpgSql | 8.0.2 | PostgreSQL health check |
| AspNetCore.HealthChecks.Redis | 8.0.1 | Redis health check |
| Hellang.Middleware.ProblemDetails | 6.5.1 | RFC 7807 errors |
| Serilog.AspNetCore | 8.0.3 | Structured logging |
| Serilog.Sinks.Console | 6.0.0 | Console output |
| Serilog.Sinks.Seq | 8.0.0 | Seq sink |

**Project References**: CatalogService.Domain, CatalogService.Infrastructure

---

## Configuration

### appsettings.json

| Key | Value/Description |
|-----|-------------------|
| `ConnectionStrings:DefaultConnection` | Neon PostgreSQL connection string |
| `Redis:ConnectionString` | `localhost:6379` |
| `Jwt:Authority` | IAM IdentityServer URL (default `http://localhost:5001`) |
| `Jwt:Secret` | JWT signing key |
| `Jwt:Issuer` | `goodgo-platform` |
| `Jwt:Audience` | `goodgo-services` |
| `Jwt:AccessTokenExpiryMinutes` | 15 |
| `Jwt:RefreshTokenExpiryDays` | 7 |
| `Serilog` | Console output with structured template |

### Environment Variables

| Variable | Description |
|----------|-------------|
| `DATABASE_URL` | Fallback connection string (overrides `DefaultConnection`) |
| `ASPNETCORE_ENVIRONMENT` | `Development` enables Swagger, sensitive data logging |

---

## Tests

### Unit Tests (`tests/CatalogService.UnitTests/`)
- `Domain/ProductAggregateTests.cs` - Tests for Product entity behavior

### Functional Tests (`tests/CatalogService.FunctionalTests/`)
- `CustomWebApplicationFactory.cs` - WebApplicationFactory with InMemory DB
- `Controllers/ProductsControllerTests.cs` - API endpoint tests

---

## Idempotency

`IRequestManager` / `RequestManager` provides duplicate request detection via `ClientRequest` entity (Id, Name, Time). Registered in DI but not currently wired into any command handler.

---

## Notes & Observations

1. **No [Authorize] attributes** on any controller endpoint. Authentication middleware is registered but authorization is not enforced at the controller level. Tenant filtering via global query filters provides shop-level isolation.
2. **Category handlers bypass repository pattern** — they use `CatalogContext` directly instead of through an `ICategoryRepository`.
3. **ProductType table dropped** in PhaseTwo migration. The `type_id` column has no FK constraint; type resolution is done in-memory via `Enumeration.GetAll<ProductType>()`.
4. **JSONB attributes** use a custom `ValueConverter` + `ValueComparer` for EF Core change tracking on `JsonDocument`.
5. **Auto-migration on startup** — `dbContext.Database.MigrateAsync()` runs at app start with error swallowing.
6. **Sample aggregate** (`SampleAggregate/`) exists from the template but is not used by any controller, command, or query.
