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
|
# Meshkee CMS API — Project Context
|
||||||
|
|
||||||
> Living reference for developers and AI assistants working on this codebase.
|
> 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
|
## 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-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/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` |
|
| 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-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/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) |
|
| 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)
|
- Multi-tenant auth (register, login, passwordless OTP login, reset password via SMS, profile)
|
||||||
- Super admin: users, businesses, domains, system business categories
|
- 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 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)
|
- 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
|
- Business team management
|
||||||
- Media upload (S3 + Sharp)
|
- Media upload (S3 + Sharp)
|
||||||
@@ -651,6 +652,7 @@ See `.env.example` for the full list. Key groups:
|
|||||||
- Products CRUD
|
- Products CRUD
|
||||||
- Product variation values (which options a product offers)
|
- Product variation values (which options a product offers)
|
||||||
- Store items / product variants (price, stock, SKU)
|
- 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)
|
- Shopping cart + checkout + orders (customer + admin)
|
||||||
- Online e-payment (Mellat + ZarinPal; stubs for SEP / Snapp Pay / DigiPay)
|
- Online e-payment (Mellat + ZarinPal; stubs for SEP / Snapp Pay / DigiPay)
|
||||||
- Product technical info
|
- 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.).
|
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
|
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.
|
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
|
### Typical bootstrap sequence
|
||||||
1. `GET /tenants/{domain}` → branding + `businessId` + `specialProductsSource` (`product` or `store_item`)
|
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",
|
"name": "Homepage",
|
||||||
"item": [
|
"item": [
|
||||||
|
|||||||
@@ -118,6 +118,17 @@
|
|||||||
There is no public <code>product-category-variation-fields</code> route.
|
There is no public <code>product-category-variation-fields</code> route.
|
||||||
</p>
|
</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>
|
<h2>For a new website AI / designer</h2>
|
||||||
<ol>
|
<ol>
|
||||||
<li>Open <a href="/docs/website/AI_PROMPT.md">AI_PROMPT.md</a> and paste it into the AI chat.</li>
|
<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": "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"
|
"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": {
|
"/tenants/{domain}/blogs": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
# Scripts on the VM (same folder):
|
# 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-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-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
|
# 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;
|
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 / {
|
location / {
|
||||||
proxy_pass http://127.0.0.1:${PORT};
|
proxy_pass http://127.0.0.1:${PORT};
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
@@ -300,6 +313,9 @@ else
|
|||||||
if [[ -x "$SCRIPT_DIR/patch-nginx-pay.sh" ]]; then
|
if [[ -x "$SCRIPT_DIR/patch-nginx-pay.sh" ]]; then
|
||||||
"$SCRIPT_DIR/patch-nginx-pay.sh" "$HOST" || true
|
"$SCRIPT_DIR/patch-nginx-pay.sh" "$HOST" || true
|
||||||
fi
|
fi
|
||||||
|
if [[ -x "$SCRIPT_DIR/patch-nginx-torob.sh" ]]; then
|
||||||
|
"$SCRIPT_DIR/patch-nginx-torob.sh" "$HOST" || true
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# SSL is issued separately via ssl.sh / Super Admin "Issue SSL" — do not run
|
# 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 { PublicSmsModule } from './public-sms/public-sms.module';
|
||||||
import { PaymentsModule } from './payments/payments.module';
|
import { PaymentsModule } from './payments/payments.module';
|
||||||
import { SitemapModule } from './sitemap/sitemap.module';
|
import { SitemapModule } from './sitemap/sitemap.module';
|
||||||
|
import { TorobModule } from './torob/torob.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -92,6 +93,7 @@ import { SitemapModule } from './sitemap/sitemap.module';
|
|||||||
AiPromptsModule,
|
AiPromptsModule,
|
||||||
PublicSmsModule,
|
PublicSmsModule,
|
||||||
SitemapModule,
|
SitemapModule,
|
||||||
|
TorobModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common';
|
import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import {
|
import {
|
||||||
MAIL_DMARC,
|
DMARC_NAME,
|
||||||
|
LEGACY_DKIM_NAME,
|
||||||
MAIL_HOSTNAME,
|
MAIL_HOSTNAME,
|
||||||
MAIL_IPV4,
|
MAIL_IPV4,
|
||||||
MAIL_MX_PRIORITY,
|
MAIL_MX_PRIORITY,
|
||||||
@@ -113,6 +114,15 @@ export class ArvanDnsService {
|
|||||||
}, created, updated, skipped);
|
}, created, updated, skipped);
|
||||||
|
|
||||||
await this.replaceExtraMx(host, auth, existing, removed);
|
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, {
|
await this.upsertTyped(host, auth, existing, {
|
||||||
type: 'mx',
|
type: 'mx',
|
||||||
name: '@',
|
name: '@',
|
||||||
@@ -140,8 +150,6 @@ export class ArvanDnsService {
|
|||||||
value: { host: `${MAIL_HOSTNAME}.`, host_header: 'source' },
|
value: { host: `${MAIL_HOSTNAME}.`, host_header: 'source' },
|
||||||
}, created, updated, skipped);
|
}, created, updated, skipped);
|
||||||
|
|
||||||
await this.ensureDmarcIfMissing(host, auth, existing, created, skipped);
|
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Arvan mail DNS ${host}: created=${created.join(',') || '-'} updated=${updated.join(',') || '-'} skipped=${skipped.join(',') || '-'} removed=${removed.join(',') || '-'}`,
|
`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');
|
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(
|
private async replaceExtraMx(
|
||||||
host: string,
|
host: string,
|
||||||
auth: string,
|
auth: string,
|
||||||
existing: ArvanDnsRecord[],
|
existing: ArvanDnsRecord[],
|
||||||
removed: string[],
|
removed: string[],
|
||||||
) {
|
) {
|
||||||
const mx = existing.filter(
|
const mx = existing.filter((item) => item.type === 'mx');
|
||||||
(item) => this.relativeName(item.name, host) === '@' && item.type === 'mx',
|
|
||||||
);
|
|
||||||
const keep = mx.find((item) => {
|
const keep = mx.find((item) => {
|
||||||
const current = item.value ?? {};
|
const name = this.relativeName(item.name, host);
|
||||||
return this.fqdn(this.mxHost(current)) === MAIL_HOSTNAME;
|
return name === '@' && this.fqdn(this.mxHost(item.value ?? {})) === MAIL_HOSTNAME;
|
||||||
});
|
});
|
||||||
for (const item of mx) {
|
for (const item of mx) {
|
||||||
if (keep && item.id === keep.id) continue;
|
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}` : ''}`,
|
`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);
|
const index = existing.indexOf(item);
|
||||||
if (index >= 0) existing.splice(index, 1);
|
if (index >= 0) existing.splice(index, 1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -162,6 +162,7 @@ export class BusinessSettingsService {
|
|||||||
patch.store = {
|
patch.store = {
|
||||||
onlineSellEnabled:
|
onlineSellEnabled:
|
||||||
dto.store.onlineSellEnabled ?? current.store.onlineSellEnabled,
|
dto.store.onlineSellEnabled ?? current.store.onlineSellEnabled,
|
||||||
|
torobEnabled: dto.store.torobEnabled ?? current.store.torobEnabled,
|
||||||
orderProcessSteps: dto.store.orderProcessSteps
|
orderProcessSteps: dto.store.orderProcessSteps
|
||||||
? normalizeBusinessSettings({
|
? normalizeBusinessSettings({
|
||||||
store: { orderProcessSteps: dto.store.orderProcessSteps },
|
store: { orderProcessSteps: dto.store.orderProcessSteps },
|
||||||
|
|||||||
@@ -95,6 +95,8 @@ export type EPaymentSettings = {
|
|||||||
/** Per-business store / sales settings. */
|
/** Per-business store / sales settings. */
|
||||||
export type StoreSettings = {
|
export type StoreSettings = {
|
||||||
onlineSellEnabled: boolean;
|
onlineSellEnabled: boolean;
|
||||||
|
/** When true, Torob Product API v3 is available for this tenant (store module still required). */
|
||||||
|
torobEnabled: boolean;
|
||||||
orderProcessSteps: OrderProcessStep[];
|
orderProcessSteps: OrderProcessStep[];
|
||||||
ePayment: EPaymentSettings;
|
ePayment: EPaymentSettings;
|
||||||
};
|
};
|
||||||
@@ -300,6 +302,7 @@ export const DEFAULT_BUSINESS_SETTINGS: BusinessSettings = {
|
|||||||
},
|
},
|
||||||
store: {
|
store: {
|
||||||
onlineSellEnabled: true,
|
onlineSellEnabled: true,
|
||||||
|
torobEnabled: false,
|
||||||
orderProcessSteps: DEFAULT_ORDER_PROCESS_STEPS,
|
orderProcessSteps: DEFAULT_ORDER_PROCESS_STEPS,
|
||||||
ePayment: { ...DEFAULT_EPAYMENT_SETTINGS },
|
ePayment: { ...DEFAULT_EPAYMENT_SETTINGS },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -412,6 +412,10 @@ export function normalizeBusinessSettings(raw: unknown): BusinessSettings {
|
|||||||
store.onlineSellEnabled,
|
store.onlineSellEnabled,
|
||||||
DEFAULT_BUSINESS_SETTINGS.store.onlineSellEnabled,
|
DEFAULT_BUSINESS_SETTINGS.store.onlineSellEnabled,
|
||||||
),
|
),
|
||||||
|
torobEnabled: readBoolean(
|
||||||
|
store.torobEnabled,
|
||||||
|
DEFAULT_BUSINESS_SETTINGS.store.torobEnabled,
|
||||||
|
),
|
||||||
orderProcessSteps: readOrderProcessSteps(store.orderProcessSteps),
|
orderProcessSteps: readOrderProcessSteps(store.orderProcessSteps),
|
||||||
ePayment: normalizeEPaymentSettings(store.ePayment),
|
ePayment: normalizeEPaymentSettings(store.ePayment),
|
||||||
},
|
},
|
||||||
@@ -457,6 +461,7 @@ export function mergeBusinessSettings(
|
|||||||
store: {
|
store: {
|
||||||
onlineSellEnabled:
|
onlineSellEnabled:
|
||||||
patch.store?.onlineSellEnabled ?? current.store.onlineSellEnabled,
|
patch.store?.onlineSellEnabled ?? current.store.onlineSellEnabled,
|
||||||
|
torobEnabled: patch.store?.torobEnabled ?? current.store.torobEnabled,
|
||||||
orderProcessSteps:
|
orderProcessSteps:
|
||||||
patch.store?.orderProcessSteps ?? current.store.orderProcessSteps,
|
patch.store?.orderProcessSteps ?? current.store.orderProcessSteps,
|
||||||
ePayment: mergeEPaymentSettings(
|
ePayment: mergeEPaymentSettings(
|
||||||
|
|||||||
@@ -167,6 +167,10 @@ class StoreSettingsDto {
|
|||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
onlineSellEnabled?: boolean;
|
onlineSellEnabled?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
torobEnabled?: boolean;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@ValidateNested({ each: true })
|
@ValidateNested({ each: true })
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common';
|
import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import {
|
import {
|
||||||
MAIL_DMARC,
|
DMARC_NAME,
|
||||||
|
LEGACY_DKIM_NAME,
|
||||||
MAIL_HOSTNAME,
|
MAIL_HOSTNAME,
|
||||||
MAIL_IPV4,
|
MAIL_IPV4,
|
||||||
MAIL_MX_PRIORITY,
|
MAIL_MX_PRIORITY,
|
||||||
@@ -101,6 +102,24 @@ export class CloudflareDnsService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await this.replaceExtraMx(zoneId, token, existing, host, removed);
|
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(
|
await this.upsertCf(
|
||||||
zoneId,
|
zoneId,
|
||||||
token,
|
token,
|
||||||
@@ -169,24 +188,6 @@ export class CloudflareDnsService {
|
|||||||
skipped,
|
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(
|
this.logger.log(
|
||||||
`Cloudflare mail DNS ${host}: created=${created.join(',') || '-'} updated=${updated.join(',') || '-'} skipped=${skipped.join(',') || '-'} removed=${removed.join(',') || '-'}`,
|
`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,
|
zone: string,
|
||||||
removed: string[],
|
removed: string[],
|
||||||
) {
|
) {
|
||||||
const mx = existing.filter(
|
const mx = existing.filter((rec) => rec.type === 'MX');
|
||||||
(rec) => rec.type === 'MX' && this.relativeName(rec.name, zone) === '@',
|
|
||||||
);
|
|
||||||
const keep = mx.find(
|
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) {
|
for (const rec of mx) {
|
||||||
if (keep && rec.id === keep.id) continue;
|
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}` : ''}`,
|
`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);
|
const index = existing.indexOf(rec);
|
||||||
if (index >= 0) existing.splice(index, 1);
|
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_HOSTNAME = 'mail.meshkee.com';
|
||||||
export const MAIL_MX_PRIORITY = 10;
|
export const MAIL_MX_PRIORITY = 10;
|
||||||
export const MAIL_SPF = `v=spf1 ip4:${MAIL_IPV4} -all`;
|
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 = {
|
export type MailDkimKey = {
|
||||||
selector: string;
|
selector: string;
|
||||||
|
|||||||
@@ -544,7 +544,7 @@ export class ProductsService {
|
|||||||
entityType: MediaEntityType.product,
|
entityType: MediaEntityType.product,
|
||||||
entityId: product.id,
|
entityId: product.id,
|
||||||
},
|
},
|
||||||
include: { category: true },
|
include: { category: { include: { parent: true } } },
|
||||||
}),
|
}),
|
||||||
this.prisma.mediaAttachment.findMany({
|
this.prisma.mediaAttachment.findMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -586,6 +586,8 @@ export class ProductsService {
|
|||||||
categoryId: categoryAssignment?.categoryId.toString() ?? null,
|
categoryId: categoryAssignment?.categoryId.toString() ?? null,
|
||||||
categoryName: categoryAssignment?.category.name ?? '',
|
categoryName: categoryAssignment?.category.name ?? '',
|
||||||
categoryNameFa: categoryAssignment?.category.nameFa ?? '',
|
categoryNameFa: categoryAssignment?.category.nameFa ?? '',
|
||||||
|
categoryParentName: categoryAssignment?.category.parent?.name ?? '',
|
||||||
|
categoryParentNameFa: categoryAssignment?.category.parent?.nameFa ?? '',
|
||||||
brandId: product.brandId?.toString() ?? null,
|
brandId: product.brandId?.toString() ?? null,
|
||||||
brand: this.brands.serializeBrandSummary(product.brand),
|
brand: this.brands.serializeBrandSummary(product.brand),
|
||||||
tags: Array.isArray(metadata.tags)
|
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.).
|
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
|
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.
|
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
|
### Typical bootstrap sequence
|
||||||
1. `GET /tenants/{domain}` → branding + `businessId` + `specialProductsSource` (`product` or `store_item`)
|
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",
|
"name": "Homepage",
|
||||||
"item": [
|
"item": [
|
||||||
|
|||||||
@@ -118,6 +118,17 @@
|
|||||||
There is no public <code>product-category-variation-fields</code> route.
|
There is no public <code>product-category-variation-fields</code> route.
|
||||||
</p>
|
</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>
|
<h2>For a new website AI / designer</h2>
|
||||||
<ol>
|
<ol>
|
||||||
<li>Open <a href="/docs/website/AI_PROMPT.md">AI_PROMPT.md</a> and paste it into the AI chat.</li>
|
<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": "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"
|
"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": {
|
"/tenants/{domain}/blogs": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
|
|||||||
Reference in New Issue
Block a user