Add CEO company branding endpoint
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -288,8 +288,10 @@ export {
|
||||
export {
|
||||
createCompanySchema,
|
||||
updateCompanySchema,
|
||||
updateCompanyBrandingSchema,
|
||||
type CreateCompany,
|
||||
type UpdateCompany,
|
||||
type UpdateCompanyBranding,
|
||||
agentSkillStateSchema,
|
||||
agentSkillSyncModeSchema,
|
||||
agentSkillEntrySchema,
|
||||
|
||||
@@ -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<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>;
|
||||
|
||||
@@ -15,8 +15,10 @@ export {
|
||||
export {
|
||||
createCompanySchema,
|
||||
updateCompanySchema,
|
||||
updateCompanyBrandingSchema,
|
||||
type CreateCompany,
|
||||
type UpdateCompany,
|
||||
type UpdateCompanyBranding,
|
||||
} from "./company.js";
|
||||
export {
|
||||
companySkillSourceTypeSchema,
|
||||
|
||||
@@ -25,6 +25,9 @@ vi.mock("../services/index.js", () => ({
|
||||
budgetService: () => ({
|
||||
upsertPolicy: vi.fn(),
|
||||
}),
|
||||
agentService: () => ({
|
||||
getById: vi.fn(),
|
||||
}),
|
||||
logActivity: vi.fn(),
|
||||
}));
|
||||
|
||||
|
||||
197
server/src/__tests__/company-branding-route.test.ts
Normal file
197
server/src/__tests__/company-branding-route.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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<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`, {}),
|
||||
remove: (companyId: string) => api.delete<{ ok: true }>(`/companies/${companyId}`),
|
||||
exportBundle: (
|
||||
|
||||
Reference in New Issue
Block a user