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:
2025-07-12 01:42:43 -04:00
parent 2d217fab47
commit a1b40e7a9c
75 changed files with 8821 additions and 1803 deletions

View File

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

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

View File

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

View File

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

View File

@@ -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!";
}),
});

View File

@@ -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),

View File

@@ -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 });

View File

@@ -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
],
);