Build fixes, email preview system

This commit is contained in:
2025-07-29 19:45:38 -04:00
parent e6791f8cb8
commit 9370d5c935
78 changed files with 5798 additions and 10397 deletions

View File

@@ -2,6 +2,7 @@ import { clientsRouter } from "~/server/api/routers/clients";
import { businessesRouter } from "~/server/api/routers/businesses";
import { invoicesRouter } from "~/server/api/routers/invoices";
import { settingsRouter } from "~/server/api/routers/settings";
import { emailRouter } from "~/server/api/routers/email";
import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc";
/**
@@ -14,6 +15,7 @@ export const appRouter = createTRPCRouter({
businesses: businessesRouter,
invoices: invoicesRouter,
settings: settingsRouter,
email: emailRouter,
});
// export type definition of API

View File

@@ -21,6 +21,20 @@ const businessSchema = z.object({
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 }) => {
@@ -208,4 +222,93 @@ export const businessesRouter = createTRPCRouter({
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,
};
}),
});

View File

@@ -0,0 +1,311 @@
import { z } from "zod";
import { Resend } from "resend";
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
import { invoices } from "~/server/db/schema";
import { eq } from "drizzle-orm";
import { env } from "~/env";
import { generateInvoicePDFBlob } from "~/lib/pdf-export";
import { generateInvoiceEmailTemplate } from "~/lib/email-templates";
// Default Resend instance - will be overridden if business has custom API key
const defaultResend = new Resend(env.RESEND_API_KEY);
export const emailRouter = createTRPCRouter({
sendInvoice: protectedProcedure
.input(
z.object({
invoiceId: z.string(),
customSubject: z.string().optional(),
customContent: z.string().optional(),
customMessage: z.string().optional(),
useHtml: z.boolean().default(false),
ccEmails: z.string().optional(),
bccEmails: z.string().optional(),
}),
)
.mutation(async ({ ctx, input }) => {
// Fetch invoice with relations
const invoice = await ctx.db.query.invoices.findFirst({
where: eq(invoices.id, input.invoiceId),
with: {
client: true,
business: true,
items: true,
},
});
if (!invoice) {
throw new Error("Invoice not found");
}
// Check if invoice belongs to the current user
if (invoice.createdById !== ctx.session.user.id) {
throw new Error("Unauthorized");
}
if (!invoice.client?.email) {
throw new Error("Client has no email address");
}
// Validate email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(invoice.client.email)) {
throw new Error("Invalid client email address format");
}
// Generate PDF for attachment
let pdfBuffer: Buffer;
try {
const pdfBlob = await generateInvoicePDFBlob(invoice);
pdfBuffer = Buffer.from(await pdfBlob.arrayBuffer());
// Validate PDF was generated successfully
if (pdfBuffer.length === 0) {
throw new Error("Generated PDF is empty");
}
} catch (pdfError) {
console.error("PDF generation error:", pdfError);
// Re-throw the original error with more context
if (pdfError instanceof Error) {
throw new Error(
`Failed to generate invoice PDF for attachment: ${pdfError.message}`,
);
}
throw new Error("Failed to generate invoice PDF for attachment");
}
// Create email content
const subject =
input.customSubject ??
`Invoice ${invoice.invoiceNumber} from ${invoice.business?.name ?? "Your Business"}`;
const userName =
invoice.business?.emailFromName ??
invoice.business?.name ??
ctx.session.user?.name ??
"Your Name";
const userEmail =
invoice.business?.email ?? ctx.session.user?.email ?? "";
// Generate branded email template
const emailTemplate = generateInvoiceEmailTemplate({
invoice: {
invoiceNumber: invoice.invoiceNumber,
issueDate: invoice.issueDate,
dueDate: invoice.dueDate,
status: invoice.status,
totalAmount: invoice.totalAmount,
taxRate: invoice.taxRate,
notes: invoice.notes,
client: {
name: invoice.client.name,
email: invoice.client.email,
},
business: invoice.business,
items: invoice.items,
},
customContent: input.customContent,
customMessage: input.customMessage,
userName,
userEmail,
baseUrl: process.env.VERCEL_URL
? `https://${process.env.VERCEL_URL}`
: process.env.NODE_ENV === "production"
? "https://beenvoice.app"
: "http://localhost:3000",
});
// Determine Resend instance and email configuration to use
let resendInstance: Resend;
let fromEmail: string;
console.log("Email configuration debug:");
console.log(
"- Business resendApiKey:",
invoice.business?.resendApiKey ? "***SET***" : "not set",
);
console.log("- Business resendDomain:", invoice.business?.resendDomain);
console.log("- System RESEND_DOMAIN:", env.RESEND_DOMAIN);
console.log("- Business email:", invoice.business?.email);
// Check if business has custom Resend configuration
if (invoice.business?.resendApiKey && invoice.business?.resendDomain) {
// Use business's custom Resend setup
resendInstance = new Resend(invoice.business.resendApiKey);
const fromName =
invoice.business.emailFromName ?? invoice.business.name ?? userName;
fromEmail = `${fromName} <noreply@${invoice.business.resendDomain}>`;
console.log("- Using business custom Resend configuration");
} else if (env.RESEND_DOMAIN) {
// Use system Resend configuration
resendInstance = defaultResend;
fromEmail = `noreply@${env.RESEND_DOMAIN}`;
console.log("- Using system Resend configuration");
} else {
// Fallback to business email if no configured domains
resendInstance = defaultResend;
fromEmail = invoice.business?.email ?? "noreply@yourdomain.com";
console.log("- Using fallback configuration");
}
console.log("- Final fromEmail:", fromEmail);
// Prepare CC and BCC lists
const ccEmails: string[] = [];
const bccEmails: string[] = [];
// Parse CC emails from input
if (input.ccEmails) {
const ccList = input.ccEmails
.split(",")
.map((email) => email.trim())
.filter((email) => email);
for (const email of ccList) {
if (emailRegex.test(email)) {
ccEmails.push(email);
} else {
console.warn("Invalid CC email format, skipping:", email);
}
}
}
// Parse BCC emails from input
if (input.bccEmails) {
const bccList = input.bccEmails
.split(",")
.map((email) => email.trim())
.filter((email) => email);
for (const email of bccList) {
if (emailRegex.test(email)) {
bccEmails.push(email);
} else {
console.warn("Invalid BCC email format, skipping:", email);
}
}
}
// Include business email in CC if it exists and is different from sender
if (invoice.business?.email && invoice.business.email !== fromEmail) {
// Validate business email format before adding to CC
if (emailRegex.test(invoice.business.email)) {
ccEmails.push(invoice.business.email);
} else {
console.warn(
"Invalid business email format, skipping CC:",
invoice.business.email,
);
}
}
// Send email with Resend
let emailResult;
try {
// Send HTML email with plain text fallback
emailResult = await resendInstance.emails.send({
from: fromEmail,
to: [invoice.client?.email ?? ""],
cc: ccEmails.length > 0 ? ccEmails : undefined,
bcc: bccEmails.length > 0 ? bccEmails : undefined,
subject: subject,
html: emailTemplate.html,
text: emailTemplate.text,
headers: {
"X-Priority": "3",
"X-MSMail-Priority": "Normal",
"X-Mailer": "beenvoice",
"MIME-Version": "1.0",
},
attachments: [
{
filename: `invoice-${invoice.invoiceNumber}.pdf`,
content: pdfBuffer,
},
],
});
} catch (sendError) {
console.error("Resend API call failed:", sendError);
throw new Error(
"Email service is currently unavailable. Please try again later.",
);
}
// Enhanced error checking
if (emailResult.error) {
console.error("Resend API error:", emailResult.error);
const errorMsg = emailResult.error.message?.toLowerCase() ?? "";
// Provide more specific error messages based on error type
if (
errorMsg.includes("invalid email") ||
errorMsg.includes("invalid recipient")
) {
throw new Error("Invalid recipient email address");
} else if (
errorMsg.includes("domain") ||
errorMsg.includes("not verified")
) {
throw new Error(
"Email domain not verified. Please configure your Resend domain in business settings.",
);
} else if (
errorMsg.includes("rate limit") ||
errorMsg.includes("too many")
) {
throw new Error("Rate limit exceeded. Please try again later.");
} else if (
errorMsg.includes("api key") ||
errorMsg.includes("unauthorized")
) {
throw new Error(
"Email service configuration error. Please check your Resend API key.",
);
} else if (
errorMsg.includes("attachment") ||
errorMsg.includes("file size")
) {
throw new Error("Invoice PDF is too large to send via email.");
} else {
throw new Error(
`Email delivery failed: ${emailResult.error.message ?? "Unknown error"}`,
);
}
}
if (!emailResult.data?.id) {
throw new Error(
"Email was not sent successfully - no delivery ID received",
);
}
// Update invoice status to "sent" if it was draft
if (invoice.status === "draft") {
try {
await ctx.db
.update(invoices)
.set({
status: "sent",
updatedAt: new Date(),
})
.where(eq(invoices.id, input.invoiceId));
} catch (dbError) {
console.error("Failed to update invoice status:", dbError);
// Don't throw here - email was sent successfully, status update is secondary
console.warn(
`Invoice ${invoice.invoiceNumber} sent but status not updated`,
);
}
}
return {
success: true,
emailId: emailResult.data.id,
message: `Invoice sent successfully to ${invoice.client?.email ?? "client"}${ccEmails.length > 0 ? ` (CC: ${ccEmails.join(", ")})` : ""}${bccEmails.length > 0 ? ` (BCC: ${bccEmails.join(", ")})` : ""}`,
deliveryDetails: {
to: invoice.client?.email ?? "",
cc: ccEmails,
bcc: bccEmails,
sentAt: new Date().toISOString(),
},
};
}),
});

View File

@@ -26,7 +26,7 @@ const createInvoiceSchema = z.object({
clientId: z.string().min(1, "Client is required"),
issueDate: z.date(),
dueDate: z.date(),
status: z.enum(["draft", "sent", "paid", "overdue"]).default("draft"),
status: z.enum(["draft", "sent", "paid"]).default("draft"),
notes: z.string().optional().or(z.literal("")),
taxRate: z.number().min(0).max(100).default(0),
items: z.array(invoiceItemSchema).min(1, "At least one item is required"),
@@ -38,7 +38,7 @@ const updateInvoiceSchema = createInvoiceSchema.partial().extend({
const updateStatusSchema = z.object({
id: z.string(),
status: z.enum(["draft", "sent", "paid", "overdue"]),
status: z.enum(["draft", "sent", "paid"]),
});
export const invoicesRouter = createTRPCRouter({
@@ -237,7 +237,9 @@ export const invoicesRouter = createTRPCRouter({
const cleanInvoiceData = {
...invoiceData,
businessId:
!invoiceData.businessId || invoiceData.businessId.trim() === "" ? null : invoiceData.businessId,
!invoiceData.businessId || invoiceData.businessId.trim() === ""
? null
: invoiceData.businessId,
notes: invoiceData.notes === "" ? null : invoiceData.notes,
};
@@ -261,7 +263,10 @@ export const invoicesRouter = createTRPCRouter({
}
// If business is being updated, verify it belongs to user
if (cleanInvoiceData.businessId && cleanInvoiceData.businessId.trim() !== "") {
if (
cleanInvoiceData.businessId &&
cleanInvoiceData.businessId.trim() !== ""
) {
const business = await ctx.db.query.businesses.findFirst({
where: eq(businesses.id, cleanInvoiceData.businessId),
});
@@ -434,7 +439,10 @@ export const invoicesRouter = createTRPCRouter({
console.log("Status update completed successfully");
return { success: true };
return {
success: true,
message: `Invoice status updated to ${input.status}`,
};
} catch (error) {
console.error("UpdateStatus error:", error);
if (error instanceof TRPCError) throw error;

View File

@@ -1,5 +1,5 @@
import { z } from "zod";
import { eq, and } from "drizzle-orm";
import { eq } from "drizzle-orm";
import bcrypt from "bcryptjs";
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
import {
@@ -40,7 +40,7 @@ const BusinessBackupSchema = z.object({
});
const InvoiceItemBackupSchema = z.object({
date: z.date(),
date: z.string().transform((str) => new Date(str)),
description: z.string(),
hours: z.number(),
rate: z.number(),
@@ -52,8 +52,8 @@ const InvoiceBackupSchema = z.object({
invoiceNumber: z.string(),
businessName: z.string().optional(),
clientName: z.string(),
issueDate: z.date(),
dueDate: z.date(),
issueDate: z.string().transform((str) => new Date(str)),
dueDate: z.string().transform((str) => new Date(str)),
status: z.string().default("draft"),
totalAmount: z.number().default(0),
taxRate: z.number().default(0),
@@ -137,7 +137,7 @@ export const settingsRouter = createTRPCRouter({
},
});
if (!user || !user.password) {
if (!user?.password) {
throw new Error("User not found or no password set");
}

View File

@@ -47,13 +47,16 @@ export const authConfig = {
name: "credentials",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" }
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) {
return null;
}
if (typeof credentials.email !== 'string' || typeof credentials.password !== 'string') {
if (
typeof credentials.email !== "string" ||
typeof credentials.password !== "string"
) {
return null;
}
@@ -61,11 +64,14 @@ export const authConfig = {
where: eq(users.email, credentials.email),
});
if (!user || !user.password) {
if (!user?.password) {
return null;
}
const isPasswordValid = await bcrypt.compare(credentials.password, user.password);
const isPasswordValid = await bcrypt.compare(
credentials.password,
user.password,
);
if (!isPasswordValid) {
return null;
@@ -76,7 +82,7 @@ export const authConfig = {
email: user.email,
name: user.name,
};
}
},
}),
],
adapter: DrizzleAdapter(db, {

View File

@@ -1,5 +1,5 @@
import { createClient, type Client } from "@libsql/client";
import { drizzle } from "drizzle-orm/libsql";
import { Pool } from "pg";
import { drizzle } from "drizzle-orm/node-postgres";
import { env } from "~/env";
import * as schema from "./schema";
@@ -9,15 +9,18 @@ import * as schema from "./schema";
* update.
*/
const globalForDb = globalThis as unknown as {
client: Client | undefined;
pool: Pool | undefined;
};
export const client =
globalForDb.client ??
createClient({
url: env.DATABASE_URL,
authToken: env.DATABASE_AUTH_TOKEN,
export const pool =
globalForDb.pool ??
new Pool({
connectionString: env.DATABASE_URL,
ssl: env.NODE_ENV === "production" ? { rejectUnauthorized: false } : false,
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});
if (env.NODE_ENV !== "production") globalForDb.client = client;
if (env.NODE_ENV !== "production") globalForDb.pool = pool;
export const db = drizzle(client, { schema });
export const db = drizzle(pool, { schema });

View File

@@ -1,5 +1,5 @@
import { relations, sql } from "drizzle-orm";
import { index, primaryKey, sqliteTableCreator } from "drizzle-orm/sqlite-core";
import { index, primaryKey, pgTableCreator } from "drizzle-orm/pg-core";
import { type AdapterAccount } from "next-auth/adapters";
/**
@@ -8,20 +8,20 @@ import { type AdapterAccount } from "next-auth/adapters";
*
* @see https://orm.drizzle.team/docs/goodies#multi-project-schema
*/
export const createTable = sqliteTableCreator((name) => `beenvoice_${name}`);
export const createTable = pgTableCreator((name) => `beenvoice_${name}`);
// Auth-related tables (keeping existing)
export const users = createTable("user", (d) => ({
id: d
.text({ length: 255 })
.varchar({ length: 255 })
.notNull()
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
name: d.text({ length: 255 }),
email: d.text({ length: 255 }).notNull(),
password: d.text({ length: 255 }),
emailVerified: d.integer({ mode: "timestamp" }).default(sql`(unixepoch())`),
image: d.text({ length: 255 }),
name: d.varchar({ length: 255 }),
email: d.varchar({ length: 255 }).notNull(),
password: d.varchar({ length: 255 }),
emailVerified: d.timestamp().default(sql`CURRENT_TIMESTAMP`),
image: d.varchar({ length: 255 }),
}));
export const usersRelations = relations(users, ({ many }) => ({
@@ -35,19 +35,19 @@ export const accounts = createTable(
"account",
(d) => ({
userId: d
.text({ length: 255 })
.varchar({ length: 255 })
.notNull()
.references(() => users.id),
type: d.text({ length: 255 }).$type<AdapterAccount["type"]>().notNull(),
provider: d.text({ length: 255 }).notNull(),
providerAccountId: d.text({ length: 255 }).notNull(),
type: d.varchar({ length: 255 }).$type<AdapterAccount["type"]>().notNull(),
provider: d.varchar({ length: 255 }).notNull(),
providerAccountId: d.varchar({ length: 255 }).notNull(),
refresh_token: d.text(),
access_token: d.text(),
expires_at: d.integer(),
token_type: d.text({ length: 255 }),
scope: d.text({ length: 255 }),
token_type: d.varchar({ length: 255 }),
scope: d.varchar({ length: 255 }),
id_token: d.text(),
session_state: d.text({ length: 255 }),
session_state: d.varchar({ length: 255 }),
}),
(t) => [
primaryKey({
@@ -64,12 +64,12 @@ export const accountsRelations = relations(accounts, ({ one }) => ({
export const sessions = createTable(
"session",
(d) => ({
sessionToken: d.text({ length: 255 }).notNull().primaryKey(),
sessionToken: d.varchar({ length: 255 }).notNull().primaryKey(),
userId: d
.text({ length: 255 })
.varchar({ length: 255 })
.notNull()
.references(() => users.id),
expires: d.integer({ mode: "timestamp" }).notNull(),
expires: d.timestamp().notNull(),
}),
(t) => [index("session_userId_idx").on(t.userId)],
);
@@ -81,9 +81,9 @@ export const sessionsRelations = relations(sessions, ({ one }) => ({
export const verificationTokens = createTable(
"verification_token",
(d) => ({
identifier: d.text({ length: 255 }).notNull(),
token: d.text({ length: 255 }).notNull(),
expires: d.integer({ mode: "timestamp" }).notNull(),
identifier: d.varchar({ length: 255 }).notNull(),
token: d.varchar({ length: 255 }).notNull(),
expires: d.timestamp().notNull(),
}),
(t) => [primaryKey({ columns: [t.identifier, t.token] })],
);
@@ -93,29 +93,29 @@ export const clients = createTable(
"client",
(d) => ({
id: d
.text({ length: 255 })
.varchar({ 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 }),
name: d.varchar({ length: 255 }).notNull(),
email: d.varchar({ length: 255 }),
phone: d.varchar({ length: 50 }),
addressLine1: d.varchar({ length: 255 }),
addressLine2: d.varchar({ length: 255 }),
city: d.varchar({ length: 100 }),
state: d.varchar({ length: 50 }),
postalCode: d.varchar({ length: 20 }),
country: d.varchar({ length: 100 }),
defaultHourlyRate: d.real().notNull().default(100.0),
createdById: d
.text({ length: 255 })
.varchar({ length: 255 })
.notNull()
.references(() => users.id),
createdAt: d
.integer({ mode: "timestamp" })
.default(sql`(unixepoch())`)
.timestamp()
.default(sql`CURRENT_TIMESTAMP`)
.notNull(),
updatedAt: d.integer({ mode: "timestamp" }).$onUpdate(() => new Date()),
updatedAt: d.timestamp().$onUpdate(() => new Date()),
}),
(t) => [
index("client_created_by_idx").on(t.createdById),
@@ -136,32 +136,36 @@ export const businesses = createTable(
"business",
(d) => ({
id: d
.text({ length: 255 })
.varchar({ 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),
name: d.varchar({ length: 255 }).notNull(),
email: d.varchar({ length: 255 }),
phone: d.varchar({ length: 50 }),
addressLine1: d.varchar({ length: 255 }),
addressLine2: d.varchar({ length: 255 }),
city: d.varchar({ length: 100 }),
state: d.varchar({ length: 50 }),
postalCode: d.varchar({ length: 20 }),
country: d.varchar({ length: 100 }),
website: d.varchar({ length: 255 }),
taxId: d.varchar({ length: 100 }),
logoUrl: d.varchar({ length: 500 }),
isDefault: d.boolean().default(false),
// Email configuration for custom Resend setup
resendApiKey: d.varchar({ length: 255 }),
resendDomain: d.varchar({ length: 255 }),
emailFromName: d.varchar({ length: 255 }),
createdById: d
.text({ length: 255 })
.varchar({ length: 255 })
.notNull()
.references(() => users.id),
createdAt: d
.integer({ mode: "timestamp" })
.default(sql`(unixepoch())`)
.timestamp()
.default(sql`CURRENT_TIMESTAMP`)
.notNull(),
updatedAt: d.integer({ mode: "timestamp" }).$onUpdate(() => new Date()),
updatedAt: d.timestamp().$onUpdate(() => new Date()),
}),
(t) => [
index("business_created_by_idx").on(t.createdById),
@@ -183,31 +187,31 @@ export const invoices = createTable(
"invoice",
(d) => ({
id: d
.text({ length: 255 })
.varchar({ length: 255 })
.notNull()
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
invoiceNumber: d.text({ length: 100 }).notNull(),
businessId: d.text({ length: 255 }).references(() => businesses.id),
invoiceNumber: d.varchar({ length: 100 }).notNull(),
businessId: d.varchar({ length: 255 }).references(() => businesses.id),
clientId: d
.text({ length: 255 })
.varchar({ length: 255 })
.notNull()
.references(() => clients.id),
issueDate: d.integer({ mode: "timestamp" }).notNull(),
dueDate: d.integer({ mode: "timestamp" }).notNull(),
status: d.text({ length: 50 }).notNull().default("draft"), // draft, sent, paid, overdue
issueDate: d.timestamp().notNull(),
dueDate: d.timestamp().notNull(),
status: d.varchar({ length: 50 }).notNull().default("draft"), // draft, sent, paid (overdue computed)
totalAmount: d.real().notNull().default(0),
taxRate: d.real().notNull().default(0.0),
notes: d.text({ length: 1000 }),
notes: d.varchar({ length: 1000 }),
createdById: d
.text({ length: 255 })
.varchar({ length: 255 })
.notNull()
.references(() => users.id),
createdAt: d
.integer({ mode: "timestamp" })
.default(sql`(unixepoch())`)
.timestamp()
.default(sql`CURRENT_TIMESTAMP`)
.notNull(),
updatedAt: d.integer({ mode: "timestamp" }).$onUpdate(() => new Date()),
updatedAt: d.timestamp().$onUpdate(() => new Date()),
}),
(t) => [
index("invoice_business_id_idx").on(t.businessId),
@@ -238,23 +242,23 @@ export const invoiceItems = createTable(
"invoice_item",
(d) => ({
id: d
.text({ length: 255 })
.varchar({ length: 255 })
.notNull()
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
invoiceId: d
.text({ length: 255 })
.varchar({ length: 255 })
.notNull()
.references(() => invoices.id, { onDelete: "cascade" }),
date: d.integer({ mode: "timestamp" }).notNull(),
description: d.text({ length: 500 }).notNull(),
date: d.timestamp().notNull(),
description: d.varchar({ length: 500 }).notNull(),
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())`)
.timestamp()
.default(sql`CURRENT_TIMESTAMP`)
.notNull(),
}),
(t) => [