Files
beenvoice-web/src/server/api/routers/clients.ts
T

255 lines
6.8 KiB
TypeScript

import { z } from "zod";
import { eq } from "drizzle-orm";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { clients, invoices } from "~/server/db/schema";
import { TRPCError } from "@trpc/server";
const createClientSchema = z.object({
name: z.string().min(1, "Name is required").max(255, "Name is too long"),
email: z.string().email("Invalid email").optional().or(z.literal("")),
phone: z
.string()
.max(50, "Phone number is too long")
.optional()
.or(z.literal("")),
addressLine1: z
.string()
.max(255, "Address is too long")
.optional()
.or(z.literal("")),
addressLine2: z
.string()
.max(255, "Address is too long")
.optional()
.or(z.literal("")),
city: z
.string()
.max(100, "City name is too long")
.optional()
.or(z.literal("")),
state: z
.string()
.max(50, "State name is too long")
.optional()
.or(z.literal("")),
postalCode: z
.string()
.max(20, "Postal code is too long")
.optional()
.or(z.literal("")),
country: z
.string()
.max(100, "Country name is too long")
.optional()
.or(z.literal("")),
defaultHourlyRate: z.number().min(0, "Rate must be positive").optional(),
});
const updateClientSchema = createClientSchema.partial().extend({
id: z.string(),
});
export const clientsRouter = createTRPCRouter({
getAll: protectedProcedure.query(async ({ ctx }) => {
try {
return await ctx.db.query.clients.findMany({
where: eq(clients.createdById, ctx.session.user.id),
orderBy: (clients, { desc }) => [desc(clients.createdAt)],
});
} catch (error) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to fetch clients",
cause: error,
});
}
}),
getById: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
try {
const client = await ctx.db.query.clients.findFirst({
where: eq(clients.id, input.id),
with: {
invoices: {
orderBy: (invoices, { desc }) => [desc(invoices.createdAt)],
},
},
});
if (!client) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Client not found",
});
}
// Check if user owns this client
if (client.createdById !== ctx.session.user.id) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You don't have permission to view this client",
});
}
return client;
} catch (error) {
if (error instanceof TRPCError) throw error;
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to fetch client",
cause: error,
});
}
}),
create: protectedProcedure
.input(createClientSchema)
.mutation(async ({ ctx, input }) => {
try {
// Clean up empty strings to null, but preserve required fields
const cleanInput = Object.fromEntries(
Object.entries(input).map(([key, value]) => [
key,
value === "" ? null : value,
]),
);
const [client] = await ctx.db
.insert(clients)
.values({
name: input.name, // Ensure name is included
...cleanInput,
createdById: ctx.session.user.id,
})
.returning();
if (!client) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to create client",
});
}
return client;
} catch (error) {
if (error instanceof TRPCError) throw error;
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to create client",
cause: error,
});
}
}),
update: protectedProcedure
.input(updateClientSchema)
.mutation(async ({ ctx, input }) => {
try {
const { id, ...data } = input;
// Verify client exists and belongs to user
const existingClient = await ctx.db.query.clients.findFirst({
where: eq(clients.id, id),
});
if (!existingClient) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Client not found",
});
}
if (existingClient.createdById !== ctx.session.user.id) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You don't have permission to update this client",
});
}
// Clean up empty strings to null
const cleanData = Object.fromEntries(
Object.entries(data).map(([key, value]) => [
key,
value === "" ? null : value,
]),
);
const [updatedClient] = await ctx.db
.update(clients)
.set({
...cleanData,
updatedAt: new Date(),
})
.where(eq(clients.id, id))
.returning();
if (!updatedClient) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to update client",
});
}
return updatedClient;
} catch (error) {
if (error instanceof TRPCError) throw error;
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to update client",
cause: error,
});
}
}),
delete: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
try {
// Verify client exists and belongs to user
const client = await ctx.db.query.clients.findFirst({
where: eq(clients.id, input.id),
});
if (!client) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Client not found",
});
}
if (client.createdById !== ctx.session.user.id) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You don't have permission to delete this client",
});
}
// Check if client has invoices
const clientInvoices = await ctx.db.query.invoices.findMany({
where: eq(invoices.clientId, input.id),
});
if (clientInvoices.length > 0) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"Cannot delete client with existing invoices. Please delete or reassign the invoices first.",
});
}
await ctx.db.delete(clients).where(eq(clients.id, input.id));
return { success: true };
} catch (error) {
if (error instanceof TRPCError) throw error;
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to delete client",
cause: error,
});
}
}),
});