Include product category URLs in sitemaps and document by-slug lookup.

Adds /products/category/{slug} to default sitemap generation and a public categories/by-slug endpoint for storefront category pages.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-23 00:15:26 +03:30
co-authored by Cursor
parent df17925d3a
commit c4367d8eb0
15 changed files with 182 additions and 10 deletions
+2 -1
View File
@@ -262,8 +262,9 @@ 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 + `/products|blog|portfolios/{id}/{faSlug}`) |
| GET | `/tenants/:host/sitemap.xml` | XML sitemap (static + categories `/products/category/{slug}` + `/products|blog|portfolios/{id}/{faSlug}`) |
| GET | `/tenants/:host/robots.txt` | robots.txt pointing to apex `/sitemap.xml` |
| GET | `/tenants/:host/categories/by-slug/:slug` | Public category by slug (`?entityType=product` default) |
| 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 |
+3 -2
View File
@@ -116,10 +116,11 @@ Minimal example:
- Include public static routes automatically; exclude login, checkout, cart, account, and admin paths.
- **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).
- Product category: `/products/category/{categorySlug}` — use category `slug` from `GET /tenants/{domain}/categories?entityType=product`; resolve via `GET /tenants/{domain}/categories/by-slug/{slug}?entityType=product`, then list products with `categoryId`.
- 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}`).
- When linking from lists/cards, use the same `{id}/{slug}` shape for details (slugify Farsi title: spaces → `-`, keep Persian letters). Category links use `/products/category/{slug}`.
- Omit `templates` in `sitemap-config.json` unless this site uses non-default paths (Meshkee sitemap defaults already use `{id}/{slug}` and `/products/category/{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).
+44 -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 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.",
"description": "Published products, product categories, 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}`, `/products/category/{categorySlug}`, `/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"
@@ -446,6 +446,49 @@
}
}
},
"/tenants/{domain}/categories/by-slug/{slug}": {
"get": {
"tags": [
"Categories"
],
"summary": "Category by slug (for /products/category/{categorySlug} pages)",
"parameters": [
{
"$ref": "#/components/parameters/domain"
},
{
"name": "slug",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "entityType",
"in": "query",
"schema": {
"type": "string",
"enum": [
"product",
"blog",
"portfolio",
"video"
],
"default": "product"
}
}
],
"responses": {
"200": {
"description": "{ category } — use category.id as categoryId when listing products"
},
"404": {
"description": "Category not found"
}
}
}
},
"/tenants/{domain}/products": {
"get": {
"tags": [
@@ -128,6 +128,7 @@ export type SitemapStaticPageConfig = {
export type SitemapPathTemplatesConfig = {
product?: string;
productCategory?: string;
blog?: string;
portfolio?: string;
storeItemByProduct?: string;
+9
View File
@@ -173,4 +173,13 @@ export class PublicCategoriesController {
list(@Param('host') host: string, @Query() query: ListCategoriesDto) {
return this.service.listPublic(host, query);
}
@Get('by-slug/:slug')
getBySlug(
@Param('host') host: string,
@Param('slug') slug: string,
@Query() query: ListCategoriesDto,
) {
return this.service.getPublicBySlug(host, slug, query.entityType);
}
}
+2 -1
View File
@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { SitemapModule } from '../sitemap/sitemap.module';
import { TenantModule } from '../tenant/tenant.module';
import { CategoriesController, PublicCategoriesController } from './categories.controller';
import { CategoriesService } from './categories.service';
@@ -9,7 +10,7 @@ import { CategoryTechnicalFormService } from './category-technical-form.service'
import { CategoryVariationsService } from './category-variations.service';
@Module({
imports: [AuthModule, TenantModule],
imports: [AuthModule, SitemapModule, TenantModule],
controllers: [CategoriesController, PublicCategoriesController],
providers: [
CategoriesService,
+28
View File
@@ -8,6 +8,7 @@ import { MediaEntityType } from '@prisma/client';
import { AuthUser } from '../auth/auth.types';
import { PermissionsService } from '../auth/permissions.service';
import { PrismaService } from '../prisma/prisma.service';
import { SitemapService } from '../sitemap/sitemap.service';
import { TenantService } from '../tenant/tenant.service';
import { CreateCategoryDto, ListCategoriesDto, UpdateCategoryDto } from './dto/category.dto';
@@ -27,6 +28,7 @@ export class CategoriesService {
private readonly prisma: PrismaService,
private readonly permissions: PermissionsService,
private readonly tenant: TenantService,
private readonly sitemap: SitemapService,
) {}
async list(businessIdRaw: string, query: ListCategoriesDto, actor: AuthUser) {
@@ -65,6 +67,26 @@ export class CategoriesService {
};
}
async getPublicBySlug(host: string, slug: string, entityType?: MediaEntityType) {
const business = await this.tenant.resolveBusinessByDomain(host);
const resolvedType = entityType ?? MediaEntityType.product;
const category = await this.prisma.category.findFirst({
where: {
businessId: business.id,
entityType: resolvedType,
slug,
isActive: true,
},
});
if (!category) {
throw new NotFoundException('Category not found');
}
return { category: this.serialize(category) };
}
async create(businessIdRaw: string, dto: CreateCategoryDto, actor: AuthUser) {
const businessId = BigInt(businessIdRaw);
await this.assertPermission(businessId, actor.id, 'categories.create');
@@ -99,6 +121,8 @@ export class CategoriesService {
},
});
await this.sitemap.invalidateForBusiness(businessId);
return {
message: 'Category created successfully',
category: this.serialize(created),
@@ -178,6 +202,8 @@ export class CategoriesService {
},
});
await this.sitemap.invalidateForBusiness(businessId);
return {
message: 'Category updated successfully',
category: this.serialize(updated),
@@ -205,6 +231,8 @@ export class CategoriesService {
}),
]);
await this.sitemap.invalidateForBusiness(businessId);
return {
message: 'Category deleted successfully',
deletedIds: descendantIds.map((id) => id.toString()),
+4
View File
@@ -12,6 +12,7 @@ import {
resolveAiProvider,
} from '../common/ai-provider.util';
import { PrismaService } from '../prisma/prisma.service';
import { SitemapService } from '../sitemap/sitemap.service';
import { GenerateCategoriesDto } from './dto/category-ai.dto';
type AiCategoryNode = {
@@ -44,6 +45,7 @@ export class CategoryAiService {
private readonly config: ConfigService,
private readonly prisma: PrismaService,
private readonly permissions: PermissionsService,
private readonly sitemap: SitemapService,
) {}
async generateProductCategories(
@@ -67,6 +69,8 @@ export class CategoryAiService {
);
});
await this.sitemap.invalidateForBusiness(businessId);
return {
message: `Created ${created.length} categor${created.length === 1 ? 'y' : 'ies'} with AI.`,
categories: created.map((item) => this.serialize(item)),
+1
View File
@@ -23,6 +23,7 @@ const BASE_URL_PATTERN = /^https:\/\/[a-z0-9.-]+(?::\d+)?\/?$/i;
const TEMPLATE_KEYS = [
'product',
'productCategory',
'blog',
'portfolio',
'storeItemByProduct',
+1 -1
View File
@@ -11,7 +11,7 @@ export type ResolvedSitemapConfig = {
templates: Required<
Pick<
import('../business-settings/business-settings.types').SitemapPathTemplatesConfig,
'product' | 'blog' | 'portfolio'
'product' | 'productCategory' | 'blog' | 'portfolio'
>
>;
};
+3
View File
@@ -98,6 +98,9 @@ export function resolveSitemapConfig(
templates: {
product:
stored?.templates?.product ?? DEFAULT_SITEMAP_PATH_TEMPLATES.product,
productCategory:
stored?.templates?.productCategory ??
DEFAULT_SITEMAP_PATH_TEMPLATES.productCategory,
blog: stored?.templates?.blog ?? DEFAULT_SITEMAP_PATH_TEMPLATES.blog,
portfolio:
stored?.templates?.portfolio ??
+1
View File
@@ -6,6 +6,7 @@ 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/{id}/{slug}',
productCategory: '/products/category/{slug}',
blog: '/blog/{id}/{slug}',
portfolio: '/portfolios/{id}/{slug}',
} as const;
+36 -1
View File
@@ -1,5 +1,5 @@
import { Injectable, Logger } from '@nestjs/common';
import { ContentStatus, Prisma } from '@prisma/client';
import { ContentStatus, MediaEntityType, 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';
@@ -119,6 +119,13 @@ export class SitemapService {
}));
if (this.isModuleEnabled(enabledModules, 'products')) {
entries.push(
...(await this.loadProductCategories(
businessId,
config.baseUrl,
config.templates.productCategory,
)),
);
entries.push(
...(await this.loadPublishedProducts(businessId, config.baseUrl, config.templates.product)),
);
@@ -143,6 +150,34 @@ export class SitemapService {
return buildSitemapXml(entries);
}
private async loadProductCategories(
businessId: bigint,
baseUrl: string,
template: string,
): Promise<SitemapUrlEntry[]> {
const rows = await this.prisma.category.findMany({
where: {
businessId,
entityType: MediaEntityType.product,
isActive: true,
},
select: {
id: true,
slug: true,
updatedAt: true,
},
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
});
return rows.map((row) =>
this.toEntry(baseUrl, template, {
id: row.id,
slug: row.slug,
updatedAt: row.updatedAt,
}),
);
}
private async loadPublishedProducts(
businessId: bigint,
baseUrl: string,
+3 -2
View File
@@ -116,10 +116,11 @@ Minimal example:
- Include public static routes automatically; exclude login, checkout, cart, account, and admin paths.
- **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).
- Product category: `/products/category/{categorySlug}` — use category `slug` from `GET /tenants/{domain}/categories?entityType=product`; resolve via `GET /tenants/{domain}/categories/by-slug/{slug}?entityType=product`, then list products with `categoryId`.
- 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}`).
- When linking from lists/cards, use the same `{id}/{slug}` shape for details (slugify Farsi title: spaces → `-`, keep Persian letters). Category links use `/products/category/{slug}`.
- Omit `templates` in `sitemap-config.json` unless this site uses non-default paths (Meshkee sitemap defaults already use `{id}/{slug}` and `/products/category/{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).
+44 -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 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.",
"description": "Published products, product categories, 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}`, `/products/category/{categorySlug}`, `/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"
@@ -446,6 +446,49 @@
}
}
},
"/tenants/{domain}/categories/by-slug/{slug}": {
"get": {
"tags": [
"Categories"
],
"summary": "Category by slug (for /products/category/{categorySlug} pages)",
"parameters": [
{
"$ref": "#/components/parameters/domain"
},
{
"name": "slug",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "entityType",
"in": "query",
"schema": {
"type": "string",
"enum": [
"product",
"blog",
"portfolio",
"video"
],
"default": "product"
}
}
],
"responses": {
"200": {
"description": "{ category } — use category.id as categoryId when listing products"
},
"404": {
"description": "Category not found"
}
}
}
},
"/tenants/{domain}/products": {
"get": {
"tags": [