Collections
Collections are fundamental in apparel because products are not only grouped by category — they follow a seasonal and merchandising lifecycle. Collections connect merchandising, markdown strategy, replenishment, promotions, and analytics. If variants are the operational core, collections are the merchandising core.
What "Collection" Means (Business Meaning)
A collection groups products that belong to a merchandising concept. Examples: Summer 2025, Back to School, Denim Essentials, Winter Jackets, Holiday Drop, Brand Capsule.
Collections are not categories.
- Category = product type (jeans, shirts) — structural taxonomy
- Collection = merchandising context (season, campaign) — lifecycle grouping
Collections are not tags.
- Tags = flexible labeling, help filtering
- Collections = strategic grouping, drive decisions
Both distinctions are important. Do not merge them.
Implementation Status
Status: Fully Implemented (MVP) Feature Branch: 022-merchandising-collections
Database Schema
Migration: packages/backend/database/src/migrations/2026-03-21t02-00-00-collection-management.mjs
Enums
collection_season: spring, summer, fall, winter, holiday, capsule, year_round, custom
collection_status: planning, active, ended, archived
TypeScript enums: packages/global/enums/collection.enums.ts — CollectionSeason, CollectionStatus
collection Table
| Column | Type | Notes |
|---|---|---|
| id | uuid PK | gen_random_uuid() |
| business_id | uuid FK | CASCADE delete, multi-tenant scope |
| name | varchar(255) | unique per business (uq_collection_name_business) |
| description | text | optional |
| season | collection_season | enum |
| custom_season_name | varchar(100) | required when season = custom |
| year | integer | e.g. 2026 |
| status | collection_status | default planning |
| launch_at | timestamptz | optional, auto-set on activation if null |
| end_at | timestamptz | optional |
| buyer_notes | text | optional merchandising notes |
| priority | integer | schema-only, no UI yet |
| planned_markdown_start | date | schema-only, no UI yet |
| target_sell_through_pct | numeric(5,2) | schema-only, no UI yet |
| created_by | uuid FK | user who created |
| updated_by | uuid FK | user who last updated |
| created_at | timestamptz | default now |
| updated_at | timestamptz | default now |
Indexes: idx_collection_business (business_id), idx_collection_status (status), idx_collection_season_year (season, year)
product_collection Join Table
| Column | Type | Notes |
|---|---|---|
| id | uuid PK | gen_random_uuid() |
| product_id | uuid FK | CASCADE delete |
| collection_id | uuid FK | CASCADE delete |
| created_by | uuid FK | user who assigned |
| created_at | timestamptz | default now |
Unique constraint: uq_product_collection (product_id, collection_id) — duplicate assignments silently ignored via ON CONFLICT DO NOTHING
Collection Lifecycle (State Machine)
Planning --> Active --> Ended --> Archived
| ^
+------------> Ended ----------------+
Valid transitions:
planning->active(launch)planning->ended(skip activation)active->ended(close)ended->archived(archive — returns stock warning if products have remaining inventory)
Constraints:
- Archived collections are read-only (update returns 400)
- Cannot assign products to archived collections
launch_atis auto-set when activating if not already set
Backend Architecture
Module: apps/backend/src/collections/ — hexagonal architecture
collections/
collections.module.ts
application/
collections.service.ts # Business logic, state machine, validation
domain/
collections-repository.domain.ts # Repository interface (port)
infrastructure/
collections.repository.ts # Kysely implementation (adapter)
interfaces/
collections.controller.ts # HTTP endpoints
dtos/
create-collection.dto.ts
update-collection.dto.ts
update-collection-status.dto.ts
assign-products.dto.ts
query/
paginate-collections.query.ts
API Endpoints
All endpoints require authentication. Guarded with @PermissionResource(PolicyResource.Collection).
| Method | Path | Description | Permission |
|---|---|---|---|
| POST | /collections | Create collection | Create |
| GET | /collections | List collections (paginated, filterable) | Read |
| GET | /collections/:id | Get collection by ID | Read |
| PATCH | /collections/:id | Update collection | Update |
| PATCH | /collections/:id/status | Transition status | Update |
| POST | /collections/:id/products | Assign products (bulk) | Update |
| DELETE | /collections/:id/products/:productId | Remove product | Update |
| GET | /collections/:id/products | List collection products | Read |
| GET | /products/:id/collections | List product's collections | Read |
Query Parameters (GET /collections)
| Param | Type | Description |
|---|---|---|
| businessId | uuid | Required, multi-tenant scope |
| page | number | Default 1 |
| size | number | Default 20 |
| search | string | ilike on name, description |
| status | enum | Filter by collection status |
| season | enum | Filter by season |
| year | number | Filter by year |
| orderBy | string | name, status, launchAt, createdAt, year |
| order | asc/desc | Sort direction |
CASL Permissions
| Role | Permission |
|---|---|
| Administrator | All (CRUD) |
| StoreManager | Create, Read, Update |
| Cashier | Read only |
Frontend (PWA) Implementation
New Files
| File | Purpose |
|---|---|
types/collection.ts | TypeScript interfaces |
services/collectionService.ts | API service (CRUD, assignment, queries) |
hooks/useCollections.ts | TanStack Query hooks with cache invalidation |
components/forms/collection/CollectionForm.tsx | Create/edit dialog (React Hook Form + Zod) |
components/forms/collection/CollectionList.tsx | Table with sort, filter, search, pagination |
components/forms/collection/CollectionsPage.tsx | Container page composing list + form + status dialogs |
components/forms/collection/CollectionDetail.tsx | Detail view with product list management |
components/forms/collection/ProductMultiPicker.tsx | Searchable multi-select for bulk product assignment |
components/forms/collection/ProductCollectionsTab.tsx | Tab in product form showing collection memberships |
Modified Files
| File | Change |
|---|---|
pages/MainPage.tsx | Added routes: /forms/CollectionsPage, /forms/CollectionDetailPage |
components/forms/product/ProductForm.tsx | Added "Collections" tab |
components/discovery/DiscoveryCatalogContent.tsx | Added collection filter dropdown |
services/discoveryService.ts | Added collectionId query param |
i18n/locales/en.json | Collection translation keys |
i18n/locales/es.json | Collection translation keys (Spanish) |
Navigation
The Collections page is accessed via sidebar navigation. The sidebar is driven by Firebase Remote Config (pwaMenu), so a new entry must be added there pointing to /forms/CollectionsPage.
User Flows
Collections Management — Create, edit, filter/search collections. Advance lifecycle status with confirmation dialogs. Archive shows stock warning.
Product Assignment — From collection detail, use "Add Products" to open the multi-picker. Search and select multiple products, confirm to bulk-assign. Remove individual products with the trash button.
Product Detail — The "Collections" tab on any product shows which collections it belongs to, with remove buttons.
Catalog Filtering — In the Discovery/Catalog view, a "Collections" dropdown appears alongside the category filter. Selecting a collection filters products to only those in that collection. Combinable with category, search, and stock filters. Backend uses a subquery through product_collection to filter.
Edge Cases Handled
| Scenario | Behavior |
|---|---|
| Duplicate collection name (same business) | 409 Conflict — validated in service before insert |
| Duplicate product assignment | Silently ignored (ON CONFLICT DO NOTHING), returns assigned/skipped counts |
| Edit archived collection | 400 Bad Request — COLLECTION_ARCHIVED_READ_ONLY |
| Assign to archived collection | 400 Bad Request |
| Planning -> Ended (skip Active) | Allowed — valid transition in state machine |
| Archive with remaining stock | Returns warning with product count and total stock units |
| Deactivated products in collection | Shown with isActive: false badge in product list |
| Products in multiple collections | Fully supported — many-to-many relationship |
| Delete collection with products | CASCADE delete removes product_collection rows |
Phase 2 (Future Enhancements)
These fields exist in the schema but have no UI or business logic yet:
priority— collection priority rankingplanned_markdown_start— planned date for markdown wavestarget_sell_through_pct— target sell-through percentage
Future capabilities:
- Collection lifecycle tracking and timeline visualization
- Assortment planning per collection
- Size curve analytics per collection
- Auto markdown by collection
- Replenishment strategy per collection
- Cross-location allocation
- Campaign linkage
- Collection-level analytics in Metabase (revenue, sell-through rate, margin, aging inventory, markdown impact)