Files
PaperClipAI/server/src/routes/primary-agent.ts
T
DottaandPaperclip ae6f95ed7a feat: add personal primary agents (#15470)
Add a personal primary agent per company and user. Initialize it from the first human-created agent, expose profile-only switching with confirmation, and use it after recent choices for task and Chat defaults.

Persist authenticated preferences, preserve lifecycle and membership rules, keep selections out of shared audit events, and document the API contract. Include the reviewed Storybook surfaces and regression coverage for concurrent choices, onboarding, cross-device updates, and browser journeys.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-07 13:53:46 -05:00

52 lines
2.7 KiB
TypeScript

import { Router } from "express";
import { agents, companies, type Db } from "@paperclipai/db";
import { and, eq } from "drizzle-orm";
import { updatePrimaryAgentSchema } from "@paperclipai/shared";
import { forbidden, notFound } from "../errors.js";
import { validate } from "../middleware/validate.js";
import { accessService } from "../services/access.js";
import { primaryAgentService } from "../services/primary-agent.js";
import { assertBoard, hasCompanyAccess } from "./authz.js";
export function primaryAgentRoutes(db: Db) {
const router = Router();
const svc = primaryAgentService(db);
const access = accessService(db);
router.use("/companies/:companyId/primary-agent/me", async (req, _res, next) => {
assertBoard(req);
const companyId = req.params.companyId as string;
if (!hasCompanyAccess(req, companyId)) throw forbidden("User does not have access to this company");
if (!req.actor.userId) throw forbidden("Board user access required");
// Personal preferences follow the same self-service rules as stars and
// resource memberships: viewers may choose their own primary too.
if (req.actor.source !== "local_implicit" && !req.actor.isInstanceAdmin
&& !req.actor.memberships?.some(membership => membership.companyId === companyId && membership.status === "active")) {
throw forbidden("User does not have active company access");
}
if (!await db.query.companies.findFirst({ where: eq(companies.id, req.params.companyId as string) })) {
throw notFound("Company not found");
}
next();
});
router.get("/companies/:companyId/primary-agent/me", async (req, res) => {
const preference = await svc.get(req.params.companyId as string, req.actor.userId!);
if (preference.primaryAgentId) {
const decision = await access.decide({ actor: req.actor, action: "agent:read",
resource: { type: "agent", companyId: preference.companyId, agentId: preference.primaryAgentId } });
if (!decision.allowed) preference.primaryAgentId = null;
}
res.json(preference);
});
router.put("/companies/:companyId/primary-agent/me", validate(updatePrimaryAgentSchema), async (req, res) => {
const companyId = req.params.companyId as string;
const agentId = req.body.primaryAgentId as string;
const agent = await db.query.agents.findFirst({ where: and(eq(agents.companyId, companyId), eq(agents.id, agentId)) });
if (!agent) throw notFound("Agent not found");
const decision = await access.decide({ actor: req.actor, action: "agent:read",
resource: { type: "agent", companyId, agentId } });
if (!decision.allowed) throw forbidden("Agent is outside this actor's authorization boundary");
res.json(await svc.set(companyId, req.actor.userId!, agentId, req.actor));
});
return router;
}