Phase 3: Product Catalog
Depends on: Phase 2 (Core Entities & Membership) New Endpoints: 20 New Files: 22
Goal
Implement the full product catalog: product CRUD, product categories (hierarchical), suppliers, and product-supplier relationships. After this phase, org admins can manage categories and suppliers org-wide, and store managers can create/update products within their stores.
Endpoints
Organization-Scoped — All Org Members (Read)
| Method | Path | Handler | Description |
|---|---|---|---|
| GET | /orgs/:org_id/categories | CategoryController.list | List categories (paginated) |
| GET | /orgs/:org_id/categories/tree | CategoryController.listTree | List category tree (hierarchical) |
| GET | /orgs/:org_id/categories/:category_id | CategoryController.get | Get single category |
| GET | /orgs/:org_id/suppliers | SupplierController.list | List suppliers (paginated) |
| GET | /orgs/:org_id/suppliers/:supplier_id | SupplierController.get | Get single supplier |
| GET | /orgs/:org_id/suppliers/:supplier_id/products | SupplierController.listProducts | List supplier's products |
Organization-Scoped — Org Admin (Write)
| Method | Path | Handler | Description |
|---|---|---|---|
| POST | /orgs/:org_id/categories | CategoryController.create | Create category |
| PUT | /orgs/:org_id/categories/:category_id | CategoryController.update | Update category |
| DELETE | /orgs/:org_id/categories/:category_id | CategoryController.delete | Delete category |
| POST | /orgs/:org_id/suppliers | SupplierController.create | Create supplier |
| PUT | /orgs/:org_id/suppliers/:supplier_id | SupplierController.update | Update supplier |
| DELETE | /orgs/:org_id/suppliers/:supplier_id | SupplierController.delete | Delete supplier |
| POST | /orgs/:org_id/suppliers/:supplier_id/products | SupplierController.addProduct | Link supplier to product |
| DELETE | /orgs/:org_id/suppliers/:supplier_id/products/:product_id | SupplierController.removeProduct | Unlink supplier |
Store-Scoped — All Store Members (Read)
| Method | Path | Handler | Description |
|---|---|---|---|
| GET | /stores/:store_id/products | ProductController.list | List products (paginated) |
| GET | /stores/:store_id/products/:id | ProductController.get | Get single product |
| GET | /stores/:store_id/products/barcode/:barcode | ProductController.getByBarcode | Barcode lookup |
| GET | /stores/:store_id/products/types | ProductController.getTypes | Get distinct product types |
Store-Scoped — Store Manager+ (Write)
| Method | Path | Handler | Description |
|---|---|---|---|
| POST | /stores/:store_id/products | ProductController.create | Create product |
| PUT | /stores/:store_id/products/:id | ProductController.update | Update product |
Store-Scoped — Store Admin Only
| Method | Path | Handler | Description |
|---|---|---|---|
| DELETE | /stores/:store_id/products/:id | ProductController.delete | Soft-delete product |
SQLDelight Tables & Queries
product_categories table
Key queries:
findByOrgId(org_id, limit, offset)- Paginated listfindTreeByOrgId(org_id)- Full tree (ordered by depth + sort_order)findById(category_id)- Single lookupcountByOrgId(org_id)- Paginationinsert(...)- Createupdate(...)- Updatedelete(category_id)- Delete (soft delete viaactive = false)findChildren(parent_id)- Get child categories
suppliers table
Key queries:
findByOrgId(org_id, limit, offset)- Paginated listfindById(supplier_id)- Single lookupcountByOrgId(org_id)- Paginationinsert(...)- Createupdate(...)- Updatedelete(supplier_id)- Soft delete
product_suppliers junction table
Key queries:
findBySupplierId(supplier_id)- List products for a supplierfindByProductId(product_id)- List suppliers for a productinsert(product_id, supplier_id, supplier_sku, cost, is_preferred)- Linkdelete(product_id, supplier_id)- Unlink
products table
Key queries:
findByStoreId(store_id, limit, offset)- Paginated listfindById(product_id)- Single lookupfindByBarcode(store_id, barcode)- Barcode lookupfindDistinctTypes(store_id)- Distinct product typescountByStoreId(store_id)- PaginationsearchByName(store_id, query, limit, offset)- ILIKE searchinsert(...)- Createupdate(...)- UpdatesoftDelete(product_id)- Setactive = false
Files to Create
Controllers (3)
controller/org/CategoryController.java
@RequestMapping("/orgs/{orgId}/categories")
GET / -> categoryService.list(orgId, page, size)
GET /tree -> categoryService.listTree(orgId)
GET /{categoryId} -> categoryService.get(categoryId)
POST / -> categoryService.create(orgId, request)
PUT /{categoryId} -> categoryService.update(categoryId, request)
DELETE /{categoryId} -> categoryService.delete(categoryId)
controller/org/SupplierController.java
@RequestMapping("/orgs/{orgId}/suppliers")
GET / -> supplierService.list(orgId, page, size)
GET /{supplierId} -> supplierService.get(supplierId)
GET /{supplierId}/products -> supplierService.listProducts(supplierId)
POST / -> supplierService.create(orgId, request)
PUT /{supplierId} -> supplierService.update(supplierId, request)
DELETE /{supplierId} -> supplierService.delete(supplierId)
POST /{supplierId}/products -> supplierService.addProduct(supplierId, request)
DELETE /{supplierId}/products/{productId} -> supplierService.removeProduct(supplierId, productId)
controller/store/ProductController.java
@RequestMapping("/stores/{storeId}/products")
GET / -> productService.list(storeId, page, size)
GET /{id} -> productService.get(id)
GET /barcode/{barcode} -> productService.getByBarcode(storeId, barcode)
GET /types -> productService.getTypes(storeId)
POST / -> productService.create(storeId, request)
PUT /{id} -> productService.update(id, request)
DELETE /{id} -> productService.delete(id)
Services (3)
service/CategoryService.java
- list, listTree, get, create, update, delete
- Tree building: flat list ordered by depth/sort_order, assembled into nested structure
- Validates: no circular parent references, depth <= 5
service/SupplierService.java
- list, get, listProducts, create, update, delete, addProduct, removeProduct
- Validates: supplier belongs to org
service/ProductService.java
- list, get, getByBarcode, getTypes, create, update, delete
- Validates: product belongs to store, barcode unique per store
Repositories (4)
repository/CategoryRepository.java
repository/SupplierRepository.java
repository/ProductSupplierRepository.java
repository/ProductRepository.java
Request DTOs (5)
dto/request/CreateCategoryRequest.java
{ name: @NotBlank String, slug: @NotBlank String, parentId: UUID, sortOrder: int }
dto/request/UpdateCategoryRequest.java
{ name: @NotBlank String, slug: @NotBlank String, parentId: UUID, sortOrder: int, active: boolean }
dto/request/CreateSupplierRequest.java
{ name: @NotBlank String, contactEmail: @Email String, contactPhone: String }
dto/request/UpdateSupplierRequest.java
{ name: @NotBlank String, contactEmail: @Email String, contactPhone: String, active: boolean }
dto/request/AddProductSupplierRequest.java
{ productId: @NotNull UUID, supplierSku: String, cost: BigDecimal, isPreferred: boolean }
dto/request/CreateProductRequest.java
{ barcode: @NotBlank String, name: @NotBlank String, description: String,
price: @NotNull BigDecimal, cost: @NotNull BigDecimal,
over18: boolean, over21: boolean, taxRate: BigDecimal,
stock: int, productType: @NotBlank String, active: boolean }
dto/request/UpdateProductRequest.java
(same fields as CreateProductRequest)
Response DTOs (4)
dto/response/CategoryResponse.java
{ categoryId: UUID, orgId: UUID, name: String, slug: String,
parentId: UUID, isChild: boolean, depth: int, sortOrder: int,
active: boolean, createdAt: Instant, updatedAt: Instant }
dto/response/CategoryTreeResponse.java
{ categoryId: UUID, name: String, slug: String, depth: int,
sortOrder: int, active: boolean, children: List<CategoryTreeResponse> }
dto/response/SupplierResponse.java
{ supplierId: UUID, orgId: UUID, name: String, contactEmail: String,
contactPhone: String, active: boolean, createdAt: Instant, updatedAt: Instant }
dto/response/ProductSupplierResponse.java
{ productId: UUID, supplierId: UUID, supplierSku: String,
cost: BigDecimal, isPreferred: boolean }
dto/response/ProductResponse.java
{ productId: UUID, storeId: UUID, barcode: String, name: String,
description: String, price: BigDecimal, cost: BigDecimal,
requiresSerial: boolean, over18: boolean, over21: boolean,
taxRate: BigDecimal, stock: int, productType: String,
active: boolean, createdAt: Instant, updatedAt: Instant }
Mappers (3)
mapper/CategoryMapper.java
- static toResponse(ProductCategory) -> CategoryResponse
- static toTree(List<ProductCategory>) -> List<CategoryTreeResponse>
mapper/SupplierMapper.java
- static toResponse(Supplier) -> SupplierResponse
- static toProductSupplierResponse(ProductSupplier) -> ProductSupplierResponse
mapper/ProductMapper.java
- static toResponse(Product) -> ProductResponse
Business Rules
- Categories support nesting up to depth 5 (parent_id references another category)
- Category slugs must be unique within an org
- Deleting a parent category should either fail if children exist or cascade-deactivate
- Product barcodes must be unique within a store
- Products reference a product_type string (not an enum - user-definable)
- Soft-delete pattern: set
active = false, filter inactive from default list queries - Supplier-product links are many-to-many via
product_suppliersjunction table - One supplier per product can be marked
is_preferred = true
Acceptance Criteria
- Category CRUD works including hierarchical tree endpoint
- Supplier CRUD works with product linking/unlinking
- Product CRUD works with barcode lookup and type listing
- All list endpoints support pagination
- Product search by name works via ILIKE
- Soft-delete is used for products, categories, and suppliers
- Barcode uniqueness is enforced per store
- Category depth is validated (<= 5 levels)
- Bazel build passes
- Unit tests for CategoryService, SupplierService, ProductService