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 <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-22 23:43:25 +03:30
co-authored by Cursor
parent 1b16e79d3f
commit 7cef24336a
16 changed files with 495 additions and 25 deletions
+4 -1
View File
@@ -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 |
+8 -3
View File
@@ -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)
+131 -1
View File
@@ -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": [
+5
View File
@@ -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);
+31
View File
@@ -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;
+8
View File
@@ -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,
+31
View File
@@ -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;
+29
View File
@@ -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(
+33
View File
@@ -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),
+22
View File
@@ -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);
}
+5 -1
View File
@@ -42,7 +42,11 @@ function normalizeStoredTemplates(
const templates: NonNullable<WebsiteSitemapConfig['templates']> = {};
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();
}
}
+7 -2
View File
@@ -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 {
+3 -3
View File
@@ -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';
+39 -10
View File
@@ -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<string, unknown> {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return value as Record<string, unknown>;
}
return {};
}
}
+8 -3
View File
@@ -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)
+131 -1
View File
@@ -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": [