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