Add backend-generated sitemap with website manifest sync and nginx SEO proxy support.
Stores synced route config from /meshkee/sitemap-config.json, serves sitemap.xml and robots.txt per tenant, and invalidates cache on CMS writes. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
c7d5204b49
commit
2e8d7c1cfd
@@ -0,0 +1,22 @@
|
||||
import { Controller, Post, Param, UseGuards } from '@nestjs/common';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator';
|
||||
import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { SitemapConfigService } from './sitemap-config.service';
|
||||
|
||||
@Controller('businesses/:businessId/website/sitemap')
|
||||
@UseGuards(JwtAuthGuard, BusinessPermissionGuard)
|
||||
export class SitemapAdminController {
|
||||
constructor(private readonly config: SitemapConfigService) {}
|
||||
|
||||
@Post('sync')
|
||||
@RequireBusinessPermission('website.update')
|
||||
syncFromWebsite(
|
||||
@Param('businessId') businessId: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.config.syncFromWebsite(businessId, user);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import type {
|
||||
SitemapChangefreq,
|
||||
SitemapPathTemplatesConfig,
|
||||
SitemapStaticPageConfig,
|
||||
WebsiteSitemapConfig,
|
||||
} from '../business-settings/business-settings.types';
|
||||
|
||||
export const WEBSITE_SITEMAP_CONFIG_PATH = '/meshkee/sitemap-config.json';
|
||||
|
||||
export const SITEMAP_CHANGEFREQ_VALUES = [
|
||||
'always',
|
||||
'hourly',
|
||||
'daily',
|
||||
'weekly',
|
||||
'monthly',
|
||||
'yearly',
|
||||
'never',
|
||||
] as const;
|
||||
|
||||
const PATH_PATTERN = /^\/[^\s?#]*$/;
|
||||
const TEMPLATE_PATTERN = /^\/[^\s?#]*\{slug\}[^\s?#]*$/;
|
||||
const BASE_URL_PATTERN = /^https:\/\/[a-z0-9.-]+(?::\d+)?\/?$/i;
|
||||
|
||||
const TEMPLATE_KEYS = [
|
||||
'product',
|
||||
'blog',
|
||||
'portfolio',
|
||||
'storeItemByProduct',
|
||||
'video',
|
||||
'instruction',
|
||||
'workshop',
|
||||
'userProduct',
|
||||
] as const satisfies readonly (keyof SitemapPathTemplatesConfig)[];
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function readString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function readChangefreq(value: unknown): SitemapChangefreq | undefined {
|
||||
const raw = readString(value);
|
||||
return (SITEMAP_CHANGEFREQ_VALUES as readonly string[]).includes(raw)
|
||||
? (raw as SitemapChangefreq)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function readPriority(value: unknown): number | undefined {
|
||||
if (value == null || value === '') return undefined;
|
||||
const parsed = typeof value === 'number' ? value : Number(value);
|
||||
if (!Number.isFinite(parsed)) return undefined;
|
||||
return Math.min(1, Math.max(0, parsed));
|
||||
}
|
||||
|
||||
function readPath(value: unknown): string | null {
|
||||
const path = readString(value);
|
||||
if (!path || !PATH_PATTERN.test(path)) return null;
|
||||
return path;
|
||||
}
|
||||
|
||||
function readTemplate(value: unknown): string | null {
|
||||
const template = readString(value);
|
||||
if (!template || !TEMPLATE_PATTERN.test(template)) return null;
|
||||
return template;
|
||||
}
|
||||
|
||||
function readBaseUrl(value: unknown): string | null {
|
||||
const url = readString(value).replace(/\/+$/, '');
|
||||
if (!url || !BASE_URL_PATTERN.test(`${url}/`)) return null;
|
||||
return url;
|
||||
}
|
||||
|
||||
export function parseWebsiteSitemapConfig(payload: unknown): WebsiteSitemapConfig {
|
||||
const root = asRecord(payload);
|
||||
if (!root) {
|
||||
throw new Error('Sitemap config must be a JSON object');
|
||||
}
|
||||
|
||||
const staticPages: SitemapStaticPageConfig[] = [];
|
||||
const seenPaths = new Set<string>();
|
||||
|
||||
if (Array.isArray(root.staticPages)) {
|
||||
for (const entry of root.staticPages) {
|
||||
const row = asRecord(entry);
|
||||
if (!row) continue;
|
||||
|
||||
const path = readPath(row.path);
|
||||
if (!path || seenPaths.has(path)) continue;
|
||||
|
||||
seenPaths.add(path);
|
||||
staticPages.push({
|
||||
path,
|
||||
...(readChangefreq(row.changefreq)
|
||||
? { changefreq: readChangefreq(row.changefreq) }
|
||||
: {}),
|
||||
...(readPriority(row.priority) != null
|
||||
? { priority: readPriority(row.priority) }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const templatesRaw = asRecord(root.templates);
|
||||
const templates: SitemapPathTemplatesConfig = {};
|
||||
|
||||
if (templatesRaw) {
|
||||
for (const key of TEMPLATE_KEYS) {
|
||||
const template = readTemplate(templatesRaw[key]);
|
||||
if (template) {
|
||||
templates[key] = template;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const baseUrl = readBaseUrl(root.baseUrl);
|
||||
|
||||
if (staticPages.length === 0 && !baseUrl && Object.keys(templates).length === 0) {
|
||||
throw new Error(
|
||||
'Sitemap config must include staticPages, templates, or baseUrl',
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
...(baseUrl ? { baseUrl } : {}),
|
||||
...(staticPages.length > 0 ? { staticPages } : {}),
|
||||
...(Object.keys(templates).length > 0 ? { templates } : {}),
|
||||
syncedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function websiteSitemapConfigUrls(hosts: string[]): string[] {
|
||||
const uniqueHosts = [
|
||||
...new Set(hosts.map((host) => host.toLowerCase().trim()).filter(Boolean)),
|
||||
];
|
||||
const urls: string[] = [];
|
||||
|
||||
for (const host of uniqueHosts) {
|
||||
const bare = host.replace(/^www\./, '');
|
||||
const variants = [bare, `www.${bare}`];
|
||||
for (const candidate of variants) {
|
||||
urls.push(`https://${candidate}${WEBSITE_SITEMAP_CONFIG_PATH}`);
|
||||
urls.push(`http://${candidate}${WEBSITE_SITEMAP_CONFIG_PATH}`);
|
||||
}
|
||||
}
|
||||
|
||||
return [...new Set(urls)];
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { PermissionsService } from '../auth/permissions.service';
|
||||
import {
|
||||
mergeBusinessSettings,
|
||||
normalizeBusinessSettings,
|
||||
toPrismaJson,
|
||||
} from '../business-settings/business-settings.util';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
parseWebsiteSitemapConfig,
|
||||
WEBSITE_SITEMAP_CONFIG_PATH,
|
||||
websiteSitemapConfigUrls,
|
||||
} from './sitemap-config.catalog';
|
||||
import { SitemapService } from './sitemap.service';
|
||||
|
||||
@Injectable()
|
||||
export class SitemapConfigService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly permissions: PermissionsService,
|
||||
private readonly sitemap: SitemapService,
|
||||
) {}
|
||||
|
||||
async syncFromWebsite(businessIdRaw: string, actor: AuthUser) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'website.update');
|
||||
|
||||
const business = await this.prisma.business.findUnique({
|
||||
where: { id: businessId },
|
||||
select: { id: true, settings: true },
|
||||
});
|
||||
|
||||
if (!business) {
|
||||
throw new NotFoundException('Business not found');
|
||||
}
|
||||
|
||||
const hosts = await this.listWebsiteHosts(businessId);
|
||||
if (hosts.length === 0) {
|
||||
throw new BadRequestException(
|
||||
'This business has no website domain to read sitemap config from',
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = await this.fetchWebsiteConfig(hosts);
|
||||
const current = normalizeBusinessSettings(business.settings);
|
||||
const next = mergeBusinessSettings(current, {
|
||||
website: {
|
||||
...current.website,
|
||||
sitemapConfig: parsed,
|
||||
},
|
||||
});
|
||||
|
||||
await this.prisma.business.update({
|
||||
where: { id: businessId },
|
||||
data: { settings: toPrismaJson(next) },
|
||||
});
|
||||
|
||||
await this.sitemap.invalidateForBusiness(businessId);
|
||||
|
||||
return {
|
||||
message: 'Sitemap config refreshed from the website',
|
||||
sitemapConfig: next.website.sitemapConfig,
|
||||
};
|
||||
}
|
||||
|
||||
private async listWebsiteHosts(businessId: bigint): Promise<string[]> {
|
||||
const domains = await this.prisma.domain.findMany({
|
||||
where: { businessId, isActive: true },
|
||||
orderBy: [{ isPrimary: 'desc' }, { host: 'asc' }],
|
||||
select: { host: true },
|
||||
});
|
||||
|
||||
return domains
|
||||
.map((domain) => domain.host.toLowerCase().trim())
|
||||
.filter(
|
||||
(host) =>
|
||||
host &&
|
||||
!host.startsWith('business.') &&
|
||||
!host.startsWith('customer.'),
|
||||
);
|
||||
}
|
||||
|
||||
private async fetchWebsiteConfig(hosts: string[]) {
|
||||
const urls = websiteSitemapConfigUrls(hosts);
|
||||
let lastError = 'Website sitemap config was not found';
|
||||
|
||||
for (const url of urls) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 8000);
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
redirect: 'follow',
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'User-Agent': 'Meshkee-CMS/1.0',
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
lastError = `Website returned ${response.status} for ${WEBSITE_SITEMAP_CONFIG_PATH}`;
|
||||
continue;
|
||||
}
|
||||
const payload: unknown = await response.json();
|
||||
return parseWebsiteSitemapConfig(payload);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.startsWith('Sitemap config')) {
|
||||
lastError = error.message;
|
||||
continue;
|
||||
}
|
||||
lastError = `Could not read ${WEBSITE_SITEMAP_CONFIG_PATH} from the website`;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
throw new BadRequestException(
|
||||
`${lastError}. The site must publish GET ${WEBSITE_SITEMAP_CONFIG_PATH} on its main domain.`,
|
||||
);
|
||||
}
|
||||
|
||||
private async assertPermission(
|
||||
businessId: bigint,
|
||||
userId: bigint,
|
||||
permission: string,
|
||||
) {
|
||||
const allowed = await this.permissions.hasBusinessPermission(
|
||||
userId,
|
||||
businessId,
|
||||
permission,
|
||||
);
|
||||
|
||||
if (!allowed) {
|
||||
throw new ForbiddenException(
|
||||
`Missing permission: ${permission} for this business`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export type {
|
||||
SitemapChangefreq,
|
||||
SitemapPathTemplatesConfig,
|
||||
SitemapStaticPageConfig,
|
||||
WebsiteSitemapConfig,
|
||||
} from '../business-settings/business-settings.types';
|
||||
|
||||
export type ResolvedSitemapConfig = {
|
||||
baseUrl: string;
|
||||
staticPages: import('../business-settings/business-settings.types').SitemapStaticPageConfig[];
|
||||
templates: Required<
|
||||
Pick<
|
||||
import('../business-settings/business-settings.types').SitemapPathTemplatesConfig,
|
||||
'product' | 'blog' | 'portfolio'
|
||||
>
|
||||
>;
|
||||
};
|
||||
@@ -0,0 +1,103 @@
|
||||
import {
|
||||
DEFAULT_SITEMAP_PATH_TEMPLATES,
|
||||
DEFAULT_SITEMAP_STATIC_PAGES,
|
||||
} from './sitemap.constants';
|
||||
import type {
|
||||
ResolvedSitemapConfig,
|
||||
SitemapStaticPageConfig,
|
||||
WebsiteSitemapConfig,
|
||||
} from './sitemap-config.types';
|
||||
|
||||
function normalizeStoredStaticPages(
|
||||
value: unknown,
|
||||
): SitemapStaticPageConfig[] | null {
|
||||
if (!Array.isArray(value)) return null;
|
||||
|
||||
const pages: SitemapStaticPageConfig[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const entry of value) {
|
||||
if (!entry || typeof entry !== 'object') continue;
|
||||
const row = entry as Record<string, unknown>;
|
||||
const path = typeof row.path === 'string' ? row.path.trim() : '';
|
||||
if (!path.startsWith('/') || seen.has(path)) continue;
|
||||
seen.add(path);
|
||||
pages.push({
|
||||
path,
|
||||
...(typeof row.changefreq === 'string'
|
||||
? { changefreq: row.changefreq as SitemapStaticPageConfig['changefreq'] }
|
||||
: {}),
|
||||
...(typeof row.priority === 'number' ? { priority: row.priority } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
return pages.length > 0 ? pages : null;
|
||||
}
|
||||
|
||||
function normalizeStoredTemplates(
|
||||
value: unknown,
|
||||
): WebsiteSitemapConfig['templates'] | null {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
const raw = value as Record<string, unknown>;
|
||||
const templates: NonNullable<WebsiteSitemapConfig['templates']> = {};
|
||||
|
||||
for (const [key, template] of Object.entries(raw)) {
|
||||
if (typeof template === 'string' && template.includes('{slug}')) {
|
||||
templates[key as keyof typeof templates] = template.trim();
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(templates).length > 0 ? templates : null;
|
||||
}
|
||||
|
||||
export function normalizeWebsiteSitemapConfig(
|
||||
raw: unknown,
|
||||
): WebsiteSitemapConfig | null {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const source = raw as Record<string, unknown>;
|
||||
const staticPages = normalizeStoredStaticPages(source.staticPages);
|
||||
const templates = normalizeStoredTemplates(source.templates);
|
||||
const baseUrl =
|
||||
typeof source.baseUrl === 'string' && source.baseUrl.trim()
|
||||
? source.baseUrl.trim().replace(/\/+$/, '')
|
||||
: null;
|
||||
const syncedAt =
|
||||
typeof source.syncedAt === 'string' && source.syncedAt.trim()
|
||||
? source.syncedAt.trim()
|
||||
: null;
|
||||
|
||||
if (!staticPages && !templates && !baseUrl) return null;
|
||||
|
||||
return {
|
||||
...(baseUrl ? { baseUrl } : {}),
|
||||
...(staticPages ? { staticPages } : {}),
|
||||
...(templates ? { templates } : {}),
|
||||
...(syncedAt ? { syncedAt } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveSitemapConfig(
|
||||
stored: WebsiteSitemapConfig | null | undefined,
|
||||
apexHost: string,
|
||||
): ResolvedSitemapConfig {
|
||||
const fallbackBase = `https://${apexHost.toLowerCase().trim()}`;
|
||||
const baseUrl = stored?.baseUrl?.replace(/\/+$/, '') || fallbackBase;
|
||||
|
||||
const staticPages =
|
||||
stored?.staticPages && stored.staticPages.length > 0
|
||||
? stored.staticPages
|
||||
: [...DEFAULT_SITEMAP_STATIC_PAGES];
|
||||
|
||||
return {
|
||||
baseUrl,
|
||||
staticPages,
|
||||
templates: {
|
||||
product:
|
||||
stored?.templates?.product ?? DEFAULT_SITEMAP_PATH_TEMPLATES.product,
|
||||
blog: stored?.templates?.blog ?? DEFAULT_SITEMAP_PATH_TEMPLATES.blog,
|
||||
portfolio:
|
||||
stored?.templates?.portfolio ??
|
||||
DEFAULT_SITEMAP_PATH_TEMPLATES.portfolio,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { SITEMAP_URLSET_NS } from './sitemap.constants';
|
||||
|
||||
export type SitemapUrlEntry = {
|
||||
loc: string;
|
||||
lastmod?: Date | null;
|
||||
changefreq?: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never';
|
||||
priority?: number;
|
||||
};
|
||||
|
||||
function escapeXml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function formatLastmod(date: Date): string {
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
function formatPriority(value: number): string {
|
||||
const clamped = Math.min(1, Math.max(0, value));
|
||||
return clamped.toFixed(1);
|
||||
}
|
||||
|
||||
export function buildSitemapXml(entries: SitemapUrlEntry[]): string {
|
||||
const urls = entries
|
||||
.map((entry) => {
|
||||
const parts = [` <loc>${escapeXml(entry.loc)}</loc>`];
|
||||
if (entry.lastmod) {
|
||||
parts.push(` <lastmod>${formatLastmod(entry.lastmod)}</lastmod>`);
|
||||
}
|
||||
if (entry.changefreq) {
|
||||
parts.push(` <changefreq>${entry.changefreq}</changefreq>`);
|
||||
}
|
||||
if (entry.priority != null) {
|
||||
parts.push(` <priority>${formatPriority(entry.priority)}</priority>`);
|
||||
}
|
||||
return ` <url>\n${parts.join('\n')}\n </url>`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
`<urlset xmlns="${SITEMAP_URLSET_NS}">`,
|
||||
urls,
|
||||
'</urlset>',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function applyPathTemplate(template: string, slug: string): string {
|
||||
return template.replace('{slug}', encodeURIComponent(slug));
|
||||
}
|
||||
|
||||
export function buildAbsoluteUrl(baseUrl: string, path: string): string {
|
||||
const normalizedBase = baseUrl.replace(/\/+$/, '');
|
||||
if (path === '/') return `${normalizedBase}/`;
|
||||
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
||||
return `${normalizedBase}${normalizedPath}`;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/** 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';
|
||||
|
||||
/** Default storefront path templates (overridable via /meshkee/sitemap-config.json). */
|
||||
export const DEFAULT_SITEMAP_PATH_TEMPLATES = {
|
||||
product: '/products/{slug}',
|
||||
blog: '/blog/{slug}',
|
||||
portfolio: '/portfolios/{slug}',
|
||||
} as const;
|
||||
|
||||
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;
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Controller, Get, Header, Param, Res } from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
import { SitemapService } from './sitemap.service';
|
||||
|
||||
@Controller('tenants/:host')
|
||||
export class SitemapController {
|
||||
constructor(private readonly sitemap: SitemapService) {}
|
||||
|
||||
@Get('sitemap.xml')
|
||||
@Header('Cache-Control', 'public, max-age=900')
|
||||
async getSitemap(@Param('host') host: string, @Res() res: Response) {
|
||||
const xml = await this.sitemap.getXmlForHost(host);
|
||||
res.setHeader('Content-Type', 'application/xml; charset=utf-8');
|
||||
res.send(xml);
|
||||
}
|
||||
|
||||
@Get('robots.txt')
|
||||
@Header('Cache-Control', 'public, max-age=900')
|
||||
async getRobots(@Param('host') host: string, @Res() res: Response) {
|
||||
const text = await this.sitemap.getRobotsForHost(host);
|
||||
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
||||
res.send(text);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { TenantModule } from '../tenant/tenant.module';
|
||||
import { SitemapAdminController } from './sitemap-admin.controller';
|
||||
import { SitemapConfigService } from './sitemap-config.service';
|
||||
import { SitemapController } from './sitemap.controller';
|
||||
import { SitemapService } from './sitemap.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, TenantModule],
|
||||
controllers: [SitemapController, SitemapAdminController],
|
||||
providers: [SitemapService, SitemapConfigService],
|
||||
exports: [SitemapService],
|
||||
})
|
||||
export class SitemapModule {}
|
||||
@@ -0,0 +1,227 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ContentStatus } 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';
|
||||
import { RedisService } from '../redis/redis.service';
|
||||
import { TenantService } from '../tenant/tenant.service';
|
||||
import { resolveSitemapConfig } from './sitemap-config.util';
|
||||
import { SITEMAP_CACHE_TTL_SECONDS } from './sitemap.constants';
|
||||
import {
|
||||
applyPathTemplate,
|
||||
buildAbsoluteUrl,
|
||||
buildSitemapXml,
|
||||
type SitemapUrlEntry,
|
||||
} from './sitemap-xml.util';
|
||||
|
||||
type PublishedSlugRow = {
|
||||
slug: string;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class SitemapService {
|
||||
private readonly logger = new Logger(SitemapService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly redis: RedisService,
|
||||
private readonly tenant: TenantService,
|
||||
) {}
|
||||
|
||||
private cacheKey(businessId: bigint): string {
|
||||
return `sitemap:${businessId.toString()}`;
|
||||
}
|
||||
|
||||
async invalidateForBusiness(businessId: bigint | string): Promise<void> {
|
||||
const id = typeof businessId === 'bigint' ? businessId : BigInt(businessId);
|
||||
try {
|
||||
await this.redis.client.del(this.cacheKey(id));
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Failed to invalidate sitemap cache for business ${id.toString()}: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async getXmlForHost(host: string): Promise<string> {
|
||||
const business = await this.tenant.resolveBusinessByDomain(host);
|
||||
const cacheKey = this.cacheKey(business.id);
|
||||
|
||||
try {
|
||||
const cached = await this.redis.client.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
} catch (err) {
|
||||
this.logger.warn(`Sitemap cache read failed: ${String(err)}`);
|
||||
}
|
||||
|
||||
const apexHost = await this.resolveApexHost(business.id, host);
|
||||
const xml = await this.buildXml(business.id, apexHost);
|
||||
|
||||
try {
|
||||
await this.redis.client.set(cacheKey, xml, 'EX', SITEMAP_CACHE_TTL_SECONDS);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Sitemap cache write failed: ${String(err)}`);
|
||||
}
|
||||
|
||||
return xml;
|
||||
}
|
||||
|
||||
async getRobotsForHost(host: string): Promise<string> {
|
||||
const business = await this.tenant.resolveBusinessByDomain(host);
|
||||
const apexHost = await this.resolveApexHost(business.id, host);
|
||||
const stored = await this.prisma.business.findUnique({
|
||||
where: { id: business.id },
|
||||
select: { settings: true },
|
||||
});
|
||||
const settings = normalizeBusinessSettings(stored?.settings);
|
||||
const config = resolveSitemapConfig(
|
||||
settings.website.sitemapConfig,
|
||||
apexHost,
|
||||
);
|
||||
|
||||
return ['User-agent: *', 'Allow: /', '', `Sitemap: ${config.baseUrl}/sitemap.xml`, ''].join(
|
||||
'\n',
|
||||
);
|
||||
}
|
||||
|
||||
private async resolveApexHost(businessId: bigint, fallbackHost: string): Promise<string> {
|
||||
const primary = await this.prisma.domain.findFirst({
|
||||
where: { businessId, isPrimary: true },
|
||||
select: { host: true },
|
||||
});
|
||||
return primary?.host ?? fallbackHost.toLowerCase().trim();
|
||||
}
|
||||
|
||||
private isModuleEnabled(
|
||||
enabledModules: readonly BusinessModuleId[],
|
||||
moduleId: BusinessModuleId,
|
||||
): boolean {
|
||||
return enabledModules.includes(moduleId);
|
||||
}
|
||||
|
||||
private async buildXml(businessId: bigint, apexHost: string): Promise<string> {
|
||||
const business = await this.prisma.business.findUnique({
|
||||
where: { id: businessId },
|
||||
select: { settings: true },
|
||||
});
|
||||
|
||||
const settings = normalizeBusinessSettings(business?.settings);
|
||||
const enabledModules = settings.modules.enabled;
|
||||
const config = resolveSitemapConfig(settings.website.sitemapConfig, apexHost);
|
||||
|
||||
const entries: SitemapUrlEntry[] = config.staticPages.map((page) => ({
|
||||
loc: buildAbsoluteUrl(config.baseUrl, page.path),
|
||||
...(page.changefreq ? { changefreq: page.changefreq } : {}),
|
||||
...(page.priority != null ? { priority: page.priority } : {}),
|
||||
}));
|
||||
|
||||
if (this.isModuleEnabled(enabledModules, 'products')) {
|
||||
entries.push(
|
||||
...(await this.loadPublishedProducts(businessId, config.baseUrl, config.templates.product)),
|
||||
);
|
||||
}
|
||||
|
||||
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 loadPublishedProducts(
|
||||
businessId: bigint,
|
||||
baseUrl: string,
|
||||
template: string,
|
||||
): Promise<SitemapUrlEntry[]> {
|
||||
const rows = await this.prisma.product.findMany({
|
||||
where: {
|
||||
businessId,
|
||||
status: ContentStatus.published,
|
||||
},
|
||||
select: {
|
||||
slug: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
orderBy: [{ updatedAt: 'desc' }, { id: 'desc' }],
|
||||
});
|
||||
|
||||
return rows.map((row) => this.toEntry(baseUrl, template, row));
|
||||
}
|
||||
|
||||
private async loadPublishedBlogs(
|
||||
businessId: bigint,
|
||||
baseUrl: string,
|
||||
template: string,
|
||||
): Promise<SitemapUrlEntry[]> {
|
||||
const rows = await this.prisma.blogs.findMany({
|
||||
where: {
|
||||
business_id: businessId,
|
||||
status: ContentStatus.published,
|
||||
},
|
||||
select: {
|
||||
slug: true,
|
||||
updated_at: true,
|
||||
},
|
||||
orderBy: [{ updated_at: 'desc' }, { id: 'desc' }],
|
||||
});
|
||||
|
||||
return rows.map((row) =>
|
||||
this.toEntry(baseUrl, template, {
|
||||
slug: row.slug,
|
||||
updatedAt: row.updated_at,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private async loadPublishedPortfolios(
|
||||
businessId: bigint,
|
||||
baseUrl: string,
|
||||
template: string,
|
||||
): Promise<SitemapUrlEntry[]> {
|
||||
const rows = await this.prisma.portfolios.findMany({
|
||||
where: {
|
||||
business_id: businessId,
|
||||
status: ContentStatus.published,
|
||||
},
|
||||
select: {
|
||||
slug: true,
|
||||
updated_at: true,
|
||||
},
|
||||
orderBy: [{ updated_at: 'desc' }, { id: 'desc' }],
|
||||
});
|
||||
|
||||
return rows.map((row) =>
|
||||
this.toEntry(baseUrl, template, {
|
||||
slug: row.slug,
|
||||
updatedAt: row.updated_at,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private toEntry(
|
||||
baseUrl: string,
|
||||
template: string,
|
||||
row: PublishedSlugRow,
|
||||
): SitemapUrlEntry {
|
||||
const path = applyPathTemplate(template, row.slug);
|
||||
return {
|
||||
loc: buildAbsoluteUrl(baseUrl, path),
|
||||
lastmod: row.updatedAt,
|
||||
changefreq: 'weekly',
|
||||
priority: 0.7,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user