From 513673a7da830f31ba9f13fa46bbaad0589c9578 Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Tue, 11 Aug 2026 16:15:52 +0330 Subject: [PATCH] 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 --- .env.example | 2 + src/invoices/invoices.controller.ts | 18 +++++++ src/invoices/invoices.service.ts | 81 +++++++++++++++++++++++++++++ 3 files changed, 101 insertions(+) diff --git a/.env.example b/.env.example index 4b4ea0c..ff4063f 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/src/invoices/invoices.controller.ts b/src/invoices/invoices.controller.ts index 1753e1f..e5329f9 100644 --- a/src/invoices/invoices.controller.ts +++ b/src/invoices/invoices.controller.ts @@ -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) { diff --git a/src/invoices/invoices.service.ts b/src/invoices/invoices.service.ts index 312027a..877d7d9 100644 --- a/src/invoices/invoices.service.ts +++ b/src/invoices/invoices.service.ts @@ -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, '>'); +} + +function stripHtmlToText(html: string): string { + return html + .replace(//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 { + 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 ` + + + + ${title} + + + + + + + + + + + + + + + + +

${title}

+ +`; + } + // --- Invoices for a business --- async listForBusiness(businessIdRaw: string, query: ListInvoicesDto, actor: AuthUser) {