mirror of
https://git.meshkee.com/Meshkee/backend.git
synced 2026-08-11 22:30:59 +04:30
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:
co-authored by
Cursor
parent
d8f4e8c4e5
commit
513673a7da
@@ -100,3 +100,5 @@ INVOICE_PUBLIC_DOMAIN=meshkee.com
|
||||
# INVOICE_PUBLIC_DEV_PORT=5174
|
||||
# INVOICE_PUBLIC_DEV_HOST=meshkee.app
|
||||
# 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
|
||||
|
||||
@@ -3,13 +3,16 @@ import {
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Header,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
Res,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard';
|
||||
import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator';
|
||||
@@ -101,6 +104,21 @@ export class InvoicesController {
|
||||
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. */
|
||||
@Post('public/invoices/:publicId/approve')
|
||||
approvePublic(@Param('publicId') publicId: string) {
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function stripHtmlToText(html: string): string {
|
||||
return html
|
||||
.replace(/<br\s*\/?>/gi, ' ')
|
||||
.replace(/<\/p>/gi, ' ')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/ /gi, ' ')
|
||||
.replace(/&/gi, '&')
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/'/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) {
|
||||
|
||||
Reference in New Issue
Block a user