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:
co-authored by
Cursor
parent
fb04425df7
commit
0d9c2fd8ea
@@ -18,6 +18,7 @@ export type ResolvedSitemapConfig = {
|
||||
| 'video'
|
||||
| 'instruction'
|
||||
| 'workshop'
|
||||
| 'userProduct'
|
||||
>
|
||||
>;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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": [
|
||||
|
||||
Reference in New Issue
Block a user