diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index e4e6e51..243e932 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -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 | diff --git a/docs/website-api/AI_PROMPT.md b/docs/website-api/AI_PROMPT.md index 1590131..983217c 100644 --- a/docs/website-api/AI_PROMPT.md +++ b/docs/website-api/AI_PROMPT.md @@ -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). diff --git a/docs/website-api/openapi.json b/docs/website-api/openapi.json index 3061ddc..0094c8c 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 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": [ diff --git a/src/business-settings/business-settings.types.ts b/src/business-settings/business-settings.types.ts index 52611ef..37c93c2 100644 --- a/src/business-settings/business-settings.types.ts +++ b/src/business-settings/business-settings.types.ts @@ -128,6 +128,7 @@ export type SitemapStaticPageConfig = { export type SitemapPathTemplatesConfig = { product?: string; + productCategory?: string; blog?: string; portfolio?: string; storeItemByProduct?: string; diff --git a/src/categories/categories.controller.ts b/src/categories/categories.controller.ts index 0d05a84..27d4178 100644 --- a/src/categories/categories.controller.ts +++ b/src/categories/categories.controller.ts @@ -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); + } } diff --git a/src/categories/categories.module.ts b/src/categories/categories.module.ts index 9e9554b..6baa737 100644 --- a/src/categories/categories.module.ts +++ b/src/categories/categories.module.ts @@ -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, diff --git a/src/categories/categories.service.ts b/src/categories/categories.service.ts index 1d7f658..e8eac9f 100644 --- a/src/categories/categories.service.ts +++ b/src/categories/categories.service.ts @@ -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()), diff --git a/src/categories/category-ai.service.ts b/src/categories/category-ai.service.ts index 6d76e45..d3b1a69 100644 --- a/src/categories/category-ai.service.ts +++ b/src/categories/category-ai.service.ts @@ -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)), diff --git a/src/sitemap/sitemap-config.catalog.ts b/src/sitemap/sitemap-config.catalog.ts index e9b5736..bc15fe6 100644 --- a/src/sitemap/sitemap-config.catalog.ts +++ b/src/sitemap/sitemap-config.catalog.ts @@ -23,6 +23,7 @@ const BASE_URL_PATTERN = /^https:\/\/[a-z0-9.-]+(?::\d+)?\/?$/i; const TEMPLATE_KEYS = [ 'product', + 'productCategory', 'blog', 'portfolio', 'storeItemByProduct', diff --git a/src/sitemap/sitemap-config.types.ts b/src/sitemap/sitemap-config.types.ts index ce290ab..7779c9d 100644 --- a/src/sitemap/sitemap-config.types.ts +++ b/src/sitemap/sitemap-config.types.ts @@ -11,7 +11,7 @@ export type ResolvedSitemapConfig = { templates: Required< Pick< import('../business-settings/business-settings.types').SitemapPathTemplatesConfig, - 'product' | 'blog' | 'portfolio' + 'product' | 'productCategory' | 'blog' | 'portfolio' > >; }; diff --git a/src/sitemap/sitemap-config.util.ts b/src/sitemap/sitemap-config.util.ts index e8fbfb2..34e6c93 100644 --- a/src/sitemap/sitemap-config.util.ts +++ b/src/sitemap/sitemap-config.util.ts @@ -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 ?? diff --git a/src/sitemap/sitemap.constants.ts b/src/sitemap/sitemap.constants.ts index 909f37a..6d38892 100644 --- a/src/sitemap/sitemap.constants.ts +++ b/src/sitemap/sitemap.constants.ts @@ -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; diff --git a/src/sitemap/sitemap.service.ts b/src/sitemap/sitemap.service.ts index 42e6ae1..f71288c 100644 --- a/src/sitemap/sitemap.service.ts +++ b/src/sitemap/sitemap.service.ts @@ -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 { + 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, diff --git a/src/website-docs/static/AI_PROMPT.md b/src/website-docs/static/AI_PROMPT.md index 1590131..983217c 100644 --- a/src/website-docs/static/AI_PROMPT.md +++ b/src/website-docs/static/AI_PROMPT.md @@ -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). diff --git a/src/website-docs/static/openapi.json b/src/website-docs/static/openapi.json index 3061ddc..0094c8c 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 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": [