From 7cef24336aa10db340d675d6bf62d4cfb55ac6b8 Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Sat, 22 Aug 2026 23:43:25 +0330 Subject: [PATCH] Use /{id}/{fa-slug} detail URLs in sitemaps and add public by-id lookups. Default sitemap templates now emit SEO-friendly Farsi path segments with stable CMS ids; storefronts resolve content via new by-id public endpoints. Co-authored-by: Cursor --- docs/PROJECT_CONTEXT.md | 5 +- docs/website-api/AI_PROMPT.md | 11 +- docs/website-api/openapi.json | 132 +++++++++++++++++++++++- src/blogs/blogs.controller.ts | 5 + src/blogs/blogs.service.ts | 31 ++++++ src/portfolios/portfolios.controller.ts | 8 ++ src/portfolios/portfolios.service.ts | 31 ++++++ src/products/products.controller.ts | 29 ++++++ src/products/products.service.ts | 33 ++++++ src/sitemap/seo-slug.util.ts | 22 ++++ src/sitemap/sitemap-config.util.ts | 6 +- src/sitemap/sitemap-xml.util.ts | 9 +- src/sitemap/sitemap.constants.ts | 6 +- src/sitemap/sitemap.service.ts | 49 +++++++-- src/website-docs/static/AI_PROMPT.md | 11 +- src/website-docs/static/openapi.json | 132 +++++++++++++++++++++++- 16 files changed, 495 insertions(+), 25 deletions(-) create mode 100644 src/sitemap/seo-slug.util.ts diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index 6d331b1..e4e6e51 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -262,8 +262,11 @@ All routes are prefixed with `/api/v1`. | POST | `/auth/verify-otp` | Verify OTP (marks cell verified; no tokens) | | POST | `/auth/handoff/consume` | One-time SSO ticket → tokens (customer → business dashboard) | | GET | `/tenants/:host` | Resolve business from domain (`specialProductsSource`, `enabledModules`, `homeCharts`, `ePayment`) | -| GET | `/tenants/:host/sitemap.xml` | XML sitemap (static pages from synced manifest + published products/blogs/portfolios) | +| GET | `/tenants/:host/sitemap.xml` | XML sitemap (static pages + `/products|blog|portfolios/{id}/{faSlug}`) | | GET | `/tenants/:host/robots.txt` | robots.txt pointing to apex `/sitemap.xml` | +| GET | `/tenants/:host/products/by-id/:productId` | Public product detail by id (for `{id}/{nameFaSlug}` storefront routes) | +| GET | `/tenants/:host/blogs/by-id/:blogId` | Public blog by id | +| GET | `/tenants/:host/portfolios/by-id/:portfolioId` | Public portfolio by id | | POST | `/businesses/:id/website/sitemap/sync` | Import manifest from `GET https://{domain}/meshkee/sitemap-config.json` | | GET | `/tenants/:host/store-specials` | Active store specials (`source` is `product` or `store_item`) | | GET | `/tenants/:host/website/category-groups` | Homepage category rows | diff --git a/docs/website-api/AI_PROMPT.md b/docs/website-api/AI_PROMPT.md index 03129f3..1590131 100644 --- a/docs/website-api/AI_PROMPT.md +++ b/docs/website-api/AI_PROMPT.md @@ -114,9 +114,14 @@ Minimal example: ``` - Include public static routes automatically; exclude login, checkout, cart, account, and admin paths. -- Omit `templates` unless this site uses non-default detail URLs (Meshkee defaults: `/products/{slug}`, `/blog/{slug}`, `/portfolios/{slug}`). -- After deploy, business owner: **Website → Settings → Sync sitemap config** (imports the manifest once; does not rebuild XML by itself). -- Dynamic URLs (products, blogs, portfolios, …) come from the CMS automatically. Hitting `/sitemap.xml` serves a Redis-cached XML that regenerates after CMS publish/update/delete (or when cache expires). +- **Canonical detail URLs (Meshkee default for all sites):** + - Product: `/products/{id}/{nameFaSlug}` — build `nameFaSlug` from `nameFa` (fallback `title`); resolve page via `GET /tenants/{domain}/products/by-id/{id}` (slug segment is SEO-only; redirect to canonical if it drifts). + - Blog: `/blog/{id}/{titleSlug}` — `GET /tenants/{domain}/blogs/by-id/{id}` + - Portfolio: `/portfolios/{id}/{titleFaSlug}` — `GET /tenants/{domain}/portfolios/by-id/{id}` +- When linking from lists/cards, use the same `{id}/{slug}` shape (slugify Farsi title the same way: spaces → `-`, keep Persian letters). +- Omit `templates` in `sitemap-config.json` unless this site uses non-default paths (Meshkee sitemap defaults already use `{id}/{slug}`). +- After deploy, business owner: **Website → Settings → Sync sitemap config** (imports static pages; does not rebuild XML by itself). +- Dynamic URLs come from the CMS automatically. Hitting `/sitemap.xml` serves a Redis-cached XML that regenerates after CMS publish/update/delete (or when cache expires). ### On-page SEO (every public page) diff --git a/docs/website-api/openapi.json b/docs/website-api/openapi.json index c1d0320..3061ddc 100644 --- a/docs/website-api/openapi.json +++ b/docs/website-api/openapi.json @@ -195,7 +195,7 @@ "SEO" ], "summary": "XML sitemap for search engines", - "description": "Published products, blogs, and portfolios for this tenant (respects enabled modules), plus static pages and URL templates from the synced website manifest (`GET https://{domain}/meshkee/sitemap-config.json`). Default paths when not synced: `/`, `/products/{slug}`, `/blog/{slug}`, `/portfolios/{slug}`. Proxied from `https://{domain}/sitemap.xml` on the storefront. Cached in Redis; refreshed after CMS writes and manifest sync.", + "description": "Published products, blogs, and portfolios for this tenant (respects enabled modules), plus static pages and URL templates from the synced website manifest (`GET https://{domain}/meshkee/sitemap-config.json`). Default detail paths: `/products/{id}/{nameFaSlug}`, `/blog/{id}/{titleSlug}`, `/portfolios/{id}/{titleFaSlug}`. Proxied from `https://{domain}/sitemap.xml` on the storefront. Cached in Redis; refreshed after CMS writes and manifest sync.", "parameters": [ { "$ref": "#/components/parameters/domain" @@ -514,6 +514,84 @@ } } }, + "/tenants/{domain}/products/by-id/{productId}": { + "get": { + "tags": [ + "Products" + ], + "summary": "Product by id (preferred for /products/{id}/{nameFaSlug} pages)", + "parameters": [ + { + "$ref": "#/components/parameters/domain" + }, + { + "name": "productId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "{ product, relatedProducts } — same shape as product-by-slug" + } + } + } + }, + "/tenants/{domain}/products/by-id/{productId}/variations": { + "get": { + "tags": [ + "Products" + ], + "summary": "Product variations by id", + "parameters": [ + { + "$ref": "#/components/parameters/domain" + }, + { + "name": "productId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Variation tree for the product" + } + } + } + }, + "/tenants/{domain}/products/by-id/{productId}/technical-info": { + "get": { + "tags": [ + "Products" + ], + "summary": "Product technical info by id", + "parameters": [ + { + "$ref": "#/components/parameters/domain" + }, + { + "name": "productId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Technical form + values" + } + } + } + }, "/tenants/{domain}/products/{slug}": { "get": { "tags": [ @@ -950,6 +1028,32 @@ } } }, + "/tenants/{domain}/blogs/by-id/{blogId}": { + "get": { + "tags": [ + "Blogs" + ], + "summary": "Blog by id (preferred for /blog/{id}/{titleSlug} pages)", + "parameters": [ + { + "$ref": "#/components/parameters/domain" + }, + { + "name": "blogId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "{ blog }" + } + } + } + }, "/tenants/{domain}/blogs/{slug}": { "get": { "tags": [ @@ -1277,6 +1381,32 @@ } } }, + "/tenants/{domain}/portfolios/by-id/{portfolioId}": { + "get": { + "tags": [ + "Portfolios" + ], + "summary": "Portfolio by id (preferred for /portfolios/{id}/{titleFaSlug} pages)", + "parameters": [ + { + "$ref": "#/components/parameters/domain" + }, + { + "name": "portfolioId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "{ portfolio }" + } + } + } + }, "/tenants/{domain}/portfolios/{slug}": { "get": { "tags": [ diff --git a/src/blogs/blogs.controller.ts b/src/blogs/blogs.controller.ts index 2317bd6..55590ee 100644 --- a/src/blogs/blogs.controller.ts +++ b/src/blogs/blogs.controller.ts @@ -99,6 +99,11 @@ export class PublicBlogsController { return this.service.listPublic(host, query); } + @Get('by-id/:blogId') + getById(@Param('host') host: string, @Param('blogId') blogId: string) { + return this.service.getPublicById(host, blogId); + } + @Get(':blogId/comments') listComments(@Param('host') host: string, @Param('blogId') blogId: string) { return this.service.listCommentsPublic(host, blogId); diff --git a/src/blogs/blogs.service.ts b/src/blogs/blogs.service.ts index 49b93c4..cdbe52b 100644 --- a/src/blogs/blogs.service.ts +++ b/src/blogs/blogs.service.ts @@ -381,6 +381,37 @@ export class BlogsService { }; } + async getPublicById(host: string, blogIdRaw: string) { + const business = await this.tenant.resolveBusinessByDomain(host); + const businessId = business.id; + let blogId: bigint; + try { + blogId = BigInt(blogIdRaw); + } catch { + throw new NotFoundException('Blog post not found'); + } + + const blog = await this.prisma.blogs.findFirst({ + where: { + business_id: businessId, + id: blogId, + status: ContentStatus.published, + }, + include: blogInclude, + }); + + if (!blog) { + throw new NotFoundException('Blog post not found'); + } + + return { + blog: await this.serializeBlog(blog, { + includeComments: true, + approvedCommentsOnly: true, + }), + }; + } + async listCommentsPublic(host: string, blogIdRaw: string) { const business = await this.tenant.resolveBusinessByDomain(host); const businessId = business.id; diff --git a/src/portfolios/portfolios.controller.ts b/src/portfolios/portfolios.controller.ts index 6eb554c..ba9d371 100644 --- a/src/portfolios/portfolios.controller.ts +++ b/src/portfolios/portfolios.controller.ts @@ -99,6 +99,14 @@ export class PublicPortfoliosController { return this.service.listPublic(host, query); } + @Get('by-id/:portfolioId') + getById( + @Param('host') host: string, + @Param('portfolioId') portfolioId: string, + ) { + return this.service.getPublicById(host, portfolioId); + } + @Get(':portfolioId/comments') listComments( @Param('host') host: string, diff --git a/src/portfolios/portfolios.service.ts b/src/portfolios/portfolios.service.ts index e46bd36..0afee13 100644 --- a/src/portfolios/portfolios.service.ts +++ b/src/portfolios/portfolios.service.ts @@ -468,6 +468,37 @@ export class PortfoliosService { }; } + async getPublicById(host: string, portfolioIdRaw: string) { + const business = await this.tenant.resolveBusinessByDomain(host); + const businessId = business.id; + let portfolioId: bigint; + try { + portfolioId = BigInt(portfolioIdRaw); + } catch { + throw new NotFoundException('Portfolio not found'); + } + + const portfolio = await this.prisma.portfolios.findFirst({ + where: { + business_id: businessId, + id: portfolioId, + status: ContentStatus.published, + }, + include: portfolioInclude, + }); + + if (!portfolio) { + throw new NotFoundException('Portfolio not found'); + } + + return { + portfolio: await this.serializePortfolio(portfolio, { + includeComments: true, + approvedCommentsOnly: true, + }), + }; + } + async listCommentsPublic(host: string, portfolioIdRaw: string) { const business = await this.tenant.resolveBusinessByDomain(host); const businessId = business.id; diff --git a/src/products/products.controller.ts b/src/products/products.controller.ts index 262205c..9a0e15f 100644 --- a/src/products/products.controller.ts +++ b/src/products/products.controller.ts @@ -171,6 +171,35 @@ export class PublicProductsController { return this.service.listPublic(host, query); } + @Get('by-id/:productId/variations') + async getVariationsById( + @Param('host') host: string, + @Param('productId') productId: string, + ) { + const detail = await this.service.getPublicById(host, productId); + return this.variationValuesService.getPublicForProduct( + detail.product.businessId, + detail.product.id, + ); + } + + @Get('by-id/:productId/technical-info') + async getTechnicalInfoById( + @Param('host') host: string, + @Param('productId') productId: string, + ) { + const detail = await this.service.getPublicById(host, productId); + return this.technicalInfoService.getPublicForProduct( + detail.product.businessId, + detail.product.id, + ); + } + + @Get('by-id/:productId') + getById(@Param('host') host: string, @Param('productId') productId: string) { + return this.service.getPublicById(host, productId); + } + @Get(':slug/variations') async getVariations(@Param('host') host: string, @Param('slug') slug: string) { const { businessId, productId } = await this.service.assertPublishedProductBySlug( diff --git a/src/products/products.service.ts b/src/products/products.service.ts index cecbb70..b21763e 100644 --- a/src/products/products.service.ts +++ b/src/products/products.service.ts @@ -175,6 +175,39 @@ export class ProductsService { throw new NotFoundException('Product not found'); } + return this.serializePublicDetail(businessId, product); + } + + async getPublicById(host: string, productIdRaw: string) { + const business = await this.tenant.resolveBusinessByDomain(host); + const businessId = business.id; + let productId: bigint; + try { + productId = BigInt(productIdRaw); + } catch { + throw new NotFoundException('Product not found'); + } + + const product = await this.prisma.product.findFirst({ + where: { + businessId, + id: productId, + status: ContentStatus.published, + }, + include: productListInclude, + }); + + if (!product) { + throw new NotFoundException('Product not found'); + } + + return this.serializePublicDetail(businessId, product); + } + + private async serializePublicDetail( + businessId: bigint, + product: Prisma.ProductGetPayload<{ include: typeof productListInclude }>, + ) { const [serializedList, relatedProducts] = await Promise.all([ this.serializePublicListItems(businessId, [product]), this.loadRelatedPublicProducts(businessId, product), diff --git a/src/sitemap/seo-slug.util.ts b/src/sitemap/seo-slug.util.ts new file mode 100644 index 0000000..161e6d3 --- /dev/null +++ b/src/sitemap/seo-slug.util.ts @@ -0,0 +1,22 @@ +/** + * Build a URL path segment from a title (supports Persian / Arabic / Latin). + * Keeps letters and digits; spaces → hyphens. + */ +export function slugifyForUrl(value: string, fallback = 'item'): string { + const slug = value + .trim() + .normalize('NFC') + .replace(/\s+/g, '-') + .replace( + /[^\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFFa-zA-Z0-9-]+/g, + '', + ) + .replace(/-+/g, '-') + .replace(/^-|-$/g, ''); + + return slug || fallback; +} + +export function encodePathSegment(value: string): string { + return encodeURIComponent(value); +} diff --git a/src/sitemap/sitemap-config.util.ts b/src/sitemap/sitemap-config.util.ts index 74f6e81..e8fbfb2 100644 --- a/src/sitemap/sitemap-config.util.ts +++ b/src/sitemap/sitemap-config.util.ts @@ -42,7 +42,11 @@ function normalizeStoredTemplates( const templates: NonNullable = {}; for (const [key, template] of Object.entries(raw)) { - if (typeof template === 'string' && template.includes('{slug}')) { + if ( + typeof template === 'string' && + template.includes('{slug}') && + template.trim().startsWith('/') + ) { templates[key as keyof typeof templates] = template.trim(); } } diff --git a/src/sitemap/sitemap-xml.util.ts b/src/sitemap/sitemap-xml.util.ts index 528a60a..5c1540b 100644 --- a/src/sitemap/sitemap-xml.util.ts +++ b/src/sitemap/sitemap-xml.util.ts @@ -51,8 +51,13 @@ export function buildSitemapXml(entries: SitemapUrlEntry[]): string { ].join('\n'); } -export function applyPathTemplate(template: string, slug: string): string { - return template.replace('{slug}', encodeURIComponent(slug)); +export function applyPathTemplate( + template: string, + vars: { id?: string; slug: string }, +): string { + return template + .replaceAll('{id}', vars.id != null ? encodeURIComponent(vars.id) : '') + .replaceAll('{slug}', encodeURIComponent(vars.slug)); } export function buildAbsoluteUrl(baseUrl: string, path: string): string { diff --git a/src/sitemap/sitemap.constants.ts b/src/sitemap/sitemap.constants.ts index c707e18..909f37a 100644 --- a/src/sitemap/sitemap.constants.ts +++ b/src/sitemap/sitemap.constants.ts @@ -5,9 +5,9 @@ export const SITEMAP_URLSET_NS = 'http://www.sitemaps.org/schemas/sitemap/0.9'; /** Default storefront path templates (overridable via /meshkee/sitemap-config.json). */ export const DEFAULT_SITEMAP_PATH_TEMPLATES = { - product: '/products/{slug}', - blog: '/blog/{slug}', - portfolio: '/portfolios/{slug}', + product: '/products/{id}/{slug}', + blog: '/blog/{id}/{slug}', + portfolio: '/portfolios/{id}/{slug}', } as const; export const WEBSITE_SITEMAP_CONFIG_PATH = '/meshkee/sitemap-config.json'; diff --git a/src/sitemap/sitemap.service.ts b/src/sitemap/sitemap.service.ts index 6e9f199..42e6ae1 100644 --- a/src/sitemap/sitemap.service.ts +++ b/src/sitemap/sitemap.service.ts @@ -1,11 +1,12 @@ import { Injectable, Logger } from '@nestjs/common'; -import { ContentStatus } from '@prisma/client'; +import { ContentStatus, Prisma } from '@prisma/client'; import { normalizeBusinessSettings } from '../business-settings/business-settings.util'; import type { BusinessModuleId } from '../business-settings/business-settings.types'; import { PrismaService } from '../prisma/prisma.service'; import { RedisService } from '../redis/redis.service'; import { TenantService } from '../tenant/tenant.service'; import { resolveSitemapConfig } from './sitemap-config.util'; +import { slugifyForUrl } from './seo-slug.util'; import { SITEMAP_CACHE_TTL_SECONDS } from './sitemap.constants'; import { applyPathTemplate, @@ -14,7 +15,8 @@ import { type SitemapUrlEntry, } from './sitemap-xml.util'; -type PublishedSlugRow = { +type PublishedDetailRow = { + id: bigint; slug: string; updatedAt: Date; }; @@ -152,13 +154,25 @@ export class SitemapService { status: ContentStatus.published, }, select: { - slug: true, + id: true, + title: true, + content: true, updatedAt: true, }, orderBy: [{ updatedAt: 'desc' }, { id: 'desc' }], }); - return rows.map((row) => this.toEntry(baseUrl, template, row)); + return rows.map((row) => { + const content = this.asRecord(row.content); + const nameFa = + typeof content.nameFa === 'string' ? content.nameFa.trim() : ''; + const pathSlug = slugifyForUrl(nameFa || row.title, 'product'); + return this.toEntry(baseUrl, template, { + id: row.id, + slug: pathSlug, + updatedAt: row.updatedAt, + }); + }); } private async loadPublishedBlogs( @@ -172,7 +186,8 @@ export class SitemapService { status: ContentStatus.published, }, select: { - slug: true, + id: true, + title: true, updated_at: true, }, orderBy: [{ updated_at: 'desc' }, { id: 'desc' }], @@ -180,7 +195,8 @@ export class SitemapService { return rows.map((row) => this.toEntry(baseUrl, template, { - slug: row.slug, + id: row.id, + slug: slugifyForUrl(row.title, 'blog'), updatedAt: row.updated_at, }), ); @@ -197,7 +213,9 @@ export class SitemapService { status: ContentStatus.published, }, select: { - slug: true, + id: true, + title: true, + title_fa: true, updated_at: true, }, orderBy: [{ updated_at: 'desc' }, { id: 'desc' }], @@ -205,7 +223,8 @@ export class SitemapService { return rows.map((row) => this.toEntry(baseUrl, template, { - slug: row.slug, + id: row.id, + slug: slugifyForUrl(row.title_fa || row.title, 'portfolio'), updatedAt: row.updated_at, }), ); @@ -214,9 +233,12 @@ export class SitemapService { private toEntry( baseUrl: string, template: string, - row: PublishedSlugRow, + row: PublishedDetailRow, ): SitemapUrlEntry { - const path = applyPathTemplate(template, row.slug); + const path = applyPathTemplate(template, { + id: row.id.toString(), + slug: row.slug, + }); return { loc: buildAbsoluteUrl(baseUrl, path), lastmod: row.updatedAt, @@ -224,4 +246,11 @@ export class SitemapService { priority: 0.7, }; } + + private asRecord(value: Prisma.JsonValue): Record { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return value as Record; + } + return {}; + } } diff --git a/src/website-docs/static/AI_PROMPT.md b/src/website-docs/static/AI_PROMPT.md index 03129f3..1590131 100644 --- a/src/website-docs/static/AI_PROMPT.md +++ b/src/website-docs/static/AI_PROMPT.md @@ -114,9 +114,14 @@ Minimal example: ``` - Include public static routes automatically; exclude login, checkout, cart, account, and admin paths. -- Omit `templates` unless this site uses non-default detail URLs (Meshkee defaults: `/products/{slug}`, `/blog/{slug}`, `/portfolios/{slug}`). -- After deploy, business owner: **Website → Settings → Sync sitemap config** (imports the manifest once; does not rebuild XML by itself). -- Dynamic URLs (products, blogs, portfolios, …) come from the CMS automatically. Hitting `/sitemap.xml` serves a Redis-cached XML that regenerates after CMS publish/update/delete (or when cache expires). +- **Canonical detail URLs (Meshkee default for all sites):** + - Product: `/products/{id}/{nameFaSlug}` — build `nameFaSlug` from `nameFa` (fallback `title`); resolve page via `GET /tenants/{domain}/products/by-id/{id}` (slug segment is SEO-only; redirect to canonical if it drifts). + - Blog: `/blog/{id}/{titleSlug}` — `GET /tenants/{domain}/blogs/by-id/{id}` + - Portfolio: `/portfolios/{id}/{titleFaSlug}` — `GET /tenants/{domain}/portfolios/by-id/{id}` +- When linking from lists/cards, use the same `{id}/{slug}` shape (slugify Farsi title the same way: spaces → `-`, keep Persian letters). +- Omit `templates` in `sitemap-config.json` unless this site uses non-default paths (Meshkee sitemap defaults already use `{id}/{slug}`). +- After deploy, business owner: **Website → Settings → Sync sitemap config** (imports static pages; does not rebuild XML by itself). +- Dynamic URLs come from the CMS automatically. Hitting `/sitemap.xml` serves a Redis-cached XML that regenerates after CMS publish/update/delete (or when cache expires). ### On-page SEO (every public page) diff --git a/src/website-docs/static/openapi.json b/src/website-docs/static/openapi.json index c1d0320..3061ddc 100644 --- a/src/website-docs/static/openapi.json +++ b/src/website-docs/static/openapi.json @@ -195,7 +195,7 @@ "SEO" ], "summary": "XML sitemap for search engines", - "description": "Published products, blogs, and portfolios for this tenant (respects enabled modules), plus static pages and URL templates from the synced website manifest (`GET https://{domain}/meshkee/sitemap-config.json`). Default paths when not synced: `/`, `/products/{slug}`, `/blog/{slug}`, `/portfolios/{slug}`. Proxied from `https://{domain}/sitemap.xml` on the storefront. Cached in Redis; refreshed after CMS writes and manifest sync.", + "description": "Published products, blogs, and portfolios for this tenant (respects enabled modules), plus static pages and URL templates from the synced website manifest (`GET https://{domain}/meshkee/sitemap-config.json`). Default detail paths: `/products/{id}/{nameFaSlug}`, `/blog/{id}/{titleSlug}`, `/portfolios/{id}/{titleFaSlug}`. Proxied from `https://{domain}/sitemap.xml` on the storefront. Cached in Redis; refreshed after CMS writes and manifest sync.", "parameters": [ { "$ref": "#/components/parameters/domain" @@ -514,6 +514,84 @@ } } }, + "/tenants/{domain}/products/by-id/{productId}": { + "get": { + "tags": [ + "Products" + ], + "summary": "Product by id (preferred for /products/{id}/{nameFaSlug} pages)", + "parameters": [ + { + "$ref": "#/components/parameters/domain" + }, + { + "name": "productId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "{ product, relatedProducts } — same shape as product-by-slug" + } + } + } + }, + "/tenants/{domain}/products/by-id/{productId}/variations": { + "get": { + "tags": [ + "Products" + ], + "summary": "Product variations by id", + "parameters": [ + { + "$ref": "#/components/parameters/domain" + }, + { + "name": "productId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Variation tree for the product" + } + } + } + }, + "/tenants/{domain}/products/by-id/{productId}/technical-info": { + "get": { + "tags": [ + "Products" + ], + "summary": "Product technical info by id", + "parameters": [ + { + "$ref": "#/components/parameters/domain" + }, + { + "name": "productId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Technical form + values" + } + } + } + }, "/tenants/{domain}/products/{slug}": { "get": { "tags": [ @@ -950,6 +1028,32 @@ } } }, + "/tenants/{domain}/blogs/by-id/{blogId}": { + "get": { + "tags": [ + "Blogs" + ], + "summary": "Blog by id (preferred for /blog/{id}/{titleSlug} pages)", + "parameters": [ + { + "$ref": "#/components/parameters/domain" + }, + { + "name": "blogId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "{ blog }" + } + } + } + }, "/tenants/{domain}/blogs/{slug}": { "get": { "tags": [ @@ -1277,6 +1381,32 @@ } } }, + "/tenants/{domain}/portfolios/by-id/{portfolioId}": { + "get": { + "tags": [ + "Portfolios" + ], + "summary": "Portfolio by id (preferred for /portfolios/{id}/{titleFaSlug} pages)", + "parameters": [ + { + "$ref": "#/components/parameters/domain" + }, + { + "name": "portfolioId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "{ portfolio }" + } + } + } + }, "/tenants/{domain}/portfolios/{slug}": { "get": { "tags": [