diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index 3e958ff..43eaa4c 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -1,7 +1,7 @@ # Meshkee CMS API — Project Context > Living reference for developers and AI assistants working on this codebase. -> Last updated: August 21, 2026 +> Last updated: September 3, 2026 ## What This Project Is @@ -272,6 +272,7 @@ All routes are prefixed with `/api/v1`. | GET | `/tenants/:host/sitemap-workshops.xml` | Published workshops `/workshops/{slug}` | | GET | `/tenants/:host/sitemap-user-products.xml` | Published user products `/user-products/{slug}` (module `customer_products`) | | GET | `/tenants/:host/robots.txt` | robots.txt pointing to apex `/sitemap.xml` | +| POST | `/tenants/:host/torob_api/v3/products` | Torob Product API v3 (JWT). Requires store module + `settings.store.torobEnabled`. Nginx: `POST https://{host}/torob_api/v3/products` | | GET | `/tenants/:host/categories/by-id/:categoryId` | Public category by id (for category landing pages) | | GET | `/tenants/:host/categories/by-slug/:slug` | Public category by CMS slug | | GET | `/tenants/:host/products/by-id/:productId` | Public product detail by id (for `{id}/{nameFaSlug}` storefront routes) | @@ -643,7 +644,7 @@ See `.env.example` for the full list. Key groups: - Multi-tenant auth (register, login, passwordless OTP login, reset password via SMS, profile) - Super admin: users, businesses, domains, system business categories - Super admin add/update domain upserts tenant DNS via ArvanCloud or Cloudflare (`@` ANAME/CNAME, `www`/`business`/`customer`/`api` CNAMEs; Cloudflare DNS-only) -- Super admin Websites ⋯ **Email DNS** upserts Stalwart mail records (mail A `185.214.101.41`, MX `mail.meshkee.com`, SPF, two DKIM TXT, autoconfig/autodiscover) on Arvan or Cloudflare +- Super admin Websites ⋯ **Email DNS** upserts Stalwart mail records (mail A `185.214.101.41`, MX `mail.meshkee.com`, SPF, two DKIM TXT, autoconfig/autodiscover) on Arvan or Cloudflare; removes extra MX, `dkim._domainkey`, and `_dmarc` - Super admin: selective migrate-from-old + purge-data (portfolio categories + portfolios; oversized images resized to max 1280×1280; purge removes portfolios + images) - Business team management - Media upload (S3 + Sharp) @@ -651,6 +652,7 @@ See `.env.example` for the full list. Key groups: - Products CRUD - Product variation values (which options a product offers) - Store items / product variants (price, stock, SKU) +- Torob Product API v3 (`POST /tenants/:host/torob_api/v3/products`) for store-module websites; nginx proxies `/torob_api/v3/products` on the shop apex - Shopping cart + checkout + orders (customer + admin) - Online e-payment (Mellat + ZarinPal; stubs for SEP / Snapp Pay / DigiPay) - Product technical info diff --git a/docs/website-api/AI_PROMPT.md b/docs/website-api/AI_PROMPT.md index aa97bd7..3531a54 100644 --- a/docs/website-api/AI_PROMPT.md +++ b/docs/website-api/AI_PROMPT.md @@ -28,6 +28,7 @@ You are building a **Meshkee business website (storefront)**. You must use the M 6. Do not call dashboard/CMS routes (`/businesses/.../products` write APIs, media upload, domain-admin, etc.). 7. **Partner SMS** (`POST /public/sms/send`) is for external partner backends with an issued `X-Api-Key` only — not for normal storefront UI. See https://api.meshkee.com/docs/website/SMS.md 8. **Technical details (labels + values):** Product/user-product detail responses may include `technicalValues` with **values only** (`fieldId` + `textValue` / `optionId` / `optionIds` — **no field labels**). To render a label→value specs table you **must** call the matching `.../technical-info` endpoint and join `form.fields[].id` ↔ `values[].fieldId`. Never invent a separate “variation fields” or “category fields” public route — those do not exist on the website API. +9. **Torob:** Do **not** add a Next.js route for `/torob_api`. Meshkee nginx on the store apex proxies `POST /torob_api/v3/products` to the API. Only businesses with the **store** module **and** Store settings → Torob switch on return products (otherwise 404). Storefront UI must not call this endpoint. ### Typical bootstrap sequence 1. `GET /tenants/{domain}` → branding + `businessId` + `specialProductsSource` (`product` or `store_item`) diff --git a/docs/website-api/Meshkee-Website-API.postman_collection.json b/docs/website-api/Meshkee-Website-API.postman_collection.json index 9283e6b..c153120 100644 --- a/docs/website-api/Meshkee-Website-API.postman_collection.json +++ b/docs/website-api/Meshkee-Website-API.postman_collection.json @@ -1487,6 +1487,37 @@ } ] }, + { + "name": "Torob", + "item": [ + { + "name": "Product API v3 (store module)", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "X-Torob-Token", + "value": "{{torobJwt}}" + }, + { + "key": "X-Torob-Token-Version", + "value": "1" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"page\": 1,\n \"sort\": \"date_added_desc\"\n}" + }, + "url": "{{baseUrl}}/tenants/{{domain}}/torob_api/v3/products", + "description": "Torob-only. 404 if the tenant does not have the store module. Prefer the shop-domain URL POST https://{domain}/torob_api/v3/products after nginx is patched." + } + } + ] + }, { "name": "Homepage", "item": [ diff --git a/docs/website-api/index.html b/docs/website-api/index.html index e2cc549..b62d41a 100644 --- a/docs/website-api/index.html +++ b/docs/website-api/index.html @@ -118,6 +118,17 @@ There is no public product-category-variation-fields route.

+

Torob (price comparison)

+

+ POST /tenants/{domain}/torob_api/v3/products is for + Torob, not storefront JavaScript. On the live shop, + nginx proxies POST https://{domain}/torob_api/v3/products + to that API. It only returns catalog store items when the business has + the store module enabled and Store settings → + Torob is on; otherwise 404. + Do not implement this path in Next.js. +

+

For a new website AI / designer

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

    +

    Torob (price comparison)

    +

    + POST /tenants/{domain}/torob_api/v3/products is for + Torob, not storefront JavaScript. On the live shop, + nginx proxies POST https://{domain}/torob_api/v3/products + to that API. It only returns catalog store items when the business has + the store module enabled and Store settings → + Torob is on; otherwise 404. + Do not implement this path in Next.js. +

    +

    For a new website AI / designer

    1. Open AI_PROMPT.md and paste it into the AI chat.
    2. diff --git a/src/website-docs/static/openapi.json b/src/website-docs/static/openapi.json index 22668e6..0d07f54 100644 --- a/src/website-docs/static/openapi.json +++ b/src/website-docs/static/openapi.json @@ -44,6 +44,10 @@ { "name": "Store" }, + { + "name": "Torob", + "description": "Product API v3 for Torob. Called by Torob (not storefront JS). Nginx on the shop apex proxies POST /torob_api/v3/products. Only tenants with the store module enabled; otherwise 404." + }, { "name": "Blogs" }, @@ -1422,6 +1426,111 @@ } } }, + "/tenants/{domain}/torob_api/v3/products": { + "post": { + "tags": [ + "Torob" + ], + "summary": "Torob Product API v3 (store module only)", + "description": "Torob POSTs here (or to `https://{domain}/torob_api/v3/products` which nginx proxies). JWT in `X-Torob-Token` (EdDSA, aud = shop host). Returns 404 when the tenant does not have the **store** module. Page size is 100.", + "parameters": [ + { + "$ref": "#/components/parameters/domain" + }, + { + "name": "X-Torob-Token", + "in": "header", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "X-Torob-Token-Version", + "in": "header", + "schema": { + "type": "string", + "example": "1" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "required": [ + "page", + "sort" + ], + "properties": { + "page": { + "type": "integer", + "minimum": 1 + }, + "sort": { + "type": "string", + "enum": [ + "date_added_desc", + "date_updated_desc" + ] + } + } + }, + { + "type": "object", + "required": [ + "page_urls" + ], + "properties": { + "page_urls": { + "type": "array", + "minItems": 1, + "items": { + "type": "string" + } + } + } + }, + { + "type": "object", + "required": [ + "page_uniques" + ], + "properties": { + "page_uniques": { + "type": "array", + "minItems": 1, + "items": { + "type": "string" + } + } + } + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "{ api_version: torob_api_v3, current_page, total, max_pages, products[] }" + }, + "400": { + "description": "{ error: string }" + }, + "401": { + "description": "Invalid or missing Torob JWT" + }, + "404": { + "description": "Unknown domain, store module off, or Torob switch off in store settings" + } + } + } + }, "/tenants/{domain}/blogs": { "get": { "tags": [