feat: add business brand assets and health checks

This commit is contained in:
2026-08-17 20:33:04 -04:00
parent 5c9fbe6dc2
commit 7be4bb2abe
36 changed files with 1215 additions and 249 deletions
@@ -0,0 +1,10 @@
ALTER TABLE "beenvoice_business" ADD COLUMN IF NOT EXISTS "logoDarkStorageKey" varchar(500);
ALTER TABLE "beenvoice_business" ADD COLUMN IF NOT EXISTS "logoDarkMimeType" varchar(100);
ALTER TABLE "beenvoice_business" ADD COLUMN IF NOT EXISTS "wordmarkLightStorageKey" varchar(500);
ALTER TABLE "beenvoice_business" ADD COLUMN IF NOT EXISTS "wordmarkLightMimeType" varchar(100);
ALTER TABLE "beenvoice_business" ADD COLUMN IF NOT EXISTS "wordmarkDarkStorageKey" varchar(500);
ALTER TABLE "beenvoice_business" ADD COLUMN IF NOT EXISTS "wordmarkDarkMimeType" varchar(100);
ALTER TABLE "beenvoice_business" ADD COLUMN IF NOT EXISTS "iconLightStorageKey" varchar(500);
ALTER TABLE "beenvoice_business" ADD COLUMN IF NOT EXISTS "iconLightMimeType" varchar(100);
ALTER TABLE "beenvoice_business" ADD COLUMN IF NOT EXISTS "iconDarkStorageKey" varchar(500);
ALTER TABLE "beenvoice_business" ADD COLUMN IF NOT EXISTS "iconDarkMimeType" varchar(100);
+7
View File
@@ -225,6 +225,13 @@
"when": 1786950000000,
"tag": "0031_timezone_safety",
"breakpoints": true
},
{
"idx": 32,
"version": "7",
"when": 1786975200000,
"tag": "0032_business_brand_assets",
"breakpoints": true
}
]
}
@@ -3,6 +3,13 @@ import { eq } from "drizzle-orm";
import { getObject } from "~/lib/object-storage";
import { db } from "~/server/db";
import { businesses } from "~/server/db/schema";
import {
brandAssetKinds,
brandAssetThemes,
resolveBusinessBrandAsset,
type BrandAssetKind,
type BrandAssetTheme,
} from "~/lib/business-branding";
export const runtime = "nodejs";
@@ -16,27 +23,57 @@ export async function GET(
{ params }: { params: Promise<{ businessId: string }> },
) {
const { businessId } = await params;
const url = new URL(req.url);
const requestedKind = url.searchParams.get("kind");
const requestedTheme = url.searchParams.get("theme");
const kind: BrandAssetKind = brandAssetKinds.includes(
requestedKind as BrandAssetKind,
)
? (requestedKind as BrandAssetKind)
: "logo";
const theme: BrandAssetTheme = brandAssetThemes.includes(
requestedTheme as BrandAssetTheme,
)
? (requestedTheme as BrandAssetTheme)
: "light";
const business = await db.query.businesses.findFirst({
where: eq(businesses.id, businessId),
columns: { logoStorageKey: true, logoMimeType: true },
columns: {
logoStorageKey: true,
logoMimeType: true,
logoDarkStorageKey: true,
logoDarkMimeType: true,
wordmarkLightStorageKey: true,
wordmarkLightMimeType: true,
wordmarkDarkStorageKey: true,
wordmarkDarkMimeType: true,
iconLightStorageKey: true,
iconLightMimeType: true,
iconDarkStorageKey: true,
iconDarkMimeType: true,
},
});
const asset = business
? resolveBusinessBrandAsset(business, kind, theme)
: null;
if (!business?.logoStorageKey || !business.logoMimeType) {
if (!asset) {
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);
url.searchParams.get("format") === "png" &&
RASTERIZABLE_MIME_TYPES.has(asset.mimeType);
try {
const body = await getObject(business.logoStorageKey);
const body = await getObject(asset.storageKey);
if (wantsPng) {
const { default: sharp } = await import("sharp");
const isSvg = business.logoMimeType === "image/svg+xml";
const isSvg = asset.mimeType === "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)
@@ -62,7 +99,7 @@ export async function GET(
return new NextResponse(new Uint8Array(body), {
headers: {
"Content-Type": business.logoMimeType,
"Content-Type": asset.mimeType,
"Cache-Control": "public, max-age=300, must-revalidate",
"X-Content-Type-Options": "nosniff",
},
@@ -71,6 +108,8 @@ export async function GET(
console.error("[business-logo] Failed to serve logo", {
backendError: error,
businessId,
kind,
theme,
wantsPng,
});
return NextResponse.json({ error: "Logo not found" }, { status: 404 });
+30
View File
@@ -0,0 +1,30 @@
import { sql } from "drizzle-orm";
import { NextResponse } from "next/server";
import { db } from "~/server/db";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET() {
try {
await Promise.race([
db.execute(sql`select 1`),
new Promise<never>((_, reject) =>
setTimeout(
() => reject(new Error("Database readiness timed out")),
2_000,
),
),
]);
return NextResponse.json(
{ status: "ok", database: "ready" },
{ headers: { "Cache-Control": "no-store" } },
);
} catch {
return NextResponse.json(
{ status: "unavailable", database: "unavailable" },
{ status: 503, headers: { "Cache-Control": "no-store" } },
);
}
}
+18 -2
View File
@@ -35,6 +35,16 @@ export async function GET(
taxId: true,
logoStorageKey: true,
logoMimeType: true,
logoDarkStorageKey: true,
logoDarkMimeType: true,
wordmarkLightStorageKey: true,
wordmarkLightMimeType: true,
wordmarkDarkStorageKey: true,
wordmarkDarkMimeType: true,
iconLightStorageKey: true,
iconLightMimeType: true,
iconDarkStorageKey: true,
iconDarkMimeType: true,
hideNameWithLogo: true,
},
},
@@ -52,8 +62,14 @@ export async function GET(
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
if (invoice.publicTokenExpiresAt && new Date(invoice.publicTokenExpiresAt) < new Date()) {
return NextResponse.json({ error: "This link has expired" }, { status: 410 });
if (
invoice.publicTokenExpiresAt &&
new Date(invoice.publicTokenExpiresAt) < new Date()
) {
return NextResponse.json(
{ error: "This link has expired" },
{ status: 410 },
);
}
const settings = await db.query.platformSettings.findFirst({
@@ -24,6 +24,8 @@ import {
Hash,
ArrowLeft,
} from "lucide-react";
import { BusinessBrandImage } from "~/components/branding/business-brand-image";
import { hasBusinessBrandAsset } from "~/lib/business-branding";
interface BusinessDetailPageProps {
params: Promise<{ id: string }>;
@@ -74,13 +76,12 @@ export default async function BusinessDetailPage({
<Card className="bg-card border-border border">
<CardHeader>
<CardTitle className="flex items-center gap-2">
{business.logoStorageKey ? (
{hasBusinessBrandAsset(business) ? (
<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"
<BusinessBrandImage
business={business}
kind="icon"
className="h-full w-full"
/>
</div>
) : (
@@ -17,6 +17,8 @@ import {
} from "~/components/ui/dialog";
import { api } from "~/trpc/react";
import { toast } from "sonner";
import { BusinessBrandImage } from "~/components/branding/business-brand-image";
import { hasBusinessBrandAsset } from "~/lib/business-branding";
// Type for business data
interface Business {
@@ -35,6 +37,17 @@ interface Business {
taxId: string | null;
logoUrl: string | null;
logoStorageKey: string | null;
logoMimeType: string | null;
logoDarkStorageKey: string | null;
logoDarkMimeType: string | null;
wordmarkLightStorageKey: string | null;
wordmarkLightMimeType: string | null;
wordmarkDarkStorageKey: string | null;
wordmarkDarkMimeType: string | null;
iconLightStorageKey: string | null;
iconLightMimeType: string | null;
iconDarkStorageKey: string | null;
iconDarkMimeType: string | null;
createdById: string;
createdAt: Date;
updatedAt: Date | null;
@@ -88,12 +101,12 @@ export function BusinessesDataTable({ businesses }: BusinessesDataTableProps) {
return (
<div className="flex items-center gap-3">
<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"
{hasBusinessBrandAsset(business) ? (
<BusinessBrandImage
business={business}
kind="icon"
decorative
className="h-full w-full"
/>
) : (
<Building className="text-primary h-4 w-4" />
@@ -28,6 +28,8 @@ import {
toZonedDateTimeInputValue,
zonedDateTimeToInstant,
} from "@beenvoice/domain/time-zone";
import { BusinessBrandImage } from "~/components/branding/business-brand-image";
import { hasBusinessBrandAsset } from "~/lib/business-branding";
import Link from "next/link";
import {
notFound,
@@ -424,13 +426,12 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{invoice.business.logoStorageKey && (
{hasBusinessBrandAsset(invoice.business) && (
<div className="bg-muted border-border/40 flex h-12 w-fit max-w-40 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"
<BusinessBrandImage
business={invoice.business}
kind="logo"
className="h-full max-w-36 min-w-20"
/>
</div>
)}
@@ -264,6 +264,19 @@ export default function SendEmailPage() {
email: invoiceData.business.email,
logoStorageKey: invoiceData.business.logoStorageKey,
logoMimeType: invoiceData.business.logoMimeType,
logoDarkStorageKey: invoiceData.business.logoDarkStorageKey,
logoDarkMimeType: invoiceData.business.logoDarkMimeType,
wordmarkLightStorageKey:
invoiceData.business.wordmarkLightStorageKey,
wordmarkLightMimeType:
invoiceData.business.wordmarkLightMimeType,
wordmarkDarkStorageKey:
invoiceData.business.wordmarkDarkStorageKey,
wordmarkDarkMimeType: invoiceData.business.wordmarkDarkMimeType,
iconLightStorageKey: invoiceData.business.iconLightStorageKey,
iconLightMimeType: invoiceData.business.iconLightMimeType,
iconDarkStorageKey: invoiceData.business.iconDarkStorageKey,
iconDarkMimeType: invoiceData.business.iconDarkMimeType,
}
: undefined,
items: invoiceData.items?.map((item) => ({
+9 -8
View File
@@ -13,6 +13,8 @@ import {
formatCalendarDate,
getEffectiveInvoiceStatus,
} from "@beenvoice/domain";
import { BusinessBrandImage } from "~/components/branding/business-brand-image";
import { hasBusinessBrandAsset } from "~/lib/business-branding";
function formatDate(date: Date) {
return formatCalendarDate(date, {
@@ -121,7 +123,7 @@ function PublicInvoiceView({ token }: { token: string }) {
? `${invoice.business.name} (${invoice.business.nickname})`
: invoice.business.name
: null;
const hasLogo = Boolean(invoice.business?.logoStorageKey);
const hasLogo = hasBusinessBrandAsset(invoice.business);
const hideName = hasLogo && Boolean(invoice.business?.hideNameWithLogo);
return (
@@ -132,13 +134,12 @@ function PublicInvoiceView({ token }: { token: string }) {
{/* Header */}
<div className="flex items-center gap-3 bg-gray-900 px-8 py-6">
{hasLogo && (
// Uploaded SVGs are sanitized and served by our route. next/image's
// optimizer intentionally rejects SVG, so a native img is required.
// eslint-disable-next-line @next/next/no-img-element
<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"
<BusinessBrandImage
business={invoice.business!}
kind="logo"
theme="dark"
decorative
className="h-16 w-[220px] max-w-[42%] shrink-0 rounded px-2 py-1.5"
/>
)}
<div className="min-w-0">
@@ -0,0 +1,54 @@
import { cn } from "~/lib/utils";
import {
businessBrandAssetPath,
hasBusinessBrandAsset,
type BrandAssetKind,
type BrandAssetTheme,
type BusinessBrandAssets,
} from "~/lib/business-branding";
type BusinessBrandImageProps = {
business: BusinessBrandAssets & { id: string; name?: string | null };
kind?: BrandAssetKind;
theme?: BrandAssetTheme | "auto";
className?: string;
imageClassName?: string;
decorative?: boolean;
};
export function BusinessBrandImage({
business,
kind = "icon",
theme = "auto",
className,
imageClassName,
decorative = false,
}: BusinessBrandImageProps) {
if (!hasBusinessBrandAsset(business)) return null;
const alt = decorative ? "" : `${business.name ?? "Business"} ${kind}`;
const image = (variant: BrandAssetTheme, variantClassName?: string) => (
// eslint-disable-next-line @next/next/no-img-element -- object-storage-backed SVG/raster brand asset
<img
src={businessBrandAssetPath(business.id, kind, variant)}
alt={alt}
className={cn(
"h-full w-full object-contain",
imageClassName,
variantClassName,
)}
/>
);
return (
<span className={cn("block overflow-hidden", className)}>
{theme === "auto" ? (
<>
{image("light", "dark:hidden")}
{image("dark", "hidden dark:block")}
</>
) : (
image(theme)
)}
</span>
);
}
@@ -0,0 +1,21 @@
"use client";
import { BusinessBrandImage } from "~/components/branding/business-brand-image";
import { Logo } from "~/components/branding/logo";
import { hasBusinessBrandAsset } from "~/lib/business-branding";
import { api } from "~/trpc/react";
export function DashboardBrand({ compact = false }: { compact?: boolean }) {
const { data: business } = api.businesses.getDefault.useQuery();
if (business && hasBusinessBrandAsset(business)) {
return (
<BusinessBrandImage
business={business}
kind={compact ? "icon" : "wordmark"}
decorative
className={compact ? "h-8 w-8" : "h-8 w-36"}
/>
);
}
return <Logo size={compact ? "icon" : "sm"} />;
}
+186 -75
View File
@@ -24,7 +24,10 @@ import { toast } from "sonner";
import { AddressForm } from "~/components/forms/address-form";
import { FloatingActionBar } from "~/components/layout/floating-action-bar";
import { DashboardPageHeader } from "~/components/layout/page-header";
import { DashboardPage, dashboardGapClass } from "~/components/layout/dashboard-page";
import {
DashboardPage,
dashboardGapClass,
} from "~/components/layout/dashboard-page";
import { cn } from "~/lib/utils";
import { Button } from "~/components/ui/button";
import { Alert, AlertDescription } from "~/components/ui/alert";
@@ -43,6 +46,13 @@ import {
VALIDATION_MESSAGES,
} from "~/lib/form-constants";
import { api } from "~/trpc/react";
import {
businessBrandAssetPath,
getBrandAssetFieldNames,
hasBusinessBrandAsset,
type BrandAssetKind,
type BrandAssetTheme,
} from "~/lib/business-branding";
interface BusinessFormProps {
businessId?: string;
@@ -114,6 +124,54 @@ const ACCEPTED_LOGO_TYPES = new Set([
"image/svg+xml",
]);
const BRAND_ASSET_SLOTS: Array<{
kind: BrandAssetKind;
theme: BrandAssetTheme;
label: string;
description: string;
}> = [
{
kind: "logo",
theme: "light",
label: "Logo · light",
description: "Icon + text for light backgrounds",
},
{
kind: "logo",
theme: "dark",
label: "Logo · dark",
description: "Icon + text for dark backgrounds",
},
{
kind: "wordmark",
theme: "light",
label: "Wordmark · light",
description: "Text-only mark for light backgrounds",
},
{
kind: "wordmark",
theme: "dark",
label: "Wordmark · dark",
description: "Text-only mark for dark backgrounds",
},
{
kind: "icon",
theme: "light",
label: "Icon · light",
description: "Compact mark for light UI",
},
{
kind: "icon",
theme: "dark",
label: "Icon · dark",
description: "Compact mark for dark UI",
},
];
function assetSlotKey(kind: BrandAssetKind, theme: BrandAssetTheme) {
return `${kind}-${theme}`;
}
export function BusinessForm({ businessId, mode }: BusinessFormProps) {
const router = useRouter();
const utils = api.useUtils();
@@ -123,7 +181,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
const [showApiKey, setShowApiKey] = useState(false);
const [isDirty, setIsDirty] = useState(false);
const [initialized, setInitialized] = useState(false);
const [isUploadingLogo, setIsUploadingLogo] = useState(false);
const [uploadingAsset, setUploadingAsset] = useState<string | null>(null);
// Fetch business data if editing
const { data: business, isLoading: isLoadingBusiness } =
@@ -165,20 +223,20 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
});
const uploadLogo = api.businesses.uploadLogo.useMutation({
onSuccess: async () => {
onSuccess: async (_data, variables) => {
await utils.businesses.getById.invalidate({ id: businessId });
toast.success("Logo updated");
toast.success(`${variables.kind} ${variables.theme} variant updated`);
},
onError: (error) => {
toast.error(error.message || "Failed to upload logo");
},
onSettled: () => setIsUploadingLogo(false),
onSettled: () => setUploadingAsset(null),
});
const removeLogo = api.businesses.removeLogo.useMutation({
onSuccess: async () => {
onSuccess: async (_data, variables) => {
await utils.businesses.getById.invalidate({ id: businessId });
toast.success("Logo removed");
toast.success(`${variables.kind} ${variables.theme} variant removed`);
},
onError: (error) => {
toast.error(error.message || "Failed to remove logo");
@@ -187,6 +245,8 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
const handleLogoFileSelected = async (
e: React.ChangeEvent<HTMLInputElement>,
kind: BrandAssetKind,
theme: BrandAssetTheme,
) => {
const file = e.target.files?.[0];
e.target.value = "";
@@ -201,7 +261,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
return;
}
setIsUploadingLogo(true);
setUploadingAsset(assetSlotKey(kind, theme));
const data = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
@@ -217,6 +277,8 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
filename: file.name,
mimeType: file.type,
data,
kind,
theme,
});
};
@@ -229,12 +291,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
// Load business data once when editing (avoid overwriting unsaved changes on refetch)
useEffect(() => {
if (
business &&
mode === "edit" &&
!initialized &&
!isLoadingEmailConfig
) {
if (business && mode === "edit" && !initialized && !isLoadingEmailConfig) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Sync loaded business data into the edit form.
setFormData({
name: business.name,
@@ -732,74 +789,128 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
<ImageIcon className="text-muted-foreground h-5 w-5" />
</div>
<div>
<CardTitle>Logo</CardTitle>
<CardTitle>Brand assets</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.
Add logos, wordmarks, and icons for light and dark
backgrounds. Missing variants fall back automatically.
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 })
}
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{BRAND_ASSET_SLOTS.map((slot) => {
const [storageField] = getBrandAssetFieldNames(
slot.kind,
slot.theme,
);
const hasAsset = Boolean(business?.[storageField]);
const key = assetSlotKey(slot.kind, slot.theme);
const inputId = `brand-asset-${key}`;
const isUploading = uploadingAsset === key;
return (
<div
key={key}
className="border-border/60 overflow-hidden rounded-xl border"
>
<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
className={cn(
"flex h-28 items-center justify-center p-4",
slot.theme === "dark"
? "bg-neutral-950"
: "bg-white",
)}
>
{hasAsset ? (
// eslint-disable-next-line @next/next/no-img-element -- object-storage-backed image
<img
src={`${businessBrandAssetPath(businessId, slot.kind, slot.theme)}&v=${business?.updatedAt ? new Date(business.updatedAt).getTime() : 0}`}
alt={`${business?.name ?? "Business"} ${slot.label}`}
className={cn(
"max-h-full max-w-full object-contain",
slot.kind === "icon" && "aspect-square",
)}
/>
) : (
<ImageIcon
className={cn(
"h-8 w-8",
slot.theme === "dark"
? "text-white/35"
: "text-black/25",
)}
/>
)}
</div>
<div className="space-y-3 p-3">
<div>
<p className="text-sm font-medium">
{slot.label}
</p>
<p className="text-muted-foreground text-xs">
{slot.description}
</p>
</div>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
size="sm"
className="flex-1"
disabled={Boolean(uploadingAsset)}
onClick={() =>
document.getElementById(inputId)?.click()
}
>
{isUploading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Upload className="h-4 w-4" />
)}
{hasAsset ? "Replace" : "Upload"}
</Button>
{hasAsset ? (
<Button
type="button"
variant="outline"
size="icon"
className="h-8 w-8 shrink-0"
aria-label={`Remove ${slot.label}`}
disabled={removeLogo.isPending}
onClick={() =>
businessId &&
removeLogo.mutate({
id: businessId,
kind: slot.kind,
theme: slot.theme,
})
}
>
<Trash2 className="h-4 w-4" />
</Button>
) : null}
<input
id={inputId}
type="file"
accept="image/png,image/jpeg,image/webp,image/svg+xml"
className="hidden"
onChange={(event) =>
void handleLogoFileSelected(
event,
slot.kind,
slot.theme,
)
}
/>
</div>
</div>
</div>
);
})}
</div>
{business?.logoStorageKey && (
{hasBusinessBrandAsset(business) && (
<div className="bg-muted border-border/40 mt-4 flex items-center justify-between border p-4">
<div className="space-y-0.5">
<Label
@@ -809,8 +920,8 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
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.
Show only the logo in the invoice header useful if
your logo already includes your business name.
</p>
</div>
<Switch
@@ -3,6 +3,7 @@
import { generateInvoiceEmailTemplate } from "~/lib/email-templates";
import { getAppUrl } from "~/lib/app-url";
import { calculateLineItemAmount } from "~/lib/invoice-line-item";
import type { BusinessBrandAssets } from "~/lib/business-branding";
interface EmailPreviewProps {
subject: string;
@@ -28,9 +29,7 @@ interface EmailPreviewProps {
id?: string;
name: string;
email: string | null;
logoStorageKey?: string | null;
logoMimeType?: string | null;
};
} & BusinessBrandAssets;
items?: Array<{
id: string;
date?: Date;
@@ -87,7 +86,8 @@ export function EmailPreview({
description: item.description ?? "Service",
hours: item.hours,
rate: item.rate,
amount: item.amount ?? calculateLineItemAmount(item.hours, item.rate),
amount:
item.amount ?? calculateLineItemAmount(item.hours, item.rate),
})) ?? [],
},
customContent: content,
@@ -9,7 +9,7 @@ import {
} from "~/components/layout/sidebar-provider";
import { cn } from "~/lib/utils";
import { Menu } from "lucide-react";
import { Logo } from "~/components/branding/logo";
import { DashboardBrand } from "~/components/branding/dashboard-brand";
import { Button } from "~/components/ui/button";
import { Sheet, SheetContent, SheetTrigger } from "~/components/ui/sheet";
import { ActiveTimerWidget } from "~/app/dashboard/_components/active-timer-widget";
@@ -48,7 +48,7 @@ function DashboardContent({ children }: { children: React.ReactNode }) {
</Button>
</SheetTrigger>
<div className="ml-3 flex min-w-0 flex-1 items-center gap-2 sm:ml-4">
<Logo size="sm" className="shrink-0" />
<DashboardBrand />
<ActiveTimerWidget compact />
</div>
<SheetContent side="left" className="w-72 p-0">
+3 -3
View File
@@ -9,7 +9,7 @@ import { LogOut, PanelLeftClose, PanelLeftOpen } from "lucide-react";
import { getNavigationForUser, isNavLinkActive } from "~/lib/navigation";
import { useSidebar } from "./sidebar-provider";
import { cn } from "~/lib/utils";
import { Logo } from "~/components/branding/logo";
import { DashboardBrand } from "~/components/branding/dashboard-brand";
import {
Tooltip,
TooltipContent,
@@ -57,10 +57,10 @@ export function Sidebar({ mobile, onClose }: SidebarProps) {
>
{!collapsed && (
<div className="flex items-center gap-2">
<Logo size="sm" />
<DashboardBrand />
</div>
)}
{collapsed && <Logo size="icon" />}
{collapsed && <DashboardBrand compact />}
{!mobile && !collapsed && (
<div className="h-8 w-8" /> // Spacer to keep alignment if needed, or just remove
+28
View File
@@ -0,0 +1,28 @@
export {
brandAssetKinds,
brandAssetThemes,
getBrandAssetFieldNames,
hasBusinessBrandAsset,
resolveBusinessBrandAsset,
} from "@beenvoice/domain/brand-assets";
export type {
BrandAssetKind,
BrandAssetTheme,
BusinessBrandAssets,
} from "@beenvoice/domain/brand-assets";
import type {
BrandAssetKind,
BrandAssetTheme,
} from "@beenvoice/domain/brand-assets";
export function businessBrandAssetPath(
businessId: string,
kind: BrandAssetKind = "logo",
theme: BrandAssetTheme = "light",
format?: "png",
): string {
const params = new URLSearchParams({ kind, theme });
if (format) params.set("format", format);
return `/api/business-logo/${businessId}?${params.toString()}`;
}
@@ -1,5 +1,10 @@
import { getAppUrl } from "~/lib/app-url";
import { formatCalendarDate } from "@beenvoice/domain/time-zone";
import {
businessBrandAssetPath,
resolveBusinessBrandAsset,
type BusinessBrandAssets,
} from "~/lib/business-branding";
// 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
@@ -7,20 +12,23 @@ import { formatCalendarDate } from "@beenvoice/domain/time-zone";
// rasterization the PDF export uses.
function resolveEmailLogoUrl(
business:
| {
| ({
id?: string;
logoStorageKey?: string | null;
logoMimeType?: string | null;
}
} & BusinessBrandAssets)
| 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" : ""}`;
if (!business?.id) return null;
const asset = resolveBusinessBrandAsset(business, "logo", "light");
if (!asset) return null;
const needsRaster = !["image/png", "image/jpeg"].includes(asset.mimeType);
const path = businessBrandAssetPath(
business.id,
"logo",
"light",
needsRaster ? "png" : undefined,
);
return `${baseUrl.replace(/\/$/, "")}${path}`;
}
@@ -37,21 +45,21 @@ interface InvoiceEmailTemplateProps {
name: string;
email: string | null;
};
business?: {
id?: string;
name: string;
nickname?: string | null;
email?: string | null;
phone?: string | null;
addressLine1?: string | null;
addressLine2?: string | null;
city?: string | null;
state?: string | null;
postalCode?: string | null;
country?: string | null;
logoStorageKey?: string | null;
logoMimeType?: string | null;
} | null;
business?:
| ({
id?: string;
name: string;
nickname?: string | null;
email?: string | null;
phone?: string | null;
addressLine1?: string | null;
addressLine2?: string | null;
city?: string | null;
state?: string | null;
postalCode?: string | null;
country?: string | null;
} & BusinessBrandAssets)
| null;
items: Array<{
date: Date;
description: string;
+33 -23
View File
@@ -18,6 +18,11 @@ import {
pdfFontCacheKey,
resolvePdfFonts,
} from "~/lib/pdf-fonts";
import {
businessBrandAssetPath,
resolveBusinessBrandAsset,
type BusinessBrandAssets,
} from "~/lib/business-branding";
// Fallback download function for better browser compatibility
function downloadBlob(blob: Blob, filename: string): void {
@@ -73,24 +78,24 @@ export interface InvoiceData {
taxRate: number;
currency?: string | null;
notes?: string | null;
business?: {
id?: string;
name: string;
nickname?: string | null;
email?: string | null;
phone?: string | null;
addressLine1?: string | null;
addressLine2?: string | null;
city?: string | null;
state?: string | null;
postalCode?: string | null;
country?: string | null;
website?: string | null;
taxId?: string | null;
logoStorageKey?: string | null;
logoMimeType?: string | null;
hideNameWithLogo?: boolean | null;
} | null;
business?:
| ({
id?: string;
name: string;
nickname?: string | null;
email?: string | null;
phone?: string | null;
addressLine1?: string | null;
addressLine2?: string | null;
city?: string | null;
state?: string | null;
postalCode?: string | null;
country?: string | null;
website?: string | null;
taxId?: string | null;
hideNameWithLogo?: boolean | null;
} & BusinessBrandAssets)
| null;
client?: {
name: string;
email?: string | null;
@@ -848,12 +853,17 @@ function resolveBusinessLogoSrc(
business: InvoiceData["business"],
baseUrlOverride?: string,
): string | null {
if (!business?.id || !business.logoStorageKey) return null;
if (!business?.id) return null;
const asset = resolveBusinessBrandAsset(business, "logo", "light");
if (!asset) return null;
const needsRaster =
business.logoMimeType != null &&
!["image/png", "image/jpeg"].includes(business.logoMimeType);
const path = `/api/business-logo/${business.id}${needsRaster ? "?format=png" : ""}`;
const needsRaster = !["image/png", "image/jpeg"].includes(asset.mimeType);
const path = businessBrandAssetPath(
business.id,
"logo",
"light",
needsRaster ? "png" : undefined,
);
if (typeof window !== "undefined") {
return `${window.location.origin}${path}`;
+1
View File
@@ -23,6 +23,7 @@ export function proxy(request: NextRequest) {
"/api/mcp",
"/api/i",
"/api/business-logo",
"/api/health",
];
// Allow API routes to pass through
+59 -16
View File
@@ -7,6 +7,11 @@ import { invoices } from "~/server/db/schema";
import { sql } from "drizzle-orm";
import { deleteObject, putObject } from "~/lib/object-storage";
import { sanitizeSvg } from "~/lib/svg-sanitize";
import {
brandAssetKinds,
brandAssetThemes,
getBrandAssetFieldNames,
} from "~/lib/business-branding";
const MAX_LOGO_BYTES = 5 * 1024 * 1024;
const allowedLogoMimeTypes = new Set([
@@ -287,6 +292,7 @@ export const businessesRouter = createTRPCRouter({
"Business not found or you don't have permission to delete it",
);
}
const existingBusiness = business[0];
// Check if this business has any invoices
const invoiceCount = await ctx.db
@@ -300,6 +306,13 @@ export const businessesRouter = createTRPCRouter({
);
}
const storageKeys = brandAssetKinds.flatMap((kind) =>
brandAssetThemes.flatMap((theme) => {
const [storageField] = getBrandAssetFieldNames(kind, theme);
const storageKey = existingBusiness[storageField];
return storageKey ? [storageKey] : [];
}),
);
await ctx.db
.delete(businesses)
.where(
@@ -309,6 +322,12 @@ export const businessesRouter = createTRPCRouter({
),
);
await Promise.all(
[...new Set(storageKeys)].map((key) =>
deleteObject(key).catch(() => undefined),
),
);
return { success: true };
}),
@@ -432,7 +451,8 @@ export const businessesRouter = createTRPCRouter({
};
}),
// Upload (or replace) a business logo, shown on invoices
// Upload (or replace) a business brand asset. Kind/theme default to the
// legacy combined/light logo so older clients remain compatible.
uploadLogo: protectedProcedure
.input(
z.object({
@@ -440,6 +460,8 @@ export const businessesRouter = createTRPCRouter({
filename: z.string().min(1).max(255),
mimeType: z.string().min(1).max(100),
data: z.string().min(1),
kind: z.enum(brandAssetKinds).default("logo"),
theme: z.enum(brandAssetThemes).default("light"),
}),
)
.mutation(async ({ ctx, input }) => {
@@ -457,7 +479,8 @@ export const businessesRouter = createTRPCRouter({
if (!business) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Business not found or you don't have permission to update it",
message:
"Business not found or you don't have permission to update it",
});
}
@@ -465,7 +488,7 @@ export const businessesRouter = createTRPCRouter({
if (!allowedLogoMimeTypes.has(mimeType)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Logo must be a PNG, JPEG, WebP, or SVG image",
message: "Brand asset must be a PNG, JPEG, WebP, or SVG image",
});
}
@@ -473,7 +496,7 @@ export const businessesRouter = createTRPCRouter({
if (!body.length || body.length > MAX_LOGO_BYTES) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Logo must be between 1 byte and 5MB",
message: "Brand asset must be between 1 byte and 5MB",
});
}
@@ -482,20 +505,24 @@ export const businessesRouter = createTRPCRouter({
}
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;
const storageKey = `logos/${ctx.session.user.id}/${business.id}/${input.kind}/${input.theme}/${crypto.randomUUID()}-${safeName}`;
const [storageField, mimeField] = getBrandAssetFieldNames(
input.kind,
input.theme,
);
const previousStorageKey = business[storageField];
try {
await putObject(storageKey, body, mimeType);
} catch (error) {
console.error("[businesses.uploadLogo] Failed to store logo", {
console.error("[businesses.uploadLogo] Failed to store brand asset", {
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.",
"Brand asset storage is unavailable. Check the object-storage service and try again.",
cause: error,
});
}
@@ -503,8 +530,8 @@ export const businessesRouter = createTRPCRouter({
const [updatedBusiness] = await ctx.db
.update(businesses)
.set({
logoStorageKey: storageKey,
logoMimeType: mimeType,
[storageField]: storageKey,
[mimeField]: mimeType,
updatedAt: new Date(),
})
.where(eq(businesses.id, business.id))
@@ -517,9 +544,15 @@ export const businessesRouter = createTRPCRouter({
return updatedBusiness;
}),
// Remove a business logo
// Remove one business brand asset. Defaults preserve older clients.
removeLogo: protectedProcedure
.input(z.object({ id: z.string() }))
.input(
z.object({
id: z.string(),
kind: z.enum(brandAssetKinds).default("logo"),
theme: z.enum(brandAssetThemes).default("light"),
}),
)
.mutation(async ({ ctx, input }) => {
const [business] = await ctx.db
.select()
@@ -535,17 +568,27 @@ export const businessesRouter = createTRPCRouter({
if (!business) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Business not found or you don't have permission to update it",
message:
"Business not found or you don't have permission to update it",
});
}
if (business.logoStorageKey) {
await deleteObject(business.logoStorageKey).catch(() => undefined);
const [storageField, mimeField] = getBrandAssetFieldNames(
input.kind,
input.theme,
);
const storageKey = business[storageField];
if (storageKey) {
await deleteObject(storageKey).catch(() => undefined);
}
const [updatedBusiness] = await ctx.db
.update(businesses)
.set({ logoStorageKey: null, logoMimeType: null, updatedAt: new Date() })
.set({
[storageField]: null,
[mimeField]: null,
updatedAt: new Date(),
})
.where(eq(businesses.id, business.id))
.returning();
@@ -1124,6 +1124,16 @@ export const invoicesRouter = createTRPCRouter({
taxId: true,
logoStorageKey: true,
logoMimeType: true,
logoDarkStorageKey: true,
logoDarkMimeType: true,
wordmarkLightStorageKey: true,
wordmarkLightMimeType: true,
wordmarkDarkStorageKey: true,
wordmarkDarkMimeType: true,
iconLightStorageKey: true,
iconLightMimeType: true,
iconDarkStorageKey: true,
iconDarkMimeType: true,
hideNameWithLogo: true,
},
},
+19 -5
View File
@@ -1212,14 +1212,21 @@ export const settingsRouter = createTRPCRouter({
.mutation(async ({ ctx }) => {
const userId = ctx.session.user.id;
const [receiptObjects, logoObjects] = await Promise.all([
const [receiptObjects, brandObjects] = await Promise.all([
ctx.db
.select({ storageKey: expenseReceipts.storageKey })
.from(expenseReceipts)
.innerJoin(expenses, eq(expenseReceipts.expenseId, expenses.id))
.where(eq(expenses.createdById, userId)),
ctx.db
.select({ storageKey: businesses.logoStorageKey })
.select({
logo: businesses.logoStorageKey,
logoDark: businesses.logoDarkStorageKey,
wordmarkLight: businesses.wordmarkLightStorageKey,
wordmarkDark: businesses.wordmarkDarkStorageKey,
iconLight: businesses.iconLightStorageKey,
iconDark: businesses.iconDarkStorageKey,
})
.from(businesses)
.where(eq(businesses.createdById, userId)),
]);
@@ -1227,9 +1234,16 @@ export const settingsRouter = createTRPCRouter({
// Delete uploaded personal data before removing its database pointers. If object
// storage is unavailable, the account remains intact so the user can retry.
await Promise.all(
[...receiptObjects, ...logoObjects].flatMap(({ storageKey }) =>
storageKey ? [deleteObject(storageKey)] : [],
),
[
...receiptObjects.flatMap(({ storageKey }) =>
storageKey ? [storageKey] : [],
),
...brandObjects.flatMap((assets) =>
Object.values(assets).filter((storageKey): storageKey is string =>
Boolean(storageKey),
),
),
].map((storageKey) => deleteObject(storageKey)),
);
await ctx.db.transaction(async (tx) => {
+12
View File
@@ -320,8 +320,20 @@ export const businesses = createTable(
website: d.varchar({ length: 255 }),
taxId: d.varchar({ length: 100 }),
logoUrl: d.varchar({ length: 500 }),
// Brand assets: the original logo columns are the combined/light variant
// for backwards compatibility with existing uploads.
logoStorageKey: d.varchar({ length: 500 }),
logoMimeType: d.varchar({ length: 100 }),
logoDarkStorageKey: d.varchar({ length: 500 }),
logoDarkMimeType: d.varchar({ length: 100 }),
wordmarkLightStorageKey: d.varchar({ length: 500 }),
wordmarkLightMimeType: d.varchar({ length: 100 }),
wordmarkDarkStorageKey: d.varchar({ length: 500 }),
wordmarkDarkMimeType: d.varchar({ length: 100 }),
iconLightStorageKey: d.varchar({ length: 500 }),
iconLightMimeType: d.varchar({ length: 100 }),
iconDarkStorageKey: d.varchar({ length: 500 }),
iconDarkMimeType: d.varchar({ length: 100 }),
hideNameWithLogo: d.boolean().default(false).notNull(),
isDefault: d.boolean().default(false),
// Email configuration for custom Resend setup