From 0d9c2fd8ea5f2b5315d82540d5346a9debc35ac5 Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Thu, 27 Aug 2026 07:16:05 +0330 Subject: [PATCH] Add published user products to the storefront sitemap. Expose /sitemap-user-products.xml when customer_products is enabled, invalidate on listing mutations, and proxy the new path from nginx provision scripts. Co-authored-by: Cursor --- docs/PROJECT_CONTEXT.md | 1 + docs/website-api/AI_PROMPT.md | 4 ++- docs/website-api/openapi.json | 28 +++++++++++++++- scripts/websites-agent/patch-nginx-seo.sh | 1 + scripts/websites-agent/provision.sh | 10 ++++++ src/sitemap/sitemap-config.types.ts | 1 + src/sitemap/sitemap-config.util.ts | 3 ++ src/sitemap/sitemap.constants.ts | 2 ++ src/sitemap/sitemap.controller.ts | 9 ++++++ src/sitemap/sitemap.service.ts | 37 ++++++++++++++++++++++ src/user-products/user-products.module.ts | 9 +++++- src/user-products/user-products.service.ts | 12 +++++++ src/website-docs/static/AI_PROMPT.md | 4 ++- src/website-docs/static/openapi.json | 28 +++++++++++++++- 14 files changed, 144 insertions(+), 5 deletions(-) diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index 62d2985..ffab8e7 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -270,6 +270,7 @@ All routes are prefixed with `/api/v1`. | 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/sitemap-user-products.xml` | Published user products `/user-products/{slug}` (module `customer_products`) | | 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 60fedd3..aa97bd7 100644 --- a/docs/website-api/AI_PROMPT.md +++ b/docs/website-api/AI_PROMPT.md @@ -152,9 +152,10 @@ Minimal example: - **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/{categoryId}/{nameFaSlug}` — build slug from `nameFa` (fallback `name`); resolve via `GET /tenants/{domain}/categories/by-id/{id}`, then list products with `categoryId`. + - User product (customer listing): `/user-products/{slug}` — use the listing’s `slug` from the API; resolve via `GET /tenants/{domain}/user-products/{slug}`. - 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 for details (slugify Farsi title: spaces → `-`, keep Persian letters). Category links use `/products/category/{id}/{nameFaSlug}`. +- 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}`. User-product cards use `/user-products/{slug}`. - 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** (lists child sitemaps for enabled modules) @@ -165,6 +166,7 @@ Minimal example: - `/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) + - `/sitemap-user-products.xml` — published user products / customer listings (when `customer_products` 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 829f00b..22668e6 100644 --- a/docs/website-api/openapi.json +++ b/docs/website-api/openapi.json @@ -203,7 +203,7 @@ "SEO" ], "summary": "XML sitemap for search engines", - "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.", + "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`, `/sitemap-user-products.xml` (when `customer_products` is enabled). Proxied from `https://{domain}/sitemap.xml`. Cached in Redis; refreshed after CMS writes and manifest sync.", "parameters": [ { "$ref": "#/components/parameters/domain" @@ -409,6 +409,32 @@ } } }, + "/tenants/{domain}/sitemap-user-products.xml": { + "get": { + "tags": [ + "SEO" + ], + "summary": "User products sitemap urlset", + "description": "Published customer marketplace listings. Default paths: `/user-products/{slug}`. Included in the index when the `customer_products` module is enabled. Override path via sitemap-config `templates.userProduct`.", + "parameters": [ + { + "$ref": "#/components/parameters/domain" + } + ], + "responses": { + "200": { + "description": "Sitemap XML (urlset) of published user products", + "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 9893956..0834d7e 100755 --- a/scripts/websites-agent/patch-nginx-seo.sh +++ b/scripts/websites-agent/patch-nginx-seo.sh @@ -36,6 +36,7 @@ paths = [ ("/sitemap-videos.xml", "sitemap-videos.xml"), ("/sitemap-instructions.xml", "sitemap-instructions.xml"), ("/sitemap-workshops.xml", "sitemap-workshops.xml"), + ("/sitemap-user-products.xml", "sitemap-user-products.xml"), ("/robots.txt", "robots.txt"), ] diff --git a/scripts/websites-agent/provision.sh b/scripts/websites-agent/provision.sh index 92e81a7..d0c654f 100755 --- a/scripts/websites-agent/provision.sh +++ b/scripts/websites-agent/provision.sh @@ -210,6 +210,16 @@ PROXY_COMMON=$(cat < >; }; diff --git a/src/sitemap/sitemap-config.util.ts b/src/sitemap/sitemap-config.util.ts index 85f2faf..35155de 100644 --- a/src/sitemap/sitemap-config.util.ts +++ b/src/sitemap/sitemap-config.util.ts @@ -111,6 +111,9 @@ export function resolveSitemapConfig( DEFAULT_SITEMAP_PATH_TEMPLATES.instruction, workshop: stored?.templates?.workshop ?? DEFAULT_SITEMAP_PATH_TEMPLATES.workshop, + userProduct: + stored?.templates?.userProduct ?? + DEFAULT_SITEMAP_PATH_TEMPLATES.userProduct, }, }; } diff --git a/src/sitemap/sitemap.constants.ts b/src/sitemap/sitemap.constants.ts index f5c6334..243f540 100644 --- a/src/sitemap/sitemap.constants.ts +++ b/src/sitemap/sitemap.constants.ts @@ -11,6 +11,7 @@ export const DEFAULT_SITEMAP_PATH_TEMPLATES = { video: '/videos/{slug}', instruction: '/instructions/{slug}', workshop: '/workshops/{slug}', + userProduct: '/user-products/{slug}', } as const; export const WEBSITE_SITEMAP_CONFIG_PATH = '/meshkee/sitemap-config.json'; @@ -27,6 +28,7 @@ export const SITEMAP_DYNAMIC_SECTIONS = [ 'videos', 'instructions', 'workshops', + 'userProducts', ] as const; export type SitemapDynamicSection = (typeof SITEMAP_DYNAMIC_SECTIONS)[number]; diff --git a/src/sitemap/sitemap.controller.ts b/src/sitemap/sitemap.controller.ts index 95f495d..67895fc 100644 --- a/src/sitemap/sitemap.controller.ts +++ b/src/sitemap/sitemap.controller.ts @@ -59,6 +59,15 @@ export class SitemapController { await this.sendDynamic(host, 'workshops', res); } + @Get('sitemap-user-products.xml') + @Header('Cache-Control', 'public, max-age=900') + async getUserProductsSitemap( + @Param('host') host: string, + @Res() res: Response, + ) { + await this.sendDynamic(host, 'userProducts', res); + } + @Get('robots.txt') @Header('Cache-Control', 'public, max-age=900') async getRobots(@Param('host') host: string, @Res() res: Response) { diff --git a/src/sitemap/sitemap.service.ts b/src/sitemap/sitemap.service.ts index 1662368..8e63502 100644 --- a/src/sitemap/sitemap.service.ts +++ b/src/sitemap/sitemap.service.ts @@ -41,6 +41,7 @@ const SECTION_MODULE: Record = { videos: 'videos', instructions: 'instructions', workshops: 'workshops', + userProducts: 'customer_products', }; const SECTION_FILE: Record = { @@ -50,6 +51,7 @@ const SECTION_FILE: Record = { videos: '/sitemap-videos.xml', instructions: '/sitemap-instructions.xml', workshops: '/sitemap-workshops.xml', + userProducts: '/sitemap-user-products.xml', }; @Injectable() @@ -258,6 +260,14 @@ export class SitemapService { config.templates.workshop, ), ); + case 'userProducts': + return buildSitemapXml( + await this.loadPublishedUserProducts( + businessId, + config.baseUrl, + config.templates.userProduct, + ), + ); } } @@ -458,6 +468,33 @@ export class SitemapService { ); } + private async loadPublishedUserProducts( + businessId: bigint, + baseUrl: string, + template: string, + ): Promise { + const rows = await this.prisma.userProduct.findMany({ + where: { + businessId, + status: ContentStatus.published, + }, + select: { + id: true, + slug: true, + updatedAt: true, + }, + orderBy: [{ updatedAt: 'desc' }, { id: 'desc' }], + }); + + return rows.map((row) => + this.toEntry(baseUrl, template, { + id: row.id, + slug: row.slug, + updatedAt: row.updatedAt, + }), + ); + } + private toEntry( baseUrl: string, template: string, diff --git a/src/user-products/user-products.module.ts b/src/user-products/user-products.module.ts index 1b9bb8a..ff9fb20 100644 --- a/src/user-products/user-products.module.ts +++ b/src/user-products/user-products.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { AuthModule } from '../auth/auth.module'; import { CategoriesModule } from '../categories/categories.module'; import { MediaModule } from '../media/media.module'; +import { SitemapModule } from '../sitemap/sitemap.module'; import { TenantModule } from '../tenant/tenant.module'; import { UserProductsAdminController } from './user-products.admin.controller'; import { UserProductsController } from './user-products.controller'; @@ -9,7 +10,13 @@ import { PublicUserProductsController } from './user-products.public.controller' import { UserProductsService } from './user-products.service'; @Module({ - imports: [AuthModule, CategoriesModule, MediaModule, TenantModule], + imports: [ + AuthModule, + CategoriesModule, + MediaModule, + TenantModule, + SitemapModule, + ], controllers: [ UserProductsController, UserProductsAdminController, diff --git a/src/user-products/user-products.service.ts b/src/user-products/user-products.service.ts index a860391..f4b1a65 100644 --- a/src/user-products/user-products.service.ts +++ b/src/user-products/user-products.service.ts @@ -26,6 +26,7 @@ import { UserProductTechnicalValueDto, } from './dto/user-product.dto'; import { TenantService } from '../tenant/tenant.service'; +import { SitemapService } from '../sitemap/sitemap.service'; function slugify(value: string): string { return ( @@ -109,6 +110,7 @@ export class UserProductsService { private readonly technicalFormService: CategoryTechnicalFormService, private readonly mediaService: MediaService, private readonly tenant: TenantService, + private readonly sitemap: SitemapService, ) {} async listPublic(host: string, query: ListPublicUserProductsDto) { @@ -404,6 +406,8 @@ export class UserProductsService { include: userProductAdminListInclude, }); + await this.sitemap.invalidateForBusiness(businessId); + return { message: 'User product created successfully', product: withUser @@ -621,6 +625,8 @@ export class UserProductsService { } await this.mediaService.deleteUnused(businessId, dropped); + await this.sitemap.invalidateForBusiness(businessId); + return { message: 'User product updated successfully', product: this.serializeAdminListItem(updated, category ?? undefined), @@ -680,6 +686,8 @@ export class UserProductsService { await this.mediaService.deleteUnused(businessId, mediaIds); + await this.sitemap.invalidateForBusiness(businessId); + return { message: 'User product deleted successfully' }; } @@ -879,6 +887,8 @@ export class UserProductsService { updated.id, ]); + await this.sitemap.invalidateForBusiness(businessId); + return { message: 'User product status updated successfully', product: this.serializeAdminListItem( @@ -961,6 +971,8 @@ export class UserProductsService { await tx.userProduct.delete({ where: { id: productId } }); }); + await this.sitemap.invalidateForBusiness(businessId); + return { message: 'User product deleted successfully' }; } diff --git a/src/website-docs/static/AI_PROMPT.md b/src/website-docs/static/AI_PROMPT.md index 60fedd3..aa97bd7 100644 --- a/src/website-docs/static/AI_PROMPT.md +++ b/src/website-docs/static/AI_PROMPT.md @@ -152,9 +152,10 @@ Minimal example: - **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/{categoryId}/{nameFaSlug}` — build slug from `nameFa` (fallback `name`); resolve via `GET /tenants/{domain}/categories/by-id/{id}`, then list products with `categoryId`. + - User product (customer listing): `/user-products/{slug}` — use the listing’s `slug` from the API; resolve via `GET /tenants/{domain}/user-products/{slug}`. - 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 for details (slugify Farsi title: spaces → `-`, keep Persian letters). Category links use `/products/category/{id}/{nameFaSlug}`. +- 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}`. User-product cards use `/user-products/{slug}`. - 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** (lists child sitemaps for enabled modules) @@ -165,6 +166,7 @@ Minimal example: - `/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) + - `/sitemap-user-products.xml` — published user products / customer listings (when `customer_products` 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 829f00b..22668e6 100644 --- a/src/website-docs/static/openapi.json +++ b/src/website-docs/static/openapi.json @@ -203,7 +203,7 @@ "SEO" ], "summary": "XML sitemap for search engines", - "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.", + "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`, `/sitemap-user-products.xml` (when `customer_products` is enabled). Proxied from `https://{domain}/sitemap.xml`. Cached in Redis; refreshed after CMS writes and manifest sync.", "parameters": [ { "$ref": "#/components/parameters/domain" @@ -409,6 +409,32 @@ } } }, + "/tenants/{domain}/sitemap-user-products.xml": { + "get": { + "tags": [ + "SEO" + ], + "summary": "User products sitemap urlset", + "description": "Published customer marketplace listings. Default paths: `/user-products/{slug}`. Included in the index when the `customer_products` module is enabled. Override path via sitemap-config `templates.userProduct`.", + "parameters": [ + { + "$ref": "#/components/parameters/domain" + } + ], + "responses": { + "200": { + "description": "Sitemap XML (urlset) of published user products", + "content": { + "application/xml": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, "/tenants/{domain}/robots.txt": { "get": { "tags": [