Add business logo branding support

This commit is contained in:
2026-08-14 16:26:43 -04:00
parent 3f3b1362a9
commit 29d7b498ae
31 changed files with 974 additions and 154 deletions
@@ -0,0 +1,78 @@
import { type NextRequest, NextResponse } from "next/server";
import { eq } from "drizzle-orm";
import { getObject } from "~/lib/object-storage";
import { db } from "~/server/db";
import { businesses } from "~/server/db/schema";
export const runtime = "nodejs";
const RASTERIZABLE_MIME_TYPES = new Set(["image/svg+xml", "image/webp"]);
// Intentionally unauthenticated: a business logo must be viewable on public,
// token-based invoice pages without a session. Business IDs are random
// UUIDs, so this only serves images to callers who already know the ID.
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ businessId: string }> },
) {
const { businessId } = await params;
const business = await db.query.businesses.findFirst({
where: eq(businesses.id, businessId),
columns: { logoStorageKey: true, logoMimeType: true },
});
if (!business?.logoStorageKey || !business.logoMimeType) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
// @react-pdf/renderer's Image component only decodes PNG/JPEG, so PDF
// generation requests a rasterized copy of SVG/WebP logos via this param.
const wantsPng =
new URL(req.url).searchParams.get("format") === "png" &&
RASTERIZABLE_MIME_TYPES.has(business.logoMimeType);
try {
const body = await getObject(business.logoStorageKey);
if (wantsPng) {
const { default: sharp } = await import("sharp");
const isSvg = business.logoMimeType === "image/svg+xml";
// SVG is vector: rasterize at a high density so the PNG stays crisp at
// the size it's actually displayed (PDF header, up to ~2.2in wide).
// withoutEnlargement only makes sense for the WebP (already-raster)
// case — for SVG it would cap us at whatever tiny canvas the source's
// intrinsic viewBox implies, even though the vector has no such limit.
const png = await sharp(body, isSvg ? { density: 600 } : undefined)
.resize({
width: 1024,
height: 1024,
fit: "inside",
withoutEnlargement: !isSvg,
})
.png()
.toBuffer();
return new NextResponse(new Uint8Array(png), {
headers: {
"Content-Type": "image/png",
"Cache-Control": "public, max-age=300, must-revalidate",
"X-Content-Type-Options": "nosniff",
},
});
}
return new NextResponse(new Uint8Array(body), {
headers: {
"Content-Type": business.logoMimeType,
"Cache-Control": "public, max-age=300, must-revalidate",
"X-Content-Type-Options": "nosniff",
},
});
} catch (error) {
console.error("[business-logo] Failed to serve logo", {
backendError: error,
businessId,
wantsPng,
});
return NextResponse.json({ error: "Logo not found" }, { status: 404 });
}
}
+44 -15
View File
@@ -7,7 +7,7 @@ import { generateInvoicePDFBlob } from "~/lib/pdf-export";
export const runtime = "nodejs";
export async function GET(
_request: Request,
request: Request,
{ params }: { params: Promise<{ token: string }> },
) {
const { token } = await params;
@@ -16,7 +16,28 @@ export async function GET(
where: eq(invoices.publicToken, token),
with: {
client: true,
business: true,
// Explicit allowlist: token-based public route — never fetch
// secret fields (resendApiKey, resendDomain) for an unauthenticated request.
business: {
columns: {
id: true,
name: true,
nickname: true,
email: true,
phone: true,
addressLine1: true,
addressLine2: true,
city: true,
state: true,
postalCode: true,
country: true,
website: true,
taxId: true,
logoStorageKey: true,
logoMimeType: true,
hideNameWithLogo: true,
},
},
items: {
orderBy: (i, { asc }) => [
asc(i.date),
@@ -39,19 +60,27 @@ export async function GET(
where: eq(platformSettings.id, "global"),
});
const pdfBlob = await generateInvoicePDFBlob(invoice, {
pdfTemplate: settings?.pdfTemplate as "classic" | "minimal" | undefined,
pdfAccentColor: settings?.pdfAccentColor,
pdfFontFamily: settings?.pdfFontFamily as "sans" | "serif" | "mono" | undefined,
pdfNumericFontFamily: settings?.pdfNumericFontFamily as
| "sans"
| "serif"
| "mono"
| undefined,
pdfFooterText: settings?.pdfFooterText,
pdfShowLogo: settings?.pdfShowLogo,
pdfShowPageNumbers: settings?.pdfShowPageNumbers,
});
const pdfBlob = await generateInvoicePDFBlob(
invoice,
{
pdfTemplate: settings?.pdfTemplate as "classic" | "minimal" | undefined,
pdfAccentColor: settings?.pdfAccentColor,
pdfFontFamily: settings?.pdfFontFamily as
| "sans"
| "serif"
| "mono"
| undefined,
pdfNumericFontFamily: settings?.pdfNumericFontFamily as
| "sans"
| "serif"
| "mono"
| undefined,
pdfFooterText: settings?.pdfFooterText,
pdfShowLogo: settings?.pdfShowLogo,
pdfShowPageNumbers: settings?.pdfShowPageNumbers,
},
{ logoBaseUrl: new URL(request.url).origin },
);
const buffer = await pdfBlob.arrayBuffer();
const filename = `invoice-${invoice.invoiceNumber}.pdf`;