diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index 8376ef2..62d2985 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -262,9 +262,14 @@ 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` | Sitemap **index** → main + products child sitemaps | -| GET | `/tenants/:host/sitemap-main.xml` | Static pages + `/products/category/{id}/{faSlug}` + blogs/portfolios | +| GET | `/tenants/:host/sitemap.xml` | Sitemap **index** → main + dynamic child sitemaps | +| GET | `/tenants/:host/sitemap-main.xml` | Static pages + `/products/category/{id}/{faSlug}` | | GET | `/tenants/:host/sitemap-products.xml` | Published products `/products/{id}/{faSlug}` | +| GET | `/tenants/:host/sitemap-blogs.xml` | Published blogs `/blog/{id}/{slug}` | +| GET | `/tenants/:host/sitemap-portfolios.xml` | Published portfolios `/portfolios/{id}/{slug}` | +| GET | `/tenants/:host/sitemap-videos.xml` | Published videos `/videos/{slug}` | +| GET | `/tenants/:host/sitemap-instructions.xml` | Published instructions `/instructions/{slug}` | +| GET | `/tenants/:host/sitemap-workshops.xml` | Published workshops `/workshops/{slug}` | | GET | `/tenants/:host/robots.txt` | robots.txt pointing to apex `/sitemap.xml` | | GET | `/tenants/:host/categories/by-id/:categoryId` | Public category by id (for category landing pages) | | GET | `/tenants/:host/categories/by-slug/:slug` | Public category by CMS slug | diff --git a/docs/website-api/AI_PROMPT.md b/docs/website-api/AI_PROMPT.md index 5697501..fc648f6 100644 --- a/docs/website-api/AI_PROMPT.md +++ b/docs/website-api/AI_PROMPT.md @@ -138,9 +138,14 @@ Minimal example: - 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/{id}/{nameFaSlug}`. - Omit `templates` in `sitemap-config.json` unless this site uses non-default paths. - **Sitemap files (proxied by nginx — do not ship local copies):** - - `/sitemap.xml` — sitemap **index** - - `/sitemap-main.xml` — static pages + categories + blogs + portfolios + - `/sitemap.xml` — sitemap **index** (lists child sitemaps for enabled modules) + - `/sitemap-main.xml` — static pages + product categories - `/sitemap-products.xml` — published products (when products module is enabled) + - `/sitemap-blogs.xml` — published blogs (when blog module is enabled) + - `/sitemap-portfolios.xml` — published portfolios (when portfolio module is enabled) + - `/sitemap-videos.xml` — published videos (when videos module is enabled) + - `/sitemap-instructions.xml` — published instructions (when instructions module is enabled) + - `/sitemap-workshops.xml` — published workshops (when workshops module is enabled) - `/robots.txt` — points at `/sitemap.xml` ### On-page SEO (every public page) diff --git a/docs/website-api/openapi.json b/docs/website-api/openapi.json index 8ad16b9..d67d88c 100644 --- a/docs/website-api/openapi.json +++ b/docs/website-api/openapi.json @@ -201,7 +201,7 @@ "SEO" ], "summary": "XML sitemap for search engines", - "description": "Sitemap **index** for this tenant. Points to `/sitemap-main.xml` (static pages, product categories, blogs, portfolios) and `/sitemap-products.xml` (published products when the products module is enabled). Category paths: `/products/category/{id}/{nameFaSlug}`. Product paths: `/products/{id}/{nameFaSlug}`. Proxied from `https://{domain}/sitemap.xml`. Cached in Redis; refreshed after CMS writes and manifest sync.", + "description": "Sitemap **index** for this tenant. Points to `/sitemap-main.xml` (static pages + product categories) plus module child sitemaps when enabled: `/sitemap-products.xml`, `/sitemap-blogs.xml`, `/sitemap-portfolios.xml`, `/sitemap-videos.xml`, `/sitemap-instructions.xml`, `/sitemap-workshops.xml`. Proxied from `https://{domain}/sitemap.xml`. Cached in Redis; refreshed after CMS writes and manifest sync.", "parameters": [ { "$ref": "#/components/parameters/domain" @@ -237,7 +237,7 @@ "tags": [ "SEO" ], - "summary": "Main sitemap urlset (static + categories + blogs + portfolios)", + "summary": "Main sitemap urlset (static pages + product categories)", "parameters": [ { "$ref": "#/components/parameters/domain" @@ -282,6 +282,131 @@ } } }, + "/tenants/{domain}/sitemap-blogs.xml": { + "get": { + "tags": [ + "SEO" + ], + "summary": "Blogs-only sitemap urlset", + "parameters": [ + { + "$ref": "#/components/parameters/domain" + } + ], + "responses": { + "200": { + "description": "Sitemap XML (urlset) of published blogs", + "content": { + "application/xml": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/tenants/{domain}/sitemap-portfolios.xml": { + "get": { + "tags": [ + "SEO" + ], + "summary": "Portfolios-only sitemap urlset", + "parameters": [ + { + "$ref": "#/components/parameters/domain" + } + ], + "responses": { + "200": { + "description": "Sitemap XML (urlset) of published portfolios", + "content": { + "application/xml": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/tenants/{domain}/sitemap-videos.xml": { + "get": { + "tags": [ + "SEO" + ], + "summary": "Videos-only sitemap urlset", + "parameters": [ + { + "$ref": "#/components/parameters/domain" + } + ], + "responses": { + "200": { + "description": "Sitemap XML (urlset) of published videos", + "content": { + "application/xml": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/tenants/{domain}/sitemap-instructions.xml": { + "get": { + "tags": [ + "SEO" + ], + "summary": "Instructions-only sitemap urlset", + "parameters": [ + { + "$ref": "#/components/parameters/domain" + } + ], + "responses": { + "200": { + "description": "Sitemap XML (urlset) of published instructions", + "content": { + "application/xml": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/tenants/{domain}/sitemap-workshops.xml": { + "get": { + "tags": [ + "SEO" + ], + "summary": "Workshops-only sitemap urlset", + "parameters": [ + { + "$ref": "#/components/parameters/domain" + } + ], + "responses": { + "200": { + "description": "Sitemap XML (urlset) of published workshops", + "content": { + "application/xml": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, "/tenants/{domain}/robots.txt": { "get": { "tags": [ diff --git a/scripts/websites-agent/patch-nginx-seo.sh b/scripts/websites-agent/patch-nginx-seo.sh index 0e64767..f42a735 100755 --- a/scripts/websites-agent/patch-nginx-seo.sh +++ b/scripts/websites-agent/patch-nginx-seo.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Insert / update Meshkee SEO proxy locations on an existing storefront nginx site. -# Covers: /sitemap.xml (index), /sitemap-main.xml, /sitemap-products.xml, /robots.txt +# Covers: /sitemap.xml (index), child sitemaps, /robots.txt set -euo pipefail HOST="${1:-}" @@ -61,6 +61,11 @@ ensure_location() { ensure_location /sitemap.xml sitemap.xml ensure_location /sitemap-main.xml sitemap-main.xml ensure_location /sitemap-products.xml sitemap-products.xml +ensure_location /sitemap-blogs.xml sitemap-blogs.xml +ensure_location /sitemap-portfolios.xml sitemap-portfolios.xml +ensure_location /sitemap-videos.xml sitemap-videos.xml +ensure_location /sitemap-instructions.xml sitemap-instructions.xml +ensure_location /sitemap-workshops.xml sitemap-workshops.xml ensure_location /robots.txt robots.txt nginx -t diff --git a/scripts/websites-agent/provision.sh b/scripts/websites-agent/provision.sh index fe3f46f..92e81a7 100755 --- a/scripts/websites-agent/provision.sh +++ b/scripts/websites-agent/provision.sh @@ -160,6 +160,56 @@ PROXY_COMMON=$(cat <, fallbackIndex: number): string { + let value = base || `option-${fallbackIndex}`; + if (!used.has(value)) { + used.add(value); + return value; + } + + let suffix = 2; + while (used.has(`${value}-${suffix}`)) { + suffix += 1; + } + const next = `${value}-${suffix}`; + used.add(next); + return next; +} + +type ExistingVariation = { + id: bigint; + name: string; + variationType: VariationType; + sortOrder: number; + options: { + id: bigint; + label: string; + value: string; + colorHex: string | null; + sortOrder: number; + }[]; +}; + @Injectable() export class CategoryVariationsService { constructor( @@ -70,32 +112,57 @@ export class CategoryVariationsService { this.validateVariationInputs(dto.variations); await this.prisma.$transaction(async (tx) => { - await tx.categoryVariation.deleteMany({ + const existing = await tx.categoryVariation.findMany({ where: { businessId, categoryId }, + include: { options: { orderBy: { sortOrder: 'asc' } } }, + orderBy: { sortOrder: 'asc' }, }); + const matchedExistingIds = new Set(); + for (const [index, variation] of dto.variations.entries()) { - const created = await tx.categoryVariation.create({ - data: { - businessId, - categoryId, - name: this.resolveVariationName(variation), - variationType: variation.type as VariationType, - sortOrder: index, - }, + const current = this.matchExistingVariation(existing, variation, matchedExistingIds); + const name = this.resolveVariationName(variation); + const uniqueValues = [ + ...new Set(variation.values.map((v) => v.trim()).filter(Boolean)), + ]; + + if (current) { + matchedExistingIds.add(current.id); + await tx.categoryVariation.update({ + where: { id: current.id }, + data: { + name, + variationType: variation.type as VariationType, + sortOrder: index, + }, + }); + await this.syncOptions(tx, current, variation.type, uniqueValues); + } else { + const created = await tx.categoryVariation.create({ + data: { + businessId, + categoryId, + name, + variationType: variation.type as VariationType, + sortOrder: index, + }, + }); + await this.createOptions(tx, created.id, variation.type, uniqueValues); + } + } + + const removed = existing.filter((item) => !matchedExistingIds.has(item.id)); + if (removed.length) { + const removedIds = removed.map((item) => item.id); + await tx.storeItemVariantSelection.deleteMany({ + where: { variationId: { in: removedIds } }, }); - - const uniqueValues = [...new Set(variation.values.map((v) => v.trim()).filter(Boolean))]; - - await tx.categoryVariationOption.createMany({ - data: uniqueValues.map((label, optionIndex) => ({ - variationId: created.id, - label, - value: slugifyValue(label) || `option-${optionIndex + 1}`, - colorHex: - variation.type === 'color' ? getColorHex(label) : null, - sortOrder: optionIndex, - })), + await tx.productVariationValue.deleteMany({ + where: { variationId: { in: removedIds } }, + }); + await tx.categoryVariation.deleteMany({ + where: { id: { in: removedIds } }, }); } }); @@ -113,6 +180,109 @@ export class CategoryVariationsService { return variations.map((item) => this.serialize(item)); } + private matchExistingVariation( + existing: ExistingVariation[], + incoming: CategoryVariationInputDto, + alreadyMatched: Set, + ): ExistingVariation | undefined { + const candidates = existing.filter((item) => !alreadyMatched.has(item.id)); + + if (incoming.type === 'color' || incoming.type === 'size') { + return candidates.find((item) => item.variationType === incoming.type); + } + + const name = incoming.name.trim().toLowerCase(); + return candidates.find( + (item) => + item.variationType === 'custom' && item.name.trim().toLowerCase() === name, + ); + } + + private async syncOptions( + tx: Prisma.TransactionClient, + current: ExistingVariation, + type: CategoryVariationInputDto['type'], + labels: string[], + ) { + const usedSlugs = new Set(); + const matchedOptionIds = new Set(); + + for (const [optionIndex, label] of labels.entries()) { + const existingOption = current.options.find( + (option) => + !matchedOptionIds.has(option.id) && option.label.trim() === label, + ); + + const colorHex = + type === 'color' + ? resolveColorHex(label) ?? + existingOption?.colorHex ?? + (existingOption && isHexColor(existingOption.value) + ? existingOption.value.trim() + : null) + : null; + + if (existingOption) { + matchedOptionIds.add(existingOption.id); + usedSlugs.add(existingOption.value); + await tx.categoryVariationOption.update({ + where: { id: existingOption.id }, + data: { + label, + colorHex, + sortOrder: optionIndex, + }, + }); + continue; + } + + const value = uniqueSlug(slugifyValue(label), usedSlugs, optionIndex + 1); + await tx.categoryVariationOption.create({ + data: { + variationId: current.id, + label, + value, + colorHex, + sortOrder: optionIndex, + }, + }); + } + + const removedOptions = current.options.filter( + (option) => !matchedOptionIds.has(option.id), + ); + if (removedOptions.length) { + const removedOptionIds = removedOptions.map((option) => option.id); + await tx.storeItemVariantSelection.deleteMany({ + where: { optionId: { in: removedOptionIds } }, + }); + await tx.productVariationValue.deleteMany({ + where: { optionId: { in: removedOptionIds } }, + }); + await tx.categoryVariationOption.deleteMany({ + where: { id: { in: removedOptionIds } }, + }); + } + } + + private async createOptions( + tx: Prisma.TransactionClient, + variationId: bigint, + type: CategoryVariationInputDto['type'], + labels: string[], + ) { + const usedSlugs = new Set(); + await tx.categoryVariationOption.createMany({ + data: labels.map((label, optionIndex) => ({ + variationId, + label, + value: uniqueSlug(slugifyValue(label), usedSlugs, optionIndex + 1), + colorHex: type === 'color' ? resolveColorHex(label) : null, + sortOrder: optionIndex, + })), + }); + } + private validateVariationInputs(variations: CategoryVariationInputDto[]) { const colorCount = variations.filter((v) => v.type === 'color').length; const sizeCount = variations.filter((v) => v.type === 'size').length; @@ -128,21 +298,9 @@ export class CategoryVariationsService { const customNames = new Set(); for (const variation of variations) { - if (variation.type === 'color') { - for (const value of variation.values) { - if (!isColorPreset(value.trim())) { - throw new BadRequestException( - `Invalid color "${value}". Choose from the predefined color palette.`, - ); - } - } - } - - if (variation.type === 'size' || variation.type === 'custom') { - const values = variation.values.map((v) => v.trim()).filter(Boolean); - if (!values.length) { - throw new BadRequestException('Each variation must have at least one value'); - } + const values = variation.values.map((v) => v.trim()).filter(Boolean); + if (!values.length) { + throw new BadRequestException('Each variation must have at least one value'); } if (variation.type === 'custom') { @@ -162,10 +320,10 @@ export class CategoryVariationsService { private resolveVariationName(variation: CategoryVariationInputDto): string { if (variation.type === 'color') { - return 'Color'; + return variation.name.trim() || 'Color'; } if (variation.type === 'size') { - return 'Size'; + return variation.name.trim() || 'Size'; } return variation.name.trim(); } @@ -226,7 +384,9 @@ export class CategoryVariationsService { id: option.id.toString(), label: option.label, value: option.value, - colorHex: option.colorHex, + colorHex: + option.colorHex ?? + (isHexColor(option.value) ? option.value.trim() : null), sortOrder: option.sortOrder, })), }; diff --git a/src/categories/color-presets.ts b/src/categories/color-presets.ts index a8395e8..69e325f 100644 --- a/src/categories/color-presets.ts +++ b/src/categories/color-presets.ts @@ -16,6 +16,34 @@ export const COLOR_PRESETS = [ { name: 'Beige', hex: '#D4C4A8' }, ] as const; +/** Common Farsi labels from legacy migrations → English preset name */ +const COLOR_ALIASES: Record = { + قرمز: 'Red', + آبی: 'Blue', + سبز: 'Green', + مشکی: 'Black', + سیاه: 'Black', + سفید: 'White', + نقرهای: 'Silver', + 'نقره ای': 'Silver', + نقره‌ای: 'Silver', + طلایی: 'Gold', + طلائی: 'Gold', + سرمهای: 'Navy', + 'سرمه ای': 'Navy', + سرمه‌ای: 'Navy', + زرد: 'Yellow', + نارنجی: 'Orange', + بنفش: 'Purple', + صورتی: 'Pink', + قهوهای: 'Brown', + 'قهوه ای': 'Brown', + قهوه‌ای: 'Brown', + خاکستری: 'Gray', + طوسی: 'Gray', + بژ: 'Beige', +}; + export const COLOR_PRESET_NAMES = COLOR_PRESETS.map((color) => color.name); export function isColorPreset(value: string): boolean { @@ -25,3 +53,27 @@ export function isColorPreset(value: string): boolean { export function getColorHex(name: string): string | null { return COLOR_PRESETS.find((color) => color.name === name)?.hex ?? null; } + +function normalizeColorKey(value: string): string { + return value + .trim() + .toLowerCase() + .replace(/\u200c/g, '') // ZWNJ + .replace(/\s+/g, ' '); +} + +/** Resolve hex from English preset name or known Farsi alias. */ +export function resolveColorHex(label: string): string | null { + const trimmed = label.trim(); + if (!trimmed) return null; + + const fromPreset = getColorHex(trimmed); + if (fromPreset) return fromPreset; + + const alias = COLOR_ALIASES[trimmed] ?? COLOR_ALIASES[normalizeColorKey(trimmed)]; + return alias ? getColorHex(alias) : null; +} + +export function isHexColor(value: string): boolean { + return /^#[0-9a-fA-F]{6}$/.test(value.trim()); +} diff --git a/src/sitemap/sitemap-config.types.ts b/src/sitemap/sitemap-config.types.ts index 7779c9d..f8bb6cf 100644 --- a/src/sitemap/sitemap-config.types.ts +++ b/src/sitemap/sitemap-config.types.ts @@ -11,7 +11,13 @@ export type ResolvedSitemapConfig = { templates: Required< Pick< import('../business-settings/business-settings.types').SitemapPathTemplatesConfig, - 'product' | 'productCategory' | 'blog' | 'portfolio' + | 'product' + | 'productCategory' + | 'blog' + | 'portfolio' + | 'video' + | 'instruction' + | 'workshop' > >; }; diff --git a/src/sitemap/sitemap-config.util.ts b/src/sitemap/sitemap-config.util.ts index 34e6c93..85f2faf 100644 --- a/src/sitemap/sitemap-config.util.ts +++ b/src/sitemap/sitemap-config.util.ts @@ -105,6 +105,12 @@ export function resolveSitemapConfig( portfolio: stored?.templates?.portfolio ?? DEFAULT_SITEMAP_PATH_TEMPLATES.portfolio, + video: stored?.templates?.video ?? DEFAULT_SITEMAP_PATH_TEMPLATES.video, + instruction: + stored?.templates?.instruction ?? + DEFAULT_SITEMAP_PATH_TEMPLATES.instruction, + workshop: + stored?.templates?.workshop ?? DEFAULT_SITEMAP_PATH_TEMPLATES.workshop, }, }; } diff --git a/src/sitemap/sitemap.constants.ts b/src/sitemap/sitemap.constants.ts index 1ff7656..f5c6334 100644 --- a/src/sitemap/sitemap.constants.ts +++ b/src/sitemap/sitemap.constants.ts @@ -1,4 +1,3 @@ -/** Safety-net TTL when invalidation is missed; normal freshness is event-driven. */ export const SITEMAP_CACHE_TTL_SECONDS = 3600; export const SITEMAP_URLSET_NS = 'http://www.sitemaps.org/schemas/sitemap/0.9'; @@ -9,6 +8,9 @@ export const DEFAULT_SITEMAP_PATH_TEMPLATES = { productCategory: '/products/category/{id}/{slug}', blog: '/blog/{id}/{slug}', portfolio: '/portfolios/{id}/{slug}', + video: '/videos/{slug}', + instruction: '/instructions/{slug}', + workshop: '/workshops/{slug}', } as const; export const WEBSITE_SITEMAP_CONFIG_PATH = '/meshkee/sitemap-config.json'; @@ -16,3 +18,17 @@ export const WEBSITE_SITEMAP_CONFIG_PATH = '/meshkee/sitemap-config.json'; export const DEFAULT_SITEMAP_STATIC_PAGES = [ { path: '/', changefreq: 'daily' as const, priority: 1.0 }, ] as const; + +/** Child sitemap sections listed from the index (excluding main). */ +export const SITEMAP_DYNAMIC_SECTIONS = [ + 'products', + 'blogs', + 'portfolios', + 'videos', + 'instructions', + 'workshops', +] as const; + +export type SitemapDynamicSection = (typeof SITEMAP_DYNAMIC_SECTIONS)[number]; + +export type SitemapSection = 'index' | 'main' | SitemapDynamicSection; diff --git a/src/sitemap/sitemap.controller.ts b/src/sitemap/sitemap.controller.ts index cda4341..95f495d 100644 --- a/src/sitemap/sitemap.controller.ts +++ b/src/sitemap/sitemap.controller.ts @@ -1,5 +1,6 @@ import { Controller, Get, Header, Param, Res } from '@nestjs/common'; import type { Response } from 'express'; +import { SITEMAP_DYNAMIC_SECTIONS, type SitemapDynamicSection } from './sitemap.constants'; import { SitemapService } from './sitemap.service'; @Controller('tenants/:host') @@ -25,9 +26,37 @@ export class SitemapController { @Get('sitemap-products.xml') @Header('Cache-Control', 'public, max-age=900') async getProductsSitemap(@Param('host') host: string, @Res() res: Response) { - const xml = await this.sitemap.getProductsXmlForHost(host); - res.setHeader('Content-Type', 'application/xml; charset=utf-8'); - res.send(xml); + await this.sendDynamic(host, 'products', res); + } + + @Get('sitemap-blogs.xml') + @Header('Cache-Control', 'public, max-age=900') + async getBlogsSitemap(@Param('host') host: string, @Res() res: Response) { + await this.sendDynamic(host, 'blogs', res); + } + + @Get('sitemap-portfolios.xml') + @Header('Cache-Control', 'public, max-age=900') + async getPortfoliosSitemap(@Param('host') host: string, @Res() res: Response) { + await this.sendDynamic(host, 'portfolios', res); + } + + @Get('sitemap-videos.xml') + @Header('Cache-Control', 'public, max-age=900') + async getVideosSitemap(@Param('host') host: string, @Res() res: Response) { + await this.sendDynamic(host, 'videos', res); + } + + @Get('sitemap-instructions.xml') + @Header('Cache-Control', 'public, max-age=900') + async getInstructionsSitemap(@Param('host') host: string, @Res() res: Response) { + await this.sendDynamic(host, 'instructions', res); + } + + @Get('sitemap-workshops.xml') + @Header('Cache-Control', 'public, max-age=900') + async getWorkshopsSitemap(@Param('host') host: string, @Res() res: Response) { + await this.sendDynamic(host, 'workshops', res); } @Get('robots.txt') @@ -37,4 +66,18 @@ export class SitemapController { res.setHeader('Content-Type', 'text/plain; charset=utf-8'); res.send(text); } + + private async sendDynamic( + host: string, + section: SitemapDynamicSection, + res: Response, + ) { + if (!SITEMAP_DYNAMIC_SECTIONS.includes(section)) { + res.status(404).send('Not found'); + return; + } + const xml = await this.sitemap.getSectionXmlForHost(host, section); + res.setHeader('Content-Type', 'application/xml; charset=utf-8'); + res.send(xml); + } } diff --git a/src/sitemap/sitemap.service.ts b/src/sitemap/sitemap.service.ts index 90dd510..1662368 100644 --- a/src/sitemap/sitemap.service.ts +++ b/src/sitemap/sitemap.service.ts @@ -8,7 +8,12 @@ import { TenantService } from '../tenant/tenant.service'; import { resolveSitemapConfig } from './sitemap-config.util'; import type { ResolvedSitemapConfig } from './sitemap-config.types'; import { slugifyForUrl } from './seo-slug.util'; -import { SITEMAP_CACHE_TTL_SECONDS } from './sitemap.constants'; +import { + SITEMAP_CACHE_TTL_SECONDS, + SITEMAP_DYNAMIC_SECTIONS, + type SitemapDynamicSection, + type SitemapSection, +} from './sitemap.constants'; import { applyPathTemplate, buildAbsoluteUrl, @@ -23,7 +28,29 @@ type PublishedDetailRow = { updatedAt: Date; }; -type SitemapSection = 'index' | 'main' | 'products'; +type SitemapContext = { + businessId: bigint; + enabledModules: readonly BusinessModuleId[]; + config: ResolvedSitemapConfig; +}; + +const SECTION_MODULE: Record = { + products: 'products', + blogs: 'blog', + portfolios: 'portfolio', + videos: 'videos', + instructions: 'instructions', + workshops: 'workshops', +}; + +const SECTION_FILE: Record = { + products: '/sitemap-products.xml', + blogs: '/sitemap-blogs.xml', + portfolios: '/sitemap-portfolios.xml', + videos: '/sitemap-videos.xml', + instructions: '/sitemap-instructions.xml', + workshops: '/sitemap-workshops.xml', +}; @Injectable() export class SitemapService { @@ -45,7 +72,7 @@ export class SitemapService { await this.redis.client.del( this.cacheKey(id, 'index'), this.cacheKey(id, 'main'), - this.cacheKey(id, 'products'), + ...SITEMAP_DYNAMIC_SECTIONS.map((section) => this.cacheKey(id, section)), `sitemap:${id.toString()}`, ); } catch (err) { @@ -63,8 +90,8 @@ export class SitemapService { return this.getSectionXml(host, 'main'); } - async getProductsXmlForHost(host: string): Promise { - return this.getSectionXml(host, 'products'); + async getSectionXmlForHost(host: string, section: SitemapDynamicSection): Promise { + return this.getSectionXml(host, section); } private async getSectionXml(host: string, section: SitemapSection): Promise { @@ -83,9 +110,9 @@ export class SitemapService { const xml = section === 'index' ? this.buildIndexXml(ctx) - : section === 'products' - ? await this.buildProductsXml(ctx) - : await this.buildMainXml(ctx); + : section === 'main' + ? await this.buildMainXml(ctx) + : await this.buildDynamicXml(ctx, section); try { await this.redis.client.set(cacheKey, xml, 'EX', SITEMAP_CACHE_TTL_SECONDS); @@ -129,7 +156,7 @@ export class SitemapService { return enabledModules.includes(moduleId); } - private async loadContext(businessId: bigint, apexHost: string) { + private async loadContext(businessId: bigint, apexHost: string): Promise { const business = await this.prisma.business.findUnique({ where: { id: businessId }, select: { settings: true }, @@ -143,28 +170,23 @@ export class SitemapService { }; } - private buildIndexXml(ctx: { - enabledModules: readonly BusinessModuleId[]; - config: ResolvedSitemapConfig; - }): string { + private buildIndexXml(ctx: SitemapContext): string { const entries = [ { loc: buildAbsoluteUrl(ctx.config.baseUrl, '/sitemap-main.xml') }, ]; - if (this.isModuleEnabled(ctx.enabledModules, 'products')) { - entries.push({ - loc: buildAbsoluteUrl(ctx.config.baseUrl, '/sitemap-products.xml'), - }); + for (const section of SITEMAP_DYNAMIC_SECTIONS) { + if (this.isModuleEnabled(ctx.enabledModules, SECTION_MODULE[section])) { + entries.push({ + loc: buildAbsoluteUrl(ctx.config.baseUrl, SECTION_FILE[section]), + }); + } } return buildSitemapIndexXml(entries); } - private async buildMainXml(ctx: { - businessId: bigint; - enabledModules: readonly BusinessModuleId[]; - config: ResolvedSitemapConfig; - }): Promise { + private async buildMainXml(ctx: SitemapContext): Promise { const { businessId, enabledModules, config } = ctx; const entries: SitemapUrlEntry[] = config.staticPages.map((page) => ({ loc: buildAbsoluteUrl(config.baseUrl, page.path), @@ -182,41 +204,61 @@ export class SitemapService { ); } - if (this.isModuleEnabled(enabledModules, 'blog')) { - entries.push( - ...(await this.loadPublishedBlogs(businessId, config.baseUrl, config.templates.blog)), - ); - } - - if (this.isModuleEnabled(enabledModules, 'portfolio')) { - entries.push( - ...(await this.loadPublishedPortfolios( - businessId, - config.baseUrl, - config.templates.portfolio, - )), - ); - } - return buildSitemapXml(entries); } - private async buildProductsXml(ctx: { - businessId: bigint; - enabledModules: readonly BusinessModuleId[]; - config: ResolvedSitemapConfig; - }): Promise { - if (!this.isModuleEnabled(ctx.enabledModules, 'products')) { + private async buildDynamicXml( + ctx: SitemapContext, + section: SitemapDynamicSection, + ): Promise { + if (!this.isModuleEnabled(ctx.enabledModules, SECTION_MODULE[section])) { return buildSitemapXml([]); } - return buildSitemapXml( - await this.loadPublishedProducts( - ctx.businessId, - ctx.config.baseUrl, - ctx.config.templates.product, - ), - ); + const { businessId, config } = ctx; + + switch (section) { + case 'products': + return buildSitemapXml( + await this.loadPublishedProducts( + businessId, + config.baseUrl, + config.templates.product, + ), + ); + case 'blogs': + return buildSitemapXml( + await this.loadPublishedBlogs(businessId, config.baseUrl, config.templates.blog), + ); + case 'portfolios': + return buildSitemapXml( + await this.loadPublishedPortfolios( + businessId, + config.baseUrl, + config.templates.portfolio, + ), + ); + case 'videos': + return buildSitemapXml( + await this.loadPublishedVideos(businessId, config.baseUrl, config.templates.video), + ); + case 'instructions': + return buildSitemapXml( + await this.loadPublishedInstructions( + businessId, + config.baseUrl, + config.templates.instruction, + ), + ); + case 'workshops': + return buildSitemapXml( + await this.loadPublishedWorkshops( + businessId, + config.baseUrl, + config.templates.workshop, + ), + ); + } } private async loadProductCategories( @@ -335,6 +377,87 @@ export class SitemapService { ); } + private async loadPublishedVideos( + businessId: bigint, + baseUrl: string, + template: string, + ): Promise { + const rows = await this.prisma.videos.findMany({ + where: { + business_id: businessId, + status: ContentStatus.published, + }, + select: { + id: true, + slug: true, + updated_at: true, + }, + orderBy: [{ updated_at: 'desc' }, { id: 'desc' }], + }); + + return rows.map((row) => + this.toEntry(baseUrl, template, { + id: row.id, + slug: row.slug, + updatedAt: row.updated_at, + }), + ); + } + + private async loadPublishedInstructions( + businessId: bigint, + baseUrl: string, + template: string, + ): Promise { + const rows = await this.prisma.instructions.findMany({ + where: { + business_id: businessId, + status: ContentStatus.published, + }, + select: { + id: true, + slug: true, + updated_at: true, + }, + orderBy: [{ updated_at: 'desc' }, { id: 'desc' }], + }); + + return rows.map((row) => + this.toEntry(baseUrl, template, { + id: row.id, + slug: row.slug, + updatedAt: row.updated_at, + }), + ); + } + + private async loadPublishedWorkshops( + businessId: bigint, + baseUrl: string, + template: string, + ): Promise { + const rows = await this.prisma.workshops.findMany({ + where: { + business_id: businessId, + status: ContentStatus.published, + }, + select: { + id: true, + slug: true, + updated_at: true, + }, + orderBy: [{ updated_at: 'desc' }, { id: 'desc' }], + }); + + return rows.map((row) => + this.toEntry(baseUrl, template, { + id: row.id, + slug: row.slug, + updatedAt: row.updated_at, + }), + ); + } + private toEntry( baseUrl: string, template: string, diff --git a/src/website-docs/static/AI_PROMPT.md b/src/website-docs/static/AI_PROMPT.md index 5697501..fc648f6 100644 --- a/src/website-docs/static/AI_PROMPT.md +++ b/src/website-docs/static/AI_PROMPT.md @@ -138,9 +138,14 @@ Minimal example: - 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/{id}/{nameFaSlug}`. - Omit `templates` in `sitemap-config.json` unless this site uses non-default paths. - **Sitemap files (proxied by nginx — do not ship local copies):** - - `/sitemap.xml` — sitemap **index** - - `/sitemap-main.xml` — static pages + categories + blogs + portfolios + - `/sitemap.xml` — sitemap **index** (lists child sitemaps for enabled modules) + - `/sitemap-main.xml` — static pages + product categories - `/sitemap-products.xml` — published products (when products module is enabled) + - `/sitemap-blogs.xml` — published blogs (when blog module is enabled) + - `/sitemap-portfolios.xml` — published portfolios (when portfolio module is enabled) + - `/sitemap-videos.xml` — published videos (when videos module is enabled) + - `/sitemap-instructions.xml` — published instructions (when instructions module is enabled) + - `/sitemap-workshops.xml` — published workshops (when workshops module is enabled) - `/robots.txt` — points at `/sitemap.xml` ### On-page SEO (every public page) diff --git a/src/website-docs/static/openapi.json b/src/website-docs/static/openapi.json index 8ad16b9..d67d88c 100644 --- a/src/website-docs/static/openapi.json +++ b/src/website-docs/static/openapi.json @@ -201,7 +201,7 @@ "SEO" ], "summary": "XML sitemap for search engines", - "description": "Sitemap **index** for this tenant. Points to `/sitemap-main.xml` (static pages, product categories, blogs, portfolios) and `/sitemap-products.xml` (published products when the products module is enabled). Category paths: `/products/category/{id}/{nameFaSlug}`. Product paths: `/products/{id}/{nameFaSlug}`. Proxied from `https://{domain}/sitemap.xml`. Cached in Redis; refreshed after CMS writes and manifest sync.", + "description": "Sitemap **index** for this tenant. Points to `/sitemap-main.xml` (static pages + product categories) plus module child sitemaps when enabled: `/sitemap-products.xml`, `/sitemap-blogs.xml`, `/sitemap-portfolios.xml`, `/sitemap-videos.xml`, `/sitemap-instructions.xml`, `/sitemap-workshops.xml`. Proxied from `https://{domain}/sitemap.xml`. Cached in Redis; refreshed after CMS writes and manifest sync.", "parameters": [ { "$ref": "#/components/parameters/domain" @@ -237,7 +237,7 @@ "tags": [ "SEO" ], - "summary": "Main sitemap urlset (static + categories + blogs + portfolios)", + "summary": "Main sitemap urlset (static pages + product categories)", "parameters": [ { "$ref": "#/components/parameters/domain" @@ -282,6 +282,131 @@ } } }, + "/tenants/{domain}/sitemap-blogs.xml": { + "get": { + "tags": [ + "SEO" + ], + "summary": "Blogs-only sitemap urlset", + "parameters": [ + { + "$ref": "#/components/parameters/domain" + } + ], + "responses": { + "200": { + "description": "Sitemap XML (urlset) of published blogs", + "content": { + "application/xml": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/tenants/{domain}/sitemap-portfolios.xml": { + "get": { + "tags": [ + "SEO" + ], + "summary": "Portfolios-only sitemap urlset", + "parameters": [ + { + "$ref": "#/components/parameters/domain" + } + ], + "responses": { + "200": { + "description": "Sitemap XML (urlset) of published portfolios", + "content": { + "application/xml": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/tenants/{domain}/sitemap-videos.xml": { + "get": { + "tags": [ + "SEO" + ], + "summary": "Videos-only sitemap urlset", + "parameters": [ + { + "$ref": "#/components/parameters/domain" + } + ], + "responses": { + "200": { + "description": "Sitemap XML (urlset) of published videos", + "content": { + "application/xml": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/tenants/{domain}/sitemap-instructions.xml": { + "get": { + "tags": [ + "SEO" + ], + "summary": "Instructions-only sitemap urlset", + "parameters": [ + { + "$ref": "#/components/parameters/domain" + } + ], + "responses": { + "200": { + "description": "Sitemap XML (urlset) of published instructions", + "content": { + "application/xml": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/tenants/{domain}/sitemap-workshops.xml": { + "get": { + "tags": [ + "SEO" + ], + "summary": "Workshops-only sitemap urlset", + "parameters": [ + { + "$ref": "#/components/parameters/domain" + } + ], + "responses": { + "200": { + "description": "Sitemap XML (urlset) of published workshops", + "content": { + "application/xml": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, "/tenants/{domain}/robots.txt": { "get": { "tags": [