Add company user profile page

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-04-19 21:24:20 -05:00
1 parent aed04c2ba0
commit 73008eacb7
12 files changed
+1158

No files matched your search

+8
View File
@@ -310,6 +310,14 @@ export type {
LiveEvent,
DashboardSummary,
ActivityEvent,
UserProfileActivitySummary,
UserProfileAgentUsage,
UserProfileDailyPoint,
UserProfileIdentity,
UserProfileIssueSummary,
UserProfileProviderUsage,
UserProfileResponse,
UserProfileWindowStats,
SidebarBadges,
SidebarOrderPreference,
InboxDismissal,
+10
View File
@@ -169,6 +169,16 @@ export type {
export type { LiveEvent } from "./live.js";
export type { DashboardSummary } from "./dashboard.js";
export type { ActivityEvent } from "./activity.js";
export type {
UserProfileActivitySummary,
UserProfileAgentUsage,
UserProfileDailyPoint,
UserProfileIdentity,
UserProfileIssueSummary,
UserProfileProviderUsage,
UserProfileResponse,
UserProfileWindowStats,
} from "./user-profile.js";
export type { SidebarBadges } from "./sidebar-badges.js";
export type { SidebarOrderPreference } from "./sidebar-preferences.js";
export type { InboxDismissal } from "./inbox-dismissal.js";
+88
View File
@@ -0,0 +1,88 @@
import type { IssuePriority, IssueStatus } from "../constants.js";
export interface UserProfileIdentity {
id: string;
slug: string;
name: string | null;
email: string | null;
image: string | null;
membershipRole: string | null;
membershipStatus: string;
joinedAt: Date;
}
export interface UserProfileWindowStats {
key: "last7" | "last30" | "all";
label: string;
touchedIssues: number;
createdIssues: number;
completedIssues: number;
assignedOpenIssues: number;
commentCount: number;
activityCount: number;
costCents: number;
inputTokens: number;
cachedInputTokens: number;
outputTokens: number;
costEventCount: number;
}
export interface UserProfileDailyPoint {
date: string;
activityCount: number;
completedIssues: number;
costCents: number;
inputTokens: number;
cachedInputTokens: number;
outputTokens: number;
}
export interface UserProfileIssueSummary {
id: string;
identifier: string | null;
title: string;
status: IssueStatus;
priority: IssuePriority;
assigneeAgentId: string | null;
assigneeUserId: string | null;
updatedAt: Date;
completedAt: Date | null;
}
export interface UserProfileActivitySummary {
id: string;
action: string;
entityType: string;
entityId: string;
details: Record<string, unknown> | null;
createdAt: Date;
}
export interface UserProfileAgentUsage {
agentId: string;
agentName: string | null;
costCents: number;
inputTokens: number;
cachedInputTokens: number;
outputTokens: number;
}
export interface UserProfileProviderUsage {
provider: string;
biller: string;
model: string;
costCents: number;
inputTokens: number;
cachedInputTokens: number;
outputTokens: number;
}
export interface UserProfileResponse {
user: UserProfileIdentity;
stats: UserProfileWindowStats[];
daily: UserProfileDailyPoint[];
recentIssues: UserProfileIssueSummary[];
recentActivity: UserProfileActivitySummary[];
topAgents: UserProfileAgentUsage[];
topProviders: UserProfileProviderUsage[];
}
@@ -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 });
});
});
+2
View File
@@ -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";
@@ -190,6 +191,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));
+435
View File
@@ -0,0 +1,435 @@
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));
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;
}
+3
View File
@@ -14,6 +14,7 @@ import { Issues } from "./pages/Issues";
import { IssueDetail } from "./pages/IssueDetail";
import { Routines } from "./pages/Routines";
import { RoutineDetail } from "./pages/RoutineDetail";
import { UserProfile } from "./pages/UserProfile";
import { ExecutionWorkspaceDetail } from "./pages/ExecutionWorkspaceDetail";
import { Goals } from "./pages/Goals";
import { GoalDetail } from "./pages/GoalDetail";
@@ -117,6 +118,7 @@ function boardRoutes() {
<Route path="inbox/all" element={<Inbox />} />
<Route path="inbox/requests" element={<JoinRequestQueue />} />
<Route path="inbox/new" element={<Navigate to="/inbox/mine" replace />} />
<Route path="u/:userSlug" element={<UserProfile />} />
<Route path="design-guide" element={<DesignGuide />} />
<Route path="tests/ux/chat" element={<IssueChatUxLab />} />
<Route path="tests/ux/invites" element={<InviteUxLab />} />
@@ -277,6 +279,7 @@ export function App() {
<Route path="issues/:issueId" element={<UnprefixedBoardRedirect />} />
<Route path="routines" element={<UnprefixedBoardRedirect />} />
<Route path="routines/:routineId" element={<UnprefixedBoardRedirect />} />
<Route path="u/:userSlug" element={<UnprefixedBoardRedirect />} />
<Route path="skills/*" element={<UnprefixedBoardRedirect />} />
<Route path="settings" element={<LegacySettingsRedirect />} />
<Route path="settings/*" element={<LegacySettingsRedirect />} />
+9
View File
@@ -0,0 +1,9 @@
import type { UserProfileResponse } from "@paperclipai/shared";
import { api } from "./client";
export const userProfilesApi = {
get: (companyId: string, userSlug: string) =>
api.get<UserProfileResponse>(
`/companies/${companyId}/users/${encodeURIComponent(userSlug)}/profile`,
),
};
+23
View File
@@ -6,6 +6,7 @@ import {
type LucideIcon,
Moon,
Settings,
UserRound,
Sun,
UserRoundPen,
} from "lucide-react";
@@ -45,6 +46,20 @@ function deriveInitials(name: string) {
return name.slice(0, 2).toUpperCase();
}
function deriveUserSlug(name: string | null | undefined, email: string | null | undefined, id: string | null | undefined) {
const candidates = [name, email?.split("@")[0], email, id];
for (const candidate of candidates) {
const slug = candidate
?.trim()
.toLowerCase()
.replace(/['"]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
if (slug) return slug;
}
return "me";
}
function MenuAction({ label, description, icon: Icon, onClick, href, external = false }: MenuActionProps) {
const className =
"flex w-full items-start gap-3 rounded-xl px-3 py-3 text-left transition-colors hover:bg-accent/60";
@@ -112,6 +127,7 @@ export function SidebarAccountMenu({
session?.user.email?.trim() || (deploymentMode === "authenticated" ? "Signed in" : "Local workspace board");
const accountBadge = deploymentMode === "authenticated" ? "Account" : "Local";
const initials = deriveInitials(displayName);
const profileHref = `/u/${deriveUserSlug(session?.user.name, session?.user.email, session?.user.id)}`;
function closeNavigationChrome() {
setOpen(false);
@@ -164,6 +180,13 @@ export function SidebarAccountMenu({
</div>
<div className="mt-4 space-y-1">
<MenuAction
label="View profile"
description="Open your activity, task, and usage ledger."
icon={UserRound}
href={profileHref}
onClick={closeNavigationChrome}
/>
<MenuAction
label="Edit profile"
description="Update your display name and avatar."
+1
View File
@@ -15,6 +15,7 @@ const BOARD_ROUTE_ROOTS = new Set([
"usage",
"activity",
"inbox",
"u",
"design-guide",
]);
+2
View File
@@ -120,6 +120,8 @@ export const queryKeys = {
providers: (companyId: string) => ["secret-providers", companyId] as const,
},
dashboard: (companyId: string) => ["dashboard", companyId] as const,
userProfile: (companyId: string, userSlug: string) =>
["user-profile", companyId, userSlug] as const,
sidebarBadges: (companyId: string) => ["sidebar-badges", companyId] as const,
inboxDismissals: (companyId: string) => ["inbox-dismissals", companyId] as const,
activity: (companyId: string) => ["activity", companyId] as const,
+372
View File
@@ -0,0 +1,372 @@
import { useEffect, useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import {
Activity,
AlertCircle,
BarChart3,
CheckCircle2,
Clock3,
Coins,
Hash,
MessageSquareText,
Ticket,
UserRound,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import type { UserProfileDailyPoint, UserProfileWindowStats } from "@paperclipai/shared";
import { Link, useParams } from "@/lib/router";
import { userProfilesApi } from "../api/userProfiles";
import { Avatar, AvatarFallback, AvatarImage } from "../components/ui/avatar";
import { Badge } from "../components/ui/badge";
import { EmptyState } from "../components/EmptyState";
import { PageSkeleton } from "../components/PageSkeleton";
import { StatusBadge } from "../components/StatusBadge";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useCompany } from "../context/CompanyContext";
import { queryKeys } from "../lib/queryKeys";
import { cn, formatCents, formatDate, formatShortDate, formatTokens, issueUrl, providerDisplayName, relativeTime } from "../lib/utils";
const NO_COMPANY = "__none__";
function initials(name: string | null | undefined) {
const value = name?.trim() || "User";
const parts = value.split(/\s+/).filter(Boolean);
if (parts.length > 1) return `${parts[0]?.[0] ?? ""}${parts[parts.length - 1]?.[0] ?? ""}`.toUpperCase();
return value.slice(0, 2).toUpperCase();
}
function totalTokens(stats: Pick<UserProfileWindowStats, "inputTokens" | "cachedInputTokens" | "outputTokens">) {
return stats.inputTokens + stats.cachedInputTokens + stats.outputTokens;
}
function completionRate(stats: UserProfileWindowStats) {
if (stats.touchedIssues === 0) return "0%";
return `${Math.round((stats.completedIssues / stats.touchedIssues) * 100)}%`;
}
function StatPill({
icon: Icon,
label,
value,
tone = "default",
}: {
icon: LucideIcon;
label: string;
value: string;
tone?: "default" | "green" | "amber" | "blue";
}) {
return (
<div
className={cn(
"min-w-0 border border-border bg-background p-3",
tone === "green" && "border-emerald-500/30 bg-emerald-500/5",
tone === "amber" && "border-amber-500/30 bg-amber-500/5",
tone === "blue" && "border-sky-500/30 bg-sky-500/5",
)}
>
<div className="flex items-center gap-2 text-[11px] font-medium uppercase text-muted-foreground">
<Icon className="size-3.5 shrink-0" />
<span className="truncate">{label}</span>
</div>
<div className="mt-2 truncate text-xl font-semibold tabular-nums">{value}</div>
</div>
);
}
function WindowPanel({ stats }: { stats: UserProfileWindowStats }) {
return (
<div className="border border-border bg-card p-4">
<div className="flex items-center justify-between gap-3">
<h2 className="text-sm font-semibold">{stats.label}</h2>
<Badge variant="outline" className="font-mono">{formatTokens(totalTokens(stats))}</Badge>
</div>
<div className="mt-4 grid grid-cols-2 gap-2">
<StatPill icon={Ticket} label="Touched" value={String(stats.touchedIssues)} />
<StatPill icon={CheckCircle2} label="Done" value={String(stats.completedIssues)} tone="green" />
<StatPill icon={MessageSquareText} label="Comments" value={String(stats.commentCount)} tone="blue" />
<StatPill icon={Coins} label="Spend" value={formatCents(stats.costCents)} tone="amber" />
</div>
<div className="mt-4 grid grid-cols-3 divide-x divide-border border border-border text-center">
<div className="p-2">
<div className="text-xs text-muted-foreground">Created</div>
<div className="text-sm font-semibold tabular-nums">{stats.createdIssues}</div>
</div>
<div className="p-2">
<div className="text-xs text-muted-foreground">Open</div>
<div className="text-sm font-semibold tabular-nums">{stats.assignedOpenIssues}</div>
</div>
<div className="p-2">
<div className="text-xs text-muted-foreground">Rate</div>
<div className="text-sm font-semibold tabular-nums">{completionRate(stats)}</div>
</div>
</div>
</div>
);
}
function UsageChart({ points }: { points: UserProfileDailyPoint[] }) {
const maxTokens = Math.max(1, ...points.map((point) => totalTokens(point)));
const maxActivity = Math.max(1, ...points.map((point) => point.activityCount + point.completedIssues));
return (
<section className="border border-border bg-card p-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 className="text-base font-semibold">Two-week signal</h2>
<p className="text-sm text-muted-foreground">Token pressure, user actions, and completed work by day.</p>
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1.5"><span className="size-2 bg-sky-500" /> tokens</span>
<span className="inline-flex items-center gap-1.5"><span className="size-2 bg-emerald-500" /> activity</span>
</div>
</div>
<div className="mt-6 grid h-44 grid-cols-[repeat(14,minmax(0,1fr))] items-end gap-1 sm:gap-2">
{points.map((point) => {
const tokenHeight = Math.max(6, Math.round((totalTokens(point) / maxTokens) * 100));
const activityHeight = Math.max(4, Math.round(((point.activityCount + point.completedIssues) / maxActivity) * 72));
return (
<div key={point.date} className="group flex h-full min-w-0 flex-col justify-end gap-1">
<div className="flex h-full items-end justify-center gap-1">
<div
className="w-full max-w-5 border border-sky-500/40 bg-sky-500/20 transition-colors group-hover:bg-sky-500/35"
style={{ height: `${tokenHeight}%` }}
title={`${formatShortDate(point.date)}: ${formatTokens(totalTokens(point))} tokens`}
/>
<div
className="w-full max-w-3 border border-emerald-500/40 bg-emerald-500/25 transition-colors group-hover:bg-emerald-500/40"
style={{ height: `${activityHeight}%` }}
title={`${formatShortDate(point.date)}: ${point.activityCount} actions, ${point.completedIssues} completed`}
/>
</div>
<div className="truncate text-center text-[10px] text-muted-foreground">{new Date(point.date).getUTCDate()}</div>
</div>
);
})}
</div>
</section>
);
}
function UsageList({
title,
empty,
rows,
}: {
title: string;
empty: string;
rows: Array<{
key: string;
label: string;
sublabel: string;
costCents: number;
inputTokens: number;
cachedInputTokens: number;
outputTokens: number;
}>;
}) {
return (
<section className="border border-border bg-card p-5">
<h2 className="text-base font-semibold">{title}</h2>
<div className="mt-4 divide-y divide-border border border-border">
{rows.length === 0 ? (
<div className="p-4 text-sm text-muted-foreground">{empty}</div>
) : rows.map((row) => (
<div key={row.key} className="grid gap-3 p-3 sm:grid-cols-[1fr_auto] sm:items-center">
<div className="min-w-0">
<div className="truncate text-sm font-medium">{row.label}</div>
<div className="truncate text-xs text-muted-foreground">{row.sublabel}</div>
</div>
<div className="flex flex-wrap gap-2 text-xs tabular-nums sm:justify-end">
<Badge variant="outline">{formatTokens(totalTokens(row))}</Badge>
<Badge variant="secondary">{formatCents(row.costCents)}</Badge>
</div>
</div>
))}
</div>
</section>
);
}
export function UserProfile() {
const { userSlug = "" } = useParams<{ userSlug: string }>();
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const companyId = selectedCompanyId ?? NO_COMPANY;
const { data, isLoading, error } = useQuery({
queryKey: queryKeys.userProfile(companyId, userSlug),
queryFn: () => userProfilesApi.get(companyId, userSlug),
enabled: !!selectedCompanyId && !!userSlug,
});
useEffect(() => {
setBreadcrumbs([{ label: "Users" }, { label: data?.user.name ?? userSlug }]);
}, [data?.user.name, setBreadcrumbs, userSlug]);
const allTime = data?.stats.find((entry) => entry.key === "all");
const last7 = data?.stats.find((entry) => entry.key === "last7");
const displayName = data?.user.name?.trim() || data?.user.email?.split("@")[0] || "User";
const agentUsageRows = useMemo(
() =>
(data?.topAgents ?? []).map((row) => ({
key: row.agentId,
label: row.agentName ?? row.agentId.slice(0, 8),
sublabel: "Issue-linked token usage",
costCents: row.costCents,
inputTokens: row.inputTokens,
cachedInputTokens: row.cachedInputTokens,
outputTokens: row.outputTokens,
})),
[data?.topAgents],
);
const providerUsageRows = useMemo(
() =>
(data?.topProviders ?? []).map((row) => ({
key: `${row.provider}:${row.biller}:${row.model}`,
label: `${providerDisplayName(row.provider)} / ${row.model}`,
sublabel: `Billed through ${providerDisplayName(row.biller)}`,
costCents: row.costCents,
inputTokens: row.inputTokens,
cachedInputTokens: row.cachedInputTokens,
outputTokens: row.outputTokens,
})),
[data?.topProviders],
);
if (!selectedCompanyId) {
return <EmptyState icon={UserRound} message="Select a company to view user profiles." />;
}
if (isLoading) {
return <PageSkeleton variant="dashboard" />;
}
if (error || !data) {
return <EmptyState icon={AlertCircle} message="User profile not found for this company." />;
}
return (
<div className="space-y-6">
<section className="border border-border bg-[linear-gradient(180deg,hsl(var(--card))_0%,hsl(var(--background))_100%)]">
<div className="grid gap-6 p-5 lg:grid-cols-[minmax(0,1fr)_360px] lg:p-7">
<div className="min-w-0">
<div className="flex flex-wrap items-start gap-5">
<Avatar className="size-20 border border-border" size="lg">
{data.user.image ? <AvatarImage src={data.user.image} alt={displayName} /> : null}
<AvatarFallback className="text-xl font-semibold">{initials(displayName)}</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h1 className="min-w-0 truncate text-3xl font-semibold tracking-normal">{displayName}</h1>
<Badge variant="outline">@{data.user.slug}</Badge>
</div>
<div className="mt-2 flex flex-wrap gap-2 text-sm text-muted-foreground">
{data.user.email ? <span className="truncate">{data.user.email}</span> : null}
<span>{data.user.membershipRole ?? "member"}</span>
<span>{data.user.membershipStatus}</span>
<span>joined {formatDate(data.user.joinedAt)}</span>
</div>
</div>
</div>
<div className="mt-7 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<StatPill icon={Coins} label="All-time tokens" value={formatTokens(allTime ? totalTokens(allTime) : 0)} tone="blue" />
<StatPill icon={CheckCircle2} label="Completed" value={String(allTime?.completedIssues ?? 0)} tone="green" />
<StatPill icon={Clock3} label="Open assigned" value={String(allTime?.assignedOpenIssues ?? 0)} tone="amber" />
<StatPill icon={Activity} label="7-day actions" value={String(last7?.activityCount ?? 0)} />
</div>
</div>
<div className="border border-border bg-background p-4">
<div className="flex items-center gap-2 text-sm font-semibold">
<Hash className="size-4 text-muted-foreground" />
Profile ledger
</div>
<div className="mt-4 space-y-3 text-sm">
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">All-time spend</span>
<span className="font-semibold tabular-nums">{formatCents(allTime?.costCents ?? 0)}</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">Attributed cost events</span>
<span className="font-semibold tabular-nums">{allTime?.costEventCount ?? 0}</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">Comments</span>
<span className="font-semibold tabular-nums">{allTime?.commentCount ?? 0}</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">Completion rate</span>
<span className="font-semibold tabular-nums">{allTime ? completionRate(allTime) : "0%"}</span>
</div>
</div>
</div>
</div>
</section>
<div className="grid gap-4 xl:grid-cols-3">
{data.stats.map((entry) => <WindowPanel key={entry.key} stats={entry} />)}
</div>
<UsageChart points={data.daily} />
<div className="grid gap-6 xl:grid-cols-2">
<section className="border border-border bg-card p-5">
<div className="flex items-center justify-between gap-3">
<h2 className="text-base font-semibold">Recent tasks</h2>
<Badge variant="outline">{data.recentIssues.length}</Badge>
</div>
<div className="mt-4 divide-y divide-border border border-border">
{data.recentIssues.length === 0 ? (
<div className="p-4 text-sm text-muted-foreground">No touched tasks yet.</div>
) : data.recentIssues.map((issue) => (
<Link
key={issue.id}
to={issueUrl(issue)}
className="grid gap-2 p-3 transition-colors hover:bg-accent/50 sm:grid-cols-[1fr_auto] sm:items-center"
>
<div className="min-w-0">
<div className="truncate text-sm font-medium">{issue.identifier ?? issue.id.slice(0, 8)}</div>
<div className="truncate text-xs text-muted-foreground">{issue.title}</div>
</div>
<div className="flex flex-wrap items-center gap-2 sm:justify-end">
<StatusBadge status={issue.status} />
<span className="text-xs text-muted-foreground">{relativeTime(issue.updatedAt)}</span>
</div>
</Link>
))}
</div>
</section>
<section className="border border-border bg-card p-5">
<div className="flex items-center justify-between gap-3">
<h2 className="text-base font-semibold">Recent activity</h2>
<Badge variant="outline">{data.recentActivity.length}</Badge>
</div>
<div className="mt-4 divide-y divide-border border border-border">
{data.recentActivity.length === 0 ? (
<div className="p-4 text-sm text-muted-foreground">No direct user actions recorded yet.</div>
) : data.recentActivity.map((event) => (
<div key={event.id} className="grid gap-2 p-3 sm:grid-cols-[1fr_auto] sm:items-center">
<div className="min-w-0">
<div className="truncate text-sm font-medium">{event.action.replaceAll("_", " ")}</div>
<div className="truncate text-xs text-muted-foreground">
{event.entityType} - {event.entityId.slice(0, 12)}
</div>
</div>
<div className="text-xs text-muted-foreground">{relativeTime(event.createdAt)}</div>
</div>
))}
</div>
</section>
</div>
<div className="grid gap-6 xl:grid-cols-2">
<UsageList title="Agent attribution" empty="No issue-linked token usage yet." rows={agentUsageRows} />
<UsageList title="Provider mix" empty="No provider usage attributed yet." rows={providerUsageRows} />
</div>
</div>
);
}