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
+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;
}