This repository has been archived on 2026-08-17. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
beenvoice-web/src/server/api/routers/businesses.ts
T

555 lines
16 KiB
TypeScript

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<number>`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;
}),
});