Use asset-backed company logos
This commit is contained in:
@@ -189,6 +189,31 @@ describe("POST /api/companies/:companyId/assets/images", () => {
|
||||
expect(createAssetMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows supported non-image attachments outside the company logo namespace", async () => {
|
||||
const text = createStorageService("text/plain");
|
||||
const app = createApp(text);
|
||||
|
||||
createAssetMock.mockResolvedValue({
|
||||
...createAsset(),
|
||||
contentType: "text/plain",
|
||||
originalFilename: "note.txt",
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/assets/images")
|
||||
.field("namespace", "issues/drafts")
|
||||
.attach("file", Buffer.from("hello"), "note.txt");
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(text.putFile).toHaveBeenCalledWith({
|
||||
companyId: "company-1",
|
||||
namespace: "assets/issues/drafts",
|
||||
originalFilename: "note.txt",
|
||||
contentType: "text/plain",
|
||||
body: expect.any(Buffer),
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects SVG image uploads that cannot be sanitized", async () => {
|
||||
const app = createApp(createStorageService("image/svg+xml"));
|
||||
createAssetMock.mockResolvedValue(createAsset());
|
||||
|
||||
@@ -116,7 +116,19 @@ export function assetRoutes(db: Db, storage: StorageService) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedMeta = createAssetImageMetadataSchema.safeParse(req.body ?? {});
|
||||
if (!parsedMeta.success) {
|
||||
res.status(400).json({ error: "Invalid image metadata", details: parsedMeta.error.issues });
|
||||
return;
|
||||
}
|
||||
|
||||
const namespaceSuffix = parsedMeta.data.namespace ?? "general";
|
||||
const isCompanyLogoNamespace = namespaceSuffix === "companies" || namespaceSuffix.startsWith("companies/");
|
||||
const contentType = (file.mimetype || "").toLowerCase();
|
||||
if (isCompanyLogoNamespace && !contentType.startsWith("image/")) {
|
||||
res.status(422).json({ error: `Unsupported image type: ${contentType || "unknown"}` });
|
||||
return;
|
||||
}
|
||||
if (contentType !== SVG_CONTENT_TYPE && !isAllowedContentType(contentType)) {
|
||||
res.status(422).json({ error: `Unsupported file type: ${contentType || "unknown"}` });
|
||||
return;
|
||||
@@ -134,15 +146,6 @@ export function assetRoutes(db: Db, storage: StorageService) {
|
||||
res.status(422).json({ error: "Image is empty" });
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedMeta = createAssetImageMetadataSchema.safeParse(req.body ?? {});
|
||||
if (!parsedMeta.success) {
|
||||
res.status(400).json({ error: "Invalid image metadata", details: parsedMeta.error.issues });
|
||||
return;
|
||||
}
|
||||
|
||||
const namespaceSuffix = parsedMeta.data.namespace ?? "general";
|
||||
const isCompanyLogoNamespace = namespaceSuffix === "companies" || namespaceSuffix.startsWith("companies/");
|
||||
if (isCompanyLogoNamespace && fileBody.length > MAX_COMPANY_LOGO_BYTES) {
|
||||
res.status(422).json({ error: `Image exceeds ${MAX_COMPANY_LOGO_BYTES} bytes` });
|
||||
return;
|
||||
|
||||
@@ -2,6 +2,8 @@ import { eq, count } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
companies,
|
||||
companyLogos,
|
||||
assets,
|
||||
agents,
|
||||
agentApiKeys,
|
||||
agentRuntimeState,
|
||||
@@ -23,10 +25,41 @@ import {
|
||||
principalPermissionGrants,
|
||||
companyMemberships,
|
||||
} from "@paperclipai/db";
|
||||
import { notFound, unprocessable } from "../errors.js";
|
||||
|
||||
export function companyService(db: Db) {
|
||||
const ISSUE_PREFIX_FALLBACK = "CMP";
|
||||
|
||||
const companySelection = {
|
||||
id: companies.id,
|
||||
name: companies.name,
|
||||
description: companies.description,
|
||||
status: companies.status,
|
||||
issuePrefix: companies.issuePrefix,
|
||||
issueCounter: companies.issueCounter,
|
||||
budgetMonthlyCents: companies.budgetMonthlyCents,
|
||||
spentMonthlyCents: companies.spentMonthlyCents,
|
||||
requireBoardApprovalForNewAgents: companies.requireBoardApprovalForNewAgents,
|
||||
brandColor: companies.brandColor,
|
||||
logoAssetId: companyLogos.assetId,
|
||||
createdAt: companies.createdAt,
|
||||
updatedAt: companies.updatedAt,
|
||||
};
|
||||
|
||||
function enrichCompany<T extends { logoAssetId: string | null }>(company: T) {
|
||||
return {
|
||||
...company,
|
||||
logoUrl: company.logoAssetId ? `/api/assets/${company.logoAssetId}/content` : null,
|
||||
};
|
||||
}
|
||||
|
||||
function getCompanyQuery(database: Pick<Db, "select">) {
|
||||
return database
|
||||
.select(companySelection)
|
||||
.from(companies)
|
||||
.leftJoin(companyLogos, eq(companyLogos.companyId, companies.id));
|
||||
}
|
||||
|
||||
function deriveIssuePrefixBase(name: string) {
|
||||
const normalized = name.toUpperCase().replace(/[^A-Z]/g, "");
|
||||
return normalized.slice(0, 3) || ISSUE_PREFIX_FALLBACK;
|
||||
@@ -70,32 +103,97 @@ export function companyService(db: Db) {
|
||||
}
|
||||
|
||||
return {
|
||||
list: () => db.select().from(companies),
|
||||
list: () =>
|
||||
getCompanyQuery(db).then((rows) => rows.map((row) => enrichCompany(row))),
|
||||
|
||||
getById: (id: string) =>
|
||||
db
|
||||
.select()
|
||||
.from(companies)
|
||||
getCompanyQuery(db)
|
||||
.where(eq(companies.id, id))
|
||||
.then((rows) => rows[0] ?? null),
|
||||
.then((rows) => (rows[0] ? enrichCompany(rows[0]) : null)),
|
||||
|
||||
create: async (data: typeof companies.$inferInsert) => createCompanyWithUniquePrefix(data),
|
||||
create: async (data: typeof companies.$inferInsert) => {
|
||||
const created = await createCompanyWithUniquePrefix(data);
|
||||
const row = await getCompanyQuery(db)
|
||||
.where(eq(companies.id, created.id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!row) throw notFound("Company not found after creation");
|
||||
return enrichCompany(row);
|
||||
},
|
||||
|
||||
update: (id: string, data: Partial<typeof companies.$inferInsert>) =>
|
||||
db
|
||||
.update(companies)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(eq(companies.id, id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null),
|
||||
update: (
|
||||
id: string,
|
||||
data: Partial<typeof companies.$inferInsert> & { logoAssetId?: string | null },
|
||||
) =>
|
||||
db.transaction(async (tx) => {
|
||||
const existing = await getCompanyQuery(tx)
|
||||
.where(eq(companies.id, id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!existing) return null;
|
||||
|
||||
const { logoAssetId, ...companyPatch } = data;
|
||||
|
||||
if (logoAssetId !== undefined && logoAssetId !== null) {
|
||||
const nextLogoAsset = await tx
|
||||
.select({ id: assets.id, companyId: assets.companyId })
|
||||
.from(assets)
|
||||
.where(eq(assets.id, logoAssetId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!nextLogoAsset) throw notFound("Logo asset not found");
|
||||
if (nextLogoAsset.companyId !== existing.id) {
|
||||
throw unprocessable("Logo asset must belong to the same company");
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await tx
|
||||
.update(companies)
|
||||
.set({ ...companyPatch, updatedAt: new Date() })
|
||||
.where(eq(companies.id, id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!updated) return null;
|
||||
|
||||
if (logoAssetId === null) {
|
||||
await tx.delete(companyLogos).where(eq(companyLogos.companyId, id));
|
||||
} else if (logoAssetId !== undefined) {
|
||||
await tx
|
||||
.insert(companyLogos)
|
||||
.values({
|
||||
companyId: id,
|
||||
assetId: logoAssetId,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: companyLogos.companyId,
|
||||
set: {
|
||||
assetId: logoAssetId,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (logoAssetId !== undefined && existing.logoAssetId && existing.logoAssetId !== logoAssetId) {
|
||||
await tx.delete(assets).where(eq(assets.id, existing.logoAssetId));
|
||||
}
|
||||
|
||||
return enrichCompany({
|
||||
...updated,
|
||||
logoAssetId: logoAssetId === undefined ? existing.logoAssetId : logoAssetId,
|
||||
});
|
||||
}),
|
||||
|
||||
archive: (id: string) =>
|
||||
db
|
||||
.update(companies)
|
||||
.set({ status: "archived", updatedAt: new Date() })
|
||||
.where(eq(companies.id, id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null),
|
||||
db.transaction(async (tx) => {
|
||||
const updated = await tx
|
||||
.update(companies)
|
||||
.set({ status: "archived", updatedAt: new Date() })
|
||||
.where(eq(companies.id, id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!updated) return null;
|
||||
const row = await getCompanyQuery(tx)
|
||||
.where(eq(companies.id, id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return row ? enrichCompany(row) : null;
|
||||
}),
|
||||
|
||||
remove: (id: string) =>
|
||||
db.transaction(async (tx) => {
|
||||
@@ -116,6 +214,8 @@ export function companyService(db: Db) {
|
||||
await tx.delete(principalPermissionGrants).where(eq(principalPermissionGrants.companyId, id));
|
||||
await tx.delete(companyMemberships).where(eq(companyMemberships.companyId, id));
|
||||
await tx.delete(issues).where(eq(issues.companyId, id));
|
||||
await tx.delete(companyLogos).where(eq(companyLogos.companyId, id));
|
||||
await tx.delete(assets).where(eq(assets.companyId, id));
|
||||
await tx.delete(goals).where(eq(goals.companyId, id));
|
||||
await tx.delete(projects).where(eq(projects.companyId, id));
|
||||
await tx.delete(agents).where(eq(agents.companyId, id));
|
||||
|
||||
Reference in New Issue
Block a user