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 <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-27 07:16:05 +03:30
co-authored by Cursor
parent fb04425df7
commit 0d9c2fd8ea
14 changed files with 144 additions and 5 deletions
+1
View File
@@ -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 |
+3 -1
View File
@@ -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)
+27 -1
View File
@@ -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": [
@@ -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"),
]
+10
View File
@@ -210,6 +210,16 @@ PROXY_COMMON=$(cat <<PROXY
add_header Cache-Control "public, max-age=900";
}
location = /sitemap-user-products.xml {
proxy_pass https://api.meshkee.com/api/v1/tenants/${HOST}/sitemap-user-products.xml;
proxy_set_header Host api.meshkee.com;
proxy_ssl_server_name on;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
add_header Cache-Control "public, max-age=900";
}
location = /robots.txt {
proxy_pass https://api.meshkee.com/api/v1/tenants/${HOST}/robots.txt;
proxy_set_header Host api.meshkee.com;
+1
View File
@@ -18,6 +18,7 @@ export type ResolvedSitemapConfig = {
| 'video'
| 'instruction'
| 'workshop'
| 'userProduct'
>
>;
};
+3
View File
@@ -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,
},
};
}
+2
View File
@@ -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];
+9
View File
@@ -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) {
+37
View File
@@ -41,6 +41,7 @@ const SECTION_MODULE: Record<SitemapDynamicSection, BusinessModuleId> = {
videos: 'videos',
instructions: 'instructions',
workshops: 'workshops',
userProducts: 'customer_products',
};
const SECTION_FILE: Record<SitemapDynamicSection, string> = {
@@ -50,6 +51,7 @@ const SECTION_FILE: Record<SitemapDynamicSection, string> = {
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<SitemapUrlEntry[]> {
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,
+8 -1
View File
@@ -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,
@@ -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' };
}
+3 -1
View File
@@ -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)
+27 -1
View File
@@ -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": [