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) {