diff --git a/src/app/api/i/[token]/pdf/route.ts b/src/app/api/i/[token]/pdf/route.ts new file mode 100644 index 0000000..d775f39 --- /dev/null +++ b/src/app/api/i/[token]/pdf/route.ts @@ -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", + }, + }); +} diff --git a/src/app/api/mcp/route.ts b/src/app/api/mcp/route.ts index e2ee28a..7705514 100644 --- a/src/app/api/mcp/route.ts +++ b/src/app/api/mcp/route.ts @@ -843,10 +843,26 @@ const tools = { handler: async (input, caller) => caller.invoices.sendReminder(input), }), invoices_generate_public_token: defineTool({ - description: "Generate a shareable public link token for an invoice. Clients can view the invoice without logging in.", - inputSchema: jsonSchemas.id, - schema: z.object({ id: z.string() }), - handler: async (input, caller) => caller.invoices.generatePublicToken(input), + 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: { + type: "object", + 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({ description: "Revoke the public shareable link for an invoice, making it inaccessible without authentication.", diff --git a/src/proxy.ts b/src/proxy.ts index 970e0cd..472b675 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -14,7 +14,7 @@ export function proxy(request: NextRequest) { const publicRoutes = ["/", "/auth/signin", "/auth/register"]; // 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 if (apiRoutes.some((route) => pathname.startsWith(route))) { diff --git a/src/server/api/routers/invoices.ts b/src/server/api/routers/invoices.ts index b585230..30a0706 100644 --- a/src/server/api/routers/invoices.ts +++ b/src/server/api/routers/invoices.ts @@ -644,7 +644,7 @@ export const invoicesRouter = createTRPCRouter({ // ── Public token (shareable link) ────────────────────────────────────────── generatePublicToken: protectedProcedure - .input(z.object({ id: z.string() })) + .input(z.object({ id: z.string(), ttlHours: z.number().positive().optional() })) .mutation(async ({ ctx, input }) => { const invoice = await ctx.db.query.invoices.findFirst({ where: eq(invoices.id, input.id), @@ -653,11 +653,14 @@ export const invoicesRouter = createTRPCRouter({ throw new TRPCError({ code: "NOT_FOUND" }); } const token = crypto.randomUUID(); + const expiresAt = input.ttlHours + ? new Date(Date.now() + input.ttlHours * 3_600_000) + : null; await ctx.db .update(invoices) - .set({ publicToken: token }) + .set({ publicToken: token, publicTokenExpiresAt: expiresAt }) .where(eq(invoices.id, input.id)); - return { token }; + return { token, expiresAt }; }), revokePublicToken: protectedProcedure @@ -684,6 +687,9 @@ export const invoicesRouter = createTRPCRouter({ with: { client: true, business: true, items: { orderBy: (i, { asc }) => [asc(i.position)] } }, }); 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; }), diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index 47df734..0435d0b 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -367,6 +367,7 @@ export const invoices = createTable( .notNull() .references(() => users.id), publicToken: d.varchar({ length: 255 }).unique(), + publicTokenExpiresAt: d.timestamp(), lastReminderSentAt: d.timestamp(), createdAt: d .timestamp()