Add Schema.org JSON-LD, SEO meta overrides, and resilient bulk SMS.
Expose Organization/LocalBusiness and Product/BlogPosting jsonLd, optional seoMeta fields on content/categories, and continue mass SMS after per-recipient failures. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
db305c58a4
commit
aaf7a83742
+87
-13
@@ -12,7 +12,16 @@ import {
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { PermissionsService } from '../auth/permissions.service';
|
||||
import { BusinessSettingsService } from '../business-settings/business-settings.service';
|
||||
import { normalizeBusinessSettings } from '../business-settings/business-settings.util';
|
||||
import { buildBlogPostingJsonLd } from '../business-settings/website-entity-schema.util';
|
||||
import {
|
||||
applySeoMetaToMetadata,
|
||||
normalizeSeoMetaInput,
|
||||
readSeoMetaFromMetadata,
|
||||
} from '../common/seo-meta.util';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { DEFAULT_SITEMAP_PATH_TEMPLATES } from '../sitemap/sitemap.constants';
|
||||
import { resolveSitemapConfig } from '../sitemap/sitemap-config.util';
|
||||
import { SitemapService } from '../sitemap/sitemap.service';
|
||||
import { TenantService } from '../tenant/tenant.service';
|
||||
import {
|
||||
@@ -138,7 +147,10 @@ export class BlogsService {
|
||||
status,
|
||||
featured_media_id: featuredMediaId,
|
||||
published_at: status === ContentStatus.published ? new Date() : null,
|
||||
metadata: this.buildMetadata(dto.tags) as Prisma.InputJsonValue,
|
||||
metadata: applySeoMetaToMetadata(
|
||||
this.buildMetadata(dto.tags),
|
||||
normalizeSeoMetaInput(dto.seoMetaTitle, dto.seoMetaDescription),
|
||||
) as Prisma.InputJsonValue,
|
||||
},
|
||||
include: blogInclude,
|
||||
});
|
||||
@@ -218,10 +230,13 @@ export class BlogsService {
|
||||
nextContent.html = dto.mainTextHtml ?? '';
|
||||
}
|
||||
|
||||
const nextMetadata = { ...existingMetadata };
|
||||
if (dto.tags !== undefined) {
|
||||
nextMetadata.tags = dto.tags;
|
||||
}
|
||||
const nextMetadata = applySeoMetaToMetadata(
|
||||
{
|
||||
...existingMetadata,
|
||||
...(dto.tags !== undefined ? { tags: dto.tags } : {}),
|
||||
},
|
||||
normalizeSeoMetaInput(dto.seoMetaTitle, dto.seoMetaDescription),
|
||||
);
|
||||
|
||||
let publishedAt: Date | null | undefined = undefined;
|
||||
if (dto.status !== undefined) {
|
||||
@@ -374,10 +389,7 @@ export class BlogsService {
|
||||
}
|
||||
|
||||
return {
|
||||
blog: await this.serializeBlog(blog, {
|
||||
includeComments: true,
|
||||
approvedCommentsOnly: true,
|
||||
}),
|
||||
blog: await this.serializePublicBlog(host, business, blog),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -405,10 +417,71 @@ export class BlogsService {
|
||||
}
|
||||
|
||||
return {
|
||||
blog: await this.serializeBlog(blog, {
|
||||
includeComments: true,
|
||||
approvedCommentsOnly: true,
|
||||
}),
|
||||
blog: await this.serializePublicBlog(host, business, blog),
|
||||
};
|
||||
}
|
||||
|
||||
private async serializePublicBlog(
|
||||
host: string,
|
||||
business: { id: bigint; settings: Prisma.JsonValue; name: string; nameFa: string | null },
|
||||
blog: BlogWithRelations,
|
||||
) {
|
||||
const serialized = await this.serializeBlog(blog, {
|
||||
includeComments: true,
|
||||
approvedCommentsOnly: true,
|
||||
});
|
||||
|
||||
const settings = normalizeBusinessSettings(business.settings);
|
||||
const normalizedHost = host
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/^(customer|business)\./, '');
|
||||
|
||||
const domain = await this.prisma.domain.findFirst({
|
||||
where: { businessId: business.id, isPrimary: true },
|
||||
select: { host: true },
|
||||
});
|
||||
const primaryHost = domain?.host ?? normalizedHost;
|
||||
const sitemap = resolveSitemapConfig(
|
||||
settings.website.sitemapConfig ?? null,
|
||||
primaryHost,
|
||||
);
|
||||
|
||||
const media = await this.prisma.business.findUnique({
|
||||
where: { id: business.id },
|
||||
select: { logoMedia: { select: { publicUrl: true } } },
|
||||
});
|
||||
|
||||
const authorName = serialized.author
|
||||
? [serialized.author.firstName, serialized.author.lastName]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.trim()
|
||||
: '';
|
||||
|
||||
const jsonLd = settings.website.schema.enabled
|
||||
? buildBlogPostingJsonLd({
|
||||
host: primaryHost,
|
||||
id: serialized.id,
|
||||
title: serialized.seoMetaTitle || serialized.title,
|
||||
slug: serialized.slug,
|
||||
abstract: serialized.seoMetaDescription || serialized.abstract,
|
||||
mainTextHtml: serialized.seoMetaDescription
|
||||
? null
|
||||
: serialized.mainTextHtml,
|
||||
titleImageUrl: serialized.titleImageUrl,
|
||||
publishedAt: serialized.publishedAt,
|
||||
updatedAt: serialized.updatedAt,
|
||||
authorName: authorName || null,
|
||||
publisherName: business.nameFa || business.name,
|
||||
publisherLogoUrl: media?.logoMedia?.publicUrl ?? null,
|
||||
pathTemplate: sitemap.templates.blog ?? DEFAULT_SITEMAP_PATH_TEMPLATES.blog,
|
||||
})
|
||||
: null;
|
||||
|
||||
return {
|
||||
...serialized,
|
||||
schema: { jsonLd },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -608,6 +681,7 @@ export class BlogsService {
|
||||
categoryId: categoryAssignment?.categoryId.toString() ?? null,
|
||||
categoryName: categoryAssignment?.category.name ?? '',
|
||||
tags: Array.isArray(metadata.tags) ? (metadata.tags as string[]) : [],
|
||||
...readSeoMetaFromMetadata(metadata),
|
||||
authorId: blog.author_id?.toString() ?? null,
|
||||
author: blog.author
|
||||
? {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Matches,
|
||||
Min,
|
||||
MinLength,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
|
||||
export class ListBlogsDto {
|
||||
@@ -107,6 +108,16 @@ export class CreateBlogDto {
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
tags?: string[];
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(70)
|
||||
seoMetaTitle?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(160)
|
||||
seoMetaDescription?: string | null;
|
||||
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@@ -152,6 +163,16 @@ export class UpdateBlogDto {
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
tags?: string[];
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(70)
|
||||
seoMetaTitle?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(160)
|
||||
seoMetaDescription?: string | null;
|
||||
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
sanitizeBusinessSettingsForClient,
|
||||
toPrismaJson,
|
||||
} from './business-settings.util';
|
||||
import { normalizeWebsiteSchemaSettings } from './website-schema.util';
|
||||
import { UpdateBusinessSettingsDto } from './dto/update-business-settings.dto';
|
||||
import { normalizeBusinessPrimaryColorId } from './business-primary-colors';
|
||||
|
||||
@@ -190,6 +191,17 @@ export class BusinessSettingsService {
|
||||
specialProductsSource:
|
||||
dto.website.specialProductsSource ??
|
||||
current.website.specialProductsSource,
|
||||
schema:
|
||||
dto.website.schema !== undefined
|
||||
? normalizeWebsiteSchemaSettings({
|
||||
...current.website.schema,
|
||||
...dto.website.schema,
|
||||
sameAs:
|
||||
dto.website.schema.sameAs !== undefined
|
||||
? dto.website.schema.sameAs
|
||||
: current.website.schema.sameAs,
|
||||
})
|
||||
: current.website.schema,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -111,6 +111,43 @@ export type WebsiteSettings = {
|
||||
specialProductsSource: SpecialProductsSource;
|
||||
/** Imported from GET https://{domain}/meshkee/sitemap-config.json */
|
||||
sitemapConfig?: WebsiteSitemapConfig | null;
|
||||
/** Site-wide Schema.org Organization / LocalBusiness settings (Phase A). */
|
||||
schema: WebsiteSchemaSettings;
|
||||
};
|
||||
|
||||
/** Schema.org @type for the site-wide entity JSON-LD. */
|
||||
export const WEBSITE_SCHEMA_TYPE_IDS = ['Organization', 'LocalBusiness'] as const;
|
||||
|
||||
export type WebsiteSchemaType = (typeof WEBSITE_SCHEMA_TYPE_IDS)[number];
|
||||
|
||||
/**
|
||||
* Business-admin overrides for site-wide structured data.
|
||||
* Empty name/description fall back to business profile fields on the public API.
|
||||
*/
|
||||
export type WebsiteSchemaSettings = {
|
||||
/** When false, websites should not emit Organization/LocalBusiness JSON-LD. */
|
||||
enabled: boolean;
|
||||
type: WebsiteSchemaType;
|
||||
/** Optional override; empty → business `name`. */
|
||||
name: string | null;
|
||||
/** Optional override; empty → business `nameFa`. */
|
||||
nameFa: string | null;
|
||||
/** Optional override; empty → business `about`. */
|
||||
description: string | null;
|
||||
/** Optional LocalBusiness priceRange (e.g. "$$" or "۱۰۰٬۰۰۰–۵٬۰۰۰٬۰۰۰ تومان"). */
|
||||
priceRange: string | null;
|
||||
/** Extra sameAs profile URLs (merged with social links from business profile). */
|
||||
sameAs: string[];
|
||||
};
|
||||
|
||||
export const DEFAULT_WEBSITE_SCHEMA_SETTINGS: WebsiteSchemaSettings = {
|
||||
enabled: true,
|
||||
type: 'Organization',
|
||||
name: null,
|
||||
nameFa: null,
|
||||
description: null,
|
||||
priceRange: null,
|
||||
sameAs: [],
|
||||
};
|
||||
|
||||
export type SitemapChangefreq =
|
||||
@@ -322,6 +359,7 @@ export const DEFAULT_BUSINESS_SETTINGS: BusinessSettings = {
|
||||
},
|
||||
website: {
|
||||
specialProductsSource: 'store_item',
|
||||
schema: { ...DEFAULT_WEBSITE_SCHEMA_SETTINGS },
|
||||
},
|
||||
sms: {
|
||||
senderNumber: null,
|
||||
|
||||
@@ -33,11 +33,13 @@ import {
|
||||
StubGatewaySettings,
|
||||
WebsiteSitemapConfig,
|
||||
ZarinpalGatewaySettings,
|
||||
DEFAULT_WEBSITE_SCHEMA_SETTINGS,
|
||||
} from './business-settings.types';
|
||||
import {
|
||||
defaultOrderStepColor,
|
||||
normalizeOrderStepColor,
|
||||
} from './order-step-colors';
|
||||
import { normalizeWebsiteSchemaSettings } from './website-schema.util';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
@@ -441,6 +443,9 @@ export function normalizeBusinessSettings(raw: unknown): BusinessSettings {
|
||||
enabledModules,
|
||||
),
|
||||
sitemapConfig: normalizeWebsiteSitemapConfig(website.sitemapConfig),
|
||||
schema: normalizeWebsiteSchemaSettings(
|
||||
isRecord(website) ? website.schema : DEFAULT_WEBSITE_SCHEMA_SETTINGS,
|
||||
),
|
||||
},
|
||||
sms: {
|
||||
senderNumber: normalizeSmsSenderNumber(sms.senderNumber),
|
||||
@@ -495,6 +500,10 @@ export function mergeBusinessSettings(
|
||||
patch.website?.sitemapConfig !== undefined
|
||||
? patch.website.sitemapConfig
|
||||
: current.website.sitemapConfig,
|
||||
schema:
|
||||
patch.website?.schema !== undefined
|
||||
? patch.website.schema
|
||||
: current.website.schema,
|
||||
},
|
||||
sms: {
|
||||
senderNumber:
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
HOME_CHART_IDS,
|
||||
PAYMENT_GATEWAY_IDS,
|
||||
SPECIAL_PRODUCTS_SOURCE_IDS,
|
||||
WEBSITE_SCHEMA_TYPE_IDS,
|
||||
} from '../business-settings.types';
|
||||
|
||||
class BrandingSettingsDto {
|
||||
@@ -199,11 +200,49 @@ class ModulesSettingsDto {
|
||||
charts?: string[];
|
||||
}
|
||||
|
||||
class WebsiteSchemaSettingsDto {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
enabled?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsIn([...WEBSITE_SCHEMA_TYPE_IDS])
|
||||
type?: (typeof WEBSITE_SCHEMA_TYPE_IDS)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
nameFa?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
priceRange?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@ArrayMaxSize(20)
|
||||
sameAs?: string[];
|
||||
}
|
||||
|
||||
class WebsiteSettingsDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsIn([...SPECIAL_PRODUCTS_SOURCE_IDS])
|
||||
specialProductsSource?: (typeof SPECIAL_PRODUCTS_SOURCE_IDS)[number];
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => WebsiteSchemaSettingsDto)
|
||||
schema?: WebsiteSchemaSettingsDto;
|
||||
}
|
||||
|
||||
export class UpdateBusinessSettingsDto {
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { DEFAULT_SITEMAP_PATH_TEMPLATES } from '../sitemap/sitemap.constants';
|
||||
import { slugifyForUrl } from '../sitemap/seo-slug.util';
|
||||
import {
|
||||
applyPathTemplate,
|
||||
buildAbsoluteUrl,
|
||||
} from '../sitemap/sitemap-xml.util';
|
||||
|
||||
export type WebsiteEntityJsonLd = Record<string, unknown>;
|
||||
|
||||
function plainText(value: string | null | undefined, maxLen = 5000): string {
|
||||
if (!value) return '';
|
||||
return value
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/ /gi, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, maxLen);
|
||||
}
|
||||
|
||||
function absoluteSiteUrl(host: string): string {
|
||||
return `https://${host.replace(/^https?:\/\//i, '').replace(/\/+$/, '')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Product detail JSON-LD for storefronts.
|
||||
* Offer is included when a store min price exists.
|
||||
*/
|
||||
export function buildProductJsonLd(input: {
|
||||
host: string;
|
||||
id: string;
|
||||
title: string;
|
||||
nameFa?: string | null;
|
||||
summary?: string | null;
|
||||
descriptionHtml?: string | null;
|
||||
imageUrls: string[];
|
||||
brandName?: string | null;
|
||||
pathTemplate?: string;
|
||||
store?: {
|
||||
minPrice: number | null;
|
||||
maxPrice?: number | null;
|
||||
inStock: boolean;
|
||||
} | null;
|
||||
}): WebsiteEntityJsonLd {
|
||||
const baseUrl = absoluteSiteUrl(input.host);
|
||||
const name =
|
||||
input.nameFa?.trim() || input.title.trim() || `Product ${input.id}`;
|
||||
const pathSlug = slugifyForUrl(input.nameFa?.trim() || input.title, 'product');
|
||||
const path = applyPathTemplate(
|
||||
input.pathTemplate ?? DEFAULT_SITEMAP_PATH_TEMPLATES.product,
|
||||
{ id: input.id, slug: pathSlug },
|
||||
);
|
||||
const url = buildAbsoluteUrl(baseUrl, path);
|
||||
const description =
|
||||
plainText(input.summary) ||
|
||||
plainText(input.descriptionHtml) ||
|
||||
undefined;
|
||||
const images = input.imageUrls.map((u) => u.trim()).filter(Boolean);
|
||||
|
||||
const jsonLd: WebsiteEntityJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Product',
|
||||
name,
|
||||
url,
|
||||
sku: input.id,
|
||||
};
|
||||
|
||||
if (description) jsonLd.description = description;
|
||||
if (images.length === 1) jsonLd.image = images[0];
|
||||
if (images.length > 1) jsonLd.image = images;
|
||||
|
||||
const brandName = input.brandName?.trim();
|
||||
if (brandName) {
|
||||
jsonLd.brand = {
|
||||
'@type': 'Brand',
|
||||
name: brandName,
|
||||
};
|
||||
}
|
||||
|
||||
const minPrice = input.store?.minPrice;
|
||||
if (typeof minPrice === 'number' && Number.isFinite(minPrice) && minPrice >= 0) {
|
||||
jsonLd.offers = {
|
||||
'@type': 'Offer',
|
||||
url,
|
||||
priceCurrency: 'IRR',
|
||||
price: String(minPrice),
|
||||
availability: input.store?.inStock
|
||||
? 'https://schema.org/InStock'
|
||||
: 'https://schema.org/OutOfStock',
|
||||
};
|
||||
}
|
||||
|
||||
return jsonLd;
|
||||
}
|
||||
|
||||
/**
|
||||
* Blog detail JSON-LD (BlogPosting) for storefronts.
|
||||
*/
|
||||
export function buildBlogPostingJsonLd(input: {
|
||||
host: string;
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
abstract?: string | null;
|
||||
mainTextHtml?: string | null;
|
||||
titleImageUrl?: string | null;
|
||||
publishedAt?: Date | string | null;
|
||||
updatedAt?: Date | string | null;
|
||||
authorName?: string | null;
|
||||
publisherName?: string | null;
|
||||
publisherLogoUrl?: string | null;
|
||||
pathTemplate?: string;
|
||||
}): WebsiteEntityJsonLd {
|
||||
const baseUrl = absoluteSiteUrl(input.host);
|
||||
const path = applyPathTemplate(
|
||||
input.pathTemplate ?? DEFAULT_SITEMAP_PATH_TEMPLATES.blog,
|
||||
{ id: input.id, slug: input.slug },
|
||||
);
|
||||
const url = buildAbsoluteUrl(baseUrl, path);
|
||||
const description =
|
||||
plainText(input.abstract) || plainText(input.mainTextHtml) || undefined;
|
||||
|
||||
const jsonLd: WebsiteEntityJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BlogPosting',
|
||||
headline: input.title.trim() || `Post ${input.id}`,
|
||||
mainEntityOfPage: {
|
||||
'@type': 'WebPage',
|
||||
'@id': url,
|
||||
},
|
||||
url,
|
||||
};
|
||||
|
||||
if (description) jsonLd.description = description;
|
||||
if (input.titleImageUrl?.trim()) jsonLd.image = input.titleImageUrl.trim();
|
||||
|
||||
if (input.publishedAt) {
|
||||
jsonLd.datePublished = new Date(input.publishedAt).toISOString();
|
||||
}
|
||||
if (input.updatedAt) {
|
||||
jsonLd.dateModified = new Date(input.updatedAt).toISOString();
|
||||
}
|
||||
|
||||
const authorName = input.authorName?.trim();
|
||||
if (authorName) {
|
||||
jsonLd.author = {
|
||||
'@type': 'Person',
|
||||
name: authorName,
|
||||
};
|
||||
}
|
||||
|
||||
const publisherName = input.publisherName?.trim();
|
||||
if (publisherName) {
|
||||
const publisher: WebsiteEntityJsonLd = {
|
||||
'@type': 'Organization',
|
||||
name: publisherName,
|
||||
};
|
||||
if (input.publisherLogoUrl?.trim()) {
|
||||
publisher.logo = {
|
||||
'@type': 'ImageObject',
|
||||
url: input.publisherLogoUrl.trim(),
|
||||
};
|
||||
}
|
||||
jsonLd.publisher = publisher;
|
||||
}
|
||||
|
||||
return jsonLd;
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import {
|
||||
DEFAULT_WEBSITE_SCHEMA_SETTINGS,
|
||||
WEBSITE_SCHEMA_TYPE_IDS,
|
||||
type WebsiteSchemaSettings,
|
||||
type WebsiteSchemaType,
|
||||
} from './business-settings.types';
|
||||
import type { BusinessSocialMedia } from '../business-profile/business-profile.types';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function readTrimmed(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function normalizeSameAs(raw: unknown): string[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const item of raw) {
|
||||
if (typeof item !== 'string') continue;
|
||||
const url = item.trim();
|
||||
if (!url || seen.has(url)) continue;
|
||||
if (!/^https?:\/\//i.test(url)) continue;
|
||||
seen.add(url);
|
||||
out.push(url);
|
||||
if (out.length >= 20) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function normalizeWebsiteSchemaSettings(
|
||||
raw: unknown,
|
||||
): WebsiteSchemaSettings {
|
||||
const source = isRecord(raw) ? raw : {};
|
||||
const typeRaw = typeof source.type === 'string' ? source.type : '';
|
||||
const type = (WEBSITE_SCHEMA_TYPE_IDS as readonly string[]).includes(typeRaw)
|
||||
? (typeRaw as WebsiteSchemaType)
|
||||
: DEFAULT_WEBSITE_SCHEMA_SETTINGS.type;
|
||||
|
||||
return {
|
||||
enabled:
|
||||
typeof source.enabled === 'boolean'
|
||||
? source.enabled
|
||||
: DEFAULT_WEBSITE_SCHEMA_SETTINGS.enabled,
|
||||
type,
|
||||
name: readTrimmed(source.name),
|
||||
nameFa: readTrimmed(source.nameFa),
|
||||
description: readTrimmed(source.description),
|
||||
priceRange: readTrimmed(source.priceRange),
|
||||
sameAs: normalizeSameAs(source.sameAs),
|
||||
};
|
||||
}
|
||||
|
||||
/** Turn social handles / URLs into absolute profile URLs for schema.org sameAs. */
|
||||
export function socialMediaToSameAsUrls(social: BusinessSocialMedia): string[] {
|
||||
const urls: string[] = [];
|
||||
|
||||
const push = (value: string, build: (handle: string) => string) => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return;
|
||||
if (/^https?:\/\//i.test(trimmed)) {
|
||||
urls.push(trimmed);
|
||||
return;
|
||||
}
|
||||
const handle = trimmed.replace(/^@/, '').replace(/^\/+/, '');
|
||||
if (!handle) return;
|
||||
urls.push(build(handle));
|
||||
};
|
||||
|
||||
push(social.instagram, (h) => `https://www.instagram.com/${h}`);
|
||||
push(social.telegram, (h) =>
|
||||
h.startsWith('t.me/') ? `https://${h}` : `https://t.me/${h}`,
|
||||
);
|
||||
push(social.linkedin, (h) =>
|
||||
h.includes('linkedin.com') ? (h.startsWith('http') ? h : `https://${h}`) : `https://www.linkedin.com/in/${h}`,
|
||||
);
|
||||
push(social.youtube, (h) =>
|
||||
h.includes('youtube.com') || h.includes('youtu.be')
|
||||
? h.startsWith('http')
|
||||
? h
|
||||
: `https://${h}`
|
||||
: `https://www.youtube.com/@${h}`,
|
||||
);
|
||||
push(social.aparat, (h) =>
|
||||
h.includes('aparat.com')
|
||||
? h.startsWith('http')
|
||||
? h
|
||||
: `https://${h}`
|
||||
: `https://www.aparat.com/${h}`,
|
||||
);
|
||||
|
||||
// WhatsApp is a phone link, not a public profile — skip for sameAs.
|
||||
|
||||
return normalizeSameAs(urls);
|
||||
}
|
||||
|
||||
export type WebsiteSchemaJsonLd = Record<string, unknown>;
|
||||
|
||||
export type BuildWebsiteSchemaJsonLdInput = {
|
||||
settings: WebsiteSchemaSettings;
|
||||
host: string;
|
||||
name: string;
|
||||
nameFa: string;
|
||||
about: string;
|
||||
logoUrl: string | null;
|
||||
emails: string[];
|
||||
phoneNumbers: Array<{ type: string; number: string }>;
|
||||
socialMedia: BusinessSocialMedia;
|
||||
addresses: Array<{
|
||||
province: string;
|
||||
city: string;
|
||||
address: string;
|
||||
postalCode: string | null;
|
||||
landline: string | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build site-wide Organization / LocalBusiness JSON-LD for storefronts.
|
||||
* Returns null when schema is disabled.
|
||||
*/
|
||||
export function buildWebsiteSchemaJsonLd(
|
||||
input: BuildWebsiteSchemaJsonLdInput,
|
||||
): WebsiteSchemaJsonLd | null {
|
||||
const { settings } = input;
|
||||
if (!settings.enabled) return null;
|
||||
|
||||
const displayName =
|
||||
settings.nameFa ||
|
||||
input.nameFa?.trim() ||
|
||||
settings.name ||
|
||||
input.name?.trim() ||
|
||||
input.host;
|
||||
|
||||
const description =
|
||||
settings.description || input.about?.trim() || undefined;
|
||||
|
||||
const sameAs = normalizeSameAs([
|
||||
...socialMediaToSameAsUrls(input.socialMedia),
|
||||
...settings.sameAs,
|
||||
]);
|
||||
|
||||
const telephone =
|
||||
input.phoneNumbers.find((p) => p.number.trim())?.number.trim() ||
|
||||
input.addresses.find((a) => a.landline?.trim())?.landline?.trim() ||
|
||||
undefined;
|
||||
|
||||
const email = input.emails.find((e) => e.trim())?.trim() || undefined;
|
||||
|
||||
const url = `https://${input.host.replace(/^https?:\/\//i, '').replace(/\/+$/, '')}`;
|
||||
|
||||
const jsonLd: WebsiteSchemaJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': settings.type,
|
||||
name: displayName,
|
||||
url,
|
||||
};
|
||||
|
||||
if (description) jsonLd.description = description;
|
||||
if (input.logoUrl) {
|
||||
jsonLd.logo = input.logoUrl;
|
||||
jsonLd.image = input.logoUrl;
|
||||
}
|
||||
if (telephone) jsonLd.telephone = telephone;
|
||||
if (email) jsonLd.email = email;
|
||||
if (sameAs.length > 0) jsonLd.sameAs = sameAs;
|
||||
|
||||
const primaryAddress = input.addresses[0];
|
||||
if (primaryAddress) {
|
||||
const street = primaryAddress.address?.trim();
|
||||
const city = primaryAddress.city?.trim();
|
||||
const province = primaryAddress.province?.trim();
|
||||
const postalCode = primaryAddress.postalCode?.trim() || undefined;
|
||||
if (street || city || province || postalCode) {
|
||||
jsonLd.address = {
|
||||
'@type': 'PostalAddress',
|
||||
...(street ? { streetAddress: street } : {}),
|
||||
...(city ? { addressLocality: city } : {}),
|
||||
...(province ? { addressRegion: province } : {}),
|
||||
...(postalCode ? { postalCode } : {}),
|
||||
addressCountry: 'IR',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (settings.type === 'LocalBusiness' && settings.priceRange) {
|
||||
jsonLd.priceRange = settings.priceRange;
|
||||
}
|
||||
|
||||
return jsonLd;
|
||||
}
|
||||
@@ -6,16 +6,29 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
MediaEntityType,
|
||||
Prisma,
|
||||
TranslationEntityType,
|
||||
TranslationStatus,
|
||||
} from '@prisma/client';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { PermissionsService } from '../auth/permissions.service';
|
||||
import {
|
||||
applySeoMetaToMetadata,
|
||||
normalizeSeoMetaInput,
|
||||
readSeoMetaFromMetadata,
|
||||
} from '../common/seo-meta.util';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SitemapService } from '../sitemap/sitemap.service';
|
||||
import { TenantService } from '../tenant/tenant.service';
|
||||
import { CreateCategoryDto, ListCategoriesDto, UpdateCategoryDto } from './dto/category.dto';
|
||||
|
||||
function asRecord(value: Prisma.JsonValue): Record<string, unknown> {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function slugify(value: string): string {
|
||||
return (
|
||||
value
|
||||
@@ -157,6 +170,10 @@ export class CategoriesService {
|
||||
slug,
|
||||
description: dto.description?.trim() || null,
|
||||
sortOrder: dto.sortOrder ?? 0,
|
||||
metadata: applySeoMetaToMetadata(
|
||||
{},
|
||||
normalizeSeoMetaInput(dto.seoMetaTitle, dto.seoMetaDescription),
|
||||
) as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -237,6 +254,14 @@ export class CategoriesService {
|
||||
...(dto.sortOrder !== undefined ? { sortOrder: dto.sortOrder } : {}),
|
||||
...(dto.isActive !== undefined ? { isActive: dto.isActive } : {}),
|
||||
...(parentId !== undefined ? { parentId } : {}),
|
||||
...(dto.seoMetaTitle !== undefined || dto.seoMetaDescription !== undefined
|
||||
? {
|
||||
metadata: applySeoMetaToMetadata(
|
||||
asRecord(existing.metadata),
|
||||
normalizeSeoMetaInput(dto.seoMetaTitle, dto.seoMetaDescription),
|
||||
) as Prisma.InputJsonValue,
|
||||
}
|
||||
: {}),
|
||||
slug,
|
||||
},
|
||||
});
|
||||
@@ -395,6 +420,7 @@ export class CategoriesService {
|
||||
nameFa: string | null;
|
||||
slug: string;
|
||||
description: string | null;
|
||||
metadata?: Prisma.JsonValue;
|
||||
sortOrder: number;
|
||||
isActive: boolean;
|
||||
createdAt: Date;
|
||||
@@ -412,6 +438,7 @@ export class CategoriesService {
|
||||
nameFa: category.nameFa,
|
||||
slug: category.slug,
|
||||
description: category.description,
|
||||
...readSeoMetaFromMetadata(asRecord(category.metadata ?? {})),
|
||||
sortOrder: category.sortOrder,
|
||||
isActive: category.isActive,
|
||||
variationCount,
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { MediaEntityType } from '@prisma/client';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsEnum, IsInt, IsOptional, IsString, Matches, Min, MinLength } from 'class-validator';
|
||||
import {
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
|
||||
export class ListCategoriesDto {
|
||||
@IsOptional()
|
||||
@@ -29,6 +38,16 @@ export class CreateCategoryDto {
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(70)
|
||||
seoMetaTitle?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(160)
|
||||
seoMetaDescription?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, {
|
||||
@@ -61,6 +80,16 @@ export class UpdateCategoryDto {
|
||||
@IsString()
|
||||
description?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(70)
|
||||
seoMetaTitle?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(160)
|
||||
seoMetaDescription?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/)
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
import {
|
||||
SEO_META_DESCRIPTION_MAX,
|
||||
SEO_META_TITLE_MAX,
|
||||
} from '../common/seo-meta.util';
|
||||
|
||||
/** Optional mixin fields — copy onto Create/Update DTOs (Nest does not support multi-inheritance well). */
|
||||
export const SEO_META_DTO_DECORATORS = {
|
||||
titleMax: SEO_META_TITLE_MAX,
|
||||
descriptionMax: SEO_META_DESCRIPTION_MAX,
|
||||
} as const;
|
||||
|
||||
export class SeoMetaDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(SEO_META_TITLE_MAX)
|
||||
seoMetaTitle?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(SEO_META_DESCRIPTION_MAX)
|
||||
seoMetaDescription?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Per-page SEO overrides stored in entity `metadata` JSON.
|
||||
* Empty / null → websites fall back to title / summary / about fields.
|
||||
*/
|
||||
|
||||
export const SEO_META_TITLE_MAX = 70;
|
||||
export const SEO_META_DESCRIPTION_MAX = 160;
|
||||
|
||||
export type SeoMetaFields = {
|
||||
seoMetaTitle: string | null;
|
||||
seoMetaDescription: string | null;
|
||||
};
|
||||
|
||||
export function emptySeoMeta(): SeoMetaFields {
|
||||
return { seoMetaTitle: null, seoMetaDescription: null };
|
||||
}
|
||||
|
||||
export function readSeoMetaFromMetadata(metadata: Record<string, unknown>): SeoMetaFields {
|
||||
const title =
|
||||
typeof metadata.seoMetaTitle === 'string' ? metadata.seoMetaTitle.trim() : '';
|
||||
const description =
|
||||
typeof metadata.seoMetaDescription === 'string'
|
||||
? metadata.seoMetaDescription.trim()
|
||||
: '';
|
||||
return {
|
||||
seoMetaTitle: title ? title.slice(0, SEO_META_TITLE_MAX) : null,
|
||||
seoMetaDescription: description
|
||||
? description.slice(0, SEO_META_DESCRIPTION_MAX)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Normalize optional DTO strings (undefined = leave unchanged when merging). */
|
||||
export function normalizeSeoMetaInput(
|
||||
seoMetaTitle?: string | null,
|
||||
seoMetaDescription?: string | null,
|
||||
): Partial<SeoMetaFields> {
|
||||
const out: Partial<SeoMetaFields> = {};
|
||||
if (seoMetaTitle !== undefined) {
|
||||
const trimmed = seoMetaTitle?.trim() ?? '';
|
||||
out.seoMetaTitle = trimmed ? trimmed.slice(0, SEO_META_TITLE_MAX) : null;
|
||||
}
|
||||
if (seoMetaDescription !== undefined) {
|
||||
const trimmed = seoMetaDescription?.trim() ?? '';
|
||||
out.seoMetaDescription = trimmed
|
||||
? trimmed.slice(0, SEO_META_DESCRIPTION_MAX)
|
||||
: null;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Merge SEO keys into a metadata object (preserves tags and other keys). */
|
||||
export function applySeoMetaToMetadata(
|
||||
metadata: Record<string, unknown>,
|
||||
seo: Partial<SeoMetaFields>,
|
||||
): Record<string, unknown> {
|
||||
const next = { ...metadata };
|
||||
if (seo.seoMetaTitle !== undefined) {
|
||||
if (seo.seoMetaTitle) next.seoMetaTitle = seo.seoMetaTitle;
|
||||
else delete next.seoMetaTitle;
|
||||
}
|
||||
if (seo.seoMetaDescription !== undefined) {
|
||||
if (seo.seoMetaDescription) next.seoMetaDescription = seo.seoMetaDescription;
|
||||
else delete next.seoMetaDescription;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
+121
-114
@@ -1,4 +1,12 @@
|
||||
import { BadRequestException, ConflictException, ForbiddenException, Injectable, NotFoundException, ServiceUnavailableException } from '@nestjs/common';
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
HttpException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { randomBytes } from 'crypto';
|
||||
@@ -769,7 +777,8 @@ export class CustomersService {
|
||||
|
||||
/**
|
||||
* Bulk SMS. Empty/omitted userIds → all enabled business customers.
|
||||
* With a personalized template, messages are sent per-recipient (not one bulk payload).
|
||||
* Always sends per-recipient so one failure does not abort the rest.
|
||||
* Returns a report with sent/failed counts and per-number errors.
|
||||
*/
|
||||
async sendBulkSms(
|
||||
businessIdRaw: string,
|
||||
@@ -863,139 +872,137 @@ export class CustomersService {
|
||||
enabled: false,
|
||||
message: 'SMS is disabled. Message was not sent.',
|
||||
recipientCount: recipients.length,
|
||||
sentCount: 0,
|
||||
failedCount: 0,
|
||||
failures: [],
|
||||
};
|
||||
}
|
||||
|
||||
const sample = await this.resolveOutboundSms(
|
||||
businessId,
|
||||
business.settings,
|
||||
dto.message,
|
||||
dto.templateId,
|
||||
{
|
||||
firstName: recipients[0].firstName,
|
||||
lastName: recipients[0].lastName,
|
||||
titleName: recipients[0].titleName,
|
||||
},
|
||||
);
|
||||
|
||||
const personalized =
|
||||
Boolean(dto.templateId) || smsTemplateUsesVariables(dto.message);
|
||||
|
||||
let charged = 0;
|
||||
let personalizedPayloads: Array<{
|
||||
const payloads: Array<{
|
||||
cellNumber: string;
|
||||
message: string;
|
||||
source: string;
|
||||
cost: number;
|
||||
templateId: string | null;
|
||||
templateVerified: boolean | null;
|
||||
}> = [];
|
||||
|
||||
if (!personalized) {
|
||||
charged = await this.smsBilling.charge(
|
||||
for (const recipient of recipients) {
|
||||
const resolved = await this.resolveOutboundSms(
|
||||
businessId,
|
||||
sample.message,
|
||||
recipients.length,
|
||||
'mass',
|
||||
);
|
||||
} else {
|
||||
personalizedPayloads = [];
|
||||
let totalCost = 0;
|
||||
for (const recipient of recipients) {
|
||||
const resolved = await this.resolveOutboundSms(
|
||||
businessId,
|
||||
business.settings,
|
||||
dto.message,
|
||||
dto.templateId,
|
||||
{
|
||||
firstName: recipient.firstName,
|
||||
lastName: recipient.lastName,
|
||||
titleName: recipient.titleName,
|
||||
},
|
||||
);
|
||||
const cost = this.smsBilling.costForMessage(resolved.message);
|
||||
personalizedPayloads.push({
|
||||
cellNumber: recipient.cellNumber,
|
||||
message: resolved.message,
|
||||
source: resolved.source,
|
||||
cost,
|
||||
});
|
||||
totalCost += cost;
|
||||
}
|
||||
charged = await this.smsBilling.chargeAmount(
|
||||
businessId,
|
||||
totalCost,
|
||||
personalizedPayloads.length,
|
||||
'mass',
|
||||
business.settings,
|
||||
dto.message,
|
||||
dto.templateId,
|
||||
{
|
||||
firstName: recipient.firstName,
|
||||
lastName: recipient.lastName,
|
||||
titleName: recipient.titleName,
|
||||
},
|
||||
);
|
||||
payloads.push({
|
||||
cellNumber: recipient.cellNumber,
|
||||
message: resolved.message,
|
||||
source: resolved.source,
|
||||
cost: this.smsBilling.costForMessage(resolved.message),
|
||||
templateId: resolved.templateId,
|
||||
templateVerified: resolved.templateVerified,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
if (!personalized) {
|
||||
const result = await this.sms.sendBulkMessage(
|
||||
recipients.map((r) => r.cellNumber),
|
||||
sample.message,
|
||||
{ source: sample.source },
|
||||
);
|
||||
return {
|
||||
enabled: true,
|
||||
message: 'Message sent successfully',
|
||||
recipientCount: result.recipientCount,
|
||||
serverIds: result.serverIds,
|
||||
source: sample.source,
|
||||
templateId: sample.templateId,
|
||||
templateVerified: sample.templateVerified,
|
||||
};
|
||||
const serverIds: string[] = [];
|
||||
const failures: Array<{ cellNumber: string; error: string }> = [];
|
||||
let sentCount = 0;
|
||||
let balanceExhausted = false;
|
||||
|
||||
for (const payload of payloads) {
|
||||
if (balanceExhausted) {
|
||||
failures.push({
|
||||
cellNumber: payload.cellNumber,
|
||||
error: 'Insufficient SMS balance. Contact Meshkee to top up.',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const serverIds: string[] = [];
|
||||
for (let i = 0; i < personalizedPayloads.length; i++) {
|
||||
const payload = personalizedPayloads[i];
|
||||
try {
|
||||
const result = await this.sms.sendMessage(
|
||||
payload.cellNumber,
|
||||
payload.message,
|
||||
{ source: payload.source },
|
||||
);
|
||||
serverIds.push(result.serverId);
|
||||
} catch (err) {
|
||||
const remaining = personalizedPayloads.slice(i);
|
||||
const refundAmount = remaining.reduce((sum, item) => sum + item.cost, 0);
|
||||
await this.smsBilling.refund(
|
||||
businessId,
|
||||
refundAmount,
|
||||
remaining.length,
|
||||
'mass',
|
||||
);
|
||||
charged = 0;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
message: 'Message sent successfully',
|
||||
recipientCount: serverIds.length,
|
||||
serverIds,
|
||||
source: sample.source,
|
||||
templateId: sample.templateId,
|
||||
templateVerified: sample.templateVerified,
|
||||
};
|
||||
} catch (err) {
|
||||
if (charged > 0) {
|
||||
await this.smsBilling.refund(
|
||||
let charged = 0;
|
||||
try {
|
||||
charged = await this.smsBilling.chargeAmount(
|
||||
businessId,
|
||||
charged,
|
||||
personalized ? personalizedPayloads.length : recipients.length,
|
||||
payload.cost,
|
||||
1,
|
||||
'mass',
|
||||
);
|
||||
} catch (err) {
|
||||
const message = this.smsFailureMessage(err);
|
||||
if (message.toLowerCase().includes('insufficient sms balance')) {
|
||||
balanceExhausted = true;
|
||||
}
|
||||
failures.push({ cellNumber: payload.cellNumber, error: message });
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
err instanceof BadRequestException ||
|
||||
err instanceof ServiceUnavailableException
|
||||
) {
|
||||
throw err;
|
||||
|
||||
try {
|
||||
const result = await this.sms.sendMessage(
|
||||
payload.cellNumber,
|
||||
payload.message,
|
||||
{ source: payload.source },
|
||||
);
|
||||
serverIds.push(result.serverId);
|
||||
sentCount += 1;
|
||||
} catch (err) {
|
||||
if (charged > 0) {
|
||||
await this.smsBilling.refund(businessId, charged, 1, 'mass');
|
||||
}
|
||||
failures.push({
|
||||
cellNumber: payload.cellNumber,
|
||||
error: this.smsFailureMessage(err),
|
||||
});
|
||||
}
|
||||
throw new ServiceUnavailableException('Unable to send SMS');
|
||||
}
|
||||
|
||||
const sample = payloads[0];
|
||||
const failedCount = failures.length;
|
||||
const message =
|
||||
failedCount === 0
|
||||
? 'Message sent successfully'
|
||||
: sentCount === 0
|
||||
? 'No messages were sent'
|
||||
: `Sent ${sentCount} of ${payloads.length}; ${failedCount} failed`;
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
message,
|
||||
recipientCount: payloads.length,
|
||||
sentCount,
|
||||
failedCount,
|
||||
serverIds,
|
||||
failures,
|
||||
source: sample?.source,
|
||||
templateId: sample?.templateId,
|
||||
templateVerified: sample?.templateVerified,
|
||||
};
|
||||
}
|
||||
|
||||
private smsFailureMessage(err: unknown): string {
|
||||
if (err instanceof HttpException) {
|
||||
const response = err.getResponse();
|
||||
if (typeof response === 'string' && response.trim()) {
|
||||
return response.trim();
|
||||
}
|
||||
if (typeof response === 'object' && response !== null && 'message' in response) {
|
||||
const message = (response as { message?: string | string[] }).message;
|
||||
if (Array.isArray(message)) {
|
||||
return message.filter(Boolean).join(', ') || err.message;
|
||||
}
|
||||
if (typeof message === 'string' && message.trim()) {
|
||||
return message.trim();
|
||||
}
|
||||
}
|
||||
return err.message || 'Unable to send SMS';
|
||||
}
|
||||
if (err instanceof Error && err.message.trim()) {
|
||||
return err.message.trim();
|
||||
}
|
||||
return 'Unable to send SMS';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Matches,
|
||||
Min,
|
||||
MinLength,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
|
||||
export class ListInstructionsDto {
|
||||
@@ -114,6 +115,16 @@ export class CreateInstructionDto {
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
tags?: string[];
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(70)
|
||||
seoMetaTitle?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(160)
|
||||
seoMetaDescription?: string | null;
|
||||
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@@ -173,6 +184,16 @@ export class UpdateInstructionDto {
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
tags?: string[];
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(70)
|
||||
seoMetaTitle?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(160)
|
||||
seoMetaDescription?: string | null;
|
||||
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
|
||||
@@ -13,6 +13,11 @@ import {
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { PermissionsService } from '../auth/permissions.service';
|
||||
import { BusinessSettingsService } from '../business-settings/business-settings.service';
|
||||
import {
|
||||
applySeoMetaToMetadata,
|
||||
normalizeSeoMetaInput,
|
||||
readSeoMetaFromMetadata,
|
||||
} from '../common/seo-meta.util';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { TenantService } from '../tenant/tenant.service';
|
||||
import {
|
||||
@@ -166,7 +171,10 @@ export class InstructionsService {
|
||||
status,
|
||||
featured_media_id: featuredMediaId,
|
||||
published_at: status === ContentStatus.published ? new Date() : null,
|
||||
metadata: this.buildMetadata(dto.tags) as Prisma.InputJsonValue,
|
||||
metadata: applySeoMetaToMetadata(
|
||||
this.buildMetadata(dto.tags),
|
||||
normalizeSeoMetaInput(dto.seoMetaTitle, dto.seoMetaDescription),
|
||||
) as Prisma.InputJsonValue,
|
||||
},
|
||||
include: instructionInclude,
|
||||
});
|
||||
@@ -260,10 +268,13 @@ export class InstructionsService {
|
||||
nextContent.html = dto.descriptionHtml ?? '';
|
||||
}
|
||||
|
||||
const nextMetadata = { ...existingMetadata };
|
||||
if (dto.tags !== undefined) {
|
||||
nextMetadata.tags = dto.tags;
|
||||
}
|
||||
const nextMetadata = applySeoMetaToMetadata(
|
||||
{
|
||||
...existingMetadata,
|
||||
...(dto.tags !== undefined ? { tags: dto.tags } : {}),
|
||||
},
|
||||
normalizeSeoMetaInput(dto.seoMetaTitle, dto.seoMetaDescription),
|
||||
);
|
||||
|
||||
let publishedAt: Date | null | undefined = undefined;
|
||||
if (dto.status !== undefined) {
|
||||
@@ -661,6 +672,7 @@ export class InstructionsService {
|
||||
categoryId: categoryAssignment?.categoryId.toString() ?? null,
|
||||
categoryName: categoryAssignment?.category.name ?? '',
|
||||
tags: Array.isArray(metadata.tags) ? (metadata.tags as string[]) : [],
|
||||
...readSeoMetaFromMetadata(metadata),
|
||||
authorId: video.author_id?.toString() ?? null,
|
||||
author: video.author
|
||||
? {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Matches,
|
||||
Min,
|
||||
MinLength,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
|
||||
export class ListPortfoliosDto {
|
||||
@@ -113,6 +114,16 @@ export class CreatePortfolioDto {
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
tags?: string[];
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(70)
|
||||
seoMetaTitle?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(160)
|
||||
seoMetaDescription?: string | null;
|
||||
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@@ -174,6 +185,16 @@ export class UpdatePortfolioDto {
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
tags?: string[];
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(70)
|
||||
seoMetaTitle?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(160)
|
||||
seoMetaDescription?: string | null;
|
||||
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
|
||||
@@ -12,6 +12,11 @@ import {
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { PermissionsService } from '../auth/permissions.service';
|
||||
import { BusinessSettingsService } from '../business-settings/business-settings.service';
|
||||
import {
|
||||
applySeoMetaToMetadata,
|
||||
normalizeSeoMetaInput,
|
||||
readSeoMetaFromMetadata,
|
||||
} from '../common/seo-meta.util';
|
||||
import { MediaService } from '../media/media.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SitemapService } from '../sitemap/sitemap.service';
|
||||
@@ -166,7 +171,10 @@ export class PortfoliosService {
|
||||
featured_media_id: featuredMediaId,
|
||||
sort_order: sortOrder,
|
||||
published_at: status === ContentStatus.published ? new Date() : null,
|
||||
metadata: this.buildMetadata(dto.tags) as Prisma.InputJsonValue,
|
||||
metadata: applySeoMetaToMetadata(
|
||||
this.buildMetadata(dto.tags),
|
||||
normalizeSeoMetaInput(dto.seoMetaTitle, dto.seoMetaDescription),
|
||||
) as Prisma.InputJsonValue,
|
||||
},
|
||||
include: portfolioInclude,
|
||||
});
|
||||
@@ -265,10 +273,13 @@ export class PortfoliosService {
|
||||
nextContent.html = dto.mainTextHtml ?? '';
|
||||
}
|
||||
|
||||
const nextMetadata = { ...existingMetadata };
|
||||
if (dto.tags !== undefined) {
|
||||
nextMetadata.tags = dto.tags;
|
||||
}
|
||||
const nextMetadata = applySeoMetaToMetadata(
|
||||
{
|
||||
...existingMetadata,
|
||||
...(dto.tags !== undefined ? { tags: dto.tags } : {}),
|
||||
},
|
||||
normalizeSeoMetaInput(dto.seoMetaTitle, dto.seoMetaDescription),
|
||||
);
|
||||
|
||||
let publishedAt: Date | null | undefined = undefined;
|
||||
if (dto.status !== undefined) {
|
||||
@@ -786,6 +797,7 @@ export class PortfoliosService {
|
||||
categoryId: categoryAssignment?.categoryId.toString() ?? null,
|
||||
categoryName: categoryAssignment?.category.name ?? '',
|
||||
tags: Array.isArray(metadata.tags) ? (metadata.tags as string[]) : [],
|
||||
...readSeoMetaFromMetadata(metadata),
|
||||
titleImageUrl:
|
||||
portfolio.media?.publicUrl ??
|
||||
galleryAttachments[0]?.media.publicUrl ??
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Matches,
|
||||
Min,
|
||||
MinLength,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
|
||||
export class ListProductsDto {
|
||||
@@ -112,6 +113,17 @@ export class CreateProductDto {
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
tags?: string[];
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(70)
|
||||
seoMetaTitle?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(160)
|
||||
seoMetaDescription?: string | null;
|
||||
|
||||
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@@ -162,6 +174,17 @@ export class UpdateProductDto {
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
tags?: string[];
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(70)
|
||||
seoMetaTitle?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(160)
|
||||
seoMetaDescription?: string | null;
|
||||
|
||||
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
|
||||
@@ -14,8 +14,17 @@ import {
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { PermissionsService } from '../auth/permissions.service';
|
||||
import { BrandsService } from '../brands/brands.service';
|
||||
import { normalizeBusinessSettings } from '../business-settings/business-settings.util';
|
||||
import { buildProductJsonLd } from '../business-settings/website-entity-schema.util';
|
||||
import {
|
||||
applySeoMetaToMetadata,
|
||||
normalizeSeoMetaInput,
|
||||
readSeoMetaFromMetadata,
|
||||
} from '../common/seo-meta.util';
|
||||
import { MediaService } from '../media/media.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { DEFAULT_SITEMAP_PATH_TEMPLATES } from '../sitemap/sitemap.constants';
|
||||
import { resolveSitemapConfig } from '../sitemap/sitemap-config.util';
|
||||
import { SitemapService } from '../sitemap/sitemap.service';
|
||||
import { TenantService } from '../tenant/tenant.service';
|
||||
import { CreateProductDto, ListProductsDto, ListPublicProductsDto, UpdateProductDto } from './dto/product.dto';
|
||||
@@ -189,7 +198,7 @@ export class ProductsService {
|
||||
throw new NotFoundException('Product not found');
|
||||
}
|
||||
|
||||
return this.serializePublicDetail(businessId, product);
|
||||
return this.serializePublicDetail(host, businessId, product);
|
||||
}
|
||||
|
||||
async getPublicById(host: string, productIdRaw: string) {
|
||||
@@ -215,20 +224,80 @@ export class ProductsService {
|
||||
throw new NotFoundException('Product not found');
|
||||
}
|
||||
|
||||
return this.serializePublicDetail(businessId, product);
|
||||
return this.serializePublicDetail(host, businessId, product);
|
||||
}
|
||||
|
||||
private async serializePublicDetail(
|
||||
host: string,
|
||||
businessId: bigint,
|
||||
product: Prisma.ProductGetPayload<{ include: typeof productListInclude }>,
|
||||
) {
|
||||
const [serializedList, relatedProducts] = await Promise.all([
|
||||
const [serializedList, relatedProducts, business] = await Promise.all([
|
||||
this.serializePublicListItems(businessId, [product]),
|
||||
this.loadRelatedPublicProducts(businessId, product),
|
||||
this.prisma.business.findUnique({
|
||||
where: { id: businessId },
|
||||
select: {
|
||||
settings: true,
|
||||
name: true,
|
||||
nameFa: true,
|
||||
domains: {
|
||||
where: { isPrimary: true },
|
||||
select: { host: true },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const productPayload = serializedList[0];
|
||||
const settings = normalizeBusinessSettings(business?.settings);
|
||||
const normalizedHost = host
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/^(customer|business)\./, '');
|
||||
const primaryHost = business?.domains[0]?.host ?? normalizedHost;
|
||||
const sitemap = resolveSitemapConfig(
|
||||
settings.website.sitemapConfig ?? null,
|
||||
primaryHost,
|
||||
);
|
||||
|
||||
const imageUrls = [
|
||||
productPayload.thumbnailUrl,
|
||||
...((productPayload.images as Array<{ url?: string }> | undefined) ?? []).map(
|
||||
(img) => img.url,
|
||||
),
|
||||
].filter((url): url is string => Boolean(url?.trim()));
|
||||
|
||||
const jsonLd = settings.website.schema.enabled
|
||||
? buildProductJsonLd({
|
||||
host: primaryHost,
|
||||
id: productPayload.id,
|
||||
title: productPayload.seoMetaTitle || productPayload.title,
|
||||
nameFa: productPayload.seoMetaTitle
|
||||
? productPayload.seoMetaTitle
|
||||
: productPayload.nameFa,
|
||||
summary:
|
||||
productPayload.seoMetaDescription || productPayload.summary,
|
||||
descriptionHtml: productPayload.seoMetaDescription
|
||||
? productPayload.seoMetaDescription
|
||||
: productPayload.descriptionHtml,
|
||||
imageUrls,
|
||||
brandName:
|
||||
productPayload.brand?.nameFa?.trim() ||
|
||||
productPayload.brand?.nameEn?.trim() ||
|
||||
null,
|
||||
pathTemplate:
|
||||
sitemap.templates.product ?? DEFAULT_SITEMAP_PATH_TEMPLATES.product,
|
||||
store: productPayload.store ?? null,
|
||||
})
|
||||
: null;
|
||||
|
||||
return {
|
||||
product: serializedList[0],
|
||||
product: {
|
||||
...productPayload,
|
||||
schema: { jsonLd },
|
||||
},
|
||||
relatedProducts,
|
||||
};
|
||||
}
|
||||
@@ -296,7 +365,10 @@ export class ProductsService {
|
||||
featuredMediaId,
|
||||
brandId,
|
||||
publishedAt: status === ContentStatus.published ? new Date() : null,
|
||||
metadata: this.buildMetadata(dto.tags),
|
||||
metadata: applySeoMetaToMetadata(
|
||||
this.buildMetadata(dto.tags),
|
||||
normalizeSeoMetaInput(dto.seoMetaTitle, dto.seoMetaDescription),
|
||||
) as Prisma.InputJsonValue,
|
||||
},
|
||||
include: {
|
||||
featuredMedia: true,
|
||||
@@ -379,10 +451,13 @@ export class ProductsService {
|
||||
nextContent.html = dto.descriptionHtml ?? '';
|
||||
}
|
||||
|
||||
const nextMetadata = { ...existingMetadata };
|
||||
if (dto.tags !== undefined) {
|
||||
nextMetadata.tags = dto.tags;
|
||||
}
|
||||
const nextMetadata = applySeoMetaToMetadata(
|
||||
{
|
||||
...existingMetadata,
|
||||
...(dto.tags !== undefined ? { tags: dto.tags } : {}),
|
||||
},
|
||||
normalizeSeoMetaInput(dto.seoMetaTitle, dto.seoMetaDescription),
|
||||
);
|
||||
|
||||
let publishedAt: Date | null | undefined = undefined;
|
||||
if (dto.status !== undefined) {
|
||||
@@ -640,6 +715,7 @@ export class ProductsService {
|
||||
tags: Array.isArray(metadata.tags)
|
||||
? (metadata.tags as string[])
|
||||
: [],
|
||||
...readSeoMetaFromMetadata(metadata),
|
||||
thumbnailUrl,
|
||||
thumbnailMediaId: product.featuredMediaId?.toString() ?? null,
|
||||
image,
|
||||
|
||||
@@ -61,6 +61,10 @@ export class TenantService {
|
||||
enabledModules: settings.modules.enabled,
|
||||
homeCharts: settings.modules.charts,
|
||||
specialProductsSource: settings.website.specialProductsSource,
|
||||
schema: {
|
||||
enabled: settings.website.schema.enabled,
|
||||
type: settings.website.schema.type,
|
||||
},
|
||||
logoUrl: media?.logoMedia?.publicUrl ?? null,
|
||||
logoDarkUrl: media?.logoDarkMedia?.publicUrl ?? null,
|
||||
faviconUrl:
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
ValidateNested,
|
||||
@@ -166,6 +167,16 @@ export class CreateUserProductDto {
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
galleryMediaIds?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(70)
|
||||
seoMetaTitle?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(160)
|
||||
seoMetaDescription?: string | null;
|
||||
}
|
||||
|
||||
export class UpdateUserProductDto extends CreateUserProductDto {}
|
||||
|
||||
@@ -14,6 +14,11 @@ import {
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { PermissionsService } from '../auth/permissions.service';
|
||||
import { CategoryTechnicalFormService } from '../categories/category-technical-form.service';
|
||||
import {
|
||||
applySeoMetaToMetadata,
|
||||
normalizeSeoMetaInput,
|
||||
readSeoMetaFromMetadata,
|
||||
} from '../common/seo-meta.util';
|
||||
import { MediaService } from '../media/media.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
@@ -337,9 +342,12 @@ export class UserProductsService {
|
||||
const content: Prisma.InputJsonValue = {
|
||||
...(dto.titleEn?.trim() ? { titleEn: dto.titleEn.trim() } : {}),
|
||||
};
|
||||
const metadata: Prisma.InputJsonValue = {
|
||||
priceByExpert: dto.priceByExpert === true,
|
||||
};
|
||||
const metadata = applySeoMetaToMetadata(
|
||||
{
|
||||
priceByExpert: dto.priceByExpert === true,
|
||||
},
|
||||
normalizeSeoMetaInput(dto.seoMetaTitle, dto.seoMetaDescription),
|
||||
) as Prisma.InputJsonValue;
|
||||
|
||||
const created = await this.prisma.$transaction(async (tx) => {
|
||||
const product = await tx.userProduct.create({
|
||||
@@ -522,10 +530,13 @@ export class UserProductsService {
|
||||
const content: Prisma.InputJsonValue = {
|
||||
...(dto.titleEn?.trim() ? { titleEn: dto.titleEn.trim() } : {}),
|
||||
};
|
||||
const metadata: Prisma.InputJsonValue = {
|
||||
...existingMetadata,
|
||||
priceByExpert: dto.priceByExpert === true,
|
||||
};
|
||||
const metadata = applySeoMetaToMetadata(
|
||||
{
|
||||
...existingMetadata,
|
||||
priceByExpert: dto.priceByExpert === true,
|
||||
},
|
||||
normalizeSeoMetaInput(dto.seoMetaTitle, dto.seoMetaDescription),
|
||||
) as Prisma.InputJsonValue;
|
||||
|
||||
let slug = existing.slug;
|
||||
if (titleFa !== existing.title) {
|
||||
@@ -1269,6 +1280,7 @@ export class UserProductsService {
|
||||
priceCurrency: product.priceCurrency,
|
||||
priceByExpert,
|
||||
promoted,
|
||||
...readSeoMetaFromMetadata(metadata),
|
||||
status: product.status,
|
||||
condition: product.condition,
|
||||
deliveryNote: product.deliveryNote,
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Matches,
|
||||
Min,
|
||||
MinLength,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
|
||||
export class ListVideosDto {
|
||||
@@ -120,6 +121,16 @@ export class CreateVideoDto {
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
tags?: string[];
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(70)
|
||||
seoMetaTitle?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(160)
|
||||
seoMetaDescription?: string | null;
|
||||
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@@ -179,6 +190,16 @@ export class UpdateVideoDto {
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
tags?: string[];
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(70)
|
||||
seoMetaTitle?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(160)
|
||||
seoMetaDescription?: string | null;
|
||||
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
|
||||
@@ -13,6 +13,11 @@ import {
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { PermissionsService } from '../auth/permissions.service';
|
||||
import { BusinessSettingsService } from '../business-settings/business-settings.service';
|
||||
import {
|
||||
applySeoMetaToMetadata,
|
||||
normalizeSeoMetaInput,
|
||||
readSeoMetaFromMetadata,
|
||||
} from '../common/seo-meta.util';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { TenantService } from '../tenant/tenant.service';
|
||||
import {
|
||||
@@ -153,7 +158,10 @@ export class VideosService {
|
||||
status,
|
||||
featured_media_id: featuredMediaId,
|
||||
published_at: status === ContentStatus.published ? new Date() : null,
|
||||
metadata: this.buildMetadata(dto.tags) as Prisma.InputJsonValue,
|
||||
metadata: applySeoMetaToMetadata(
|
||||
this.buildMetadata(dto.tags),
|
||||
normalizeSeoMetaInput(dto.seoMetaTitle, dto.seoMetaDescription),
|
||||
) as Prisma.InputJsonValue,
|
||||
},
|
||||
include: videoInclude,
|
||||
});
|
||||
@@ -247,10 +255,13 @@ export class VideosService {
|
||||
nextContent.html = dto.descriptionHtml ?? '';
|
||||
}
|
||||
|
||||
const nextMetadata = { ...existingMetadata };
|
||||
if (dto.tags !== undefined) {
|
||||
nextMetadata.tags = dto.tags;
|
||||
}
|
||||
const nextMetadata = applySeoMetaToMetadata(
|
||||
{
|
||||
...existingMetadata,
|
||||
...(dto.tags !== undefined ? { tags: dto.tags } : {}),
|
||||
},
|
||||
normalizeSeoMetaInput(dto.seoMetaTitle, dto.seoMetaDescription),
|
||||
);
|
||||
|
||||
let publishedAt: Date | null | undefined = undefined;
|
||||
if (dto.status !== undefined) {
|
||||
@@ -651,6 +662,7 @@ export class VideosService {
|
||||
categoryId: categoryAssignment?.categoryId.toString() ?? null,
|
||||
categoryName: categoryAssignment?.category.name ?? '',
|
||||
tags: Array.isArray(metadata.tags) ? (metadata.tags as string[]) : [],
|
||||
...readSeoMetaFromMetadata(metadata),
|
||||
authorId: video.author_id?.toString() ?? null,
|
||||
author: video.author
|
||||
? {
|
||||
|
||||
@@ -319,11 +319,73 @@ These are **required on every crawlable page** (home, listing, product/blog/port
|
||||
**Open Graph (recommended)**
|
||||
- Set `og:title`, `og:description`, and `og:image` on important pages (home + detail pages) from CMS media when available.
|
||||
|
||||
### Site-wide Schema.org JSON-LD (Organization / LocalBusiness)
|
||||
|
||||
Business admins configure this under **Website → Settings → Structured data**. The API builds a ready `jsonLd` object for you.
|
||||
|
||||
1. `GET /tenants/{domain}/website/business-info`
|
||||
2. If `schema.enabled` and `schema.jsonLd` is an object, render once in the root layout (or every public page):
|
||||
|
||||
```html
|
||||
<script type="application/ld+json">
|
||||
…paste schema.jsonLd…
|
||||
</script>
|
||||
```
|
||||
|
||||
In Next.js App Router, put it in the root layout via `<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(schema.jsonLd) }} />` when `jsonLd` is non-null.
|
||||
|
||||
- Do **not** invent Organization fields locally when `jsonLd` is present — use the CMS object.
|
||||
- When `schema.enabled` is false, `jsonLd` is `null` — omit the script tag.
|
||||
- Tenant resolve also exposes `{ schema: { enabled, type } }` for lightweight checks; full payload is on business-info.
|
||||
- Social profile URLs from business profile are merged into `sameAs` automatically; admin can add extra https URLs.
|
||||
|
||||
### Product + blog Schema.org JSON-LD (auto)
|
||||
|
||||
When **Website → Settings → Structured data** is enabled, detail endpoints include a ready `schema.jsonLd` (no admin fields to edit):
|
||||
|
||||
| Page | Endpoint | `@type` |
|
||||
|------|----------|---------|
|
||||
| Product detail | `GET /tenants/{domain}/products/by-id/{id}` (or by slug) | `Product` (+ `Offer` when store min price exists) |
|
||||
| Blog detail | `GET /tenants/{domain}/blogs/{slug}` (or by id) | `BlogPosting` |
|
||||
|
||||
On the product/blog page:
|
||||
|
||||
```tsx
|
||||
{product.schema?.jsonLd ? (
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(product.schema.jsonLd) }}
|
||||
/>
|
||||
) : null}
|
||||
```
|
||||
|
||||
- Emit **in addition to** the site-wide Organization/LocalBusiness script from business-info.
|
||||
- When structured data is disabled, `schema.jsonLd` is `null` — omit the tag.
|
||||
- Do not invent Product/Article fields locally when `jsonLd` is present.
|
||||
|
||||
### Per-page SEO meta overrides (optional)
|
||||
|
||||
CMS forms expose optional **SEO title** / **SEO description** on products, blogs, portfolios, videos, instructions, workshops, user-products, and categories.
|
||||
|
||||
Public detail payloads include:
|
||||
|
||||
- `seoMetaTitle` (`string | null`)
|
||||
- `seoMetaDescription` (`string | null`)
|
||||
|
||||
Website rules:
|
||||
|
||||
1. Prefer `seoMetaTitle` for `<title>` when non-null; else use the normal title/`nameFa`.
|
||||
2. Prefer `seoMetaDescription` for `<meta name="description">` when non-null; else summary/abstract/about.
|
||||
3. Product/blog `schema.jsonLd` already prefers these overrides when set.
|
||||
4. Empty/null means “no override” — never show placeholder text as meta.
|
||||
|
||||
**Checklist before shipping a page**
|
||||
1. Unique `<title>` and meta description
|
||||
1. Unique `<title>` and meta description (use `seoMeta*` when present)
|
||||
2. One clear H1 + sensible H2 sections
|
||||
3. All content images have alt text
|
||||
4. Public URL is included via CMS sitemap and/or `sitemap-config.json` static pages
|
||||
5. Site-wide Organization/LocalBusiness JSON-LD from `business-info.schema.jsonLd` when enabled
|
||||
6. Product/blog detail pages emit `product.schema.jsonLd` / `blog.schema.jsonLd` when present
|
||||
|
||||
If OpenAPI and this brief conflict, **OpenAPI wins**.
|
||||
|
||||
|
||||
@@ -195,6 +195,18 @@
|
||||
"store_item"
|
||||
],
|
||||
"description": "Where homepage special-product carousels read from. Defaults to store_item when the store module is enabled, otherwise product."
|
||||
},
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"description": "Site-wide Schema.org flags. Full jsonLd is on GET /tenants/{domain}/website/business-info.",
|
||||
"properties": {
|
||||
"enabled": { "type": "boolean" },
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["Organization", "LocalBusiness"]
|
||||
}
|
||||
},
|
||||
"required": ["enabled", "type"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -530,7 +542,32 @@
|
||||
}
|
||||
},
|
||||
"socialMedia": { "type": "object" },
|
||||
"addresses": { "type": "array", "items": { "type": "object" } }
|
||||
"addresses": { "type": "array", "items": { "type": "object" } },
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"description": "Site-wide Schema.org settings + ready-to-emit jsonLd (Phase A).",
|
||||
"properties": {
|
||||
"enabled": { "type": "boolean" },
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["Organization", "LocalBusiness"]
|
||||
},
|
||||
"name": { "type": "string", "nullable": true },
|
||||
"nameFa": { "type": "string", "nullable": true },
|
||||
"description": { "type": "string", "nullable": true },
|
||||
"priceRange": { "type": "string", "nullable": true },
|
||||
"sameAs": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"jsonLd": {
|
||||
"type": "object",
|
||||
"nullable": true,
|
||||
"description": "Null when schema.enabled is false. Otherwise emit as application/ld+json on every public page (or at least the layout)."
|
||||
}
|
||||
},
|
||||
"required": ["enabled", "type", "sameAs", "jsonLd"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1001,7 +1038,7 @@
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "{ product, relatedProducts } — same shape as product-by-slug"
|
||||
"description": "{ product, relatedProducts }. `product.schema.jsonLd` is Product Schema.org JSON-LD when website structured data is enabled (null otherwise). Related products omit schema."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1080,7 +1117,7 @@
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "{ product, relatedProducts } — relatedProducts uses the product list-card shape (incl. store). Ranked by relativity (same category, then same brand) with availability beside it (in-stock first within each group). Up to 8 items; excludes the current product."
|
||||
"description": "{ product, relatedProducts }. `product.schema.jsonLd` is Product Schema.org JSON-LD when website structured data is enabled (null otherwise). Related products omit schema. relatedProducts uses the product list-card shape (incl. store). Ranked by relativity (same category, then same brand) with availability beside it (in-stock first within each group). Up to 8 items; excludes the current product."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1622,7 +1659,7 @@
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "{ blog }"
|
||||
"description": "{ blog }. `blog.schema.jsonLd` is BlogPosting Schema.org JSON-LD when website structured data is enabled (null otherwise)."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1648,7 +1685,7 @@
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "{ blog }"
|
||||
"description": "{ blog }. `blog.schema.jsonLd` is BlogPosting Schema.org JSON-LD when website structured data is enabled (null otherwise)."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
normalizePhoneNumbers,
|
||||
normalizeSocialMedia,
|
||||
} from '../business-profile/business-profile.util';
|
||||
import { normalizeBusinessSettings } from '../business-settings/business-settings.util';
|
||||
import { buildWebsiteSchemaJsonLd } from '../business-settings/website-schema.util';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { TenantService } from '../tenant/tenant.service';
|
||||
|
||||
@@ -16,6 +18,7 @@ export class WebsiteBusinessInfoService {
|
||||
|
||||
async getPublic(host: string) {
|
||||
const business = await this.tenant.resolveBusinessByDomain(host);
|
||||
const settings = normalizeBusinessSettings(business.settings);
|
||||
|
||||
const record = await this.prisma.business.findUnique({
|
||||
where: { id: business.id },
|
||||
@@ -31,27 +34,61 @@ export class WebsiteBusinessInfoService {
|
||||
throw new NotFoundException('Business not found');
|
||||
}
|
||||
|
||||
const emails = normalizeEmails(record.emails);
|
||||
const phoneNumbers = normalizePhoneNumbers(record.phoneNumbers);
|
||||
const socialMedia = normalizeSocialMedia(record.socialMedia);
|
||||
const addresses = record.addresses.map((address) => ({
|
||||
id: address.id.toString(),
|
||||
province: address.province,
|
||||
city: address.city,
|
||||
address: address.address,
|
||||
postalCode: address.postalCode,
|
||||
landline: address.landline,
|
||||
}));
|
||||
const logoUrl = record.logoMedia?.publicUrl ?? null;
|
||||
const normalizedHost = host
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/^(customer|business)\./, '');
|
||||
|
||||
const schemaSettings = settings.website.schema;
|
||||
const jsonLd = buildWebsiteSchemaJsonLd({
|
||||
settings: schemaSettings,
|
||||
host: normalizedHost,
|
||||
name: record.name,
|
||||
nameFa: record.nameFa ?? '',
|
||||
about: record.about ?? '',
|
||||
logoUrl,
|
||||
emails,
|
||||
phoneNumbers,
|
||||
socialMedia,
|
||||
addresses,
|
||||
});
|
||||
|
||||
return {
|
||||
id: record.id.toString(),
|
||||
name: record.name,
|
||||
nameFa: record.nameFa ?? '',
|
||||
about: record.about ?? '',
|
||||
vision: record.vision ?? '',
|
||||
logoUrl: record.logoMedia?.publicUrl ?? null,
|
||||
logoUrl,
|
||||
logoDarkUrl: record.logoDarkMedia?.publicUrl ?? null,
|
||||
faviconUrl:
|
||||
record.faviconMedia?.publicUrl ?? record.logoMedia?.publicUrl ?? null,
|
||||
emails: normalizeEmails(record.emails),
|
||||
phoneNumbers: normalizePhoneNumbers(record.phoneNumbers),
|
||||
socialMedia: normalizeSocialMedia(record.socialMedia),
|
||||
addresses: record.addresses.map((address) => ({
|
||||
id: address.id.toString(),
|
||||
province: address.province,
|
||||
city: address.city,
|
||||
address: address.address,
|
||||
postalCode: address.postalCode,
|
||||
landline: address.landline,
|
||||
})),
|
||||
emails,
|
||||
phoneNumbers,
|
||||
socialMedia,
|
||||
addresses,
|
||||
schema: {
|
||||
enabled: schemaSettings.enabled,
|
||||
type: schemaSettings.type,
|
||||
name: schemaSettings.name,
|
||||
nameFa: schemaSettings.nameFa,
|
||||
description: schemaSettings.description,
|
||||
priceRange: schemaSettings.priceRange,
|
||||
sameAs: schemaSettings.sameAs,
|
||||
jsonLd,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,6 +110,16 @@ export class CreateWorkshopDto {
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
tags?: string[];
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(70)
|
||||
seoMetaTitle?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(160)
|
||||
seoMetaDescription?: string | null;
|
||||
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@@ -182,6 +192,16 @@ export class UpdateWorkshopDto {
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
tags?: string[];
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(70)
|
||||
seoMetaTitle?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(160)
|
||||
seoMetaDescription?: string | null;
|
||||
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
|
||||
@@ -13,6 +13,11 @@ import {
|
||||
} from '@prisma/client';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { PermissionsService } from '../auth/permissions.service';
|
||||
import {
|
||||
applySeoMetaToMetadata,
|
||||
normalizeSeoMetaInput,
|
||||
readSeoMetaFromMetadata,
|
||||
} from '../common/seo-meta.util';
|
||||
import { MediaService } from '../media/media.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { TenantService } from '../tenant/tenant.service';
|
||||
@@ -158,7 +163,10 @@ export class WorkshopsService {
|
||||
status,
|
||||
featured_media_id: featuredMediaId,
|
||||
published_at: status === ContentStatus.published ? new Date() : null,
|
||||
metadata: this.buildMetadata(dto.tags) as Prisma.InputJsonValue,
|
||||
metadata: applySeoMetaToMetadata(
|
||||
this.buildMetadata(dto.tags),
|
||||
normalizeSeoMetaInput(dto.seoMetaTitle, dto.seoMetaDescription),
|
||||
) as Prisma.InputJsonValue,
|
||||
event_date: this.parseEventDate(dto.eventDate) ?? null,
|
||||
event_time: dto.eventTime?.trim() || null,
|
||||
duration: dto.duration?.trim() || null,
|
||||
@@ -262,10 +270,13 @@ export class WorkshopsService {
|
||||
nextContent.html = dto.descriptionHtml ?? '';
|
||||
}
|
||||
|
||||
const nextMetadata = { ...existingMetadata };
|
||||
if (dto.tags !== undefined) {
|
||||
nextMetadata.tags = dto.tags;
|
||||
}
|
||||
const nextMetadata = applySeoMetaToMetadata(
|
||||
{
|
||||
...existingMetadata,
|
||||
...(dto.tags !== undefined ? { tags: dto.tags } : {}),
|
||||
},
|
||||
normalizeSeoMetaInput(dto.seoMetaTitle, dto.seoMetaDescription),
|
||||
);
|
||||
|
||||
let publishedAt: Date | null | undefined = undefined;
|
||||
if (dto.status !== undefined) {
|
||||
@@ -801,6 +812,7 @@ export class WorkshopsService {
|
||||
categoryId: categoryAssignment?.categoryId.toString() ?? null,
|
||||
categoryName: categoryAssignment?.category.name ?? '',
|
||||
tags: Array.isArray(metadata.tags) ? (metadata.tags as string[]) : [],
|
||||
...readSeoMetaFromMetadata(metadata),
|
||||
authorId: workshop.author_id?.toString() ?? null,
|
||||
author: workshop.author
|
||||
? {
|
||||
|
||||
Reference in New Issue
Block a user