Files
beenvoice/src/server/api/routers/settings.ts
T

747 lines
23 KiB
TypeScript

import { z } from "zod";
import { eq } from "drizzle-orm";
import { TRPCError } from "@trpc/server";
import bcrypt from "bcryptjs";
import {
createTRPCRouter,
protectedProcedure,
publicProcedure,
} from "~/server/api/trpc";
import {
users,
clients,
businesses,
invoices,
invoiceItems,
platformSettings,
} from "~/server/db/schema";
import {
defaultBodyFontPreference,
defaultFontPreference,
defaultHeadingFontPreference,
defaultInterfaceTheme,
defaultRadiusPreference,
defaultSidebarStyle,
type ColorMode,
type ColorTheme,
type FontPreference,
type InterfaceTheme,
type RadiusPreference,
type SidebarStyle,
} from "~/lib/branding";
async function requireAdmin(ctx: {
db: typeof import("~/server/db").db;
session: { user: { id: string } };
}) {
const user = await ctx.db.query.users.findFirst({
where: eq(users.id, ctx.session.user.id),
columns: { role: true },
});
if (user?.role !== "admin") {
throw new TRPCError({ code: "FORBIDDEN" });
}
}
// 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(),
nickname: z.string().optional(),
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.string().transform((str) => new Date(str)),
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(),
businessNickname: z.string().optional(),
clientName: z.string(),
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),
notes: z.string().optional(),
emailMessage: 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({
listAccounts: protectedProcedure.query(async ({ ctx }) => {
await requireAdmin(ctx);
return ctx.db.query.users.findMany({
columns: {
id: true,
name: true,
email: true,
role: true,
emailVerified: true,
createdAt: true,
},
orderBy: (users, { asc }) => [asc(users.createdAt)],
});
}),
updateAccountRole: protectedProcedure
.input(
z.object({
userId: z.string().min(1),
role: z.enum(["user", "admin"]),
}),
)
.mutation(async ({ ctx, input }) => {
await requireAdmin(ctx);
await ctx.db
.update(users)
.set({ role: input.role })
.where(eq(users.id, input.userId));
return { success: true };
}),
// 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,
role: true,
},
});
return user;
}),
// Get animation preferences
getAnimationPreferences: publicProcedure.query(async ({ ctx }) => {
// Return defaults if not authenticated
if (!ctx.session?.user?.id) {
return {
prefersReducedMotion: false,
animationSpeedMultiplier: 1,
};
}
const user = await ctx.db.query.users.findFirst({
where: eq(users.id, ctx.session.user.id),
columns: {
prefersReducedMotion: true,
animationSpeedMultiplier: true,
},
});
return {
prefersReducedMotion: user?.prefersReducedMotion ?? false,
animationSpeedMultiplier: user?.animationSpeedMultiplier ?? 1,
};
}),
// Update animation preferences
updateAnimationPreferences: protectedProcedure
.input(
z.object({
prefersReducedMotion: z.boolean().optional(),
animationSpeedMultiplier: z
.number()
.min(0.25, "Minimum 0.25x")
.max(4, "Maximum 4x")
.optional(),
}),
)
.mutation(async ({ ctx, input }) => {
await ctx.db
.update(users)
.set({
...(input.prefersReducedMotion !== undefined && {
prefersReducedMotion: input.prefersReducedMotion,
}),
...(input.animationSpeedMultiplier !== undefined && {
animationSpeedMultiplier: input.animationSpeedMultiplier,
}),
})
.where(eq(users.id, ctx.session.user.id));
return { success: true };
}),
// Get theme preferences
getTheme: publicProcedure.query(async ({ ctx }) => {
const settings = await ctx.db.query.platformSettings.findFirst({
where: eq(platformSettings.id, "global"),
});
return {
colorTheme: (settings?.colorTheme as ColorTheme) ?? "slate",
customColor: settings?.customColor ?? undefined,
theme: (settings?.theme as ColorMode) ?? "system",
interfaceTheme:
(settings?.interfaceTheme as InterfaceTheme) ?? defaultInterfaceTheme,
fontPreference: defaultFontPreference,
bodyFontPreference:
(settings?.bodyFontPreference as FontPreference) ??
defaultBodyFontPreference,
headingFontPreference:
(settings?.headingFontPreference as FontPreference) ??
defaultHeadingFontPreference,
radiusPreference:
(settings?.radiusPreference as RadiusPreference) ??
defaultRadiusPreference,
sidebarStyle:
(settings?.sidebarStyle as SidebarStyle) ?? defaultSidebarStyle,
brandName: settings?.brandName ?? "beenvoice",
brandTagline:
settings?.brandTagline ??
"Simple and efficient invoicing for freelancers and small businesses",
brandLogoText: settings?.brandLogoText ?? "beenvoice",
brandIcon: settings?.brandIcon ?? "$",
pdfTemplate:
(settings?.pdfTemplate as "classic" | "minimal") ?? "classic",
pdfAccentColor: settings?.pdfAccentColor ?? "#111827",
pdfFooterText: settings?.pdfFooterText ?? "Professional Invoicing",
pdfShowLogo: settings?.pdfShowLogo ?? true,
pdfShowPageNumbers: settings?.pdfShowPageNumbers ?? true,
};
}),
// Update theme preferences
updateTheme: protectedProcedure
.input(
z.object({
colorTheme: z
.enum(["slate", "blue", "green", "rose", "orange", "custom"])
.optional(),
customColor: z.string().optional(),
theme: z.enum(["light", "dark", "system"]).optional(),
interfaceTheme: z
.enum(["beenvoice", "shadcn", "minimal", "editorial"])
.optional(),
fontPreference: z
.enum(["brand", "platform", "inter", "serif"])
.optional(),
bodyFontPreference: z
.enum(["brand", "platform", "inter", "serif"])
.optional(),
headingFontPreference: z
.enum(["brand", "platform", "inter", "serif"])
.optional(),
radiusPreference: z.enum(["none", "sm", "md", "lg", "xl"]).optional(),
sidebarStyle: z.enum(["floating", "docked"]).optional(),
brandName: z.string().min(1).max(100).optional(),
brandTagline: z.string().min(1).max(255).optional(),
brandLogoText: z.string().min(1).max(100).optional(),
brandIcon: z.string().min(1).max(20).optional(),
pdfTemplate: z.enum(["classic", "minimal"]).optional(),
pdfAccentColor: z.string().min(4).max(50).optional(),
pdfFooterText: z.string().min(1).max(120).optional(),
pdfShowLogo: z.boolean().optional(),
pdfShowPageNumbers: z.boolean().optional(),
}),
)
.mutation(async ({ ctx, input }) => {
await requireAdmin(ctx);
await ctx.db
.insert(platformSettings)
.values({
id: "global",
brandName: input.brandName ?? "beenvoice",
brandTagline:
input.brandTagline ??
"Simple and efficient invoicing for freelancers and small businesses",
brandLogoText: input.brandLogoText ?? "beenvoice",
brandIcon: input.brandIcon ?? "$",
colorTheme: input.colorTheme ?? "slate",
customColor: input.customColor,
theme: input.theme ?? "system",
interfaceTheme: input.interfaceTheme ?? defaultInterfaceTheme,
bodyFontPreference:
input.bodyFontPreference ?? defaultBodyFontPreference,
headingFontPreference:
input.headingFontPreference ?? defaultHeadingFontPreference,
radiusPreference: input.radiusPreference ?? defaultRadiusPreference,
sidebarStyle: input.sidebarStyle ?? defaultSidebarStyle,
pdfTemplate: input.pdfTemplate ?? "classic",
pdfAccentColor: input.pdfAccentColor ?? "#111827",
pdfFooterText: input.pdfFooterText ?? "Professional Invoicing",
pdfShowLogo: input.pdfShowLogo ?? true,
pdfShowPageNumbers: input.pdfShowPageNumbers ?? true,
})
.onConflictDoUpdate({
target: platformSettings.id,
set: {
...(input.brandName && { brandName: input.brandName }),
...(input.brandTagline && { brandTagline: input.brandTagline }),
...(input.brandLogoText && {
brandLogoText: input.brandLogoText,
}),
...(input.brandIcon && { brandIcon: input.brandIcon }),
...(input.colorTheme && { colorTheme: input.colorTheme }),
...(input.customColor !== undefined && {
customColor: input.customColor,
}),
...(input.theme && { theme: input.theme }),
...(input.interfaceTheme && {
interfaceTheme: input.interfaceTheme,
}),
...(input.bodyFontPreference && {
bodyFontPreference: input.bodyFontPreference,
}),
...(input.headingFontPreference && {
headingFontPreference: input.headingFontPreference,
}),
...(input.radiusPreference && {
radiusPreference: input.radiusPreference,
}),
...(input.sidebarStyle && { sidebarStyle: input.sidebarStyle }),
...(input.pdfTemplate && { pdfTemplate: input.pdfTemplate }),
...(input.pdfAccentColor && {
pdfAccentColor: input.pdfAccentColor,
}),
...(input.pdfFooterText && { pdfFooterText: input.pdfFooterText }),
...(input.pdfShowLogo !== undefined && {
pdfShowLogo: input.pdfShowLogo,
}),
...(input.pdfShowPageNumbers !== undefined && {
pdfShowPageNumbers: input.pdfShowPageNumbers,
}),
updatedAt: new Date(),
},
});
return { success: true };
}),
// 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 };
}),
// Change user password
changePassword: protectedProcedure
.input(
z
.object({
currentPassword: z.string().min(1, "Current password is required"),
newPassword: z
.string()
.min(8, "New password must be at least 8 characters"),
confirmPassword: z
.string()
.min(1, "Password confirmation is required"),
})
.refine((data) => data.newPassword === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"],
}),
)
.mutation(async ({ ctx, input }) => {
const userId = ctx.session.user.id;
// Get the current user with password
const user = await ctx.db.query.users.findFirst({
where: eq(users.id, userId),
columns: {
id: true,
password: true,
},
});
if (!user?.password) {
throw new Error("User not found or no password set");
}
// Verify current password
const isCurrentPasswordValid = await bcrypt.compare(
input.currentPassword,
user.password,
);
if (!isCurrentPasswordValid) {
throw new Error("Current password is incorrect");
}
// Hash the new password
const saltRounds = 12;
const hashedNewPassword = await bcrypt.hash(
input.newPassword,
saltRounds,
);
// Update the password
await ctx.db
.update(users)
.set({
password: hashedNewPassword,
})
.where(eq(users.id, userId));
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,
nickname: 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,
nickname: 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,
nickname: business.nickname ?? undefined,
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,
businessNickname: invoice.business?.nickname,
clientName: invoice.client.name,
issueDate: invoice.issueDate,
dueDate: invoice.dueDate,
status: invoice.status,
totalAmount: invoice.totalAmount,
taxRate: invoice.taxRate,
notes: invoice.notes ?? undefined,
emailMessage: invoice.emailMessage ?? 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);
if (businessData.nickname) {
businessIdMap.set(businessData.nickname, 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.businessNickname
? (businessIdMap.get(invoiceData.businessNickname) ??
(invoiceData.businessName
? (businessIdMap.get(invoiceData.businessName) ?? null)
: null))
: invoiceData.businessName
? (businessIdMap.get(invoiceData.businessName) ?? null)
: 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,
emailMessage: invoiceData.emailMessage,
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 };
});
}),
});