Add Open Graph HTML for public invoice link previews.

WhatsApp and other crawlers can scrape invoice title and image without running the SPA.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-11 16:15:52 +03:30
co-authored by Cursor
parent d8f4e8c4e5
commit 513673a7da
3 changed files with 101 additions and 0 deletions
+81
View File
@@ -30,6 +30,27 @@ import type { DashboardLocale } from '../business-settings/business-settings.typ
const PUBLIC_ID_MIN = 100_000_000_000;
const PUBLIC_ID_MAX = 999_999_999_999;
function escapeHtmlAttr(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
function stripHtmlToText(html: string): string {
return html
.replace(/<br\s*\/?>/gi, ' ')
.replace(/<\/p>/gi, ' ')
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/gi, ' ')
.replace(/&amp;/gi, '&')
.replace(/&quot;/gi, '"')
.replace(/&#39;/gi, "'")
.replace(/\s+/g, ' ')
.trim();
}
@Injectable()
export class InvoicesService {
constructor(
@@ -1311,6 +1332,66 @@ export class InvoicesService {
return this.toPublicInvoicePayload(row);
}
/** HTML document for social crawlers (WhatsApp, etc.) — Open Graph + redirect to SPA. */
async buildPublicInvoiceOgHtml(publicIdRaw: string): Promise<string> {
const invoice = await this.getPublicInvoice(publicIdRaw);
const title = escapeHtmlAttr(
(invoice.name?.trim() || `Invoice ${invoice.publicId}`).slice(0, 120),
);
const businessName =
(invoice.business &&
'displayName' in invoice.business &&
typeof invoice.business.displayName === 'string' &&
invoice.business.displayName.trim()) ||
invoice.business?.nameFa?.trim() ||
invoice.business?.name?.trim() ||
'Meshkee';
const totalLabel =
typeof invoice.total === 'number'
? `${invoice.total.toLocaleString('en-US')} IRT`
: '';
const fromTop = stripHtmlToText(invoice.topText ?? '').slice(0, 140);
const description = escapeHtmlAttr(
fromTop ||
[businessName, totalLabel].filter(Boolean).join(' · ') ||
'Meshkee invoice',
);
const pageUrl = escapeHtmlAttr(
invoice.publicUrl ||
`https://${process.env.INVOICE_PUBLIC_DOMAIN?.trim() || 'meshkee.com'}/invoices/${invoice.publicId}`,
);
const imageUrl = escapeHtmlAttr(
process.env.INVOICE_OG_IMAGE_URL?.trim() ||
'https://manage.meshkee.com/og-invoice.png',
);
const locale = invoice.locale === 'en' ? 'en_US' : 'fa_IR';
return `<!DOCTYPE html>
<html lang="${invoice.locale === 'en' ? 'en' : 'fa'}">
<head>
<meta charset="utf-8" />
<title>${title}</title>
<meta name="description" content="${description}" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="Meshkee" />
<meta property="og:locale" content="${locale}" />
<meta property="og:title" content="${title}" />
<meta property="og:description" content="${description}" />
<meta property="og:url" content="${pageUrl}" />
<meta property="og:image" content="${imageUrl}" />
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content="${title}" />
<meta name="twitter:description" content="${description}" />
<meta name="twitter:image" content="${imageUrl}" />
<link rel="canonical" href="${pageUrl}" />
<meta http-equiv="refresh" content="0;url=${pageUrl}" />
</head>
<body>
<p><a href="${pageUrl}">${title}</a></p>
</body>
</html>`;
}
// --- Invoices for a business ---
async listForBusiness(businessIdRaw: string, query: ListInvoicesDto, actor: AuthUser) {