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:
Alireza Hassani
2026-09-09 23:30:24 +03:30
co-authored by Cursor
parent db305c58a4
commit aaf7a83742
33 changed files with 1395 additions and 188 deletions
+87 -13
View File
@@ -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
? {
+21
View File
@@ -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(/&nbsp;/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;
}
+27
View File
@@ -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,
+30 -1
View File
@@ -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]+)*$/)
+23
View File
@@ -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;
}
+67
View File
@@ -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
View File
@@ -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';
}
/**
+21
View File
@@ -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()
+17 -5
View File
@@ -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
? {
+21
View File
@@ -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()
+17 -5
View File
@@ -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 ??
+23
View File
@@ -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()
+85 -9
View File
@@ -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,
+4
View File
@@ -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:
+11
View File
@@ -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 {}
+19 -7
View File
@@ -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,
+21
View File
@@ -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()
+17 -5
View File
@@ -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
? {
+63 -1
View File
@@ -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**.
+42 -5
View File
@@ -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)."
}
}
}
+49 -12
View File
@@ -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,
},
};
}
}
+20
View File
@@ -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()
+17 -5
View File
@@ -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
? {