feat: remove start.sh script and add appearance preferences management

- Deleted the start.sh script for container management.
- Added AGENTS.md for project guidelines and development principles.
- Introduced new SQL migration files for user appearance preferences and platform settings.
- Implemented appearance provider to manage user interface themes and preferences.
- Created branding utility to define and manage branding-related constants and types.

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
2026-04-27 22:12:16 -04:00
parent b582b6c88e
commit fbeca7cfee
39 changed files with 3388 additions and 977 deletions
+22 -10
View File
@@ -1,15 +1,12 @@
import { z } from "zod";
import { Resend } from "resend";
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
import { invoices } from "~/server/db/schema";
import { invoices, platformSettings } 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(
@@ -56,7 +53,19 @@ export const emailRouter = createTRPCRouter({
// Generate PDF for attachment
let pdfBuffer: Buffer;
try {
const pdfBlob = await generateInvoicePDFBlob(invoice);
const settings = await ctx.db.query.platformSettings.findFirst({
where: eq(platformSettings.id, "global"),
});
const pdfBlob = await generateInvoicePDFBlob(invoice, {
pdfTemplate: settings?.pdfTemplate as
| "classic"
| "minimal"
| undefined,
pdfAccentColor: settings?.pdfAccentColor,
pdfFooterText: settings?.pdfFooterText,
pdfShowLogo: settings?.pdfShowLogo,
pdfShowPageNumbers: settings?.pdfShowPageNumbers,
});
pdfBuffer = Buffer.from(await pdfBlob.arrayBuffer());
// Validate PDF was generated successfully
@@ -126,14 +135,17 @@ export const emailRouter = createTRPCRouter({
: invoice.business.name) ??
userName;
fromEmail = `${fromName} <noreply@${invoice.business.resendDomain}>`;
} else if (env.RESEND_DOMAIN) {
} else if (env.RESEND_API_KEY && env.RESEND_DOMAIN) {
// Use system Resend configuration
resendInstance = defaultResend;
resendInstance = new Resend(env.RESEND_API_KEY);
fromEmail = `noreply@${env.RESEND_DOMAIN}`;
} else if (env.RESEND_API_KEY) {
resendInstance = new Resend(env.RESEND_API_KEY);
fromEmail = invoice.business?.email ?? "noreply@example.com";
} else {
// Fallback to business email if no configured domains
resendInstance = defaultResend;
fromEmail = invoice.business?.email ?? "noreply@yourdomain.com";
throw new Error(
"Email delivery is not configured. Add a Resend API key globally or on this business.",
);
}
// Prepare CC and BCC lists
+191 -19
View File
@@ -1,14 +1,48 @@
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 {
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({
@@ -76,6 +110,37 @@ const BackupDataSchema = z.object({
});
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({
@@ -85,6 +150,7 @@ export const settingsRouter = createTRPCRouter({
name: true,
email: true,
image: true,
role: true,
},
});
@@ -144,20 +210,41 @@ export const settingsRouter = createTRPCRouter({
}),
// Get theme preferences
getTheme: protectedProcedure.query(async ({ ctx }) => {
const user = await ctx.db.query.users.findFirst({
where: eq(users.id, ctx.session.user.id),
columns: {
colorTheme: true,
customColor: true,
theme: true,
},
getTheme: publicProcedure.query(async ({ ctx }) => {
const settings = await ctx.db.query.platformSettings.findFirst({
where: eq(platformSettings.id, "global"),
});
return {
colorTheme: (user?.colorTheme as "slate" | "blue" | "green" | "rose" | "orange" | "custom") ?? "slate",
customColor: user?.customColor ?? undefined,
theme: (user?.theme as "light" | "dark" | "system") ?? "system",
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,
};
}),
@@ -165,20 +252,105 @@ export const settingsRouter = createTRPCRouter({
updateTheme: protectedProcedure
.input(
z.object({
colorTheme: z.enum(["slate", "blue", "green", "rose", "orange", "custom"]).optional(),
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
.update(users)
.set({
...(input.colorTheme && { colorTheme: input.colorTheme }),
...(input.customColor !== undefined && { customColor: input.customColor }),
...(input.theme && { theme: input.theme }),
.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,
})
.where(eq(users.id, ctx.session.user.id));
.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 };
}),
+101 -16
View File
@@ -36,7 +36,10 @@ const migrationsFolder = path.resolve(__dirname, "../../../drizzle");
const pool = new Pool({
connectionString: databaseUrl,
ssl: process.env.DB_DISABLE_SSL === "true" ? false : { rejectUnauthorized: false },
ssl:
process.env.DB_DISABLE_SSL === "true"
? false
: { rejectUnauthorized: false },
max: 1,
});
@@ -50,7 +53,11 @@ const db = drizzle(pool);
* so migrate() will re-run those migrations.
*/
async function baselineIfNeeded(client: Pool) {
const hasMigrationsTable = await tableExists(client, "drizzle", "__drizzle_migrations");
const hasMigrationsTable = await tableExists(
client,
"drizzle",
"__drizzle_migrations",
);
// Always ensure the drizzle schema + table exist
await client.query(`CREATE SCHEMA IF NOT EXISTS drizzle`);
@@ -63,18 +70,24 @@ async function baselineIfNeeded(client: Pool) {
`);
const { rows: entryRows } = await client.query<{ count: string }>(
`SELECT COUNT(*)::text AS count FROM drizzle.__drizzle_migrations`
`SELECT COUNT(*)::text AS count FROM drizzle.__drizzle_migrations`,
);
const hasEntries = parseInt(entryRows[0]?.count ?? "0") > 0;
if (!hasMigrationsTable || !hasEntries) {
// No history at all — check if DB was previously set up via db:push
const dbAlreadyExists = await tableExists(client, "public", "beenvoice_account");
const dbAlreadyExists = await tableExists(
client,
"public",
"beenvoice_account",
);
if (!dbAlreadyExists) {
return; // Fresh DB — let migrate() run everything normally
}
console.log("[migrate] Existing database detected without migration history — baselining...");
console.log(
"[migrate] Existing database detected without migration history — baselining...",
);
await seedMigrationHistory(client);
return;
}
@@ -86,7 +99,7 @@ async function baselineIfNeeded(client: Pool) {
async function seedMigrationHistory(client: Pool) {
const journal = JSON.parse(
fs.readFileSync(path.join(migrationsFolder, "meta/_journal.json"), "utf8")
fs.readFileSync(path.join(migrationsFolder, "meta/_journal.json"), "utf8"),
) as { entries: { idx: number; tag: string; when: number }[] };
for (const entry of journal.entries) {
@@ -96,12 +109,13 @@ async function seedMigrationHistory(client: Pool) {
continue;
}
const sql = fs.readFileSync(
path.join(migrationsFolder, `${entry.tag}.sql`), "utf8"
path.join(migrationsFolder, `${entry.tag}.sql`),
"utf8",
);
const hash = crypto.createHash("sha256").update(sql).digest("hex");
await client.query(
`INSERT INTO drizzle.__drizzle_migrations (hash, created_at) VALUES ($1, $2)`,
[hash, entry.when]
[hash, entry.when],
);
console.log(`[migrate] Baselined: ${entry.tag}`);
}
@@ -111,16 +125,17 @@ async function seedMigrationHistory(client: Pool) {
async function removeBogusEntries(client: Pool) {
// Get all recorded hashes
const { rows } = await client.query<{ id: number; hash: string }>(
`SELECT id, hash FROM drizzle.__drizzle_migrations ORDER BY id`
`SELECT id, hash FROM drizzle.__drizzle_migrations ORDER BY id`,
);
const journal = JSON.parse(
fs.readFileSync(path.join(migrationsFolder, "meta/_journal.json"), "utf8")
fs.readFileSync(path.join(migrationsFolder, "meta/_journal.json"), "utf8"),
) as { entries: { idx: number; tag: string; when: number }[] };
for (const entry of journal.entries) {
const sql = fs.readFileSync(
path.join(migrationsFolder, `${entry.tag}.sql`), "utf8"
path.join(migrationsFolder, `${entry.tag}.sql`),
"utf8",
);
const expectedHash = crypto.createHash("sha256").update(sql).digest("hex");
const recorded = rows.find((r) => r.hash === expectedHash);
@@ -129,17 +144,29 @@ async function removeBogusEntries(client: Pool) {
// It's recorded — verify it's actually applied in the schema
const applied = await isMigrationApplied(client, entry.tag);
if (!applied) {
console.log(`[migrate] Removing bogus migration record for: ${entry.tag}`);
await client.query(`DELETE FROM drizzle.__drizzle_migrations WHERE id = $1`, [recorded.id]);
console.log(
`[migrate] Removing bogus migration record for: ${entry.tag}`,
);
await client.query(
`DELETE FROM drizzle.__drizzle_migrations WHERE id = $1`,
[recorded.id],
);
}
}
}
async function tableExists(client: Pool, schema: string, table: string): Promise<boolean> {
const { rows } = await client.query<{ count: string }>(`
async function tableExists(
client: Pool,
schema: string,
table: string,
): Promise<boolean> {
const { rows } = await client.query<{ count: string }>(
`
SELECT COUNT(*)::text AS count FROM information_schema.tables
WHERE table_schema = $1 AND table_name = $2
`, [schema, table]);
`,
[schema, table],
);
return parseInt(rows[0]?.count ?? "0") > 0;
}
@@ -170,10 +197,68 @@ async function isMigrationApplied(client: Pool, tag: string): Promise<boolean> {
`);
return parseInt(rows[0]?.count ?? "0") > 0;
}
if (tag === "0003_appearance_preferences") {
// 0003 adds appearance preferences to beenvoice_user
const { rows } = await client.query<{ count: string }>(`
SELECT COUNT(*)::text AS count FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'beenvoice_user'
AND column_name = 'interfaceTheme'
`);
return parseInt(rows[0]?.count ?? "0") > 0;
}
if (tag === "0004_platform_appearance_controls") {
// 0004 adds platform-level appearance controls to beenvoice_user
const { rows } = await client.query<{ count: string }>(`
SELECT COUNT(*)::text AS count FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'beenvoice_user'
AND column_name = 'sidebarStyle'
`);
return parseInt(rows[0]?.count ?? "0") > 0;
}
if (tag === "0005_platform_settings_and_roles") {
const hasRole = await columnExists(
client,
"public",
"beenvoice_user",
"role",
);
const hasPlatformSettings = await tableExists(
client,
"public",
"beenvoice_platform_setting",
);
return hasRole && hasPlatformSettings;
}
if (tag === "0006_pdf_generation_settings") {
return columnExists(
client,
"public",
"beenvoice_platform_setting",
"pdfTemplate",
);
}
// Unknown migration — assume not applied so it runs
return false;
}
async function columnExists(
client: Pool,
schema: string,
table: string,
column: string,
): Promise<boolean> {
const { rows } = await client.query<{ count: string }>(
`
SELECT COUNT(*)::text AS count FROM information_schema.columns
WHERE table_schema = $1 AND table_name = $2 AND column_name = $3
`,
[schema, table, column],
);
return parseInt(rows[0]?.count ?? "0") > 0;
}
console.log("[migrate] Running migrations from", migrationsFolder);
try {
+42
View File
@@ -35,6 +35,48 @@ export const users = createTable("user", (d) => ({
colorTheme: d.varchar({ length: 50 }).default("slate").notNull(),
customColor: d.varchar({ length: 50 }),
theme: d.varchar({ length: 20 }).default("system").notNull(),
interfaceTheme: d.varchar({ length: 50 }).default("beenvoice").notNull(),
fontPreference: d.varchar({ length: 50 }).default("brand").notNull(),
bodyFontPreference: d.varchar({ length: 50 }).default("brand").notNull(),
headingFontPreference: d.varchar({ length: 50 }).default("brand").notNull(),
radiusPreference: d.varchar({ length: 20 }).default("xl").notNull(),
sidebarStyle: d.varchar({ length: 20 }).default("floating").notNull(),
role: d.varchar({ length: 20 }).default("user").notNull(),
}));
export const platformSettings = createTable("platform_setting", (d) => ({
id: d.varchar({ length: 50 }).notNull().primaryKey().default("global"),
brandName: d.varchar({ length: 100 }).default("beenvoice").notNull(),
brandTagline: d
.varchar({ length: 255 })
.default(
"Simple and efficient invoicing for freelancers and small businesses",
)
.notNull(),
brandLogoText: d.varchar({ length: 100 }).default("beenvoice").notNull(),
brandIcon: d.varchar({ length: 20 }).default("$").notNull(),
colorTheme: d.varchar({ length: 50 }).default("slate").notNull(),
customColor: d.varchar({ length: 50 }),
theme: d.varchar({ length: 20 }).default("system").notNull(),
interfaceTheme: d.varchar({ length: 50 }).default("beenvoice").notNull(),
bodyFontPreference: d.varchar({ length: 50 }).default("brand").notNull(),
headingFontPreference: d.varchar({ length: 50 }).default("brand").notNull(),
radiusPreference: d.varchar({ length: 20 }).default("xl").notNull(),
sidebarStyle: d.varchar({ length: 20 }).default("floating").notNull(),
pdfTemplate: d.varchar({ length: 20 }).default("classic").notNull(),
pdfAccentColor: d.varchar({ length: 50 }).default("#111827").notNull(),
pdfFooterText: d
.varchar({ length: 120 })
.default("Professional Invoicing")
.notNull(),
pdfShowLogo: d.boolean().default(true).notNull(),
pdfShowPageNumbers: d.boolean().default(true).notNull(),
createdAt: d.timestamp().notNull().defaultNow(),
updatedAt: d
.timestamp()
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
}));
export const usersRelations = relations(users, ({ many }) => ({