Add business logo branding support
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getObject } from "~/lib/object-storage";
|
||||
import { db } from "~/server/db";
|
||||
import { businesses } from "~/server/db/schema";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const RASTERIZABLE_MIME_TYPES = new Set(["image/svg+xml", "image/webp"]);
|
||||
|
||||
// Intentionally unauthenticated: a business logo must be viewable on public,
|
||||
// token-based invoice pages without a session. Business IDs are random
|
||||
// UUIDs, so this only serves images to callers who already know the ID.
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ businessId: string }> },
|
||||
) {
|
||||
const { businessId } = await params;
|
||||
const business = await db.query.businesses.findFirst({
|
||||
where: eq(businesses.id, businessId),
|
||||
columns: { logoStorageKey: true, logoMimeType: true },
|
||||
});
|
||||
|
||||
if (!business?.logoStorageKey || !business.logoMimeType) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// @react-pdf/renderer's Image component only decodes PNG/JPEG, so PDF
|
||||
// generation requests a rasterized copy of SVG/WebP logos via this param.
|
||||
const wantsPng =
|
||||
new URL(req.url).searchParams.get("format") === "png" &&
|
||||
RASTERIZABLE_MIME_TYPES.has(business.logoMimeType);
|
||||
|
||||
try {
|
||||
const body = await getObject(business.logoStorageKey);
|
||||
|
||||
if (wantsPng) {
|
||||
const { default: sharp } = await import("sharp");
|
||||
const isSvg = business.logoMimeType === "image/svg+xml";
|
||||
// SVG is vector: rasterize at a high density so the PNG stays crisp at
|
||||
// the size it's actually displayed (PDF header, up to ~2.2in wide).
|
||||
// withoutEnlargement only makes sense for the WebP (already-raster)
|
||||
// case — for SVG it would cap us at whatever tiny canvas the source's
|
||||
// intrinsic viewBox implies, even though the vector has no such limit.
|
||||
const png = await sharp(body, isSvg ? { density: 600 } : undefined)
|
||||
.resize({
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
fit: "inside",
|
||||
withoutEnlargement: !isSvg,
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
return new NextResponse(new Uint8Array(png), {
|
||||
headers: {
|
||||
"Content-Type": "image/png",
|
||||
"Cache-Control": "public, max-age=300, must-revalidate",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return new NextResponse(new Uint8Array(body), {
|
||||
headers: {
|
||||
"Content-Type": business.logoMimeType,
|
||||
"Cache-Control": "public, max-age=300, must-revalidate",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[business-logo] Failed to serve logo", {
|
||||
backendError: error,
|
||||
businessId,
|
||||
wantsPng,
|
||||
});
|
||||
return NextResponse.json({ error: "Logo not found" }, { status: 404 });
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { generateInvoicePDFBlob } from "~/lib/pdf-export";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ token: string }> },
|
||||
) {
|
||||
const { token } = await params;
|
||||
@@ -16,7 +16,28 @@ export async function GET(
|
||||
where: eq(invoices.publicToken, token),
|
||||
with: {
|
||||
client: true,
|
||||
business: true,
|
||||
// Explicit allowlist: token-based public route — never fetch
|
||||
// secret fields (resendApiKey, resendDomain) for an unauthenticated request.
|
||||
business: {
|
||||
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,
|
||||
logoStorageKey: true,
|
||||
logoMimeType: true,
|
||||
hideNameWithLogo: true,
|
||||
},
|
||||
},
|
||||
items: {
|
||||
orderBy: (i, { asc }) => [
|
||||
asc(i.date),
|
||||
@@ -39,19 +60,27 @@ export async function GET(
|
||||
where: eq(platformSettings.id, "global"),
|
||||
});
|
||||
|
||||
const pdfBlob = await generateInvoicePDFBlob(invoice, {
|
||||
pdfTemplate: settings?.pdfTemplate as "classic" | "minimal" | undefined,
|
||||
pdfAccentColor: settings?.pdfAccentColor,
|
||||
pdfFontFamily: settings?.pdfFontFamily as "sans" | "serif" | "mono" | undefined,
|
||||
pdfNumericFontFamily: settings?.pdfNumericFontFamily as
|
||||
| "sans"
|
||||
| "serif"
|
||||
| "mono"
|
||||
| undefined,
|
||||
pdfFooterText: settings?.pdfFooterText,
|
||||
pdfShowLogo: settings?.pdfShowLogo,
|
||||
pdfShowPageNumbers: settings?.pdfShowPageNumbers,
|
||||
});
|
||||
const pdfBlob = await generateInvoicePDFBlob(
|
||||
invoice,
|
||||
{
|
||||
pdfTemplate: settings?.pdfTemplate as "classic" | "minimal" | undefined,
|
||||
pdfAccentColor: settings?.pdfAccentColor,
|
||||
pdfFontFamily: settings?.pdfFontFamily as
|
||||
| "sans"
|
||||
| "serif"
|
||||
| "mono"
|
||||
| undefined,
|
||||
pdfNumericFontFamily: settings?.pdfNumericFontFamily as
|
||||
| "sans"
|
||||
| "serif"
|
||||
| "mono"
|
||||
| undefined,
|
||||
pdfFooterText: settings?.pdfFooterText,
|
||||
pdfShowLogo: settings?.pdfShowLogo,
|
||||
pdfShowPageNumbers: settings?.pdfShowPageNumbers,
|
||||
},
|
||||
{ logoBaseUrl: new URL(request.url).origin },
|
||||
);
|
||||
|
||||
const buffer = await pdfBlob.arrayBuffer();
|
||||
const filename = `invoice-${invoice.invoiceNumber}.pdf`;
|
||||
|
||||
@@ -74,9 +74,20 @@ export default async function BusinessDetailPage({
|
||||
<Card className="bg-card border-border border">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<div className="bg-primary/10 p-2">
|
||||
<Building className="text-primary h-5 w-5" />
|
||||
</div>
|
||||
{business.logoStorageKey ? (
|
||||
<div className="bg-muted border-border/40 flex h-9 max-w-32 shrink-0 items-center justify-center overflow-hidden border px-1.5 py-1">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- object-storage-backed image, not a static asset */}
|
||||
<img
|
||||
src={`/api/business-logo/${business.id}`}
|
||||
alt={`${business.name} logo`}
|
||||
className="h-full w-auto max-w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-primary/10 p-2">
|
||||
<Building className="text-primary h-5 w-5" />
|
||||
</div>
|
||||
)}
|
||||
<span>Business Information</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
@@ -34,6 +34,7 @@ interface Business {
|
||||
website: string | null;
|
||||
taxId: string | null;
|
||||
logoUrl: string | null;
|
||||
logoStorageKey: string | null;
|
||||
createdById: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date | null;
|
||||
@@ -86,8 +87,17 @@ export function BusinessesDataTable({ businesses }: BusinessesDataTableProps) {
|
||||
const business = row.original;
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-primary/10 hidden p-2 sm:flex">
|
||||
<Building className="text-primary h-4 w-4" />
|
||||
<div className="bg-primary/10 hidden h-8 w-8 shrink-0 items-center justify-center overflow-hidden p-2 sm:flex">
|
||||
{business.logoStorageKey ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- object-storage-backed image, not a static asset
|
||||
<img
|
||||
src={`/api/business-logo/${business.id}`}
|
||||
alt=""
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<Building className="text-primary h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium">{business.name}</p>
|
||||
|
||||
@@ -377,6 +377,16 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{invoice.business.logoStorageKey && (
|
||||
<div className="bg-muted border-border/40 flex h-12 max-w-40 w-fit items-center justify-center overflow-hidden border px-2 py-1.5">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- object-storage-backed image, not a static asset */}
|
||||
<img
|
||||
src={`/api/business-logo/${invoice.business.id}`}
|
||||
alt={`${invoice.business.name} logo`}
|
||||
className="h-full w-auto max-w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<h3 className="text-foreground text-xl font-semibold">
|
||||
{invoice.business.name}
|
||||
</h3>
|
||||
|
||||
@@ -204,9 +204,12 @@ export default function SendEmailPage() {
|
||||
: undefined,
|
||||
business: invoiceData.business
|
||||
? {
|
||||
id: invoiceData.business.id,
|
||||
name: invoiceData.business.name,
|
||||
nickname: invoiceData.business.nickname,
|
||||
email: invoiceData.business.email,
|
||||
logoStorageKey: invoiceData.business.logoStorageKey,
|
||||
logoMimeType: invoiceData.business.logoMimeType,
|
||||
}
|
||||
: undefined,
|
||||
items: invoiceData.items?.map((item) => ({
|
||||
|
||||
@@ -92,6 +92,8 @@ function PublicInvoiceView({ token }: { token: string }) {
|
||||
? `${invoice.business.name} (${invoice.business.nickname})`
|
||||
: invoice.business.name
|
||||
: null;
|
||||
const hasLogo = Boolean(invoice.business?.logoStorageKey);
|
||||
const hideName = hasLogo && Boolean(invoice.business?.hideNameWithLogo);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-10 px-4">
|
||||
@@ -99,11 +101,22 @@ function PublicInvoiceView({ token }: { token: string }) {
|
||||
{/* Card */}
|
||||
<div className="overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm">
|
||||
{/* Header */}
|
||||
<div className="bg-gray-900 px-8 py-6">
|
||||
<p className="text-lg font-bold text-white">{senderName ?? "Invoice"}</p>
|
||||
{invoice.business?.email && (
|
||||
<p className="mt-0.5 text-sm text-gray-400">{invoice.business.email}</p>
|
||||
<div className="flex items-center gap-3 bg-gray-900 px-8 py-6">
|
||||
{hasLogo && (
|
||||
<img
|
||||
src={`/api/business-logo/${invoice.business!.id}`}
|
||||
alt=""
|
||||
className="h-16 w-auto max-w-[220px] shrink-0 rounded bg-white object-contain px-2 py-1.5"
|
||||
/>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
{!hideName && (
|
||||
<p className="truncate text-lg font-bold text-white">{senderName ?? "Invoice"}</p>
|
||||
)}
|
||||
{invoice.business?.email && (
|
||||
<p className="mt-0.5 truncate text-sm text-gray-400">{invoice.business.email}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
|
||||
+2
-14
@@ -8,6 +8,7 @@ import { getAppUrl } from "~/lib/app-url";
|
||||
import { brand } from "~/lib/branding";
|
||||
|
||||
import { UmamiScript } from "~/components/analytics/umami-script";
|
||||
import { AppearanceInitScript } from "~/components/layout/appearance-init-script";
|
||||
import { BrandBackground } from "~/components/layout/brand-background";
|
||||
|
||||
const siteTitle = `${brand.name} - Invoicing Made Simple`;
|
||||
@@ -63,20 +64,7 @@ export default function RootLayout({
|
||||
className={`${geistSans.variable} ${playfair.variable} ${geistMono.variable}`}
|
||||
>
|
||||
<head>
|
||||
<script
|
||||
id="appearance-init"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
try {
|
||||
var stored = JSON.parse(localStorage.getItem("bv.appearance") || "{}");
|
||||
var colorMode = stored.colorMode || "system";
|
||||
var root = document.documentElement;
|
||||
root.dataset.colorMode = colorMode;
|
||||
if (colorMode === "dark") root.classList.add("dark");
|
||||
} catch {}
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
<AppearanceInitScript />
|
||||
</head>
|
||||
<body className="bg-background text-foreground relative min-h-screen overflow-x-hidden font-sans antialiased">
|
||||
<BrandBackground />
|
||||
|
||||
@@ -7,12 +7,15 @@ import {
|
||||
EyeOff,
|
||||
FileText,
|
||||
Globe,
|
||||
ImageIcon,
|
||||
Info,
|
||||
Key,
|
||||
Loader2,
|
||||
Mail,
|
||||
Save,
|
||||
Star,
|
||||
Trash2,
|
||||
Upload,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -59,6 +62,7 @@ interface FormData {
|
||||
country: string;
|
||||
website: string;
|
||||
taxId: string;
|
||||
hideNameWithLogo: boolean;
|
||||
isDefault: boolean;
|
||||
resendApiKey: string;
|
||||
resendDomain: string;
|
||||
@@ -95,20 +99,31 @@ const initialFormData: FormData = {
|
||||
country: "United States",
|
||||
website: "",
|
||||
taxId: "",
|
||||
hideNameWithLogo: false,
|
||||
isDefault: false,
|
||||
resendApiKey: "",
|
||||
resendDomain: "",
|
||||
emailFromName: "",
|
||||
};
|
||||
|
||||
const MAX_LOGO_BYTES = 5 * 1024 * 1024;
|
||||
const ACCEPTED_LOGO_TYPES = new Set([
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/webp",
|
||||
"image/svg+xml",
|
||||
]);
|
||||
|
||||
export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
||||
const router = useRouter();
|
||||
const utils = api.useUtils();
|
||||
const [formData, setFormData] = useState<FormData>(initialFormData);
|
||||
const [errors, setErrors] = useState<FormErrors>({});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
const [isUploadingLogo, setIsUploadingLogo] = useState(false);
|
||||
|
||||
// Fetch business data if editing
|
||||
const { data: business, isLoading: isLoadingBusiness } =
|
||||
@@ -149,6 +164,62 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
||||
},
|
||||
});
|
||||
|
||||
const uploadLogo = api.businesses.uploadLogo.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.businesses.getById.invalidate({ id: businessId });
|
||||
toast.success("Logo updated");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || "Failed to upload logo");
|
||||
},
|
||||
onSettled: () => setIsUploadingLogo(false),
|
||||
});
|
||||
|
||||
const removeLogo = api.businesses.removeLogo.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.businesses.getById.invalidate({ id: businessId });
|
||||
toast.success("Logo removed");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || "Failed to remove logo");
|
||||
},
|
||||
});
|
||||
|
||||
const handleLogoFileSelected = async (
|
||||
e: React.ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (!file || !businessId) return;
|
||||
|
||||
if (!ACCEPTED_LOGO_TYPES.has(file.type)) {
|
||||
toast.error("Logo must be a PNG, JPEG, WebP, or SVG image");
|
||||
return;
|
||||
}
|
||||
if (file.size > MAX_LOGO_BYTES) {
|
||||
toast.error("Logo must be 5MB or less");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUploadingLogo(true);
|
||||
const data = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const result = reader.result;
|
||||
resolve(typeof result === "string" ? (result.split(",")[1] ?? "") : "");
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
uploadLogo.mutate({
|
||||
id: businessId,
|
||||
filename: file.name,
|
||||
mimeType: file.type,
|
||||
data,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- Reset form when navigating to a different business.
|
||||
setInitialized(false);
|
||||
@@ -178,6 +249,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
||||
country: business.country ?? "United States",
|
||||
website: business.website ?? "",
|
||||
taxId: business.taxId ?? "",
|
||||
hideNameWithLogo: business.hideNameWithLogo ?? false,
|
||||
isDefault: business.isDefault ?? false,
|
||||
resendApiKey: "", // Never pre-fill API key for security
|
||||
resendDomain: emailConfig?.resendDomain ?? "",
|
||||
@@ -338,6 +410,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
||||
country: dataToSubmit.country,
|
||||
website: dataToSubmit.website,
|
||||
taxId: dataToSubmit.taxId,
|
||||
hideNameWithLogo: dataToSubmit.hideNameWithLogo,
|
||||
isDefault: dataToSubmit.isDefault,
|
||||
};
|
||||
|
||||
@@ -376,6 +449,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
||||
country: dataToSubmit.country,
|
||||
website: dataToSubmit.website,
|
||||
taxId: dataToSubmit.taxId,
|
||||
hideNameWithLogo: dataToSubmit.hideNameWithLogo,
|
||||
isDefault: dataToSubmit.isDefault,
|
||||
};
|
||||
|
||||
@@ -649,6 +723,110 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Logo */}
|
||||
{mode === "edit" && businessId && (
|
||||
<Card className="bg-card border-border border">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-muted flex h-10 w-10 items-center justify-center">
|
||||
<ImageIcon className="text-muted-foreground h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle>Logo</CardTitle>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
Shown on invoices sent to your clients. PNG, JPEG,
|
||||
WebP, or SVG, up to 5MB.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="bg-muted border-border/40 flex h-20 min-w-20 max-w-[240px] shrink-0 items-center justify-center overflow-hidden border px-2">
|
||||
{business?.logoStorageKey ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- external/object-storage-backed image, not a static asset
|
||||
<img
|
||||
src={`/api/business-logo/${businessId}?v=${business.updatedAt ? new Date(business.updatedAt).getTime() : 0}`}
|
||||
alt={`${business.name} logo`}
|
||||
className="h-full w-auto max-w-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<ImageIcon className="text-muted-foreground/50 h-8 w-8" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isUploadingLogo}
|
||||
onClick={() =>
|
||||
document.getElementById("logo-upload-input")?.click()
|
||||
}
|
||||
>
|
||||
{isUploadingLogo ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin sm:mr-2" />
|
||||
) : (
|
||||
<Upload className="h-4 w-4 sm:mr-2" />
|
||||
)}
|
||||
<span className="hidden sm:inline">
|
||||
{business?.logoStorageKey
|
||||
? "Replace logo"
|
||||
: "Upload logo"}
|
||||
</span>
|
||||
</Button>
|
||||
{business?.logoStorageKey && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={removeLogo.isPending}
|
||||
onClick={() =>
|
||||
businessId && removeLogo.mutate({ id: businessId })
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 sm:mr-2" />
|
||||
<span className="hidden sm:inline">Remove</span>
|
||||
</Button>
|
||||
)}
|
||||
<input
|
||||
id="logo-upload-input"
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp,image/svg+xml"
|
||||
className="hidden"
|
||||
onChange={handleLogoFileSelected}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{business?.logoStorageKey && (
|
||||
<div className="bg-muted border-border/40 mt-4 flex items-center justify-between border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label
|
||||
htmlFor="hideNameWithLogo"
|
||||
className="text-base font-medium"
|
||||
>
|
||||
Hide business name on invoices
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Show only the logo in the invoice header — useful
|
||||
if your logo already includes your business name.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="hideNameWithLogo"
|
||||
checked={formData.hideNameWithLogo}
|
||||
onCheckedChange={(checked) =>
|
||||
handleInputChange("hideNameWithLogo", checked)
|
||||
}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Address */}
|
||||
<Card className="bg-card border-border border">
|
||||
<CardHeader>
|
||||
|
||||
@@ -25,8 +25,11 @@ interface EmailPreviewProps {
|
||||
email: string | null;
|
||||
};
|
||||
business?: {
|
||||
id?: string;
|
||||
name: string;
|
||||
email: string | null;
|
||||
logoStorageKey?: string | null;
|
||||
logoMimeType?: string | null;
|
||||
};
|
||||
items?: Array<{
|
||||
id: string;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
// Sets data-color-mode / .dark on <html> from localStorage before paint, to
|
||||
// avoid a flash of the wrong theme. Rendered only during SSR (typeof window
|
||||
// check) and returns null on the client, so the <script> element never
|
||||
// enters the tree React reconciles during hydration — React 19 otherwise
|
||||
// warns "Encountered a script tag while rendering React component" for any
|
||||
// <script> it walks while hydrating, even one from next/script. Same fix
|
||||
// next-themes ships for its inline ThemeScript (shadcn-ui/ui#10238).
|
||||
const APPEARANCE_INIT_SOURCE = `
|
||||
try {
|
||||
var stored = JSON.parse(localStorage.getItem("bv.appearance") || "{}");
|
||||
var colorMode = stored.colorMode || "system";
|
||||
var root = document.documentElement;
|
||||
root.dataset.colorMode = colorMode;
|
||||
if (colorMode === "dark") root.classList.add("dark");
|
||||
} catch {}
|
||||
`;
|
||||
|
||||
export function AppearanceInitScript() {
|
||||
if (typeof window !== "undefined") return null;
|
||||
return (
|
||||
<script
|
||||
id="appearance-init"
|
||||
suppressHydrationWarning
|
||||
dangerouslySetInnerHTML={{ __html: APPEARANCE_INIT_SOURCE }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -19,7 +19,13 @@ export function AppearanceProviderSynced({
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [colorMode, setColorMode] = useState<ColorMode>(defaultColorMode);
|
||||
// Lazy initializer so the first render already matches what the inline
|
||||
// appearance-init script set on <html> — a separate mount effect here
|
||||
// would run one render behind it, transiently flashing (and persisting)
|
||||
// colorMode back to the default before the effect's own state update lands.
|
||||
const [colorMode, setColorMode] = useState<ColorMode>(
|
||||
() => readStoredColorMode() ?? defaultColorMode,
|
||||
);
|
||||
const serverHydratedRef = useRef(false);
|
||||
const utils = api.useUtils();
|
||||
const updateMutation = api.settings.updateColorMode.useMutation({
|
||||
@@ -41,14 +47,6 @@ export function AppearanceProviderSynced({
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const stored = readStoredColorMode();
|
||||
if (stored) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setColorMode(stored);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!serverColorMode?.colorMode) return;
|
||||
if (serverHydratedRef.current) return;
|
||||
|
||||
@@ -63,15 +63,13 @@ export function AppearanceProvider({
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [colorMode, setColorMode] = useState<ColorMode>(defaultColorMode);
|
||||
|
||||
useEffect(() => {
|
||||
const stored = readStoredColorMode();
|
||||
if (stored) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setColorMode(stored);
|
||||
}
|
||||
}, []);
|
||||
// Lazy initializer so the first render already matches what the inline
|
||||
// appearance-init script set on <html> — a separate mount effect here
|
||||
// would run one render behind it, transiently flashing (and persisting)
|
||||
// colorMode back to the default before the effect's own state update lands.
|
||||
const [colorMode, setColorMode] = useState<ColorMode>(
|
||||
() => readStoredColorMode() ?? defaultColorMode,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
applyColorMode(colorMode);
|
||||
|
||||
@@ -10,6 +10,25 @@ export function getAppUrl(): string {
|
||||
return `http://localhost:${process.env.PORT ?? 3000}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Origin derived from an incoming request's own headers — robust against
|
||||
* NEXT_PUBLIC_APP_URL/BETTER_AUTH_URL drifting from the port the server is
|
||||
* actually reachable on (e.g. local dev when the configured port is taken).
|
||||
* Falls back to getAppUrl() if the request has no usable host header.
|
||||
*/
|
||||
export function getRequestOrigin(headers: Headers): string {
|
||||
const host = headers.get("x-forwarded-host") ?? headers.get("host");
|
||||
if (!host) return getAppUrl();
|
||||
|
||||
const forwardedProto = headers.get("x-forwarded-proto");
|
||||
const protocol =
|
||||
forwardedProto ?? (host.startsWith("localhost:") || host.startsWith("127.0.0.1:")
|
||||
? "http"
|
||||
: "https");
|
||||
|
||||
return `${protocol}://${host}`;
|
||||
}
|
||||
|
||||
/** Hostname for display (e.g. marketing browser chrome). */
|
||||
export function getAppHost(): string {
|
||||
try {
|
||||
|
||||
@@ -39,6 +39,11 @@ export const auth = betterAuth({
|
||||
secret: process.env.AUTH_SECRET,
|
||||
advanced: {
|
||||
trustedProxyHeaders: true,
|
||||
// Login from a LAN IP, ngrok tunnel, or the Expo dev client hits the API
|
||||
// from an Origin that's rarely worth adding to trustedOrigins ahead of
|
||||
// time. Skip the Origin/CSRF check in dev only; production still
|
||||
// enforces it via trustedOrigins above.
|
||||
...(env.NODE_ENV === "development" ? { disableCSRFCheck: true } : {}),
|
||||
},
|
||||
rateLimit: {
|
||||
enabled: true,
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
import { getAppUrl } from "~/lib/app-url";
|
||||
|
||||
// Most email clients render <img src> fine for PNG/JPEG but are inconsistent
|
||||
// with SVG (Outlook and several webmail clients strip or refuse it), so
|
||||
// non-raster logos are requested through the same on-the-fly PNG
|
||||
// rasterization the PDF export uses.
|
||||
function resolveEmailLogoUrl(
|
||||
business: { id?: string; logoStorageKey?: string | null; logoMimeType?: string | null } | null | undefined,
|
||||
baseUrl: string,
|
||||
): string | null {
|
||||
if (!business?.id || !business.logoStorageKey) return null;
|
||||
const needsRaster =
|
||||
business.logoMimeType != null &&
|
||||
!["image/png", "image/jpeg"].includes(business.logoMimeType);
|
||||
const path = `/api/business-logo/${business.id}${needsRaster ? "?format=png" : ""}`;
|
||||
return `${baseUrl.replace(/\/$/, "")}${path}`;
|
||||
}
|
||||
|
||||
interface InvoiceEmailTemplateProps {
|
||||
invoice: {
|
||||
invoiceNumber: string;
|
||||
@@ -14,6 +30,7 @@ interface InvoiceEmailTemplateProps {
|
||||
email: string | null;
|
||||
};
|
||||
business?: {
|
||||
id?: string;
|
||||
name: string;
|
||||
nickname?: string | null;
|
||||
email?: string | null;
|
||||
@@ -24,6 +41,8 @@ interface InvoiceEmailTemplateProps {
|
||||
state?: string | null;
|
||||
postalCode?: string | null;
|
||||
country?: string | null;
|
||||
logoStorageKey?: string | null;
|
||||
logoMimeType?: string | null;
|
||||
} | null;
|
||||
items: Array<{
|
||||
date: Date;
|
||||
@@ -73,6 +92,7 @@ export function generateInvoiceEmailTemplate({
|
||||
const subtotal = invoice.items.reduce((sum, item) => sum + item.amount, 0);
|
||||
const taxAmount = subtotal * (invoice.taxRate / 100);
|
||||
const total = subtotal + taxAmount;
|
||||
const logoUrl = resolveEmailLogoUrl(invoice.business, baseUrl);
|
||||
|
||||
const businessAddress = invoice.business
|
||||
? [
|
||||
@@ -409,6 +429,7 @@ export function generateInvoiceEmailTemplate({
|
||||
<body>
|
||||
<div class="email-container">
|
||||
<div class="header">
|
||||
${logoUrl ? `<img src="${logoUrl}" alt="${invoice.business?.name ?? ""}" style="max-height: 40px; max-width: 200px; margin-bottom: 12px;">` : ""}
|
||||
<div class="header-content">Invoice ${invoice.invoiceNumber}</div>
|
||||
<div class="header-subtitle">From ${invoice.business?.name ?? "Your Business"}</div>
|
||||
</div>
|
||||
|
||||
@@ -56,7 +56,11 @@ export const navigationConfig: NavSection[] = [
|
||||
{ name: "Time clock", href: "/dashboard/time-clock", icon: Clock },
|
||||
{ name: "Entities", href: "/dashboard/entities", icon: Users },
|
||||
{ name: "Invoices", href: "/dashboard/invoices", icon: FileText },
|
||||
{ name: "Recurring", href: "/dashboard/invoices/recurring", icon: RefreshCw },
|
||||
{
|
||||
name: "Recurring",
|
||||
href: "/dashboard/invoices/recurring",
|
||||
icon: RefreshCw,
|
||||
},
|
||||
{ name: "Expenses", href: "/dashboard/expenses", icon: Receipt },
|
||||
{ name: "Reports", href: "/dashboard/reports", icon: BarChart2 },
|
||||
],
|
||||
|
||||
+72
-13
@@ -75,6 +75,7 @@ export interface InvoiceData {
|
||||
currency?: string | null;
|
||||
notes?: string | null;
|
||||
business?: {
|
||||
id?: string;
|
||||
name: string;
|
||||
nickname?: string | null;
|
||||
email?: string | null;
|
||||
@@ -87,6 +88,9 @@ export interface InvoiceData {
|
||||
country?: string | null;
|
||||
website?: string | null;
|
||||
taxId?: string | null;
|
||||
logoStorageKey?: string | null;
|
||||
logoMimeType?: string | null;
|
||||
hideNameWithLogo?: boolean | null;
|
||||
} | null;
|
||||
client?: {
|
||||
name: string;
|
||||
@@ -843,14 +847,45 @@ function getColumnWidths(showRate: boolean) {
|
||||
: { date: "15%", description: "48%", hours: "14%", amount: "23%" };
|
||||
}
|
||||
|
||||
// @react-pdf/renderer's Image component only reliably decodes PNG/JPEG.
|
||||
// SVG and WebP logos are rasterized to PNG on the fly by the serving route
|
||||
// (via ?format=png, using sharp) so every logo format still shows in the PDF.
|
||||
function resolveBusinessLogoSrc(
|
||||
business: InvoiceData["business"],
|
||||
baseUrlOverride?: string,
|
||||
): string | null {
|
||||
if (!business?.id || !business.logoStorageKey) return null;
|
||||
|
||||
const needsRaster =
|
||||
business.logoMimeType != null &&
|
||||
!["image/png", "image/jpeg"].includes(business.logoMimeType);
|
||||
const path = `/api/business-logo/${business.id}${needsRaster ? "?format=png" : ""}`;
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
return `${window.location.origin}${path}`;
|
||||
}
|
||||
// Server-side rendering has no window.location — callers that know their
|
||||
// own request origin (e.g. a Route Handler) should pass baseUrlOverride,
|
||||
// since NEXT_PUBLIC_APP_URL/BETTER_AUTH_URL can drift from the port the
|
||||
// server actually ends up running on (e.g. in local dev).
|
||||
const base =
|
||||
baseUrlOverride ??
|
||||
process.env.NEXT_PUBLIC_APP_URL ??
|
||||
process.env.BETTER_AUTH_URL;
|
||||
return base ? `${base.replace(/\/$/, "")}${path}` : null;
|
||||
}
|
||||
|
||||
// Dense header component (first page)
|
||||
const DenseHeader: React.FC<{
|
||||
invoice: InvoiceData;
|
||||
settings: Required<PDFGenerationSettings>;
|
||||
pdfStyles: PdfStyleBundle;
|
||||
}> = ({ invoice, settings, pdfStyles }) => {
|
||||
logoBaseUrl?: string;
|
||||
}> = ({ invoice, settings, pdfStyles, logoBaseUrl }) => {
|
||||
const { styles, minimalStyles, getStatusStyle } = pdfStyles;
|
||||
const isMinimal = settings.pdfTemplate === "minimal";
|
||||
const logoSrc = resolveBusinessLogoSrc(invoice.business, logoBaseUrl);
|
||||
const hideName = Boolean(logoSrc && invoice.business?.hideNameWithLogo);
|
||||
|
||||
return (
|
||||
<View
|
||||
@@ -860,15 +895,30 @@ const DenseHeader: React.FC<{
|
||||
style={[styles.headerTop, isMinimal ? minimalStyles.headerTop : {}]}
|
||||
>
|
||||
<View style={styles.businessSection}>
|
||||
<Text
|
||||
style={[
|
||||
styles.businessName,
|
||||
isMinimal ? minimalStyles.businessName : {},
|
||||
{ color: settings.pdfAccentColor },
|
||||
]}
|
||||
>
|
||||
{invoice.business?.name ?? "Your Business Name"}
|
||||
</Text>
|
||||
{logoSrc && (
|
||||
// eslint-disable-next-line jsx-a11y/alt-text -- @react-pdf/renderer Image does not support alt.
|
||||
<Image
|
||||
src={logoSrc}
|
||||
style={{
|
||||
width: 160,
|
||||
height: 64,
|
||||
marginBottom: 6,
|
||||
objectFit: "contain",
|
||||
objectPosition: "left",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!hideName && (
|
||||
<Text
|
||||
style={[
|
||||
styles.businessName,
|
||||
isMinimal ? minimalStyles.businessName : {},
|
||||
{ color: settings.pdfAccentColor },
|
||||
]}
|
||||
>
|
||||
{invoice.business?.name ?? "Your Business Name"}
|
||||
</Text>
|
||||
)}
|
||||
{invoice.business?.email && (
|
||||
<Text
|
||||
style={[
|
||||
@@ -1390,7 +1440,8 @@ const TotalsSection: React.FC<{
|
||||
export const InvoicePDF: React.FC<{
|
||||
invoice: InvoiceData;
|
||||
settings?: PDFGenerationSettings;
|
||||
}> = ({ invoice, settings: inputSettings }) => {
|
||||
logoBaseUrl?: string;
|
||||
}> = ({ invoice, settings: inputSettings, logoBaseUrl }) => {
|
||||
const settings = resolvePDFSettings(inputSettings);
|
||||
const pdfStyles = getPdfStyleBundle(
|
||||
settings.pdfFontFamily,
|
||||
@@ -1402,6 +1453,7 @@ export const InvoicePDF: React.FC<{
|
||||
invoice={invoice}
|
||||
settings={settings}
|
||||
pdfStyles={pdfStyles}
|
||||
logoBaseUrl={logoBaseUrl}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1410,7 +1462,8 @@ const InvoicePDFDocument: React.FC<{
|
||||
invoice: InvoiceData;
|
||||
settings: Required<PDFGenerationSettings>;
|
||||
pdfStyles: PdfStyleBundle;
|
||||
}> = ({ invoice, settings, pdfStyles }) => {
|
||||
logoBaseUrl?: string;
|
||||
}> = ({ invoice, settings, pdfStyles, logoBaseUrl }) => {
|
||||
const { styles, minimalStyles } = pdfStyles;
|
||||
const items = invoice.items?.filter(Boolean) ?? [];
|
||||
const currency = invoice.currency ?? "USD";
|
||||
@@ -1426,6 +1479,7 @@ const InvoicePDFDocument: React.FC<{
|
||||
>
|
||||
<DenseHeader
|
||||
invoice={invoice}
|
||||
logoBaseUrl={logoBaseUrl}
|
||||
settings={settings}
|
||||
pdfStyles={pdfStyles}
|
||||
/>
|
||||
@@ -1592,6 +1646,7 @@ export async function generateInvoicePDF(
|
||||
export async function generateInvoicePDFBlob(
|
||||
invoice: InvoiceData,
|
||||
settings?: PDFGenerationSettings,
|
||||
options?: { logoBaseUrl?: string },
|
||||
): Promise<Blob> {
|
||||
try {
|
||||
// Validate invoice data
|
||||
@@ -1609,7 +1664,11 @@ export async function generateInvoicePDFBlob(
|
||||
|
||||
// Generate PDF blob
|
||||
const originalBlob = await pdf(
|
||||
<InvoicePDF invoice={invoice} settings={settings} />,
|
||||
<InvoicePDF
|
||||
invoice={invoice}
|
||||
settings={settings}
|
||||
logoBaseUrl={options?.logoBaseUrl}
|
||||
/>,
|
||||
).toBlob();
|
||||
|
||||
// Validate blob
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import "server-only";
|
||||
|
||||
/**
|
||||
* Lightweight defense-in-depth pass over uploaded SVG markup before it is
|
||||
* stored. Strips executable content (scripts, event handlers, external
|
||||
* references) so a malicious SVG can't run script if it's ever rendered
|
||||
* inline (dangerouslySetInnerHTML) rather than via <img src>. Not a full
|
||||
* parser — good enough for a self-uploaded logo, not a substitute for
|
||||
* treating SVG as active content from an untrusted source.
|
||||
*/
|
||||
export function sanitizeSvg(input: string): string {
|
||||
let svg = input;
|
||||
|
||||
// Strip <script>...</script> blocks and self-closing <script/> tags.
|
||||
svg = svg.replace(/<script[\s\S]*?<\/script\s*>/gi, "");
|
||||
svg = svg.replace(/<script\b[^>]*\/>/gi, "");
|
||||
|
||||
// Strip on* event handler attributes (onload, onclick, onerror, ...).
|
||||
svg = svg.replace(/\son\w+\s*=\s*"[^"]*"/gi, "");
|
||||
svg = svg.replace(/\son\w+\s*=\s*'[^']*'/gi, "");
|
||||
svg = svg.replace(/\son\w+\s*=\s*[^\s>]+/gi, "");
|
||||
|
||||
// Strip javascript: URIs in href/xlink:href/src attributes.
|
||||
svg = svg.replace(
|
||||
/((?:xlink:href|href|src)\s*=\s*)"javascript:[^"]*"/gi,
|
||||
'$1""',
|
||||
);
|
||||
svg = svg.replace(
|
||||
/((?:xlink:href|href|src)\s*=\s*)'javascript:[^']*'/gi,
|
||||
"$1''",
|
||||
);
|
||||
|
||||
// Strip <foreignObject> (can embed arbitrary HTML) and <iframe>.
|
||||
svg = svg.replace(/<foreignObject[\s\S]*?<\/foreignObject\s*>/gi, "");
|
||||
svg = svg.replace(/<iframe[\s\S]*?<\/iframe\s*>/gi, "");
|
||||
|
||||
return svg;
|
||||
}
|
||||
+7
-1
@@ -17,7 +17,13 @@ export function proxy(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl;
|
||||
|
||||
// Define API routes that should be handled separately
|
||||
const apiRoutes = ["/api/auth", "/api/trpc", "/api/mcp", "/api/i"];
|
||||
const apiRoutes = [
|
||||
"/api/auth",
|
||||
"/api/trpc",
|
||||
"/api/mcp",
|
||||
"/api/i",
|
||||
"/api/business-logo",
|
||||
];
|
||||
|
||||
// Allow API routes to pass through
|
||||
if (apiRoutes.some((route) => pathname.startsWith(route))) {
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
import { z } from "zod";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
import { businesses } from "~/server/db/schema";
|
||||
import { eq, and, desc } from "drizzle-orm";
|
||||
import { invoices } from "~/server/db/schema";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { deleteObject, putObject } from "~/lib/object-storage";
|
||||
import { sanitizeSvg } from "~/lib/svg-sanitize";
|
||||
|
||||
const MAX_LOGO_BYTES = 5 * 1024 * 1024;
|
||||
const allowedLogoMimeTypes = new Set([
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/webp",
|
||||
"image/svg+xml",
|
||||
]);
|
||||
|
||||
const businessSchema = z.object({
|
||||
name: z
|
||||
@@ -28,6 +39,7 @@ const businessSchema = z.object({
|
||||
website: z.string().url().optional().or(z.literal("")),
|
||||
taxId: z.string().optional().or(z.literal("")),
|
||||
logoUrl: z.string().optional().or(z.literal("")),
|
||||
hideNameWithLogo: z.boolean().default(false),
|
||||
isDefault: z.boolean().default(false),
|
||||
});
|
||||
|
||||
@@ -153,6 +165,7 @@ export const businessesRouter = createTRPCRouter({
|
||||
input.logoUrl && input.logoUrl.trim() !== ""
|
||||
? input.logoUrl.trim()
|
||||
: null,
|
||||
hideNameWithLogo: input.hideNameWithLogo ?? false,
|
||||
isDefault: input.isDefault ?? false,
|
||||
createdById: ctx.session.user.id,
|
||||
})
|
||||
@@ -232,6 +245,7 @@ export const businessesRouter = createTRPCRouter({
|
||||
updateData.logoUrl && updateData.logoUrl.trim() !== ""
|
||||
? updateData.logoUrl.trim()
|
||||
: null,
|
||||
hideNameWithLogo: updateData.hideNameWithLogo ?? false,
|
||||
isDefault: updateData.isDefault ?? false,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
@@ -417,4 +431,124 @@ export const businessesRouter = createTRPCRouter({
|
||||
hasApiKey: !!business[0].hasApiKey,
|
||||
};
|
||||
}),
|
||||
|
||||
// Upload (or replace) a business logo, shown on invoices
|
||||
uploadLogo: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
filename: z.string().min(1).max(255),
|
||||
mimeType: z.string().min(1).max(100),
|
||||
data: z.string().min(1),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const [business] = await ctx.db
|
||||
.select()
|
||||
.from(businesses)
|
||||
.where(
|
||||
and(
|
||||
eq(businesses.id, input.id),
|
||||
eq(businesses.createdById, ctx.session.user.id),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!business) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Business not found or you don't have permission to update it",
|
||||
});
|
||||
}
|
||||
|
||||
const mimeType = input.mimeType.toLowerCase().split(";")[0]!.trim();
|
||||
if (!allowedLogoMimeTypes.has(mimeType)) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Logo must be a PNG, JPEG, WebP, or SVG image",
|
||||
});
|
||||
}
|
||||
|
||||
let body = Buffer.from(input.data, "base64");
|
||||
if (!body.length || body.length > MAX_LOGO_BYTES) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Logo must be between 1 byte and 5MB",
|
||||
});
|
||||
}
|
||||
|
||||
if (mimeType === "image/svg+xml") {
|
||||
body = Buffer.from(sanitizeSvg(body.toString("utf8")), "utf8");
|
||||
}
|
||||
|
||||
const safeName = input.filename.replace(/[^a-zA-Z0-9._-]/g, "_");
|
||||
const storageKey = `logos/${ctx.session.user.id}/${business.id}/${crypto.randomUUID()}-${safeName}`;
|
||||
const previousStorageKey = business.logoStorageKey;
|
||||
|
||||
try {
|
||||
await putObject(storageKey, body, mimeType);
|
||||
} catch (error) {
|
||||
console.error("[businesses.uploadLogo] Failed to store logo", {
|
||||
backendError: error,
|
||||
businessId: business.id,
|
||||
});
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message:
|
||||
"Logo storage is unavailable. Check the object-storage service and try again.",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
|
||||
const [updatedBusiness] = await ctx.db
|
||||
.update(businesses)
|
||||
.set({
|
||||
logoStorageKey: storageKey,
|
||||
logoMimeType: mimeType,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(businesses.id, business.id))
|
||||
.returning();
|
||||
|
||||
if (previousStorageKey) {
|
||||
await deleteObject(previousStorageKey).catch(() => undefined);
|
||||
}
|
||||
|
||||
return updatedBusiness;
|
||||
}),
|
||||
|
||||
// Remove a business logo
|
||||
removeLogo: protectedProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const [business] = await ctx.db
|
||||
.select()
|
||||
.from(businesses)
|
||||
.where(
|
||||
and(
|
||||
eq(businesses.id, input.id),
|
||||
eq(businesses.createdById, ctx.session.user.id),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!business) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Business not found or you don't have permission to update it",
|
||||
});
|
||||
}
|
||||
|
||||
if (business.logoStorageKey) {
|
||||
await deleteObject(business.logoStorageKey).catch(() => undefined);
|
||||
}
|
||||
|
||||
const [updatedBusiness] = await ctx.db
|
||||
.update(businesses)
|
||||
.set({ logoStorageKey: null, logoMimeType: null, updatedAt: new Date() })
|
||||
.where(eq(businesses.id, business.id))
|
||||
.returning();
|
||||
|
||||
return updatedBusiness;
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import { invoices, platformSettings } from "~/server/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { env } from "~/env";
|
||||
import { NOREPLY_EMAIL } from "~/lib/app-email";
|
||||
import { getAppUrl } from "~/lib/app-url";
|
||||
import { getRequestOrigin } from "~/lib/app-url";
|
||||
import { generateInvoicePDFBlob } from "~/lib/pdf-export";
|
||||
import { generateInvoiceEmailTemplate } from "~/lib/email-templates";
|
||||
|
||||
@@ -88,26 +88,30 @@ export const emailRouter = createTRPCRouter({
|
||||
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,
|
||||
pdfFontFamily: settings?.pdfFontFamily as
|
||||
| "sans"
|
||||
| "serif"
|
||||
| "mono"
|
||||
| undefined,
|
||||
pdfNumericFontFamily: settings?.pdfNumericFontFamily as
|
||||
| "sans"
|
||||
| "serif"
|
||||
| "mono"
|
||||
| undefined,
|
||||
pdfFooterText: settings?.pdfFooterText,
|
||||
pdfShowLogo: settings?.pdfShowLogo,
|
||||
pdfShowPageNumbers: settings?.pdfShowPageNumbers,
|
||||
});
|
||||
const pdfBlob = await generateInvoicePDFBlob(
|
||||
invoice,
|
||||
{
|
||||
pdfTemplate: settings?.pdfTemplate as
|
||||
| "classic"
|
||||
| "minimal"
|
||||
| undefined,
|
||||
pdfAccentColor: settings?.pdfAccentColor,
|
||||
pdfFontFamily: settings?.pdfFontFamily as
|
||||
| "sans"
|
||||
| "serif"
|
||||
| "mono"
|
||||
| undefined,
|
||||
pdfNumericFontFamily: settings?.pdfNumericFontFamily as
|
||||
| "sans"
|
||||
| "serif"
|
||||
| "mono"
|
||||
| undefined,
|
||||
pdfFooterText: settings?.pdfFooterText,
|
||||
pdfShowLogo: settings?.pdfShowLogo,
|
||||
pdfShowPageNumbers: settings?.pdfShowPageNumbers,
|
||||
},
|
||||
{ logoBaseUrl: getRequestOrigin(ctx.headers) },
|
||||
);
|
||||
pdfBuffer = Buffer.from(await pdfBlob.arrayBuffer());
|
||||
|
||||
// Validate PDF was generated successfully
|
||||
@@ -165,7 +169,7 @@ export const emailRouter = createTRPCRouter({
|
||||
customMessage,
|
||||
userName,
|
||||
userEmail,
|
||||
baseUrl: getAppUrl(),
|
||||
baseUrl: getRequestOrigin(ctx.headers),
|
||||
});
|
||||
|
||||
// Determine Resend instance and email configuration to use
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { calculateLineItemAmount } from "~/lib/invoice-line-item";
|
||||
import { generateInvoicePDFBlob } from "~/lib/pdf-export";
|
||||
import { getRequestOrigin } from "~/lib/app-url";
|
||||
import { defaultDueDate, generateInvoiceNumber } from "~/lib/draft-invoice";
|
||||
import { Resend } from "resend";
|
||||
import { env } from "~/env";
|
||||
@@ -984,6 +985,7 @@ export const invoicesRouter = createTRPCRouter({
|
||||
pdfShowLogo: settings?.pdfShowLogo,
|
||||
pdfShowPageNumbers: settings?.pdfShowPageNumbers,
|
||||
},
|
||||
{ logoBaseUrl: getRequestOrigin(ctx.headers) },
|
||||
);
|
||||
|
||||
const buffer = Buffer.from(await pdfBlob.arrayBuffer());
|
||||
@@ -1046,7 +1048,29 @@ export const invoicesRouter = createTRPCRouter({
|
||||
where: eq(invoices.publicToken, input.token),
|
||||
with: {
|
||||
client: true,
|
||||
business: true,
|
||||
// Explicit allowlist: this is a publicProcedure — never let
|
||||
// secret fields (resendApiKey, resendDomain) reach an
|
||||
// unauthenticated caller via the business relation.
|
||||
business: {
|
||||
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,
|
||||
logoStorageKey: true,
|
||||
logoMimeType: true,
|
||||
hideNameWithLogo: true,
|
||||
},
|
||||
},
|
||||
items: {
|
||||
orderBy: (i, { asc }) => [
|
||||
asc(i.date),
|
||||
|
||||
+16
-13
@@ -318,6 +318,9 @@ export const businesses = createTable(
|
||||
website: d.varchar({ length: 255 }),
|
||||
taxId: d.varchar({ length: 100 }),
|
||||
logoUrl: d.varchar({ length: 500 }),
|
||||
logoStorageKey: d.varchar({ length: 500 }),
|
||||
logoMimeType: d.varchar({ length: 100 }),
|
||||
hideNameWithLogo: d.boolean().default(false).notNull(),
|
||||
isDefault: d.boolean().default(false),
|
||||
// Email configuration for custom Resend setup
|
||||
resendApiKey: d.varchar({ length: 255 }),
|
||||
@@ -609,10 +612,7 @@ export const invoicePayments = createTable(
|
||||
amount: d.real().notNull(),
|
||||
currency: d.varchar({ length: 3 }).default("USD").notNull(),
|
||||
date: d.timestamp().notNull(),
|
||||
method: d
|
||||
.varchar({ length: 50 })
|
||||
.notNull()
|
||||
.default("other"), // cash | check | bank_transfer | credit_card | paypal | other
|
||||
method: d.varchar({ length: 50 }).notNull().default("other"), // cash | check | bank_transfer | credit_card | paypal | other
|
||||
notes: d.varchar({ length: 500 }),
|
||||
createdById: d
|
||||
.varchar({ length: 255 })
|
||||
@@ -629,16 +629,19 @@ export const invoicePayments = createTable(
|
||||
],
|
||||
);
|
||||
|
||||
export const invoicePaymentsRelations = relations(invoicePayments, ({ one }) => ({
|
||||
invoice: one(invoices, {
|
||||
fields: [invoicePayments.invoiceId],
|
||||
references: [invoices.id],
|
||||
export const invoicePaymentsRelations = relations(
|
||||
invoicePayments,
|
||||
({ one }) => ({
|
||||
invoice: one(invoices, {
|
||||
fields: [invoicePayments.invoiceId],
|
||||
references: [invoices.id],
|
||||
}),
|
||||
createdBy: one(users, {
|
||||
fields: [invoicePayments.createdById],
|
||||
references: [users.id],
|
||||
}),
|
||||
}),
|
||||
createdBy: one(users, {
|
||||
fields: [invoicePayments.createdById],
|
||||
references: [users.id],
|
||||
}),
|
||||
}));
|
||||
);
|
||||
|
||||
// ─── Recurring Invoices ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
Reference in New Issue
Block a user