Add settings page, port to turso

This commit is contained in:
2025-07-12 20:10:39 -04:00
parent 446e3abb0d
commit 07f190bce2
17 changed files with 3356 additions and 92 deletions

View File

@@ -1,6 +1,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 { createCallerFactory, createTRPCRouter } from "~/server/api/trpc";
/**
@@ -12,6 +13,7 @@ export const appRouter = createTRPCRouter({
clients: clientsRouter,
businesses: businessesRouter,
invoices: invoicesRouter,
settings: settingsRouter,
});
// export type definition of API

View File

@@ -0,0 +1,395 @@
import { z } from "zod";
import { eq, and } from "drizzle-orm";
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
import {
users,
clients,
businesses,
invoices,
invoiceItems
} from "~/server/db/schema";
// Validation schemas for backup data
const ClientBackupSchema = z.object({
name: z.string(),
email: z.string().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 BusinessBackupSchema = z.object({
name: z.string(),
email: z.string().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(),
website: z.string().optional(),
taxId: z.string().optional(),
logoUrl: z.string().optional(),
isDefault: z.boolean().default(false),
});
const InvoiceItemBackupSchema = z.object({
date: z.date(),
description: z.string(),
hours: z.number(),
rate: z.number(),
amount: z.number(),
position: z.number().default(0),
});
const InvoiceBackupSchema = z.object({
invoiceNumber: z.string(),
businessName: z.string().optional(),
clientName: z.string(),
issueDate: z.date(),
dueDate: z.date(),
status: z.string().default("draft"),
totalAmount: z.number().default(0),
taxRate: z.number().default(0),
notes: z.string().optional(),
items: z.array(InvoiceItemBackupSchema),
});
const BackupDataSchema = z.object({
exportDate: z.string(),
version: z.string().default("1.0"),
user: z.object({
name: z.string().optional(),
email: z.string(),
}),
clients: z.array(ClientBackupSchema),
businesses: z.array(BusinessBackupSchema),
invoices: z.array(InvoiceBackupSchema),
});
export const settingsRouter = createTRPCRouter({
// Get user profile information
getProfile: protectedProcedure.query(async ({ ctx }) => {
const user = await ctx.db.query.users.findFirst({
where: eq(users.id, ctx.session.user.id),
columns: {
id: true,
name: true,
email: true,
image: true,
},
});
return user;
}),
// Update user profile
updateProfile: protectedProcedure
.input(
z.object({
name: z.string().min(1, "Name is required"),
})
)
.mutation(async ({ ctx, input }) => {
await ctx.db
.update(users)
.set({
name: input.name,
})
.where(eq(users.id, ctx.session.user.id));
return { success: true };
}),
// Export user data (backup)
exportData: protectedProcedure.query(async ({ ctx }) => {
const userId = ctx.session.user.id;
// Get user info
const user = await ctx.db.query.users.findFirst({
where: eq(users.id, userId),
columns: {
name: true,
email: true,
},
});
// Get all clients
const userClients = await ctx.db.query.clients.findMany({
where: eq(clients.createdById, userId),
columns: {
id: true,
name: true,
email: true,
phone: true,
addressLine1: true,
addressLine2: true,
city: true,
state: true,
postalCode: true,
country: true,
},
});
// Get all businesses
const userBusinesses = await ctx.db.query.businesses.findMany({
where: eq(businesses.createdById, userId),
columns: {
id: true,
name: true,
email: true,
phone: true,
addressLine1: true,
addressLine2: true,
city: true,
state: true,
postalCode: true,
country: true,
website: true,
taxId: true,
logoUrl: true,
isDefault: true,
},
});
// Get all invoices with their items
const userInvoices = await ctx.db.query.invoices.findMany({
where: eq(invoices.createdById, userId),
with: {
client: {
columns: {
name: true,
},
},
business: {
columns: {
name: true,
},
},
items: {
columns: {
date: true,
description: true,
hours: true,
rate: true,
amount: true,
position: true,
},
orderBy: (items, { asc }) => [asc(items.position)],
},
},
});
// Format the data for export
const backupData = {
exportDate: new Date().toISOString(),
version: "1.0",
user: {
name: user?.name ?? "",
email: user?.email ?? "",
},
clients: userClients.map(client => ({
name: client.name,
email: client.email ?? undefined,
phone: client.phone ?? undefined,
addressLine1: client.addressLine1 ?? undefined,
addressLine2: client.addressLine2 ?? undefined,
city: client.city ?? undefined,
state: client.state ?? undefined,
postalCode: client.postalCode ?? undefined,
country: client.country ?? undefined,
})),
businesses: userBusinesses.map(business => ({
name: business.name,
email: business.email ?? undefined,
phone: business.phone ?? undefined,
addressLine1: business.addressLine1 ?? undefined,
addressLine2: business.addressLine2 ?? undefined,
city: business.city ?? undefined,
state: business.state ?? undefined,
postalCode: business.postalCode ?? undefined,
country: business.country ?? undefined,
website: business.website ?? undefined,
taxId: business.taxId ?? undefined,
logoUrl: business.logoUrl ?? undefined,
isDefault: business.isDefault ?? false,
})),
invoices: userInvoices.map(invoice => ({
invoiceNumber: invoice.invoiceNumber,
businessName: invoice.business?.name,
clientName: invoice.client.name,
issueDate: invoice.issueDate,
dueDate: invoice.dueDate,
status: invoice.status,
totalAmount: invoice.totalAmount,
taxRate: invoice.taxRate,
notes: invoice.notes ?? undefined,
items: invoice.items,
})),
};
return backupData;
}),
// Import user data (restore)
importData: protectedProcedure
.input(BackupDataSchema)
.mutation(async ({ ctx, input }) => {
const userId = ctx.session.user.id;
return await ctx.db.transaction(async (tx) => {
// Create a map to track old to new client IDs
const clientIdMap = new Map<string, string>();
const businessIdMap = new Map<string, string>();
// Import clients
for (const clientData of input.clients) {
const [newClient] = await tx
.insert(clients)
.values({
...clientData,
createdById: userId,
})
.returning({ id: clients.id });
if (newClient) {
clientIdMap.set(clientData.name, newClient.id);
}
}
// Import businesses
for (const businessData of input.businesses) {
const [newBusiness] = await tx
.insert(businesses)
.values({
...businessData,
createdById: userId,
})
.returning({ id: businesses.id });
if (newBusiness) {
businessIdMap.set(businessData.name, newBusiness.id);
}
}
// Import invoices
for (const invoiceData of input.invoices) {
const clientId = clientIdMap.get(invoiceData.clientName);
if (!clientId) {
throw new Error(`Client ${invoiceData.clientName} not found`);
}
const businessId = invoiceData.businessName
? businessIdMap.get(invoiceData.businessName)
: null;
const [newInvoice] = await tx
.insert(invoices)
.values({
invoiceNumber: invoiceData.invoiceNumber,
businessId,
clientId,
issueDate: invoiceData.issueDate,
dueDate: invoiceData.dueDate,
status: invoiceData.status,
totalAmount: invoiceData.totalAmount,
taxRate: invoiceData.taxRate,
notes: invoiceData.notes,
createdById: userId,
})
.returning({ id: invoices.id });
if (newInvoice && invoiceData.items.length > 0) {
// Import invoice items
await tx.insert(invoiceItems).values(
invoiceData.items.map(item => ({
...item,
invoiceId: newInvoice.id,
}))
);
}
}
return {
success: true,
imported: {
clients: input.clients.length,
businesses: input.businesses.length,
invoices: input.invoices.length,
items: input.invoices.reduce((sum, inv) => sum + inv.items.length, 0),
}
};
});
}),
// Get data statistics
getDataStats: protectedProcedure.query(async ({ ctx }) => {
const userId = ctx.session.user.id;
const [clientCount, businessCount, invoiceCount] = await Promise.all([
ctx.db
.select({ count: clients.id })
.from(clients)
.where(eq(clients.createdById, userId))
.then(result => result.length),
ctx.db
.select({ count: businesses.id })
.from(businesses)
.where(eq(businesses.createdById, userId))
.then(result => result.length),
ctx.db
.select({ count: invoices.id })
.from(invoices)
.where(eq(invoices.createdById, userId))
.then(result => result.length),
]);
return {
clients: clientCount,
businesses: businessCount,
invoices: invoiceCount,
};
}),
// Delete all user data (for account deletion)
deleteAllData: protectedProcedure
.input(z.object({
confirmText: z.string().refine(val => val === "DELETE ALL DATA", {
message: "You must type 'DELETE ALL DATA' to confirm",
}),
}))
.mutation(async ({ ctx }) => {
const userId = ctx.session.user.id;
return await ctx.db.transaction(async (tx) => {
// Delete in order due to foreign key constraints
// 1. Invoice items (cascade should handle this, but being explicit)
const userInvoiceIds = await tx
.select({ id: invoices.id })
.from(invoices)
.where(eq(invoices.createdById, userId));
if (userInvoiceIds.length > 0) {
for (const invoice of userInvoiceIds) {
await tx.delete(invoiceItems).where(eq(invoiceItems.invoiceId, invoice.id));
}
}
// 2. Invoices
await tx.delete(invoices).where(eq(invoices.createdById, userId));
// 3. Clients
await tx.delete(clients).where(eq(clients.createdById, userId));
// 4. Businesses
await tx.delete(businesses).where(eq(businesses.createdById, userId));
return { success: true };
});
}),
});