Add Torob Product API v3 for store-module sites with a store setting to enable it.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-09-03 15:26:23 +03:30
co-authored by Cursor
parent 6f1b13b6dc
commit 42cf29bc4e
28 changed files with 1080 additions and 62 deletions
+4 -2
View File
@@ -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
+1
View File
@@ -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`)
@@ -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": [
+11
View File
@@ -118,6 +118,17 @@
There is no public <code>product-category-variation-fields</code> route.
</p>
<h2>Torob (price comparison)</h2>
<p>
<code>POST /tenants/{domain}/torob_api/v3/products</code> is for
<strong>Torob</strong>, not storefront JavaScript. On the live shop,
nginx proxies <code>POST https://{domain}/torob_api/v3/products</code>
to that API. It only returns catalog store items when the business has
the <strong>store</strong> module enabled <em>and</em> Store settings →
Torob is on; otherwise 404.
Do not implement this path in Next.js.
</p>
<h2>For a new website AI / designer</h2>
<ol>
<li>Open <a href="/docs/website/AI_PROMPT.md">AI_PROMPT.md</a> and paste it into the AI chat.</li>
+109
View File
@@ -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": [
+1
View File
@@ -14,6 +14,7 @@
# Scripts on the VM (same folder):
# patch-nginx-seo.sh <host> — add /sitemap.xml + /robots.txt API proxies to an existing nginx site
# patch-nginx-pay.sh <host> — add /meshkee/payments/{zarinpal|mellat}/callback API proxies
# patch-nginx-torob.sh <host> — add /torob_api/v3/products API proxy (store-module tenants)
#
# Env (.env): PORT, DEPLOY_TOKEN, ALLOWED_SLUGS
#
+58
View File
@@ -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 <host>" >&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 <<NGINX
location = ${PATH_LOC} {
proxy_pass https://${API_HOST}/api/v1/tenants/${HOST}/torob_api/v3/products;
proxy_set_header Host ${API_HOST};
proxy_ssl_server_name on;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_set_header X-Torob-Token \$http_x_torob_token;
proxy_set_header X-Torob-Token-Version \$http_x_torob_token_version;
proxy_pass_request_body on;
proxy_pass_request_headers on;
}
NGINX
)"
tmp="$(mktemp)"
awk -v block="$block" '
/location \/ \{/ && !done {
print block
done = 1
}
{ print }
' "$NGINX_AVAILABLE" >"$tmp"
mv "$tmp" "$NGINX_AVAILABLE"
echo "added: ${PATH_LOC}"
nginx -t
systemctl reload nginx
echo "patched nginx Torob product API location for $HOST"
+16
View File
@@ -252,6 +252,19 @@ PROXY_COMMON=$(cat <<PROXY
proxy_pass_request_headers on;
}
location = /torob_api/v3/products {
proxy_pass https://api.meshkee.com/api/v1/tenants/${HOST}/torob_api/v3/products;
proxy_set_header Host api.meshkee.com;
proxy_ssl_server_name on;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_set_header X-Torob-Token \$http_x_torob_token;
proxy_set_header X-Torob-Token-Version \$http_x_torob_token_version;
proxy_pass_request_body on;
proxy_pass_request_headers on;
}
location / {
proxy_pass http://127.0.0.1:${PORT};
proxy_http_version 1.1;
@@ -300,6 +313,9 @@ else
if [[ -x "$SCRIPT_DIR/patch-nginx-pay.sh" ]]; then
"$SCRIPT_DIR/patch-nginx-pay.sh" "$HOST" || true
fi
if [[ -x "$SCRIPT_DIR/patch-nginx-torob.sh" ]]; then
"$SCRIPT_DIR/patch-nginx-torob.sh" "$HOST" || true
fi
fi
# SSL is issued separately via ssl.sh / Super Admin "Issue SSL" — do not run
+2
View File
@@ -44,6 +44,7 @@ import { LegacyMysqlModule } from './legacy-mysql/legacy-mysql.module';
import { PublicSmsModule } from './public-sms/public-sms.module';
import { PaymentsModule } from './payments/payments.module';
import { SitemapModule } from './sitemap/sitemap.module';
import { TorobModule } from './torob/torob.module';
@Module({
imports: [
@@ -92,6 +93,7 @@ import { SitemapModule } from './sitemap/sitemap.module';
AiPromptsModule,
PublicSmsModule,
SitemapModule,
TorobModule,
],
})
export class AppModule {}
+16 -34
View File
@@ -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,
@@ -113,6 +114,15 @@ export class ArvanDnsService {
}, created, updated, skipped);
await this.replaceExtraMx(host, auth, existing, removed);
await this.replaceConflicting(
host,
auth,
existing,
LEGACY_DKIM_NAME,
['txt', 'cname'],
removed,
);
await this.replaceConflicting(host, auth, existing, DMARC_NAME, ['txt', 'cname'], removed);
await this.upsertTyped(host, auth, existing, {
type: 'mx',
name: '@',
@@ -140,8 +150,6 @@ export class ArvanDnsService {
value: { host: `${MAIL_HOSTNAME}.`, host_header: 'source' },
}, created, updated, skipped);
await this.ensureDmarcIfMissing(host, auth, existing, created, skipped);
this.logger.log(
`Arvan mail DNS ${host}: created=${created.join(',') || '-'} updated=${updated.join(',') || '-'} skipped=${skipped.join(',') || '-'} removed=${removed.join(',') || '-'}`,
);
@@ -430,43 +438,16 @@ export class ArvanDnsService {
created.push('TXT @ SPF');
}
private async ensureDmarcIfMissing(
host: string,
auth: string,
existing: ArvanDnsRecord[],
created: string[],
skipped: string[],
) {
const match = existing.find(
(item) => 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);
}
@@ -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 },
@@ -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 },
},
@@ -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(
@@ -167,6 +167,10 @@ class StoreSettingsDto {
@IsBoolean()
onlineSellEnabled?: boolean;
@IsOptional()
@IsBoolean()
torobEnabled?: boolean;
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
+24 -24
View File
@@ -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);
}
+3 -1
View File
@@ -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;
+3 -1
View File
@@ -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)
+24
View File
@@ -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[];
}
+47
View File
@@ -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;
}
}
+68
View File
@@ -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<string, unknown> {
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<string, unknown>;
}
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');
}
}
+14
View File
@@ -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];
+16
View File
@@ -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 ?? {});
}
}
+12
View File
@@ -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 {}
+455
View File
@@ -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<string, string | number>;
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<string>();
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<typeof resolveSitemapConfig>,
): Promise<TorobProduct[]> {
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<typeof resolveSitemapConfig>,
imageLinks: string[],
categoryName: string,
spec: Record<string, string | number>,
): 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<Map<string, string[]>> {
const featured = new Map<string, string>();
for (const item of items) {
const url = item.storeItem.product.featuredMedia?.publicUrl?.trim();
if (url) featured.set(item.storeItem.productId.toString(), url);
}
const attachments = await this.prisma.mediaAttachment.findMany({
where: {
businessId,
entityType: MediaEntityType.product,
entityId: { in: productIds },
},
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
include: { media: { select: { publicUrl: true } } },
});
const map = new Map<string, string[]>();
for (const productId of productIds) {
const key = productId.toString();
const urls: string[] = [];
const seen = new Set<string>();
const push = (url: string | null | undefined) => {
const trimmed = url?.trim() ?? '';
if (!trimmed || seen.has(trimmed)) return;
seen.add(trimmed);
urls.push(trimmed);
};
push(featured.get(key));
for (const attachment of attachments) {
if (attachment.entityId.toString() !== key) continue;
push(attachment.media.publicUrl);
}
map.set(key, urls);
}
return map;
}
private async loadCategoryNames(
businessId: bigint,
productIds: bigint[],
): Promise<Map<string, string>> {
const assignments = await this.prisma.categoryAssignment.findMany({
where: {
businessId,
entityType: MediaEntityType.product,
entityId: { in: productIds },
},
include: { category: { select: { name: true, nameFa: true } } },
});
const map = new Map<string, string>();
for (const row of assignments) {
const key = row.entityId.toString();
if (map.has(key)) continue;
map.set(key, row.category.nameFa?.trim() || row.category.name);
}
return map;
}
private async loadSpecs(
businessId: bigint,
productIds: bigint[],
): Promise<Map<string, Record<string, string | number>>> {
const values = await this.prisma.productTechnicalFieldValue.findMany({
where: { businessId, productId: { in: productIds } },
include: {
field: { select: { label: true } },
option: { select: { label: true } },
selectedOptions: { include: { option: { select: { label: true } } } },
},
});
const map = new Map<string, Record<string, string | number>>();
for (const row of values) {
const key = row.productId.toString();
const spec = map.get(key) ?? {};
const label = row.field.label.trim() || `field_${row.fieldId.toString()}`;
const multi = row.selectedOptions
.map((item) => item.option.label.trim())
.filter(Boolean);
const text = row.textValue?.trim() ?? '';
const option = row.option?.label.trim() ?? '';
const value = multi.length > 0 ? multi.join(', ') : text || option;
if (value) spec[this.clip(label, 80)] = this.clip(value, 200);
map.set(key, spec);
}
return map;
}
private asRecord(value: unknown): Record<string, unknown> {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return value as Record<string, unknown>;
}
return {};
}
private clip(value: string, max: number): string {
return value.length <= max ? value : value.slice(0, max);
}
}
+1
View File
@@ -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`)
@@ -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": [
+11
View File
@@ -118,6 +118,17 @@
There is no public <code>product-category-variation-fields</code> route.
</p>
<h2>Torob (price comparison)</h2>
<p>
<code>POST /tenants/{domain}/torob_api/v3/products</code> is for
<strong>Torob</strong>, not storefront JavaScript. On the live shop,
nginx proxies <code>POST https://{domain}/torob_api/v3/products</code>
to that API. It only returns catalog store items when the business has
the <strong>store</strong> module enabled <em>and</em> Store settings →
Torob is on; otherwise 404.
Do not implement this path in Next.js.
</p>
<h2>For a new website AI / designer</h2>
<ol>
<li>Open <a href="/docs/website/AI_PROMPT.md">AI_PROMPT.md</a> and paste it into the AI chat.</li>
+109
View File
@@ -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": [