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
+2
View File
@@ -76,6 +76,8 @@ RUN chmod -R a+rX apps/web/drizzle apps/web/public apps/web/src/server/db/migrat
USER bun USER bun
EXPOSE 3000 EXPOSE 3000
WORKDIR /app/apps/web WORKDIR /app/apps/web
HEALTHCHECK --interval=10s --timeout=5s --start-period=30s --retries=5 \
CMD bun -e 'const port = process.env.PORT || "3000"; const response = await fetch("http://127.0.0.1:" + port + "/api/health"); if (!response.ok) process.exit(1)'
CMD ["sh", "-c", "bun src/server/db/migrate.ts && bun run start"] CMD ["sh", "-c", "bun src/server/db/migrate.ts && bun run start"]
FROM base AS worker FROM base AS worker
+64 -21
View File
@@ -1,12 +1,6 @@
import { router } from "expo-router"; import { router } from "expo-router";
import { useState } from "react"; import { useState } from "react";
import { import { Alert, ScrollView, StyleSheet, Text, View } from "react-native";
Alert,
ScrollView,
StyleSheet,
Text,
View,
} from "react-native";
import { AppBackground } from "@/components/AppBackground"; import { AppBackground } from "@/components/AppBackground";
import { FilterChip } from "@/components/FilterChip"; import { FilterChip } from "@/components/FilterChip";
@@ -24,6 +18,10 @@ import { formatCurrency } from "@/lib/format";
import type { ThemeColors } from "@/lib/theme-palette"; import type { ThemeColors } from "@/lib/theme-palette";
import { useThemedStyles } from "@/lib/use-themed-styles"; import { useThemedStyles } from "@/lib/use-themed-styles";
import { api } from "@/lib/trpc"; import { api } from "@/lib/trpc";
import {
BusinessBrandImage,
hasMobileBusinessBrandAsset,
} from "@/components/businesses/BusinessBrandImage";
type EntityTab = "clients" | "businesses"; type EntityTab = "clients" | "businesses";
@@ -48,7 +46,8 @@ export default function EntitiesScreen() {
const activeQuery = tab === "clients" ? clientsQuery : businessesQuery; const activeQuery = tab === "clients" ? clientsQuery : businessesQuery;
const isLoading = const isLoading =
clientsQuery.isLoading || (tab === "businesses" && businessesQuery.isLoading); clientsQuery.isLoading ||
(tab === "businesses" && businessesQuery.isLoading);
if (isLoading) { if (isLoading) {
return <LoadingScreen message="Loading…" />; return <LoadingScreen message="Loading…" />;
@@ -71,11 +70,16 @@ export default function EntitiesScreen() {
const businesses = businessesQuery.data ?? []; const businesses = businessesQuery.data ?? [];
function refresh() { function refresh() {
return tab === "clients" ? clientsQuery.refetch() : businessesQuery.refetch(); return tab === "clients"
? clientsQuery.refetch()
: businessesQuery.refetch();
} }
function confirmDelete(id: string, name: string) { function confirmDelete(id: string, name: string) {
Alert.alert(`Delete ${tab === "clients" ? "client" : "business"}?`, `Remove ${name}?`, [ Alert.alert(
`Delete ${tab === "clients" ? "client" : "business"}?`,
`Remove ${name}?`,
[
{ text: "Cancel", style: "cancel" }, { text: "Cancel", style: "cancel" },
{ {
text: "Delete", text: "Delete",
@@ -85,7 +89,8 @@ export default function EntitiesScreen() {
else deleteBusiness.mutate({ id }); else deleteBusiness.mutate({ id });
}, },
}, },
]); ],
);
} }
return ( return (
@@ -99,10 +104,7 @@ export default function EntitiesScreen() {
/> />
} }
refreshControl={ refreshControl={
<PullToRefresh <PullToRefresh onRefresh={refresh} tintColor={colors.primary} />
onRefresh={refresh}
tintColor={colors.primary}
/>
} }
> >
<ScrollView <ScrollView
@@ -141,7 +143,10 @@ export default function EntitiesScreen() {
icon: "create-outline", icon: "create-outline",
color: "#fff", color: "#fff",
backgroundColor: colors.primary, backgroundColor: colors.primary,
onPress: () => router.push(`/(app)/entities/clients/edit/${client.id}`), onPress: () =>
router.push(
`/(app)/entities/clients/edit/${client.id}`,
),
}, },
{ {
key: "delete", key: "delete",
@@ -152,7 +157,9 @@ export default function EntitiesScreen() {
onPress: () => confirmDelete(client.id, client.name), onPress: () => confirmDelete(client.id, client.name),
}, },
]} ]}
onPress={() => router.push(`/(app)/entities/clients/${client.id}`)} onPress={() =>
router.push(`/(app)/entities/clients/${client.id}`)
}
> >
<GlassSurface style={styles.card}> <GlassSurface style={styles.card}>
<View style={styles.cardInner}> <View style={styles.cardInner}>
@@ -162,7 +169,10 @@ export default function EntitiesScreen() {
) : null} ) : null}
{client.defaultHourlyRate != null ? ( {client.defaultHourlyRate != null ? (
<Text style={styles.meta}> <Text style={styles.meta}>
{formatCurrency(client.defaultHourlyRate, client.currency ?? "USD")} {formatCurrency(
client.defaultHourlyRate,
client.currency ?? "USD",
)}
/hr /hr
</Text> </Text>
) : null} ) : null}
@@ -190,7 +200,10 @@ export default function EntitiesScreen() {
icon: "create-outline", icon: "create-outline",
color: "#fff", color: "#fff",
backgroundColor: colors.primary, backgroundColor: colors.primary,
onPress: () => router.push(`/(app)/entities/businesses/edit/${business.id}`), onPress: () =>
router.push(
`/(app)/entities/businesses/edit/${business.id}`,
),
}, },
{ {
key: "delete", key: "delete",
@@ -201,10 +214,21 @@ export default function EntitiesScreen() {
onPress: () => confirmDelete(business.id, business.name), onPress: () => confirmDelete(business.id, business.name),
}, },
]} ]}
onPress={() => router.push(`/(app)/entities/businesses/${business.id}`)} onPress={() =>
router.push(`/(app)/entities/businesses/${business.id}`)
}
> >
<GlassSurface style={styles.card}> <GlassSurface style={styles.card}>
<View style={styles.cardInner}> <View style={styles.cardInner}>
<View style={styles.businessRow}>
{hasMobileBusinessBrandAsset(business) ? (
<BusinessBrandImage
business={business}
kind="icon"
style={styles.businessIcon}
/>
) : null}
<View style={styles.businessCopy}>
<View style={styles.nameRow}> <View style={styles.nameRow}>
<Text style={styles.name}>{business.name}</Text> <Text style={styles.name}>{business.name}</Text>
{business.isDefault ? ( {business.isDefault ? (
@@ -214,7 +238,11 @@ export default function EntitiesScreen() {
{business.nickname ? ( {business.nickname ? (
<Text style={styles.meta}>{business.nickname}</Text> <Text style={styles.meta}>{business.nickname}</Text>
) : null} ) : null}
{business.email ? <Text style={styles.meta}>{business.email}</Text> : null} {business.email ? (
<Text style={styles.meta}>{business.email}</Text>
) : null}
</View>
</View>
</View> </View>
</GlassSurface> </GlassSurface>
</SwipeableRow> </SwipeableRow>
@@ -257,6 +285,21 @@ const createEntitiesStyles = (colors: ThemeColors, isDark: boolean) =>
gap: spacing.sm, gap: spacing.sm,
flexWrap: "wrap", flexWrap: "wrap",
}, },
businessRow: {
flexDirection: "row",
alignItems: "center",
gap: spacing.md,
},
businessCopy: {
flex: 1,
minWidth: 0,
gap: 4,
},
businessIcon: {
width: 44,
height: 44,
borderRadius: 10,
},
name: { name: {
fontSize: 16, fontSize: 16,
fontFamily: fonts.bodySemiBold, fontFamily: fonts.bodySemiBold,
+24 -2
View File
@@ -8,6 +8,11 @@ import { Logo } from "@/components/Logo";
import { fonts, radii, spacing } from "@/constants/theme"; import { fonts, radii, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext"; import { useAppTheme } from "@/contexts/ThemeContext";
import { TOP_CHROME_ROW_HEIGHT } from "@/lib/top-chrome-insets"; import { TOP_CHROME_ROW_HEIGHT } from "@/lib/top-chrome-insets";
import {
BusinessBrandImage,
hasMobileBusinessBrandAsset,
} from "@/components/businesses/BusinessBrandImage";
import { api } from "@/lib/trpc";
type TopChromeProps = { type TopChromeProps = {
showMoreBack?: boolean; showMoreBack?: boolean;
@@ -16,6 +21,7 @@ type TopChromeProps = {
/** Wordmark left, account switcher right — sits on TopChromeBar blur. */ /** Wordmark left, account switcher right — sits on TopChromeBar blur. */
export function TopChrome({ showMoreBack = false }: TopChromeProps) { export function TopChrome({ showMoreBack = false }: TopChromeProps) {
const { colors, isDark } = useAppTheme(); const { colors, isDark } = useAppTheme();
const defaultBusiness = api.businesses.getDefault.useQuery();
function handleBack() { function handleBack() {
if (router.canGoBack()) { if (router.canGoBack()) {
@@ -34,13 +40,25 @@ export function TopChrome({ showMoreBack = false }: TopChromeProps) {
onPress={handleBack} onPress={handleBack}
style={({ pressed }) => [ style={({ pressed }) => [
styles.backButton, styles.backButton,
{ borderColor: colors.borderGlass, backgroundColor: colors.cardGlass }, {
borderColor: colors.borderGlass,
backgroundColor: colors.cardGlass,
},
pressed && styles.pressed, pressed && styles.pressed,
]} ]}
> >
<Ionicons name="chevron-back" size={18} color={colors.foreground} /> <Ionicons name="chevron-back" size={18} color={colors.foreground} />
<Text style={[styles.backLabel, { color: colors.foreground }]}>More</Text> <Text style={[styles.backLabel, { color: colors.foreground }]}>
More
</Text>
</Pressable> </Pressable>
) : defaultBusiness.data &&
hasMobileBusinessBrandAsset(defaultBusiness.data) ? (
<BusinessBrandImage
business={defaultBusiness.data}
kind="wordmark"
style={styles.businessWordmark}
/>
) : ( ) : (
<Logo size="xs" onDark={isDark} /> <Logo size="xs" onDark={isDark} />
)} )}
@@ -66,6 +84,10 @@ const styles = StyleSheet.create({
borderWidth: 1, borderWidth: 1,
borderRadius: radii.pill, borderRadius: radii.pill,
}, },
businessWordmark: {
width: 132,
height: 32,
},
backLabel: { backLabel: {
fontFamily: fonts.bodySemiBold, fontFamily: fonts.bodySemiBold,
fontSize: 13, fontSize: 13,
@@ -0,0 +1,53 @@
import { Image, type ImageStyle, type StyleProp } from "react-native";
import { useAccounts } from "@/contexts/AccountsContext";
import { useAppTheme } from "@/contexts/ThemeContext";
import { hasBusinessBrandAsset } from "@beenvoice/domain/brand-assets";
type BrandAssetKind = "logo" | "wordmark" | "icon";
type BrandAssetTheme = "light" | "dark";
type BusinessBrandImageProps = {
business: {
id: string;
name?: string | null;
logoStorageKey?: string | null;
logoDarkStorageKey?: string | null;
wordmarkLightStorageKey?: string | null;
wordmarkDarkStorageKey?: string | null;
iconLightStorageKey?: string | null;
iconDarkStorageKey?: string | null;
};
kind?: BrandAssetKind;
theme?: BrandAssetTheme;
style?: StyleProp<ImageStyle>;
};
export function hasMobileBusinessBrandAsset(
business: BusinessBrandImageProps["business"],
) {
return hasBusinessBrandAsset(business);
}
export function BusinessBrandImage({
business,
kind = "icon",
theme: themeOverride,
style,
}: BusinessBrandImageProps) {
const { apiUrl } = useAccounts();
const { isDark } = useAppTheme();
if (!hasMobileBusinessBrandAsset(business)) return null;
const theme = themeOverride ?? (isDark ? "dark" : "light");
const base = apiUrl.replace(/\/$/, "");
return (
<Image
source={{
uri: `${base}/api/business-logo/${business.id}?kind=${kind}&theme=${theme}&format=png`,
}}
accessibilityLabel={`${business.name ?? "Business"} ${kind}`}
resizeMode="contain"
style={style}
/>
);
}
@@ -1,4 +1,5 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import * as ImagePicker from "expo-image-picker";
import { import {
Alert, Alert,
KeyboardAvoidingView, KeyboardAvoidingView,
@@ -19,6 +20,12 @@ import type { ThemeColors } from "@/lib/theme-palette";
import { useThemedStyles } from "@/lib/use-themed-styles"; import { useThemedStyles } from "@/lib/use-themed-styles";
import { isRequiredString, useFieldVisibility } from "@/lib/form-validation"; import { isRequiredString, useFieldVisibility } from "@/lib/form-validation";
import { api } from "@/lib/trpc"; import { api } from "@/lib/trpc";
import { BusinessBrandImage } from "@/components/businesses/BusinessBrandImage";
import {
getBrandAssetFieldNames,
type BrandAssetKind,
type BrandAssetTheme,
} from "@beenvoice/domain/brand-assets";
type BusinessFormValues = { type BusinessFormValues = {
name: string; name: string;
@@ -33,6 +40,7 @@ type BusinessFormValues = {
country: string; country: string;
website: string; website: string;
taxId: string; taxId: string;
hideNameWithLogo: boolean;
isDefault: boolean; isDefault: boolean;
}; };
@@ -49,9 +57,24 @@ const emptyValues: BusinessFormValues = {
country: "United States", country: "United States",
website: "", website: "",
taxId: "", taxId: "",
hideNameWithLogo: false,
isDefault: false, isDefault: false,
}; };
const MAX_BRAND_ASSET_BYTES = 5 * 1024 * 1024;
const BRAND_ASSET_SLOTS: Array<{
kind: BrandAssetKind;
theme: BrandAssetTheme;
label: string;
}> = [
{ kind: "logo", theme: "light", label: "Logo · light" },
{ kind: "logo", theme: "dark", label: "Logo · dark" },
{ kind: "wordmark", theme: "light", label: "Wordmark · light" },
{ kind: "wordmark", theme: "dark", label: "Wordmark · dark" },
{ kind: "icon", theme: "light", label: "Icon · light" },
{ kind: "icon", theme: "dark", label: "Icon · dark" },
];
type BusinessFormProps = { type BusinessFormProps = {
mode: "create" | "edit"; mode: "create" | "edit";
businessId?: string; businessId?: string;
@@ -78,6 +101,7 @@ export function BusinessForm({
const [values, setValues] = useState<BusinessFormValues>(emptyValues); const [values, setValues] = useState<BusinessFormValues>(emptyValues);
const [fieldError, setFieldError] = useState<string | null>(null); const [fieldError, setFieldError] = useState<string | null>(null);
const [uploadingAsset, setUploadingAsset] = useState<string | null>(null);
const { touch, visible, markSubmitted } = useFieldVisibility(); const { touch, visible, markSubmitted } = useFieldVisibility();
const switchProps = { const switchProps = {
@@ -102,6 +126,7 @@ export function BusinessForm({
country: business.country ?? "United States", country: business.country ?? "United States",
website: business.website ?? "", website: business.website ?? "",
taxId: business.taxId ?? "", taxId: business.taxId ?? "",
hideNameWithLogo: business.hideNameWithLogo ?? false,
isDefault: business.isDefault ?? false, isDefault: business.isDefault ?? false,
}); });
}, [businessQuery.data]); }, [businessQuery.data]);
@@ -117,7 +142,8 @@ export function BusinessForm({
const updateBusiness = api.businesses.update.useMutation({ const updateBusiness = api.businesses.update.useMutation({
onSuccess: () => { onSuccess: () => {
void utils.businesses.getAll.invalidate(); void utils.businesses.getAll.invalidate();
if (businessId) void utils.businesses.getById.invalidate({ id: businessId }); if (businessId)
void utils.businesses.getById.invalidate({ id: businessId });
onSaved(); onSaved();
}, },
onError: (err) => setFieldError(err.message), onError: (err) => setFieldError(err.message),
@@ -130,8 +156,68 @@ export function BusinessForm({
}, },
onError: (err) => Alert.alert("Could not delete business", err.message), onError: (err) => Alert.alert("Could not delete business", err.message),
}); });
const uploadLogo = api.businesses.uploadLogo.useMutation({
onSuccess: () => {
if (businessId)
void utils.businesses.getById.invalidate({ id: businessId });
},
onError: (err) => Alert.alert("Could not upload brand asset", err.message),
onSettled: () => setUploadingAsset(null),
});
const removeLogo = api.businesses.removeLogo.useMutation({
onSuccess: () => {
if (businessId)
void utils.businesses.getById.invalidate({ id: businessId });
},
onError: (err) => Alert.alert("Could not remove brand asset", err.message),
});
function patch<K extends keyof BusinessFormValues>(field: K, value: BusinessFormValues[K]) { async function pickBrandAsset(kind: BrandAssetKind, theme: BrandAssetTheme) {
if (!businessId) return;
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!permission.granted) {
Alert.alert(
"Photos access needed",
"Allow photo access to upload branding.",
);
return;
}
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ["images"],
quality: 1,
base64: true,
});
if (result.canceled) return;
const asset = result.assets[0];
if (!asset?.base64) return;
const mimeType = asset.mimeType ?? "image/jpeg";
if (!["image/png", "image/jpeg", "image/webp"].includes(mimeType)) {
Alert.alert(
"Unsupported image",
"Mobile supports PNG, JPEG, and WebP. Upload SVG assets from the web app.",
);
return;
}
if (asset.fileSize && asset.fileSize > MAX_BRAND_ASSET_BYTES) {
Alert.alert("Image too large", "Brand assets must be 5MB or less.");
return;
}
const key = `${kind}-${theme}`;
setUploadingAsset(key);
uploadLogo.mutate({
id: businessId,
kind,
theme,
filename: asset.fileName ?? `${key}.jpg`,
mimeType,
data: asset.base64,
});
}
function patch<K extends keyof BusinessFormValues>(
field: K,
value: BusinessFormValues[K],
) {
setValues((prev) => ({ ...prev, [field]: value })); setValues((prev) => ({ ...prev, [field]: value }));
setFieldError(null); setFieldError(null);
} }
@@ -150,6 +236,7 @@ export function BusinessForm({
country: values.country.trim() || "United States", country: values.country.trim() || "United States",
website: values.website.trim(), website: values.website.trim(),
taxId: values.taxId.trim(), taxId: values.taxId.trim(),
hideNameWithLogo: values.hideNameWithLogo,
isDefault: values.isDefault, isDefault: values.isDefault,
}; };
} }
@@ -186,7 +273,9 @@ export function BusinessForm({
} }
const saving = createBusiness.isPending || updateBusiness.isPending; const saving = createBusiness.isPending || updateBusiness.isPending;
const nameError = values.name.trim() ? undefined : "Business name is required"; const nameError = values.name.trim()
? undefined
: "Business name is required";
const canSave = isRequiredString(values.name); const canSave = isRequiredString(values.name);
return ( return (
@@ -195,8 +284,13 @@ export function BusinessForm({
style={styles.flex} style={styles.flex}
> >
<ScrollView <ScrollView
contentContainerStyle={[styles.container, { paddingBottom: scrollPadding }]} contentContainerStyle={[
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : undefined} styles.container,
{ paddingBottom: scrollPadding },
]}
contentInsetAdjustmentBehavior={
Platform.OS === "ios" ? "automatic" : undefined
}
scrollIndicatorInsets={{ bottom: scrollPadding }} scrollIndicatorInsets={{ bottom: scrollPadding }}
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
> >
@@ -247,7 +341,9 @@ export function BusinessForm({
<Text style={[styles.switchLabel, { color: colors.foreground }]}> <Text style={[styles.switchLabel, { color: colors.foreground }]}>
Default business Default business
</Text> </Text>
<Text style={[styles.switchHint, { color: colors.mutedForeground }]}> <Text
style={[styles.switchHint, { color: colors.mutedForeground }]}
>
Used for new invoices when none is selected Used for new invoices when none is selected
</Text> </Text>
</View> </View>
@@ -259,6 +355,108 @@ export function BusinessForm({
</View> </View>
</Card> </Card>
{mode === "edit" && businessQuery.data ? (
<Card title="Brand assets">
<Text style={[styles.brandHint, { color: colors.mutedForeground }]}>
Upload combined logos, wordmarks, and compact icons for light and
dark backgrounds. Missing variants fall back automatically.
</Text>
{BRAND_ASSET_SLOTS.map((slot) => {
const [storageField] = getBrandAssetFieldNames(
slot.kind,
slot.theme,
);
const hasAsset = Boolean(businessQuery.data[storageField]);
const key = `${slot.kind}-${slot.theme}`;
return (
<View key={key} style={styles.brandSlot}>
<View
style={[
styles.brandPreview,
{
backgroundColor:
slot.theme === "dark" ? "#09090b" : "#ffffff",
borderColor: colors.border,
},
]}
>
{hasAsset ? (
<BusinessBrandImage
business={businessQuery.data}
kind={slot.kind}
theme={slot.theme}
style={styles.brandImage}
/>
) : (
<Text
style={{
color:
slot.theme === "dark"
? "rgba(255,255,255,0.45)"
: "rgba(0,0,0,0.35)",
}}
>
No asset
</Text>
)}
</View>
<Text
style={[styles.brandLabel, { color: colors.foreground }]}
>
{slot.label}
</Text>
<View style={styles.brandActions}>
<Button
title={hasAsset ? "Replace" : "Upload"}
variant="secondary"
leftIcon="cloud-upload-outline"
loading={uploadingAsset === key}
disabled={Boolean(uploadingAsset)}
style={styles.brandAction}
onPress={() => void pickBrandAsset(slot.kind, slot.theme)}
/>
{hasAsset ? (
<Button
title="Remove"
variant="ghost"
leftIcon="trash-outline"
loading={removeLogo.isPending}
style={styles.brandAction}
onPress={() =>
removeLogo.mutate({
id: businessId!,
kind: slot.kind,
theme: slot.theme,
})
}
/>
) : null}
</View>
</View>
);
})}
<View style={styles.switchRow}>
<View style={styles.switchCopy}>
<Text
style={[styles.switchLabel, { color: colors.foreground }]}
>
Hide business name on invoices
</Text>
<Text
style={[styles.switchHint, { color: colors.mutedForeground }]}
>
Useful when the combined logo already includes the name
</Text>
</View>
<Switch
value={values.hideNameWithLogo}
onValueChange={(value) => patch("hideNameWithLogo", value)}
{...switchProps}
/>
</View>
</Card>
) : null}
<Card title="Address"> <Card title="Address">
<Input <Input
label="Address line 1" label="Address line 1"
@@ -270,8 +468,16 @@ export function BusinessForm({
value={values.addressLine2} value={values.addressLine2}
onChangeText={(v) => patch("addressLine2", v)} onChangeText={(v) => patch("addressLine2", v)}
/> />
<Input label="City" value={values.city} onChangeText={(v) => patch("city", v)} /> <Input
<Input label="State" value={values.state} onChangeText={(v) => patch("state", v)} /> label="City"
value={values.city}
onChangeText={(v) => patch("city", v)}
/>
<Input
label="State"
value={values.state}
onChangeText={(v) => patch("state", v)}
/>
<Input <Input
label="Postal code" label="Postal code"
value={values.postalCode} value={values.postalCode}
@@ -284,7 +490,11 @@ export function BusinessForm({
/> />
</Card> </Card>
{fieldError ? <Text selectable style={styles.error}>{fieldError}</Text> : null} {fieldError ? (
<Text selectable style={styles.error}>
{fieldError}
</Text>
) : null}
<View style={styles.actions}> <View style={styles.actions}>
<Button <Button
@@ -337,6 +547,38 @@ const createBusinessFormStyles = (colors: ThemeColors, _isDark: boolean) =>
actions: { actions: {
gap: spacing.sm, gap: spacing.sm,
}, },
brandHint: {
fontFamily: fonts.body,
fontSize: 13,
lineHeight: 18,
},
brandSlot: {
gap: spacing.sm,
paddingVertical: spacing.xs,
},
brandPreview: {
height: 92,
borderWidth: 1,
borderRadius: 12,
alignItems: "center",
justifyContent: "center",
padding: spacing.md,
},
brandImage: {
width: "100%",
height: "100%",
},
brandLabel: {
fontFamily: fonts.bodySemiBold,
fontSize: 14,
},
brandActions: {
flexDirection: "row",
gap: spacing.sm,
},
brandAction: {
flex: 1,
},
error: { error: {
color: colors.destructive, color: colors.destructive,
fontFamily: fonts.body, fontFamily: fonts.body,
+20 -13
View File
@@ -3,6 +3,8 @@
import { afterEach, describe, expect, test } from "bun:test"; import { afterEach, describe, expect, test } from "bun:test";
import { import {
EXPENSE_CATEGORIES as domainExpenseCategories, EXPENSE_CATEGORIES as domainExpenseCategories,
addCalendarDays,
calendarDateFromInstant,
formatElapsedSeconds as formatDomainElapsedSeconds, formatElapsedSeconds as formatDomainElapsedSeconds,
getEffectiveInvoiceStatus, getEffectiveInvoiceStatus,
} from "@beenvoice/domain"; } from "@beenvoice/domain";
@@ -14,9 +16,7 @@ import { generateInvoiceNumber as generateMobileInvoiceNumber } from "../lib/inv
import { formatElapsedSeconds as formatAppElapsedSeconds } from "../lib/time-clock"; import { formatElapsedSeconds as formatAppElapsedSeconds } from "../lib/time-clock";
import { generateInvoiceNumber as generateWebInvoiceNumber } from "../../web/src/lib/draft-invoice"; import { generateInvoiceNumber as generateWebInvoiceNumber } from "../../web/src/lib/draft-invoice";
import { safeCallbackPath } from "../../web/src/lib/safe-callback-url"; import { safeCallbackPath } from "../../web/src/lib/safe-callback-url";
import { import { normalizeOptionalId } from "../../web/src/lib/time-clock";
normalizeOptionalId,
} from "../../web/src/lib/time-clock";
const originalFetch = globalThis.fetch; const originalFetch = globalThis.fetch;
@@ -52,15 +52,18 @@ describe("invoice parity", () => {
test("web and mobile use the device-local date in invoice numbers", () => { test("web and mobile use the device-local date in invoice numbers", () => {
const lateLocalEvening = new Date(2026, 7, 16, 23, 30, 0, 123); const lateLocalEvening = new Date(2026, 7, 16, 23, 30, 0, 123);
expect(generateMobileInvoiceNumber(lateLocalEvening)).toStartWith("INV-20260816-"); expect(generateMobileInvoiceNumber(lateLocalEvening)).toStartWith(
expect(generateWebInvoiceNumber(lateLocalEvening)).toStartWith("INV-20260816-"); "INV-20260816-",
);
expect(generateWebInvoiceNumber(lateLocalEvening)).toStartWith(
"INV-20260816-",
);
}); });
test("web and mobile agree on draft, paid, sent, and overdue states", () => { test("web and mobile agree on draft, paid, sent, and overdue states", () => {
const today = new Date(); const timeZone = "America/New_York";
today.setHours(0, 0, 0, 0); const today = calendarDateFromInstant(new Date(), timeZone);
const yesterday = new Date(today); const yesterday = addCalendarDays(today, -1);
yesterday.setDate(yesterday.getDate() - 1);
const fixtures = [ const fixtures = [
{ {
@@ -82,11 +85,15 @@ describe("invoice parity", () => {
]; ];
for (const fixture of fixtures) { for (const fixture of fixtures) {
expect(getEffectiveInvoiceStatus(fixture.stored, fixture.dueDate)).toBe(
fixture.expected,
);
expect( expect(
getInvoiceStatus({ status: fixture.stored, dueDate: fixture.dueDate }), getEffectiveInvoiceStatus(fixture.stored, fixture.dueDate, timeZone),
).toBe(fixture.expected);
expect(
getInvoiceStatus({
status: fixture.stored,
dueDate: fixture.dueDate,
createdBy: { timeZone },
}),
).toBe(fixture.expected); ).toBe(fixture.expected);
} }
}); });
@@ -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, "when": 1786950000000,
"tag": "0031_timezone_safety", "tag": "0031_timezone_safety",
"breakpoints": true "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 { getObject } from "~/lib/object-storage";
import { db } from "~/server/db"; import { db } from "~/server/db";
import { businesses } from "~/server/db/schema"; import { businesses } from "~/server/db/schema";
import {
brandAssetKinds,
brandAssetThemes,
resolveBusinessBrandAsset,
type BrandAssetKind,
type BrandAssetTheme,
} from "~/lib/business-branding";
export const runtime = "nodejs"; export const runtime = "nodejs";
@@ -16,27 +23,57 @@ export async function GET(
{ params }: { params: Promise<{ businessId: string }> }, { params }: { params: Promise<{ businessId: string }> },
) { ) {
const { businessId } = await params; 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({ const business = await db.query.businesses.findFirst({
where: eq(businesses.id, businessId), 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 }); return NextResponse.json({ error: "Not found" }, { status: 404 });
} }
// @react-pdf/renderer's Image component only decodes PNG/JPEG, so PDF // @react-pdf/renderer's Image component only decodes PNG/JPEG, so PDF
// generation requests a rasterized copy of SVG/WebP logos via this param. // generation requests a rasterized copy of SVG/WebP logos via this param.
const wantsPng = const wantsPng =
new URL(req.url).searchParams.get("format") === "png" && url.searchParams.get("format") === "png" &&
RASTERIZABLE_MIME_TYPES.has(business.logoMimeType); RASTERIZABLE_MIME_TYPES.has(asset.mimeType);
try { try {
const body = await getObject(business.logoStorageKey); const body = await getObject(asset.storageKey);
if (wantsPng) { if (wantsPng) {
const { default: sharp } = await import("sharp"); 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 // 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). // the size it's actually displayed (PDF header, up to ~2.2in wide).
// withoutEnlargement only makes sense for the WebP (already-raster) // withoutEnlargement only makes sense for the WebP (already-raster)
@@ -62,7 +99,7 @@ export async function GET(
return new NextResponse(new Uint8Array(body), { return new NextResponse(new Uint8Array(body), {
headers: { headers: {
"Content-Type": business.logoMimeType, "Content-Type": asset.mimeType,
"Cache-Control": "public, max-age=300, must-revalidate", "Cache-Control": "public, max-age=300, must-revalidate",
"X-Content-Type-Options": "nosniff", "X-Content-Type-Options": "nosniff",
}, },
@@ -71,6 +108,8 @@ export async function GET(
console.error("[business-logo] Failed to serve logo", { console.error("[business-logo] Failed to serve logo", {
backendError: error, backendError: error,
businessId, businessId,
kind,
theme,
wantsPng, wantsPng,
}); });
return NextResponse.json({ error: "Logo not found" }, { status: 404 }); 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, taxId: true,
logoStorageKey: true, logoStorageKey: true,
logoMimeType: 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, hideNameWithLogo: true,
}, },
}, },
@@ -52,8 +62,14 @@ export async function GET(
return NextResponse.json({ error: "Not found" }, { status: 404 }); return NextResponse.json({ error: "Not found" }, { status: 404 });
} }
if (invoice.publicTokenExpiresAt && new Date(invoice.publicTokenExpiresAt) < new Date()) { if (
return NextResponse.json({ error: "This link has expired" }, { status: 410 }); 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({ const settings = await db.query.platformSettings.findFirst({
@@ -24,6 +24,8 @@ import {
Hash, Hash,
ArrowLeft, ArrowLeft,
} from "lucide-react"; } from "lucide-react";
import { BusinessBrandImage } from "~/components/branding/business-brand-image";
import { hasBusinessBrandAsset } from "~/lib/business-branding";
interface BusinessDetailPageProps { interface BusinessDetailPageProps {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
@@ -74,13 +76,12 @@ export default async function BusinessDetailPage({
<Card className="bg-card border-border border"> <Card className="bg-card border-border border">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <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"> <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 */} <BusinessBrandImage
<img business={business}
src={`/api/business-logo/${business.id}`} kind="icon"
alt={`${business.name} logo`} className="h-full w-full"
className="h-full w-auto max-w-full object-contain"
/> />
</div> </div>
) : ( ) : (
@@ -17,6 +17,8 @@ import {
} from "~/components/ui/dialog"; } from "~/components/ui/dialog";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import { toast } from "sonner"; import { toast } from "sonner";
import { BusinessBrandImage } from "~/components/branding/business-brand-image";
import { hasBusinessBrandAsset } from "~/lib/business-branding";
// Type for business data // Type for business data
interface Business { interface Business {
@@ -35,6 +37,17 @@ interface Business {
taxId: string | null; taxId: string | null;
logoUrl: string | null; logoUrl: string | null;
logoStorageKey: 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; createdById: string;
createdAt: Date; createdAt: Date;
updatedAt: Date | null; updatedAt: Date | null;
@@ -88,12 +101,12 @@ export function BusinessesDataTable({ businesses }: BusinessesDataTableProps) {
return ( return (
<div className="flex items-center gap-3"> <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"> <div className="bg-primary/10 hidden h-8 w-8 shrink-0 items-center justify-center overflow-hidden p-2 sm:flex">
{business.logoStorageKey ? ( {hasBusinessBrandAsset(business) ? (
// eslint-disable-next-line @next/next/no-img-element -- object-storage-backed image, not a static asset <BusinessBrandImage
<img business={business}
src={`/api/business-logo/${business.id}`} kind="icon"
alt="" decorative
className="h-full w-full object-contain" className="h-full w-full"
/> />
) : ( ) : (
<Building className="text-primary h-4 w-4" /> <Building className="text-primary h-4 w-4" />
@@ -28,6 +28,8 @@ import {
toZonedDateTimeInputValue, toZonedDateTimeInputValue,
zonedDateTimeToInstant, zonedDateTimeToInstant,
} from "@beenvoice/domain/time-zone"; } 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 Link from "next/link";
import { import {
notFound, notFound,
@@ -424,13 +426,12 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <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"> <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 */} <BusinessBrandImage
<img business={invoice.business}
src={`/api/business-logo/${invoice.business.id}`} kind="logo"
alt={`${invoice.business.name} logo`} className="h-full max-w-36 min-w-20"
className="h-full w-auto max-w-full object-contain"
/> />
</div> </div>
)} )}
@@ -264,6 +264,19 @@ export default function SendEmailPage() {
email: invoiceData.business.email, email: invoiceData.business.email,
logoStorageKey: invoiceData.business.logoStorageKey, logoStorageKey: invoiceData.business.logoStorageKey,
logoMimeType: invoiceData.business.logoMimeType, 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, : undefined,
items: invoiceData.items?.map((item) => ({ items: invoiceData.items?.map((item) => ({
+9 -8
View File
@@ -13,6 +13,8 @@ import {
formatCalendarDate, formatCalendarDate,
getEffectiveInvoiceStatus, getEffectiveInvoiceStatus,
} from "@beenvoice/domain"; } from "@beenvoice/domain";
import { BusinessBrandImage } from "~/components/branding/business-brand-image";
import { hasBusinessBrandAsset } from "~/lib/business-branding";
function formatDate(date: Date) { function formatDate(date: Date) {
return formatCalendarDate(date, { return formatCalendarDate(date, {
@@ -121,7 +123,7 @@ function PublicInvoiceView({ token }: { token: string }) {
? `${invoice.business.name} (${invoice.business.nickname})` ? `${invoice.business.name} (${invoice.business.nickname})`
: invoice.business.name : invoice.business.name
: null; : null;
const hasLogo = Boolean(invoice.business?.logoStorageKey); const hasLogo = hasBusinessBrandAsset(invoice.business);
const hideName = hasLogo && Boolean(invoice.business?.hideNameWithLogo); const hideName = hasLogo && Boolean(invoice.business?.hideNameWithLogo);
return ( return (
@@ -132,13 +134,12 @@ function PublicInvoiceView({ token }: { token: string }) {
{/* Header */} {/* Header */}
<div className="flex items-center gap-3 bg-gray-900 px-8 py-6"> <div className="flex items-center gap-3 bg-gray-900 px-8 py-6">
{hasLogo && ( {hasLogo && (
// Uploaded SVGs are sanitized and served by our route. next/image's <BusinessBrandImage
// optimizer intentionally rejects SVG, so a native img is required. business={invoice.business!}
// eslint-disable-next-line @next/next/no-img-element kind="logo"
<img theme="dark"
src={`/api/business-logo/${invoice.business!.id}`} decorative
alt="" className="h-16 w-[220px] max-w-[42%] shrink-0 rounded px-2 py-1.5"
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"> <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"} />;
}
+158 -47
View File
@@ -24,7 +24,10 @@ import { toast } from "sonner";
import { AddressForm } from "~/components/forms/address-form"; import { AddressForm } from "~/components/forms/address-form";
import { FloatingActionBar } from "~/components/layout/floating-action-bar"; import { FloatingActionBar } from "~/components/layout/floating-action-bar";
import { DashboardPageHeader } from "~/components/layout/page-header"; 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 { cn } from "~/lib/utils";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Alert, AlertDescription } from "~/components/ui/alert"; import { Alert, AlertDescription } from "~/components/ui/alert";
@@ -43,6 +46,13 @@ import {
VALIDATION_MESSAGES, VALIDATION_MESSAGES,
} from "~/lib/form-constants"; } from "~/lib/form-constants";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import {
businessBrandAssetPath,
getBrandAssetFieldNames,
hasBusinessBrandAsset,
type BrandAssetKind,
type BrandAssetTheme,
} from "~/lib/business-branding";
interface BusinessFormProps { interface BusinessFormProps {
businessId?: string; businessId?: string;
@@ -114,6 +124,54 @@ const ACCEPTED_LOGO_TYPES = new Set([
"image/svg+xml", "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) { export function BusinessForm({ businessId, mode }: BusinessFormProps) {
const router = useRouter(); const router = useRouter();
const utils = api.useUtils(); const utils = api.useUtils();
@@ -123,7 +181,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
const [showApiKey, setShowApiKey] = useState(false); const [showApiKey, setShowApiKey] = useState(false);
const [isDirty, setIsDirty] = useState(false); const [isDirty, setIsDirty] = useState(false);
const [initialized, setInitialized] = useState(false); const [initialized, setInitialized] = useState(false);
const [isUploadingLogo, setIsUploadingLogo] = useState(false); const [uploadingAsset, setUploadingAsset] = useState<string | null>(null);
// Fetch business data if editing // Fetch business data if editing
const { data: business, isLoading: isLoadingBusiness } = const { data: business, isLoading: isLoadingBusiness } =
@@ -165,20 +223,20 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
}); });
const uploadLogo = api.businesses.uploadLogo.useMutation({ const uploadLogo = api.businesses.uploadLogo.useMutation({
onSuccess: async () => { onSuccess: async (_data, variables) => {
await utils.businesses.getById.invalidate({ id: businessId }); await utils.businesses.getById.invalidate({ id: businessId });
toast.success("Logo updated"); toast.success(`${variables.kind} ${variables.theme} variant updated`);
}, },
onError: (error) => { onError: (error) => {
toast.error(error.message || "Failed to upload logo"); toast.error(error.message || "Failed to upload logo");
}, },
onSettled: () => setIsUploadingLogo(false), onSettled: () => setUploadingAsset(null),
}); });
const removeLogo = api.businesses.removeLogo.useMutation({ const removeLogo = api.businesses.removeLogo.useMutation({
onSuccess: async () => { onSuccess: async (_data, variables) => {
await utils.businesses.getById.invalidate({ id: businessId }); await utils.businesses.getById.invalidate({ id: businessId });
toast.success("Logo removed"); toast.success(`${variables.kind} ${variables.theme} variant removed`);
}, },
onError: (error) => { onError: (error) => {
toast.error(error.message || "Failed to remove logo"); toast.error(error.message || "Failed to remove logo");
@@ -187,6 +245,8 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
const handleLogoFileSelected = async ( const handleLogoFileSelected = async (
e: React.ChangeEvent<HTMLInputElement>, e: React.ChangeEvent<HTMLInputElement>,
kind: BrandAssetKind,
theme: BrandAssetTheme,
) => { ) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
e.target.value = ""; e.target.value = "";
@@ -201,7 +261,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
return; return;
} }
setIsUploadingLogo(true); setUploadingAsset(assetSlotKey(kind, theme));
const data = await new Promise<string>((resolve, reject) => { const data = await new Promise<string>((resolve, reject) => {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = () => { reader.onload = () => {
@@ -217,6 +277,8 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
filename: file.name, filename: file.name,
mimeType: file.type, mimeType: file.type,
data, 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) // Load business data once when editing (avoid overwriting unsaved changes on refetch)
useEffect(() => { useEffect(() => {
if ( if (business && mode === "edit" && !initialized && !isLoadingEmailConfig) {
business &&
mode === "edit" &&
!initialized &&
!isLoadingEmailConfig
) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Sync loaded business data into the edit form. // eslint-disable-next-line react-hooks/set-state-in-effect -- Sync loaded business data into the edit form.
setFormData({ setFormData({
name: business.name, name: business.name,
@@ -732,74 +789,128 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
<ImageIcon className="text-muted-foreground h-5 w-5" /> <ImageIcon className="text-muted-foreground h-5 w-5" />
</div> </div>
<div> <div>
<CardTitle>Logo</CardTitle> <CardTitle>Brand assets</CardTitle>
<p className="text-muted-foreground mt-1 text-sm"> <p className="text-muted-foreground mt-1 text-sm">
Shown on invoices sent to your clients. PNG, JPEG, Add logos, wordmarks, and icons for light and dark
WebP, or SVG, up to 5MB. backgrounds. Missing variants fall back automatically.
PNG, JPEG, WebP, or SVG, up to 5MB.
</p> </p>
</div> </div>
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="flex items-center gap-4"> <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<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"> {BRAND_ASSET_SLOTS.map((slot) => {
{business?.logoStorageKey ? ( const [storageField] = getBrandAssetFieldNames(
// eslint-disable-next-line @next/next/no-img-element -- external/object-storage-backed image, not a static asset 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"
>
<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 <img
src={`/api/business-logo/${businessId}?v=${business.updatedAt ? new Date(business.updatedAt).getTime() : 0}`} src={`${businessBrandAssetPath(businessId, slot.kind, slot.theme)}&v=${business?.updatedAt ? new Date(business.updatedAt).getTime() : 0}`}
alt={`${business.name} logo`} alt={`${business?.name ?? "Business"} ${slot.label}`}
className="h-full w-auto max-w-full object-contain" className={cn(
"max-h-full max-w-full object-contain",
slot.kind === "icon" && "aspect-square",
)}
/> />
) : ( ) : (
<ImageIcon className="text-muted-foreground/50 h-8 w-8" /> <ImageIcon
className={cn(
"h-8 w-8",
slot.theme === "dark"
? "text-white/35"
: "text-black/25",
)}
/>
)} )}
</div> </div>
<div className="flex flex-col gap-2 sm:flex-row"> <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 <Button
type="button" type="button"
variant="outline" variant="outline"
size="sm" size="sm"
disabled={isUploadingLogo} className="flex-1"
disabled={Boolean(uploadingAsset)}
onClick={() => onClick={() =>
document.getElementById("logo-upload-input")?.click() document.getElementById(inputId)?.click()
} }
> >
{isUploadingLogo ? ( {isUploading ? (
<Loader2 className="h-4 w-4 animate-spin sm:mr-2" /> <Loader2 className="h-4 w-4 animate-spin" />
) : ( ) : (
<Upload className="h-4 w-4 sm:mr-2" /> <Upload className="h-4 w-4" />
)} )}
<span className="hidden sm:inline"> {hasAsset ? "Replace" : "Upload"}
{business?.logoStorageKey
? "Replace logo"
: "Upload logo"}
</span>
</Button> </Button>
{business?.logoStorageKey && ( {hasAsset ? (
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
size="sm" size="icon"
className="h-8 w-8 shrink-0"
aria-label={`Remove ${slot.label}`}
disabled={removeLogo.isPending} disabled={removeLogo.isPending}
onClick={() => onClick={() =>
businessId && removeLogo.mutate({ id: businessId }) businessId &&
removeLogo.mutate({
id: businessId,
kind: slot.kind,
theme: slot.theme,
})
} }
> >
<Trash2 className="h-4 w-4 sm:mr-2" /> <Trash2 className="h-4 w-4" />
<span className="hidden sm:inline">Remove</span>
</Button> </Button>
)} ) : null}
<input <input
id="logo-upload-input" id={inputId}
type="file" type="file"
accept="image/png,image/jpeg,image/webp,image/svg+xml" accept="image/png,image/jpeg,image/webp,image/svg+xml"
className="hidden" className="hidden"
onChange={handleLogoFileSelected} onChange={(event) =>
void handleLogoFileSelected(
event,
slot.kind,
slot.theme,
)
}
/> />
</div> </div>
</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="bg-muted border-border/40 mt-4 flex items-center justify-between border p-4">
<div className="space-y-0.5"> <div className="space-y-0.5">
<Label <Label
@@ -809,8 +920,8 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
Hide business name on invoices Hide business name on invoices
</Label> </Label>
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm">
Show only the logo in the invoice header useful Show only the logo in the invoice header useful if
if your logo already includes your business name. your logo already includes your business name.
</p> </p>
</div> </div>
<Switch <Switch
@@ -3,6 +3,7 @@
import { generateInvoiceEmailTemplate } from "~/lib/email-templates"; import { generateInvoiceEmailTemplate } from "~/lib/email-templates";
import { getAppUrl } from "~/lib/app-url"; import { getAppUrl } from "~/lib/app-url";
import { calculateLineItemAmount } from "~/lib/invoice-line-item"; import { calculateLineItemAmount } from "~/lib/invoice-line-item";
import type { BusinessBrandAssets } from "~/lib/business-branding";
interface EmailPreviewProps { interface EmailPreviewProps {
subject: string; subject: string;
@@ -28,9 +29,7 @@ interface EmailPreviewProps {
id?: string; id?: string;
name: string; name: string;
email: string | null; email: string | null;
logoStorageKey?: string | null; } & BusinessBrandAssets;
logoMimeType?: string | null;
};
items?: Array<{ items?: Array<{
id: string; id: string;
date?: Date; date?: Date;
@@ -87,7 +86,8 @@ export function EmailPreview({
description: item.description ?? "Service", description: item.description ?? "Service",
hours: item.hours, hours: item.hours,
rate: item.rate, rate: item.rate,
amount: item.amount ?? calculateLineItemAmount(item.hours, item.rate), amount:
item.amount ?? calculateLineItemAmount(item.hours, item.rate),
})) ?? [], })) ?? [],
}, },
customContent: content, customContent: content,
@@ -9,7 +9,7 @@ import {
} from "~/components/layout/sidebar-provider"; } from "~/components/layout/sidebar-provider";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
import { Menu } from "lucide-react"; 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 { Button } from "~/components/ui/button";
import { Sheet, SheetContent, SheetTrigger } from "~/components/ui/sheet"; import { Sheet, SheetContent, SheetTrigger } from "~/components/ui/sheet";
import { ActiveTimerWidget } from "~/app/dashboard/_components/active-timer-widget"; import { ActiveTimerWidget } from "~/app/dashboard/_components/active-timer-widget";
@@ -48,7 +48,7 @@ function DashboardContent({ children }: { children: React.ReactNode }) {
</Button> </Button>
</SheetTrigger> </SheetTrigger>
<div className="ml-3 flex min-w-0 flex-1 items-center gap-2 sm:ml-4"> <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 /> <ActiveTimerWidget compact />
</div> </div>
<SheetContent side="left" className="w-72 p-0"> <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 { getNavigationForUser, isNavLinkActive } from "~/lib/navigation";
import { useSidebar } from "./sidebar-provider"; import { useSidebar } from "./sidebar-provider";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
import { Logo } from "~/components/branding/logo"; import { DashboardBrand } from "~/components/branding/dashboard-brand";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@@ -57,10 +57,10 @@ export function Sidebar({ mobile, onClose }: SidebarProps) {
> >
{!collapsed && ( {!collapsed && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Logo size="sm" /> <DashboardBrand />
</div> </div>
)} )}
{collapsed && <Logo size="icon" />} {collapsed && <DashboardBrand compact />}
{!mobile && !collapsed && ( {!mobile && !collapsed && (
<div className="h-8 w-8" /> // Spacer to keep alignment if needed, or just remove <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 { getAppUrl } from "~/lib/app-url";
import { formatCalendarDate } from "@beenvoice/domain/time-zone"; 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 // 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 // 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. // rasterization the PDF export uses.
function resolveEmailLogoUrl( function resolveEmailLogoUrl(
business: business:
| { | ({
id?: string; id?: string;
logoStorageKey?: string | null; } & BusinessBrandAssets)
logoMimeType?: string | null;
}
| null | null
| undefined, | undefined,
baseUrl: string, baseUrl: string,
): string | null { ): string | null {
if (!business?.id || !business.logoStorageKey) return null; if (!business?.id) return null;
const needsRaster = const asset = resolveBusinessBrandAsset(business, "logo", "light");
business.logoMimeType != null && if (!asset) return null;
!["image/png", "image/jpeg"].includes(business.logoMimeType); const needsRaster = !["image/png", "image/jpeg"].includes(asset.mimeType);
const path = `/api/business-logo/${business.id}${needsRaster ? "?format=png" : ""}`; const path = businessBrandAssetPath(
business.id,
"logo",
"light",
needsRaster ? "png" : undefined,
);
return `${baseUrl.replace(/\/$/, "")}${path}`; return `${baseUrl.replace(/\/$/, "")}${path}`;
} }
@@ -37,7 +45,8 @@ interface InvoiceEmailTemplateProps {
name: string; name: string;
email: string | null; email: string | null;
}; };
business?: { business?:
| ({
id?: string; id?: string;
name: string; name: string;
nickname?: string | null; nickname?: string | null;
@@ -49,9 +58,8 @@ interface InvoiceEmailTemplateProps {
state?: string | null; state?: string | null;
postalCode?: string | null; postalCode?: string | null;
country?: string | null; country?: string | null;
logoStorageKey?: string | null; } & BusinessBrandAssets)
logoMimeType?: string | null; | null;
} | null;
items: Array<{ items: Array<{
date: Date; date: Date;
description: string; description: string;
+19 -9
View File
@@ -18,6 +18,11 @@ import {
pdfFontCacheKey, pdfFontCacheKey,
resolvePdfFonts, resolvePdfFonts,
} from "~/lib/pdf-fonts"; } from "~/lib/pdf-fonts";
import {
businessBrandAssetPath,
resolveBusinessBrandAsset,
type BusinessBrandAssets,
} from "~/lib/business-branding";
// Fallback download function for better browser compatibility // Fallback download function for better browser compatibility
function downloadBlob(blob: Blob, filename: string): void { function downloadBlob(blob: Blob, filename: string): void {
@@ -73,7 +78,8 @@ export interface InvoiceData {
taxRate: number; taxRate: number;
currency?: string | null; currency?: string | null;
notes?: string | null; notes?: string | null;
business?: { business?:
| ({
id?: string; id?: string;
name: string; name: string;
nickname?: string | null; nickname?: string | null;
@@ -87,10 +93,9 @@ export interface InvoiceData {
country?: string | null; country?: string | null;
website?: string | null; website?: string | null;
taxId?: string | null; taxId?: string | null;
logoStorageKey?: string | null;
logoMimeType?: string | null;
hideNameWithLogo?: boolean | null; hideNameWithLogo?: boolean | null;
} | null; } & BusinessBrandAssets)
| null;
client?: { client?: {
name: string; name: string;
email?: string | null; email?: string | null;
@@ -848,12 +853,17 @@ function resolveBusinessLogoSrc(
business: InvoiceData["business"], business: InvoiceData["business"],
baseUrlOverride?: string, baseUrlOverride?: string,
): string | null { ): 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 = const needsRaster = !["image/png", "image/jpeg"].includes(asset.mimeType);
business.logoMimeType != null && const path = businessBrandAssetPath(
!["image/png", "image/jpeg"].includes(business.logoMimeType); business.id,
const path = `/api/business-logo/${business.id}${needsRaster ? "?format=png" : ""}`; "logo",
"light",
needsRaster ? "png" : undefined,
);
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
return `${window.location.origin}${path}`; return `${window.location.origin}${path}`;
+1
View File
@@ -23,6 +23,7 @@ export function proxy(request: NextRequest) {
"/api/mcp", "/api/mcp",
"/api/i", "/api/i",
"/api/business-logo", "/api/business-logo",
"/api/health",
]; ];
// Allow API routes to pass through // 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 { sql } from "drizzle-orm";
import { deleteObject, putObject } from "~/lib/object-storage"; import { deleteObject, putObject } from "~/lib/object-storage";
import { sanitizeSvg } from "~/lib/svg-sanitize"; import { sanitizeSvg } from "~/lib/svg-sanitize";
import {
brandAssetKinds,
brandAssetThemes,
getBrandAssetFieldNames,
} from "~/lib/business-branding";
const MAX_LOGO_BYTES = 5 * 1024 * 1024; const MAX_LOGO_BYTES = 5 * 1024 * 1024;
const allowedLogoMimeTypes = new Set([ const allowedLogoMimeTypes = new Set([
@@ -287,6 +292,7 @@ export const businessesRouter = createTRPCRouter({
"Business not found or you don't have permission to delete it", "Business not found or you don't have permission to delete it",
); );
} }
const existingBusiness = business[0];
// Check if this business has any invoices // Check if this business has any invoices
const invoiceCount = await ctx.db 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 await ctx.db
.delete(businesses) .delete(businesses)
.where( .where(
@@ -309,6 +322,12 @@ export const businessesRouter = createTRPCRouter({
), ),
); );
await Promise.all(
[...new Set(storageKeys)].map((key) =>
deleteObject(key).catch(() => undefined),
),
);
return { success: true }; 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 uploadLogo: protectedProcedure
.input( .input(
z.object({ z.object({
@@ -440,6 +460,8 @@ export const businessesRouter = createTRPCRouter({
filename: z.string().min(1).max(255), filename: z.string().min(1).max(255),
mimeType: z.string().min(1).max(100), mimeType: z.string().min(1).max(100),
data: z.string().min(1), data: z.string().min(1),
kind: z.enum(brandAssetKinds).default("logo"),
theme: z.enum(brandAssetThemes).default("light"),
}), }),
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
@@ -457,7 +479,8 @@ export const businessesRouter = createTRPCRouter({
if (!business) { if (!business) {
throw new TRPCError({ throw new TRPCError({
code: "NOT_FOUND", 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)) { if (!allowedLogoMimeTypes.has(mimeType)) {
throw new TRPCError({ throw new TRPCError({
code: "BAD_REQUEST", 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) { if (!body.length || body.length > MAX_LOGO_BYTES) {
throw new TRPCError({ throw new TRPCError({
code: "BAD_REQUEST", 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 safeName = input.filename.replace(/[^a-zA-Z0-9._-]/g, "_");
const storageKey = `logos/${ctx.session.user.id}/${business.id}/${crypto.randomUUID()}-${safeName}`; const storageKey = `logos/${ctx.session.user.id}/${business.id}/${input.kind}/${input.theme}/${crypto.randomUUID()}-${safeName}`;
const previousStorageKey = business.logoStorageKey; const [storageField, mimeField] = getBrandAssetFieldNames(
input.kind,
input.theme,
);
const previousStorageKey = business[storageField];
try { try {
await putObject(storageKey, body, mimeType); await putObject(storageKey, body, mimeType);
} catch (error) { } catch (error) {
console.error("[businesses.uploadLogo] Failed to store logo", { console.error("[businesses.uploadLogo] Failed to store brand asset", {
backendError: error, backendError: error,
businessId: business.id, businessId: business.id,
}); });
throw new TRPCError({ throw new TRPCError({
code: "INTERNAL_SERVER_ERROR", code: "INTERNAL_SERVER_ERROR",
message: 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, cause: error,
}); });
} }
@@ -503,8 +530,8 @@ export const businessesRouter = createTRPCRouter({
const [updatedBusiness] = await ctx.db const [updatedBusiness] = await ctx.db
.update(businesses) .update(businesses)
.set({ .set({
logoStorageKey: storageKey, [storageField]: storageKey,
logoMimeType: mimeType, [mimeField]: mimeType,
updatedAt: new Date(), updatedAt: new Date(),
}) })
.where(eq(businesses.id, business.id)) .where(eq(businesses.id, business.id))
@@ -517,9 +544,15 @@ export const businessesRouter = createTRPCRouter({
return updatedBusiness; return updatedBusiness;
}), }),
// Remove a business logo // Remove one business brand asset. Defaults preserve older clients.
removeLogo: protectedProcedure 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 }) => { .mutation(async ({ ctx, input }) => {
const [business] = await ctx.db const [business] = await ctx.db
.select() .select()
@@ -535,17 +568,27 @@ export const businessesRouter = createTRPCRouter({
if (!business) { if (!business) {
throw new TRPCError({ throw new TRPCError({
code: "NOT_FOUND", 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) { const [storageField, mimeField] = getBrandAssetFieldNames(
await deleteObject(business.logoStorageKey).catch(() => undefined); input.kind,
input.theme,
);
const storageKey = business[storageField];
if (storageKey) {
await deleteObject(storageKey).catch(() => undefined);
} }
const [updatedBusiness] = await ctx.db const [updatedBusiness] = await ctx.db
.update(businesses) .update(businesses)
.set({ logoStorageKey: null, logoMimeType: null, updatedAt: new Date() }) .set({
[storageField]: null,
[mimeField]: null,
updatedAt: new Date(),
})
.where(eq(businesses.id, business.id)) .where(eq(businesses.id, business.id))
.returning(); .returning();
@@ -1124,6 +1124,16 @@ export const invoicesRouter = createTRPCRouter({
taxId: true, taxId: true,
logoStorageKey: true, logoStorageKey: true,
logoMimeType: 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, hideNameWithLogo: true,
}, },
}, },
+18 -4
View File
@@ -1212,14 +1212,21 @@ export const settingsRouter = createTRPCRouter({
.mutation(async ({ ctx }) => { .mutation(async ({ ctx }) => {
const userId = ctx.session.user.id; const userId = ctx.session.user.id;
const [receiptObjects, logoObjects] = await Promise.all([ const [receiptObjects, brandObjects] = await Promise.all([
ctx.db ctx.db
.select({ storageKey: expenseReceipts.storageKey }) .select({ storageKey: expenseReceipts.storageKey })
.from(expenseReceipts) .from(expenseReceipts)
.innerJoin(expenses, eq(expenseReceipts.expenseId, expenses.id)) .innerJoin(expenses, eq(expenseReceipts.expenseId, expenses.id))
.where(eq(expenses.createdById, userId)), .where(eq(expenses.createdById, userId)),
ctx.db 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) .from(businesses)
.where(eq(businesses.createdById, userId)), .where(eq(businesses.createdById, userId)),
]); ]);
@@ -1227,9 +1234,16 @@ export const settingsRouter = createTRPCRouter({
// Delete uploaded personal data before removing its database pointers. If object // Delete uploaded personal data before removing its database pointers. If object
// storage is unavailable, the account remains intact so the user can retry. // storage is unavailable, the account remains intact so the user can retry.
await Promise.all( 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) => { await ctx.db.transaction(async (tx) => {
+12
View File
@@ -320,8 +320,20 @@ export const businesses = createTable(
website: d.varchar({ length: 255 }), website: d.varchar({ length: 255 }),
taxId: d.varchar({ length: 100 }), taxId: d.varchar({ length: 100 }),
logoUrl: d.varchar({ length: 500 }), 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 }), logoStorageKey: d.varchar({ length: 500 }),
logoMimeType: d.varchar({ length: 100 }), 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(), hideNameWithLogo: d.boolean().default(false).notNull(),
isDefault: d.boolean().default(false), isDefault: d.boolean().default(false),
// Email configuration for custom Resend setup // Email configuration for custom Resend setup
+13 -1
View File
@@ -54,6 +54,18 @@ services:
S3_REGION: ${S3_REGION:-garage} S3_REGION: ${S3_REGION:-garage}
expose: expose:
- "${APP_PORT:-3000}" - "${APP_PORT:-3000}"
healthcheck:
test:
[
"CMD",
"bun",
"-e",
'const port = process.env.PORT || "3000"; const response = await fetch("http://127.0.0.1:" + port + "/api/health"); if (!response.ok) process.exit(1)',
]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
@@ -80,7 +92,7 @@ services:
APP_INTERNAL_URL: http://app:${APP_PORT:-3000} APP_INTERNAL_URL: http://app:${APP_PORT:-3000}
depends_on: depends_on:
app: app:
condition: service_started condition: service_healthy
db: db:
condition: service_healthy condition: service_healthy
restart: unless-stopped restart: unless-stopped
+13 -1
View File
@@ -50,6 +50,18 @@ services:
S3_REGION: ${S3_REGION:-garage} S3_REGION: ${S3_REGION:-garage}
ports: ports:
- "${WEB_PORT:-${PORT:-3000}}:3000" - "${WEB_PORT:-${PORT:-3000}}:3000"
healthcheck:
test:
[
"CMD",
"bun",
"-e",
'const port = process.env.PORT || "3000"; const response = await fetch("http://127.0.0.1:" + port + "/api/health"); if (!response.ok) process.exit(1)',
]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
@@ -76,7 +88,7 @@ services:
APP_INTERNAL_URL: http://app:${APP_PORT:-3000} APP_INTERNAL_URL: http://app:${APP_PORT:-3000}
depends_on: depends_on:
app: app:
condition: service_started condition: service_healthy
db: db:
condition: service_healthy condition: service_healthy
restart: unless-stopped restart: unless-stopped
+1
View File
@@ -5,6 +5,7 @@
"type": "module", "type": "module",
"exports": { "exports": {
".": "./src/index.ts", ".": "./src/index.ts",
"./brand-assets": "./src/brand-assets.ts",
"./expense-categories": "./src/expense-categories.ts", "./expense-categories": "./src/expense-categories.ts",
"./invoice-status": "./src/invoice-status.ts", "./invoice-status": "./src/invoice-status.ts",
"./receipt-parse": "./src/receipt-parse.ts", "./receipt-parse": "./src/receipt-parse.ts",
+84
View File
@@ -0,0 +1,84 @@
export const brandAssetKinds = ["logo", "wordmark", "icon"] as const;
export const brandAssetThemes = ["light", "dark"] as const;
export type BrandAssetKind = (typeof brandAssetKinds)[number];
export type BrandAssetTheme = (typeof brandAssetThemes)[number];
export type BusinessBrandAssets = {
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;
};
const fields = {
logo: {
light: ["logoStorageKey", "logoMimeType"],
dark: ["logoDarkStorageKey", "logoDarkMimeType"],
},
wordmark: {
light: ["wordmarkLightStorageKey", "wordmarkLightMimeType"],
dark: ["wordmarkDarkStorageKey", "wordmarkDarkMimeType"],
},
icon: {
light: ["iconLightStorageKey", "iconLightMimeType"],
dark: ["iconDarkStorageKey", "iconDarkMimeType"],
},
} as const;
export function getBrandAssetFieldNames(
kind: BrandAssetKind,
theme: BrandAssetTheme,
) {
return fields[kind][theme];
}
function fallbackOrder(
kind: BrandAssetKind,
theme: BrandAssetTheme,
): Array<readonly [BrandAssetKind, BrandAssetTheme]> {
const opposite = theme === "light" ? "dark" : "light";
const otherKinds = brandAssetKinds.filter((candidate) => candidate !== kind);
return [
[kind, theme],
[kind, opposite],
...otherKinds.map((candidate) => [candidate, theme] as const),
...otherKinds.map((candidate) => [candidate, opposite] as const),
];
}
export function resolveBusinessBrandAsset(
business: BusinessBrandAssets,
kind: BrandAssetKind,
theme: BrandAssetTheme,
): { storageKey: string; mimeType: string } | null {
for (const [candidateKind, candidateTheme] of fallbackOrder(kind, theme)) {
const [storageField, mimeField] = fields[candidateKind][candidateTheme];
const storageKey = business[storageField];
const mimeType = business[mimeField];
if (storageKey && mimeType) return { storageKey, mimeType };
}
return null;
}
export function hasBusinessBrandAsset(
business: BusinessBrandAssets | null | undefined,
): boolean {
return Boolean(
business &&
brandAssetKinds.some((kind) =>
brandAssetThemes.some((theme) => {
const [storageField] = fields[kind][theme];
return Boolean(business[storageField]);
}),
),
);
}
+1
View File
@@ -1,3 +1,4 @@
export * from "./brand-assets";
export * from "./expense-categories"; export * from "./expense-categories";
export * from "./invoice-status"; export * from "./invoice-status";
export * from "./receipt-parse"; export * from "./receipt-parse";
@@ -0,0 +1,44 @@
import { describe, expect, test } from "bun:test";
import {
hasBusinessBrandAsset,
resolveBusinessBrandAsset,
} from "../src/brand-assets";
import { businessBrandAssetPath } from "../../../apps/web/src/lib/business-branding";
describe("business brand assets", () => {
test("prefers the requested kind and theme", () => {
const asset = resolveBusinessBrandAsset(
{
logoStorageKey: "logo-light",
logoMimeType: "image/png",
iconDarkStorageKey: "icon-dark",
iconDarkMimeType: "image/svg+xml",
},
"icon",
"dark",
);
expect(asset).toEqual({
storageKey: "icon-dark",
mimeType: "image/svg+xml",
});
});
test("falls back to the legacy combined light logo", () => {
const business = {
logoStorageKey: "legacy-logo",
logoMimeType: "image/jpeg",
};
expect(hasBusinessBrandAsset(business)).toBe(true);
expect(resolveBusinessBrandAsset(business, "wordmark", "dark")).toEqual({
storageKey: "legacy-logo",
mimeType: "image/jpeg",
});
});
test("builds a role and theme-aware public URL", () => {
expect(businessBrandAssetPath("abc", "icon", "dark", "png")).toBe(
"/api/business-logo/abc?kind=icon&theme=dark&format=png",
);
});
});