mirror of
https://github.com/soconnor0919/beenvoice.git
synced 2025-12-18 05:13:16 -05:00
feat: implement complete invoicing application with CSV import and PDF export
- Add comprehensive CSV import system with drag-and-drop upload and validation - Create UniversalTable component with advanced filtering, searching, and batch actions - Implement invoice management (view, edit, delete) with professional PDF export - Add client management with full CRUD operations - Set up authentication with NextAuth.js and email/password login - Configure database schema with users, clients, invoices, and invoice_items tables - Build responsive UI with shadcn/ui components and emerald branding - Add type-safe API layer with tRPC and Zod validation - Include proper error handling and user feedback with toast notifications - Set up development environment with Bun, TypeScript, and Tailwind CSS
This commit is contained in:
25
src/server/api/root.ts
Normal file
25
src/server/api/root.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { clientsRouter } from "~/server/api/routers/clients";
|
||||
import { invoicesRouter } from "~/server/api/routers/invoices";
|
||||
import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc";
|
||||
|
||||
/**
|
||||
* This is the primary router for your server.
|
||||
*
|
||||
* All routers added in /api/routers should be manually added here.
|
||||
*/
|
||||
export const appRouter = createTRPCRouter({
|
||||
clients: clientsRouter,
|
||||
invoices: invoicesRouter,
|
||||
});
|
||||
|
||||
// export type definition of API
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
||||
/**
|
||||
* Create a server-side caller for the tRPC API.
|
||||
* @example
|
||||
* const trpc = createCaller(createContext);
|
||||
* const res = await trpc.clients.getAll();
|
||||
* ^? Client[]
|
||||
*/
|
||||
export const createCaller = createCallerFactory(appRouter);
|
||||
70
src/server/api/routers/clients.ts
Normal file
70
src/server/api/routers/clients.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { z } from "zod";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
import { clients } from "~/server/db/schema";
|
||||
|
||||
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(),
|
||||
});
|
||||
|
||||
const updateClientSchema = createClientSchema.partial().extend({
|
||||
id: z.string(),
|
||||
});
|
||||
|
||||
export const clientsRouter = createTRPCRouter({
|
||||
getAll: protectedProcedure.query(async ({ ctx }) => {
|
||||
return await ctx.db.query.clients.findMany({
|
||||
where: eq(clients.createdById, ctx.session.user.id),
|
||||
orderBy: (clients, { desc }) => [desc(clients.createdAt)],
|
||||
});
|
||||
}),
|
||||
|
||||
getById: protectedProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return await ctx.db.query.clients.findFirst({
|
||||
where: eq(clients.id, input.id),
|
||||
with: {
|
||||
invoices: {
|
||||
orderBy: (invoices, { desc }) => [desc(invoices.createdAt)],
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
create: protectedProcedure
|
||||
.input(createClientSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return await ctx.db.insert(clients).values({
|
||||
...input,
|
||||
createdById: ctx.session.user.id,
|
||||
});
|
||||
}),
|
||||
|
||||
update: protectedProcedure
|
||||
.input(updateClientSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { id, ...data } = input;
|
||||
return await ctx.db
|
||||
.update(clients)
|
||||
.set({
|
||||
...data,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(clients.id, id));
|
||||
}),
|
||||
|
||||
delete: protectedProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return await ctx.db.delete(clients).where(eq(clients.id, input.id));
|
||||
}),
|
||||
});
|
||||
148
src/server/api/routers/invoices.ts
Normal file
148
src/server/api/routers/invoices.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { z } from "zod";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
import { invoices, invoiceItems, clients } from "~/server/db/schema";
|
||||
|
||||
const invoiceItemSchema = z.object({
|
||||
date: z.date(),
|
||||
description: z.string().min(1, "Description is required"),
|
||||
hours: z.number().min(0, "Hours must be positive"),
|
||||
rate: z.number().min(0, "Rate must be positive"),
|
||||
});
|
||||
|
||||
const createInvoiceSchema = z.object({
|
||||
invoiceNumber: z.string().min(1, "Invoice number is required"),
|
||||
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(),
|
||||
items: z.array(invoiceItemSchema).min(1, "At least one item is required"),
|
||||
});
|
||||
|
||||
const updateInvoiceSchema = createInvoiceSchema.partial().extend({
|
||||
id: z.string(),
|
||||
});
|
||||
|
||||
export const invoicesRouter = createTRPCRouter({
|
||||
getAll: protectedProcedure.query(async ({ ctx }) => {
|
||||
return await ctx.db.query.invoices.findMany({
|
||||
where: eq(invoices.createdById, ctx.session.user.id),
|
||||
with: {
|
||||
client: true,
|
||||
items: true,
|
||||
},
|
||||
orderBy: (invoices, { desc }) => [desc(invoices.createdAt)],
|
||||
});
|
||||
}),
|
||||
|
||||
getById: protectedProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return await ctx.db.query.invoices.findFirst({
|
||||
where: eq(invoices.id, input.id),
|
||||
with: {
|
||||
client: true,
|
||||
items: {
|
||||
orderBy: (items, { asc }) => [asc(items.date)],
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
create: protectedProcedure
|
||||
.input(createInvoiceSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { items, ...invoiceData } = input;
|
||||
|
||||
// Calculate total amount
|
||||
const totalAmount = items.reduce((sum, item) => sum + (item.hours * item.rate), 0);
|
||||
|
||||
// Create invoice
|
||||
const [invoice] = await ctx.db.insert(invoices).values({
|
||||
...invoiceData,
|
||||
totalAmount,
|
||||
createdById: ctx.session.user.id,
|
||||
}).returning();
|
||||
|
||||
if (!invoice) {
|
||||
throw new Error("Failed to create invoice");
|
||||
}
|
||||
|
||||
// Create invoice items
|
||||
const itemsToInsert = items.map(item => ({
|
||||
...item,
|
||||
invoiceId: invoice.id,
|
||||
amount: item.hours * item.rate,
|
||||
}));
|
||||
|
||||
await ctx.db.insert(invoiceItems).values(itemsToInsert);
|
||||
|
||||
return invoice;
|
||||
}),
|
||||
|
||||
update: protectedProcedure
|
||||
.input(updateInvoiceSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { id, items, ...invoiceData } = input;
|
||||
|
||||
if (items) {
|
||||
// Calculate total amount
|
||||
const totalAmount = items.reduce((sum, item) => sum + (item.hours * item.rate), 0);
|
||||
|
||||
// Update invoice
|
||||
await ctx.db
|
||||
.update(invoices)
|
||||
.set({
|
||||
...invoiceData,
|
||||
totalAmount,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(invoices.id, id));
|
||||
|
||||
// Delete existing items and create new ones
|
||||
await ctx.db.delete(invoiceItems).where(eq(invoiceItems.invoiceId, id));
|
||||
|
||||
const itemsToInsert = items.map(item => ({
|
||||
...item,
|
||||
invoiceId: id,
|
||||
amount: item.hours * item.rate,
|
||||
}));
|
||||
|
||||
await ctx.db.insert(invoiceItems).values(itemsToInsert);
|
||||
} else {
|
||||
// Update invoice without items
|
||||
await ctx.db
|
||||
.update(invoices)
|
||||
.set({
|
||||
...invoiceData,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(invoices.id, id));
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
delete: protectedProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Items will be deleted automatically due to cascade
|
||||
return await ctx.db.delete(invoices).where(eq(invoices.id, input.id));
|
||||
}),
|
||||
|
||||
updateStatus: protectedProcedure
|
||||
.input(z.object({
|
||||
id: z.string(),
|
||||
status: z.enum(["draft", "sent", "paid", "overdue"]),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return await ctx.db
|
||||
.update(invoices)
|
||||
.set({
|
||||
status: input.status,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(invoices.id, input.id));
|
||||
}),
|
||||
});
|
||||
39
src/server/api/routers/post.ts
Normal file
39
src/server/api/routers/post.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
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!";
|
||||
}),
|
||||
});
|
||||
133
src/server/api/trpc.ts
Normal file
133
src/server/api/trpc.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* YOU PROBABLY DON'T NEED TO EDIT THIS FILE, UNLESS:
|
||||
* 1. You want to modify request context (see Part 1).
|
||||
* 2. You want to create a new middleware or type of procedure (see Part 3).
|
||||
*
|
||||
* TL;DR - This is where all the tRPC server stuff is created and plugged in. The pieces you will
|
||||
* need to use are documented accordingly near the end.
|
||||
*/
|
||||
|
||||
import { initTRPC, TRPCError } from "@trpc/server";
|
||||
import superjson from "superjson";
|
||||
import { ZodError } from "zod";
|
||||
|
||||
import { auth } from "~/server/auth";
|
||||
import { db } from "~/server/db";
|
||||
|
||||
/**
|
||||
* 1. CONTEXT
|
||||
*
|
||||
* This section defines the "contexts" that are available in the backend API.
|
||||
*
|
||||
* These allow you to access things when processing a request, like the database, the session, etc.
|
||||
*
|
||||
* This helper generates the "internals" for a tRPC context. The API handler and RSC clients each
|
||||
* wrap this and provides the required context.
|
||||
*
|
||||
* @see https://trpc.io/docs/server/context
|
||||
*/
|
||||
export const createTRPCContext = async (opts: { headers: Headers }) => {
|
||||
const session = await auth();
|
||||
|
||||
return {
|
||||
db,
|
||||
session,
|
||||
...opts,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 2. INITIALIZATION
|
||||
*
|
||||
* This is where the tRPC API is initialized, connecting the context and transformer. We also parse
|
||||
* ZodErrors so that you get typesafety on the frontend if your procedure fails due to validation
|
||||
* errors on the backend.
|
||||
*/
|
||||
const t = initTRPC.context<typeof createTRPCContext>().create({
|
||||
transformer: superjson,
|
||||
errorFormatter({ shape, error }) {
|
||||
return {
|
||||
...shape,
|
||||
data: {
|
||||
...shape.data,
|
||||
zodError:
|
||||
error.cause instanceof ZodError ? error.cause.flatten() : null,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Create a server-side caller.
|
||||
*
|
||||
* @see https://trpc.io/docs/server/server-side-calls
|
||||
*/
|
||||
export const createCallerFactory = t.createCallerFactory;
|
||||
|
||||
/**
|
||||
* 3. ROUTER & PROCEDURE (THE IMPORTANT BIT)
|
||||
*
|
||||
* These are the pieces you use to build your tRPC API. You should import these a lot in the
|
||||
* "/src/server/api/routers" directory.
|
||||
*/
|
||||
|
||||
/**
|
||||
* This is how you create new routers and sub-routers in your tRPC API.
|
||||
*
|
||||
* @see https://trpc.io/docs/router
|
||||
*/
|
||||
export const createTRPCRouter = t.router;
|
||||
|
||||
/**
|
||||
* Middleware for timing procedure execution and adding an artificial delay in development.
|
||||
*
|
||||
* You can remove this if you don't like it, but it can help catch unwanted waterfalls by simulating
|
||||
* network latency that would occur in production but not in local development.
|
||||
*/
|
||||
const timingMiddleware = t.middleware(async ({ next, path }) => {
|
||||
const start = Date.now();
|
||||
|
||||
if (t._config.isDev) {
|
||||
// artificial delay in dev
|
||||
const waitMs = Math.floor(Math.random() * 400) + 100;
|
||||
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
||||
}
|
||||
|
||||
const result = await next();
|
||||
|
||||
const end = Date.now();
|
||||
console.log(`[TRPC] ${path} took ${end - start}ms to execute`);
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
/**
|
||||
* Public (unauthenticated) procedure
|
||||
*
|
||||
* This is the base piece you use to build new queries and mutations on your tRPC API. It does not
|
||||
* guarantee that a user querying is authorized, but you can still access user session data if they
|
||||
* are logged in.
|
||||
*/
|
||||
export const publicProcedure = t.procedure.use(timingMiddleware);
|
||||
|
||||
/**
|
||||
* Protected (authenticated) procedure
|
||||
*
|
||||
* If you want a query or mutation to ONLY be accessible to logged in users, use this. It verifies
|
||||
* the session is valid and guarantees `ctx.session.user` is not null.
|
||||
*
|
||||
* @see https://trpc.io/docs/procedures
|
||||
*/
|
||||
export const protectedProcedure = t.procedure
|
||||
.use(timingMiddleware)
|
||||
.use(({ ctx, next }) => {
|
||||
if (!ctx.session?.user) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
return next({
|
||||
ctx: {
|
||||
// infers the `session` as non-nullable
|
||||
session: { ...ctx.session, user: ctx.session.user },
|
||||
},
|
||||
});
|
||||
});
|
||||
110
src/server/auth/config.ts
Normal file
110
src/server/auth/config.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { DrizzleAdapter } from "@auth/drizzle-adapter";
|
||||
import { type DefaultSession, type NextAuthConfig } from "next-auth";
|
||||
import CredentialsProvider from "next-auth/providers/credentials";
|
||||
import { eq } from "drizzle-orm";
|
||||
import bcrypt from "bcryptjs";
|
||||
|
||||
import { db } from "~/server/db";
|
||||
import {
|
||||
accounts,
|
||||
sessions,
|
||||
users,
|
||||
verificationTokens,
|
||||
} from "~/server/db/schema";
|
||||
|
||||
/**
|
||||
* Module augmentation for `next-auth` types. Allows us to add custom properties to the `session`
|
||||
* object and keep type safety.
|
||||
*
|
||||
* @see https://next-auth.js.org/getting-started/typescript#module-augmentation
|
||||
*/
|
||||
declare module "next-auth" {
|
||||
interface Session extends DefaultSession {
|
||||
user: {
|
||||
id: string;
|
||||
// ...other properties
|
||||
// role: UserRole;
|
||||
} & DefaultSession["user"];
|
||||
}
|
||||
|
||||
// interface User {
|
||||
// // ...other properties
|
||||
// // role: UserRole;
|
||||
// }
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for NextAuth.js used to configure adapters, providers, callbacks, etc.
|
||||
*
|
||||
* @see https://next-auth.js.org/configuration/options
|
||||
*/
|
||||
export const authConfig = {
|
||||
session: {
|
||||
strategy: "jwt",
|
||||
},
|
||||
providers: [
|
||||
CredentialsProvider({
|
||||
name: "credentials",
|
||||
credentials: {
|
||||
email: { label: "Email", type: "email" },
|
||||
password: { label: "Password", type: "password" }
|
||||
},
|
||||
async authorize(credentials) {
|
||||
if (!credentials?.email || !credentials?.password) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.email, credentials.email),
|
||||
});
|
||||
|
||||
if (!user || !user.password) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isPasswordValid = await bcrypt.compare(credentials.password, user.password);
|
||||
|
||||
if (!isPasswordValid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
};
|
||||
}
|
||||
}),
|
||||
],
|
||||
adapter: DrizzleAdapter(db, {
|
||||
usersTable: users,
|
||||
accountsTable: accounts,
|
||||
sessionsTable: sessions,
|
||||
verificationTokensTable: verificationTokens,
|
||||
}),
|
||||
callbacks: {
|
||||
session: ({ session, token }) => ({
|
||||
...session,
|
||||
user: {
|
||||
...session.user,
|
||||
id: token.sub,
|
||||
},
|
||||
}),
|
||||
jwt: ({ token, user }) => {
|
||||
if (user) {
|
||||
token.sub = user.id;
|
||||
}
|
||||
return token;
|
||||
},
|
||||
redirect: ({ url, baseUrl }) => {
|
||||
// Allows relative callback URLs
|
||||
if (url.startsWith("/")) return `${baseUrl}${url}`;
|
||||
// Allows callback URLs on the same origin
|
||||
else if (new URL(url).origin === baseUrl) return url;
|
||||
return baseUrl + "/dashboard";
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
signIn: "/auth/signin",
|
||||
},
|
||||
} satisfies NextAuthConfig;
|
||||
10
src/server/auth/index.ts
Normal file
10
src/server/auth/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import NextAuth from "next-auth";
|
||||
import { cache } from "react";
|
||||
|
||||
import { authConfig } from "./config";
|
||||
|
||||
const { auth: uncachedAuth, handlers, signIn, signOut } = NextAuth(authConfig);
|
||||
|
||||
const auth = cache(uncachedAuth);
|
||||
|
||||
export { auth, handlers, signIn, signOut };
|
||||
19
src/server/db/index.ts
Normal file
19
src/server/db/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { createClient, type Client } from "@libsql/client";
|
||||
import { drizzle } from "drizzle-orm/libsql";
|
||||
|
||||
import { env } from "~/env";
|
||||
import * as schema from "./schema";
|
||||
|
||||
/**
|
||||
* Cache the database connection in development. This avoids creating a new connection on every HMR
|
||||
* update.
|
||||
*/
|
||||
const globalForDb = globalThis as unknown as {
|
||||
client: Client | undefined;
|
||||
};
|
||||
|
||||
export const client =
|
||||
globalForDb.client ?? createClient({ url: env.DATABASE_URL });
|
||||
if (env.NODE_ENV !== "production") globalForDb.client = client;
|
||||
|
||||
export const db = drizzle(client, { schema });
|
||||
202
src/server/db/schema.ts
Normal file
202
src/server/db/schema.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import { relations, sql } from "drizzle-orm";
|
||||
import { index, primaryKey, sqliteTableCreator } from "drizzle-orm/sqlite-core";
|
||||
import { type AdapterAccount } from "next-auth/adapters";
|
||||
|
||||
/**
|
||||
* This is an example of how to use the multi-project schema feature of Drizzle ORM. Use the same
|
||||
* database instance for multiple projects.
|
||||
*
|
||||
* @see https://orm.drizzle.team/docs/goodies#multi-project-schema
|
||||
*/
|
||||
export const createTable = sqliteTableCreator((name) => `beenvoice_${name}`);
|
||||
|
||||
// Auth-related tables (keeping existing)
|
||||
export const users = createTable("user", (d) => ({
|
||||
id: d
|
||||
.text({ 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 }),
|
||||
}));
|
||||
|
||||
export const usersRelations = relations(users, ({ many }) => ({
|
||||
accounts: many(accounts),
|
||||
clients: many(clients),
|
||||
invoices: many(invoices),
|
||||
}));
|
||||
|
||||
export const accounts = createTable(
|
||||
"account",
|
||||
(d) => ({
|
||||
userId: d
|
||||
.text({ 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(),
|
||||
refresh_token: d.text(),
|
||||
access_token: d.text(),
|
||||
expires_at: d.integer(),
|
||||
token_type: d.text({ length: 255 }),
|
||||
scope: d.text({ length: 255 }),
|
||||
id_token: d.text(),
|
||||
session_state: d.text({ length: 255 }),
|
||||
}),
|
||||
(t) => [
|
||||
primaryKey({
|
||||
columns: [t.provider, t.providerAccountId],
|
||||
}),
|
||||
index("account_user_id_idx").on(t.userId),
|
||||
],
|
||||
);
|
||||
|
||||
export const accountsRelations = relations(accounts, ({ one }) => ({
|
||||
user: one(users, { fields: [accounts.userId], references: [users.id] }),
|
||||
}));
|
||||
|
||||
export const sessions = createTable(
|
||||
"session",
|
||||
(d) => ({
|
||||
sessionToken: d.text({ length: 255 }).notNull().primaryKey(),
|
||||
userId: d
|
||||
.text({ length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
expires: d.integer({ mode: "timestamp" }).notNull(),
|
||||
}),
|
||||
(t) => [index("session_userId_idx").on(t.userId)],
|
||||
);
|
||||
|
||||
export const sessionsRelations = relations(sessions, ({ one }) => ({
|
||||
user: one(users, { fields: [sessions.userId], references: [users.id] }),
|
||||
}));
|
||||
|
||||
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(),
|
||||
}),
|
||||
(t) => [primaryKey({ columns: [t.identifier, t.token] })],
|
||||
);
|
||||
|
||||
// Invoicing app tables
|
||||
export const clients = createTable(
|
||||
"client",
|
||||
(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 }),
|
||||
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("client_created_by_idx").on(t.createdById),
|
||||
index("client_name_idx").on(t.name),
|
||||
index("client_email_idx").on(t.email),
|
||||
],
|
||||
);
|
||||
|
||||
export const clientsRelations = relations(clients, ({ one, many }) => ({
|
||||
createdBy: one(users, { fields: [clients.createdById], references: [users.id] }),
|
||||
invoices: many(invoices),
|
||||
}));
|
||||
|
||||
export const invoices = createTable(
|
||||
"invoice",
|
||||
(d) => ({
|
||||
id: d
|
||||
.text({ length: 255 })
|
||||
.notNull()
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
invoiceNumber: d.text({ length: 100 }).notNull(),
|
||||
clientId: d
|
||||
.text({ 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
|
||||
totalAmount: d.real().notNull().default(0),
|
||||
notes: d.text({ length: 1000 }),
|
||||
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("invoice_client_id_idx").on(t.clientId),
|
||||
index("invoice_created_by_idx").on(t.createdById),
|
||||
index("invoice_number_idx").on(t.invoiceNumber),
|
||||
index("invoice_status_idx").on(t.status),
|
||||
],
|
||||
);
|
||||
|
||||
export const invoicesRelations = relations(invoices, ({ one, many }) => ({
|
||||
client: one(clients, { fields: [invoices.clientId], references: [clients.id] }),
|
||||
createdBy: one(users, { fields: [invoices.createdById], references: [users.id] }),
|
||||
items: many(invoiceItems),
|
||||
}));
|
||||
|
||||
export const invoiceItems = createTable(
|
||||
"invoice_item",
|
||||
(d) => ({
|
||||
id: d
|
||||
.text({ length: 255 })
|
||||
.notNull()
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
invoiceId: d
|
||||
.text({ length: 255 })
|
||||
.notNull()
|
||||
.references(() => invoices.id, { onDelete: "cascade" }),
|
||||
date: d.integer({ mode: "timestamp" }).notNull(),
|
||||
description: d.text({ length: 500 }).notNull(),
|
||||
hours: d.real().notNull(),
|
||||
rate: d.real().notNull(),
|
||||
amount: d.real().notNull(),
|
||||
createdAt: d
|
||||
.integer({ mode: "timestamp" })
|
||||
.default(sql`(unixepoch())`)
|
||||
.notNull(),
|
||||
}),
|
||||
(t) => [
|
||||
index("invoice_item_invoice_id_idx").on(t.invoiceId),
|
||||
index("invoice_item_date_idx").on(t.date),
|
||||
],
|
||||
);
|
||||
|
||||
export const invoiceItemsRelations = relations(invoiceItems, ({ one }) => ({
|
||||
invoice: one(invoices, { fields: [invoiceItems.invoiceId], references: [invoices.id] }),
|
||||
}));
|
||||
Reference in New Issue
Block a user