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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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 }) => ({
|
||||
|
||||
Reference in New Issue
Block a user