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 .string() .trim() .min(1, "Business name is required") .max(255, "Business name must be 255 characters or less"), nickname: z .string() .trim() .max(255, "Nickname must be 255 characters or less") .optional() .or(z.literal("")), email: z.string().email().optional().or(z.literal("")), phone: z.string().optional().or(z.literal("")), addressLine1: z.string().optional().or(z.literal("")), addressLine2: z.string().optional().or(z.literal("")), city: z.string().optional().or(z.literal("")), state: z.string().optional().or(z.literal("")), postalCode: z.string().optional().or(z.literal("")), country: z.string().optional().or(z.literal("")), 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), }); const emailConfigSchema = z.object({ resendApiKey: z .string() .min(1, "Resend API Key is required") .optional() .or(z.literal("")), resendDomain: z .string() .min(1, "Resend Domain is required") .optional() .or(z.literal("")), emailFromName: z.string().optional().or(z.literal("")), }); export const businessesRouter = createTRPCRouter({ // Get all businesses for the current user getAll: protectedProcedure.query(async ({ ctx }) => { const userBusinesses = await ctx.db .select() .from(businesses) .where(eq(businesses.createdById, ctx.session.user.id)) .orderBy(desc(businesses.isDefault), desc(businesses.createdAt)); return userBusinesses; }), // Get a single business by ID getById: protectedProcedure .input(z.object({ id: z.string() })) .query(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); return business[0]; }), // Get default business for the current user getDefault: protectedProcedure.query(async ({ ctx }) => { const defaultBusiness = await ctx.db .select() .from(businesses) .where( and( eq(businesses.createdById, ctx.session.user.id), eq(businesses.isDefault, true), ), ) .limit(1); return defaultBusiness[0]; }), // Create a new business create: protectedProcedure .input(businessSchema) .mutation(async ({ ctx, input }) => { // If this is the first business or isDefault is true, unset other defaults if (input.isDefault) { await ctx.db .update(businesses) .set({ isDefault: false }) .where(eq(businesses.createdById, ctx.session.user.id)); } const [newBusiness] = await ctx.db .insert(businesses) .values({ name: input.name.trim(), nickname: input.nickname && input.nickname.trim() !== "" ? input.nickname.trim() : null, email: input.email && input.email.trim() !== "" ? input.email.trim() : null, phone: input.phone && input.phone.trim() !== "" ? input.phone.trim() : null, addressLine1: input.addressLine1 && input.addressLine1.trim() !== "" ? input.addressLine1.trim() : null, addressLine2: input.addressLine2 && input.addressLine2.trim() !== "" ? input.addressLine2.trim() : null, city: input.city && input.city.trim() !== "" ? input.city.trim() : null, state: input.state && input.state.trim() !== "" ? input.state.trim() : null, postalCode: input.postalCode && input.postalCode.trim() !== "" ? input.postalCode.trim() : null, country: input.country && input.country.trim() !== "" ? input.country.trim() : null, website: input.website && input.website.trim() !== "" ? input.website.trim() : null, taxId: input.taxId && input.taxId.trim() !== "" ? input.taxId.trim() : null, logoUrl: input.logoUrl && input.logoUrl.trim() !== "" ? input.logoUrl.trim() : null, hideNameWithLogo: input.hideNameWithLogo ?? false, isDefault: input.isDefault ?? false, createdById: ctx.session.user.id, }) .returning(); return newBusiness; }), // Update an existing business update: protectedProcedure .input( z.object({ id: z.string(), ...businessSchema.shape, }), ) .mutation(async ({ ctx, input }) => { const { id, ...updateData } = input; // If setting this business as default, unset other defaults if (updateData.isDefault) { await ctx.db .update(businesses) .set({ isDefault: false }) .where(eq(businesses.createdById, ctx.session.user.id)); } const [updatedBusiness] = await ctx.db .update(businesses) .set({ name: (updateData.name ?? "").trim(), nickname: updateData.nickname && updateData.nickname.trim() !== "" ? updateData.nickname.trim() : null, email: updateData.email && updateData.email.trim() !== "" ? updateData.email.trim() : null, phone: updateData.phone && updateData.phone.trim() !== "" ? updateData.phone.trim() : null, addressLine1: updateData.addressLine1 && updateData.addressLine1.trim() !== "" ? updateData.addressLine1.trim() : null, addressLine2: updateData.addressLine2 && updateData.addressLine2.trim() !== "" ? updateData.addressLine2.trim() : null, city: updateData.city && updateData.city.trim() !== "" ? updateData.city.trim() : null, state: updateData.state && updateData.state.trim() !== "" ? updateData.state.trim() : null, postalCode: updateData.postalCode && updateData.postalCode.trim() !== "" ? updateData.postalCode.trim() : null, country: updateData.country && updateData.country.trim() !== "" ? updateData.country.trim() : null, website: updateData.website && updateData.website.trim() !== "" ? updateData.website.trim() : null, taxId: updateData.taxId && updateData.taxId.trim() !== "" ? updateData.taxId.trim() : null, logoUrl: updateData.logoUrl && updateData.logoUrl.trim() !== "" ? updateData.logoUrl.trim() : null, hideNameWithLogo: updateData.hideNameWithLogo ?? false, isDefault: updateData.isDefault ?? false, updatedAt: new Date(), }) .where( and( eq(businesses.id, id), eq(businesses.createdById, ctx.session.user.id), ), ) .returning(); if (!updatedBusiness) { throw new Error( "Business not found or you don't have permission to update it", ); } return updatedBusiness; }), // Delete a business delete: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { // Check if business exists and belongs to user 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[0]) { throw new Error( "Business not found or you don't have permission to delete it", ); } // Check if this business has any invoices const invoiceCount = await ctx.db .select({ count: sql`count(*)` }) .from(invoices) .where(eq(invoices.businessId, input.id)); if (invoiceCount[0] && invoiceCount[0].count > 0) { throw new Error( "Cannot delete business that has invoices. Please delete all invoices first.", ); } await ctx.db .delete(businesses) .where( and( eq(businesses.id, input.id), eq(businesses.createdById, ctx.session.user.id), ), ); return { success: true }; }), // Set a business as default setDefault: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { // First, unset all other defaults for this user await ctx.db .update(businesses) .set({ isDefault: false }) .where(eq(businesses.createdById, ctx.session.user.id)); // Then set the specified business as default const [updatedBusiness] = await ctx.db .update(businesses) .set({ isDefault: true }) .where( and( eq(businesses.id, input.id), eq(businesses.createdById, ctx.session.user.id), ), ) .returning(); if (!updatedBusiness) { throw new Error( "Business not found or you don't have permission to update it", ); } return updatedBusiness; }), // Update email configuration for a business updateEmailConfig: protectedProcedure .input( z.object({ id: z.string(), ...emailConfigSchema.shape, }), ) .mutation(async ({ ctx, input }) => { const { id, ...emailConfig } = input; // Validate that business belongs to user const business = await ctx.db .select() .from(businesses) .where( and( eq(businesses.id, id), eq(businesses.createdById, ctx.session.user.id), ), ) .limit(1); if (!business[0]) { throw new Error( "Business not found or you don't have permission to update it", ); } // Update email configuration const [updatedBusiness] = await ctx.db .update(businesses) .set({ resendApiKey: emailConfig.resendApiKey ?? null, resendDomain: emailConfig.resendDomain ?? null, emailFromName: emailConfig.emailFromName ?? null, updatedAt: new Date(), }) .where( and( eq(businesses.id, id), eq(businesses.createdById, ctx.session.user.id), ), ) .returning(); if (!updatedBusiness) { throw new Error("Failed to update email configuration"); } return { success: true, message: "Email configuration updated successfully", }; }), // Get email configuration for a business (without exposing the API key) getEmailConfig: protectedProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { const business = await ctx.db .select({ id: businesses.id, name: businesses.name, resendDomain: businesses.resendDomain, emailFromName: businesses.emailFromName, hasApiKey: businesses.resendApiKey, }) .from(businesses) .where( and( eq(businesses.id, input.id), eq(businesses.createdById, ctx.session.user.id), ), ) .limit(1); if (!business[0]) { throw new Error( "Business not found or you don't have permission to view it", ); } return { ...business[0], 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; }), });