From 42cf29bc4eb756cdf716fccbe54905e0a2e0e752 Mon Sep 17 00:00:00 2001
From: Alireza Hassani
Date: Thu, 3 Sep 2026 15:26:23 +0330
Subject: [PATCH] Add Torob Product API v3 for store-module sites with a store
setting to enable it.
Co-authored-by: Cursor
---
docs/PROJECT_CONTEXT.md | 6 +-
docs/website-api/AI_PROMPT.md | 1 +
...eshkee-Website-API.postman_collection.json | 31 ++
docs/website-api/index.html | 11 +
docs/website-api/openapi.json | 109 +++++
scripts/websites-agent/README.md | 1 +
scripts/websites-agent/patch-nginx-torob.sh | 58 +++
scripts/websites-agent/provision.sh | 16 +
src/app.module.ts | 2 +
src/arvan-dns/arvan-dns.service.ts | 50 +-
.../business-settings.service.ts | 1 +
.../business-settings.types.ts | 3 +
.../business-settings.util.ts | 5 +
.../dto/update-business-settings.dto.ts | 4 +
src/cloudflare-dns/cloudflare-dns.service.ts | 48 +-
src/mail-dns/mail-dns.ts | 4 +-
src/products/products.service.ts | 4 +-
src/torob/dto/torob-products-query.dto.ts | 24 +
src/torob/torob-token.guard.ts | 47 ++
src/torob/torob-token.ts | 68 +++
src/torob/torob.constants.ts | 14 +
src/torob/torob.controller.ts | 16 +
src/torob/torob.module.ts | 12 +
src/torob/torob.service.ts | 455 ++++++++++++++++++
src/website-docs/static/AI_PROMPT.md | 1 +
...eshkee-Website-API.postman_collection.json | 31 ++
src/website-docs/static/index.html | 11 +
src/website-docs/static/openapi.json | 109 +++++
28 files changed, 1080 insertions(+), 62 deletions(-)
create mode 100755 scripts/websites-agent/patch-nginx-torob.sh
create mode 100644 src/torob/dto/torob-products-query.dto.ts
create mode 100644 src/torob/torob-token.guard.ts
create mode 100644 src/torob/torob-token.ts
create mode 100644 src/torob/torob.constants.ts
create mode 100644 src/torob/torob.controller.ts
create mode 100644 src/torob/torob.module.ts
create mode 100644 src/torob/torob.service.ts
diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md
index 3e958ff..43eaa4c 100644
--- a/docs/PROJECT_CONTEXT.md
+++ b/docs/PROJECT_CONTEXT.md
@@ -1,7 +1,7 @@
# Meshkee CMS API — Project Context
> Living reference for developers and AI assistants working on this codebase.
-> Last updated: August 21, 2026
+> Last updated: September 3, 2026
## What This Project Is
@@ -272,6 +272,7 @@ All routes are prefixed with `/api/v1`.
| GET | `/tenants/:host/sitemap-workshops.xml` | Published workshops `/workshops/{slug}` |
| GET | `/tenants/:host/sitemap-user-products.xml` | Published user products `/user-products/{slug}` (module `customer_products`) |
| GET | `/tenants/:host/robots.txt` | robots.txt pointing to apex `/sitemap.xml` |
+| POST | `/tenants/:host/torob_api/v3/products` | Torob Product API v3 (JWT). Requires store module + `settings.store.torobEnabled`. Nginx: `POST https://{host}/torob_api/v3/products` |
| GET | `/tenants/:host/categories/by-id/:categoryId` | Public category by id (for category landing pages) |
| GET | `/tenants/:host/categories/by-slug/:slug` | Public category by CMS slug |
| GET | `/tenants/:host/products/by-id/:productId` | Public product detail by id (for `{id}/{nameFaSlug}` storefront routes) |
@@ -643,7 +644,7 @@ See `.env.example` for the full list. Key groups:
- Multi-tenant auth (register, login, passwordless OTP login, reset password via SMS, profile)
- Super admin: users, businesses, domains, system business categories
- Super admin add/update domain upserts tenant DNS via ArvanCloud or Cloudflare (`@` ANAME/CNAME, `www`/`business`/`customer`/`api` CNAMEs; Cloudflare DNS-only)
-- Super admin Websites ⋯ **Email DNS** upserts Stalwart mail records (mail A `185.214.101.41`, MX `mail.meshkee.com`, SPF, two DKIM TXT, autoconfig/autodiscover) on Arvan or Cloudflare
+- Super admin Websites ⋯ **Email DNS** upserts Stalwart mail records (mail A `185.214.101.41`, MX `mail.meshkee.com`, SPF, two DKIM TXT, autoconfig/autodiscover) on Arvan or Cloudflare; removes extra MX, `dkim._domainkey`, and `_dmarc`
- Super admin: selective migrate-from-old + purge-data (portfolio categories + portfolios; oversized images resized to max 1280×1280; purge removes portfolios + images)
- Business team management
- Media upload (S3 + Sharp)
@@ -651,6 +652,7 @@ See `.env.example` for the full list. Key groups:
- Products CRUD
- Product variation values (which options a product offers)
- Store items / product variants (price, stock, SKU)
+- Torob Product API v3 (`POST /tenants/:host/torob_api/v3/products`) for store-module websites; nginx proxies `/torob_api/v3/products` on the shop apex
- Shopping cart + checkout + orders (customer + admin)
- Online e-payment (Mellat + ZarinPal; stubs for SEP / Snapp Pay / DigiPay)
- Product technical info
diff --git a/docs/website-api/AI_PROMPT.md b/docs/website-api/AI_PROMPT.md
index aa97bd7..3531a54 100644
--- a/docs/website-api/AI_PROMPT.md
+++ b/docs/website-api/AI_PROMPT.md
@@ -28,6 +28,7 @@ You are building a **Meshkee business website (storefront)**. You must use the M
6. Do not call dashboard/CMS routes (`/businesses/.../products` write APIs, media upload, domain-admin, etc.).
7. **Partner SMS** (`POST /public/sms/send`) is for external partner backends with an issued `X-Api-Key` only — not for normal storefront UI. See https://api.meshkee.com/docs/website/SMS.md
8. **Technical details (labels + values):** Product/user-product detail responses may include `technicalValues` with **values only** (`fieldId` + `textValue` / `optionId` / `optionIds` — **no field labels**). To render a label→value specs table you **must** call the matching `.../technical-info` endpoint and join `form.fields[].id` ↔ `values[].fieldId`. Never invent a separate “variation fields” or “category fields” public route — those do not exist on the website API.
+9. **Torob:** Do **not** add a Next.js route for `/torob_api`. Meshkee nginx on the store apex proxies `POST /torob_api/v3/products` to the API. Only businesses with the **store** module **and** Store settings → Torob switch on return products (otherwise 404). Storefront UI must not call this endpoint.
### Typical bootstrap sequence
1. `GET /tenants/{domain}` → branding + `businessId` + `specialProductsSource` (`product` or `store_item`)
diff --git a/docs/website-api/Meshkee-Website-API.postman_collection.json b/docs/website-api/Meshkee-Website-API.postman_collection.json
index 9283e6b..c153120 100644
--- a/docs/website-api/Meshkee-Website-API.postman_collection.json
+++ b/docs/website-api/Meshkee-Website-API.postman_collection.json
@@ -1487,6 +1487,37 @@
}
]
},
+ {
+ "name": "Torob",
+ "item": [
+ {
+ "name": "Product API v3 (store module)",
+ "request": {
+ "method": "POST",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "X-Torob-Token",
+ "value": "{{torobJwt}}"
+ },
+ {
+ "key": "X-Torob-Token-Version",
+ "value": "1"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"page\": 1,\n \"sort\": \"date_added_desc\"\n}"
+ },
+ "url": "{{baseUrl}}/tenants/{{domain}}/torob_api/v3/products",
+ "description": "Torob-only. 404 if the tenant does not have the store module. Prefer the shop-domain URL POST https://{domain}/torob_api/v3/products after nginx is patched."
+ }
+ }
+ ]
+ },
{
"name": "Homepage",
"item": [
diff --git a/docs/website-api/index.html b/docs/website-api/index.html
index e2cc549..b62d41a 100644
--- a/docs/website-api/index.html
+++ b/docs/website-api/index.html
@@ -118,6 +118,17 @@
There is no public product-category-variation-fields route.
+ Torob (price comparison)
+
+ POST /tenants/{domain}/torob_api/v3/products is for
+ Torob, not storefront JavaScript. On the live shop,
+ nginx proxies POST https://{domain}/torob_api/v3/products
+ to that API. It only returns catalog store items when the business has
+ the store module enabled and Store settings →
+ Torob is on; otherwise 404.
+ Do not implement this path in Next.js.
+
+
For a new website AI / designer
- Open AI_PROMPT.md and paste it into the AI chat.
diff --git a/docs/website-api/openapi.json b/docs/website-api/openapi.json
index 22668e6..0d07f54 100644
--- a/docs/website-api/openapi.json
+++ b/docs/website-api/openapi.json
@@ -44,6 +44,10 @@
{
"name": "Store"
},
+ {
+ "name": "Torob",
+ "description": "Product API v3 for Torob. Called by Torob (not storefront JS). Nginx on the shop apex proxies POST /torob_api/v3/products. Only tenants with the store module enabled; otherwise 404."
+ },
{
"name": "Blogs"
},
@@ -1422,6 +1426,111 @@
}
}
},
+ "/tenants/{domain}/torob_api/v3/products": {
+ "post": {
+ "tags": [
+ "Torob"
+ ],
+ "summary": "Torob Product API v3 (store module only)",
+ "description": "Torob POSTs here (or to `https://{domain}/torob_api/v3/products` which nginx proxies). JWT in `X-Torob-Token` (EdDSA, aud = shop host). Returns 404 when the tenant does not have the **store** module. Page size is 100.",
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/domain"
+ },
+ {
+ "name": "X-Torob-Token",
+ "in": "header",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "X-Torob-Token-Version",
+ "in": "header",
+ "schema": {
+ "type": "string",
+ "example": "1"
+ }
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "oneOf": [
+ {
+ "type": "object",
+ "required": [
+ "page",
+ "sort"
+ ],
+ "properties": {
+ "page": {
+ "type": "integer",
+ "minimum": 1
+ },
+ "sort": {
+ "type": "string",
+ "enum": [
+ "date_added_desc",
+ "date_updated_desc"
+ ]
+ }
+ }
+ },
+ {
+ "type": "object",
+ "required": [
+ "page_urls"
+ ],
+ "properties": {
+ "page_urls": {
+ "type": "array",
+ "minItems": 1,
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "required": [
+ "page_uniques"
+ ],
+ "properties": {
+ "page_uniques": {
+ "type": "array",
+ "minItems": 1,
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ ]
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "{ api_version: torob_api_v3, current_page, total, max_pages, products[] }"
+ },
+ "400": {
+ "description": "{ error: string }"
+ },
+ "401": {
+ "description": "Invalid or missing Torob JWT"
+ },
+ "404": {
+ "description": "Unknown domain, store module off, or Torob switch off in store settings"
+ }
+ }
+ }
+ },
"/tenants/{domain}/blogs": {
"get": {
"tags": [
diff --git a/scripts/websites-agent/README.md b/scripts/websites-agent/README.md
index 2d659ce..04a5be7 100644
--- a/scripts/websites-agent/README.md
+++ b/scripts/websites-agent/README.md
@@ -14,6 +14,7 @@
# Scripts on the VM (same folder):
# patch-nginx-seo.sh — add /sitemap.xml + /robots.txt API proxies to an existing nginx site
# patch-nginx-pay.sh — add /meshkee/payments/{zarinpal|mellat}/callback API proxies
+# patch-nginx-torob.sh — add /torob_api/v3/products API proxy (store-module tenants)
#
# Env (.env): PORT, DEPLOY_TOKEN, ALLOWED_SLUGS
#
diff --git a/scripts/websites-agent/patch-nginx-torob.sh b/scripts/websites-agent/patch-nginx-torob.sh
new file mode 100755
index 0000000..381ce66
--- /dev/null
+++ b/scripts/websites-agent/patch-nginx-torob.sh
@@ -0,0 +1,58 @@
+#!/usr/bin/env bash
+# Insert / update the Torob Product API v3 proxy on a storefront nginx site.
+# Torob POSTs https://{host}/torob_api/v3/products (JWT aud = shop host).
+set -euo pipefail
+
+HOST="${1:-}"
+API_HOST="${MESHKEE_API_HOST:-api.meshkee.com}"
+
+if [[ -z "$HOST" ]]; then
+ echo "usage: patch-nginx-torob.sh " >&2
+ exit 1
+fi
+
+NGINX_AVAILABLE="/etc/nginx/sites-available/$HOST"
+
+if [[ ! -f "$NGINX_AVAILABLE" ]]; then
+ echo "missing nginx site for $HOST" >&2
+ exit 1
+fi
+
+PATH_LOC="/torob_api/v3/products"
+
+if grep -qF "location = ${PATH_LOC}" "$NGINX_AVAILABLE"; then
+ echo "already present: ${PATH_LOC}"
+ exit 0
+fi
+
+block="$(cat <"$tmp"
+mv "$tmp" "$NGINX_AVAILABLE"
+echo "added: ${PATH_LOC}"
+
+nginx -t
+systemctl reload nginx
+echo "patched nginx Torob product API location for $HOST"
diff --git a/scripts/websites-agent/provision.sh b/scripts/websites-agent/provision.sh
index d0c654f..40c6f15 100755
--- a/scripts/websites-agent/provision.sh
+++ b/scripts/websites-agent/provision.sh
@@ -252,6 +252,19 @@ PROXY_COMMON=$(cat < this.relativeName(item.name, host) === '_dmarc' && item.type === 'txt',
- );
- if (match) {
- skipped.push('TXT _dmarc');
- return;
- }
- await this.upsertTyped(
- host,
- auth,
- existing,
- { type: 'txt', name: '_dmarc', value: { text: MAIL_DMARC } },
- created,
- [],
- skipped,
- );
- }
-
private async replaceExtraMx(
host: string,
auth: string,
existing: ArvanDnsRecord[],
removed: string[],
) {
- const mx = existing.filter(
- (item) => this.relativeName(item.name, host) === '@' && item.type === 'mx',
- );
+ const mx = existing.filter((item) => item.type === 'mx');
const keep = mx.find((item) => {
- const current = item.value ?? {};
- return this.fqdn(this.mxHost(current)) === MAIL_HOSTNAME;
+ const name = this.relativeName(item.name, host);
+ return name === '@' && this.fqdn(this.mxHost(item.value ?? {})) === MAIL_HOSTNAME;
});
for (const item of mx) {
if (keep && item.id === keep.id) continue;
@@ -480,7 +461,8 @@ export class ArvanDnsService {
`Arvan failed to remove extra MX (${del.status})${del.message ? `: ${del.message}` : ''}`,
);
}
- removed.push(`MX ${this.mxHost(item.value ?? {}) || item.id}`);
+ const name = this.relativeName(item.name, host);
+ removed.push(`MX ${name} ${this.mxHost(item.value ?? {}) || item.id}`);
const index = existing.indexOf(item);
if (index >= 0) existing.splice(index, 1);
}
diff --git a/src/business-settings/business-settings.service.ts b/src/business-settings/business-settings.service.ts
index 21edaa6..15d9f85 100644
--- a/src/business-settings/business-settings.service.ts
+++ b/src/business-settings/business-settings.service.ts
@@ -162,6 +162,7 @@ export class BusinessSettingsService {
patch.store = {
onlineSellEnabled:
dto.store.onlineSellEnabled ?? current.store.onlineSellEnabled,
+ torobEnabled: dto.store.torobEnabled ?? current.store.torobEnabled,
orderProcessSteps: dto.store.orderProcessSteps
? normalizeBusinessSettings({
store: { orderProcessSteps: dto.store.orderProcessSteps },
diff --git a/src/business-settings/business-settings.types.ts b/src/business-settings/business-settings.types.ts
index f8e7b59..3a02fdb 100644
--- a/src/business-settings/business-settings.types.ts
+++ b/src/business-settings/business-settings.types.ts
@@ -95,6 +95,8 @@ export type EPaymentSettings = {
/** Per-business store / sales settings. */
export type StoreSettings = {
onlineSellEnabled: boolean;
+ /** When true, Torob Product API v3 is available for this tenant (store module still required). */
+ torobEnabled: boolean;
orderProcessSteps: OrderProcessStep[];
ePayment: EPaymentSettings;
};
@@ -300,6 +302,7 @@ export const DEFAULT_BUSINESS_SETTINGS: BusinessSettings = {
},
store: {
onlineSellEnabled: true,
+ torobEnabled: false,
orderProcessSteps: DEFAULT_ORDER_PROCESS_STEPS,
ePayment: { ...DEFAULT_EPAYMENT_SETTINGS },
},
diff --git a/src/business-settings/business-settings.util.ts b/src/business-settings/business-settings.util.ts
index 3e4d3da..19cbd79 100644
--- a/src/business-settings/business-settings.util.ts
+++ b/src/business-settings/business-settings.util.ts
@@ -412,6 +412,10 @@ export function normalizeBusinessSettings(raw: unknown): BusinessSettings {
store.onlineSellEnabled,
DEFAULT_BUSINESS_SETTINGS.store.onlineSellEnabled,
),
+ torobEnabled: readBoolean(
+ store.torobEnabled,
+ DEFAULT_BUSINESS_SETTINGS.store.torobEnabled,
+ ),
orderProcessSteps: readOrderProcessSteps(store.orderProcessSteps),
ePayment: normalizeEPaymentSettings(store.ePayment),
},
@@ -457,6 +461,7 @@ export function mergeBusinessSettings(
store: {
onlineSellEnabled:
patch.store?.onlineSellEnabled ?? current.store.onlineSellEnabled,
+ torobEnabled: patch.store?.torobEnabled ?? current.store.torobEnabled,
orderProcessSteps:
patch.store?.orderProcessSteps ?? current.store.orderProcessSteps,
ePayment: mergeEPaymentSettings(
diff --git a/src/business-settings/dto/update-business-settings.dto.ts b/src/business-settings/dto/update-business-settings.dto.ts
index 152dcff..8a6abf6 100644
--- a/src/business-settings/dto/update-business-settings.dto.ts
+++ b/src/business-settings/dto/update-business-settings.dto.ts
@@ -167,6 +167,10 @@ class StoreSettingsDto {
@IsBoolean()
onlineSellEnabled?: boolean;
+ @IsOptional()
+ @IsBoolean()
+ torobEnabled?: boolean;
+
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
diff --git a/src/cloudflare-dns/cloudflare-dns.service.ts b/src/cloudflare-dns/cloudflare-dns.service.ts
index c309517..6743180 100644
--- a/src/cloudflare-dns/cloudflare-dns.service.ts
+++ b/src/cloudflare-dns/cloudflare-dns.service.ts
@@ -1,7 +1,8 @@
import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
- MAIL_DMARC,
+ DMARC_NAME,
+ LEGACY_DKIM_NAME,
MAIL_HOSTNAME,
MAIL_IPV4,
MAIL_MX_PRIORITY,
@@ -101,6 +102,24 @@ export class CloudflareDnsService {
);
await this.replaceExtraMx(zoneId, token, existing, host, removed);
+ await this.deleteRecordsOfTypes(
+ zoneId,
+ token,
+ existing,
+ host,
+ LEGACY_DKIM_NAME,
+ ['TXT', 'CNAME'],
+ removed,
+ );
+ await this.deleteRecordsOfTypes(
+ zoneId,
+ token,
+ existing,
+ host,
+ DMARC_NAME,
+ ['TXT', 'CNAME'],
+ removed,
+ );
await this.upsertCf(
zoneId,
token,
@@ -169,24 +188,6 @@ export class CloudflareDnsService {
skipped,
);
- const dmarc = existing.find(
- (rec) => rec.type === 'TXT' && this.relativeName(rec.name, host) === '_dmarc',
- );
- if (dmarc) {
- skipped.push('TXT _dmarc');
- } else {
- await this.upsertCf(
- zoneId,
- token,
- existing,
- host,
- { type: 'TXT', name: '_dmarc', content: MAIL_DMARC, proxied: false },
- created,
- updated,
- skipped,
- );
- }
-
this.logger.log(
`Cloudflare mail DNS ${host}: created=${created.join(',') || '-'} updated=${updated.join(',') || '-'} skipped=${skipped.join(',') || '-'} removed=${removed.join(',') || '-'}`,
);
@@ -488,11 +489,10 @@ export class CloudflareDnsService {
zone: string,
removed: string[],
) {
- const mx = existing.filter(
- (rec) => rec.type === 'MX' && this.relativeName(rec.name, zone) === '@',
- );
+ const mx = existing.filter((rec) => rec.type === 'MX');
const keep = mx.find(
- (rec) => this.sameContent(rec, MAIL_HOSTNAME) && this.mxPriority(rec) === MAIL_MX_PRIORITY,
+ (rec) =>
+ this.relativeName(rec.name, zone) === '@' && this.sameContent(rec, MAIL_HOSTNAME),
);
for (const rec of mx) {
if (keep && rec.id === keep.id) continue;
@@ -502,7 +502,7 @@ export class CloudflareDnsService {
`Cloudflare failed to remove extra MX (${del.status})${del.message ? `: ${del.message}` : ''}`,
);
}
- removed.push(`MX ${rec.content}`);
+ removed.push(`MX ${this.relativeName(rec.name, zone)} ${rec.content}`);
const index = existing.indexOf(rec);
if (index >= 0) existing.splice(index, 1);
}
diff --git a/src/mail-dns/mail-dns.ts b/src/mail-dns/mail-dns.ts
index 533e047..020080f 100644
--- a/src/mail-dns/mail-dns.ts
+++ b/src/mail-dns/mail-dns.ts
@@ -2,7 +2,9 @@ export const MAIL_IPV4 = '185.214.101.41';
export const MAIL_HOSTNAME = 'mail.meshkee.com';
export const MAIL_MX_PRIORITY = 10;
export const MAIL_SPF = `v=spf1 ip4:${MAIL_IPV4} -all`;
-export const MAIL_DMARC = 'v=DMARC1; p=none';
+/** Old provider selector (dkim._domainkey.example.com) — removed on mail DNS update. */
+export const LEGACY_DKIM_NAME = 'dkim._domainkey';
+export const DMARC_NAME = '_dmarc';
export type MailDkimKey = {
selector: string;
diff --git a/src/products/products.service.ts b/src/products/products.service.ts
index 777e43a..ba7672a 100644
--- a/src/products/products.service.ts
+++ b/src/products/products.service.ts
@@ -544,7 +544,7 @@ export class ProductsService {
entityType: MediaEntityType.product,
entityId: product.id,
},
- include: { category: true },
+ include: { category: { include: { parent: true } } },
}),
this.prisma.mediaAttachment.findMany({
where: {
@@ -586,6 +586,8 @@ export class ProductsService {
categoryId: categoryAssignment?.categoryId.toString() ?? null,
categoryName: categoryAssignment?.category.name ?? '',
categoryNameFa: categoryAssignment?.category.nameFa ?? '',
+ categoryParentName: categoryAssignment?.category.parent?.name ?? '',
+ categoryParentNameFa: categoryAssignment?.category.parent?.nameFa ?? '',
brandId: product.brandId?.toString() ?? null,
brand: this.brands.serializeBrandSummary(product.brand),
tags: Array.isArray(metadata.tags)
diff --git a/src/torob/dto/torob-products-query.dto.ts b/src/torob/dto/torob-products-query.dto.ts
new file mode 100644
index 0000000..ac19fbc
--- /dev/null
+++ b/src/torob/dto/torob-products-query.dto.ts
@@ -0,0 +1,24 @@
+import { Type } from 'class-transformer';
+import { IsArray, IsInt, IsOptional, IsString, Min } from 'class-validator';
+
+export class TorobProductsQueryDto {
+ @IsOptional()
+ @Type(() => Number)
+ @IsInt()
+ @Min(1)
+ page?: number;
+
+ @IsOptional()
+ @IsString()
+ sort?: string;
+
+ @IsOptional()
+ @IsArray()
+ @IsString({ each: true })
+ page_urls?: string[];
+
+ @IsOptional()
+ @IsArray()
+ @IsString({ each: true })
+ page_uniques?: string[];
+}
diff --git a/src/torob/torob-token.guard.ts b/src/torob/torob-token.guard.ts
new file mode 100644
index 0000000..a61e327
--- /dev/null
+++ b/src/torob/torob-token.guard.ts
@@ -0,0 +1,47 @@
+import {
+ CanActivate,
+ ExecutionContext,
+ Injectable,
+ UnauthorizedException,
+} from '@nestjs/common';
+import type { Request } from 'express';
+import {
+ TOROB_TOKEN_HEADER,
+ TOROB_TOKEN_VERSION,
+ TOROB_TOKEN_VERSION_HEADER,
+} from './torob.constants';
+import { verifyTorobJwt } from './torob-token';
+
+@Injectable()
+export class TorobTokenGuard implements CanActivate {
+ canActivate(context: ExecutionContext): boolean {
+ const request = context.switchToHttp().getRequest<
+ Request & { params: { host?: string } }
+ >();
+ const host = request.params.host?.trim().toLowerCase() ?? '';
+ if (!host) {
+ throw new UnauthorizedException('Domain host is required');
+ }
+
+ const headerMap = request.headers;
+ const rawToken = headerMap[TOROB_TOKEN_HEADER];
+ const token = Array.isArray(rawToken) ? rawToken[0] : rawToken;
+ if (!token?.trim()) {
+ throw new UnauthorizedException('Missing X-Torob-Token');
+ }
+
+ const rawVersion = headerMap[TOROB_TOKEN_VERSION_HEADER];
+ const version = Array.isArray(rawVersion) ? rawVersion[0] : rawVersion;
+ if (version && version !== TOROB_TOKEN_VERSION) {
+ throw new UnauthorizedException('Unsupported X-Torob-Token-Version');
+ }
+
+ try {
+ verifyTorobJwt(token, host);
+ } catch {
+ throw new UnauthorizedException('Invalid Torob token');
+ }
+
+ return true;
+ }
+}
diff --git a/src/torob/torob-token.ts b/src/torob/torob-token.ts
new file mode 100644
index 0000000..c1c2195
--- /dev/null
+++ b/src/torob/torob-token.ts
@@ -0,0 +1,68 @@
+import { createPublicKey, verify, type KeyObject } from 'node:crypto';
+import { TOROB_PUBLIC_KEY_PEM } from './torob.constants';
+
+let cachedKey: KeyObject | null = null;
+
+function publicKey(): KeyObject {
+ if (!cachedKey) {
+ cachedKey = createPublicKey(TOROB_PUBLIC_KEY_PEM);
+ }
+ return cachedKey;
+}
+
+function decodeBase64UrlJson(part: string): Record {
+ const json = Buffer.from(part, 'base64url').toString('utf8');
+ const parsed = JSON.parse(json) as unknown;
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
+ throw new Error('Invalid JWT payload');
+ }
+ return parsed as Record;
+}
+
+function audienceMatches(aud: unknown, apexHost: string): boolean {
+ const expected = apexHost.trim().toLowerCase();
+ const allowed = new Set([expected, `www.${expected}`]);
+ if (typeof aud === 'string') return allowed.has(aud.trim().toLowerCase());
+ if (Array.isArray(aud)) {
+ return aud.some(
+ (item) => typeof item === 'string' && allowed.has(item.trim().toLowerCase()),
+ );
+ }
+ return false;
+}
+
+export function verifyTorobJwt(token: string, apexHost: string): void {
+ const compact = token.trim();
+ const parts = compact.split('.');
+ if (parts.length !== 3) {
+ throw new Error('Malformed JWT');
+ }
+
+ const [headerPart, payloadPart, signaturePart] = parts;
+ const header = decodeBase64UrlJson(headerPart);
+ const alg = typeof header.alg === 'string' ? header.alg : '';
+ if (alg !== 'EdDSA') {
+ throw new Error('Unexpected JWT algorithm');
+ }
+
+ const data = Buffer.from(`${headerPart}.${payloadPart}`);
+ const signature = Buffer.from(signaturePart, 'base64url');
+ const ok = verify(null, data, publicKey(), signature);
+ if (!ok) {
+ throw new Error('Invalid JWT signature');
+ }
+
+ const payload = decodeBase64UrlJson(payloadPart);
+ const now = Math.floor(Date.now() / 1000);
+ const exp = typeof payload.exp === 'number' ? payload.exp : null;
+ const nbf = typeof payload.nbf === 'number' ? payload.nbf : null;
+ if (exp == null || now > exp) {
+ throw new Error('JWT expired');
+ }
+ if (nbf != null && now < nbf) {
+ throw new Error('JWT not yet valid');
+ }
+ if (!audienceMatches(payload.aud, apexHost)) {
+ throw new Error('JWT audience mismatch');
+ }
+}
diff --git a/src/torob/torob.constants.ts b/src/torob/torob.constants.ts
new file mode 100644
index 0000000..6314a5d
--- /dev/null
+++ b/src/torob/torob.constants.ts
@@ -0,0 +1,14 @@
+/** Torob Product API v3 — https://github.com/torob/Torob-Sync */
+
+export const TOROB_API_VERSION = 'torob_api_v3';
+export const TOROB_PAGE_SIZE = 100;
+export const TOROB_TOKEN_HEADER = 'x-torob-token';
+export const TOROB_TOKEN_VERSION_HEADER = 'x-torob-token-version';
+export const TOROB_TOKEN_VERSION = '1';
+
+export const TOROB_PUBLIC_KEY_PEM = `-----BEGIN PUBLIC KEY-----
+MCowBQYDK2VwAyEAt6Mu4T0pBORY11W+QeM35UsmLO3vsf+6yKpFDEImFk0=
+-----END PUBLIC KEY-----`;
+
+export const TOROB_SORTS = ['date_added_desc', 'date_updated_desc'] as const;
+export type TorobSort = (typeof TOROB_SORTS)[number];
diff --git a/src/torob/torob.controller.ts b/src/torob/torob.controller.ts
new file mode 100644
index 0000000..6b4d11a
--- /dev/null
+++ b/src/torob/torob.controller.ts
@@ -0,0 +1,16 @@
+import { Body, Controller, HttpCode, Param, Post, UseGuards } from '@nestjs/common';
+import { TorobProductsQueryDto } from './dto/torob-products-query.dto';
+import { TorobTokenGuard } from './torob-token.guard';
+import { TorobService } from './torob.service';
+
+@Controller('tenants/:host/torob_api/v3')
+export class TorobController {
+ constructor(private readonly service: TorobService) {}
+
+ @Post('products')
+ @HttpCode(200)
+ @UseGuards(TorobTokenGuard)
+ list(@Param('host') host: string, @Body() body?: TorobProductsQueryDto) {
+ return this.service.listProducts(host, body ?? {});
+ }
+}
diff --git a/src/torob/torob.module.ts b/src/torob/torob.module.ts
new file mode 100644
index 0000000..8c1f705
--- /dev/null
+++ b/src/torob/torob.module.ts
@@ -0,0 +1,12 @@
+import { Module } from '@nestjs/common';
+import { TenantModule } from '../tenant/tenant.module';
+import { TorobController } from './torob.controller';
+import { TorobService } from './torob.service';
+import { TorobTokenGuard } from './torob-token.guard';
+
+@Module({
+ imports: [TenantModule],
+ controllers: [TorobController],
+ providers: [TorobService, TorobTokenGuard],
+})
+export class TorobModule {}
diff --git a/src/torob/torob.service.ts b/src/torob/torob.service.ts
new file mode 100644
index 0000000..038ffdb
--- /dev/null
+++ b/src/torob/torob.service.ts
@@ -0,0 +1,455 @@
+import { HttpException, HttpStatus, Injectable, NotFoundException } from '@nestjs/common';
+import { ContentStatus, MediaEntityType, Prisma } from '@prisma/client';
+import { normalizeBusinessSettings } from '../business-settings/business-settings.util';
+import { PrismaService } from '../prisma/prisma.service';
+import {
+ applyPathTemplate,
+ buildAbsoluteUrl,
+} from '../sitemap/sitemap-xml.util';
+import { resolveSitemapConfig } from '../sitemap/sitemap-config.util';
+import { slugifyForUrl } from '../sitemap/seo-slug.util';
+import { TenantService } from '../tenant/tenant.service';
+import type { TorobProductsQueryDto } from './dto/torob-products-query.dto';
+import {
+ TOROB_API_VERSION,
+ TOROB_PAGE_SIZE,
+ TOROB_SORTS,
+ type TorobSort,
+} from './torob.constants';
+
+const variantInclude = {
+ storeItem: {
+ include: {
+ product: {
+ include: {
+ featuredMedia: true,
+ },
+ },
+ },
+ },
+ selections: {
+ include: {
+ variation: true,
+ option: true,
+ },
+ },
+} satisfies Prisma.StoreItemVariantInclude;
+
+type VariantRow = Prisma.StoreItemVariantGetPayload<{
+ include: typeof variantInclude;
+}>;
+
+type TorobProduct = {
+ page_unique: string;
+ page_url: string;
+ product_group_id: string;
+ title: string;
+ subtitle?: string;
+ current_price: number;
+ old_price?: number;
+ availability: boolean;
+ category_name?: string;
+ image_links: string[];
+ spec: Record;
+ guarantee?: string;
+ short_desc?: string;
+ date_added: string;
+ date_updated: string;
+};
+
+type ParsedQuery =
+ | { mode: 'page'; page: number; sort: TorobSort }
+ | { mode: 'urls'; page_urls: string[] }
+ | { mode: 'uniques'; page_uniques: string[] };
+
+@Injectable()
+export class TorobService {
+ constructor(
+ private readonly prisma: PrismaService,
+ private readonly tenant: TenantService,
+ ) {}
+
+ async listProducts(hostRaw: string, dto: TorobProductsQueryDto) {
+ const business = await this.tenant.resolveBusinessByDomain(hostRaw);
+ const settings = normalizeBusinessSettings(business.settings);
+ if (!settings.modules.enabled.includes('store') || !settings.store.torobEnabled) {
+ throw new NotFoundException('Torob product API is not available for this website');
+ }
+
+ const apexHost = this.tenantHost(hostRaw);
+ const sitemap = resolveSitemapConfig(settings.website.sitemapConfig, apexHost);
+ const query = this.parseQuery(dto);
+
+ const baseWhere: Prisma.StoreItemVariantWhereInput = {
+ businessId: business.id,
+ isActive: true,
+ storeItem: {
+ isActive: true,
+ product: { status: ContentStatus.published },
+ },
+ };
+
+ if (query.mode === 'urls') {
+ const productIds = this.productIdsFromPageUrls(query.page_urls, sitemap.baseUrl);
+ const items = productIds.length
+ ? await this.prisma.storeItemVariant.findMany({
+ where: {
+ ...baseWhere,
+ storeItem: {
+ isActive: true,
+ product: {
+ status: ContentStatus.published,
+ id: { in: productIds },
+ },
+ },
+ },
+ orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
+ include: variantInclude,
+ })
+ : [];
+ const products = await this.serializeMany(business.id, items, sitemap);
+ return this.wrap(1, products.length, products);
+ }
+
+ if (query.mode === 'uniques') {
+ const ids = query.page_uniques
+ .map((value) => value.trim())
+ .filter((value) => /^\d+$/.test(value))
+ .map((value) => BigInt(value));
+ const items = ids.length
+ ? await this.prisma.storeItemVariant.findMany({
+ where: { ...baseWhere, id: { in: ids } },
+ include: variantInclude,
+ })
+ : [];
+ const products = await this.serializeMany(business.id, items, sitemap);
+ return this.wrap(1, products.length, products);
+ }
+
+ const orderBy: Prisma.StoreItemVariantOrderByWithRelationInput[] =
+ query.sort === 'date_updated_desc'
+ ? [{ updatedAt: 'desc' }, { id: 'desc' }]
+ : [{ createdAt: 'desc' }, { id: 'desc' }];
+
+ const total = await this.prisma.storeItemVariant.count({ where: baseWhere });
+ const maxPages = Math.max(1, Math.ceil(total / TOROB_PAGE_SIZE));
+ const skip = (query.page - 1) * TOROB_PAGE_SIZE;
+ const items =
+ query.page > maxPages && total > 0
+ ? []
+ : await this.prisma.storeItemVariant.findMany({
+ where: baseWhere,
+ orderBy,
+ skip,
+ take: TOROB_PAGE_SIZE,
+ include: variantInclude,
+ });
+ const products = await this.serializeMany(business.id, items, sitemap);
+ return this.wrap(query.page, total, products, maxPages);
+ }
+
+ private parseQuery(dto: TorobProductsQueryDto): ParsedQuery {
+ const hasUrls = Array.isArray(dto.page_urls) && dto.page_urls.length > 0;
+ const hasUniques = Array.isArray(dto.page_uniques) && dto.page_uniques.length > 0;
+ if (Array.isArray(dto.page_urls) && dto.page_urls.length === 0) {
+ this.badRequest('page_urls must contain at least 1 item');
+ }
+ if (Array.isArray(dto.page_uniques) && dto.page_uniques.length === 0) {
+ this.badRequest('page_uniques must contain at least 1 item');
+ }
+ const hasPage = dto.page !== undefined;
+ const hasSort = typeof dto.sort === 'string' && dto.sort.trim() !== '';
+ const modes = [hasUrls, hasUniques, hasPage || hasSort].filter(Boolean).length;
+
+ if (modes !== 1) {
+ this.badRequest('Request body must be page_urls, page_uniques, or page+sort');
+ }
+
+ if (hasUrls) {
+ return { mode: 'urls', page_urls: dto.page_urls!.map((item) => item.trim()).filter(Boolean) };
+ }
+ if (hasUniques) {
+ return {
+ mode: 'uniques',
+ page_uniques: dto.page_uniques!.map((item) => item.trim()).filter(Boolean),
+ };
+ }
+ if (dto.page == null) {
+ this.badRequest('page parameter is not provided');
+ }
+ if (!hasSort) {
+ this.badRequest('sort parameter is not provided');
+ }
+ const sort = dto.sort!.trim();
+ if (!TOROB_SORTS.includes(sort as TorobSort)) {
+ this.badRequest('sort parameter is invalid');
+ }
+ return { mode: 'page', page: dto.page, sort: sort as TorobSort };
+ }
+
+ private badRequest(error: string): never {
+ throw new HttpException({ error }, HttpStatus.BAD_REQUEST);
+ }
+
+ private tenantHost(hostRaw: string): string {
+ return hostRaw.trim().toLowerCase().replace(/^www\./, '');
+ }
+
+ private wrap(
+ currentPage: number,
+ total: number,
+ products: TorobProduct[],
+ maxPages = Math.max(1, Math.ceil(total / TOROB_PAGE_SIZE)),
+ ) {
+ return {
+ api_version: TOROB_API_VERSION,
+ current_page: currentPage,
+ total,
+ max_pages: maxPages,
+ products,
+ };
+ }
+
+ private productIdsFromPageUrls(urls: string[], baseUrl: string): bigint[] {
+ const ids: bigint[] = [];
+ const seen = new Set();
+ const origin = this.originOf(baseUrl);
+
+ for (const raw of urls) {
+ const id = this.productIdFromPageUrl(raw, origin);
+ if (!id) continue;
+ const key = id.toString();
+ if (seen.has(key)) continue;
+ seen.add(key);
+ ids.push(id);
+ }
+ return ids;
+ }
+
+ private originOf(baseUrl: string): string {
+ try {
+ return new URL(baseUrl).origin.toLowerCase();
+ } catch {
+ return '';
+ }
+ }
+
+ private productIdFromPageUrl(raw: string, expectedOrigin: string): bigint | null {
+ try {
+ const url = new URL(raw.trim());
+ const origin = url.origin.toLowerCase().replace('://www.', '://');
+ const expected = expectedOrigin.replace('://www.', '://');
+ if (expected && origin !== expected) return null;
+ const match = url.pathname.match(/\/products\/(\d+)(?:\/|$)/);
+ if (!match) return null;
+ return BigInt(match[1]);
+ } catch {
+ return null;
+ }
+ }
+
+ private async serializeMany(
+ businessId: bigint,
+ items: VariantRow[],
+ sitemap: ReturnType,
+ ): Promise {
+ if (items.length === 0) return [];
+
+ const productIds = [...new Set(items.map((item) => item.storeItem.productId))];
+ const [images, categories, specs] = await Promise.all([
+ this.loadImageLinks(businessId, productIds, items),
+ this.loadCategoryNames(businessId, productIds),
+ this.loadSpecs(businessId, productIds),
+ ]);
+
+ return items.map((item) =>
+ this.serializeOne(
+ item,
+ sitemap,
+ images.get(item.storeItem.productId.toString()) ?? [],
+ categories.get(item.storeItem.productId.toString()) ?? '',
+ specs.get(item.storeItem.productId.toString()) ?? {},
+ ),
+ );
+ }
+
+ private serializeOne(
+ variant: VariantRow,
+ sitemap: ReturnType,
+ imageLinks: string[],
+ categoryName: string,
+ spec: Record,
+ ): TorobProduct {
+ const product = variant.storeItem.product;
+ const content = this.asRecord(product.content);
+ const metadata = this.asRecord(product.metadata);
+ const nameFa = typeof content.nameFa === 'string' ? content.nameFa.trim() : '';
+ const title = this.clip(nameFa || product.title, 500);
+ const variantLabel = variant.selections
+ .map((selection) => selection.option.label)
+ .filter(Boolean)
+ .join(' · ');
+ const subtitleRaw =
+ variantLabel ||
+ (nameFa && product.title !== nameFa ? product.title : '') ||
+ (typeof content.nameEn === 'string' ? content.nameEn.trim() : '');
+ const slug = slugifyForUrl(nameFa || product.title, 'product');
+ const path = applyPathTemplate(sitemap.templates.product, {
+ id: product.id.toString(),
+ slug,
+ });
+ const pageUrl = buildAbsoluteUrl(sitemap.baseUrl, path);
+ const { currentPrice, oldPrice, availability } = this.prices(variant);
+ const description =
+ typeof product.description === 'string' ? product.description.trim() : '';
+ const guarantee =
+ typeof metadata.guarantee === 'string' ? metadata.guarantee.trim() : '';
+
+ const row: TorobProduct = {
+ page_unique: variant.id.toString(),
+ page_url: this.clip(pageUrl, 1500),
+ product_group_id: product.id.toString(),
+ title,
+ current_price: currentPrice,
+ availability,
+ image_links: imageLinks.slice(0, 20).map((link) => this.clip(link, 1000)),
+ spec,
+ date_added: variant.createdAt.toISOString(),
+ date_updated: variant.updatedAt.toISOString(),
+ };
+
+ if (subtitleRaw) row.subtitle = this.clip(subtitleRaw, 500);
+ if (oldPrice != null) row.old_price = oldPrice;
+ if (categoryName) row.category_name = this.clip(categoryName, 200);
+ if (description) row.short_desc = this.clip(description, 500);
+ if (guarantee) row.guarantee = this.clip(guarantee, 200);
+ if (row.image_links.length === 0) row.image_links = [];
+
+ return row;
+ }
+
+ private prices(variant: VariantRow): {
+ currentPrice: number;
+ oldPrice?: number;
+ availability: boolean;
+ } {
+ const stock = variant.stockQuantity;
+ const available = stock == null || stock > 0;
+ const listPrice = variant.price == null ? 0 : Math.round(Number(variant.price));
+ const compare =
+ variant.compareAtPrice == null ? null : Math.round(Number(variant.compareAtPrice));
+ const discounted =
+ compare != null && listPrice > 0 && compare > 0 && compare < listPrice ? compare : null;
+ if (!available) {
+ return { currentPrice: 0, oldPrice: discounted != null ? listPrice : undefined, availability: false };
+ }
+ if (discounted != null) {
+ return { currentPrice: discounted, oldPrice: listPrice, availability: true };
+ }
+ return { currentPrice: listPrice, availability: true };
+ }
+
+ private async loadImageLinks(
+ businessId: bigint,
+ productIds: bigint[],
+ items: VariantRow[],
+ ): Promise