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`
|
- `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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
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 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;
|
||||||
|
|||||||
@@ -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: (
|
||||||
|
|||||||
Reference in New Issue
Block a user