| 1 | /** |
| 2 | * People: a workspace's directory of everyone in it, people and agents, |
| 3 | * and each member's place in it: a title, who they report to, and what |
| 4 | * they own. Kept by identity beside membership; mirrors |
| 5 | * `crates/contracts/src/people.rs`. Agents come from the agents service, |
| 6 | * which teams name by id. |
| 7 | */ |
| 8 | import type { User } from "./identity"; |
| 9 | import type { Result } from "./result"; |
| 10 | import type { TeamChannel, TeamLead, TeamRef, TeamRole, TeamVisibility } from "./teams"; |
| 11 | |
| 12 | /** The longest title. */ |
| 13 | export const MAX_TITLE_LENGTH = 80; |
| 14 | /** The most things one person owns, as their profile lists them, and the longest of them. */ |
| 15 | export const MAX_OWNS = 8; |
| 16 | export const MAX_OWNS_LENGTH = 60; |
| 17 | |
| 18 | /** One member, as the directory, their profile and the org chart show them. */ |
| 19 | export type DirectoryPerson = { |
| 20 | user_id: string; |
| 21 | username: string; |
| 22 | display_username?: string; |
| 23 | name: string | null; |
| 24 | avatar: string | null; |
| 25 | bio: string | null; |
| 26 | location: string | null; |
| 27 | pronouns: string | null; |
| 28 | /** An IANA time zone, for their local time. */ |
| 29 | timezone: string | null; |
| 30 | role: "owner" | "member"; |
| 31 | /** Their title in this workspace. */ |
| 32 | title: string | null; |
| 33 | /** Who they report to, by username. */ |
| 34 | manager: string | null; |
| 35 | /** What they own: a few short phrases. */ |
| 36 | owns: string[]; |
| 37 | joined_at: string; |
| 38 | }; |
| 39 | |
| 40 | export type TeamPersonRef = { username: string; role: TeamRole }; |
| 41 | |
| 42 | /** A team as the directory and the org chart need it. */ |
| 43 | export type DirectoryTeam = { |
| 44 | slug: string; |
| 45 | name: string; |
| 46 | description: string | null; |
| 47 | visibility: TeamVisibility; |
| 48 | parent: TeamRef | null; |
| 49 | lead: TeamLead | null; |
| 50 | channel: TeamChannel | null; |
| 51 | budget_micros: number | null; |
| 52 | /** Its own people. */ |
| 53 | people: TeamPersonRef[]; |
| 54 | /** Agents added to it, by id. Agents whose home team it is are on it too (`agentsOnTeam`). */ |
| 55 | agent_ids: string[]; |
| 56 | repos_count: number; |
| 57 | }; |
| 58 | |
| 59 | export type PeopleDirectory = { |
| 60 | people: DirectoryPerson[]; |
| 61 | teams: DirectoryTeam[]; |
| 62 | /** What members get on every repository. */ |
| 63 | base_permission: "none" | "read" | "write" | "admin"; |
| 64 | /** Whether the viewer owns the workspace: they set anyone's title, manager and what they own. */ |
| 65 | can_manage: boolean; |
| 66 | }; |
| 67 | |
| 68 | /** What changes on a member's profile; the rest stays. `manager: ""` clears it (owners only). */ |
| 69 | export type MemberProfileChanges = { |
| 70 | title?: string; |
| 71 | manager?: string; |
| 72 | owns?: string[]; |
| 73 | }; |
| 74 | |
| 75 | export interface PeopleClient { |
| 76 | /** Everyone in a workspace and its teams, as the viewer may see them. Members only. */ |
| 77 | peopleDirectory(viewer: User | null, workspace: string): Promise<Result<PeopleDirectory>>; |
| 78 | /** A member's title and what they own (themselves or an owner), and their manager (owners). */ |
| 79 | setMemberProfile(actor: User, workspace: string, username: string, changes: MemberProfileChanges): Promise<Result<DirectoryPerson>>; |
| 80 | } |
| 81 | |
| 82 | /** |
| 83 | * The agents on a team, by id: those added to it, then those whose home |
| 84 | * team it is (their profile names it). |
| 85 | */ |
| 86 | export function agentsOnTeam(team: { slug: string; agent_ids: readonly string[] }, agents: readonly { id: string; team: string | null }[]): string[] { |
| 87 | const ids = [...team.agent_ids]; |
| 88 | for (const agent of agents) if (agent.team === team.slug && !ids.includes(agent.id)) ids.push(agent.id); |
| 89 | return ids; |
| 90 | } |
| 91 | |
| 92 | /** What a team is made of. */ |
| 93 | export type TeamKind = "mixed" | "people" | "agents" | "empty"; |
| 94 | |
| 95 | export function teamKind(people: number, agents: number): TeamKind { |
| 96 | if (people && agents) return "mixed"; |
| 97 | if (people) return "people"; |
| 98 | if (agents) return "agents"; |
| 99 | return "empty"; |
| 100 | } |
| 101 | |
| 102 | /** "2 people and 1 agent", "People only", "Agents only", "No one yet". */ |
| 103 | export function teamKindLabel(people: number, agents: number): string { |
| 104 | const kind = teamKind(people, agents); |
| 105 | if (kind === "people") return "People only"; |
| 106 | if (kind === "agents") return "Agents only"; |
| 107 | if (kind === "empty") return "No one yet"; |
| 108 | return `${people} ${people === 1 ? "person" : "people"} and ${agents} ${agents === 1 ? "agent" : "agents"}`; |
| 109 | } |
| 110 | |
| 111 | /** What someone owns, as identity keeps it: trimmed, each once (ignoring case), at most eight. */ |
| 112 | export function cleanOwns(owns: readonly string[]): string[] { |
| 113 | const out: string[] = []; |
| 114 | for (const raw of owns) { |
| 115 | const phrase = raw.split(/\s+/).filter(Boolean).join(" ").slice(0, MAX_OWNS_LENGTH).replace(/[.,;]+$/, "").trim(); |
| 116 | if (!phrase || out.some((kept) => kept.toLowerCase() === phrase.toLowerCase())) continue; |
| 117 | out.push(phrase); |
| 118 | if (out.length === MAX_OWNS) break; |
| 119 | } |
| 120 | return out; |
| 121 | } |