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
+2
View File
@@ -100,3 +100,5 @@ INVOICE_PUBLIC_DOMAIN=meshkee.com
# INVOICE_PUBLIC_DEV_PORT=5174 # INVOICE_PUBLIC_DEV_PORT=5174
# INVOICE_PUBLIC_DEV_HOST=meshkee.app # INVOICE_PUBLIC_DEV_HOST=meshkee.app
# INVOICE_PUBLIC_DEV_PROTOCOL=https # INVOICE_PUBLIC_DEV_PROTOCOL=https
# Absolute image for WhatsApp / social link previews (defaults to manage.meshkee.com/og-invoice.png)
# INVOICE_OG_IMAGE_URL=https://manage.meshkee.com/og-invoice.png
+18
View File
@@ -3,13 +3,16 @@ import {
Controller, Controller,
Delete, Delete,
Get, Get,
Header,
Param, Param,
Patch, Patch,
Post, Post,
Put, Put,
Query, Query,
Res,
UseGuards, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import type { Response } from 'express';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard'; import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard';
import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator'; import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator';
@@ -101,6 +104,21 @@ export class InvoicesController {
return this.service.getPublicInvoice(publicId); return this.service.getPublicInvoice(publicId);
} }
/**
* Lightweight HTML with Open Graph tags for WhatsApp / social link previews.
* Served to crawlers via nginx; humans still get the SPA at /invoices/:id.
*/
@Get('public/invoices/:publicId/og')
@Header('Cache-Control', 'public, max-age=300')
async getPublicOg(
@Param('publicId') publicId: string,
@Res() res: Response,
) {
const html = await this.service.buildPublicInvoiceOgHtml(publicId);
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.send(html);
}
/** Public approve action (no auth). issued → approved. */ /** Public approve action (no auth). issued → approved. */
@Post('public/invoices/:publicId/approve') @Post('public/invoices/:publicId/approve')
approvePublic(@Param('publicId') publicId: string) { approvePublic(@Param('publicId') publicId: string) {
+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_MIN = 100_000_000_000;
const PUBLIC_ID_MAX = 999_999_999_999; 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() @Injectable()
export class InvoicesService { export class InvoicesService {
constructor( constructor(
@@ -1311,6 +1332,66 @@ export class InvoicesService {
return this.toPublicInvoicePayload(row); 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 --- // --- Invoices for a business ---
async listForBusiness(businessIdRaw: string, query: ListInvoicesDto, actor: AuthUser) { async listForBusiness(businessIdRaw: string, query: ListInvoicesDto, actor: AuthUser) {