255 lines
6.8 KiB
TypeScript
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,
|
|
});
|
|
}
|
|
}),
|
|
});
|