diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 8817916dfbc..ce384c73519 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -310,6 +310,14 @@ export type { LiveEvent, DashboardSummary, ActivityEvent, + UserProfileActivitySummary, + UserProfileAgentUsage, + UserProfileDailyPoint, + UserProfileIdentity, + UserProfileIssueSummary, + UserProfileProviderUsage, + UserProfileResponse, + UserProfileWindowStats, SidebarBadges, SidebarOrderPreference, InboxDismissal, diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 20d59f9de67..600d97a80b5 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -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"; diff --git a/packages/shared/src/types/user-profile.ts b/packages/shared/src/types/user-profile.ts new file mode 100644 index 00000000000..2ed483da1b2 --- /dev/null +++ b/packages/shared/src/types/user-profile.ts @@ -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 | 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[]; +} diff --git a/server/src/__tests__/user-profile-routes.test.ts b/server/src/__tests__/user-profile-routes.test.ts new file mode 100644 index 00000000000..750577860f1 --- /dev/null +++ b/server/src/__tests__/user-profile-routes.test.ts @@ -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; + let tempDb: Awaited> | 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 }); + }); +}); diff --git a/server/src/app.ts b/server/src/app.ts index 971645d5e91..ea2849c68e0 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -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)); diff --git a/server/src/routes/user-profiles.ts b/server/src/routes/user-profiles.ts new file mode 100644 index 00000000000..35e624bdde9 --- /dev/null +++ b/server/src/routes/user-profiles.ts @@ -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(); + 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 { + 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` + ( + ${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) { + return sql`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`coalesce(sum(${column}), 0)::double precision`; +} + +async function loadWindowStats( + db: Db, + companyId: string, + userId: string, + key: UserProfileWindowStats["key"], + label: string, + from: Date | null, +): Promise { + 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`count(distinct case when ${involvement} ${fromIso ? sql`and ${issues.updatedAt} >= ${fromIso}` : sql``} then ${issues.id} end)::int`, + createdIssues: sql`count(distinct case when ${issues.createdByUserId} = ${userId} ${fromIso ? sql`and ${issues.createdAt} >= ${fromIso}` : sql``} then ${issues.id} end)::int`, + completedIssues: sql`count(distinct case when ${involvement} and ${issues.status} = 'done' ${fromIso ? sql`and ${issues.completedAt} >= ${fromIso}` : sql``} then ${issues.id} end)::int`, + assignedOpenIssues: sql`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`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`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`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 { + const firstDay = startOfUtcDay(new Date(Date.now() - 13 * 24 * 60 * 60 * 1000)); + const points = new Map(); + 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`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`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; +} diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 9fe1f9d9dfb..ddf6d1bb46a 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> } /> @@ -277,6 +279,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/ui/src/api/userProfiles.ts b/ui/src/api/userProfiles.ts new file mode 100644 index 00000000000..6c1c13c8513 --- /dev/null +++ b/ui/src/api/userProfiles.ts @@ -0,0 +1,9 @@ +import type { UserProfileResponse } from "@paperclipai/shared"; +import { api } from "./client"; + +export const userProfilesApi = { + get: (companyId: string, userSlug: string) => + api.get( + `/companies/${companyId}/users/${encodeURIComponent(userSlug)}/profile`, + ), +}; diff --git a/ui/src/components/SidebarAccountMenu.tsx b/ui/src/components/SidebarAccountMenu.tsx index 452a41ea34c..ec6e1367dec 100644 --- a/ui/src/components/SidebarAccountMenu.tsx +++ b/ui/src/components/SidebarAccountMenu.tsx @@ -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({
+ ["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, diff --git a/ui/src/pages/UserProfile.tsx b/ui/src/pages/UserProfile.tsx new file mode 100644 index 00000000000..29fc013bddc --- /dev/null +++ b/ui/src/pages/UserProfile.tsx @@ -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) { + 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 ( +
+
+ + {label} +
+
{value}
+
+ ); +} + +function WindowPanel({ stats }: { stats: UserProfileWindowStats }) { + return ( +
+
+

{stats.label}

+ {formatTokens(totalTokens(stats))} +
+
+ + + + +
+
+
+
Created
+
{stats.createdIssues}
+
+
+
Open
+
{stats.assignedOpenIssues}
+
+
+
Rate
+
{completionRate(stats)}
+
+
+
+ ); +} + +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 ( +
+
+
+

Two-week signal

+

Token pressure, user actions, and completed work by day.

+
+
+ tokens + activity +
+
+
+ {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 ( +
+
+
+
+
+
{new Date(point.date).getUTCDate()}
+
+ ); + })} +
+
+ ); +} + +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 ( +
+

{title}

+
+ {rows.length === 0 ? ( +
{empty}
+ ) : rows.map((row) => ( +
+
+
{row.label}
+
{row.sublabel}
+
+
+ {formatTokens(totalTokens(row))} + {formatCents(row.costCents)} +
+
+ ))} +
+
+ ); +} + +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 ; + } + + if (isLoading) { + return ; + } + + if (error || !data) { + return ; + } + + return ( +
+
+
+
+
+ + {data.user.image ? : null} + {initials(displayName)} + +
+
+

{displayName}

+ @{data.user.slug} +
+
+ {data.user.email ? {data.user.email} : null} + {data.user.membershipRole ?? "member"} + {data.user.membershipStatus} + joined {formatDate(data.user.joinedAt)} +
+
+
+ +
+ + + + +
+
+ +
+
+ + Profile ledger +
+
+
+ All-time spend + {formatCents(allTime?.costCents ?? 0)} +
+
+ Attributed cost events + {allTime?.costEventCount ?? 0} +
+
+ Comments + {allTime?.commentCount ?? 0} +
+
+ Completion rate + {allTime ? completionRate(allTime) : "0%"} +
+
+
+
+
+ +
+ {data.stats.map((entry) => )} +
+ + + +
+
+
+

Recent tasks

+ {data.recentIssues.length} +
+
+ {data.recentIssues.length === 0 ? ( +
No touched tasks yet.
+ ) : data.recentIssues.map((issue) => ( + +
+
{issue.identifier ?? issue.id.slice(0, 8)}
+
{issue.title}
+
+
+ + {relativeTime(issue.updatedAt)} +
+ + ))} +
+
+ +
+
+

Recent activity

+ {data.recentActivity.length} +
+
+ {data.recentActivity.length === 0 ? ( +
No direct user actions recorded yet.
+ ) : data.recentActivity.map((event) => ( +
+
+
{event.action.replaceAll("_", " ")}
+
+ {event.entityType} - {event.entityId.slice(0, 12)} +
+
+
{relativeTime(event.createdAt)}
+
+ ))} +
+
+
+ +
+ + +
+
+ ); +}