mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-15 02:20:38 +09:00
[codex] Add access cleanup and user profile page (#4088)
## Thinking Path > - Paperclip is moving from a solo local operator model toward teams supervising AI-agent companies. > - Human access management and human-visible profile surfaces are part of that multiple-user path. > - The branch included related access cleanup, archived-member removal, permission protection, and a user profile page. > - These changes share company membership, user attribution, and access-service behavior. > - This pull request groups those human access/profile changes into one standalone branch. > - The benefit is safer member removal behavior and a first profile surface for user work, activity, and cost attribution. ## What Changed - Added archived company member removal support across shared contracts, server routes/services, and UI. - Protected company member removal with stricter permission checks and tests. - Added company user profile API, shared types, route wiring, client API, route, and UI page. - Simplified the user profile page visual design to a neutral typography-led layout. ## Verification - `pnpm install --frozen-lockfile` - `pnpm exec vitest run server/src/__tests__/access-service.test.ts server/src/__tests__/user-profile-routes.test.ts ui/src/pages/CompanyAccess.test.tsx --hookTimeout=30000` - `pnpm exec vitest run server/src/__tests__/user-profile-routes.test.ts --testTimeout=30000 --hookTimeout=30000` after an initial local embedded-Postgres hook timeout in the combined run. - Split integration check: merged after runtime/governance and dev-infra/backups with no merge conflicts. - Confirmed this branch does not include `pnpm-lock.yaml`. ## Risks - Medium risk: changes member removal permissions and adds a new user profile route with cross-table stats. - The profile page is a new UI surface and may need visual follow-up in browser QA. - No database migrations are included. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5.4 tool-enabled coding model, agentic code-editing/runtime with local shell and GitHub CLI access; exact context window and reasoning mode are not exposed by the Paperclip harness. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
e89d3f7e11
commit
d8b63a18e7
23 changed files with 2156 additions and 51 deletions
|
|
@ -5,6 +5,8 @@ import {
|
|||
companies,
|
||||
companyMemberships,
|
||||
createDb,
|
||||
instanceUserRoles,
|
||||
issues,
|
||||
principalPermissionGrants,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
|
|
@ -51,7 +53,9 @@ describeEmbeddedPostgres("access service", () => {
|
|||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(issues);
|
||||
await db.delete(principalPermissionGrants);
|
||||
await db.delete(instanceUserRoles);
|
||||
await db.delete(companyMemberships);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
|
@ -96,4 +100,125 @@ describeEmbeddedPostgres("access service", () => {
|
|||
.then((rows) => rows[0]!);
|
||||
expect(unchanged.status).toBe("active");
|
||||
});
|
||||
|
||||
it("archives members, clears grants, and reassigns open issues without deleting history", async () => {
|
||||
const { company, owner } = await createCompanyWithOwner(db);
|
||||
const member = await db
|
||||
.insert(companyMemberships)
|
||||
.values({
|
||||
companyId: company.id,
|
||||
principalType: "user",
|
||||
principalId: `member-${randomUUID()}`,
|
||||
status: "active",
|
||||
membershipRole: "operator",
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]!);
|
||||
await db.insert(principalPermissionGrants).values({
|
||||
companyId: company.id,
|
||||
principalType: "user",
|
||||
principalId: member.principalId,
|
||||
permissionKey: "tasks:assign",
|
||||
grantedByUserId: owner.principalId,
|
||||
});
|
||||
const openIssue = await db
|
||||
.insert(issues)
|
||||
.values({
|
||||
companyId: company.id,
|
||||
title: "Open assigned issue",
|
||||
status: "in_progress",
|
||||
assigneeUserId: member.principalId,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]!);
|
||||
const doneIssue = await db
|
||||
.insert(issues)
|
||||
.values({
|
||||
companyId: company.id,
|
||||
title: "Historical assigned issue",
|
||||
status: "done",
|
||||
assigneeUserId: member.principalId,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]!);
|
||||
|
||||
const access = accessService(db);
|
||||
const result = await access.archiveMember(company.id, member.id, {
|
||||
reassignment: { assigneeUserId: owner.principalId },
|
||||
});
|
||||
|
||||
expect(result?.reassignedIssueCount).toBe(1);
|
||||
const archived = await db
|
||||
.select()
|
||||
.from(companyMemberships)
|
||||
.where(eq(companyMemberships.id, member.id))
|
||||
.then((rows) => rows[0]!);
|
||||
expect(archived.status).toBe("archived");
|
||||
|
||||
const remainingGrants = await db
|
||||
.select()
|
||||
.from(principalPermissionGrants)
|
||||
.where(eq(principalPermissionGrants.principalId, member.principalId));
|
||||
expect(remainingGrants).toHaveLength(0);
|
||||
|
||||
const reassignedIssue = await db
|
||||
.select()
|
||||
.from(issues)
|
||||
.where(eq(issues.id, openIssue.id))
|
||||
.then((rows) => rows[0]!);
|
||||
expect(reassignedIssue.assigneeUserId).toBe(owner.principalId);
|
||||
expect(reassignedIssue.status).toBe("todo");
|
||||
|
||||
const historicalIssue = await db
|
||||
.select()
|
||||
.from(issues)
|
||||
.where(eq(issues.id, doneIssue.id))
|
||||
.then((rows) => rows[0]!);
|
||||
expect(historicalIssue.assigneeUserId).toBe(member.principalId);
|
||||
});
|
||||
|
||||
it("rejects instance-level company access removal for self and protected users", async () => {
|
||||
const { company, owner } = await createCompanyWithOwner(db);
|
||||
const access = accessService(db);
|
||||
|
||||
await expect(
|
||||
access.setUserCompanyAccess(owner.principalId, [], { actorUserId: owner.principalId }),
|
||||
).rejects.toThrow("You cannot remove yourself");
|
||||
|
||||
const admin = await db
|
||||
.insert(companyMemberships)
|
||||
.values({
|
||||
companyId: company.id,
|
||||
principalType: "user",
|
||||
principalId: `admin-${randomUUID()}`,
|
||||
status: "active",
|
||||
membershipRole: "admin",
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]!);
|
||||
|
||||
await expect(
|
||||
access.setUserCompanyAccess(admin.principalId, [], { actorUserId: owner.principalId }),
|
||||
).rejects.toThrow("Owners and admins cannot be removed from company access");
|
||||
|
||||
const operator = await db
|
||||
.insert(companyMemberships)
|
||||
.values({
|
||||
companyId: company.id,
|
||||
principalType: "user",
|
||||
principalId: `operator-${randomUUID()}`,
|
||||
status: "active",
|
||||
membershipRole: "operator",
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]!);
|
||||
await db.insert(instanceUserRoles).values({
|
||||
userId: operator.principalId,
|
||||
role: "instance_admin",
|
||||
});
|
||||
|
||||
await expect(
|
||||
access.setUserCompanyAccess(operator.principalId, [], { actorUserId: owner.principalId }),
|
||||
).rejects.toThrow("Instance admins cannot be removed from company access");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
205
server/src/__tests__/user-profile-routes.test.ts
Normal file
205
server/src/__tests__/user-profile-routes.test.ts
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
activityLog,
|
||||
agents,
|
||||
authUsers,
|
||||
companies,
|
||||
companyMemberships,
|
||||
costEvents,
|
||||
createDb,
|
||||
issueComments,
|
||||
issues,
|
||||
} from "@paperclipai/db";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
import { userProfileRoutes } from "../routes/user-profiles.js";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
`Skipping embedded Postgres user profile route tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||
);
|
||||
}
|
||||
|
||||
describeEmbeddedPostgres("GET /companies/:companyId/users/:userSlug/profile", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
let companyId!: string;
|
||||
let userId!: string;
|
||||
let agentId!: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-user-profile-route-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
}, 20_000);
|
||||
|
||||
beforeEach(async () => {
|
||||
companyId = randomUUID();
|
||||
userId = randomUUID();
|
||||
agentId = randomUUID();
|
||||
const now = new Date();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `U${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(authUsers).values({
|
||||
id: userId,
|
||||
name: "Dotta",
|
||||
email: "dotta@example.com",
|
||||
emailVerified: true,
|
||||
image: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
await db.insert(companyMemberships).values({
|
||||
companyId,
|
||||
principalType: "user",
|
||||
principalId: userId,
|
||||
status: "active",
|
||||
membershipRole: "owner",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "Coder",
|
||||
role: "engineer",
|
||||
adapterType: "process",
|
||||
adapterConfig: {},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(costEvents);
|
||||
await db.delete(issueComments);
|
||||
await db.delete(activityLog);
|
||||
await db.delete(issues);
|
||||
await db.delete(agents);
|
||||
await db.delete(companyMemberships);
|
||||
await db.delete(authUsers);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
function createApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as any).actor = {
|
||||
type: "board",
|
||||
source: "local_implicit",
|
||||
userId,
|
||||
companyIds: [companyId],
|
||||
};
|
||||
next();
|
||||
});
|
||||
app.use("/api", userProfileRoutes(db));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
it("resolves a user slug and returns issue, activity, and attributed cost stats", async () => {
|
||||
const doneIssueId = randomUUID();
|
||||
const openIssueId = randomUUID();
|
||||
const now = new Date();
|
||||
const older = new Date(now.getTime() - 60_000);
|
||||
|
||||
await db.insert(issues).values([
|
||||
{
|
||||
id: doneIssueId,
|
||||
companyId,
|
||||
title: "Ship profile page",
|
||||
status: "done",
|
||||
priority: "high",
|
||||
createdByUserId: userId,
|
||||
identifier: "USR-1",
|
||||
completedAt: now,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
{
|
||||
id: openIssueId,
|
||||
companyId,
|
||||
title: "Review profile copy",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
assigneeUserId: userId,
|
||||
identifier: "USR-2",
|
||||
createdAt: older,
|
||||
updatedAt: older,
|
||||
},
|
||||
]);
|
||||
await db.insert(issueComments).values({
|
||||
companyId,
|
||||
issueId: openIssueId,
|
||||
authorUserId: userId,
|
||||
body: "Looks good.",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
await db.insert(activityLog).values({
|
||||
companyId,
|
||||
actorType: "user",
|
||||
actorId: userId,
|
||||
action: "issue.updated",
|
||||
entityType: "issue",
|
||||
entityId: doneIssueId,
|
||||
createdAt: now,
|
||||
});
|
||||
await db.insert(costEvents).values({
|
||||
companyId,
|
||||
agentId,
|
||||
issueId: doneIssueId,
|
||||
provider: "openai",
|
||||
biller: "openai",
|
||||
billingType: "metered_api",
|
||||
model: "gpt-test",
|
||||
inputTokens: 120,
|
||||
cachedInputTokens: 30,
|
||||
outputTokens: 40,
|
||||
costCents: 42,
|
||||
occurredAt: now,
|
||||
});
|
||||
|
||||
const response = await request(createApp()).get(`/api/companies/${companyId}/users/dotta/profile`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.user.slug).toBe("dotta");
|
||||
expect(response.body.user.membershipRole).toBe("owner");
|
||||
expect(response.body.stats).toHaveLength(3);
|
||||
|
||||
const all = response.body.stats.find((entry: { key: string }) => entry.key === "all");
|
||||
expect(all).toMatchObject({
|
||||
touchedIssues: 2,
|
||||
createdIssues: 1,
|
||||
completedIssues: 1,
|
||||
assignedOpenIssues: 1,
|
||||
commentCount: 1,
|
||||
activityCount: 1,
|
||||
costCents: 42,
|
||||
inputTokens: 120,
|
||||
cachedInputTokens: 30,
|
||||
outputTokens: 40,
|
||||
costEventCount: 1,
|
||||
});
|
||||
expect(response.body.recentIssues.map((issue: { identifier: string }) => issue.identifier)).toEqual(["USR-1", "USR-2"]);
|
||||
expect(response.body.recentActivity[0].action).toBe("issue.updated");
|
||||
expect(response.body.topAgents[0]).toMatchObject({ agentId, agentName: "Coder", costCents: 42 });
|
||||
expect(response.body.topProviders[0]).toMatchObject({ provider: "openai", model: "gpt-test", costCents: 42 });
|
||||
});
|
||||
});
|
||||
|
|
@ -23,6 +23,7 @@ import { secretRoutes } from "./routes/secrets.js";
|
|||
import { costRoutes } from "./routes/costs.js";
|
||||
import { activityRoutes } from "./routes/activity.js";
|
||||
import { dashboardRoutes } from "./routes/dashboard.js";
|
||||
import { userProfileRoutes } from "./routes/user-profiles.js";
|
||||
import { sidebarBadgeRoutes } from "./routes/sidebar-badges.js";
|
||||
import { sidebarPreferenceRoutes } from "./routes/sidebar-preferences.js";
|
||||
import { inboxDismissalRoutes } from "./routes/inbox-dismissals.js";
|
||||
|
|
@ -195,6 +196,7 @@ export async function createApp(
|
|||
api.use(costRoutes(db));
|
||||
api.use(activityRoutes(db));
|
||||
api.use(dashboardRoutes(db));
|
||||
api.use(userProfileRoutes(db));
|
||||
api.use(sidebarBadgeRoutes(db));
|
||||
api.use(sidebarPreferenceRoutes(db));
|
||||
api.use(inboxDismissalRoutes(db));
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import path from "node:path";
|
|||
import { fileURLToPath } from "node:url";
|
||||
import { Router } from "express";
|
||||
import type { Request } from "express";
|
||||
import { and, desc, eq, gt, inArray, isNotNull, isNull, lte, sql } from "drizzle-orm";
|
||||
import { and, desc, eq, gt, inArray, isNotNull, isNull, lte, ne, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
assets,
|
||||
|
|
@ -18,6 +18,7 @@ import {
|
|||
companies,
|
||||
companyLogos,
|
||||
companyMemberships,
|
||||
instanceUserRoles,
|
||||
invites,
|
||||
joinRequests,
|
||||
principalPermissionGrants,
|
||||
|
|
@ -34,11 +35,12 @@ import {
|
|||
searchAdminUsersQuerySchema,
|
||||
updateCompanyMemberWithPermissionsSchema,
|
||||
updateCompanyMemberSchema,
|
||||
archiveCompanyMemberSchema,
|
||||
updateMemberPermissionsSchema,
|
||||
updateUserCompanyAccessSchema,
|
||||
PERMISSION_KEYS
|
||||
} from "@paperclipai/shared";
|
||||
import type { DeploymentExposure, DeploymentMode, PermissionKey } from "@paperclipai/shared";
|
||||
import type { DeploymentExposure, DeploymentMode, HumanCompanyMembershipRole, PermissionKey } from "@paperclipai/shared";
|
||||
import {
|
||||
forbidden,
|
||||
conflict,
|
||||
|
|
@ -994,7 +996,11 @@ async function loadCompanyAccessSummary(
|
|||
};
|
||||
}
|
||||
|
||||
async function loadCompanyMemberRecords(db: Db, companyId: string) {
|
||||
async function loadCompanyMemberRecords(
|
||||
db: Db,
|
||||
companyId: string,
|
||||
options: { includeArchived?: boolean } = {},
|
||||
) {
|
||||
const members = await db
|
||||
.select()
|
||||
.from(companyMemberships)
|
||||
|
|
@ -1002,6 +1008,7 @@ async function loadCompanyMemberRecords(db: Db, companyId: string) {
|
|||
and(
|
||||
eq(companyMemberships.companyId, companyId),
|
||||
eq(companyMemberships.principalType, "user"),
|
||||
options.includeArchived ? undefined : ne(companyMemberships.status, "archived"),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(companyMemberships.updatedAt));
|
||||
|
|
@ -1041,6 +1048,116 @@ async function loadCompanyMemberRecords(db: Db, companyId: string) {
|
|||
}));
|
||||
}
|
||||
|
||||
type CompanyMemberRecord = Awaited<ReturnType<typeof loadCompanyMemberRecords>>[number];
|
||||
|
||||
const humanRoleRank: Record<HumanCompanyMembershipRole, number> = {
|
||||
viewer: 1,
|
||||
operator: 2,
|
||||
admin: 3,
|
||||
owner: 4,
|
||||
};
|
||||
|
||||
async function resolveActorHumanRole(
|
||||
req: Request,
|
||||
access: ReturnType<typeof accessService>,
|
||||
companyId: string,
|
||||
): Promise<HumanCompanyMembershipRole | null> {
|
||||
if (req.actor.type !== "board") return null;
|
||||
if (isLocalImplicit(req) || req.actor.isInstanceAdmin) return "owner";
|
||||
const userId = req.actor.userId ?? null;
|
||||
if (!userId) return null;
|
||||
const membership = await access.getMembership(companyId, "user", userId);
|
||||
if (membership?.status !== "active" || !membership.membershipRole) return null;
|
||||
return normalizeHumanRole(membership.membershipRole, "operator");
|
||||
}
|
||||
|
||||
async function getProtectedMemberReason(
|
||||
req: Request,
|
||||
access: ReturnType<typeof accessService>,
|
||||
companyId: string,
|
||||
member: { principalId: string; principalType: string; membershipRole: string | null },
|
||||
opts?: {
|
||||
actorRole?: HumanCompanyMembershipRole | null;
|
||||
instanceAdminUserIds?: ReadonlySet<string>;
|
||||
operation?: "archive" | "update";
|
||||
},
|
||||
): Promise<string | null> {
|
||||
if (member.principalType !== "user") return "Only human company members can be removed.";
|
||||
if (req.actor.type !== "board") return "Board access is required to remove members.";
|
||||
if (member.principalId === req.actor.userId) return "You cannot remove yourself.";
|
||||
const isTargetInstanceAdmin = opts?.instanceAdminUserIds
|
||||
? opts.instanceAdminUserIds.has(member.principalId)
|
||||
: await access.isInstanceAdmin(member.principalId);
|
||||
if (isTargetInstanceAdmin) {
|
||||
return "Instance admins cannot be removed from company access.";
|
||||
}
|
||||
|
||||
const targetRole = member.membershipRole
|
||||
? normalizeHumanRole(member.membershipRole, "operator")
|
||||
: "operator";
|
||||
if (opts?.operation === "archive") {
|
||||
if (targetRole === "owner") return "Board owners cannot be removed from company access.";
|
||||
if (targetRole === "admin") return "Company admins cannot be removed from company access.";
|
||||
}
|
||||
|
||||
const actorRole = opts?.actorRole ?? await resolveActorHumanRole(req, access, companyId);
|
||||
if (!actorRole) return "Only active company members can remove users.";
|
||||
if (humanRoleRank[targetRole] >= humanRoleRank[actorRole]) {
|
||||
return "You can only remove users below your company role.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function assertCanManageCompanyMember(
|
||||
req: Request,
|
||||
access: ReturnType<typeof accessService>,
|
||||
companyId: string,
|
||||
member: { principalId: string; principalType: string; membershipRole: string | null },
|
||||
operation: "archive" | "update" = "update",
|
||||
) {
|
||||
const reason = await getProtectedMemberReason(req, access, companyId, member, { operation });
|
||||
if (reason) throw forbidden(reason);
|
||||
}
|
||||
|
||||
async function addCompanyMemberRemovalAccess(
|
||||
req: Request,
|
||||
db: Db,
|
||||
access: ReturnType<typeof accessService>,
|
||||
companyId: string,
|
||||
members: CompanyMemberRecord[],
|
||||
) {
|
||||
const actorRole = await resolveActorHumanRole(req, access, companyId);
|
||||
const userIds = [...new Set(members
|
||||
.filter((member) => member.principalType === "user")
|
||||
.map((member) => member.principalId))];
|
||||
const instanceAdminUserIds = userIds.length > 0
|
||||
? new Set(
|
||||
await db
|
||||
.select({ userId: instanceUserRoles.userId })
|
||||
.from(instanceUserRoles)
|
||||
.where(and(inArray(instanceUserRoles.userId, userIds), eq(instanceUserRoles.role, "instance_admin")))
|
||||
.then((rows) => rows.map((row) => row.userId)),
|
||||
)
|
||||
: new Set<string>();
|
||||
return Promise.all(
|
||||
members.map(async (member) => {
|
||||
const reason = await getProtectedMemberReason(req, access, companyId, member, {
|
||||
actorRole,
|
||||
instanceAdminUserIds,
|
||||
operation: "archive",
|
||||
});
|
||||
return {
|
||||
...member,
|
||||
removal: {
|
||||
canArchive: !reason,
|
||||
reason,
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function loadCompanyUserDirectory(db: Db, companyId: string) {
|
||||
const members = await db
|
||||
.select({
|
||||
|
|
@ -3604,7 +3721,7 @@ export function accessRoutes(
|
|||
loadCompanyAccessSummary(req, access, companyId),
|
||||
]);
|
||||
res.json({
|
||||
members,
|
||||
members: await addCompanyMemberRemovalAccess(req, db, access, companyId, members),
|
||||
access: currentAccess,
|
||||
});
|
||||
});
|
||||
|
|
@ -3623,6 +3740,9 @@ export function accessRoutes(
|
|||
const companyId = req.params.companyId as string;
|
||||
const memberId = req.params.memberId as string;
|
||||
await assertCompanyPermission(req, companyId, "users:manage_permissions");
|
||||
const memberToUpdate = await access.getMemberById(companyId, memberId);
|
||||
if (!memberToUpdate) throw notFound("Member not found");
|
||||
await assertCanManageCompanyMember(req, access, companyId, memberToUpdate);
|
||||
|
||||
const updated = await db.transaction(async (tx) => {
|
||||
await tx.execute(sql`
|
||||
|
|
@ -3717,6 +3837,9 @@ export function accessRoutes(
|
|||
const companyId = req.params.companyId as string;
|
||||
const memberId = req.params.memberId as string;
|
||||
await assertCompanyPermission(req, companyId, "users:manage_permissions");
|
||||
const memberToUpdate = await access.getMemberById(companyId, memberId);
|
||||
if (!memberToUpdate) throw notFound("Member not found");
|
||||
await assertCanManageCompanyMember(req, access, companyId, memberToUpdate);
|
||||
|
||||
const updated = await db.transaction(async (tx) => {
|
||||
await tx.execute(sql`
|
||||
|
|
@ -3834,6 +3957,47 @@ export function accessRoutes(
|
|||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/companies/:companyId/members/:memberId/archive",
|
||||
validate(archiveCompanyMemberSchema),
|
||||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const memberId = req.params.memberId as string;
|
||||
await assertCompanyPermission(req, companyId, "users:manage_permissions");
|
||||
const memberToArchive = await access.getMemberById(companyId, memberId);
|
||||
if (!memberToArchive) throw notFound("Member not found");
|
||||
await assertCanManageCompanyMember(req, access, companyId, memberToArchive, "archive");
|
||||
|
||||
const result = await access.archiveMember(companyId, memberId, {
|
||||
reassignment: req.body.reassignment ?? null,
|
||||
});
|
||||
if (!result) throw notFound("Member not found");
|
||||
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: "user",
|
||||
actorId: req.actor.userId ?? "board",
|
||||
action: "company_member.archived",
|
||||
entityType: "company_membership",
|
||||
entityId: memberId,
|
||||
details: {
|
||||
principalId: result.member.principalId,
|
||||
reassignedIssueCount: result.reassignedIssueCount,
|
||||
reassignment: req.body.reassignment ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
const member = (await loadCompanyMemberRecords(db, companyId, { includeArchived: true })).find(
|
||||
(entry) => entry.id === memberId,
|
||||
);
|
||||
if (!member) throw notFound("Member not found");
|
||||
res.json({
|
||||
member,
|
||||
reassignedIssueCount: result.reassignedIssueCount,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
router.patch(
|
||||
"/companies/:companyId/members/:memberId/permissions",
|
||||
validate(updateMemberPermissionsSchema),
|
||||
|
|
@ -3841,6 +4005,9 @@ export function accessRoutes(
|
|||
const companyId = req.params.companyId as string;
|
||||
const memberId = req.params.memberId as string;
|
||||
await assertCompanyPermission(req, companyId, "users:manage_permissions");
|
||||
const memberToUpdate = await access.getMemberById(companyId, memberId);
|
||||
if (!memberToUpdate) throw notFound("Member not found");
|
||||
await assertCanManageCompanyMember(req, access, companyId, memberToUpdate);
|
||||
const updated = await access.setMemberPermissions(
|
||||
companyId,
|
||||
memberId,
|
||||
|
|
@ -3962,7 +4129,8 @@ export function accessRoutes(
|
|||
const userId = req.params.userId as string;
|
||||
await access.setUserCompanyAccess(
|
||||
userId,
|
||||
req.body.companyIds ?? []
|
||||
req.body.companyIds ?? [],
|
||||
{ actorUserId: req.actor.userId ?? null },
|
||||
);
|
||||
res.json(await loadUserCompanyAccessResponse(db, access, userId));
|
||||
}
|
||||
|
|
|
|||
436
server/src/routes/user-profiles.ts
Normal file
436
server/src/routes/user-profiles.ts
Normal file
|
|
@ -0,0 +1,436 @@
|
|||
import { Router } from "express";
|
||||
import { and, desc, eq, gte, isNull, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
activityLog,
|
||||
agents,
|
||||
authUsers,
|
||||
companyMemberships,
|
||||
costEvents,
|
||||
issueComments,
|
||||
issues,
|
||||
} from "@paperclipai/db";
|
||||
import type {
|
||||
UserProfileDailyPoint,
|
||||
UserProfileIdentity,
|
||||
UserProfileResponse,
|
||||
UserProfileWindowStats,
|
||||
} from "@paperclipai/shared";
|
||||
import { notFound } from "../errors.js";
|
||||
import { assertCompanyAccess } from "./authz.js";
|
||||
|
||||
type CompanyUserRow = {
|
||||
id: string;
|
||||
principalId: string;
|
||||
status: string;
|
||||
membershipRole: string | null;
|
||||
createdAt: Date;
|
||||
userId: string | null;
|
||||
name: string | null;
|
||||
email: string | null;
|
||||
image: string | null;
|
||||
};
|
||||
|
||||
const PROFILE_WINDOWS = [
|
||||
{ key: "last7", label: "Last 7 days", days: 7 },
|
||||
{ key: "last30", label: "Last 30 days", days: 30 },
|
||||
{ key: "all", label: "All time", days: null },
|
||||
] as const;
|
||||
|
||||
function slugifyUserPart(value: string | null | undefined) {
|
||||
const normalized = value
|
||||
?.trim()
|
||||
.toLowerCase()
|
||||
.replace(/['"]/g, "")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
function userSlugCandidates(row: CompanyUserRow) {
|
||||
const candidates = new Set<string>();
|
||||
const add = (value: string | null | undefined) => {
|
||||
const slug = slugifyUserPart(value);
|
||||
if (slug) candidates.add(slug);
|
||||
};
|
||||
add(row.name);
|
||||
add(row.email?.split("@")[0]);
|
||||
add(row.email);
|
||||
add(row.principalId);
|
||||
return [...candidates];
|
||||
}
|
||||
|
||||
async function resolveCompanyUser(db: Db, companyId: string, rawSlug: string): Promise<CompanyUserRow | null> {
|
||||
const slug = slugifyUserPart(rawSlug);
|
||||
if (!slug) return null;
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: companyMemberships.id,
|
||||
principalId: companyMemberships.principalId,
|
||||
status: companyMemberships.status,
|
||||
membershipRole: companyMemberships.membershipRole,
|
||||
createdAt: companyMemberships.createdAt,
|
||||
userId: authUsers.id,
|
||||
name: authUsers.name,
|
||||
email: authUsers.email,
|
||||
image: authUsers.image,
|
||||
})
|
||||
.from(companyMemberships)
|
||||
.leftJoin(authUsers, eq(authUsers.id, companyMemberships.principalId))
|
||||
.where(
|
||||
and(
|
||||
eq(companyMemberships.companyId, companyId),
|
||||
eq(companyMemberships.principalType, "user"),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(companyMemberships.updatedAt))
|
||||
.limit(200);
|
||||
|
||||
return rows.find((row) => userSlugCandidates(row).includes(slug)) ?? null;
|
||||
}
|
||||
|
||||
function userIssueInvolvementSql(companyId: string, userId: string) {
|
||||
return sql<boolean>`
|
||||
(
|
||||
${issues.createdByUserId} = ${userId}
|
||||
OR ${issues.assigneeUserId} = ${userId}
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM ${issueComments}
|
||||
WHERE ${issueComments.companyId} = ${companyId}
|
||||
AND ${issueComments.issueId} = ${issues.id}
|
||||
AND ${issueComments.authorUserId} = ${userId}
|
||||
)
|
||||
)
|
||||
`;
|
||||
}
|
||||
|
||||
function windowStart(days: number | null) {
|
||||
if (!days) return null;
|
||||
return new Date(Date.now() - days * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
function startOfUtcDay(date: Date) {
|
||||
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
|
||||
}
|
||||
|
||||
function isoDay(date: Date) {
|
||||
return startOfUtcDay(date).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function dayKeyExpr(dateSql: ReturnType<typeof sql>) {
|
||||
return sql<string>`to_char(date_trunc('day', ${dateSql}), 'YYYY-MM-DD')`;
|
||||
}
|
||||
|
||||
function sumNumber(column: typeof costEvents.costCents | typeof costEvents.inputTokens | typeof costEvents.cachedInputTokens | typeof costEvents.outputTokens) {
|
||||
return sql<number>`coalesce(sum(${column}), 0)::double precision`;
|
||||
}
|
||||
|
||||
async function loadWindowStats(
|
||||
db: Db,
|
||||
companyId: string,
|
||||
userId: string,
|
||||
key: UserProfileWindowStats["key"],
|
||||
label: string,
|
||||
from: Date | null,
|
||||
): Promise<UserProfileWindowStats> {
|
||||
const involvement = userIssueInvolvementSql(companyId, userId);
|
||||
const openStatuses = ["backlog", "todo", "in_progress", "in_review", "blocked"];
|
||||
const fromIso = from?.toISOString();
|
||||
|
||||
const [issueStats] = await db
|
||||
.select({
|
||||
touchedIssues: sql<number>`count(distinct case when ${involvement} ${fromIso ? sql`and ${issues.updatedAt} >= ${fromIso}` : sql``} then ${issues.id} end)::int`,
|
||||
createdIssues: sql<number>`count(distinct case when ${issues.createdByUserId} = ${userId} ${fromIso ? sql`and ${issues.createdAt} >= ${fromIso}` : sql``} then ${issues.id} end)::int`,
|
||||
completedIssues: sql<number>`count(distinct case when ${involvement} and ${issues.status} = 'done' ${fromIso ? sql`and ${issues.completedAt} >= ${fromIso}` : sql``} then ${issues.id} end)::int`,
|
||||
assignedOpenIssues: sql<number>`count(distinct case when ${issues.assigneeUserId} = ${userId} and ${issues.status} in (${sql.join(openStatuses.map((status) => sql`${status}`), sql`, `)}) then ${issues.id} end)::int`,
|
||||
})
|
||||
.from(issues)
|
||||
.where(and(eq(issues.companyId, companyId), isNull(issues.hiddenAt)));
|
||||
|
||||
const commentConditions = [
|
||||
eq(issueComments.companyId, companyId),
|
||||
eq(issueComments.authorUserId, userId),
|
||||
];
|
||||
if (from) commentConditions.push(gte(issueComments.createdAt, from));
|
||||
const [commentStats] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(issueComments)
|
||||
.where(and(...commentConditions));
|
||||
|
||||
const activityConditions = [
|
||||
eq(activityLog.companyId, companyId),
|
||||
eq(activityLog.actorType, "user"),
|
||||
eq(activityLog.actorId, userId),
|
||||
];
|
||||
if (from) activityConditions.push(gte(activityLog.createdAt, from));
|
||||
const [activityStats] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(activityLog)
|
||||
.where(and(...activityConditions));
|
||||
|
||||
const costConditions = [
|
||||
eq(costEvents.companyId, companyId),
|
||||
userIssueInvolvementSql(companyId, userId),
|
||||
];
|
||||
if (from) costConditions.push(gte(costEvents.occurredAt, from));
|
||||
const [costStats] = await db
|
||||
.select({
|
||||
costCents: sumNumber(costEvents.costCents),
|
||||
inputTokens: sumNumber(costEvents.inputTokens),
|
||||
cachedInputTokens: sumNumber(costEvents.cachedInputTokens),
|
||||
outputTokens: sumNumber(costEvents.outputTokens),
|
||||
costEventCount: sql<number>`count(${costEvents.id})::int`,
|
||||
})
|
||||
.from(costEvents)
|
||||
.innerJoin(issues, and(eq(issues.id, costEvents.issueId), eq(issues.companyId, costEvents.companyId)))
|
||||
.where(and(...costConditions));
|
||||
|
||||
return {
|
||||
key,
|
||||
label,
|
||||
touchedIssues: Number(issueStats?.touchedIssues ?? 0),
|
||||
createdIssues: Number(issueStats?.createdIssues ?? 0),
|
||||
completedIssues: Number(issueStats?.completedIssues ?? 0),
|
||||
assignedOpenIssues: Number(issueStats?.assignedOpenIssues ?? 0),
|
||||
commentCount: Number(commentStats?.count ?? 0),
|
||||
activityCount: Number(activityStats?.count ?? 0),
|
||||
costCents: Number(costStats?.costCents ?? 0),
|
||||
inputTokens: Number(costStats?.inputTokens ?? 0),
|
||||
cachedInputTokens: Number(costStats?.cachedInputTokens ?? 0),
|
||||
outputTokens: Number(costStats?.outputTokens ?? 0),
|
||||
costEventCount: Number(costStats?.costEventCount ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
async function loadDailyStats(db: Db, companyId: string, userId: string): Promise<UserProfileDailyPoint[]> {
|
||||
const firstDay = startOfUtcDay(new Date(Date.now() - 13 * 24 * 60 * 60 * 1000));
|
||||
const points = new Map<string, UserProfileDailyPoint>();
|
||||
for (let index = 0; index < 14; index += 1) {
|
||||
const date = new Date(firstDay.getTime() + index * 24 * 60 * 60 * 1000);
|
||||
points.set(isoDay(date), {
|
||||
date: isoDay(date),
|
||||
activityCount: 0,
|
||||
completedIssues: 0,
|
||||
costCents: 0,
|
||||
inputTokens: 0,
|
||||
cachedInputTokens: 0,
|
||||
outputTokens: 0,
|
||||
});
|
||||
}
|
||||
|
||||
const activityDay = dayKeyExpr(sql`${activityLog.createdAt}`);
|
||||
const activityRows = await db
|
||||
.select({
|
||||
date: activityDay,
|
||||
count: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(activityLog)
|
||||
.where(
|
||||
and(
|
||||
eq(activityLog.companyId, companyId),
|
||||
eq(activityLog.actorType, "user"),
|
||||
eq(activityLog.actorId, userId),
|
||||
gte(activityLog.createdAt, firstDay),
|
||||
),
|
||||
)
|
||||
.groupBy(activityDay);
|
||||
|
||||
for (const row of activityRows) {
|
||||
const point = points.get(row.date);
|
||||
if (point) point.activityCount = Number(row.count);
|
||||
}
|
||||
|
||||
const completedDay = dayKeyExpr(sql`${issues.completedAt}`);
|
||||
const completedRows = await db
|
||||
.select({
|
||||
date: completedDay,
|
||||
count: sql<number>`count(distinct ${issues.id})::int`,
|
||||
})
|
||||
.from(issues)
|
||||
.where(
|
||||
and(
|
||||
eq(issues.companyId, companyId),
|
||||
isNull(issues.hiddenAt),
|
||||
eq(issues.status, "done"),
|
||||
gte(issues.completedAt, firstDay),
|
||||
userIssueInvolvementSql(companyId, userId),
|
||||
),
|
||||
)
|
||||
.groupBy(completedDay);
|
||||
|
||||
for (const row of completedRows) {
|
||||
const point = points.get(row.date);
|
||||
if (point) point.completedIssues = Number(row.count);
|
||||
}
|
||||
|
||||
const costDay = dayKeyExpr(sql`${costEvents.occurredAt}`);
|
||||
const costRows = await db
|
||||
.select({
|
||||
date: costDay,
|
||||
costCents: sumNumber(costEvents.costCents),
|
||||
inputTokens: sumNumber(costEvents.inputTokens),
|
||||
cachedInputTokens: sumNumber(costEvents.cachedInputTokens),
|
||||
outputTokens: sumNumber(costEvents.outputTokens),
|
||||
})
|
||||
.from(costEvents)
|
||||
.innerJoin(issues, and(eq(issues.id, costEvents.issueId), eq(issues.companyId, costEvents.companyId)))
|
||||
.where(
|
||||
and(
|
||||
eq(costEvents.companyId, companyId),
|
||||
gte(costEvents.occurredAt, firstDay),
|
||||
userIssueInvolvementSql(companyId, userId),
|
||||
),
|
||||
)
|
||||
.groupBy(costDay);
|
||||
|
||||
for (const row of costRows) {
|
||||
const point = points.get(row.date);
|
||||
if (!point) continue;
|
||||
point.costCents = Number(row.costCents);
|
||||
point.inputTokens = Number(row.inputTokens);
|
||||
point.cachedInputTokens = Number(row.cachedInputTokens);
|
||||
point.outputTokens = Number(row.outputTokens);
|
||||
}
|
||||
|
||||
return [...points.values()];
|
||||
}
|
||||
|
||||
export function userProfileRoutes(db: Db) {
|
||||
const router = Router();
|
||||
|
||||
router.get("/companies/:companyId/users/:userSlug/profile", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const userSlug = req.params.userSlug as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
|
||||
const row = await resolveCompanyUser(db, companyId, userSlug);
|
||||
if (!row) throw notFound("User not found");
|
||||
const canonicalSlug = userSlugCandidates(row)[0] ?? row.principalId;
|
||||
const userId = row.userId ?? row.principalId;
|
||||
|
||||
const [stats, daily, recentIssues, recentActivity, topAgents, topProviders] = await Promise.all([
|
||||
Promise.all(
|
||||
PROFILE_WINDOWS.map((entry) =>
|
||||
loadWindowStats(db, companyId, userId, entry.key, entry.label, windowStart(entry.days)),
|
||||
),
|
||||
),
|
||||
loadDailyStats(db, companyId, userId),
|
||||
db
|
||||
.select({
|
||||
id: issues.id,
|
||||
identifier: issues.identifier,
|
||||
title: issues.title,
|
||||
status: issues.status,
|
||||
priority: issues.priority,
|
||||
assigneeAgentId: issues.assigneeAgentId,
|
||||
assigneeUserId: issues.assigneeUserId,
|
||||
updatedAt: issues.updatedAt,
|
||||
completedAt: issues.completedAt,
|
||||
})
|
||||
.from(issues)
|
||||
.where(
|
||||
and(
|
||||
eq(issues.companyId, companyId),
|
||||
isNull(issues.hiddenAt),
|
||||
userIssueInvolvementSql(companyId, userId),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(issues.updatedAt))
|
||||
.limit(8),
|
||||
db
|
||||
.select({
|
||||
id: activityLog.id,
|
||||
action: activityLog.action,
|
||||
entityType: activityLog.entityType,
|
||||
entityId: activityLog.entityId,
|
||||
details: activityLog.details,
|
||||
createdAt: activityLog.createdAt,
|
||||
})
|
||||
.from(activityLog)
|
||||
.where(
|
||||
and(
|
||||
eq(activityLog.companyId, companyId),
|
||||
eq(activityLog.actorType, "user"),
|
||||
eq(activityLog.actorId, userId),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(activityLog.createdAt))
|
||||
.limit(12),
|
||||
db
|
||||
.select({
|
||||
agentId: costEvents.agentId,
|
||||
agentName: agents.name,
|
||||
costCents: sumNumber(costEvents.costCents),
|
||||
inputTokens: sumNumber(costEvents.inputTokens),
|
||||
cachedInputTokens: sumNumber(costEvents.cachedInputTokens),
|
||||
outputTokens: sumNumber(costEvents.outputTokens),
|
||||
})
|
||||
.from(costEvents)
|
||||
.innerJoin(issues, and(eq(issues.id, costEvents.issueId), eq(issues.companyId, costEvents.companyId)))
|
||||
.leftJoin(agents, eq(agents.id, costEvents.agentId))
|
||||
.where(and(eq(costEvents.companyId, companyId), userIssueInvolvementSql(companyId, userId)))
|
||||
.groupBy(costEvents.agentId, agents.name)
|
||||
.orderBy(desc(sumNumber(costEvents.costCents)))
|
||||
.limit(5),
|
||||
db
|
||||
.select({
|
||||
provider: costEvents.provider,
|
||||
biller: costEvents.biller,
|
||||
model: costEvents.model,
|
||||
costCents: sumNumber(costEvents.costCents),
|
||||
inputTokens: sumNumber(costEvents.inputTokens),
|
||||
cachedInputTokens: sumNumber(costEvents.cachedInputTokens),
|
||||
outputTokens: sumNumber(costEvents.outputTokens),
|
||||
})
|
||||
.from(costEvents)
|
||||
.innerJoin(issues, and(eq(issues.id, costEvents.issueId), eq(issues.companyId, costEvents.companyId)))
|
||||
.where(and(eq(costEvents.companyId, companyId), userIssueInvolvementSql(companyId, userId)))
|
||||
.groupBy(costEvents.provider, costEvents.biller, costEvents.model)
|
||||
.orderBy(desc(sumNumber(costEvents.costCents)))
|
||||
.limit(5),
|
||||
]);
|
||||
|
||||
const user: UserProfileIdentity = {
|
||||
id: userId,
|
||||
slug: canonicalSlug,
|
||||
name: row.name,
|
||||
email: row.email,
|
||||
image: row.image,
|
||||
membershipRole: row.membershipRole,
|
||||
membershipStatus: row.status,
|
||||
joinedAt: row.createdAt,
|
||||
};
|
||||
|
||||
const payload: UserProfileResponse = {
|
||||
user,
|
||||
stats,
|
||||
daily,
|
||||
recentIssues: recentIssues.map((issue) => ({
|
||||
...issue,
|
||||
status: issue.status as UserProfileResponse["recentIssues"][number]["status"],
|
||||
priority: issue.priority as UserProfileResponse["recentIssues"][number]["priority"],
|
||||
})),
|
||||
recentActivity,
|
||||
topAgents: topAgents.map((entry) => ({
|
||||
...entry,
|
||||
costCents: Number(entry.costCents),
|
||||
inputTokens: Number(entry.inputTokens),
|
||||
cachedInputTokens: Number(entry.cachedInputTokens),
|
||||
outputTokens: Number(entry.outputTokens),
|
||||
})),
|
||||
topProviders: topProviders.map((entry) => ({
|
||||
...entry,
|
||||
costCents: Number(entry.costCents),
|
||||
inputTokens: Number(entry.inputTokens),
|
||||
cachedInputTokens: Number(entry.cachedInputTokens),
|
||||
outputTokens: Number(entry.outputTokens),
|
||||
})),
|
||||
};
|
||||
|
||||
res.json(payload);
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
|
@ -1,8 +1,10 @@
|
|||
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||
import { and, eq, inArray, ne, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
agents,
|
||||
companyMemberships,
|
||||
instanceUserRoles,
|
||||
issues,
|
||||
principalPermissionGrants,
|
||||
} from "@paperclipai/db";
|
||||
import type { PermissionKey, PrincipalType } from "@paperclipai/shared";
|
||||
|
|
@ -14,6 +16,13 @@ type GrantInput = {
|
|||
scope?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
type MemberArchiveInput = {
|
||||
reassignment?: {
|
||||
assigneeAgentId?: string | null;
|
||||
assigneeUserId?: string | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export function accessService(db: Db) {
|
||||
async function isInstanceAdmin(userId: string | null | undefined): Promise<boolean> {
|
||||
if (!userId) return false;
|
||||
|
|
@ -239,6 +248,176 @@ export function accessService(db: Db) {
|
|||
});
|
||||
}
|
||||
|
||||
async function assertCanRemoveActiveOwner(
|
||||
companyId: string,
|
||||
principalType: PrincipalType,
|
||||
status: string,
|
||||
membershipRole: string | null,
|
||||
tx: Pick<Db, "select">,
|
||||
) {
|
||||
if (
|
||||
principalType !== "user" ||
|
||||
status !== "active" ||
|
||||
membershipRole !== "owner"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeOwnerCount = await tx
|
||||
.select({ id: companyMemberships.id })
|
||||
.from(companyMemberships)
|
||||
.where(
|
||||
and(
|
||||
eq(companyMemberships.companyId, companyId),
|
||||
eq(companyMemberships.principalType, "user"),
|
||||
eq(companyMemberships.status, "active"),
|
||||
eq(companyMemberships.membershipRole, "owner"),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows.length);
|
||||
if (activeOwnerCount <= 1) {
|
||||
throw conflict("Cannot remove the last active owner");
|
||||
}
|
||||
}
|
||||
|
||||
async function assertAssignableArchiveTarget(
|
||||
companyId: string,
|
||||
input: MemberArchiveInput["reassignment"],
|
||||
tx: Pick<Db, "select">,
|
||||
) {
|
||||
if (!input?.assigneeAgentId && !input?.assigneeUserId) return;
|
||||
if (input.assigneeAgentId && input.assigneeUserId) {
|
||||
throw conflict("Choose either an agent or user reassignment target");
|
||||
}
|
||||
if (input.assigneeUserId) {
|
||||
const membership = await tx
|
||||
.select({ id: companyMemberships.id })
|
||||
.from(companyMemberships)
|
||||
.where(
|
||||
and(
|
||||
eq(companyMemberships.companyId, companyId),
|
||||
eq(companyMemberships.principalType, "user"),
|
||||
eq(companyMemberships.principalId, input.assigneeUserId),
|
||||
eq(companyMemberships.status, "active"),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!membership) {
|
||||
throw conflict("Replacement user must be an active company member");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const agent = await tx
|
||||
.select({
|
||||
id: agents.id,
|
||||
companyId: agents.companyId,
|
||||
status: agents.status,
|
||||
})
|
||||
.from(agents)
|
||||
.where(eq(agents.id, input.assigneeAgentId!))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!agent || agent.companyId !== companyId) {
|
||||
throw conflict("Replacement agent must belong to the same company");
|
||||
}
|
||||
if (agent.status === "pending_approval" || agent.status === "terminated") {
|
||||
throw conflict("Replacement agent must be assignable");
|
||||
}
|
||||
}
|
||||
|
||||
async function archiveMember(companyId: string, memberId: string, input: MemberArchiveInput = {}) {
|
||||
return db.transaction(async (tx) => {
|
||||
await tx.execute(sql`
|
||||
select ${companyMemberships.id}
|
||||
from ${companyMemberships}
|
||||
where ${companyMemberships.companyId} = ${companyId}
|
||||
and ${companyMemberships.principalType} = 'user'
|
||||
and ${companyMemberships.status} = 'active'
|
||||
and ${companyMemberships.membershipRole} = 'owner'
|
||||
for update
|
||||
`);
|
||||
|
||||
const existing = await tx
|
||||
.select()
|
||||
.from(companyMemberships)
|
||||
.where(and(eq(companyMemberships.companyId, companyId), eq(companyMemberships.id, memberId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!existing) return null;
|
||||
if (existing.principalType !== "user") {
|
||||
throw conflict("Only human company members can be archived");
|
||||
}
|
||||
if (existing.status === "archived") {
|
||||
return { member: existing, reassignedIssueCount: 0 };
|
||||
}
|
||||
if (input.reassignment?.assigneeUserId === existing.principalId) {
|
||||
throw conflict("Replacement user cannot be the archived member");
|
||||
}
|
||||
|
||||
await assertCanRemoveActiveOwner(
|
||||
companyId,
|
||||
existing.principalType,
|
||||
existing.status,
|
||||
existing.membershipRole,
|
||||
tx,
|
||||
);
|
||||
await assertAssignableArchiveTarget(companyId, input.reassignment, tx);
|
||||
|
||||
const now = new Date();
|
||||
const assignmentPatch = {
|
||||
assigneeAgentId: input.reassignment?.assigneeAgentId ?? null,
|
||||
assigneeUserId: input.reassignment?.assigneeUserId ?? null,
|
||||
updatedAt: now,
|
||||
};
|
||||
const assignedOpenIssueWhere = and(
|
||||
eq(issues.companyId, companyId),
|
||||
eq(issues.assigneeUserId, existing.principalId),
|
||||
sql`${issues.status} not in ('done', 'cancelled')`,
|
||||
);
|
||||
const resetInProgress = await tx
|
||||
.update(issues)
|
||||
.set({
|
||||
...assignmentPatch,
|
||||
status: "todo",
|
||||
startedAt: null,
|
||||
checkoutRunId: null,
|
||||
executionRunId: null,
|
||||
executionLockedAt: null,
|
||||
})
|
||||
.where(and(assignedOpenIssueWhere, eq(issues.status, "in_progress")))
|
||||
.returning({ id: issues.id });
|
||||
const reassigned = await tx
|
||||
.update(issues)
|
||||
.set(assignmentPatch)
|
||||
.where(and(assignedOpenIssueWhere, ne(issues.status, "in_progress")))
|
||||
.returning({ id: issues.id });
|
||||
|
||||
await tx
|
||||
.delete(principalPermissionGrants)
|
||||
.where(
|
||||
and(
|
||||
eq(principalPermissionGrants.companyId, companyId),
|
||||
eq(principalPermissionGrants.principalType, existing.principalType),
|
||||
eq(principalPermissionGrants.principalId, existing.principalId),
|
||||
),
|
||||
);
|
||||
|
||||
const archived = await tx
|
||||
.update(companyMemberships)
|
||||
.set({
|
||||
status: "archived",
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(companyMemberships.id, existing.id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? existing);
|
||||
|
||||
return {
|
||||
member: archived,
|
||||
reassignedIssueCount: resetInProgress.length + reassigned.length,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function promoteInstanceAdmin(userId: string) {
|
||||
const existing = await db
|
||||
.select()
|
||||
|
|
@ -272,19 +451,81 @@ export function accessService(db: Db) {
|
|||
.orderBy(sql`${companyMemberships.createdAt} desc`);
|
||||
}
|
||||
|
||||
async function setUserCompanyAccess(userId: string, companyIds: string[]) {
|
||||
async function setUserCompanyAccess(
|
||||
userId: string,
|
||||
companyIds: string[],
|
||||
options: { actorUserId?: string | null } = {},
|
||||
) {
|
||||
const existing = await listUserCompanyAccess(userId);
|
||||
const existingByCompany = new Map(existing.map((row) => [row.companyId, row]));
|
||||
const target = new Set(companyIds);
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
const toDelete = existing.filter((row) => !target.has(row.companyId)).map((row) => row.id);
|
||||
if (toDelete.length > 0) {
|
||||
await tx.delete(companyMemberships).where(inArray(companyMemberships.id, toDelete));
|
||||
const toArchive = existing.filter((row) => !target.has(row.companyId) && row.status !== "archived");
|
||||
if (toArchive.length > 0 && options.actorUserId && options.actorUserId === userId) {
|
||||
throw conflict("You cannot remove yourself");
|
||||
}
|
||||
if (toArchive.length > 0 && (await isInstanceAdmin(userId))) {
|
||||
throw conflict("Instance admins cannot be removed from company access");
|
||||
}
|
||||
const protectedArchives = toArchive.filter((row) => row.membershipRole === "owner" || row.membershipRole === "admin");
|
||||
if (protectedArchives.length > 0) {
|
||||
throw conflict("Owners and admins cannot be removed from company access");
|
||||
}
|
||||
const activeOwnerArchives = toArchive.filter(
|
||||
(row) => row.status === "active" && row.membershipRole === "owner",
|
||||
);
|
||||
if (activeOwnerArchives.length > 0) {
|
||||
const activeOwnerRows = await tx
|
||||
.select({ companyId: companyMemberships.companyId, id: companyMemberships.id })
|
||||
.from(companyMemberships)
|
||||
.where(
|
||||
and(
|
||||
eq(companyMemberships.principalType, "user"),
|
||||
eq(companyMemberships.status, "active"),
|
||||
eq(companyMemberships.membershipRole, "owner"),
|
||||
inArray(companyMemberships.companyId, activeOwnerArchives.map((row) => row.companyId)),
|
||||
),
|
||||
);
|
||||
for (const row of activeOwnerArchives) {
|
||||
const remainingOwners =
|
||||
activeOwnerRows.filter((owner) => owner.companyId === row.companyId).length - 1;
|
||||
if (remainingOwners <= 0) {
|
||||
throw conflict("Cannot remove the last active owner");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (toArchive.length > 0) {
|
||||
await tx
|
||||
.update(companyMemberships)
|
||||
.set({ status: "archived", updatedAt: new Date() })
|
||||
.where(inArray(companyMemberships.id, toArchive.map((row) => row.id)));
|
||||
await tx
|
||||
.delete(principalPermissionGrants)
|
||||
.where(
|
||||
and(
|
||||
eq(principalPermissionGrants.principalType, "user"),
|
||||
eq(principalPermissionGrants.principalId, userId),
|
||||
inArray(principalPermissionGrants.companyId, toArchive.map((row) => row.companyId)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
for (const companyId of target) {
|
||||
if (existingByCompany.has(companyId)) continue;
|
||||
const existingMembership = existingByCompany.get(companyId);
|
||||
if (existingMembership) {
|
||||
if (existingMembership.status !== "active") {
|
||||
await tx
|
||||
.update(companyMemberships)
|
||||
.set({
|
||||
status: "active",
|
||||
membershipRole: existingMembership.membershipRole ?? "operator",
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(companyMemberships.id, existingMembership.id));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
await tx.insert(companyMemberships).values({
|
||||
companyId,
|
||||
principalType: "user",
|
||||
|
|
@ -535,6 +776,7 @@ export function accessService(db: Db) {
|
|||
listMembers,
|
||||
listActiveUserMemberships,
|
||||
copyActiveUserMemberships,
|
||||
archiveMember,
|
||||
setMemberPermissions,
|
||||
updateMemberAndPermissions,
|
||||
promoteInstanceAdmin,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue