feat: add PDF preview functionality and normalize email message handling
This commit is contained in:
@@ -17,6 +17,22 @@ function plainTextToHtml(value: string) {
|
||||
.replace(/\n/g, "<br>");
|
||||
}
|
||||
|
||||
function normalizeEmailNoteHtml(value: string) {
|
||||
const visibleText = value
|
||||
.replace(/<br\s*\/?>/gi, "\n")
|
||||
.replace(/<\/p>/gi, "\n")
|
||||
.replace(/<[^>]*>/g, "")
|
||||
.replace(/ |\u00a0/g, " ")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.trim();
|
||||
|
||||
return visibleText ? value.trim() : "";
|
||||
}
|
||||
|
||||
export const emailRouter = createTRPCRouter({
|
||||
sendInvoice: protectedProcedure
|
||||
.input(
|
||||
@@ -105,6 +121,12 @@ export const emailRouter = createTRPCRouter({
|
||||
"Your Name";
|
||||
const userEmail =
|
||||
invoice.business?.email ?? ctx.session.user?.email ?? "";
|
||||
const customMessage =
|
||||
input.customMessage !== undefined
|
||||
? normalizeEmailNoteHtml(input.customMessage)
|
||||
: invoice.emailMessage
|
||||
? plainTextToHtml(invoice.emailMessage)
|
||||
: undefined;
|
||||
|
||||
// Generate branded email template
|
||||
const emailTemplate = generateInvoiceEmailTemplate({
|
||||
@@ -124,11 +146,7 @@ export const emailRouter = createTRPCRouter({
|
||||
items: invoice.items,
|
||||
},
|
||||
customContent: input.customContent,
|
||||
customMessage:
|
||||
input.customMessage ??
|
||||
(invoice.emailMessage
|
||||
? plainTextToHtml(invoice.emailMessage)
|
||||
: undefined),
|
||||
customMessage,
|
||||
userName,
|
||||
userEmail,
|
||||
baseUrl: process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000",
|
||||
|
||||
@@ -6,8 +6,16 @@ import {
|
||||
invoiceItems,
|
||||
clients,
|
||||
businesses,
|
||||
platformSettings,
|
||||
} from "~/server/db/schema";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { generateInvoicePDFBlob } from "~/lib/pdf-export";
|
||||
import type { db } from "~/server/db";
|
||||
|
||||
type InvoiceRouterContext = {
|
||||
db: typeof db;
|
||||
session: { user: { id: string } };
|
||||
};
|
||||
|
||||
const invoiceItemSchema = z.object({
|
||||
date: z.date(),
|
||||
@@ -44,6 +52,55 @@ const updateStatusSchema = z.object({
|
||||
status: z.enum(["draft", "sent", "paid"]),
|
||||
});
|
||||
|
||||
async function verifyBusinessAccess(
|
||||
ctx: InvoiceRouterContext,
|
||||
businessId?: string | null,
|
||||
) {
|
||||
if (!businessId) return null;
|
||||
|
||||
const business = await ctx.db.query.businesses.findFirst({
|
||||
where: eq(businesses.id, businessId),
|
||||
});
|
||||
|
||||
if (!business) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Business not found",
|
||||
});
|
||||
}
|
||||
|
||||
if (business.createdById !== ctx.session.user.id) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You don't have permission to use this business",
|
||||
});
|
||||
}
|
||||
|
||||
return business;
|
||||
}
|
||||
|
||||
async function verifyClientAccess(ctx: InvoiceRouterContext, clientId: string) {
|
||||
const client = await ctx.db.query.clients.findFirst({
|
||||
where: eq(clients.id, clientId),
|
||||
});
|
||||
|
||||
if (!client) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Client not found",
|
||||
});
|
||||
}
|
||||
|
||||
if (client.createdById !== ctx.session.user.id) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You don't have permission to use this client",
|
||||
});
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
const calculateInvoiceTotal = (
|
||||
items: Array<z.infer<typeof invoiceItemSchema>>,
|
||||
taxRate: number,
|
||||
@@ -162,46 +219,10 @@ export const invoicesRouter = createTRPCRouter({
|
||||
};
|
||||
|
||||
// Verify business exists and belongs to user (if provided)
|
||||
if (cleanInvoiceData.businessId) {
|
||||
const business = await ctx.db.query.businesses.findFirst({
|
||||
where: eq(businesses.id, cleanInvoiceData.businessId),
|
||||
});
|
||||
|
||||
if (!business) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Business not found",
|
||||
});
|
||||
}
|
||||
|
||||
if (business.createdById !== ctx.session.user.id) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message:
|
||||
"You don't have permission to create invoices for this business",
|
||||
});
|
||||
}
|
||||
}
|
||||
await verifyBusinessAccess(ctx, cleanInvoiceData.businessId);
|
||||
|
||||
// Verify client exists and belongs to user
|
||||
const client = await ctx.db.query.clients.findFirst({
|
||||
where: eq(clients.id, cleanInvoiceData.clientId),
|
||||
});
|
||||
|
||||
if (!client) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Client not found",
|
||||
});
|
||||
}
|
||||
|
||||
if (client.createdById !== ctx.session.user.id) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message:
|
||||
"You don't have permission to create invoices for this client",
|
||||
});
|
||||
}
|
||||
await verifyClientAccess(ctx, cleanInvoiceData.clientId);
|
||||
|
||||
const totalAmount = calculateInvoiceTotal(
|
||||
items,
|
||||
@@ -300,30 +321,12 @@ export const invoicesRouter = createTRPCRouter({
|
||||
cleanInvoiceData.businessId &&
|
||||
cleanInvoiceData.businessId.trim() !== ""
|
||||
) {
|
||||
const business = await ctx.db.query.businesses.findFirst({
|
||||
where: eq(businesses.id, cleanInvoiceData.businessId),
|
||||
});
|
||||
|
||||
if (!business || business.createdById !== ctx.session.user.id) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You don't have permission to use this business",
|
||||
});
|
||||
}
|
||||
await verifyBusinessAccess(ctx, cleanInvoiceData.businessId);
|
||||
}
|
||||
|
||||
// If client is being updated, verify it belongs to user
|
||||
if (cleanInvoiceData.clientId) {
|
||||
const client = await ctx.db.query.clients.findFirst({
|
||||
where: eq(clients.id, cleanInvoiceData.clientId),
|
||||
});
|
||||
|
||||
if (!client || client.createdById !== ctx.session.user.id) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You don't have permission to use this client",
|
||||
});
|
||||
}
|
||||
await verifyClientAccess(ctx, cleanInvoiceData.clientId);
|
||||
}
|
||||
|
||||
await ctx.db.transaction(async (tx) => {
|
||||
@@ -524,4 +527,69 @@ export const invoicesRouter = createTRPCRouter({
|
||||
|
||||
return { success: true, deleted: ownedIds.length };
|
||||
}),
|
||||
|
||||
previewPdf: protectedProcedure
|
||||
.input(createInvoiceSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
try {
|
||||
const businessId =
|
||||
input.businessId && input.businessId.trim() !== ""
|
||||
? input.businessId
|
||||
: null;
|
||||
const [client, business, settings] = await Promise.all([
|
||||
verifyClientAccess(ctx, input.clientId),
|
||||
verifyBusinessAccess(ctx, businessId),
|
||||
ctx.db.query.platformSettings.findFirst({
|
||||
where: eq(platformSettings.id, "global"),
|
||||
}),
|
||||
]);
|
||||
|
||||
const totalAmount = calculateInvoiceTotal(input.items, input.taxRate);
|
||||
const pdfBlob = await generateInvoicePDFBlob(
|
||||
{
|
||||
invoiceNumber: input.invoiceNumber,
|
||||
invoicePrefix: input.invoicePrefix,
|
||||
issueDate: input.issueDate,
|
||||
dueDate: input.dueDate,
|
||||
status: input.status,
|
||||
totalAmount,
|
||||
taxRate: input.taxRate,
|
||||
currency: input.currency,
|
||||
notes: input.notes,
|
||||
client,
|
||||
business,
|
||||
items: input.items.map((item) => ({
|
||||
date: item.date,
|
||||
description: item.description,
|
||||
hours: item.hours,
|
||||
rate: item.rate,
|
||||
amount: item.hours * item.rate,
|
||||
})),
|
||||
},
|
||||
{
|
||||
pdfTemplate: settings?.pdfTemplate as
|
||||
| "classic"
|
||||
| "minimal"
|
||||
| undefined,
|
||||
pdfAccentColor: settings?.pdfAccentColor,
|
||||
pdfFooterText: settings?.pdfFooterText,
|
||||
pdfShowLogo: settings?.pdfShowLogo,
|
||||
pdfShowPageNumbers: settings?.pdfShowPageNumbers,
|
||||
},
|
||||
);
|
||||
|
||||
const buffer = Buffer.from(await pdfBlob.arrayBuffer());
|
||||
return {
|
||||
contentType: "application/pdf",
|
||||
base64: buffer.toString("base64"),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) throw error;
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to generate PDF preview",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user