Add business logo branding support

This commit is contained in:
2026-08-14 16:26:43 -04:00
parent 3f3b1362a9
commit 29d7b498ae
31 changed files with 974 additions and 154 deletions
+134
View File
@@ -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;
}),
});