From 7b4a4f45ed8eab5b82157c3f1124749df0dbd67f Mon Sep 17 00:00:00 2001 From: dotta Date: Wed, 18 Mar 2026 21:03:41 -0500 Subject: [PATCH] Add CEO company branding endpoint Co-Authored-By: Paperclip --- doc/SPEC-implementation.md | 1 + packages/shared/src/index.ts | 2 + packages/shared/src/validators/company.ts | 16 +- packages/shared/src/validators/index.ts | 2 + .../companies-route-path-guard.test.ts | 3 + .../__tests__/company-branding-route.test.ts | 197 ++++++++++++++++++ server/src/routes/companies.ts | 42 +++- ui/src/api/companies.ts | 3 + 8 files changed, 264 insertions(+), 2 deletions(-) create mode 100644 server/src/__tests__/company-branding-route.test.ts diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index e69ff44f..fd2c4842 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -441,6 +441,7 @@ All endpoints are under `/api` and return JSON. - `POST /companies` - `GET /companies/:companyId` - `PATCH /companies/:companyId` +- `PATCH /companies/:companyId/branding` - `POST /companies/:companyId/archive` ## 10.2 Goals diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index f353f5ea..131c837b 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -288,8 +288,10 @@ export { export { createCompanySchema, updateCompanySchema, + updateCompanyBrandingSchema, type CreateCompany, type UpdateCompany, + type UpdateCompanyBranding, agentSkillStateSchema, agentSkillSyncModeSchema, agentSkillEntrySchema, diff --git a/packages/shared/src/validators/company.ts b/packages/shared/src/validators/company.ts index bb4851f4..7e91a61b 100644 --- a/packages/shared/src/validators/company.ts +++ b/packages/shared/src/validators/company.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { COMPANY_STATUSES } from "../constants.js"; const logoAssetIdSchema = z.string().uuid().nullable().optional(); +const brandColorSchema = z.string().regex(/^#[0-9a-fA-F]{6}$/).nullable().optional(); export const createCompanySchema = z.object({ name: z.string().min(1), @@ -17,8 +18,21 @@ export const updateCompanySchema = createCompanySchema status: z.enum(COMPANY_STATUSES).optional(), spentMonthlyCents: z.number().int().nonnegative().optional(), requireBoardApprovalForNewAgents: z.boolean().optional(), - brandColor: z.string().regex(/^#[0-9a-fA-F]{6}$/).nullable().optional(), + brandColor: brandColorSchema, logoAssetId: logoAssetIdSchema, }); export type UpdateCompany = z.infer; + +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; diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 44c3a38b..a3d06b68 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -15,8 +15,10 @@ export { export { createCompanySchema, updateCompanySchema, + updateCompanyBrandingSchema, type CreateCompany, type UpdateCompany, + type UpdateCompanyBranding, } from "./company.js"; export { companySkillSourceTypeSchema, diff --git a/server/src/__tests__/companies-route-path-guard.test.ts b/server/src/__tests__/companies-route-path-guard.test.ts index a06a826e..ca43ae60 100644 --- a/server/src/__tests__/companies-route-path-guard.test.ts +++ b/server/src/__tests__/companies-route-path-guard.test.ts @@ -25,6 +25,9 @@ vi.mock("../services/index.js", () => ({ budgetService: () => ({ upsertPolicy: vi.fn(), }), + agentService: () => ({ + getById: vi.fn(), + }), logActivity: vi.fn(), })); diff --git a/server/src/__tests__/company-branding-route.test.ts b/server/src/__tests__/company-branding-route.test.ts new file mode 100644 index 00000000..352bdda4 --- /dev/null +++ b/server/src/__tests__/company-branding-route.test.ts @@ -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) { + 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(); + }); +}); diff --git a/server/src/routes/companies.ts b/server/src/routes/companies.ts index bb6585a2..49945db5 100644 --- a/server/src/routes/companies.ts +++ b/server/src/routes/companies.ts @@ -1,16 +1,18 @@ -import { Router } from "express"; +import { Router, type Request } from "express"; import type { Db } from "@paperclipai/db"; import { companyPortabilityExportSchema, companyPortabilityImportSchema, companyPortabilityPreviewSchema, createCompanySchema, + updateCompanyBrandingSchema, updateCompanySchema, } from "@paperclipai/shared"; import { forbidden } from "../errors.js"; import { validate } from "../middleware/validate.js"; import { accessService, + agentService, budgetService, companyPortabilityService, companyService, @@ -21,10 +23,25 @@ import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js"; export function companyRoutes(db: Db) { const router = Router(); const svc = companyService(db); + const agents = agentService(db); const portability = companyPortabilityService(db); const access = accessService(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) => { assertBoard(req); const result = await svc.list(); @@ -165,6 +182,29 @@ export function companyRoutes(db: Db) { 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) => { assertBoard(req); const companyId = req.params.companyId as string; diff --git a/ui/src/api/companies.ts b/ui/src/api/companies.ts index d048e756..f19a063c 100644 --- a/ui/src/api/companies.ts +++ b/ui/src/api/companies.ts @@ -5,6 +5,7 @@ import type { CompanyPortabilityImportResult, CompanyPortabilityPreviewRequest, CompanyPortabilityPreviewResult, + UpdateCompanyBranding, } from "@paperclipai/shared"; import { api } from "./client"; @@ -29,6 +30,8 @@ export const companiesApi = { > >, ) => api.patch(`/companies/${companyId}`, data), + updateBranding: (companyId: string, data: UpdateCompanyBranding) => + api.patch(`/companies/${companyId}/branding`, data), archive: (companyId: string) => api.post(`/companies/${companyId}/archive`, {}), remove: (companyId: string) => api.delete<{ ok: true }>(`/companies/${companyId}`), exportBundle: (