feat: add business brand assets and health checks
This commit is contained in:
@@ -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);
|
||||
@@ -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 });
|
||||
|
||||
@@ -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" } },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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"} />;
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -23,6 +23,7 @@ export function proxy(request: NextRequest) {
|
||||
"/api/mcp",
|
||||
"/api/i",
|
||||
"/api/business-logo",
|
||||
"/api/health",
|
||||
];
|
||||
|
||||
// Allow API routes to pass through
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user