Add CEO company branding endpoint

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta
2026-03-18 21:03:41 -05:00
parent 87b17de0bd
commit 7b4a4f45ed
8 changed files with 264 additions and 2 deletions

View File

@@ -441,6 +441,7 @@ All endpoints are under `/api` and return JSON.
- `POST /companies` - `POST /companies`
- `GET /companies/:companyId` - `GET /companies/:companyId`
- `PATCH /companies/:companyId` - `PATCH /companies/:companyId`
- `PATCH /companies/:companyId/branding`
- `POST /companies/:companyId/archive` - `POST /companies/:companyId/archive`
## 10.2 Goals ## 10.2 Goals

View File

@@ -288,8 +288,10 @@ export {
export { export {
createCompanySchema, createCompanySchema,
updateCompanySchema, updateCompanySchema,
updateCompanyBrandingSchema,
type CreateCompany, type CreateCompany,
type UpdateCompany, type UpdateCompany,
type UpdateCompanyBranding,
agentSkillStateSchema, agentSkillStateSchema,
agentSkillSyncModeSchema, agentSkillSyncModeSchema,
agentSkillEntrySchema, agentSkillEntrySchema,

View File

@@ -2,6 +2,7 @@ import { z } from "zod";
import { COMPANY_STATUSES } from "../constants.js"; import { COMPANY_STATUSES } from "../constants.js";
const logoAssetIdSchema = z.string().uuid().nullable().optional(); const logoAssetIdSchema = z.string().uuid().nullable().optional();
const brandColorSchema = z.string().regex(/^#[0-9a-fA-F]{6}$/).nullable().optional();
export const createCompanySchema = z.object({ export const createCompanySchema = z.object({
name: z.string().min(1), name: z.string().min(1),
@@ -17,8 +18,21 @@ export const updateCompanySchema = createCompanySchema
status: z.enum(COMPANY_STATUSES).optional(), status: z.enum(COMPANY_STATUSES).optional(),
spentMonthlyCents: z.number().int().nonnegative().optional(), spentMonthlyCents: z.number().int().nonnegative().optional(),
requireBoardApprovalForNewAgents: z.boolean().optional(), requireBoardApprovalForNewAgents: z.boolean().optional(),
brandColor: z.string().regex(/^#[0-9a-fA-F]{6}$/).nullable().optional(), brandColor: brandColorSchema,
logoAssetId: logoAssetIdSchema, logoAssetId: logoAssetIdSchema,
}); });
export type UpdateCompany = z.infer<typeof updateCompanySchema>; export type UpdateCompany = z.infer<typeof updateCompanySchema>;
export const updateCompanyBrandingSchema = z
.object({
brandColor: brandColorSchema,
logoAssetId: logoAssetIdSchema,
})
.strict()
.refine(
(value) => value.brandColor !== undefined || value.logoAssetId !== undefined,
"At least one branding field must be provided",
);
export type UpdateCompanyBranding = z.infer<typeof updateCompanyBrandingSchema>;

View File

@@ -15,8 +15,10 @@ export {
export { export {
createCompanySchema, createCompanySchema,
updateCompanySchema, updateCompanySchema,
updateCompanyBrandingSchema,
type CreateCompany, type CreateCompany,
type UpdateCompany, type UpdateCompany,
type UpdateCompanyBranding,
} from "./company.js"; } from "./company.js";
export { export {
companySkillSourceTypeSchema, companySkillSourceTypeSchema,

View File

@@ -25,6 +25,9 @@ vi.mock("../services/index.js", () => ({
budgetService: () => ({ budgetService: () => ({
upsertPolicy: vi.fn(), upsertPolicy: vi.fn(),
}), }),
agentService: () => ({
getById: vi.fn(),
}),
logActivity: vi.fn(), logActivity: vi.fn(),
})); }));

View File

@@ -0,0 +1,197 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { companyRoutes } from "../routes/companies.js";
import { errorHandler } from "../middleware/index.js";
const mockCompanyService = vi.hoisted(() => ({
list: vi.fn(),
stats: vi.fn(),
getById: vi.fn(),
create: vi.fn(),
update: vi.fn(),
archive: vi.fn(),
remove: vi.fn(),
}));
const mockAgentService = vi.hoisted(() => ({
getById: vi.fn(),
}));
const mockAccessService = vi.hoisted(() => ({
ensureMembership: vi.fn(),
}));
const mockBudgetService = vi.hoisted(() => ({
upsertPolicy: vi.fn(),
}));
const mockCompanyPortabilityService = vi.hoisted(() => ({
exportBundle: vi.fn(),
previewImport: vi.fn(),
importBundle: vi.fn(),
}));
const mockLogActivity = vi.hoisted(() => vi.fn());
vi.mock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => mockAgentService,
budgetService: () => mockBudgetService,
companyPortabilityService: () => mockCompanyPortabilityService,
companyService: () => mockCompanyService,
logActivity: mockLogActivity,
}));
function createCompany() {
const now = new Date("2026-03-19T02:00:00.000Z");
return {
id: "company-1",
name: "Paperclip",
description: null,
status: "active",
issuePrefix: "PAP",
issueCounter: 568,
budgetMonthlyCents: 0,
spentMonthlyCents: 0,
requireBoardApprovalForNewAgents: false,
brandColor: "#123456",
logoAssetId: "11111111-1111-4111-8111-111111111111",
logoUrl: "/api/assets/11111111-1111-4111-8111-111111111111/content",
createdAt: now,
updatedAt: now,
};
}
function createApp(actor: Record<string, unknown>) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = actor;
next();
});
app.use("/api/companies", companyRoutes({} as any));
app.use(errorHandler);
return app;
}
describe("PATCH /api/companies/:companyId/branding", () => {
beforeEach(() => {
mockCompanyService.update.mockReset();
mockAgentService.getById.mockReset();
mockLogActivity.mockReset();
});
it("rejects non-CEO agent callers", async () => {
mockAgentService.getById.mockResolvedValue({
id: "agent-1",
companyId: "company-1",
role: "engineer",
});
const app = createApp({
type: "agent",
agentId: "agent-1",
companyId: "company-1",
source: "agent_key",
runId: "run-1",
});
const res = await request(app)
.patch("/api/companies/company-1/branding")
.send({ logoAssetId: "11111111-1111-4111-8111-111111111111" });
expect(res.status).toBe(403);
expect(res.body.error).toContain("Only CEO agents");
expect(mockCompanyService.update).not.toHaveBeenCalled();
});
it("allows CEO agent callers to update branding fields", async () => {
const company = createCompany();
mockAgentService.getById.mockResolvedValue({
id: "agent-1",
companyId: "company-1",
role: "ceo",
});
mockCompanyService.update.mockResolvedValue(company);
const app = createApp({
type: "agent",
agentId: "agent-1",
companyId: "company-1",
source: "agent_key",
runId: "run-1",
});
const res = await request(app)
.patch("/api/companies/company-1/branding")
.send({
logoAssetId: "11111111-1111-4111-8111-111111111111",
brandColor: "#123456",
});
expect(res.status).toBe(200);
expect(res.body.logoAssetId).toBe(company.logoAssetId);
expect(mockCompanyService.update).toHaveBeenCalledWith("company-1", {
logoAssetId: "11111111-1111-4111-8111-111111111111",
brandColor: "#123456",
});
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
companyId: "company-1",
actorType: "agent",
actorId: "agent-1",
agentId: "agent-1",
runId: "run-1",
action: "company.branding_updated",
details: {
logoAssetId: "11111111-1111-4111-8111-111111111111",
brandColor: "#123456",
},
}),
);
});
it("allows board callers to update branding fields", async () => {
const company = createCompany();
mockCompanyService.update.mockResolvedValue({
...company,
brandColor: null,
logoAssetId: null,
logoUrl: null,
});
const app = createApp({
type: "board",
userId: "user-1",
source: "local_implicit",
});
const res = await request(app)
.patch("/api/companies/company-1/branding")
.send({ brandColor: null, logoAssetId: null });
expect(res.status).toBe(200);
expect(mockCompanyService.update).toHaveBeenCalledWith("company-1", {
brandColor: null,
logoAssetId: null,
});
});
it("rejects non-branding fields in the request body", async () => {
const app = createApp({
type: "board",
userId: "user-1",
source: "local_implicit",
});
const res = await request(app)
.patch("/api/companies/company-1/branding")
.send({
logoAssetId: "11111111-1111-4111-8111-111111111111",
status: "archived",
});
expect(res.status).toBe(400);
expect(res.body.error).toBe("Validation error");
expect(mockCompanyService.update).not.toHaveBeenCalled();
});
});

View File

@@ -1,16 +1,18 @@
import { Router } from "express"; import { Router, type Request } from "express";
import type { Db } from "@paperclipai/db"; import type { Db } from "@paperclipai/db";
import { import {
companyPortabilityExportSchema, companyPortabilityExportSchema,
companyPortabilityImportSchema, companyPortabilityImportSchema,
companyPortabilityPreviewSchema, companyPortabilityPreviewSchema,
createCompanySchema, createCompanySchema,
updateCompanyBrandingSchema,
updateCompanySchema, updateCompanySchema,
} from "@paperclipai/shared"; } from "@paperclipai/shared";
import { forbidden } from "../errors.js"; import { forbidden } from "../errors.js";
import { validate } from "../middleware/validate.js"; import { validate } from "../middleware/validate.js";
import { import {
accessService, accessService,
agentService,
budgetService, budgetService,
companyPortabilityService, companyPortabilityService,
companyService, companyService,
@@ -21,10 +23,25 @@ import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
export function companyRoutes(db: Db) { export function companyRoutes(db: Db) {
const router = Router(); const router = Router();
const svc = companyService(db); const svc = companyService(db);
const agents = agentService(db);
const portability = companyPortabilityService(db); const portability = companyPortabilityService(db);
const access = accessService(db); const access = accessService(db);
const budgets = budgetService(db); const budgets = budgetService(db);
async function assertCanUpdateBranding(req: Request, companyId: string) {
assertCompanyAccess(req, companyId);
if (req.actor.type === "board") return;
if (!req.actor.agentId) throw forbidden("Agent authentication required");
const actorAgent = await agents.getById(req.actor.agentId);
if (!actorAgent || actorAgent.companyId !== companyId) {
throw forbidden("Agent key cannot access another company");
}
if (actorAgent.role !== "ceo") {
throw forbidden("Only CEO agents can update company branding");
}
}
router.get("/", async (req, res) => { router.get("/", async (req, res) => {
assertBoard(req); assertBoard(req);
const result = await svc.list(); const result = await svc.list();
@@ -165,6 +182,29 @@ export function companyRoutes(db: Db) {
res.json(company); res.json(company);
}); });
router.patch("/:companyId/branding", validate(updateCompanyBrandingSchema), async (req, res) => {
const companyId = req.params.companyId as string;
await assertCanUpdateBranding(req, companyId);
const company = await svc.update(companyId, req.body);
if (!company) {
res.status(404).json({ error: "Company not found" });
return;
}
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "company.branding_updated",
entityType: "company",
entityId: companyId,
details: req.body,
});
res.json(company);
});
router.post("/:companyId/archive", async (req, res) => { router.post("/:companyId/archive", async (req, res) => {
assertBoard(req); assertBoard(req);
const companyId = req.params.companyId as string; const companyId = req.params.companyId as string;

View File

@@ -5,6 +5,7 @@ import type {
CompanyPortabilityImportResult, CompanyPortabilityImportResult,
CompanyPortabilityPreviewRequest, CompanyPortabilityPreviewRequest,
CompanyPortabilityPreviewResult, CompanyPortabilityPreviewResult,
UpdateCompanyBranding,
} from "@paperclipai/shared"; } from "@paperclipai/shared";
import { api } from "./client"; import { api } from "./client";
@@ -29,6 +30,8 @@ export const companiesApi = {
> >
>, >,
) => api.patch<Company>(`/companies/${companyId}`, data), ) => api.patch<Company>(`/companies/${companyId}`, data),
updateBranding: (companyId: string, data: UpdateCompanyBranding) =>
api.patch<Company>(`/companies/${companyId}/branding`, data),
archive: (companyId: string) => api.post<Company>(`/companies/${companyId}/archive`, {}), archive: (companyId: string) => api.post<Company>(`/companies/${companyId}/archive`, {}),
remove: (companyId: string) => api.delete<{ ok: true }>(`/companies/${companyId}`), remove: (companyId: string) => api.delete<{ ok: true }>(`/companies/${companyId}`),
exportBundle: ( exportBundle: (