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:
co-authored by
Cursor
parent
df17925d3a
commit
c4367d8eb0
@@ -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 |
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -23,6 +23,7 @@ const BASE_URL_PATTERN = /^https:\/\/[a-z0-9.-]+(?::\d+)?\/?$/i;
|
||||
|
||||
const TEMPLATE_KEYS = [
|
||||
'product',
|
||||
'productCategory',
|
||||
'blog',
|
||||
'portfolio',
|
||||
'storeItemByProduct',
|
||||
|
||||
@@ -11,7 +11,7 @@ export type ResolvedSitemapConfig = {
|
||||
templates: Required<
|
||||
Pick<
|
||||
import('../business-settings/business-settings.types').SitemapPathTemplatesConfig,
|
||||
'product' | 'blog' | 'portfolio'
|
||||
'product' | 'productCategory' | 'blog' | 'portfolio'
|
||||
>
|
||||
>;
|
||||
};
|
||||
|
||||
@@ -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 ??
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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": [
|
||||
|
||||
Reference in New Issue
Block a user