Add support for company logos, including schema adjustments, validation, assets handling, and UI display enhancements.

This commit is contained in:
JonCSykes
2026-03-06 16:39:35 -05:00
parent b155415d7d
commit b19d0b6f3b
17 changed files with 6211 additions and 26 deletions

4
.gitignore vendored
View File

@@ -36,4 +36,6 @@ tmp/
*.tmp
.vscode/
.claude/settings.local.json
.paperclip-local/
.paperclip-local/
/.idea/
/.agents/

View File

@@ -14,6 +14,7 @@ function makeCompany(overrides: Partial<Company>): Company {
spentMonthlyCents: 0,
requireBoardApprovalForNewAgents: false,
brandColor: null,
logoUrl: null,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,

View File

@@ -42,6 +42,24 @@ PATCH /api/companies/{companyId}
}
```
## Upload Company Logo
Upload an image for a company icon and store it as that companys logo.
```
POST /api/companies/{companyId}/assets/images
Content-Type: multipart/form-data
```
Valid image content types:
- `image/png`
- `image/jpeg`
- `image/jpg`
- `image/webp`
- `image/gif`
- `image/svg+xml` (`.svg`)
## Archive Company
```
@@ -58,6 +76,7 @@ Archives a company. Archived companies are hidden from default listings.
| `name` | string | Company name |
| `description` | string | Company description |
| `status` | string | `active`, `paused`, `archived` |
| `logoUrl` | string | Optional path or URL for the logo image |
| `budgetMonthlyCents` | number | Monthly budget limit |
| `createdAt` | string | ISO timestamp |
| `updatedAt` | string | ISO timestamp |

View File

@@ -0,0 +1 @@
ALTER TABLE "companies" ADD COLUMN IF NOT EXISTS "logo_url" text;

File diff suppressed because it is too large Load Diff

View File

@@ -183,6 +183,13 @@
"when": 1772807461603,
"tag": "0025_nasty_salo",
"breakpoints": true
},
{
"idx": 26,
"version": "7",
"when": 1772823634634,
"tag": "0026_high_anita_blake",
"breakpoints": true
}
]
}

View File

@@ -15,6 +15,7 @@ export const companies = pgTable(
.notNull()
.default(true),
brandColor: text("brand_color"),
logoUrl: text("logo_url"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},

View File

@@ -11,6 +11,7 @@ export interface Company {
spentMonthlyCents: number;
requireBoardApprovalForNewAgents: boolean;
brandColor: string | null;
logoUrl: string | null;
createdAt: Date;
updatedAt: Date;
}

View File

@@ -1,10 +1,19 @@
import { z } from "zod";
import { COMPANY_STATUSES } from "../constants.js";
const logoUrlSchema = z
.string()
.trim()
.max(2048)
.regex(/^\/api\/assets\/[^\s]+$|^https?:\/\/[^\s]+$/)
.nullable()
.optional();
export const createCompanySchema = z.object({
name: z.string().min(1),
description: z.string().optional().nullable(),
budgetMonthlyCents: z.number().int().nonnegative().optional().default(0),
logoUrl: logoUrlSchema,
});
export type CreateCompany = z.infer<typeof createCompanySchema>;
@@ -16,6 +25,7 @@ export const updateCompanySchema = createCompanySchema
spentMonthlyCents: z.number().int().nonnegative().optional(),
requireBoardApprovalForNewAgents: z.boolean().optional(),
brandColor: z.string().regex(/^#[0-9a-fA-F]{6}$/).nullable().optional(),
logoUrl: logoUrlSchema,
});
export type UpdateCompany = z.infer<typeof updateCompanySchema>;

21
pnpm-lock.yaml generated
View File

@@ -126,6 +126,9 @@ importers:
specifier: ^1.1.1
version: 1.1.1
devDependencies:
'@types/node':
specifier: ^24.6.0
version: 24.12.0
typescript:
specifier: ^5.7.3
version: 5.9.3
@@ -156,8 +159,8 @@ importers:
version: 1.1.1
devDependencies:
'@types/node':
specifier: ^22.12.0
version: 22.19.11
specifier: ^24.6.0
version: 24.12.0
typescript:
specifier: ^5.7.3
version: 5.9.3
@@ -8162,7 +8165,7 @@ snapshots:
'@types/body-parser@1.19.6':
dependencies:
'@types/connect': 3.4.38
'@types/node': 24.12.0
'@types/node': 25.2.3
'@types/chai@5.2.3':
dependencies:
@@ -8171,7 +8174,7 @@ snapshots:
'@types/connect@3.4.38':
dependencies:
'@types/node': 24.12.0
'@types/node': 25.2.3
'@types/cookiejar@2.1.5': {}
@@ -8189,7 +8192,7 @@ snapshots:
'@types/express-serve-static-core@5.1.1':
dependencies:
'@types/node': 24.12.0
'@types/node': 25.2.3
'@types/qs': 6.14.0
'@types/range-parser': 1.2.7
'@types/send': 1.2.1
@@ -8246,18 +8249,18 @@ snapshots:
'@types/send@1.2.1':
dependencies:
'@types/node': 24.12.0
'@types/node': 25.2.3
'@types/serve-static@2.2.0':
dependencies:
'@types/http-errors': 2.0.5
'@types/node': 24.12.0
'@types/node': 25.2.3
'@types/superagent@8.1.9':
dependencies:
'@types/cookiejar': 2.1.5
'@types/methods': 1.1.4
'@types/node': 24.12.0
'@types/node': 25.2.3
form-data: 4.0.5
'@types/supertest@6.0.3':
@@ -8271,7 +8274,7 @@ snapshots:
'@types/ws@8.18.1':
dependencies:
'@types/node': 24.12.0
'@types/node': 25.2.3
'@ungap/structured-clone@1.3.0': {}

View File

@@ -0,0 +1,157 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import express from "express";
import request from "supertest";
import { assetRoutes } from "../routes/assets.js";
import type { StorageService } from "../storage/types.js";
const { createAssetMock, getAssetByIdMock, logActivityMock } = vi.hoisted(() => ({
createAssetMock: vi.fn(),
getAssetByIdMock: vi.fn(),
logActivityMock: vi.fn(),
}));
vi.mock("../services/index.js", () => ({
assetService: vi.fn(() => ({
create: createAssetMock,
getById: getAssetByIdMock,
})),
logActivity: logActivityMock,
}));
function createAsset() {
const now = new Date("2026-01-01T00:00:00.000Z");
return {
id: "asset-1",
companyId: "company-1",
provider: "local",
objectKey: "assets/abc",
contentType: "image/svg+xml",
byteSize: 40,
sha256: "sha256-sample",
originalFilename: "logo.svg",
createdByAgentId: null,
createdByUserId: "user-1",
createdAt: now,
updatedAt: now,
};
}
function createStorageService(contentType = "image/svg+xml"): StorageService {
const putFile: StorageService["putFile"] = vi.fn(async (input: {
companyId: string;
namespace: string;
originalFilename: string | null;
contentType: string;
body: Buffer;
}) => {
return {
provider: "local_disk" as const,
objectKey: `${input.namespace}/${input.originalFilename ?? "upload"}`,
contentType: contentType || input.contentType,
byteSize: input.body.length,
sha256: "sha256-sample",
originalFilename: input.originalFilename,
};
});
return {
provider: "local_disk" as const,
putFile,
getObject: vi.fn(),
headObject: vi.fn(),
deleteObject: vi.fn(),
};
}
function createApp(storage: ReturnType<typeof createStorageService>) {
const app = express();
app.use((req, _res, next) => {
req.actor = {
type: "board",
source: "local_implicit",
userId: "user-1",
};
next();
});
app.use("/api", assetRoutes({} as any, storage));
return app;
}
describe("POST /api/companies/:companyId/assets/images", () => {
afterEach(() => {
createAssetMock.mockReset();
getAssetByIdMock.mockReset();
logActivityMock.mockReset();
});
it("accepts SVG image uploads and returns an asset path", async () => {
const svg = createStorageService("image/svg+xml");
const app = createApp(svg);
createAssetMock.mockResolvedValue(createAsset());
const res = await request(app)
.post("/api/companies/company-1/assets/images")
.field("namespace", "companies")
.attach("file", Buffer.from("<svg xmlns='http://www.w3.org/2000/svg'></svg>"), "logo.svg");
expect(res.status).toBe(201);
expect(res.body.contentPath).toBe("/api/assets/asset-1/content");
expect(createAssetMock).toHaveBeenCalledTimes(1);
expect(svg.putFile).toHaveBeenCalledWith({
companyId: "company-1",
namespace: "assets/companies",
originalFilename: "logo.svg",
contentType: "image/svg+xml",
body: expect.any(Buffer),
});
});
it("rejects files larger than 100 KB", async () => {
const app = createApp(createStorageService());
createAssetMock.mockResolvedValue(createAsset());
const file = Buffer.alloc(100 * 1024 + 1, "a");
const res = await request(app)
.post("/api/companies/company-1/assets/images")
.field("namespace", "companies")
.attach("file", file, "too-large.png");
expect(res.status).toBe(422);
expect(res.body.error).toBe("Image exceeds 102400 bytes");
});
it("allows larger non-logo images within the general asset limit", async () => {
const png = createStorageService("image/png");
const app = createApp(png);
createAssetMock.mockResolvedValue({
...createAsset(),
contentType: "image/png",
originalFilename: "goal.png",
});
const file = Buffer.alloc(150 * 1024, "a");
const res = await request(app)
.post("/api/companies/company-1/assets/images")
.field("namespace", "goals")
.attach("file", file, "goal.png");
expect(res.status).toBe(201);
expect(createAssetMock).toHaveBeenCalledTimes(1);
});
it("rejects unsupported image types", async () => {
const app = createApp(createStorageService("text/plain"));
createAssetMock.mockResolvedValue(createAsset());
const res = await request(app)
.post("/api/companies/company-1/assets/images")
.field("namespace", "companies")
.attach("file", Buffer.from("not an image"), "note.txt");
expect(res.status).toBe(422);
expect(res.body.error).toBe("Unsupported image type: text/plain");
expect(createAssetMock).not.toHaveBeenCalled();
});
});

View File

@@ -7,12 +7,14 @@ import { assetService, logActivity } from "../services/index.js";
import { assertCompanyAccess, getActorInfo } from "./authz.js";
const MAX_ASSET_IMAGE_BYTES = Number(process.env.PAPERCLIP_ATTACHMENT_MAX_BYTES) || 10 * 1024 * 1024;
const MAX_COMPANY_LOGO_BYTES = 100 * 1024;
const ALLOWED_IMAGE_CONTENT_TYPES = new Set([
"image/png",
"image/jpeg",
"image/jpg",
"image/webp",
"image/gif",
"image/svg+xml",
]);
export function assetRoutes(db: Db, storage: StorageService) {
@@ -73,6 +75,12 @@ export function assetRoutes(db: Db, storage: StorageService) {
}
const namespaceSuffix = parsedMeta.data.namespace ?? "general";
const isCompanyLogoNamespace = namespaceSuffix === "companies" || namespaceSuffix.startsWith("companies/");
if (isCompanyLogoNamespace && file.buffer.length > MAX_COMPANY_LOGO_BYTES) {
res.status(422).json({ error: `Image exceeds ${MAX_COMPANY_LOGO_BYTES} bytes` });
return;
}
const actor = getActorInfo(req);
const stored = await storage.putFile({
companyId,
@@ -150,4 +158,3 @@ export function assetRoutes(db: Db, storage: StorageService) {
return router;
}

View File

@@ -14,14 +14,19 @@ export const companiesApi = {
list: () => api.get<Company[]>("/companies"),
get: (companyId: string) => api.get<Company>(`/companies/${companyId}`),
stats: () => api.get<CompanyStats>("/companies/stats"),
create: (data: { name: string; description?: string | null; budgetMonthlyCents?: number }) =>
create: (data: {
name: string;
description?: string | null;
budgetMonthlyCents?: number;
logoUrl?: string | null;
}) =>
api.post<Company>("/companies", data),
update: (
companyId: string,
data: Partial<
Pick<
Company,
"name" | "description" | "status" | "budgetMonthlyCents" | "requireBoardApprovalForNewAgents" | "brandColor"
"name" | "description" | "status" | "budgetMonthlyCents" | "requireBoardApprovalForNewAgents" | "brandColor" | "logoUrl"
>
>,
) => api.patch<Company>(`/companies/${companyId}`, data),

View File

@@ -1,4 +1,4 @@
import { useMemo } from "react";
import { useEffect, useMemo, useState } from "react";
import { cn } from "../lib/utils";
const BAYER_4X4 = [
@@ -10,6 +10,7 @@ const BAYER_4X4 = [
interface CompanyPatternIconProps {
companyName: string;
logoUrl?: string | null;
brandColor?: string | null;
className?: string;
}
@@ -159,8 +160,18 @@ function makeCompanyPatternDataUrl(seed: string, brandColor?: string | null, log
return canvas.toDataURL("image/png");
}
export function CompanyPatternIcon({ companyName, brandColor, className }: CompanyPatternIconProps) {
export function CompanyPatternIcon({
companyName,
logoUrl,
brandColor,
className,
}: CompanyPatternIconProps) {
const initial = companyName.trim().charAt(0).toUpperCase() || "?";
const [imageError, setImageError] = useState(false);
const logo = !imageError && typeof logoUrl === "string" && logoUrl.trim().length > 0 ? logoUrl : null;
useEffect(() => {
setImageError(false);
}, [logoUrl]);
const patternDataUrl = useMemo(
() => makeCompanyPatternDataUrl(companyName.trim().toLowerCase(), brandColor),
[companyName, brandColor],
@@ -173,7 +184,14 @@ export function CompanyPatternIcon({ companyName, brandColor, className }: Compa
className,
)}
>
{patternDataUrl ? (
{logo ? (
<img
src={logo}
alt={`${companyName} logo`}
onError={() => setImageError(true)}
className="absolute inset-0 h-full w-full object-cover"
/>
) : patternDataUrl ? (
<img
src={patternDataUrl}
alt=""
@@ -184,9 +202,11 @@ export function CompanyPatternIcon({ companyName, brandColor, className }: Compa
) : (
<div className="absolute inset-0 bg-muted" />
)}
<span className="relative z-10 drop-shadow-[0_1px_2px_rgba(0,0,0,0.65)]">
{initial}
</span>
{!logo && (
<span className="relative z-10 drop-shadow-[0_1px_2px_rgba(0,0,0,0.65)]">
{initial}
</span>
)}
</div>
);
}

View File

@@ -121,6 +121,7 @@ function SortableCompanyItem({
>
<CompanyPatternIcon
companyName={company.name}
logoUrl={company.logoUrl}
brandColor={company.brandColor}
className={cn(
isSelected

View File

@@ -29,6 +29,7 @@ interface CompanyContextValue {
name: string;
description?: string | null;
budgetMonthlyCents?: number;
logoUrl?: string | null;
}) => Promise<Company>;
}
@@ -86,7 +87,12 @@ export function CompanyProvider({ children }: { children: ReactNode }) {
}, [queryClient]);
const createMutation = useMutation({
mutationFn: (data: { name: string; description?: string | null; budgetMonthlyCents?: number }) =>
mutationFn: (data: {
name: string;
description?: string | null;
budgetMonthlyCents?: number;
logoUrl?: string | null;
}) =>
companiesApi.create(data),
onSuccess: (company) => {
queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
@@ -95,7 +101,12 @@ export function CompanyProvider({ children }: { children: ReactNode }) {
});
const createCompany = useCallback(
async (data: { name: string; description?: string | null; budgetMonthlyCents?: number }) => {
async (data: {
name: string;
description?: string | null;
budgetMonthlyCents?: number;
logoUrl?: string | null;
}) => {
return createMutation.mutateAsync(data);
},
[createMutation],

View File

@@ -1,9 +1,10 @@
import { useEffect, useState } from "react";
import { ChangeEvent, useEffect, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { companiesApi } from "../api/companies";
import { accessApi } from "../api/access";
import { assetsApi } from "../api/assets";
import { queryKeys } from "../lib/queryKeys";
import { Button } from "@/components/ui/button";
import { Settings, Check } from "lucide-react";
@@ -34,6 +35,8 @@ export function CompanySettings() {
const [companyName, setCompanyName] = useState("");
const [description, setDescription] = useState("");
const [brandColor, setBrandColor] = useState("");
const [logoUrl, setLogoUrl] = useState("");
const [logoUploadError, setLogoUploadError] = useState<string | null>(null);
// Sync local state from selected company
useEffect(() => {
@@ -41,6 +44,7 @@ export function CompanySettings() {
setCompanyName(selectedCompany.name);
setDescription(selectedCompany.description ?? "");
setBrandColor(selectedCompany.brandColor ?? "");
setLogoUrl(selectedCompany.logoUrl ?? "");
}, [selectedCompany]);
const [inviteError, setInviteError] = useState<string | null>(null);
@@ -130,6 +134,46 @@ export function CompanySettings() {
}
});
const syncLogoState = (nextLogoUrl: string | null) => {
setLogoUrl(nextLogoUrl ?? "");
void queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
};
const logoUploadMutation = useMutation({
mutationFn: (file: File) =>
assetsApi
.uploadImage(selectedCompanyId!, file, "companies")
.then((asset) => companiesApi.update(selectedCompanyId!, { logoUrl: asset.contentPath })),
onSuccess: (company) => {
syncLogoState(company.logoUrl);
setLogoUploadError(null);
}
});
const clearLogoMutation = useMutation({
mutationFn: () => companiesApi.update(selectedCompanyId!, { logoUrl: null }),
onSuccess: (company) => {
setLogoUploadError(null);
syncLogoState(company.logoUrl);
}
});
function handleLogoFileChange(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0] ?? null;
event.currentTarget.value = "";
if (!file) return;
if (file.size >= 100 * 1024) {
setLogoUploadError("Logo image must be smaller than 100 KB.");
return;
}
setLogoUploadError(null);
logoUploadMutation.mutate(file);
}
function handleClearLogo() {
clearLogoMutation.mutate();
}
useEffect(() => {
setInviteError(null);
setInviteSnippet(null);
@@ -226,11 +270,53 @@ export function CompanySettings() {
<div className="shrink-0">
<CompanyPatternIcon
companyName={companyName || selectedCompany.name}
logoUrl={logoUrl || null}
brandColor={brandColor || null}
className="rounded-[14px]"
/>
</div>
<div className="flex-1 space-y-2">
<div className="flex-1 space-y-3">
<Field
label="Logo"
hint="Upload a logo image to replace the generated icon. Maximum size: 100 KB."
>
<div className="space-y-2">
<input
type="file"
accept="image/*,image/svg+xml"
onChange={handleLogoFileChange}
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none file:mr-4 file:rounded-md file:border-0 file:bg-muted file:px-2.5 file:py-1 file:text-xs"
/>
{logoUrl && (
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
onClick={handleClearLogo}
disabled={clearLogoMutation.isPending}
>
{clearLogoMutation.isPending ? "Removing..." : "Remove logo"}
</Button>
</div>
)}
{(logoUploadMutation.isError || logoUploadError) && (
<span className="text-xs text-destructive">
{logoUploadError ??
(logoUploadMutation.error instanceof Error
? logoUploadMutation.error.message
: "Logo upload failed")}
</span>
)}
{clearLogoMutation.isError && (
<span className="text-xs text-destructive">
{clearLogoMutation.error.message}
</span>
)}
{logoUploadMutation.isPending && (
<span className="text-xs text-muted-foreground">Uploading logo...</span>
)}
</div>
</Field>
<Field
label="Brand color"
hint="Sets the hue for the company icon. Leave empty for auto-generated color."
@@ -286,9 +372,7 @@ export function CompanySettings() {
)}
{generalMutation.isError && (
<span className="text-xs text-destructive">
{generalMutation.error instanceof Error
? generalMutation.error.message
: "Failed to save"}
{generalMutation.error.message}
</span>
)}
</div>