mirror of
https://github.com/soconnor0919/beenvoice.git
synced 2026-02-05 08:16:31 -05:00
Build fixes, email preview system
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
311
src/server/api/routers/email.ts
Normal file
311
src/server/api/routers/email.ts
Normal 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(),
|
||||
},
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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) => [
|
||||
|
||||
Reference in New Issue
Block a user