Add temporary PDF preview links via public token TTL

- Schema: add publicTokenExpiresAt to invoices table
- invoices.generatePublicToken: accepts optional ttlHours param; sets
  expiry timestamp when provided
- invoices.getByPublicToken: returns 403 if token is expired
- New route GET /api/i/[token]/pdf: streams the invoice PDF publicly,
  returns 410 Gone if expired; excluded from auth middleware
- MCP invoices_generate_public_token: exposes ttlHours, returns both
  webUrl (/i/{token}) and pdfUrl (/api/i/{token}/pdf)

https://claude.ai/code/session_014126WHVRT8mftmqkU6dajG
This commit is contained in:
Claude
2026-06-11 05:37:17 +00:00
parent c0a333710f
commit e03c553b7f
5 changed files with 85 additions and 8 deletions
+54
View File
@@ -0,0 +1,54 @@
import { NextResponse } from "next/server";
import { eq } from "drizzle-orm";
import { db } from "~/server/db";
import { invoices, platformSettings } from "~/server/db/schema";
import { generateInvoicePDFBlob } from "~/lib/pdf-export";
export const runtime = "nodejs";
export async function GET(
_request: Request,
{ params }: { params: Promise<{ token: string }> },
) {
const { token } = await params;
const invoice = await db.query.invoices.findFirst({
where: eq(invoices.publicToken, token),
with: {
client: true,
business: true,
items: { orderBy: (i, { asc }) => [asc(i.position)] },
},
});
if (!invoice) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
if (invoice.publicTokenExpiresAt && new Date(invoice.publicTokenExpiresAt) < new Date()) {
return NextResponse.json({ error: "This link has expired" }, { status: 410 });
}
const settings = await db.query.platformSettings.findFirst({
where: eq(platformSettings.id, "global"),
});
const pdfBlob = await generateInvoicePDFBlob(invoice, {
pdfTemplate: settings?.pdfTemplate as "classic" | "minimal" | undefined,
pdfAccentColor: settings?.pdfAccentColor,
pdfFooterText: settings?.pdfFooterText,
pdfShowLogo: settings?.pdfShowLogo,
pdfShowPageNumbers: settings?.pdfShowPageNumbers,
});
const buffer = await pdfBlob.arrayBuffer();
const filename = `invoice-${invoice.invoiceNumber}.pdf`;
return new Response(buffer, {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `inline; filename="${filename}"`,
"Cache-Control": "private, no-store",
},
});
}