Archived
Add business logo branding support
This commit is contained in:
@@ -1,9 +1,20 @@
|
||||
import { z } from "zod";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
import { businesses } from "~/server/db/schema";
|
||||
import { eq, and, desc } from "drizzle-orm";
|
||||
import { invoices } from "~/server/db/schema";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { deleteObject, putObject } from "~/lib/object-storage";
|
||||
import { sanitizeSvg } from "~/lib/svg-sanitize";
|
||||
|
||||
const MAX_LOGO_BYTES = 5 * 1024 * 1024;
|
||||
const allowedLogoMimeTypes = new Set([
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/webp",
|
||||
"image/svg+xml",
|
||||
]);
|
||||
|
||||
const businessSchema = z.object({
|
||||
name: z
|
||||
@@ -28,6 +39,7 @@ const businessSchema = z.object({
|
||||
website: z.string().url().optional().or(z.literal("")),
|
||||
taxId: z.string().optional().or(z.literal("")),
|
||||
logoUrl: z.string().optional().or(z.literal("")),
|
||||
hideNameWithLogo: z.boolean().default(false),
|
||||
isDefault: z.boolean().default(false),
|
||||
});
|
||||
|
||||
@@ -153,6 +165,7 @@ export const businessesRouter = createTRPCRouter({
|
||||
input.logoUrl && input.logoUrl.trim() !== ""
|
||||
? input.logoUrl.trim()
|
||||
: null,
|
||||
hideNameWithLogo: input.hideNameWithLogo ?? false,
|
||||
isDefault: input.isDefault ?? false,
|
||||
createdById: ctx.session.user.id,
|
||||
})
|
||||
@@ -232,6 +245,7 @@ export const businessesRouter = createTRPCRouter({
|
||||
updateData.logoUrl && updateData.logoUrl.trim() !== ""
|
||||
? updateData.logoUrl.trim()
|
||||
: null,
|
||||
hideNameWithLogo: updateData.hideNameWithLogo ?? false,
|
||||
isDefault: updateData.isDefault ?? false,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
@@ -417,4 +431,124 @@ export const businessesRouter = createTRPCRouter({
|
||||
hasApiKey: !!business[0].hasApiKey,
|
||||
};
|
||||
}),
|
||||
|
||||
// Upload (or replace) a business logo, shown on invoices
|
||||
uploadLogo: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
filename: z.string().min(1).max(255),
|
||||
mimeType: z.string().min(1).max(100),
|
||||
data: z.string().min(1),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const [business] = await ctx.db
|
||||
.select()
|
||||
.from(businesses)
|
||||
.where(
|
||||
and(
|
||||
eq(businesses.id, input.id),
|
||||
eq(businesses.createdById, ctx.session.user.id),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!business) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Business not found or you don't have permission to update it",
|
||||
});
|
||||
}
|
||||
|
||||
const mimeType = input.mimeType.toLowerCase().split(";")[0]!.trim();
|
||||
if (!allowedLogoMimeTypes.has(mimeType)) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Logo must be a PNG, JPEG, WebP, or SVG image",
|
||||
});
|
||||
}
|
||||
|
||||
let body = Buffer.from(input.data, "base64");
|
||||
if (!body.length || body.length > MAX_LOGO_BYTES) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Logo must be between 1 byte and 5MB",
|
||||
});
|
||||
}
|
||||
|
||||
if (mimeType === "image/svg+xml") {
|
||||
body = Buffer.from(sanitizeSvg(body.toString("utf8")), "utf8");
|
||||
}
|
||||
|
||||
const safeName = input.filename.replace(/[^a-zA-Z0-9._-]/g, "_");
|
||||
const storageKey = `logos/${ctx.session.user.id}/${business.id}/${crypto.randomUUID()}-${safeName}`;
|
||||
const previousStorageKey = business.logoStorageKey;
|
||||
|
||||
try {
|
||||
await putObject(storageKey, body, mimeType);
|
||||
} catch (error) {
|
||||
console.error("[businesses.uploadLogo] Failed to store logo", {
|
||||
backendError: error,
|
||||
businessId: business.id,
|
||||
});
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message:
|
||||
"Logo storage is unavailable. Check the object-storage service and try again.",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
|
||||
const [updatedBusiness] = await ctx.db
|
||||
.update(businesses)
|
||||
.set({
|
||||
logoStorageKey: storageKey,
|
||||
logoMimeType: mimeType,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(businesses.id, business.id))
|
||||
.returning();
|
||||
|
||||
if (previousStorageKey) {
|
||||
await deleteObject(previousStorageKey).catch(() => undefined);
|
||||
}
|
||||
|
||||
return updatedBusiness;
|
||||
}),
|
||||
|
||||
// Remove a business logo
|
||||
removeLogo: protectedProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const [business] = await ctx.db
|
||||
.select()
|
||||
.from(businesses)
|
||||
.where(
|
||||
and(
|
||||
eq(businesses.id, input.id),
|
||||
eq(businesses.createdById, ctx.session.user.id),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!business) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Business not found or you don't have permission to update it",
|
||||
});
|
||||
}
|
||||
|
||||
if (business.logoStorageKey) {
|
||||
await deleteObject(business.logoStorageKey).catch(() => undefined);
|
||||
}
|
||||
|
||||
const [updatedBusiness] = await ctx.db
|
||||
.update(businesses)
|
||||
.set({ logoStorageKey: null, logoMimeType: null, updatedAt: new Date() })
|
||||
.where(eq(businesses.id, business.id))
|
||||
.returning();
|
||||
|
||||
return updatedBusiness;
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import { invoices, platformSettings } from "~/server/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { env } from "~/env";
|
||||
import { NOREPLY_EMAIL } from "~/lib/app-email";
|
||||
import { getAppUrl } from "~/lib/app-url";
|
||||
import { getRequestOrigin } from "~/lib/app-url";
|
||||
import { generateInvoicePDFBlob } from "~/lib/pdf-export";
|
||||
import { generateInvoiceEmailTemplate } from "~/lib/email-templates";
|
||||
|
||||
@@ -88,26 +88,30 @@ export const emailRouter = createTRPCRouter({
|
||||
const settings = await ctx.db.query.platformSettings.findFirst({
|
||||
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: getRequestOrigin(ctx.headers) },
|
||||
);
|
||||
pdfBuffer = Buffer.from(await pdfBlob.arrayBuffer());
|
||||
|
||||
// Validate PDF was generated successfully
|
||||
@@ -165,7 +169,7 @@ export const emailRouter = createTRPCRouter({
|
||||
customMessage,
|
||||
userName,
|
||||
userEmail,
|
||||
baseUrl: getAppUrl(),
|
||||
baseUrl: getRequestOrigin(ctx.headers),
|
||||
});
|
||||
|
||||
// Determine Resend instance and email configuration to use
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { calculateLineItemAmount } from "~/lib/invoice-line-item";
|
||||
import { generateInvoicePDFBlob } from "~/lib/pdf-export";
|
||||
import { getRequestOrigin } from "~/lib/app-url";
|
||||
import { defaultDueDate, generateInvoiceNumber } from "~/lib/draft-invoice";
|
||||
import { Resend } from "resend";
|
||||
import { env } from "~/env";
|
||||
@@ -984,6 +985,7 @@ export const invoicesRouter = createTRPCRouter({
|
||||
pdfShowLogo: settings?.pdfShowLogo,
|
||||
pdfShowPageNumbers: settings?.pdfShowPageNumbers,
|
||||
},
|
||||
{ logoBaseUrl: getRequestOrigin(ctx.headers) },
|
||||
);
|
||||
|
||||
const buffer = Buffer.from(await pdfBlob.arrayBuffer());
|
||||
@@ -1046,7 +1048,29 @@ export const invoicesRouter = createTRPCRouter({
|
||||
where: eq(invoices.publicToken, input.token),
|
||||
with: {
|
||||
client: true,
|
||||
business: true,
|
||||
// Explicit allowlist: this is a publicProcedure — never let
|
||||
// secret fields (resendApiKey, resendDomain) reach an
|
||||
// unauthenticated caller via the business relation.
|
||||
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),
|
||||
|
||||
+16
-13
@@ -318,6 +318,9 @@ export const businesses = createTable(
|
||||
website: d.varchar({ length: 255 }),
|
||||
taxId: d.varchar({ length: 100 }),
|
||||
logoUrl: d.varchar({ length: 500 }),
|
||||
logoStorageKey: d.varchar({ length: 500 }),
|
||||
logoMimeType: d.varchar({ length: 100 }),
|
||||
hideNameWithLogo: d.boolean().default(false).notNull(),
|
||||
isDefault: d.boolean().default(false),
|
||||
// Email configuration for custom Resend setup
|
||||
resendApiKey: d.varchar({ length: 255 }),
|
||||
@@ -609,10 +612,7 @@ export const invoicePayments = createTable(
|
||||
amount: d.real().notNull(),
|
||||
currency: d.varchar({ length: 3 }).default("USD").notNull(),
|
||||
date: d.timestamp().notNull(),
|
||||
method: d
|
||||
.varchar({ length: 50 })
|
||||
.notNull()
|
||||
.default("other"), // cash | check | bank_transfer | credit_card | paypal | other
|
||||
method: d.varchar({ length: 50 }).notNull().default("other"), // cash | check | bank_transfer | credit_card | paypal | other
|
||||
notes: d.varchar({ length: 500 }),
|
||||
createdById: d
|
||||
.varchar({ length: 255 })
|
||||
@@ -629,16 +629,19 @@ export const invoicePayments = createTable(
|
||||
],
|
||||
);
|
||||
|
||||
export const invoicePaymentsRelations = relations(invoicePayments, ({ one }) => ({
|
||||
invoice: one(invoices, {
|
||||
fields: [invoicePayments.invoiceId],
|
||||
references: [invoices.id],
|
||||
export const invoicePaymentsRelations = relations(
|
||||
invoicePayments,
|
||||
({ one }) => ({
|
||||
invoice: one(invoices, {
|
||||
fields: [invoicePayments.invoiceId],
|
||||
references: [invoices.id],
|
||||
}),
|
||||
createdBy: one(users, {
|
||||
fields: [invoicePayments.createdById],
|
||||
references: [users.id],
|
||||
}),
|
||||
}),
|
||||
createdBy: one(users, {
|
||||
fields: [invoicePayments.createdById],
|
||||
references: [users.id],
|
||||
}),
|
||||
}));
|
||||
);
|
||||
|
||||
// ─── Recurring Invoices ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
Reference in New Issue
Block a user