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:
co-authored by
Cursor
parent
6f1b13b6dc
commit
42cf29bc4e
@@ -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
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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
|
||||
#
|
||||
|
||||
Executable
+58
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
@@ -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 ?? {});
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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": [
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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": [
|
||||
|
||||
Reference in New Issue
Block a user