# Product Management

**Author**: Josh S. Sakweli, Backend Lead Team  
**Last Updated**: 2026-07-07  
**Version**: v3.0

**Short Description**: The Product Management API provides comprehensive functionality for managing products within shops on the NextGate platform. It supports group buying, installment payment plans, color variations, specifications, digital product file delivery, product preview media (video, PDF, 3D, image), and comprehensive search/filter capabilities with role-based access control.

**Hints**:
- All shop-related endpoints use the prefix `api/v1/e-commerce/shops/{shopId}/products`
- Shop owners and system admins have full product management access
- Public users can only view active products from approved, active shops
- Products have a `productType`: `PHYSICAL` (shipping + delivery confirmation) or `DIGITAL` (instant download after payment)
- Action parameter (`SAVE_DRAFT`/`SAVE_PUBLISH`) determines product status on create/update
- Search supports multi-word queries with enhanced pattern matching across multiple fields
- Pagination is 1-indexed (page parameter starts from 1)
- Products use soft-delete (marked as deleted rather than permanently removed, except drafts)
- Product slugs are unique within each shop scope
- SKUs are auto-generated using shop, category, brand, and sequence information
- Group buying requires a maximum participant count, a group price, and a time limit
- Installment plans are managed separately via the Installment Plan Config endpoints
- Color variations can have individual price adjustments and separate image sets
- Digital products require uploading a file via the Digital File Management endpoint before buyers can download
- Products can have a single preview (video, PDF, 3D model, or image) attached via the Product Preview endpoints — visible publicly before purchase

**All product media (product images/videos, color images, digital files, previews) go through FileThunder — no raw URLs are ever sent by the client**:
1. Client calls `POST api/v1/files/request-upload` with a `context` (`PRODUCT_IMAGE`, `PRODUCT_VIDEO`, `DIGITAL_PRODUCT`, `PRODUCT_PREVIEW_IMAGE`/`_VIDEO`/`_DOCUMENT`) → gets back a presigned upload URL and a `fileId`
2. Client `PUT`s the raw file bytes directly to that URL (not through this API)
3. Client references that `fileId` in the relevant product field (`mediaFileIds`, `colors[].imageFileIds`, `digitalFileId`, preview's `ftFileId`, ...) — the file is still processing (virus scan + variant generation) at this point, so validation only checks Redis-cached ownership/context, not readiness
4. FileThunder finishes processing asynchronously and webhooks back into the product/preview/digital-file "updater" services, which upgrade the stored record to `READY` with resolved `variants` (a map of variant name → CDN URL)
- Responses never return raw `productImages`/`previewUrl` strings — image-bearing fields are `ProductMediaItem` objects (`fileId`, `mediaType`, `status`, `variants`, `mimeType`, `order`) or similarly-shaped preview/file objects, and URLs are assembled fresh on every read via `FileThunderUrlAssembler`
- **Known gap**: color `imageFileIds` are stored as raw file IDs and are *not* validated/resolved through FileThunder like top-level product media — the `colors[].images` field in responses currently echoes back the file ID strings as-is, not resolved variant URLs

---

## Endpoints

## 1. Create Product
**Purpose**: Creates a new product in a shop, supporting group buying, color variations, specifications, and digital product rules.

**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `api/v1/e-commerce/shops/{shopId}/products`

**Access Level**: 🔒 Protected (Requires shop owner or system admin role)

**Authentication**: Bearer Token

**Request Headers**:
| Header | Type | Required | Description |
|--------|------|----------|-------------|
| Authorization | string | Yes | Bearer token |
| Content-Type | string | Yes | application/json |

**Path Parameters**:
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| shopId | UUID | Yes | ID of the shop |

**Query Parameters**:
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| action | ReqAction | Yes | — | `SAVE_DRAFT` or `SAVE_PUBLISH` |

**Request Body Parameters**:
| Parameter | Type | Required | Description | Validation |
|-----------|------|----------|-------------|------------|
| productType | ProductType | Yes | `PHYSICAL` or `DIGITAL` | Required |
| productName | string | Yes | Unique name within shop | Min: 2, Max: 100 chars |
| productDescription | string | Yes | Detailed description | Min: 10, Max: 1000 chars |
| price | decimal | Yes | Selling price | Min: 0.01, max 8 digits + 2 decimal places |
| stockQuantity | integer | Yes | Available stock | Min: 0 |
| categoryId | UUID | Yes | Product category | Must exist and be active |
| comparePrice | decimal | No | Original price for discount display | Must be > price if provided |
| lowStockThreshold | integer | No | Low stock alert threshold | Min: 1, Max: 1000, Default: 5 |
| condition | ProductCondition | No | Product condition | `NEW`, `USED_LIKE_NEW`, `USED_GOOD`, `USED_FAIR`, `REFURBISHED`, `FOR_PARTS` |
| status | ProductStatus | No | Initial status (overridden by `action`) | Default: `ACTIVE` |
| mediaFileIds | array\<UUID\> | Yes | FileThunder file IDs for product images/videos, in display order | Not null; each ID must belong to the caller and have been uploaded with context `PRODUCT_IMAGE` or `PRODUCT_VIDEO` |
| specifications | object | No | Key-value specs | Key max 100 chars, Value max 500 chars |
| colors | array | No | Color variations | See Color object below |
| minOrderQuantity | integer | No | Minimum order qty | Min: 1, Default: 1 |
| maxOrderQuantity | integer | No | Maximum order qty per order | Min: 1, must be ≥ minOrderQuantity |
| groupBuyingEnabled | boolean | No | Enable group buying | Default: false |
| groupMaxSize | integer | No | Maximum group participants | Min: 2, required if groupBuyingEnabled |
| groupPrice | decimal | No | Discounted group price | Must be < price |
| groupTimeLimitHours | integer | No | Group formation time limit | Min: 1, Max: 8760 |
| digitalFileId | UUID | Conditional | FileThunder file ID of the downloadable file | Required when `productType=DIGITAL`; uploaded with context `DIGITAL_PRODUCT` |
| previewFileId | UUID | No | FileThunder file ID of a public preview (DIGITAL only) | Uploaded with context `PRODUCT_PREVIEW_IMAGE`/`_VIDEO`/`_DOCUMENT` |
| previewDownloadable | boolean | No | Whether buyers can download the preview file itself | Default: false |
| downloadExpiryDays | integer | No | Download link expiry (DIGITAL only) | Min: 1, Default: 7 |
| maxDownloadsPerBuyer | integer | No | Download attempts per buyer (DIGITAL only) | Min: 1 |
| maxQuantityForDigital | integer | No | Purchase cap per buyer (DIGITAL only) | Min: 1 |

**Color Object**:
| Field | Type | Required | Validation |
|-------|------|----------|------------|
| name | string | Yes | Max: 50 chars |
| hex | string | Yes | Valid `#RRGGBB` format |
| imageFileIds | array\<UUID\> | No | FileThunder file IDs for this color's images — stored as-is, **not** validated/resolved against FileThunder (see top-of-doc note) |
| priceAdjustment | decimal | No | Min: 0.0, Default: 0 |

**Request JSON Sample (PHYSICAL)**:
```json
{
  "productType": "PHYSICAL",
  "productName": "iPhone 15 Pro Max 256GB",
  "productDescription": "The most advanced iPhone featuring the A17 Pro chip and titanium design.",
  "price": 1199.00,
  "comparePrice": 1299.00,
  "stockQuantity": 25,
  "lowStockThreshold": 5,
  "categoryId": "123e4567-e89b-12d3-a456-426614174000",
  "condition": "NEW",
  "mediaFileIds": [
    "f11e4567-e89b-12d3-a456-426614174111",
    "f22e4567-e89b-12d3-a456-426614174222"
  ],
  "specifications": {
    "Display": "6.7-inch Super Retina XDR OLED",
    "Chip": "A17 Pro"
  },
  "colors": [
    {
      "name": "Natural Titanium",
      "hex": "#F5F5DC",
      "imageFileIds": ["f33e4567-e89b-12d3-a456-426614174333"],
      "priceAdjustment": 0.00
    }
  ],
  "minOrderQuantity": 1,
  "maxOrderQuantity": 3,
  "groupBuyingEnabled": true,
  "groupMaxSize": 50,
  "groupPrice": 1099.00,
  "groupTimeLimitHours": 72
}
```

**Request JSON Sample (DIGITAL)**:
```json
{
  "productType": "DIGITAL",
  "productName": "UI Design Kit Pro",
  "productDescription": "A comprehensive Figma component library with 500+ components.",
  "price": 49.00,
  "stockQuantity": 1000,
  "categoryId": "123e4567-e89b-12d3-a456-426614174000",
  "mediaFileIds": ["f44e4567-e89b-12d3-a456-426614174444"],
  "digitalFileId": "f55e4567-e89b-12d3-a456-426614174555",
  "previewFileId": "f66e4567-e89b-12d3-a456-426614174666",
  "previewDownloadable": false,
  "downloadExpiryDays": 30,
  "maxDownloadsPerBuyer": 5,
  "maxQuantityForDigital": 1
}
```

**Response JSON Sample**:
```json
{
  "success": true,
  "message": "Product created successfully",
  "data": {
    "productId": "456e7890-e89b-12d3-a456-426614174001",
    "productSlug": "iphone-15-pro-max-256gb",
    "productName": "iPhone 15 Pro Max 256GB",
    "status": "ACTIVE",
    "shopSlug": "techstore-pro"
  }
}
```

**Business Rules**:
- Product name must be unique within the shop
- `comparePrice` must be greater than `price`
- `groupPrice` must be less than `price`
- All group buying settings (`groupMaxSize`, `groupPrice`, `groupTimeLimitHours`) required when `groupBuyingEnabled=true`
- `maxOrderQuantity` must be ≥ `minOrderQuantity`
- Digital download fields (`downloadExpiryDays`, `maxDownloadsPerBuyer`, `maxQuantityForDigital`) only apply to `DIGITAL` products
- `mediaFileIds` are resolved from the FileThunder Redis cache at request time — each file must have been requested via `POST api/v1/files/request-upload` (context `PRODUCT_IMAGE`/`PRODUCT_VIDEO`), be owned by the caller, and be at least `PROCESSING` (a still-uploading or expired/never-started file is rejected). Files that finish processing after the product is created are silently upgraded to `READY` in place via a FileThunder webhook
- `DIGITAL` products require `digitalFileId` — creation fails immediately if it's missing
- After creation, add installment plans via [Installment Plan Config](#15-installment-plan-config); digital files and previews can also be (re)managed later via [Digital File Management](#16-digital-file-management) and [Product Preview Management](#17-product-preview-management)

**Error Responses**:
- `400`: Validation errors or business rule violations
- `401`: Authentication required
- `403`: Insufficient permissions
- `404`: Shop or category not found, or a referenced `mediaFileIds`/`digitalFileId`/`previewFileId` was never uploaded (upload expired or never started)
- `409`: Product with same name already exists in shop
- `422`: Field-level validation errors

---

## 2. Update Product
**Purpose**: Updates an existing product. Only provided fields are updated.

**Endpoint**: <span style="background-color: #ffc107; color: black; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">PUT</span> `api/v1/e-commerce/shops/{shopId}/products/{productId}`

**Access Level**: 🔒 Protected (Requires shop owner or system admin role)

**Authentication**: Bearer Token

**Path Parameters**:
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| shopId | UUID | Yes | ID of the shop |
| productId | UUID | Yes | ID of the product |

**Query Parameters**:
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| action | ReqAction | Yes | — | `SAVE_DRAFT` or `SAVE_PUBLISH` |

**Request Body Parameters** (all optional):
| Parameter | Type | Description | Validation |
|-----------|------|-------------|------------|
| productName | string | Updated name | Min: 2, Max: 100 chars |
| productDescription | string | Updated description | Min: 10, Max: 1000 chars |
| price | decimal | Updated selling price | Min: 0.01 |
| comparePrice | decimal | Updated compare price | Must be > price |
| stockQuantity | integer | Updated stock | Min: 0 |
| lowStockThreshold | integer | Updated low stock threshold | Min: 1, Max: 1000 |
| condition | ProductCondition | Updated condition | See enum values |
| status | ProductStatus | Updated status | Overridden by `action` |
| urgencyTag | UrgencyTag | Urgency badge on product | `NONE`, `NEW_ARRIVAL`, `LIMITED_EDITION`, `LIMITED_OFFER`, `FEW_REMAINS` |
| categoryId | UUID | Updated category | Must exist and be active |
| mediaFileIds | array\<UUID\> | Updated list of FileThunder file IDs for product images/videos, in order | Same validation as Create; **merged**, not replaced — see note below |
| specifications | object | Updated specifications | Completely replaces existing |
| colors | array | Updated color variations | Completely replaces existing — see Color object in [Create Product](#1-create-product) (same `imageFileIds` shape) |
| minOrderQuantity | integer | Updated min order qty | Min: 1 |
| maxOrderQuantity | integer | Updated max order qty | Min: 1, must be ≥ min |
| groupBuyingEnabled | boolean | Enable/disable group buying | — |
| groupMaxSize | integer | Updated group max | Min: 2 |
| groupPrice | decimal | Updated group price | Must be < price |
| groupTimeLimitHours | integer | Updated group time limit | Min: 1, Max: 8760 |
| installmentEnabled | boolean | Enable/disable installment feature toggle | — |
| maxQuantityForInstallment | integer | Max qty a buyer can purchase on installment | Min: 1 |
| showStockAvailableToPublic | boolean | Show available stock count publicly | — |
| showSoldCountToPublic | boolean | Show sold count publicly | — |
| clearPreview | boolean | ⚠️ Currently only clears legacy preview fields — does **not** remove an active preview (see note below) | — |
| previewDownloadable | boolean | Allow/disallow viewers from downloading the preview file | — |

**Notes on `mediaFileIds`**:
- Unlike Create, Update **merges** by fileId: any ID already present in the product's stored media that has finished processing (has resolved `variants`) is kept as-is and just re-ordered; any new ID is resolved fresh from the FileThunder Redis cache (same ownership/context checks as Create)
- To remove an image, omit its fileId from the array — there is no separate "delete image" endpoint
- Digital file and preview are **not** updatable through this endpoint — there is no `digitalFileId`/`previewFileId` field here. Manage them via [Digital File Management](#16-digital-file-management) and [Product Preview Management](#17-product-preview-management)

**Note on `clearPreview`**: this flag only nulls the legacy `previewType`/`previewUrl` entity fields, which are already dead (never populated by the current preview pipeline). It does **not** clear `previewFtFileId`/`previewContext`/`previewStatus`/`previewVariants`. To actually remove a product's preview, call `DELETE .../products/{productId}/preview` ([17c](#17c-remove-preview)).

**Response JSON Sample**:
```json
{
  "success": true,
  "message": "Product updated successfully and published",
  "data": {
    "...": "full ProductDetailedResponse — same shape as Get Product Detailed (#6), rebuilt from the saved product"
  }
}
```

**Error Responses**:
- `400`: Invalid update data or validation errors
- `401`: Authentication required
- `403`: Insufficient permissions
- `404`: Shop or product not found, or a referenced `mediaFileIds` entry was never uploaded
- `409`: Updated product name already exists

---

## 3. Publish Product
**Purpose**: Publishes a draft product making it active and publicly available.

**Endpoint**: <span style="background-color: #6f42c1; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">PATCH</span> `api/v1/e-commerce/shops/{shopId}/products/{productId}/publish`

**Access Level**: 🔒 Protected (Requires shop owner or system admin role)

**Authentication**: Bearer Token

**Path Parameters**:
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| shopId | UUID | Yes | ID of the shop |
| productId | UUID | Yes | ID of the draft product |

**Response JSON Sample**:
```json
{
  "success": true,
  "message": "Product 'iPhone 15 Pro Max' published successfully",
  "data": {
    "productId": "456e7890-e89b-12d3-a456-426614174001",
    "productName": "iPhone 15 Pro Max",
    "status": "ACTIVE",
    "publishedAt": "2026-05-19T14:45:00Z"
  }
}
```

**Publishing Requirements**:
- Product name, description, price, stock quantity, category, and at least one image must be present
- Group buying settings complete if enabled
- Installment plans present if installment is enabled

**Error Responses**:
- `400`: Product already published or missing required publish fields
- `401`: Authentication required
- `403`: Insufficient permissions
- `404`: Shop or product not found

---

## 4. Delete Product
**Purpose**: Deletes a product. Draft products are hard-deleted; published products are soft-deleted.

**Endpoint**: <span style="background-color: #dc3545; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">DELETE</span> `api/v1/e-commerce/shops/{shopId}/products/{productId}`

**Access Level**: 🔒 Protected (Requires shop owner or system admin role)

**Authentication**: Bearer Token

**Path Parameters**:
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| shopId | UUID | Yes | ID of the shop |
| productId | UUID | Yes | ID of the product |

**Response JSON Sample (Soft Delete)**:
```json
{
  "success": true,
  "message": "Product 'iPhone 15 Pro Max' has been deleted and will be permanently removed after 30 days",
  "data": {
    "productName": "iPhone 15 Pro Max",
    "productId": "456e7890-e89b-12d3-a456-426614174001",
    "previousStatus": "ACTIVE",
    "deletedAt": "2026-05-19T15:00:00Z",
    "deletionType": "SOFT_DELETE"
  }
}
```

**Response JSON Sample (Hard Delete)**:
```json
{
  "success": true,
  "message": "Draft product 'iPhone 15 Pro Max' has been permanently deleted",
  "data": null
}
```

**Deletion Logic**:
- **Draft products**: Hard delete (permanently removed)
- **Published products**: Soft delete (status → ARCHIVED, 30-day recovery window)

**Error Responses**:
- `401`: Authentication required
- `403`: Insufficient permissions
- `404`: Shop or product not found

---

## 5. Restore Product
**Purpose**: Restores a soft-deleted product back to draft status.

**Endpoint**: <span style="background-color: #6f42c1; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">PATCH</span> `api/v1/e-commerce/shops/{shopId}/products/{productId}/restore`

**Access Level**: 🔒 Protected (Requires shop owner or system admin role)

**Authentication**: Bearer Token

**Path Parameters**:
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| shopId | UUID | Yes | ID of the shop |
| productId | UUID | Yes | ID of the soft-deleted product |

**Response JSON Sample**:
```json
{
  "success": true,
  "message": "Product 'iPhone 15 Pro Max' has been restored successfully",
  "data": {
    "productId": "456e7890-e89b-12d3-a456-426614174001",
    "productName": "iPhone 15 Pro Max",
    "status": "DRAFT",
    "restoredAt": "2026-05-19T15:30:00Z",
    "note": "Product restored as draft. Publish to make it active again."
  }
}
```

**Error Responses**:
- `400`: Product is not deleted
- `401`: Authentication required
- `403`: Insufficient permissions
- `404`: Shop or product not found

---

## 6. Get Product Detailed (Owner/Admin View)
**Purpose**: Retrieves comprehensive product details including all management information.

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `api/v1/e-commerce/shops/{shopId}/products/{productId}/detailed`

**Access Level**: 🔒 Protected (Requires shop owner or system admin role)

**Authentication**: Bearer Token

**Path Parameters**:
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| shopId | UUID | Yes | ID of the shop |
| productId | UUID | Yes | ID of the product |

**Response JSON Sample** (`ProductDetailedResponse`):
```json
{
  "success": true,
  "message": "Product details retrieved successfully",
  "data": {
    "productId": "456e7890-e89b-12d3-a456-426614174001",
    "productName": "iPhone 15 Pro Max 512GB",
    "productType": "PHYSICAL",
    "productSlug": "iphone-15-pro-max-512gb",
    "productDescription": "The most advanced iPhone ever...",
    "productMedia": [
      {
        "fileId": "f11e4567-e89b-12d3-a456-426614174111",
        "mediaType": "IMAGE",
        "status": "READY",
        "variants": {
          "original": "https://cdn.example.com/products/f11e4567/original.jpg",
          "thumb": "https://cdn.example.com/products/f11e4567/thumb.jpg"
        },
        "mimeType": "image/jpeg",
        "order": 0
      }
    ],
    "price": 1199.00,
    "comparePrice": 1299.00,
    "discountPercentage": 7.69,
    "isOnSale": true,
    "stockQuantity": 25,
    "lowStockThreshold": 5,
    "isInStock": true,
    "isLowStock": false,
    "stockInfo": {
      "stockTotal": 25,
      "stockHeld": 2,
      "stockAvailable": 23,
      "soldCount": 140,
      "holdBreakdown": {
        "heldForCheckout": 1,
        "heldForGroupPurchase": 1,
        "heldForInstallment": 0,
        "nearestCheckoutExpiry": "2026-07-08T16:00:00"
      },
      "showStockAvailableToPublic": true,
      "showSoldCountToPublic": true
    },
    "condition": "NEW",
    "status": "ACTIVE",
    "urgencyTag": "NONE",
    "shopId": "123e4567-e89b-12d3-a456-426614174000",
    "shopName": "TechStore Pro",
    "shopSlug": "techstore-pro",
    "categoryId": "789e0123-e89b-12d3-a456-426614174002",
    "categoryName": "Smartphones",
    "createdAt": "2026-05-19T10:30:00",
    "updatedAt": "2026-05-19T14:30:00",
    "createdBy": "111e4567-e89b-12d3-a456-426614174aaa",
    "editedBy": "111e4567-e89b-12d3-a456-426614174aaa",
    "specifications": {
      "Display": "6.7-inch Super Retina XDR OLED",
      "Chip": "A17 Pro"
    },
    "hasSpecifications": true,
    "specificationCount": 2,
    "colors": [
      {
        "name": "Natural Titanium",
        "hex": "#F5F5DC",
        "images": ["f33e4567-e89b-12d3-a456-426614174333"],
        "priceAdjustment": 0.00,
        "finalPrice": 1199.00,
        "hasExtraFee": false,
        "extraFeeReason": null
      }
    ],
    "hasMultipleColors": false,
    "colorCount": 1,
    "priceRange": {
      "minPrice": 1199.00,
      "maxPrice": 1199.00,
      "priceStartsFrom": 1199.00,
      "hasPriceVariations": false
    },
    "orderingLimits": {
      "minOrderQuantity": 1,
      "maxOrderQuantity": 3,
      "canOrderQuantity": 3,
      "maxAllowedQuantity": 3,
      "hasOrderingLimits": true
    },
    "groupBuying": {
      "isEnabled": true,
      "maxGroupSize": 50,
      "groupPrice": 1099.00,
      "groupDiscount": 100.00,
      "groupDiscountPercentage": 8.34,
      "timeLimitHours": 72,
      "canJoinGroup": true
    },
    "installmentOptions": {
      "isEnabled": true,
      "isAvailable": true,
      "downPaymentRequired": true,
      "minDownPaymentPercentage": 20.00,
      "plans": [],
      "eligibilityStatus": "ELIGIBLE",
      "creditCheckRequired": false
    },
    "purchaseOptions": {
      "canBuyNow": true,
      "canJoinGroup": true,
      "canPayInstallment": true,
      "recommendedOption": "GROUP_BUYING",
      "bestDeal": { "option": "GROUP_BUYING", "savings": 100.00, "finalPrice": 1099.00 }
    },
    "preview": {
      "type": null,
      "variants": { "thumb": "https://cdn.example.com/preview/f66e4567/thumb.jpg" },
      "downloadable": false
    }
  }
}
```

**Notes on media/preview in this response**:
- `productMedia` items may be `status: "PROCESSING"` with `variants: null` right after creation — poll this endpoint (or re-fetch) until `status` flips to `"READY"`
- `colors[].images` currently holds the raw FileThunder file ID strings supplied at create/update time, **not** resolved URLs — see the "known gap" note at the top of this doc
- `preview.type` is always `null` here regardless of whether a preview exists — it's wired to a legacy field the current preview pipeline never sets. Use `GET .../products/{productId}/preview` ([17b](#17b-get-preview)) for the authoritative preview `context`/`status`/`variants`

**Error Responses**:
- `401`: Authentication required
- `403`: Insufficient permissions
- `404`: Shop or product not found

---

## 7. Get Shop Products (Management View)
**Purpose**: Retrieves all products for a shop with summary statistics.

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `api/v1/e-commerce/shops/{shopId}/products/all`

**Access Level**: 🔒 Protected (Requires shop owner or system admin role)

**Authentication**: Bearer Token

**Path Parameters**:
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| shopId | UUID | Yes | ID of the shop |

**Response JSON Sample**:
```json
{
  "success": true,
  "message": "Retrieved 47 products from shop: TechStore Pro",
  "data": {
    "shop": {
      "shopId": "123e4567-e89b-12d3-a456-426614174000",
      "shopName": "TechStore Pro",
      "isVerified": true,
      "isMyShop": true
    },
    "summary": {
      "totalProducts": 47,
      "activeProducts": 35,
      "draftProducts": 8,
      "outOfStockProducts": 4,
      "lowStockProducts": 6,
      "productsWithGroupBuying": 18,
      "productsWithInstallments": 25
    },
    "products": [
      {
        "productId": "456e7890-e89b-12d3-a456-426614174001",
        "productName": "iPhone 15 Pro Max 512GB",
        "productType": "PHYSICAL",
        "productSlug": "iphone-15-pro-max-512gb",
        "productPrimaryMedia": {
          "fileId": "f11e4567-e89b-12d3-a456-426614174111",
          "mediaType": "IMAGE",
          "status": "READY",
          "thumbUrl": "https://cdn.example.com/products/f11e4567/thumb.jpg",
          "mimeType": "image/jpeg"
        },
        "price": 1199.00,
        "stockQuantity": 25,
        "status": "ACTIVE",
        "isInStock": true,
        "hasGroupBuying": true,
        "hasInstallments": true,
        "createdAt": "2026-05-19T10:30:00Z"
      }
    ],
    "totalProducts": 47
  }
}
```

**Note**: each product card is a `ProductSummaryResponse` — `productPrimaryMedia` is a `PrimaryMediaResponse` (`fileId`, `mediaType`, `status`, `thumbUrl`, `mimeType`), or `null` if no media was uploaded at all. It resolves to the product's first `IMAGE` item; if the product has no image (e.g. video-only), it falls back to that video's `poster`/`thumb` frame instead — so `mediaType` here can be `VIDEO` even though `thumbUrl` is a still image. `thumbUrl` is only populated once the underlying file's `status` is `READY`; while `PROCESSING`, `productPrimaryMedia` still comes back (so the client can show a placeholder) but `thumbUrl` is `null`. Every response type that surfaces a card thumbnail (`ProductSummaryResponse`, `MarketplaceProductResponse`, cart/wishlist items, installment plan's `ProductBasicInfo`) uses this same shape. Similarly, the accompanying shop logo is always `ShopLogoPrimaryMedia` (`fileId`, `status`, `thumbUrl`, `mimeType`) under the field name `shopLogoMedia` in these same list/card responses — full `ShopMedia` (with the raw `variants` map) is reserved for single-entity detail views only (`ShopResponse`, `ProductPublicResponse`).

**Error Responses**:
- `401`: Authentication required
- `403`: Insufficient permissions
- `404`: Shop not found

---

## 8. Get Shop Products Paginated (Management View)
**Purpose**: Retrieves shop products with pagination for management dashboard.

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `api/v1/e-commerce/shops/{shopId}/products/all-paged`

**Access Level**: 🔒 Protected (Requires shop owner or system admin role)

**Authentication**: Bearer Token

**Path Parameters**:
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| shopId | UUID | Yes | ID of the shop |

**Query Parameters**:
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| page | integer | No | 1 | Page number (1-indexed) |
| size | integer | No | 10 | Items per page (max: 100) |

**Response JSON Sample**:
```json
{
  "success": true,
  "message": "Retrieved 10 products from shop: TechStore Pro (Page 1 of 5)",
  "data": {
    "contents": { "...same structure as /all..." },
    "currentPage": 1,
    "pageSize": 10,
    "totalElements": 47,
    "totalPages": 5,
    "hasNext": true,
    "hasPrevious": false
  }
}
```

**Error Responses**:
- `401`: Authentication required
- `403`: Insufficient permissions
- `404`: Shop not found

---

## 9. Get Public Product by ID
**Purpose**: Retrieves a single active product for public viewing.

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `api/v1/e-commerce/shops/{shopId}/products/{productId}`

**Access Level**: 🌐 Public

**Path Parameters**:
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| shopId | UUID | Yes | Shop must be active and approved |
| productId | UUID | Yes | Product must be active |

**Response JSON Sample** (`ProductPublicResponse`):
```json
{
  "success": true,
  "message": "Product retrieved successfully",
  "data": {
    "productId": "456e7890-e89b-12d3-a456-426614174001",
    "productName": "iPhone 15 Pro Max 512GB",
    "productSlug": "iphone-15-pro-max-512gb",
    "productDescription": "The most advanced iPhone ever...",
    "productMedia": [
      {
        "fileId": "f11e4567-e89b-12d3-a456-426614174111",
        "mediaType": "IMAGE",
        "status": "READY",
        "variants": {
          "original": "https://cdn.example.com/products/f11e4567/original.jpg",
          "thumb": "https://cdn.example.com/products/f11e4567/thumb.jpg"
        },
        "mimeType": "image/jpeg",
        "order": 0
      }
    ],
    "price": 1199.00,
    "comparePrice": 1299.00,
    "discountPercentage": 7.69,
    "isOnSale": true,
    "groupPurchasePrice": 1099.00,
    "isInStock": true,
    "isLowStock": false,
    "stockInfo": {
      "stockAvailable": 23,
      "soldCount": 140
    },
    "condition": "NEW",
    "urgencyTag": "NONE",
    "shopId": "123e4567-e89b-12d3-a456-426614174000",
    "shopName": "TechStore Pro",
    "shopSlug": "techstore-pro",
    "shopLogoMedia": {
      "fileId": "s11e4567-e89b-12d3-a456-426614174001",
      "status": "READY",
      "variants": { "thumb": "https://cdn.example.com/shops/s11e4567/thumb.jpg" },
      "mimeType": "image/png"
    },
    "categoryId": "789e0123-e89b-12d3-a456-426614174002",
    "categoryName": "Smartphones",
    "specifications": { "Display": "6.7-inch OLED" },
    "hasSpecifications": true,
    "colors": [
      {
        "name": "Natural Titanium",
        "hex": "#F5F5DC",
        "images": ["f33e4567-e89b-12d3-a456-426614174333"],
        "priceAdjustment": 0.00,
        "finalPrice": 1199.00
      }
    ],
    "hasMultipleColors": false,
    "priceRange": {
      "minPrice": 1199.00,
      "maxPrice": 1199.00,
      "hasPriceVariations": false
    },
    "groupBuying": {
      "isAvailable": true,
      "maxGroupSize": 50,
      "groupPrice": 1099.00,
      "groupDiscount": 100.00,
      "groupDiscountPercentage": 8.34,
      "timeLimitHours": 72
    },
    "installmentOptions": {
      "isAvailable": true,
      "downPaymentRequired": true,
      "minDownPaymentPercentage": 20.00,
      "plans": [
        {
          "installmentPlanId": "aa1e4567-e89b-12d3-a456-426614174aaa",
          "duration": 6,
          "interval": "MONTHLY",
          "interestRate": 0.00,
          "description": "6-Month Interest-Free"
        }
      ]
    },
    "previewType": null,
    "previewUrl": null,
    "previewDownloadable": true,
    "isInCart": null,
    "cartQuantity": null,
    "isInWishlist": null,
    "wishlistItemId": null,
    "wishlistGroupId": null,
    "wishlistGroupName": null,
    "createdAt": "2026-05-19T10:30:00"
  }
}
```

**Notes**:
- `previewType` and `previewUrl` are wired to legacy fields that the current preview pipeline never populates — they will always be `null`, even when the product has an active, ready preview. There is currently no public field exposing the resolved preview media/variants; `previewDownloadable` is the one real preview field here. To surface preview media publicly, the frontend must call the seller-facing `GET .../preview` endpoint ([17b](#17b-get-preview)) — note that endpoint currently requires shop-owner/admin auth, so a fully public preview-viewing path doesn't exist yet
- `colors[].images` are raw FileThunder file ID strings, not resolved URLs (same gap as the detailed response)
- `isInCart`/`cartQuantity`/`isInWishlist`/`wishlistItemId`/`wishlistGroupId`/`wishlistGroupName` are only populated on this single-product view (not on list responses like #10/#11), and only when the request is authenticated

**Error Responses**:
- `404`: Shop not found/not approved, or product not found/not active

---

## 10. Get Public Shop Products
**Purpose**: Retrieves all active products from a shop for public browsing.

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `api/v1/e-commerce/shops/{shopId}/products/public-view/all`

**Access Level**: 🌐 Public

**Path Parameters**:
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| shopId | UUID | Yes | Shop must be active and approved |

**Response JSON Sample**:
```json
{
  "success": true,
  "message": "Retrieved 23 products from TechStore Pro",
  "data": {
    "shop": {
      "shopId": "123e4567-e89b-12d3-a456-426614174000",
      "shopName": "TechStore Pro",
      "isVerified": true
    },
    "products": [
      {
        "productId": "456e7890-e89b-12d3-a456-426614174001",
        "productName": "iPhone 15 Pro Max",
        "productPrimaryMedia": {
          "fileId": "f11e4567-e89b-12d3-a456-426614174111",
          "mediaType": "IMAGE",
          "status": "READY",
          "thumbUrl": "https://cdn.example.com/products/f11e4567/thumb.jpg",
          "mimeType": "image/jpeg"
        },
        "price": 1199.00,
        "isOnSale": true,
        "isInStock": true,
        "hasGroupBuying": true,
        "hasInstallments": true
      }
    ],
    "totalProducts": 23
  }
}
```

**Error Responses**:
- `404`: Shop not found, not approved, or not active

---

## 11. Get Public Shop Products Paginated
**Purpose**: Retrieves active products from a shop with pagination for public browsing.

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `api/v1/e-commerce/shops/{shopId}/products/public-view/all-paged`

**Access Level**: 🌐 Public

**Path Parameters**:
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| shopId | UUID | Yes | Shop must be active and approved |

**Query Parameters**:
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| page | integer | No | 1 | Page number (1-indexed) |
| size | integer | No | 10 | Items per page (max: 50) |

**Error Responses**:
- `404`: Shop not found, not approved, or not active

---

## 12. Search Products
**Purpose**: Searches products within a shop using multi-word query matching across multiple fields.

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `api/v1/e-commerce/shops/{shopId}/products/search`

**Access Level**: 🌐 Public (Enhanced features for authenticated users)

**Authentication**: Optional Bearer Token

**Path Parameters**:
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| shopId | UUID | Yes | ID of the shop |

**Query Parameters**:
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| q | string | Yes | — | Search query (min: 2, max: 100 chars) |
| status | ProductStatus[] | No | ACTIVE | Statuses to search (owners/admins only for non-ACTIVE) |
| page | integer | No | 1 | Page number |
| size | integer | No | 10 | Items per page (max: 50) |
| sortBy | string | No | relevance | `relevance`, `createdAt`, `updatedAt`, `productName`, `price`, `stockQuantity`, `brand` |
| sortDir | string | No | desc | `asc` or `desc` |

**Search Behavior**:
| Feature | Description |
|---------|-------------|
| Multi-word | Searches for products containing ALL words |
| Partial match | `"iph"` matches `"iPhone"` |
| Cross-field | Matches against name, description, brand, tags, specifications |
| Case-insensitive | `"APPLE"` matches `"apple"` |

**User Access**:
| User Type | Searchable Statuses |
|-----------|-------------------|
| Public / Authenticated | ACTIVE only |
| Shop Owner / Admin | All statuses |

**Response JSON Sample**:
```json
{
  "success": true,
  "message": "Found 12 products matching 'iphone'",
  "data": {
    "contents": {
      "shop": { "shopId": "...", "shopName": "TechStore Pro" },
      "products": [ { "...product summary fields..." } ],
      "totalProducts": 12,
      "searchMetadata": {
        "searchQuery": "iphone",
        "searchedStatuses": ["ACTIVE"],
        "userType": "PUBLIC"
      }
    },
    "currentPage": 1,
    "pageSize": 10,
    "totalElements": 12,
    "totalPages": 2,
    "hasNext": true,
    "hasPrevious": false
  }
}
```

**Error Responses**:
- `400`: Query too short or too long
- `404`: Shop not found or not accessible

---

## 13. Advanced Product Filter
**Purpose**: Filters products using multiple criteria with combined AND/OR logic.

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `api/v1/e-commerce/shops/{shopId}/products/advanced-filter`

**Access Level**: 🌐 Public (Enhanced features for authenticated users)

**Authentication**: Optional Bearer Token

**Path Parameters**:
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| shopId | UUID | Yes | ID of the shop |

**Query Parameters**:
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| minPrice | decimal | No | — | Minimum price |
| maxPrice | decimal | No | — | Maximum price (must be ≥ minPrice) |
| condition | ProductCondition | No | — | Condition filter |
| categoryId | UUID | No | — | Category filter |
| inStock | boolean | No | — | Filter by availability |
| onSale | boolean | No | — | Filter by sale status |
| hasGroupBuying | boolean | No | — | Filter by group buying |
| hasInstallments | boolean | No | — | Filter by installments |
| hasMultipleColors | boolean | No | — | Filter by color variations |
| status | ProductStatus[] | No | ACTIVE | Status filter (owners/admins for non-ACTIVE) |
| page | integer | No | 1 | Page number |
| size | integer | No | 10 | Items per page (max: 50) |
| sortBy | string | No | createdAt | `createdAt`, `updatedAt`, `productName`, `price`, `stockQuantity` |
| sortDir | string | No | desc | `asc` or `desc` |

**Filter Logic**:
| Filter Type | Logic |
|-------------|-------|
| Price range | AND (minPrice AND maxPrice) |
| Feature flags | AND (all must match) |
| Multiple statuses | OR |

**Error Responses**:
- `400`: Invalid filter values or price range error
- `404`: Shop or category not found

---

## 14. Get Public Product by Slug
**Purpose**: Retrieves a single active product by its slug (same response shape as [Get Public Product by ID](#9-get-public-product-by-id)).

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `api/v1/e-commerce/shops/{shopId}/products/find-by-slug/{slug}`

**Access Level**: 🌐 Public

**Path Parameters**:
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| shopId | UUID | Yes | Shop must be active and approved |
| slug | string | Yes | Product slug |

**Error Responses**:
- `404`: Shop not found/not approved, or product not found/not active

---

## 15. Installment Plan Config
**Purpose**: CRUD for installment plans attached to a product. Plans are created separately after the product, and linked to it by `productId`.

**Base URL**: `api/v1/e-commerce/products/{shopId}/{productId}/installment-plans`

**Access Level**: 🔒 Protected (Requires shop owner or system admin role)

**Authentication**: Bearer Token

**Path Parameters (shared)**:
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| shopId | UUID | Yes | ID of the shop |
| productId | UUID | Yes | ID of the product |

---

### 15a. Create Installment Plan
**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `api/v1/e-commerce/products/{shopId}/{productId}/installment-plans`

**Request Body**:
| Parameter | Type | Required | Description | Validation |
|-----------|------|----------|-------------|------------|
| planName | string | Yes | Display name for the plan | Min: 3, Max: 100 chars |
| paymentFrequency | PaymentFrequency | Yes | Payment interval | `DAILY`, `WEEKLY`, `BI_WEEKLY`, `SEMI_MONTHLY`, `MONTHLY`, `QUARTERLY`, `CUSTOM_DAYS` |
| customFrequencyDays | integer | Conditional | Days between payments | Required when `paymentFrequency=CUSTOM_DAYS`, min: 1 |
| numberOfPayments | integer | Yes | Total number of payments | Min: 2, Max: 120 |
| apr | decimal | Yes | Annual percentage rate | Min: 0.0, Max: 36.0, 2 decimal places |
| minDownPaymentPercent | integer | Yes | Minimum down payment % | Min: 10, Max: 50 |
| fulfillmentTiming | FulfillmentTiming | Yes | When to ship | `IMMEDIATE` (ship after down payment), `AFTER_PAYMENT` (layaway — ship after final payment) |
| displayOrder | integer | No | Sort order in UI | Min: 0, Default: 0 |
| isFeatured | boolean | No | Highlight as recommended plan | Default: false |
| isActive | boolean | No | Plan is available to buyers | Default: true |

**Request JSON Sample**:
```json
{
  "planName": "6-Month Interest-Free",
  "paymentFrequency": "MONTHLY",
  "numberOfPayments": 6,
  "apr": 0.00,
  "minDownPaymentPercent": 20,
  "fulfillmentTiming": "IMMEDIATE",
  "displayOrder": 1,
  "isFeatured": true,
  "isActive": true
}
```

**Error Responses**:
- `400`: Validation errors
- `404`: Shop or product not found

---

### 15b. Get All Installment Plans
**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `api/v1/e-commerce/products/{shopId}/{productId}/installment-plans`

Returns a list of all installment plans for the product.

**Error Responses**:
- `404`: Shop or product not found

---

### 15c. Get Installment Plan by ID
**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `api/v1/e-commerce/products/{shopId}/{productId}/installment-plans/{planId}`

**Additional Path Parameter**:
| Parameter | Type | Description |
|-----------|------|-------------|
| planId | UUID | ID of the installment plan |

---

### 15d. Update Installment Plan
**Endpoint**: <span style="background-color: #ffc107; color: black; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">PUT</span> `api/v1/e-commerce/products/{shopId}/{productId}/installment-plans/{planId}`

All fields are optional — only provided fields are updated. Same field structure as Create.

---

### 15e. Delete Installment Plan
**Endpoint**: <span style="background-color: #dc3545; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">DELETE</span> `api/v1/e-commerce/products/{shopId}/{productId}/installment-plans/{planId}`

---

### 15f. Activate / Deactivate Plan
**Activate**: <span style="background-color: #6f42c1; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">PATCH</span> `api/v1/e-commerce/products/{shopId}/{productId}/installment-plans/{planId}/activate`

**Deactivate**: <span style="background-color: #6f42c1; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">PATCH</span> `api/v1/e-commerce/products/{shopId}/{productId}/installment-plans/{planId}/deactivate`

Toggles `isActive` on the plan without changing any other fields.

---

### 15g. Set Featured Plan
**Endpoint**: <span style="background-color: #6f42c1; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">PATCH</span> `api/v1/e-commerce/products/{shopId}/{productId}/installment-plans/{planId}/set-featured`

Marks the specified plan as the featured (recommended) plan for this product.

---

## 16. Digital File Management
**Purpose**: Manages the single downloadable file for a `DIGITAL` product. The client uploads through the generic FileThunder pipeline first (see the file/media note at the top of this doc), then registers the resulting `ftFileId` with the product here. There is exactly **one** file per product — registering a new `ftFileId` replaces the current pending/active file.

**Base URL**: `api/v1/e-commerce/shops/{shopId}/products/{productId}/digital-file`

**Access Level**: 🔒 Protected (Requires shop owner or system admin role)

**Authentication**: Bearer Token

**Path Parameters (shared)**:
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| shopId | UUID | Yes | ID of the shop |
| productId | UUID | Yes | ID of the digital product |

---

### 16a. Register File
**Purpose**: Links a FileThunder file (already uploaded with context `DIGITAL_PRODUCT` via `POST api/v1/files/request-upload`) to this product. The file starts in a "pending" shadow state and is promoted to the live/active file once FileThunder finishes virus-scanning it (webhook-driven) — or immediately, if it was already `READY` by the time this call lands (race-condition-safe).

**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `api/v1/e-commerce/shops/{shopId}/products/{productId}/digital-file/register`

**Request Body**:
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| ftFileId | UUID | Yes | FileThunder file ID from `POST api/v1/files/request-upload` (context `DIGITAL_PRODUCT`), owned by the caller |

**Request JSON Sample**:
```json
{
  "ftFileId": "f55e4567-e89b-12d3-a456-426614174555"
}
```

**Response JSON Sample** (`DigitalFileResponse`):
```json
{
  "success": true,
  "message": "File registered — scanning in progress",
  "data": {
    "productId": "456e7890-e89b-12d3-a456-426614174001",
    "fileName": null,
    "contentType": null,
    "fileSize": null,
    "fileVersion": 0,
    "status": null,
    "uploadedAt": null,
    "pending": {
      "status": "PROCESSING",
      "failureReason": null,
      "uploadedAt": "2026-07-07T10:45:00"
    }
  }
}
```

**Upload Flow**:
1. `POST api/v1/files/request-upload` with `context: "DIGITAL_PRODUCT"` → receive `uploadUrl` and `fileId`
2. `PUT {uploadUrl}` with the raw file bytes (client-to-storage, not through this API)
3. `POST .../digital-file/register` with `{ "ftFileId": fileId }` — do this immediately, you don't need to wait for the upload/scan to finish
4. Poll `GET .../digital-file` until `status` (or `pending.status`) is `READY`; `FAILED` means re-upload and register again

**Notes**:
- The top-level `fileName`/`contentType`/`fileSize`/`status`/`uploadedAt` describe the **active** (already-promoted) file; `pending` describes a file that was just registered and hasn't finished processing/replacing the active one yet. Both can be non-null at once while a replacement is in flight
- Can also happen implicitly during [Create Product](#1-create-product) via the `digitalFileId` field — same underlying registration logic

**Error Responses**:
- `400`: File not found in Redis (upload expired/never started), or file does not belong to the caller
- `401`: Authentication required
- `403`: Insufficient permissions, or product is not `DIGITAL`
- `404`: Shop or product not found

---

### 16b. Get Product File
**Purpose**: Retrieves the current state of the product's digital file (active + any pending replacement).

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `api/v1/e-commerce/shops/{shopId}/products/{productId}/digital-file`

Returns the same `DigitalFileResponse` shape as [16a](#16a-register-file).

**Error Responses**:
- `401`: Authentication required
- `403`: Insufficient permissions
- `404`: Shop/product not found, or no digital file has ever been registered for this product

---

### 16c. Delete File
**Purpose**: Removes the digital file record from the product entirely (both active and pending state).

**Endpoint**: <span style="background-color: #dc3545; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">DELETE</span> `api/v1/e-commerce/shops/{shopId}/products/{productId}/digital-file`

**Response JSON Sample**:
```json
{
  "success": true,
  "message": "Digital file removed",
  "data": null
}
```

**Error Responses**:
- `401`: Authentication required
- `403`: Insufficient permissions
- `404`: Shop/product not found, or no digital file exists for this product

---

## 17. Product Preview Management
**Purpose**: Manages a single preview file per product — a teaser shown to buyers before purchase (works for any `productType`, not just `DIGITAL`). Distinct from the private [Digital File](#16-digital-file-management). Supports VIDEO, PDF, 3D model, and IMAGE previews. Like all product media, previews go through the generic FileThunder upload pipeline (see the note at the top of this doc) — there is no preview-specific presign/confirm flow anymore.

**Base URL**: `api/v1/e-commerce/shops/{shopId}/products/{productId}/preview`

**Access Level**: 🔒 Protected (Requires shop owner or system admin role) — **except** [17d](#17d-get-preview-document-url), which only requires authentication (any buyer)

**Authentication**: Bearer Token

**Path Parameters (shared)**:
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| shopId | UUID | Yes | ID of the shop |
| productId | UUID | Yes | ID of the product (any type — PHYSICAL or DIGITAL) |

---

### 17a. Register Preview
**Purpose**: Links a FileThunder file (already uploaded via `POST api/v1/files/request-upload` with context `PRODUCT_PREVIEW_IMAGE`, `PRODUCT_PREVIEW_VIDEO`, or `PRODUCT_PREVIEW_DOCUMENT`) to this product as its preview. The preview's context/type is inferred server-side from the file's MIME type — there is no client-supplied `previewType` anymore. If the product already has a preview, it's replaced. Processing is async and webhook-driven, with the same immediate-promotion race-condition guard as the digital file.

**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `api/v1/e-commerce/shops/{shopId}/products/{productId}/preview/register`

**Request Body** (`RegisterPreviewRequest`):
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| ftFileId | UUID | Yes | FileThunder file ID, owned by the caller |
| previewDownloadable | boolean | No | Whether buyers can download the raw preview file itself. Default: `false` (view/stream only) |

**Request JSON Sample**:
```json
{
  "ftFileId": "f66e4567-e89b-12d3-a456-426614174666",
  "previewDownloadable": false
}
```

**Response JSON Sample** (`PreviewResponse`):
```json
{
  "success": true,
  "message": "Preview registered — processing in background",
  "data": {
    "previewFtFileId": "f66e4567-e89b-12d3-a456-426614174666",
    "previewContext": "PRODUCT_PREVIEW_VIDEO",
    "previewStatus": "PROCESSING",
    "previewFailureReason": null,
    "previewVariants": null,
    "previewDownloadable": false
  }
}
```

**Upload Flow**:
1. `POST api/v1/files/request-upload` with `context: "PRODUCT_PREVIEW_VIDEO"` (or `_IMAGE`/`_DOCUMENT`) → receive `uploadUrl` and `fileId`
2. `PUT {uploadUrl}` with the raw file bytes (client-to-storage, not through this API)
3. `POST .../preview/register` with `{ "ftFileId": fileId }`
4. Poll `GET .../preview` ([17b](#17b-get-preview)) until `previewStatus` is `READY` — `previewVariants` will then hold the resolved, playable CDN URLs (image/video previews only; documents have no variants — see [17d](#17d-get-preview-document-url))

**Can also happen implicitly** during [Create Product](#1-create-product) via the `previewFileId`/`previewDownloadable` fields (DIGITAL products only there) — same underlying registration logic.

**Error Responses**:
- `400`: File not found in Redis (upload expired/never started), or file does not belong to the caller
- `401`: Authentication required
- `403`: Insufficient permissions
- `404`: Shop or product not found

---

### 17b. Get Preview
**Purpose**: Retrieves the current preview state for the product.

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `api/v1/e-commerce/shops/{shopId}/products/{productId}/preview`

Returns the same `PreviewResponse` shape as [17a](#17a-register-preview). All fields are `null` if no preview has ever been registered.

**Error Responses**:
- `401`: Authentication required
- `403`: Insufficient permissions
- `404`: Shop or product not found

---

### 17c. Remove Preview
**Purpose**: Fully clears the product's preview — `previewFtFileId`, `previewContext`, `previewStatus`, `previewFailureReason`, `previewVariants`, and `previewDownloadable` are all reset. This is the only way to actually clear a preview (the `clearPreview` flag on [Update Product](#2-update-product) does **not** do this — see the note there).

**Endpoint**: <span style="background-color: #dc3545; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">DELETE</span> `api/v1/e-commerce/shops/{shopId}/products/{productId}/preview`

**Response JSON Sample**:
```json
{
  "success": true,
  "message": "Preview removed from product",
  "data": null
}
```

**Error Responses**:
- `401`: Authentication required
- `403`: Insufficient permissions
- `404`: Shop or product not found

---

### 17d. Get Preview Document URL
**Purpose**: For document previews only (`previewContext = PRODUCT_PREVIEW_DOCUMENT`, e.g. a PDF) — generates a short-lived, direct download URL. Documents don't get resolved `variants` like images/videos do, so this is the only way to actually fetch a document preview's bytes. **Access differs from the rest of this section**: any authenticated user can call it (no shop-owner/admin check) — it's meant for buyers evaluating the product.

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `api/v1/e-commerce/shops/{shopId}/products/{productId}/preview/document-url`

**Response JSON Sample** (`PreviewDocumentUrlResponse`):
```json
{
  "success": true,
  "message": "Preview document URL generated — valid for 15 minutes",
  "data": {
    "url": "https://cdn.example.com/preview/f66e4567/sample.pdf?X-Amz-Signature=...",
    "expiresInSeconds": 900
  }
}
```

**Error Responses**:
- `400`: No preview registered, preview is not a document, preview is not `READY` yet, or `previewDownloadable` is `false` for this product
- `401`: Authentication required
- `404`: Product not found

---

## Quick Reference

### Common HTTP Status Codes
| Code | Meaning |
|------|---------|
| `200 OK` | Successful GET/PUT/PATCH |
| `201 Created` | Successful POST (resource created) |
| `400 Bad Request` | Invalid data, validation errors, business rule violations |
| `401 Unauthorized` | Authentication required or invalid token |
| `403 Forbidden` | Insufficient permissions |
| `404 Not Found` | Resource not found or not accessible |
| `409 Conflict` | Duplicate product name or business constraint violation |
| `422 Unprocessable Entity` | Field-level validation errors |
| `500 Internal Server Error` | Server error |

### User Access Levels
| User Type | Product Management | Status Access |
|-----------|-------------------|---------------|
| **Public** | View active products only | ACTIVE only |
| **Authenticated** | View active products only | ACTIVE only |
| **Shop Owner** | Full CRUD on own shop | All statuses |
| **System Admin** | Full CRUD on all shops | All statuses |

### Product Type Fulfillment Flows
| Type | Flow |
|------|------|
| `PHYSICAL` | Payment → `PENDING_SHIPMENT` → Seller ships → Buyer confirms with 6-digit code → Escrow releases → `COMPLETED` |
| `DIGITAL` | Payment → `COMPLETED` immediately → Escrow released → `DigitalDownloadAccess` records created → Buyer downloads |

### Product Status Lifecycle
```
DRAFT → ACTIVE → INACTIVE → ARCHIVED
  ↑                            ↓
  └───────── RESTORE ──────────┘

OUT_OF_STOCK ←→ ACTIVE (automatic based on inventory)
```

| Status | Public Visibility | Available Actions |
|--------|-------------------|-------------------|
| `DRAFT` | Hidden | Edit, Publish, Hard Delete |
| `ACTIVE` | Visible | Edit, Deactivate, Soft Delete |
| `INACTIVE` | Hidden | Edit, Activate, Soft Delete |
| `OUT_OF_STOCK` | Visible (out of stock badge) | Restock (auto-activates) |
| `ARCHIVED` | Hidden | Restore |

### Enums Reference

**ProductType**: `PHYSICAL`, `DIGITAL`

**PreviewType**: `VIDEO`, `PDF`, `THREE_D`, `IMAGE` — legacy enum, no longer populated by the preview pipeline (always `null` in responses). The real preview media type is inferred from FileThunder's `FtMediaContext`: `PRODUCT_PREVIEW_IMAGE`, `PRODUCT_PREVIEW_VIDEO`, `PRODUCT_PREVIEW_DOCUMENT` (exposed as `previewContext` — see [17b](#17b-get-preview))

**FtFileStatus** (file/media processing status — `mediaFileIds`, digital file, and preview all use this): `PENDING`, `UPLOADING`, `UPLOADED`, `SCANNING`, `PROCESSING`, `LIVE_PARTIAL`, `READY`, `FAILED`

**ProductMediaType** (top-level product media only): `IMAGE`, `VIDEO`

**ProductCondition**: `NEW`, `USED_LIKE_NEW`, `USED_GOOD`, `USED_FAIR`, `REFURBISHED`, `FOR_PARTS`

**ReqAction**: `SAVE_DRAFT` (→ DRAFT status), `SAVE_PUBLISH` (→ ACTIVE status)

**UrgencyTag**: `NONE`, `NEW_ARRIVAL`, `LIMITED_EDITION`, `LIMITED_OFFER`, `FEW_REMAINS`

**PaymentFrequency**: `DAILY`, `WEEKLY`, `BI_WEEKLY`, `SEMI_MONTHLY`, `MONTHLY`, `QUARTERLY`, `CUSTOM_DAYS`

**FulfillmentTiming**: `IMMEDIATE` (ship after down payment), `AFTER_PAYMENT` (layaway — ship after final payment)

### SKU Format
`SHP[8-CHAR-UUID]-[CATEGORY-3]-[BRAND-3]-[ATTRIBUTE-3]-[SEQUENCE-4]`

Example: `SHP12345678-ELE-APP-512-0001`

### Data Format Standards
- **Dates**: ISO 8601 (`2026-05-19T14:30:00Z`)
- **Prices**: Decimal with 2 decimal places, stored as BigDecimal
- **UUIDs**: Standard UUID format
- **Pagination**: 1-indexed page parameter
- **Colors**: Hex format `#RRGGBB`
- **Percentages**: Decimal format (`20.00` = 20%)

### Error Response Format
```json
{
  "success": false,
  "message": "Human-readable error message",
  "error": {
    "code": "ERROR_CODE",
    "details": "Detailed information",
    "field": "fieldName (if field-specific)",
    "timestamp": "2026-05-19T14:30:00Z"
  }
}
```

### Product Creation Flow
```
0. POST /api/v1/files/request-upload (context=PRODUCT_IMAGE/PRODUCT_VIDEO, one call per file)
   PUT {uploadUrl} (direct to storage, one per file)
   — repeat for the digital file (context=DIGITAL_PRODUCT) and preview (context=PRODUCT_PREVIEW_*) if needed
   — collect the resulting fileIds; no need to wait for processing to finish

1. POST /shops/{shopId}/products?action=SAVE_DRAFT
   body includes mediaFileIds, colors[].imageFileIds, digitalFileId, previewFileId
   — create the product shell; product/digital-file/preview media all start as PROCESSING
   and flip to READY asynchronously once FileThunder finishes scanning/transcoding

2. POST /products/{shopId}/{productId}/installment-plans
   — add installment plans (if installmentEnabled)

3. (optional, if not supplied at create, or to replace later)
   POST /shops/{shopId}/products/{productId}/digital-file/register  { "ftFileId": ... }
   — attach/replace the private digital file (DIGITAL products only)

4. (optional, if not supplied at create, or to replace later)
   POST /shops/{shopId}/products/{productId}/preview/register  { "ftFileId": ..., "previewDownloadable": false }
   — attach/replace the preview teaser (any product type)

5. PATCH /shops/{shopId}/products/{productId}/publish
   — publish when ready
```

### Preview vs Digital File
| Aspect | Preview | Digital File |
|--------|---------|---------------|
| **Who sees it** | Everyone (before purchase) | Buyers only (after payment) |
| **Upload context** | `PRODUCT_PREVIEW_IMAGE`/`_VIDEO`/`_DOCUMENT` | `DIGITAL_PRODUCT` |
| **Access** | `previewDownloadable` gates raw download; documents get a 15-min presigned URL via [17d](#17d-get-preview-document-url) | Buyer-only download, expires per `downloadExpiryDays` (see order/download endpoints) |
| **Count** | One per product | One per product |
| **Products** | PHYSICAL or DIGITAL | DIGITAL only |
| **Registration endpoint** | `POST .../preview/register` | `POST .../digital-file/register` |
| **Purpose** | Teaser/sample before buying | Actual purchased content |