# Products API Contract

Screen-driven contract for the Product API used by Mobile and Web. Product
list/details are shared reads for Client and Provider; create/edit/delete/
visibility remain Provider-only.

**Base path:** `/api`  
**Swagger UI:** `/api-docs/`  
**OpenAPI source:** [`docs/openapi/paths/products.yaml`](openapi/paths/products.yaml)  
**Bundled spec:** [`public/api-docs/openapi.json`](../public/api-docs/openapi.json)  
**Figma file:** [كم تسوي (`mTRILiWaS8pzLcNnmAUr2k`)](https://www.figma.com/proto/mTRILiWaS8pzLcNnmAUr2k)

Clients must branch on response `key`, not only HTTP status.

### Dev seed (attributes / sizes / colors)

To populate reusable IDs for variant testing (Swagger / Postman / mobile):

```bash
npm run seed:product-attributes
# or: node scripts/seedProductAttributes.js
```

Seeds `Attribute` (`size` / `color`) and linked `AttributeValue` rows (`kind=size` / `kind=color`). Idempotent; prints real IDs and a sample `attributes` + `variants` payload. Does not create products or touch users.

---

## 1. Endpoint overview

| Method | Path | operationId | Purpose |
|--------|------|-------------|---------|
| `GET` | `/api/products` | `getProviderProducts` | Shared list (Client public catalogue / Provider owner inventory) |
| `GET` | `/api/products/similar?productId=` | `getSimilarProducts` | Guest/client similar-products list (paginated) |
| `GET` | `/api/products/details` | `getProviderProductDetails` | Product details (Client or Provider) |
| `POST` | `/api/products` | `createProviderProduct` | Create simple or variant product (multipart) |
| `PATCH` | `/api/products` | `updateProviderProduct` | Partial update of an owned product (multipart) |
| `DELETE` | `/api/products` | `deleteProviderProduct` | Soft-delete an owned product |
| `PATCH` | `/api/products/visibility` | `setProviderProductVisibility` | Set `isVisible` for an owned product |
| `PATCH` | `/api/products/premium` | `markProviderProductPremium` | Mark an accepted, non-premium owned product as premium (active premium subscription required) |

Legacy singular routes (`/product`, `/product/provider`, …) still exist and are **out of scope** for this contract.

`GET /products/similar?productId=` is independent from Product Details. It
returns the full combined paginated list: same subDepartment first, then same
department fallback. Product Details remains a preview capped at 4 items with
`hasMore` and no pagination block.

---

## 2. Figma screen mapping

Swagger UI renders Design badges via `<div class="figma-links">` (same pattern as Pricing / Chat / Departments), not raw URL lists. OpenAPI also stores `x-figma.mobile` / `x-figma.web` (+ related node ids) for the Manual Check / Postman export helpers.

| Endpoint | Screen | Mobile proto | Web proto |
|----------|--------|--------------|-----------|
| `GET /products` (list) | Product list / inventory | [9967:23316](https://www.figma.com/proto/mTRILiWaS8pzLcNnmAUr2k/%D9%83%D9%85-%D8%AA%D8%B3%D9%88%D9%8A?node-id=9967-23316&viewport=176%2C570%2C0.02&t=c4yTtQLlUc9FQir5-1&scaling=scale-down&content-scaling=fixed&starting-point-node-id=9064%3A6087&page-id=0%3A1) | [10766:121182](https://www.figma.com/proto/mTRILiWaS8pzLcNnmAUr2k/%D9%83%D9%85-%D8%AA%D8%B3%D9%88%D9%8A?page-id=10227%3A31854&node-id=10766-121182&viewport=-948%2C0%2C0.12&t=PNIRCKFxGV24peHK-1&scaling=min-zoom&content-scaling=fixed&starting-point-node-id=10449%3A65038) |
| `GET /products/details` | Product details | [9967:23330](https://www.figma.com/proto/mTRILiWaS8pzLcNnmAUr2k/%D9%83%D9%85-%D8%AA%D8%B3%D9%88%D9%8A?node-id=9967-23330&viewport=176%2C570%2C0.02&t=c4yTtQLlUc9FQir5-1&scaling=scale-down&content-scaling=fixed&starting-point-node-id=9064%3A6087&page-id=0%3A1) | [10767:133155](https://www.figma.com/proto/mTRILiWaS8pzLcNnmAUr2k/%D9%83%D9%85-%D8%AA%D8%B3%D9%88%D9%8A?page-id=10227%3A31854&node-id=10767-133155&viewport=-948%2C0%2C0.12&t=PNIRCKFxGV24peHK-1&scaling=min-zoom&content-scaling=fixed&starting-point-node-id=10449%3A65038) |
| `POST /products` (simple) | Simple create | [9967:24058](https://www.figma.com/proto/mTRILiWaS8pzLcNnmAUr2k/%D9%83%D9%85-%D8%AA%D8%B3%D9%88%D9%8A?node-id=9967-24058&viewport=176%2C570%2C0.02&t=c4yTtQLlUc9FQir5-1&scaling=scale-down&content-scaling=fixed&starting-point-node-id=9064%3A6087&page-id=0%3A1) | [10768:137676](https://www.figma.com/proto/mTRILiWaS8pzLcNnmAUr2k/%D9%83%D9%85-%D8%AA%D8%B3%D9%88%D9%8A?page-id=10227%3A31854&node-id=10768-137676&viewport=-948%2C0%2C0.12&t=PNIRCKFxGV24peHK-1&scaling=min-zoom&content-scaling=fixed&starting-point-node-id=10449%3A65038) |
| `POST /products` (variant) | Variant builder flow | [9967:23380](https://www.figma.com/proto/mTRILiWaS8pzLcNnmAUr2k/%D9%83%D9%85-%D8%AA%D8%B3%D9%88%D9%8A?node-id=9967-23380&viewport=176%2C570%2C0.02&t=c4yTtQLlUc9FQir5-1&scaling=scale-down&content-scaling=fixed&starting-point-node-id=9064%3A6087&page-id=0%3A1), [9967:23616](https://www.figma.com/proto/mTRILiWaS8pzLcNnmAUr2k/%D9%83%D9%85-%D8%AA%D8%B3%D9%88%D9%8A?node-id=9967-23616&viewport=176%2C570%2C0.02&t=c4yTtQLlUc9FQir5-1&scaling=scale-down&content-scaling=fixed&starting-point-node-id=9064%3A6087&page-id=0%3A1), [9967:23634](https://www.figma.com/proto/mTRILiWaS8pzLcNnmAUr2k/%D9%83%D9%85-%D8%AA%D8%B3%D9%88%D9%8A?node-id=9967-23634&viewport=176%2C570%2C0.02&t=c4yTtQLlUc9FQir5-1&scaling=scale-down&content-scaling=fixed&starting-point-node-id=9064%3A6087&page-id=0%3A1), [9967:23682](https://www.figma.com/proto/mTRILiWaS8pzLcNnmAUr2k/%D9%83%D9%85-%D8%AA%D8%B3%D9%88%D9%8A?node-id=9967-23682&viewport=176%2C570%2C0.02&t=c4yTtQLlUc9FQir5-1&scaling=scale-down&content-scaling=fixed&starting-point-node-id=9064%3A6087&page-id=0%3A1) | [10767:134557](https://www.figma.com/proto/mTRILiWaS8pzLcNnmAUr2k/%D9%83%D9%85-%D8%AA%D8%B3%D9%88%D9%8A?page-id=10227%3A31854&node-id=10767-134557&viewport=-948%2C0%2C0.12&t=PNIRCKFxGV24peHK-1&scaling=min-zoom&content-scaling=fixed&starting-point-node-id=10449%3A65038), [10767:135919](https://www.figma.com/proto/mTRILiWaS8pzLcNnmAUr2k/%D9%83%D9%85-%D8%AA%D8%B3%D9%88%D9%8A?page-id=10227%3A31854&node-id=10767-135919&viewport=-948%2C0%2C0.12&t=PNIRCKFxGV24peHK-1&scaling=min-zoom&content-scaling=fixed&starting-point-node-id=10449%3A65038), [10767:136553](https://www.figma.com/proto/mTRILiWaS8pzLcNnmAUr2k/%D9%83%D9%85-%D8%AA%D8%B3%D9%88%D9%8A?page-id=10227%3A31854&node-id=10767-136553&viewport=-948%2C0%2C0.12&t=PNIRCKFxGV24peHK-1&scaling=min-zoom&content-scaling=fixed&starting-point-node-id=10449%3A65038) |
| `PATCH /products` (simple) | Simple edit | [9967:24294](https://www.figma.com/proto/mTRILiWaS8pzLcNnmAUr2k/%D9%83%D9%85-%D8%AA%D8%B3%D9%88%D9%8A?node-id=9967-24294&viewport=176%2C570%2C0.02&t=c4yTtQLlUc9FQir5-1&scaling=scale-down&content-scaling=fixed&starting-point-node-id=9064%3A6087&page-id=0%3A1) | Same web variant/edit builder screens as create (adapt to PATCH) |
| `PATCH /products` (variant) | Variant edit | Same mobile variant flow nodes as create | Same web variant flow nodes as create |
| `DELETE /products` | Delete from details / list | Details [9967:23330](https://www.figma.com/proto/mTRILiWaS8pzLcNnmAUr2k/%D9%83%D9%85-%D8%AA%D8%B3%D9%88%D9%8A?node-id=9967-23330&viewport=176%2C570%2C0.02&t=c4yTtQLlUc9FQir5-1&scaling=scale-down&content-scaling=fixed&starting-point-node-id=9064%3A6087&page-id=0%3A1); List [9967:23316](https://www.figma.com/proto/mTRILiWaS8pzLcNnmAUr2k/%D9%83%D9%85-%D8%AA%D8%B3%D9%88%D9%8A?node-id=9967-23316&viewport=176%2C570%2C0.02&t=c4yTtQLlUc9FQir5-1&scaling=scale-down&content-scaling=fixed&starting-point-node-id=9064%3A6087&page-id=0%3A1) | Details [10767:133155](https://www.figma.com/proto/mTRILiWaS8pzLcNnmAUr2k/%D9%83%D9%85-%D8%AA%D8%B3%D9%88%D9%8A?page-id=10227%3A31854&node-id=10767-133155&viewport=-948%2C0%2C0.12&t=PNIRCKFxGV24peHK-1&scaling=min-zoom&content-scaling=fixed&starting-point-node-id=10449%3A65038); List [10766:121182](https://www.figma.com/proto/mTRILiWaS8pzLcNnmAUr2k/%D9%83%D9%85-%D8%AA%D8%B3%D9%88%D9%8A?page-id=10227%3A31854&node-id=10766-121182&viewport=-948%2C0%2C0.12&t=PNIRCKFxGV24peHK-1&scaling=min-zoom&content-scaling=fixed&starting-point-node-id=10449%3A65038) |
| `PATCH /products/visibility` | Toggle visibility from details / list | Same mobile details/list nodes as delete | Same web details/list nodes as delete |
| `PATCH /products/premium` | Mark product as premium | [9967:24058](https://www.figma.com/proto/mTRILiWaS8pzLcNnmAUr2k/%D9%83%D9%85-%D8%AA%D8%B3%D9%88%D9%8A?node-id=9967-24058&viewport=176%2C570%2C0.02&t=c4yTtQLlUc9FQir5-1&scaling=scale-down&content-scaling=fixed&starting-point-node-id=9064%3A6087&page-id=0%3A1) | [10768:137676](https://www.figma.com/proto/mTRILiWaS8pzLcNnmAUr2k/%D9%83%D9%85-%D8%AA%D8%B3%D9%88%D9%8A?page-id=10227%3A31854&node-id=10768-137676&viewport=-948%2C0%2C0.12&t=PNIRCKFxGV24peHK-1&scaling=min-zoom&content-scaling=fixed&starting-point-node-id=10449%3A65038) |

---

## 3. Auth matrix

| Endpoint | Actor | `secretkey` | Bearer | Notes |
|----------|-------|-------------|--------|-------|
| `GET /products` | **Client or Provider** | Required (`SecretKeyAuth`) | `ClientBearerAuth` **or** `ProviderBearerAuth` | Dual-token; response card and database scope depend on role |
| `GET /products/details` | **Client or Provider** | Required (`SecretKeyAuth`) | `ClientBearerAuth` **or** `ProviderBearerAuth` | Dual-token; response shape depends on role |
| `GET /products/similar?productId=` | Guest or Client | Required (`SecretKeyAuth`) | Optional `ClientBearerAuth` | Provider tokens are rejected; valid client token personalizes `isFavorite` |
| All mutation endpoints above | **Provider** only | Required (`SecretKeyAuth`) | `ProviderBearerAuth` | `authorize(PROVIDER)` on the route |
| — | Client / visitor | — | — | Not accepted on these routes |

**Headers**

| Header | Required | Value |
|--------|----------|-------|
| `secretkey` | Yes | App secret (not `x-secret-key`) |
| `Authorization` | Yes | `Bearer <provider JWT>` |
| `lang` | Recommended | `ar` (default) or `en` |

Ownership (`provider`) is taken from the JWT. Body fields such as `provider`, `providerId`, `ownerId`, `status`, `approvalStatus`, `isHidden`, `deletedAt`, `aiPrice`, `aiSuggestedPrice` are **rejected**.

---

## 4. Request field tables

### 4.1 `GET /products` (query)

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `search` | string ≤100 | No | Case-insensitive name search (`name` / `name.ar` / `name.en`) |
| `type` | `simple` \| `variant` | No | `variant` also matches legacy `multi_attribute` |
| `condition` | `new` \| `used` | No | |
| `visibility` | boolean | No | Maps to `isHidden = !visibility` |
| `departmentId` | MongoId | No | Main department filter (`department`) |
| `subdepartmentId` | MongoId | No | Sub department filter (`subDepartment`); combinable with `departmentId` |
| `page` | int ≥1 | No | Default `1` |
| `limit` | int 1–100 | No | Default `20` (not `perPage`) |
| `sort` | enum | No | `createdAt`, `-createdAt`, `price`, `-price`, `quantity`, `-quantity`, `name`, `-name` |

**Role behavior**
- **Provider** — unchanged owner inventory. Hidden products remain readable by
  their owner; deleted products are excluded. The `visibility` filter applies.
- **Client** — accepted, visible, non-deleted products from active providers only.
  The client card returns top-level `isFav` plus
  `actions.{chatButton,chatId,detailsButton,aiPricingButton,isFav}`.
  `chatId` is empty when there is no existing direct chat; list reads never
  create chats. The `visibility` query cannot expose hidden products.

### 4.1b `GET /products/details` (query)

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `productId` | MongoId | Yes | Provider: owned, not-deleted. Client: accepted + visible public product |

**Role behavior**
- **Provider** — owner admin DTO (`ProviderProductDetails`); hidden/pending remain readable.
- **Client** — client product-details DTO including `similarAds` (max 4 +
  `hasMore`) and top-level `isFav` plus
  `actions.{chatButton,chatId,detailsButton,aiPricingButton,isFav}`.

### 4.1c `GET /products/similar?productId=`

| Field | Location | Type | Required | Description |
|-------|----------|------|----------|-------------|
| `productId` | query | MongoId | Yes | Active, accepted, visible product |
| `page` | query | int ≥1 | No | Default `1` |
| `limit` | query | int 1–30 | No | Default `10` |

The response nests `{ items, paginate }` under `data`. `paginate.total` is the
total valid combined result after excluding the current product, hidden/deleted/
rejected records, and products owned by blocked/deleted providers. Missing
taxonomy returns an empty result; unrelated random products are never used.

### 4.2 `POST /products` (multipart/form-data)

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `type` | `simple` \| `variant` | Yes | Product shape |
| `departmentId` | text MongoId | Yes | Existing, active, non-deleted department |
| `subdepartmentId` | text MongoId | Yes | Existing, active, non-deleted and must belong to `departmentId` |
| `name` | text containing `{"ar","en"}` JSON | Yes | Ordinary form-data text part; 2–200 chars per language |
| `description` | text containing `{"ar","en"}` JSON | Yes | Ordinary form-data text part; 1–500 chars per language |
| `condition` | `new` \| `used` | Yes | |
| `pricingMethod` | `manual` \| `ai` | Yes | Source of the selling price |
| `aiPricingRequestId` | MongoId | When `pricingMethod=ai` | Call `POST /pricing-request` first and pass `data.id`; optional for `manual` |
| `quantity` | int ≥1 | Simple only | Forbidden for variant |
| `price` | number >0 | Simple only | Manual price; forbidden for variant |
| `discountType` | `none` \| `percentage` \| `fixed` | Simple optional | Default `none` |
| `discountValue` | number | Simple optional | Validated against price |
| `attributes` | text containing MongoId[] JSON | Variant only | Ordinary form-data text part with selected attribute IDs |
| `variants` | text containing object[] JSON | Variant only | Ordinary form-data text part; see §7 |
| `images` | file[] | Yes | 1–10; JPEG/PNG/WEBP; ≤5MB each; magic-byte checked |
| `video` | file | No | MP4/MOV; ≤20MB. Clients must validate/compress before starting the request. |

New images and video are stored under
`public/assets/uploads/products/{productId}/`. The service generates the
Product ObjectId before upload, but persists only the final filename (basename)
in `images[]` and `video`. Previously stored `products/{productId}/{filename}`
references and legacy provider-scoped filenames remain readable without migration.

### 4.3 `PATCH /products` (multipart + query)

| Field | Location | Description |
|-------|----------|-------------|
| `productId` | query | Required MongoId of owned product |
| Allowlisted body fields | form | Same editable fields as create (all optional); at least one change required |
| `images` | files | When sent, **replaces** the full image list |
| `video` | file | Replaces stored video |
| `removeVideo` | boolean | Removes video reference only |
| `aiPricingRequestId` | form | Call `POST /pricing-request` and pass its response `data.id` to replace the AI suggestion |

Replacement images and video use the same
`public/assets/uploads/products/{productId}/` directory as create, and MongoDB
stores only their final filenames.

Type conversion (simple ↔ variant) requires a complete, valid final state after merge.
If `departmentId` or `subdepartmentId` changes, the service validates the final
pair: both records must exist, be active and non-deleted, and the subdepartment
must belong to the department.

### 4.4 `DELETE /products`

| Field | Location | Required |
|-------|----------|----------|
| `productId` | query | Yes |

### 4.5 `PATCH /products/visibility`

| Field | Location | Required | Description |
|-------|----------|----------|-------------|
| `productId` | query | Yes | Owned, not-deleted product |

**Request:** `productId` only — no body / no `isVisible`.  
**Behavior:** toggles `isHidden` (`true` ↔ `false`).  
**Preconditions:** ownership + not deleted (no `approvalStatus` / subscription checks).  
**Success:** message-only `{ key, message, status }` (no `data`).

### 4.6 `PATCH /products/premium`

| Field | Location | Required | Description |
|-------|----------|----------|-------------|
| `productId` | query | Yes | Owned, accepted (`approvalStatus=accept`), non-premium product |

**Preconditions:** active premium package subscription on the provider.  
**Success:** message-only `{ key, message, status }` (no `data`).

---

## 5. Response envelope

### List (`GET /products`)

Project `ApiResponse` shape — **`paginate` is a sibling of `data`, not nested inside it**.
Provider cards remain unchanged:

```json
{
  "key": "success",
  "message": "My Products",
  "status": 200,
  "data": [
    {
      "id": "665f1c2a9b4e1d0012ab3402",
      "name": "Product",
      "type": "simple",
      "price": 100,
      "priceText": "100 ﷼",
      "primaryImage": "https://host/uploads/...",
      "visibility": true,
      "pricingMethod": "manual"
    }
  ],
  "paginate": {
    "currentPage": 1,
    "lastPage": 1,
    "perPage": 20,
    "total": 1
  }
}
```

Client cards use this role-specific shape:

```json
{
  "id": "665f1c2a9b4e1d0012ab3402",
  "name": "Product",
  "image": "https://host/assets/uploads/products/665f1c2a9b4e1d0012ab3402/image.png",
  "total": 100,
  "currency": "SAR",
  "provider": {
    "id": "665f1c2a9b4e1d0012ab3400",
    "name": "Provider",
    "location": { "title": "", "description": "", "lat": 0, "lng": 0 }
  },
  "isFav": false,
  "isFeatured": false,
  "actions": {
    "chatButton": true,
    "chatId": "",
    "detailsButton": true,
    "aiPricingButton": true,
    "isFav": false
  }
}
```

### Field alias map (brief → live)

| Brief name | Live contract |
|------------|---------------|
| `data.items` + nested `paginate` | `data` array + sibling `paginate` |
| `total` (money) | `price` + `priceText` on list cards |
| `visibility: visible\|hidden` | `visibility: boolean` (`!isHidden`) |
| `image` | `primaryImage` (first of `images`) |
| `perPage` query | `limit` query (`paginate.perPage` in response) |
| price source | `pricingMethod`: `manual` \| `ai` |

### Details / create / update

`data` is a `ProviderProductDetails` DTO (`toProviderProductDetailsDto`): includes localized `name` / `description` (from request `lang`), department/subdepartment, attributes, variants, resolved media URLs, pricing, provider summary, timestamps, and allowed actions. For a variant product, `attributes` groups each Attribute with a deduplicated `values` array containing only the size/color values actually used by its variants (`id`, localized `name`, `kind`, `colorCode`); simple products return `attributes: []`.

Contract hygiene:

- absent semantic values use `null` where the OpenAPI schema is nullable (`rejectionReason`, non-colour `colorCode`, missing AI suggestion/reference);
- the machine-readable `date` remains ASCII `YYYY-MM-DD`, while `time` is localized;
- `quantity` / `quantityText` represent total stock for both simple and variant products;
- every variant returns raw and formatted quantity/price/final-price values and a localized discount label;
- `pricing` is the canonical grouped pricing object. Existing top-level price/pricing aliases remain for compatibility and must not diverge from it;
- `primaryImage` is the first resolved entry in `images`, or an empty string when the product has no image.

The DTO never returns passwords, OTPs, tokens, `deletedAt`, `deletedBy`, or filesystem paths. List cards use the slim `toProviderProductCardDto` shape above.

### Delete

```json
{
  "key": "success",
  "message": "Product deleted successfully",
  "status": 200,
  "data": { "productId": "...", "deleted": true }
}
```

### Visibility

```json
{
  "key": "success",
  "message": "...",
  "status": 200,
  "data": { "productId": "...", "isVisible": false }
}
```

Idempotent: requesting the current visibility still returns `200` with the current state.

---

## 6. Simple product create example

`POST /api/products`  
`Content-Type: multipart/form-data`

All non-file values are regular text parts. Do not mark `name`, `description`,
`attributes`, or `variants` as `application/json` parts; put their JSON text
directly in the form-data value. Swagger displays these four JSON values in
multiline editors, but they remain ordinary text parts on the wire.

**Do not send** `attributes` or `variants` with `type=simple` — that returns
`products.invalidProductPayload`. Empty strings, empty arrays, and Swagger
“Send empty value” leftovers are also rejected for simple products. Omit the
fields entirely.

| Part | Example |
|------|---------|
| `type` | `simple` |
| `departmentId` | `6a65c62109d85c1dbe0e86e2` |
| `subdepartmentId` | `6a65c81b09d85c1dbe0e8732` |
| `name` | `{"ar":"سماعات سوني","en":"Sony headphones"}` |
| `description` | `{"ar":"سماعات بحالة ممتازة","en":"Headphones in excellent condition"}` |
| `condition` | `used` |
| `pricingMethod` | `manual` |
| `quantity` | `4` |
| `price` | `1400` |
| `discountType` | `percentage` |
| `discountValue` | `10` |
| `images` | binary file(s) |
| `attributes` | *(omit / clear)* |
| `variants` | *(omit / clear)* |

Success: HTTP `200` with `{ key, message, status }` only. There is no `data`
field. Moderation starts as wait; admin is notified.
The administration Notification record is persisted before success, while
external push/FCM delivery is deferred so network delivery does not delay the
HTTP response.

---

## 7. Variant product create example

`POST /api/products`  
`Content-Type: multipart/form-data`

**Do not send** top-level `price`, `quantity`, `discountType`, or `discountValue`
with `type=variant` — that returns `products.invalidProductPayload`.

Sample Attribute / AttributeValue IDs:

| Role | Id |
|------|----|
| `attributeId` | `6a64b113dc6f51f903524f5a` |
| size `valueId` (`kind=size`) | `6a64b145dc6f51f903524f73` |
| color `valueId` (`kind=color`) | `6a64b15fdc6f51f903524f8d` |

| Part | Example |
|------|---------|
| `type` | `variant` |
| `departmentId` | `6a65c62109d85c1dbe0e86e2` |
| `subdepartmentId` | `6a65c81b09d85c1dbe0e8732` |
| `name` | `{"ar":"قميص زراعي","en":"Field shirt"}` |
| `description` | `{"ar":"متوفر بألوان ومقاسات","en":"Available in colors and sizes"}` |
| `condition` | `new` |
| `pricingMethod` | `manual` |
| `attributes` | `["6a64b113dc6f51f903524f5a"]` (JSON array string — brackets required) |
| `variants` | See JSON below |
| `images` | 1–10 files |

```json
[
  {
    "attributes": [
      {
        "attributeId": "6a64b113dc6f51f903524f5a",
        "valueId": "6a64b145dc6f51f903524f73"
      }
    ],
    "quantity": 5,
    "price": 120,
    "discountType": "none",
    "discountValue": 0
  },
  {
    "attributes": [
      {
        "attributeId": "6a64b113dc6f51f903524f5a",
        "valueId": "6a64b15fdc6f51f903524f8d"
      }
    ],
    "quantity": 3,
    "price": 130,
    "discountType": "none",
    "discountValue": 0
  }
]
```

Rules:

- Every variant must cover **exactly** the selected `attributes` set.
- Combinations are canonicalized; reordering the same pairs cannot create duplicates.
- Top-level `price` / `quantity` / simple discounts are forbidden for `type=variant`.
- Selling price lives on each variant (`variants[].price`), not on the product root.
- Stored as typed variant subdocuments (Attribute / AttributeValue refs) — not arbitrary JSON blobs.
- Aggregate stock is the sum of variant quantities (`count`).

---

## 8. Update examples

**Simple price change**

```
PATCH /api/products?productId=<id>
multipart: price=1300
```

**Replace images**

```
PATCH /api/products?productId=<id>
multipart: images=<file1>, images=<file2>
```

**Convert to variant** — must send a complete valid variant payload (`attributes` + `variants`) so the merged state validates.

**Ownership failure** — another provider’s `productId` returns the same masked `fail` / product-not-found as a missing id.

---

## 9. Delete behavior

1. Requires owned, non-deleted product.
2. Soft delete: sets `status=deleted`, `deletedAt`, `deletedBy=provider`. Media and variants are **kept**.
3. Blocked by an active Order that still requires payment, fulfillment, delivery, or cancellation handling.
4. Blocked by a scheduled/live Auction, and by a finished bidding cycle that has a winner but is still awaiting payment or delivery.
5. Completed/cancelled Order history, cancelled Auctions, delivered Auction history, normal Cart membership, favorites, ratings, and media do not block deletion.
6. Repeated delete → masked not-found (`fail`).
7. Used from list and details screens via the same endpoint + `productId`.

The guard uses the canonical `Order.product` / `Auction.product` ObjectId and
minimal existence queries. It does not delete or rewrite related records, so
historical references continue to resolve to the soft-deleted Product.

The initial read and final mutation both repeat provider ownership and the
non-deleted predicate. Dependency checks and the final Product update are not a
cross-collection transaction, so a narrow race remains if a new Order/Auction
relationship is created after the checks and before the soft-delete update.

---

## 10. Visibility behavior

1. Body `isVisible` maps to DB `isHidden = !isVisible`.
2. Independent of moderation / workflow status.
3. Hidden products remain visible to the **owner** list/details; public marketplace filters exclude them.
4. Owner-only; cross-provider → masked not-found.
5. Used from list and details screens.

---

## 11. Validation rules (summary)

- express-validator chains in `src/utils/validations/api/product.js`.
- Protected fields rejected: `provider`, `providerId`, `ownerId`, `createdBy`, `deletedBy`, `status`, `approvalStatus`, `rejectionReason`, `aiPrice`, `aiSuggestedPrice`, `isHidden`, `deletedAt`, `isDeleted`, `createdAt`, `updatedAt`.
- Unsafe object keys (`$`, `.`, `__proto__`, …) rejected.
- Taxonomy: active subdepartment must belong to active department.
- Attribute values must exist and match the declared attributes.
- Money values: positive, ≤2 decimal places; discount rules enforce non-negative final price.
- Pagination / sort enums enforced on list.

---

## 12. Upload rules

| Rule | Value |
|------|-------|
| Transport | `multipart/form-data` via `express-fileupload` |
| Images | JPEG / PNG / WEBP; magic bytes; ≤5MB; 1–10 |
| Video | Optional MP4/MOV; ≤20MB; client-side validation/compression before upload |
| Physical storage | `public/assets/uploads/products/{productId}/{filename}` |
| MongoDB value | Filename/basename only; no directory prefix |
| Response URLs | Built in `@returnObj` (`productImagePath`); never raw FS paths |
| Replace semantics | New image upload replaces entire list; new files cleaned up if DB save fails (helper flow) |
| Registry | No media ownership registry (documented limitation) |

---

## 13. Known limitations / blockers

| Item | Status |
|------|--------|
| Simple + variant Figma flows | Supported by current model — **no schema blocker** |
| Nested `data.items` envelope | **Not used**; keep sibling `paginate` |
| `aiPricingRequestId` | Always required on create when `pricingMethod=ai`; optional for `manual`. PricingRequest has **no expiry / product-fingerprint** checks |
| Upstream AI engine | Stub; suggested price copied from PricingRequest |
| Legacy `/product` routes | Still mounted; do not confuse with `/products` |
| Product media paths | New plural-route files use `products/{productId}/` physically, but MongoDB stores basename only; previous full references and legacy provider-scoped files remain readable and are not migrated |
| Tag name | Shared list/details use **Shared Products**; provider mutations remain **Provider Products** |
| Public marketplace list | Authenticated Client tokens receive the public catalogue branch; Provider tokens receive owner inventory |
| Moderation notifications | Create emits `product_submitted_for_review`; approved-content edit emits `approved_product_content_updated`. Notification persistence remains synchronous; external push delivery is deferred. There is **no transaction/outbox** — failed Product writes never create a moderation-review notification |

---

## 14. QA checklist

- [ ] `secretkey` + Client/Provider bearer accepted on shared list/details
- [ ] Provider bearer remains required on every mutation
- [ ] List returns `data` array + `paginate.{currentPage,lastPage,perPage,total}`
- [ ] List cards expose `id`, localized `name`, `type`, `price`, `priceText`, `primaryImage`, `visibility`, `pricingMethod`
- [ ] Client list cards expose `isFav` + `actions.{chatButton,chatId,detailsButton,aiPricingButton,isFav}`
- [ ] List has no `status` / `productId` query filters; no `finalPrice` / `createdAt` on cards
- [ ] List `departmentId` / `subdepartmentId` filter the owner scope; malformed ids → 400
- [ ] `visibility` reflects `!isHidden` (not always true)
- [ ] `GET /products/details?productId=` returns provider owner DTO or client DTO+similarAds; foreign/hidden/deleted masked
- [ ] Create simple with images + `aiPricingRequestId` → 200 without `data`
- [ ] Create variant with attribute matrix → 200 without `data`; duplicate combination → fail
- [ ] Reject body `provider` / `providerId` / `isDeleted`
- [ ] Reject non-image upload / oversized image
- [ ] Create and replacement media are stored physically under `products/{productId}/`
- [ ] MongoDB `images[]` and `video` contain filenames only
- [ ] Legacy provider-scoped product media URLs remain readable
- [ ] PATCH another provider’s product → fail (masked)
- [ ] DELETE soft-deletes; second delete → fail
- [ ] DELETE blocked with active order/auction
- [ ] Visibility toggle from list and details; idempotent
- [ ] Response images are HTTPS URLs, not `/home/...` paths
- [ ] No password / token / OTP / secret in any product response
- [ ] Swagger `/api-docs/` shows list/details under Shared Products and mutations under Provider Products
- [ ] Postman export includes `secretkey` + provider token; **no** token auto-save on product requests

---

## Implementation references

| Layer | Path |
|-------|------|
| Route | `src/routes/api/ProductRoute/ProductRoute.js` |
| Controller | `src/controllers/api/productController.js` |
| Service | `src/helpers/api/Product.js` |
| Validator | `src/utils/validations/api/product.js` |
| DTOs | `src/helpers/returnObject/returnObject.js` (`toProviderProductCardDto`, `toProviderProductDetailsDto`) |
| Contract helpers | `src/helpers/product/providerProductContract.js` |
| Model | `src/models/productModel.js` |
| Tests | `test/providerProducts.test.js` |
