mirror of
https://github.com/soconnor0919/beenvoice.git
synced 2026-02-05 00:06:36 -05:00
Add Turso/Vercel deployment configuration
- Updated database connection to support Turso auth token - Added vercel.json with bun build configuration - Updated environment schema for production deployment - Added new features and components for production readiness
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { clientsRouter } from "~/server/api/routers/clients";
|
||||
import { businessesRouter } from "~/server/api/routers/businesses";
|
||||
import { invoicesRouter } from "~/server/api/routers/invoices";
|
||||
import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc";
|
||||
|
||||
@@ -9,6 +10,7 @@ import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc";
|
||||
*/
|
||||
export const appRouter = createTRPCRouter({
|
||||
clients: clientsRouter,
|
||||
businesses: businessesRouter,
|
||||
invoices: invoicesRouter,
|
||||
});
|
||||
|
||||
|
||||
208
src/server/api/routers/businesses.ts
Normal file
208
src/server/api/routers/businesses.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import { z } from "zod";
|
||||
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";
|
||||
|
||||
const businessSchema = z.object({
|
||||
name: z.string().min(1, "Business name is required"),
|
||||
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("")),
|
||||
isDefault: z.boolean().default(false),
|
||||
});
|
||||
|
||||
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({
|
||||
...input,
|
||||
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(
|
||||
and(
|
||||
eq(businesses.createdById, ctx.session.user.id),
|
||||
eq(businesses.id, id)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const [updatedBusiness] = await ctx.db
|
||||
.update(businesses)
|
||||
.set({
|
||||
...updateData,
|
||||
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;
|
||||
}),
|
||||
});
|
||||
@@ -1,18 +1,19 @@
|
||||
import { z } from "zod";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
import { clients } from "~/server/db/schema";
|
||||
import { clients, invoices } from "~/server/db/schema";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
const createClientSchema = z.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
email: z.string().email("Invalid email").optional(),
|
||||
phone: z.string().optional(),
|
||||
addressLine1: z.string().optional(),
|
||||
addressLine2: z.string().optional(),
|
||||
city: z.string().optional(),
|
||||
state: z.string().optional(),
|
||||
postalCode: z.string().optional(),
|
||||
country: z.string().optional(),
|
||||
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("")),
|
||||
});
|
||||
|
||||
const updateClientSchema = createClientSchema.partial().extend({
|
||||
@@ -21,16 +22,25 @@ const updateClientSchema = createClientSchema.partial().extend({
|
||||
|
||||
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 }) => {
|
||||
return await ctx.db.query.clients.findFirst({
|
||||
try {
|
||||
const client = await ctx.db.query.clients.findFirst({
|
||||
where: eq(clients.id, input.id),
|
||||
with: {
|
||||
invoices: {
|
||||
@@ -38,33 +48,174 @@ export const clientsRouter = createTRPCRouter({
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
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 }) => {
|
||||
return await ctx.db.insert(clients).values({
|
||||
...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;
|
||||
return await ctx.db
|
||||
|
||||
// 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({
|
||||
...data,
|
||||
...cleanData,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(clients.id, id));
|
||||
.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 }) => {
|
||||
return await ctx.db.delete(clients).where(eq(clients.id, input.id));
|
||||
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,
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
import { z } from "zod";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
import { invoices, invoiceItems, clients } from "~/server/db/schema";
|
||||
import { invoices, invoiceItems, clients, businesses } from "~/server/db/schema";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
const invoiceItemSchema = z.object({
|
||||
date: z.date(),
|
||||
@@ -12,11 +13,13 @@ const invoiceItemSchema = z.object({
|
||||
|
||||
const createInvoiceSchema = z.object({
|
||||
invoiceNumber: z.string().min(1, "Invoice number is required"),
|
||||
businessId: z.string().min(1, "Business is required").optional(),
|
||||
clientId: z.string().min(1, "Client is required"),
|
||||
issueDate: z.date(),
|
||||
dueDate: z.date(),
|
||||
status: z.enum(["draft", "sent", "paid", "overdue"]).default("draft"),
|
||||
notes: z.string().optional(),
|
||||
taxRate: z.number().min(0).max(100).default(0),
|
||||
items: z.array(invoiceItemSchema).min(1, "At least one item is required"),
|
||||
});
|
||||
|
||||
@@ -24,39 +27,123 @@ const updateInvoiceSchema = createInvoiceSchema.partial().extend({
|
||||
id: z.string(),
|
||||
});
|
||||
|
||||
const updateStatusSchema = z.object({
|
||||
id: z.string(),
|
||||
status: z.enum(["draft", "sent", "paid", "overdue"]),
|
||||
});
|
||||
|
||||
export const invoicesRouter = createTRPCRouter({
|
||||
getAll: protectedProcedure.query(async ({ ctx }) => {
|
||||
try {
|
||||
return await ctx.db.query.invoices.findMany({
|
||||
where: eq(invoices.createdById, ctx.session.user.id),
|
||||
with: {
|
||||
business: true,
|
||||
client: true,
|
||||
items: true,
|
||||
},
|
||||
orderBy: (invoices, { desc }) => [desc(invoices.createdAt)],
|
||||
});
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to fetch invoices",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
getById: protectedProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return await ctx.db.query.invoices.findFirst({
|
||||
try {
|
||||
const invoice = await ctx.db.query.invoices.findFirst({
|
||||
where: eq(invoices.id, input.id),
|
||||
with: {
|
||||
business: true,
|
||||
client: true,
|
||||
items: {
|
||||
orderBy: (items, { asc }) => [asc(items.date)],
|
||||
orderBy: (items, { asc }) => [asc(items.position)],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!invoice) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Invoice not found",
|
||||
});
|
||||
}
|
||||
|
||||
// Check if user owns this invoice
|
||||
if (invoice.createdById !== ctx.session.user.id) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You don't have permission to view this invoice",
|
||||
});
|
||||
}
|
||||
|
||||
return invoice;
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) throw error;
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to fetch invoice",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
create: protectedProcedure
|
||||
.input(createInvoiceSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
try {
|
||||
const { items, ...invoiceData } = input;
|
||||
|
||||
// Verify business exists and belongs to user (if provided)
|
||||
if (invoiceData.businessId) {
|
||||
const business = await ctx.db.query.businesses.findFirst({
|
||||
where: eq(businesses.id, invoiceData.businessId),
|
||||
});
|
||||
|
||||
if (!business) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Business not found",
|
||||
});
|
||||
}
|
||||
|
||||
if (business.createdById !== ctx.session.user.id) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You don't have permission to create invoices for this business",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Verify client exists and belongs to user
|
||||
const client = await ctx.db.query.clients.findFirst({
|
||||
where: eq(clients.id, invoiceData.clientId),
|
||||
});
|
||||
|
||||
if (!client) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Client not found",
|
||||
});
|
||||
}
|
||||
|
||||
if (client.createdById !== ctx.session.user.id) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You don't have permission to create invoices for this client",
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate total amount
|
||||
const totalAmount = items.reduce((sum, item) => sum + (item.hours * item.rate), 0);
|
||||
// Calculate subtotal and tax
|
||||
const subtotal = items.reduce((sum, item) => sum + (item.hours * item.rate), 0);
|
||||
const taxAmount = (subtotal * invoiceData.taxRate) / 100;
|
||||
const totalAmount = subtotal + taxAmount;
|
||||
|
||||
// Create invoice
|
||||
const [invoice] = await ctx.db.insert(invoices).values({
|
||||
@@ -66,29 +153,91 @@ export const invoicesRouter = createTRPCRouter({
|
||||
}).returning();
|
||||
|
||||
if (!invoice) {
|
||||
throw new Error("Failed to create invoice");
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to create invoice",
|
||||
});
|
||||
}
|
||||
|
||||
// Create invoice items
|
||||
const itemsToInsert = items.map(item => ({
|
||||
const itemsToInsert = items.map((item, idx) => ({
|
||||
...item,
|
||||
invoiceId: invoice.id,
|
||||
amount: item.hours * item.rate,
|
||||
position: idx,
|
||||
}));
|
||||
|
||||
await ctx.db.insert(invoiceItems).values(itemsToInsert);
|
||||
|
||||
return invoice;
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) throw error;
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to create invoice",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
update: protectedProcedure
|
||||
.input(updateInvoiceSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
try {
|
||||
const { id, items, ...invoiceData } = input;
|
||||
|
||||
// Verify invoice exists and belongs to user
|
||||
const existingInvoice = await ctx.db.query.invoices.findFirst({
|
||||
where: eq(invoices.id, id),
|
||||
});
|
||||
|
||||
if (!existingInvoice) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Invoice not found",
|
||||
});
|
||||
}
|
||||
|
||||
if (existingInvoice.createdById !== ctx.session.user.id) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You don't have permission to update this invoice",
|
||||
});
|
||||
}
|
||||
|
||||
// If business is being updated, verify it belongs to user
|
||||
if (invoiceData.businessId) {
|
||||
const business = await ctx.db.query.businesses.findFirst({
|
||||
where: eq(businesses.id, invoiceData.businessId),
|
||||
});
|
||||
|
||||
if (!business || business.createdById !== ctx.session.user.id) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You don't have permission to use this business",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// If client is being updated, verify it belongs to user
|
||||
if (invoiceData.clientId) {
|
||||
const client = await ctx.db.query.clients.findFirst({
|
||||
where: eq(clients.id, invoiceData.clientId),
|
||||
});
|
||||
|
||||
if (!client || client.createdById !== ctx.session.user.id) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You don't have permission to use this client",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (items) {
|
||||
// Calculate total amount
|
||||
const totalAmount = items.reduce((sum, item) => sum + (item.hours * item.rate), 0);
|
||||
// Calculate subtotal and tax
|
||||
const subtotal = items.reduce((sum, item) => sum + (item.hours * item.rate), 0);
|
||||
const taxAmount = (subtotal * (invoiceData.taxRate ?? existingInvoice.taxRate)) / 100;
|
||||
const totalAmount = subtotal + taxAmount;
|
||||
|
||||
// Update invoice
|
||||
await ctx.db
|
||||
@@ -103,10 +252,11 @@ export const invoicesRouter = createTRPCRouter({
|
||||
// Delete existing items and create new ones
|
||||
await ctx.db.delete(invoiceItems).where(eq(invoiceItems.invoiceId, id));
|
||||
|
||||
const itemsToInsert = items.map(item => ({
|
||||
const itemsToInsert = items.map((item, idx) => ({
|
||||
...item,
|
||||
invoiceId: id,
|
||||
amount: item.hours * item.rate,
|
||||
position: idx,
|
||||
}));
|
||||
|
||||
await ctx.db.insert(invoiceItems).values(itemsToInsert);
|
||||
@@ -122,27 +272,92 @@ export const invoicesRouter = createTRPCRouter({
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) throw error;
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to update invoice",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
delete: protectedProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
try {
|
||||
// Verify invoice exists and belongs to user
|
||||
const invoice = await ctx.db.query.invoices.findFirst({
|
||||
where: eq(invoices.id, input.id),
|
||||
});
|
||||
|
||||
if (!invoice) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Invoice not found",
|
||||
});
|
||||
}
|
||||
|
||||
if (invoice.createdById !== ctx.session.user.id) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You don't have permission to delete this invoice",
|
||||
});
|
||||
}
|
||||
|
||||
// Items will be deleted automatically due to cascade
|
||||
return await ctx.db.delete(invoices).where(eq(invoices.id, input.id));
|
||||
await ctx.db.delete(invoices).where(eq(invoices.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 invoice",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
updateStatus: protectedProcedure
|
||||
.input(z.object({
|
||||
id: z.string(),
|
||||
status: z.enum(["draft", "sent", "paid", "overdue"]),
|
||||
}))
|
||||
.input(updateStatusSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return await ctx.db
|
||||
try {
|
||||
// Verify invoice exists and belongs to user
|
||||
const invoice = await ctx.db.query.invoices.findFirst({
|
||||
where: eq(invoices.id, input.id),
|
||||
});
|
||||
|
||||
if (!invoice) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Invoice not found",
|
||||
});
|
||||
}
|
||||
|
||||
if (invoice.createdById !== ctx.session.user.id) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You don't have permission to update this invoice",
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.db
|
||||
.update(invoices)
|
||||
.set({
|
||||
status: input.status,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(invoices.id, input.id));
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) throw error;
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to update invoice status",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
@@ -1,39 +0,0 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
createTRPCRouter,
|
||||
protectedProcedure,
|
||||
publicProcedure,
|
||||
} from "~/server/api/trpc";
|
||||
import { posts } from "~/server/db/schema";
|
||||
|
||||
export const postRouter = createTRPCRouter({
|
||||
hello: publicProcedure
|
||||
.input(z.object({ text: z.string() }))
|
||||
.query(({ input }) => {
|
||||
return {
|
||||
greeting: `Hello ${input.text}`,
|
||||
};
|
||||
}),
|
||||
|
||||
create: protectedProcedure
|
||||
.input(z.object({ name: z.string().min(1) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ctx.db.insert(posts).values({
|
||||
name: input.name,
|
||||
createdById: ctx.session.user.id,
|
||||
});
|
||||
}),
|
||||
|
||||
getLatest: protectedProcedure.query(async ({ ctx }) => {
|
||||
const post = await ctx.db.query.posts.findFirst({
|
||||
orderBy: (posts, { desc }) => [desc(posts.createdAt)],
|
||||
});
|
||||
|
||||
return post ?? null;
|
||||
}),
|
||||
|
||||
getSecretMessage: protectedProcedure.query(() => {
|
||||
return "you can now see this secret message!";
|
||||
}),
|
||||
});
|
||||
@@ -53,6 +53,9 @@ export const authConfig = {
|
||||
if (!credentials?.email || !credentials?.password) {
|
||||
return null;
|
||||
}
|
||||
if (typeof credentials.email !== 'string' || typeof credentials.password !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.email, credentials.email),
|
||||
|
||||
@@ -13,7 +13,11 @@ const globalForDb = globalThis as unknown as {
|
||||
};
|
||||
|
||||
export const client =
|
||||
globalForDb.client ?? createClient({ url: env.DATABASE_URL });
|
||||
globalForDb.client ??
|
||||
createClient({
|
||||
url: env.DATABASE_URL,
|
||||
authToken: env.DATABASE_AUTH_TOKEN,
|
||||
});
|
||||
if (env.NODE_ENV !== "production") globalForDb.client = client;
|
||||
|
||||
export const db = drizzle(client, { schema });
|
||||
|
||||
@@ -27,6 +27,7 @@ export const users = createTable("user", (d) => ({
|
||||
export const usersRelations = relations(users, ({ many }) => ({
|
||||
accounts: many(accounts),
|
||||
clients: many(clients),
|
||||
businesses: many(businesses),
|
||||
invoices: many(invoices),
|
||||
}));
|
||||
|
||||
@@ -127,6 +128,50 @@ export const clientsRelations = relations(clients, ({ one, many }) => ({
|
||||
invoices: many(invoices),
|
||||
}));
|
||||
|
||||
export const businesses = createTable(
|
||||
"business",
|
||||
(d) => ({
|
||||
id: d
|
||||
.text({ length: 255 })
|
||||
.notNull()
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
name: d.text({ length: 255 }).notNull(),
|
||||
email: d.text({ length: 255 }),
|
||||
phone: d.text({ length: 50 }),
|
||||
addressLine1: d.text({ length: 255 }),
|
||||
addressLine2: d.text({ length: 255 }),
|
||||
city: d.text({ length: 100 }),
|
||||
state: d.text({ length: 50 }),
|
||||
postalCode: d.text({ length: 20 }),
|
||||
country: d.text({ length: 100 }),
|
||||
website: d.text({ length: 255 }),
|
||||
taxId: d.text({ length: 100 }),
|
||||
logoUrl: d.text({ length: 500 }),
|
||||
isDefault: d.integer({ mode: "boolean" }).default(false),
|
||||
createdById: d
|
||||
.text({ length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
createdAt: d
|
||||
.integer({ mode: "timestamp" })
|
||||
.default(sql`(unixepoch())`)
|
||||
.notNull(),
|
||||
updatedAt: d.integer({ mode: "timestamp" }).$onUpdate(() => new Date()),
|
||||
}),
|
||||
(t) => [
|
||||
index("business_created_by_idx").on(t.createdById),
|
||||
index("business_name_idx").on(t.name),
|
||||
index("business_email_idx").on(t.email),
|
||||
index("business_is_default_idx").on(t.isDefault),
|
||||
],
|
||||
);
|
||||
|
||||
export const businessesRelations = relations(businesses, ({ one, many }) => ({
|
||||
createdBy: one(users, { fields: [businesses.createdById], references: [users.id] }),
|
||||
invoices: many(invoices),
|
||||
}));
|
||||
|
||||
export const invoices = createTable(
|
||||
"invoice",
|
||||
(d) => ({
|
||||
@@ -136,6 +181,9 @@ export const invoices = createTable(
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
invoiceNumber: d.text({ length: 100 }).notNull(),
|
||||
businessId: d
|
||||
.text({ length: 255 })
|
||||
.references(() => businesses.id),
|
||||
clientId: d
|
||||
.text({ length: 255 })
|
||||
.notNull()
|
||||
@@ -144,6 +192,7 @@ export const invoices = createTable(
|
||||
dueDate: d.integer({ mode: "timestamp" }).notNull(),
|
||||
status: d.text({ length: 50 }).notNull().default("draft"), // draft, sent, paid, overdue
|
||||
totalAmount: d.real().notNull().default(0),
|
||||
taxRate: d.real().notNull().default(0.00),
|
||||
notes: d.text({ length: 1000 }),
|
||||
createdById: d
|
||||
.text({ length: 255 })
|
||||
@@ -156,6 +205,7 @@ export const invoices = createTable(
|
||||
updatedAt: d.integer({ mode: "timestamp" }).$onUpdate(() => new Date()),
|
||||
}),
|
||||
(t) => [
|
||||
index("invoice_business_id_idx").on(t.businessId),
|
||||
index("invoice_client_id_idx").on(t.clientId),
|
||||
index("invoice_created_by_idx").on(t.createdById),
|
||||
index("invoice_number_idx").on(t.invoiceNumber),
|
||||
@@ -164,6 +214,7 @@ export const invoices = createTable(
|
||||
);
|
||||
|
||||
export const invoicesRelations = relations(invoices, ({ one, many }) => ({
|
||||
business: one(businesses, { fields: [invoices.businessId], references: [businesses.id] }),
|
||||
client: one(clients, { fields: [invoices.clientId], references: [clients.id] }),
|
||||
createdBy: one(users, { fields: [invoices.createdById], references: [users.id] }),
|
||||
items: many(invoiceItems),
|
||||
@@ -186,6 +237,7 @@ export const invoiceItems = createTable(
|
||||
hours: d.real().notNull(),
|
||||
rate: d.real().notNull(),
|
||||
amount: d.real().notNull(),
|
||||
position: d.integer().notNull().default(0), // NEW: position for ordering
|
||||
createdAt: d
|
||||
.integer({ mode: "timestamp" })
|
||||
.default(sql`(unixepoch())`)
|
||||
@@ -194,6 +246,7 @@ export const invoiceItems = createTable(
|
||||
(t) => [
|
||||
index("invoice_item_invoice_id_idx").on(t.invoiceId),
|
||||
index("invoice_item_date_idx").on(t.date),
|
||||
index("invoice_item_position_idx").on(t.position), // NEW: index for position
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user