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:
@@ -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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -843,10 +843,26 @@ const tools = {
|
|||||||
handler: async (input, caller) => caller.invoices.sendReminder(input),
|
handler: async (input, caller) => caller.invoices.sendReminder(input),
|
||||||
}),
|
}),
|
||||||
invoices_generate_public_token: defineTool({
|
invoices_generate_public_token: defineTool({
|
||||||
description: "Generate a shareable public link token for an invoice. Clients can view the invoice without logging in.",
|
description: "Generate a shareable public link for an invoice. Returns a web view URL (/i/{token}) and a direct PDF URL (/api/i/{token}/pdf). Set ttlHours to make the link expire automatically (e.g. 24 for a 24-hour preview link). Omit ttlHours for a permanent link.",
|
||||||
inputSchema: jsonSchemas.id,
|
inputSchema: {
|
||||||
schema: z.object({ id: z.string() }),
|
type: "object",
|
||||||
handler: async (input, caller) => caller.invoices.generatePublicToken(input),
|
properties: {
|
||||||
|
id: { type: "string" },
|
||||||
|
ttlHours: { type: "number", exclusiveMinimum: 0, description: "Hours until the link expires. Omit for a permanent link." },
|
||||||
|
},
|
||||||
|
required: ["id"],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
schema: z.object({ id: z.string(), ttlHours: z.number().positive().optional() }),
|
||||||
|
handler: async (input, caller) => {
|
||||||
|
const result = await caller.invoices.generatePublicToken(input);
|
||||||
|
const base = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
webUrl: `${base}/i/${result.token}`,
|
||||||
|
pdfUrl: `${base}/api/i/${result.token}/pdf`,
|
||||||
|
};
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
invoices_revoke_public_token: defineTool({
|
invoices_revoke_public_token: defineTool({
|
||||||
description: "Revoke the public shareable link for an invoice, making it inaccessible without authentication.",
|
description: "Revoke the public shareable link for an invoice, making it inaccessible without authentication.",
|
||||||
|
|||||||
+1
-1
@@ -14,7 +14,7 @@ export function proxy(request: NextRequest) {
|
|||||||
const publicRoutes = ["/", "/auth/signin", "/auth/register"];
|
const publicRoutes = ["/", "/auth/signin", "/auth/register"];
|
||||||
|
|
||||||
// Define API routes that should be handled separately
|
// Define API routes that should be handled separately
|
||||||
const apiRoutes = ["/api/auth", "/api/trpc", "/api/mcp"];
|
const apiRoutes = ["/api/auth", "/api/trpc", "/api/mcp", "/api/i"];
|
||||||
|
|
||||||
// Allow API routes to pass through
|
// Allow API routes to pass through
|
||||||
if (apiRoutes.some((route) => pathname.startsWith(route))) {
|
if (apiRoutes.some((route) => pathname.startsWith(route))) {
|
||||||
|
|||||||
@@ -644,7 +644,7 @@ export const invoicesRouter = createTRPCRouter({
|
|||||||
// ── Public token (shareable link) ──────────────────────────────────────────
|
// ── Public token (shareable link) ──────────────────────────────────────────
|
||||||
|
|
||||||
generatePublicToken: protectedProcedure
|
generatePublicToken: protectedProcedure
|
||||||
.input(z.object({ id: z.string() }))
|
.input(z.object({ id: z.string(), ttlHours: z.number().positive().optional() }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const invoice = await ctx.db.query.invoices.findFirst({
|
const invoice = await ctx.db.query.invoices.findFirst({
|
||||||
where: eq(invoices.id, input.id),
|
where: eq(invoices.id, input.id),
|
||||||
@@ -653,11 +653,14 @@ export const invoicesRouter = createTRPCRouter({
|
|||||||
throw new TRPCError({ code: "NOT_FOUND" });
|
throw new TRPCError({ code: "NOT_FOUND" });
|
||||||
}
|
}
|
||||||
const token = crypto.randomUUID();
|
const token = crypto.randomUUID();
|
||||||
|
const expiresAt = input.ttlHours
|
||||||
|
? new Date(Date.now() + input.ttlHours * 3_600_000)
|
||||||
|
: null;
|
||||||
await ctx.db
|
await ctx.db
|
||||||
.update(invoices)
|
.update(invoices)
|
||||||
.set({ publicToken: token })
|
.set({ publicToken: token, publicTokenExpiresAt: expiresAt })
|
||||||
.where(eq(invoices.id, input.id));
|
.where(eq(invoices.id, input.id));
|
||||||
return { token };
|
return { token, expiresAt };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
revokePublicToken: protectedProcedure
|
revokePublicToken: protectedProcedure
|
||||||
@@ -684,6 +687,9 @@ export const invoicesRouter = createTRPCRouter({
|
|||||||
with: { client: true, business: true, items: { orderBy: (i, { asc }) => [asc(i.position)] } },
|
with: { client: true, business: true, items: { orderBy: (i, { asc }) => [asc(i.position)] } },
|
||||||
});
|
});
|
||||||
if (!invoice) throw new TRPCError({ code: "NOT_FOUND" });
|
if (!invoice) throw new TRPCError({ code: "NOT_FOUND" });
|
||||||
|
if (invoice.publicTokenExpiresAt && new Date(invoice.publicTokenExpiresAt) < new Date()) {
|
||||||
|
throw new TRPCError({ code: "FORBIDDEN", message: "This link has expired" });
|
||||||
|
}
|
||||||
return invoice;
|
return invoice;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|||||||
@@ -367,6 +367,7 @@ export const invoices = createTable(
|
|||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id),
|
.references(() => users.id),
|
||||||
publicToken: d.varchar({ length: 255 }).unique(),
|
publicToken: d.varchar({ length: 255 }).unique(),
|
||||||
|
publicTokenExpiresAt: d.timestamp(),
|
||||||
lastReminderSentAt: d.timestamp(),
|
lastReminderSentAt: d.timestamp(),
|
||||||
createdAt: d
|
createdAt: d
|
||||||
.timestamp()
|
.timestamp()
|
||||||
|
|||||||
Reference in New Issue
Block a user