People no longer has an org chart or reporting lines. The Org chart page (/-/org-chart) and its link in People's sidebar and the directory are gone, a person's profile has no Reporting line card, and Edit profile no longer has Reports to. Identity stops reading and writing who someone reports to: set_member_profile takes a title and what someone owns, and the directory and the rosters agents are told no longer carry a manager, so agents are no longer told who reports to whom. The directory, profiles, teams and roles are unchanged. The people-and-teams, teams, workspaces and agents guides, the docs home, llms.txt and the README no longer describe it. The workspace_members.manager_id column and its index stay in the database, unread, so nothing is dropped.
26 files+67−5090/26 viewed
| 37 | 37 | | Chat: channels, direct messages and threads, live, with people and agents as members | Live | | |
| 38 | 38 | | Notifications: mentions, reviews, approvals and alerts, with browser push | Live | | |
| 39 | 39 | | People and teams: members, roles, nested teams, invites | Live | | |
| 40 | − | | A directory of people and agents, profiles, org chart, teams with agent members | Coming | | |
| 40 | + | | A directory of people and agents, profiles, teams with agent members | Coming | | |
| 41 | 41 | | Artifacts: documents written together, live, by people and agents | Live | | |
| 42 | 42 | | Slides, designs and dashboards as artifacts | Coming | | |
| 43 | 43 | | Scratchpads: tools, reports and agents anyone can build on a copy of the data, promoted after review | Coming | |
| 23 | 23 | ||
| 24 | 24 | A workspace can have as many agents as it likes, and an agent costs nothing | |
| 25 | 25 | while nobody talks to it. Hire one from each template, add each to its | |
| 26 | − | team, and a workspace's org chart can read like a real company's: | |
| 26 | + | team, and a workspace's teams can read like a real company's: | |
| 27 | 27 | ||
| 28 | 28 | | Team | Hired from the template | As | | |
| 29 | 29 | | --- | --- | --- | | |
| ⋯ | |||
| 312 | 312 | An agent's page in Agents is its only profile. The workspace's | |
| 313 | 313 | [People directory](/guides/people-and-teams/#the-directory) lists people | |
| 314 | 314 | only; agents appear there on the teams they are on, under the team's | |
| 315 | − | people and marked **Agent**, and in the org chart beside the person who | |
| 316 | − | leads their team. | |
| 315 | + | people and marked **Agent**. | |
| 317 | 316 | ||
| 318 | 317 | The Agents sidebar groups specialists by the teams they are on, each team | |
| 319 | 318 | by name: an agent on two teams shows under both, and agents on none show | |
| ⋯ | |||
| 505 | 504 | Every agent, not only g1t, knows the team: each colleague's name, title, | |
| 506 | 505 | the teams it is on, responsibilities and status. It also knows the people and agents on | |
| 507 | 506 | its own [teams](/guides/teams/): who leads each, what each person owns, | |
| 508 | − | who reports to whom, who is around now and their local time, and who to | |
| 507 | + | who is around now and their local time, and who to | |
| 509 | 508 | ask when a person is needed. It is told this every turn; see | |
| 510 | 509 | [what agents are told](/guides/people-and-teams/#what-agents-are-told). | |
| 511 | 510 | When a question belongs to someone else, it makes one of three moves, | |
| 1 | 1 | --- | |
| 2 | 2 | title: People and teams | |
| 3 | − | description: Find anyone in a workspace, see what they own and who they report to, put agents on teams beside them, and know what agents are told about their teams. | |
| 3 | + | description: Find anyone in a workspace, see what they own, put agents on teams beside them, and know what agents are told about their teams. | |
| 4 | 4 | --- | |
| 5 | 5 | ||
| 6 | 6 | **People** is where a workspace's people are listed: who they are, the | |
| 7 | − | teams they are on, what they own and who they report to. Agents are not | |
| 8 | − | in it: each has its page under [Agents](/guides/agents/), and agents join | |
| 7 | + | teams they are on and what they own. Agents are not in it: each has its page under [Agents](/guides/agents/), and agents join | |
| 9 | 8 | [teams](/guides/teams/) beside people. Open it from **People** at the foot | |
| 10 | 9 | of the rail. Its sidebar has: | |
| 11 | 10 | ||
| ⋯ | |||
| 13 | 12 | | --- | --- | --- | | |
| 14 | 13 | | **Everyone** | `g1t.sh/<workspace>/-/people` | The [directory](#the-directory) of the workspace's people. | | |
| 15 | 14 | | **Teams** | `g1t.sh/<workspace>/-/teams` | The workspace's [teams](/guides/teams/). | | |
| 16 | − | | **Org chart** | `g1t.sh/<workspace>/-/org-chart` | Who reports to whom. See [the org chart](#the-org-chart). | | |
| 17 | 15 | ||
| 18 | 16 | Only members of the workspace can open these pages. Who belongs, and | |
| 19 | 17 | inviting people, is the workspace's own business: **Members**, | |
| ⋯ | |||
| 46 | 44 | [Agents](/guides/agents/), and a team's page lists the agents on it under | |
| 47 | 45 | its people (see [agents on a team](/guides/teams/#agents-on-a-team)). | |
| 48 | 46 | ||
| 49 | − | **Org chart** opens [the org chart](#the-org-chart). Owners also see | |
| 50 | − | **Invite people**, which opens the workspace's | |
| 47 | + | Owners see **Invite people**, which opens the workspace's | |
| 51 | 48 | [Members](#members-invitations-and-permissions) page, where **Invite** is. | |
| 52 | 49 | ||
| 53 | 50 | ## A person's profile | |
| ⋯ | |||
| 63 | 60 | | Card | What it shows | | |
| 64 | 61 | | --- | --- | | |
| 65 | 62 | | **Teams** | The teams they are on, with **Lead** or **Maintainer** beside each where it applies. | | |
| 66 | − | | **Reporting line** | Who they report to, and who reports to them. | | |
| 67 | 63 | | **Agents on their teams** | Every agent on the teams they are on. | | |
| 68 | 64 | | **Access** | **Code**: their role on the workspace's repositories, such as *Admin on every repository, as an owner* or *Read on every repository, the workspace's base permission*, then any roles through teams, such as *Roles on 3 repositories through Backend*. **Storage**: coming. **Agents' spend for them this month**, against their budget if they have one, shown to them and to owners. | | |
| 69 | 65 | ||
| ⋯ | |||
| 81 | 77 | | --- | --- | --- | | |
| 82 | 78 | | **Title** | The person, or an owner | Their job here, such as *Staff Engineer*. Up to 80 characters. | | |
| 83 | 79 | | **What they own** | The person, or an owner | One per line: a product, a process, an account. Up to 8, each up to 60 characters. Repeats are left out. | | |
| 84 | − | | **Reports to** | Owners only | Another member of the workspace, or no one. Never themselves, and never someone who already reports to them, directly or through others. | | |
| 85 | 80 | ||
| 86 | 81 | What you set here is shown in the directory and told to the agents on | |
| 87 | 82 | their teams (see [what agents are told](#what-agents-are-told)). Each | |
| 88 | 83 | change is recorded in the workspace's [audit log](/guides/audit-log/) as | |
| 89 | 84 | `member.profile_edited`. | |
| 90 | 85 | ||
| 91 | − | When someone leaves the workspace, anyone who reported to them reports to | |
| 92 | − | no one, and any team they led has no lead. | |
| 86 | + | When someone leaves the workspace, any team they led has no lead. | |
| 93 | 87 | ||
| 94 | 88 | ## Agents | |
| 95 | 89 | ||
| ⋯ | |||
| 99 | 93 | memory, routines, spend and activity, and its **Profile** tab, which has | |
| 100 | 94 | its definition and, under **Teams**, the teams it is on and | |
| 101 | 95 | [what it is told about them](#what-agents-are-told). Wherever People | |
| 102 | − | names an agent, on a team's page, on a person's profile under **Agents | |
| 103 | − | on their teams** or in the org chart, the name opens that page. | |
| 96 | + | names an agent, on a team's page or on a person's profile under | |
| 97 | + | **Agents on their teams**, the name opens that page. | |
| 104 | 98 | ||
| 105 | 99 | Agents still join teams like anyone: see [teams of any mix](#teams-of-any-mix) | |
| 106 | 100 | below. The old address of an agent's People profile, | |
| ⋯ | |||
| 120 | 114 | | Add or remove an agent | The team's **People and agents** tab, or **Teams** on the agent's **Profile** tab in Agents. See [agents on a team](/guides/teams/#agents-on-a-team). | | |
| 121 | 115 | | Set the lead, channel and budget | The team's **Settings**. See [lead, channel and budget](/guides/teams/#lead-channel-and-budget). | | |
| 122 | 116 | | Storage for a team | Coming. | | |
| 123 | − | ||
| 124 | − | ## The org chart | |
| 125 | 117 | ||
| 126 | − | The org chart, `g1t.sh/<workspace>/-/org-chart`, draws the reporting | |
| 127 | − | lines as a tree: each person, with the people who report to them below. | |
| 128 | − | Beside each person are the agents on the teams they lead, as members of | |
| 129 | − | those teams: hover one for its name, title and that it is an agent, and | |
| 130 | − | choose it to open its page in Agents. Agents have no place of their own | |
| 131 | − | in the tree, since no one reports to an agent and an agent reports to no | |
| 132 | − | one. | |
| 133 | − | ||
| 134 | − | Under **Teams no person leads** are the teams led by an agent or by no | |
| 135 | − | one, each with its agents. | |
| 136 | − | ||
| 137 | − | Until someone has a manager, the page says so. Owners set who each person | |
| 138 | − | reports to with **Edit profile** on that person's profile (see | |
| 139 | − | [edit a profile](#edit-a-profile)). | |
| 140 | − | ||
| 141 | 118 | ## What agents are told | |
| 142 | 119 | ||
| 143 | 120 | Every time an agent replies, and at every step of a | |
| ⋯ | |||
| 150 | 127 | 1. The team's description, who leads it, its channel and its shared | |
| 151 | 128 | budget for agents. | |
| 152 | 129 | 2. Each person on it: name and username, title, whether they are the lead | |
| 153 | − | or a maintainer, what they own, who they report to, how they show right | |
| 154 | − | now, and their local time. | |
| 130 | + | or a maintainer, what they own, how they show right now, and their | |
| 131 | + | local time. | |
| 155 | 132 | 3. Each agent on it, with *you* for itself. | |
| 156 | 133 | 4. **When a person is needed**: who to ask. | |
| 157 | 134 | ||
| ⋯ | |||
| 202 | 179 | ||
| 203 | 180 | Payments, invoices and the billing pages. Led by Priya Shah (@priya). Its channel is #billing. Its agents share a budget of $150.00 a month. | |
| 204 | 181 | ||
| 205 | − | - Priya Shah (@priya); Engineering Manager, the lead, a maintainer; owns Stripe, Invoices; reports to @dana (online, 14:05 their time) | |
| 206 | − | - Leo Park (@leo); Staff Engineer; owns Refunds; reports to @priya (focusing, in Do Not Disturb until 16:00 their time, 22:05 their time) | |
| 182 | + | - Priya Shah (@priya); Engineering Manager, the lead, a maintainer; owns Stripe, Invoices (online, 14:05 their time) | |
| 183 | + | - Leo Park (@leo); Staff Engineer; owns Refunds (focusing, in Do Not Disturb until 16:00 their time, 22:05 their time) | |
| 207 | 184 | - @margo: you | |
| 208 | 185 | - @sam (Sam), an agent: Support Specialist | |
| 209 | 186 | ||
| 15 | 15 | Only members of a workspace, and its own agents, can be in its teams. | |
| 16 | 16 | Someone who leaves the workspace leaves all of its teams with it. | |
| 17 | 17 | ||
| 18 | − | For the workspace's directory, profiles and org chart, see | |
| 18 | + | For the workspace's directory and profiles, see | |
| 19 | 19 | [people and teams](/guides/people-and-teams/). | |
| 20 | 20 | ||
| 21 | 21 | ## Create a team | |
| ⋯ | |||
| 182 | 182 | ||
| 183 | 183 | Every agent on a visible team is told about it every time it replies and | |
| 184 | 184 | at every session step: its description, lead, channel and budget, each | |
| 185 | − | person with their title, what they own, who they report to, whether they | |
| 186 | − | are around and their local time, and the team's other agents. The | |
| 185 | + | person with their title, what they own, whether they are around and | |
| 186 | + | their local time, and the team's other agents. The | |
| 187 | 187 | **People and agents** tab shows the team's part of it, exactly as the | |
| 188 | 188 | agents get it, under **What the agents on** *team* **know about it**. | |
| 189 | 189 | ||
| 571 | 571 | | [**Packages**](/guides/packages/) | `/-/packages` | Everyone | What the workspace publishes. A visitor opens it from **Packages** on the workspace's page. | | |
| 572 | 572 | | [**Teams**](/guides/teams/) | `/-/teams` | Members | Groups of members given roles on repositories together, mentioned as `@workspace/team` and asked to review together. Each team has its own page at `/-/teams/<team>`. | | |
| 573 | 573 | | [**People**](/guides/people-and-teams/) | `/-/people` | Members | The workspace's people, with their titles, teams and what they own. Each has a profile at `/-/people/<username>`. Agents are not in it; an agent's page is `/-/agents/<handle>`. | | |
| 574 | − | | [**Org chart**](/guides/people-and-teams/#the-org-chart) | `/-/org-chart` | Members | Who reports to whom, with the agents on the teams each person leads beside them. | | |
| 575 | 574 | | **Members and invites** | `/-/members` | Members | Who belongs. Owners invite, change roles and remove people here. | | |
| 576 | 575 | | **Insights** | `/-/insights` | Members | Coming soon: how the whole workspace delivers. | | |
| 577 | 576 | | **Settings** | `/-/settings` | Owners | How the workspace is set up and connected (below). | | |
| ⋯ | |||
| 637 | 636 | | **Code** | Projects, pull requests and checks. Not shown to a member without [Code access](/guides/agent-access/). | | |
| 638 | 637 | | **Agents** | The workspace's agents and their sessions, and how they are set up. See [agents](/guides/agents/). | | |
| 639 | 638 | | **Artifacts** | Documents, decks and pages. See [artifacts](/guides/artifacts/). | | |
| 640 | − | | **People** | Everyone in the workspace (its people; agents are under Agents); its [teams](/guides/teams/), which agents can be on; and the org chart. See [people and teams](/guides/people-and-teams/). | | |
| 639 | + | | **People** | Everyone in the workspace (its people; agents are under Agents); and its [teams](/guides/teams/), which agents can be on. See [people and teams](/guides/people-and-teams/). | | |
| 641 | 640 | | **Workspace** | The workspace's settings: who has access, money, compute, and how each part of g1t is set up. See [Workspace mode](#workspace-mode). | | |
| 642 | 641 | ||
| 643 | 642 | The app you are in sits on a filled square. Your account is the | |
| 66 | 66 | | [Home](/guides/home/) | The front page: where you're needed now, what happened since you were last here, what is running, and where to start. | <Status is="preview" /> | | |
| 67 | 67 | | [Chat](/guides/chat/) | Channels, direct messages and threads, live. People and agents are members alike. No seats and no history limit. | <Status is="live" /> | | |
| 68 | 68 | | [Notifications](/guides/notifications/) | Everything waiting on you: mentions, reviews, approvals and alerts, with browser push. | <Status is="live" /> | | |
| 69 | − | | [People and teams](/guides/people-and-teams/) | Everyone in the workspace in one search, with profiles, reporting lines, an org chart and teams that include agents beside people. Members, roles, nested teams and invites. | <Status is="live" /> | | |
| 69 | + | | [People and teams](/guides/people-and-teams/) | Everyone in the workspace in one search, with profiles and teams that include agents beside people. Members, roles, nested teams and invites. | <Status is="live" /> | | |
| 70 | 70 | | [Artifacts](/guides/artifacts/) | Documents written together, live, by people and agents. Private until you share them. | <Status is="live" /> | | |
| 71 | 71 | | More kinds of artifact | Slides, designs and dashboards that keep the query behind every number. | <Status is="coming" /> | | |
| 72 | 72 | | Scratchpads | Tools, reports and agents anyone can build on a copy of the data, used in production only after review. | <Status is="coming" /> | | |
| ⋯ | |||
| 208 | 208 | /> | |
| 209 | 209 | <LinkCard | |
| 210 | 210 | title="People and teams" | |
| 211 | − | description="Find anyone, person or agent, see what they own and who they report to, and read what agents are told about their teams." | |
| 211 | + | description="Find anyone, person or agent, see what they own, and read what agents are told about their teams." | |
| 212 | 212 | href="/guides/people-and-teams/" | |
| 213 | 213 | /> | |
| 214 | 214 | <LinkCard | |
| 1356 | 1356 | ||
| 1357 | 1357 | /** | |
| 1358 | 1358 | * People's sidebar: everyone in the workspace (its people; agents are | |
| 1359 | − | * under Agents); its teams, which agents can be on; and the org chart. Who | |
| 1360 | − | * belongs, and inviting people, is the workspace's: Workspace mode's | |
| 1361 | − | * Access group. | |
| 1359 | + | * under Agents) and its teams, which agents can be on. Who belongs, and | |
| 1360 | + | * inviting people, is the workspace's: Workspace mode's Access group. | |
| 1362 | 1361 | */ | |
| 1363 | 1362 | function PeopleSidebar({ slug }: { slug: string }) { | |
| 1364 | 1363 | const { pathname } = useLocation(); | |
| ⋯ | |||
| 1376 | 1375 | <SidebarLink to={`/${slug}/-/teams`} icon={<UsersRound size={15} />} current={at("teams")}> | |
| 1377 | 1376 | Teams | |
| 1378 | 1377 | </SidebarLink> | |
| 1379 | − | <SidebarLink to={`/${slug}/-/org-chart`} icon={<Network size={15} />} current={at("org-chart")}> | |
| 1380 | − | Org chart | |
| 1381 | − | </SidebarLink> | |
| 1382 | 1378 | </NavRows> | |
| 1383 | 1379 | </nav> | |
| 1384 | 1380 | </div> | |
| ⋯ | |||
| 1477 | 1473 | secrets: "Secrets and variables", | |
| 1478 | 1474 | settings: "Settings", | |
| 1479 | 1475 | people: "People", | |
| 1480 | − | "org-chart": "Org chart", | |
| 1481 | 1476 | members: "Members", | |
| 1482 | 1477 | permissions: "Permissions", | |
| 1483 | 1478 | projects: "Projects", | |
| 13 | 13 | budgetFromText, | |
| 14 | 14 | codeAccessWords, | |
| 15 | 15 | directoryEntries, | |
| 16 | − | managerChoices, | |
| 17 | − | orgChart, | |
| 18 | 16 | ownsFromText, | |
| 19 | 17 | teamBlock, | |
| 20 | 18 | teamsOfAgent, | |
| ⋯ | |||
| 31 | 29 | timezone: null, | |
| 32 | 30 | role: "member", | |
| 33 | 31 | title: null, | |
| 34 | − | manager: null, | |
| 35 | 32 | owns: [], | |
| 36 | 33 | joined_at: "2026-10-01T00:00:00Z", | |
| 37 | 34 | ...extra, | |
| ⋯ | |||
| 70 | 67 | const directory: PeopleDirectory = { | |
| 71 | 68 | people: [ | |
| 72 | 69 | person("chase", { name: "Chase Pierce", title: "Founder", role: "owner" }), | |
| 73 | − | person("sofia", { name: "Sofia Reyes", title: "Head of Sales", manager: "chase", owns: ["the forecast"] }), | |
| 74 | − | person("jordan", { name: "Jordan Lee", title: "Account Executive", manager: "sofia", owns: ["Halcyon", "Bluebird"] }), | |
| 75 | − | person("kai", { name: "Kai Nakamura", title: "Product Designer", manager: "chase" }), | |
| 70 | + | person("sofia", { name: "Sofia Reyes", title: "Head of Sales", owns: ["the forecast"] }), | |
| 71 | + | person("jordan", { name: "Jordan Lee", title: "Account Executive", owns: ["Halcyon", "Bluebird"] }), | |
| 72 | + | person("kai", { name: "Kai Nakamura", title: "Product Designer" }), | |
| 76 | 73 | ], | |
| 77 | 74 | teams: [ | |
| 78 | 75 | team("sales", "Sales", { | |
| ⋯ | |||
| 136 | 133 | ||
| 137 | 134 | test("an agent's profile is its page in Agents, where People links to it", () => { | |
| 138 | 135 | assert.equal(agentPath("acme", "margo"), "/acme/-/agents/margo"); | |
| 139 | − | }); | |
| 140 | − | ||
| 141 | − | test("the org chart follows reporting lines, with each team's agents beside its lead", () => { | |
| 142 | − | const chart = orgChart(directory, agents); | |
| 143 | − | assert.deepEqual(chart.roots.map((n) => n.person.username), ["chase"]); | |
| 144 | − | const chase = chart.roots[0]!; | |
| 145 | − | assert.deepEqual(chase.reports.map((n) => n.person.username), ["sofia", "kai"]); | |
| 146 | − | const sofia = chase.reports[0]!; | |
| 147 | − | assert.deepEqual(sofia.agents.map((a) => a.handle), ["david"]); | |
| 148 | − | assert.deepEqual(sofia.reports.map((n) => n.person.username), ["jordan"]); | |
| 149 | − | // A team an agent leads sits apart, with its agents. | |
| 150 | − | assert.deepEqual(chart.unled.map((u) => [u.team.slug, u.agents.map((a) => a.handle)]), [["night-shift", ["atlas", "sentinel", "david"]]]); | |
| 151 | − | }); | |
| 152 | − | ||
| 153 | − | test("no one goes missing from the org chart, even in a loop", () => { | |
| 154 | − | const looped: PeopleDirectory = { ...directory, people: [person("a", { manager: "b" }), person("b", { manager: "a" })] }; | |
| 155 | − | const chart = orgChart(looped, []); | |
| 156 | − | const all = (nodes: typeof chart.roots): string[] => nodes.flatMap((n) => [n.person.username, ...all(n.reports)]); | |
| 157 | − | assert.deepEqual(all(chart.roots).sort(), ["a", "b"]); | |
| 158 | − | }); | |
| 159 | − | ||
| 160 | − | test("someone can report to anyone but themselves and the people under them", () => { | |
| 161 | − | assert.deepEqual(managerChoices(directory.people, "sofia").map((p) => p.username), ["chase", "kai"]); | |
| 162 | − | assert.deepEqual(managerChoices(directory.people, "jordan").map((p) => p.username), ["chase", "sofia", "kai"]); | |
| 163 | 136 | }); | |
| 164 | 137 | ||
| 165 | 138 | test("forms read plainly", () => { | |
| 1 | 1 | /** | |
| 2 | 2 | * People on the site: the directory (people only; agents are on their | |
| 3 | − | * teams and under Agents), profiles, teams of any mix, and the org chart. | |
| 3 | + | * teams and under Agents), profiles, and teams of any mix. | |
| 4 | 4 | * Types only from the contracts, so this file runs under `node --test`. | |
| 5 | 5 | */ | |
| 6 | 6 | import type { DirectoryPerson, DirectoryTeam, PeopleDirectory, TeamLead, WorkspaceAgent } from "@g1t/contracts"; | |
| ⋯ | |||
| 119 | 119 | if (matches(query, fields)) out.push({ person, teams }); | |
| 120 | 120 | } | |
| 121 | 121 | return out; | |
| 122 | − | } | |
| 123 | − | ||
| 124 | − | /** One person in the org chart, with who reports to them and the agents of the teams they lead. */ | |
| 125 | − | export type OrgNode = { person: DirectoryPerson; agents: PeopleAgent[]; leads: DirectoryTeam[]; reports: OrgNode[] }; | |
| 126 | − | ||
| 127 | − | export type OrgChart = { | |
| 128 | − | /** Everyone at the top: no manager, or one who is no longer here. */ | |
| 129 | − | roots: OrgNode[]; | |
| 130 | − | /** Teams no person leads (an agent does, or no one), with their agents. */ | |
| 131 | − | unled: { team: DirectoryTeam; agents: PeopleAgent[] }[]; | |
| 132 | − | }; | |
| 133 | − | ||
| 134 | − | /** | |
| 135 | − | * Reporting lines, with each team's agents beside the person who leads it. | |
| 136 | − | * A loop (which identity refuses) would leave its people out of `roots`; | |
| 137 | − | * they are put back at the top, so no one is ever missing. | |
| 138 | − | */ | |
| 139 | − | export function orgChart(directory: PeopleDirectory, agents: readonly PeopleAgent[]): OrgChart { | |
| 140 | − | const known = new Set(directory.people.map((person) => person.username)); | |
| 141 | − | const byManager = new Map<string, DirectoryPerson[]>(); | |
| 142 | − | for (const person of directory.people) { | |
| 143 | − | if (person.manager && known.has(person.manager) && person.manager !== person.username) { | |
| 144 | − | byManager.set(person.manager, [...(byManager.get(person.manager) ?? []), person]); | |
| 145 | − | } | |
| 146 | − | } | |
| 147 | − | const placed = new Set<string>(); | |
| 148 | − | const node = (person: DirectoryPerson): OrgNode => { | |
| 149 | − | placed.add(person.username); | |
| 150 | − | const led = directory.teams.filter((team) => leads(team.lead, { username: person.username })); | |
| 151 | − | const theirAgents: PeopleAgent[] = []; | |
| 152 | − | for (const team of led) for (const agent of agentsOn(team, agents)) if (!theirAgents.includes(agent)) theirAgents.push(agent); | |
| 153 | − | const reports = (byManager.get(person.username) ?? []).filter((report) => !placed.has(report.username)).map(node); | |
| 154 | − | return { person, agents: theirAgents, leads: led, reports }; | |
| 155 | − | }; | |
| 156 | − | const roots = directory.people.filter((person) => !person.manager || !known.has(person.manager)).map(node); | |
| 157 | − | for (const person of directory.people) if (!placed.has(person.username)) roots.push(node(person)); | |
| 158 | − | const unled = directory.teams | |
| 159 | − | .filter((team) => !team.lead || team.lead.kind === "agent") | |
| 160 | − | .map((team) => ({ team, agents: agentsOn(team, agents) })) | |
| 161 | − | .filter((entry) => entry.agents.length > 0); | |
| 162 | − | return { roots, unled }; | |
| 163 | − | } | |
| 164 | − | ||
| 165 | − | /** Everyone who reports to `username`, directly. */ | |
| 166 | − | export function reportsOf(people: readonly DirectoryPerson[], username: string): DirectoryPerson[] { | |
| 167 | − | return people.filter((person) => person.manager === username && person.username !== username); | |
| 168 | − | } | |
| 169 | − | ||
| 170 | − | /** Who `username` could report to: anyone but themselves and the people under them. */ | |
| 171 | − | export function managerChoices(people: readonly DirectoryPerson[], username: string): DirectoryPerson[] { | |
| 172 | − | const below = new Set<string>([username]); | |
| 173 | − | let grew = true; | |
| 174 | − | while (grew) { | |
| 175 | − | grew = false; | |
| 176 | − | for (const person of people) { | |
| 177 | − | if (person.manager && below.has(person.manager) && !below.has(person.username)) { | |
| 178 | − | below.add(person.username); | |
| 179 | − | grew = true; | |
| 180 | − | } | |
| 181 | − | } | |
| 182 | − | } | |
| 183 | − | return people.filter((person) => !below.has(person.username)); | |
| 184 | 122 | } | |
| 185 | 123 | ||
| 186 | 124 | /** What someone owns, from a form's text: one per line or comma. */ | |
| 106 | 106 | assert.equal(modeOf("/acme/-/people/ana", "acme"), "people"); | |
| 107 | 107 | // An agent's old People address (it redirects to Agents) is still read as People's until it does. | |
| 108 | 108 | assert.equal(modeOf("/acme/-/people/agents/margo", "acme"), "people"); | |
| 109 | − | assert.equal(modeOf("/acme/-/org-chart", "acme"), "people"); | |
| 110 | 109 | // Members, invitations and permissions are the workspace's settings. | |
| 111 | 110 | assert.equal(modeOf("/acme/-/members", "acme"), "workspace"); | |
| 112 | 111 | assert.equal(modeOf("/acme/-/members/invitations", "acme"), "workspace"); |
| 317 | 317 | members: "workspace", | |
| 318 | 318 | permissions: "workspace", | |
| 319 | 319 | teams: "people", | |
| 320 | − | "org-chart": "people", | |
| 321 | 320 | workspace: "workspace", | |
| 322 | 321 | spend: "workspace", | |
| 323 | 322 | usage: "workspace", |
| 100 | 100 | index("routes/workspace/overview.tsx"), | |
| 101 | 101 | // Its pages, each in the sidebar: Overview (above), Projects, People, Insights; Teams and Packages below. | |
| 102 | 102 | route("-/projects", "routes/workspace/projects.tsx"), | |
| 103 | − | // People mode: the directory of people, each one's profile, and the org chart. Agents | |
| 103 | + | // People mode: the directory of people and each one's profile. Agents | |
| 104 | 104 | // are not in it; an agent's old People address redirects to its page in Agents. | |
| 105 | 105 | route("-/people", "routes/workspace/people.tsx"), | |
| 106 | 106 | route("-/people/agents/:handle", "routes/workspace/people/agent.tsx"), | |
| 107 | 107 | route("-/people/:username", "routes/workspace/people/person.tsx"), | |
| 108 | − | route("-/org-chart", "routes/workspace/org-chart.tsx"), | |
| 109 | 108 | // Workspace mode's Access: who is a member and their roles, the invitations sent, and who may do what. | |
| 110 | 109 | route("-/members", "routes/workspace/members.tsx"), | |
| 111 | 110 | route("-/members/invitations", "routes/workspace/member-invitations.tsx"), |
| 67 | 67 | title: "Permissions", | |
| 68 | 68 | about: "Who may do what in the workspace, part by part: repositories, teams, chat, artifacts, agents, deployments and packages. Owners only.", | |
| 69 | 69 | }, | |
| 70 | − | "org-chart": { | |
| 71 | − | title: "Org chart", | |
| 72 | − | about: "Who reports to whom, with each team's agents beside the person who leads it.", | |
| 73 | − | }, | |
| 74 | 70 | projects: { | |
| 75 | 71 | title: "Projects", | |
| 76 | 72 | about: "Everything the workspace builds and runs. Search, filter and sort them, and pin the ones you use most to keep them in your sidebar.", |
| 1 | − | import { Info } from "lucide-react"; | |
| 2 | − | import { Link, data } from "react-router"; | |
| 3 | − | ||
| 4 | − | import type { Route } from "./+types/org-chart"; | |
| 5 | − | import { AgentPersonFace, PersonFace, TeamKindMark } from "../../components/people"; | |
| 6 | − | import { Card } from "../../components/ui/card"; | |
| 7 | − | import { Hint } from "../../components/ui/hint"; | |
| 8 | − | import { page } from "../../lib/meta"; | |
| 9 | − | import { type OrgNode, agentPath, orgChart, peopleAgent, personName, personPath } from "../../lib/people"; | |
| 10 | − | import { teamPath } from "../../lib/teams"; | |
| 11 | − | import { identity, workspaceAgents } from "../../lib/services.server"; | |
| 12 | − | import { getViewer, roleIn, unwrap } from "../../lib/session.server"; | |
| 13 | − | ||
| 14 | − | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 15 | − | return page(args, { title: `Org chart · ${params.owner} · g1t` }); | |
| 16 | − | } | |
| 17 | − | ||
| 18 | − | export async function loader({ params, context }: Route.LoaderArgs) { | |
| 19 | − | const viewer = getViewer(context); | |
| 20 | − | if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 }); | |
| 21 | − | const [directory, agents] = await Promise.all([ | |
| 22 | − | identity.peopleDirectory(viewer, params.owner).then(unwrap), | |
| 23 | − | workspaceAgents.list(params.owner, viewer!).catch(() => null), | |
| 24 | − | ]); | |
| 25 | − | return { slug: params.owner.toLowerCase(), directory, agents: agents?.ok ? agents.value.map(peopleAgent) : [] }; | |
| 26 | − | } | |
| 27 | − | ||
| 28 | − | export default function OrgChartPage({ loaderData }: Route.ComponentProps) { | |
| 29 | − | const { slug, directory, agents } = loaderData; | |
| 30 | − | const chart = orgChart(directory, agents); | |
| 31 | − | const lines = directory.people.some((person) => person.manager); | |
| 32 | − | ||
| 33 | − | return ( | |
| 34 | − | <div className="space-y-6"> | |
| 35 | − | {!lines && ( | |
| 36 | − | <Card asChild radius="lg" className="flex items-start gap-2 px-3 py-2.5 text-sm text-muted"> | |
| 37 | − | <p> | |
| 38 | − | <Info size={15} className="mt-0.5 shrink-0 text-faint" /> | |
| 39 | − | {directory.can_manage | |
| 40 | − | ? "No one reports to anyone yet. Set who each person reports to under Edit profile on their profile." | |
| 41 | − | : "No reporting lines yet. An owner sets who each person reports to on their profile."} | |
| 42 | − | </p> | |
| 43 | − | </Card> | |
| 44 | − | )} | |
| 45 | − | <Card asChild className="overflow-x-auto p-4 sm:p-5"> | |
| 46 | − | <section aria-label="Reporting lines"> | |
| 47 | − | <ul className="space-y-1"> | |
| 48 | − | {chart.roots.map((root) => ( | |
| 49 | − | <Branch key={root.person.username} slug={slug} node={root} /> | |
| 50 | − | ))} | |
| 51 | − | </ul> | |
| 52 | − | <p className="mt-4 text-xs text-faint">The agents on a team appear beside the person who leads it, as its members; each opens the agent's page in Agents.</p> | |
| 53 | − | </section> | |
| 54 | − | </Card> | |
| 55 | − | ||
| 56 | − | {chart.unled.length > 0 && ( | |
| 57 | − | <section aria-labelledby="unled" className="space-y-3"> | |
| 58 | − | <h2 id="unled" className="text-sm font-medium text-muted"> | |
| 59 | − | Teams no person leads | |
| 60 | − | </h2> | |
| 61 | − | <ul className="grid gap-3 sm:grid-cols-2"> | |
| 62 | − | {chart.unled.map(({ team, agents: teamAgents }) => ( | |
| 63 | − | <Card asChild key={team.slug} className="p-4"> | |
| 64 | − | <li> | |
| 65 | − | <div className="flex items-center gap-2"> | |
| 66 | − | <TeamKindMark people={team.people.length} agents={teamAgents.length} /> | |
| 67 | − | <Link to={teamPath(slug, team.slug)} className="font-medium hover:text-accent"> | |
| 68 | − | {team.name} | |
| 69 | − | </Link> | |
| 70 | − | {team.lead?.kind === "agent" && ( | |
| 71 | − | <span className="text-xs text-faint">led by {teamAgents.find((a) => a.id === (team.lead as { agent_id: string }).agent_id)?.display_name ?? "an agent"}</span> | |
| 72 | − | )} | |
| 73 | − | </div> | |
| 74 | − | <div className="mt-3 flex flex-wrap gap-2"> | |
| 75 | − | {teamAgents.map((agent) => ( | |
| 76 | − | <Link key={agent.id} to={agentPath(slug, agent.handle)} className="inline-flex items-center gap-1.5 rounded-full border border-line py-0.5 pr-2.5 pl-0.5 text-xs hover:border-line-strong"> | |
| 77 | − | <AgentPersonFace agent={agent} size={20} ring="var(--color-surface)" /> | |
| 78 | − | {agent.display_name} | |
| 79 | − | </Link> | |
| 80 | − | ))} | |
| 81 | − | </div> | |
| 82 | − | </li> | |
| 83 | − | </Card> | |
| 84 | − | ))} | |
| 85 | − | </ul> | |
| 86 | − | </section> | |
| 87 | − | )} | |
| 88 | − | </div> | |
| 89 | − | ); | |
| 90 | − | } | |
| 91 | − | ||
| 92 | − | /** One person, the agents of the teams they lead, and everyone who reports to them below. */ | |
| 93 | − | function Branch({ slug, node }: { slug: string; node: OrgNode }) { | |
| 94 | − | const { person } = node; | |
| 95 | − | return ( | |
| 96 | − | <li> | |
| 97 | − | <div className="flex flex-wrap items-center gap-2 py-1"> | |
| 98 | − | <Link | |
| 99 | − | to={personPath(slug, person.username)} | |
| 100 | − | prefetch="intent" | |
| 101 | − | className="inline-flex min-w-0 items-center gap-2.5 rounded-lg border border-line bg-bg py-1.5 pr-3 pl-1.5 transition-colors hover:border-accent/60" | |
| 102 | − | > | |
| 103 | − | <PersonFace person={person} size={28} /> | |
| 104 | − | <span className="grid min-w-0 text-left"> | |
| 105 | − | <span className="truncate text-sm font-medium">{personName(person)}</span> | |
| 106 | − | <span className="truncate text-xs text-muted">{person.title ?? `@${person.username}`}</span> | |
| 107 | − | </span> | |
| 108 | − | </Link> | |
| 109 | − | {node.agents.length > 0 && ( | |
| 110 | − | <span className="inline-flex items-center gap-2"> | |
| 111 | − | <span className="flex"> | |
| 112 | − | {node.agents.slice(0, 6).map((agent, index) => ( | |
| 113 | − | <Hint key={agent.id} label={`${agent.display_name} · ${agent.title || agent.role} · agent`}> | |
| 114 | − | <Link to={agentPath(slug, agent.handle)} className={index ? "-ml-1.5" : undefined} aria-label={agent.display_name}> | |
| 115 | − | <AgentPersonFace agent={agent} size={22} ring="var(--color-surface)" /> | |
| 116 | − | </Link> | |
| 117 | − | </Hint> | |
| 118 | − | ))} | |
| 119 | − | </span> | |
| 120 | − | <span className="text-xs text-faint"> | |
| 121 | − | {node.agents.length > 6 ? `+${node.agents.length - 6} · ` : ""} | |
| 122 | − | {node.leads.map((team) => team.name).join(", ")} | |
| 123 | − | </span> | |
| 124 | − | </span> | |
| 125 | − | )} | |
| 126 | − | </div> | |
| 127 | − | {node.reports.length > 0 && ( | |
| 128 | − | <ul className="ml-[17px] space-y-1 border-l border-dashed border-line-strong pl-5 sm:pl-7"> | |
| 129 | − | {node.reports.map((report) => ( | |
| 130 | − | <Branch key={report.person.username} slug={slug} node={report} /> | |
| 131 | − | ))} | |
| 132 | − | </ul> | |
| 133 | − | )} | |
| 134 | − | </li> | |
| 135 | − | ); | |
| 136 | − | } |
| 1 | − | import { Bot, Network, Search, UserPlus } from "lucide-react"; | |
| 1 | + | import { Bot, Search, UserPlus } from "lucide-react"; | |
| 2 | 2 | import { Link, data, useSearchParams, type ShouldRevalidateFunctionArgs } from "react-router"; | |
| 3 | 3 | ||
| 4 | 4 | import type { Route } from "./+types/people"; | |
| ⋯ | |||
| 70 | 70 | <span role="status" className="mr-1 text-sm text-muted tabular-nums"> | |
| 71 | 71 | {query ? `${entries.length} of ${total}` : total} {total === 1 && !query ? "person" : "people"} | |
| 72 | 72 | </span> | |
| 73 | − | <ButtonLink to={`/${slug}/-/org-chart`} variant="outline"> | |
| 74 | − | <Network size={15} /> | |
| 75 | − | Org chart | |
| 76 | − | </ButtonLink> | |
| 77 | 73 | {directory.can_manage && ( | |
| 78 | 74 | <ButtonLink to={`/${slug}/-/members`}> | |
| 79 | 75 | <UserPlus size={15} /> | |
| 1 | − | import { Bot, ChevronRight, KeyRound, MapPin, MessageSquare, Network, Pencil, UsersRound, Wallet } from "lucide-react"; | |
| 1 | + | import { Bot, ChevronRight, KeyRound, MapPin, MessageSquare, Pencil, UsersRound, Wallet } from "lucide-react"; | |
| 2 | 2 | import { type ReactNode, useEffect, useState } from "react"; | |
| 3 | 3 | import { Form, Link, data, redirect, useNavigation } from "react-router"; | |
| 4 | 4 | ||
| 5 | 5 | import type { DirectoryPerson } from "@g1t/contracts"; | |
| 6 | 6 | ||
| 7 | 7 | import type { Route } from "./+types/person"; | |
| 8 | − | import { AgentLink, AgentPersonFace, Coming, LocalTime, PersonFace, PersonLink, TeamKindMark } from "../../../components/people"; | |
| 8 | + | import { AgentLink, AgentPersonFace, Coming, LocalTime, PersonFace, TeamKindMark } from "../../../components/people"; | |
| 9 | 9 | import { PresenceSummary } from "../../../components/presence"; | |
| 10 | 10 | import { ErrorText, Field, Input, SubmitButton, Textarea } from "../../../components/ui"; | |
| 11 | 11 | import { Badge } from "../../../components/ui/badge"; | |
| 12 | 12 | import { Button } from "../../../components/ui/button"; | |
| 13 | 13 | import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../../../components/ui/dialog"; | |
| 14 | − | import { SelectField } from "../../../components/ui/select"; | |
| 15 | 14 | import { channelPath } from "../../../lib/chat"; | |
| 16 | 15 | import { page } from "../../../lib/meta"; | |
| 17 | 16 | import { | |
| 18 | 17 | agentsOn, | |
| 19 | 18 | codeAccessWords, | |
| 20 | 19 | leads, | |
| 21 | − | managerChoices, | |
| 22 | 20 | ownsFromText, | |
| 23 | 21 | peopleAgent, | |
| 24 | 22 | personName, | |
| 25 | − | reportsOf, | |
| 26 | 23 | teamsOfPerson, | |
| 27 | 24 | } from "../../../lib/people"; | |
| 28 | 25 | import { teamPath } from "../../../lib/teams"; | |
| ⋯ | |||
| 35 | 32 | return page(args, { title: `${name} · People · ${params.owner} · g1t` }); | |
| 36 | 33 | } | |
| 37 | 34 | ||
| 38 | − | /** The few fields of everyone else a profile shows. */ | |
| 39 | − | function slim(person: DirectoryPerson) { | |
| 40 | − | const { user_id, username, display_username, name, avatar, title, manager } = person; | |
| 41 | − | return { user_id, username, display_username, name, avatar, title, manager }; | |
| 42 | − | } | |
| 43 | − | ||
| 44 | 35 | export async function loader({ params, context }: Route.LoaderArgs) { | |
| 45 | 36 | const viewer = getViewer(context); | |
| 46 | 37 | if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 }); | |
| ⋯ | |||
| 60 | 51 | return { | |
| 61 | 52 | slug: params.owner.toLowerCase(), | |
| 62 | 53 | person, | |
| 63 | − | people: directory.people.map(slim), | |
| 64 | 54 | teams, | |
| 65 | 55 | agents: all, | |
| 66 | 56 | base: directory.base_permission, | |
| ⋯ | |||
| 84 | 74 | throw redirect(channelPath(params.owner.toLowerCase(), opened.value)); | |
| 85 | 75 | } | |
| 86 | 76 | if (intent === "profile") { | |
| 87 | − | const changes: { title?: string; owns?: string[]; manager?: string } = { | |
| 77 | + | const changes = { | |
| 88 | 78 | title: String(form.get("title") ?? ""), | |
| 89 | 79 | owns: ownsFromText(String(form.get("owns") ?? "")), | |
| 90 | 80 | }; | |
| 91 | − | if (form.has("manager")) { | |
| 92 | − | const manager = String(form.get("manager") ?? ""); | |
| 93 | − | changes.manager = manager === "none" ? "" : manager; | |
| 94 | − | } | |
| 95 | 81 | const saved = await identity.setMemberProfile(user, params.owner, params.username, changes); | |
| 96 | 82 | return saved.ok ? { intent, saved: true as const, error: null } : { intent, error: saved.error.message }; | |
| 97 | 83 | } | |
| ⋯ | |||
| 99 | 85 | } | |
| 100 | 86 | ||
| 101 | 87 | export default function PersonProfile({ loaderData, actionData }: Route.ComponentProps) { | |
| 102 | − | const { slug, person, people, teams, agents, base, canManage, self, spend } = loaderData; | |
| 103 | − | const manager = person.manager ? people.find((p) => p.username === person.manager) : null; | |
| 104 | − | const reports = reportsOf(people as DirectoryPerson[], person.username); | |
| 88 | + | const { slug, person, teams, agents, base, canManage, self, spend } = loaderData; | |
| 105 | 89 | const teamAgents = teams.flatMap((team) => agentsOn(team, agents).map((agent) => ({ agent, team }))); | |
| 106 | 90 | const seen = new Set<string>(); | |
| 107 | 91 | const uniqueAgents = teamAgents.filter(({ agent }) => (seen.has(agent.id) ? false : (seen.add(agent.id), true))); | |
| ⋯ | |||
| 154 | 138 | </SubmitButton> | |
| 155 | 139 | </Form> | |
| 156 | 140 | )} | |
| 157 | − | {mayEdit && <EditProfile person={person} people={people as DirectoryPerson[]} canManage={canManage} actionData={actionData} />} | |
| 141 | + | {mayEdit && <EditProfile person={person} actionData={actionData} />} | |
| 158 | 142 | </div> | |
| 159 | 143 | </header> | |
| 160 | 144 | {actionData?.intent === "message" && <ErrorText>{actionData.error}</ErrorText>} | |
| ⋯ | |||
| 198 | 182 | )} | |
| 199 | 183 | </Card> | |
| 200 | 184 | ||
| 201 | − | <Card icon={<Network size={14} />} title="Reporting line"> | |
| 202 | − | <dl className="space-y-3 text-sm"> | |
| 203 | − | <div> | |
| 204 | − | <dt className="text-xs text-faint">Reports to</dt> | |
| 205 | − | <dd className="mt-1">{manager ? <PersonRow slug={slug} person={manager} /> : <Quiet>No one{canManage ? ". Set it under Edit profile." : "."}</Quiet>}</dd> | |
| 206 | − | </div> | |
| 207 | − | <div> | |
| 208 | − | <dt className="text-xs text-faint"> | |
| 209 | − | Reports <span className="tabular-nums">{reports.length}</span> | |
| 210 | − | </dt> | |
| 211 | − | <dd className="mt-1 space-y-1.5"> | |
| 212 | − | {reports.length ? reports.map((report) => <PersonRow key={report.username} slug={slug} person={report} />) : <Quiet>No one reports to them.</Quiet>} | |
| 213 | − | </dd> | |
| 214 | − | </div> | |
| 215 | − | </dl> | |
| 216 | − | </Card> | |
| 217 | − | ||
| 218 | 185 | <Card icon={<Bot size={14} />} title="Agents on their teams"> | |
| 219 | 186 | {uniqueAgents.length ? ( | |
| 220 | 187 | <ul className="space-y-2"> | |
| ⋯ | |||
| 285 | 252 | return <p className="text-sm text-faint">{children}</p>; | |
| 286 | 253 | } | |
| 287 | 254 | ||
| 288 | − | function PersonRow({ slug, person }: { slug: string; person: Pick<DirectoryPerson, "user_id" | "username" | "display_username" | "name" | "avatar" | "title"> }) { | |
| 289 | − | return ( | |
| 290 | − | <span className="flex min-w-0 items-center gap-2.5"> | |
| 291 | − | <PersonFace person={person} size={24} ring="var(--color-surface)" /> | |
| 292 | − | <span className="min-w-0 truncate"> | |
| 293 | − | <PersonLink workspace={slug} person={person} className="font-medium" /> | |
| 294 | − | {person.title && <span className="text-muted"> · {person.title}</span>} | |
| 295 | − | </span> | |
| 296 | − | </span> | |
| 297 | − | ); | |
| 298 | − | } | |
| 299 | − | ||
| 300 | − | /** Their title and what they own (theirs, or an owner's to change), and who they report to (an owner's). */ | |
| 255 | + | /** Their title and what they own: theirs, or an owner's, to change. */ | |
| 301 | 256 | function EditProfile({ | |
| 302 | 257 | person, | |
| 303 | − | people, | |
| 304 | − | canManage, | |
| 305 | 258 | actionData, | |
| 306 | 259 | }: { | |
| 307 | 260 | person: DirectoryPerson; | |
| 308 | − | people: DirectoryPerson[]; | |
| 309 | − | canManage: boolean; | |
| 310 | 261 | actionData: { intent: string; error: string | null; saved?: true } | undefined; | |
| 311 | 262 | }) { | |
| 312 | 263 | const [open, setOpen] = useState(false); | |
| ⋯ | |||
| 314 | 265 | useEffect(() => { | |
| 315 | 266 | if (saved) setOpen(false); | |
| 316 | 267 | }, [saved, actionData]); | |
| 317 | − | const choices = managerChoices(people, person.username); | |
| 318 | 268 | return ( | |
| 319 | 269 | <> | |
| 320 | 270 | <Button variant="outline" onClick={() => setOpen(true)}> | |
| ⋯ | |||
| 335 | 285 | <Field label="What they own" hint="One per line: a product, a process, an account. Agents ask them about these."> | |
| 336 | 286 | <Textarea name="owns" defaultValue={person.owns.join("\n")} rows={3} placeholder={"storefront\nthe release process"} /> | |
| 337 | 287 | </Field> | |
| 338 | − | {canManage && ( | |
| 339 | − | <label className="block"> | |
| 340 | − | <span className="mb-1.5 block text-sm font-medium text-muted">Reports to</span> | |
| 341 | − | <SelectField | |
| 342 | − | name="manager" | |
| 343 | − | defaultValue={person.manager ?? "none"} | |
| 344 | − | className="w-full" | |
| 345 | − | options={[{ value: "none", label: "No one" }, ...choices.map((p) => ({ value: p.username, label: `${personName(p)}${p.title ? ` · ${p.title}` : ""}` }))]} | |
| 346 | − | /> | |
| 347 | − | </label> | |
| 348 | − | )} | |
| 349 | 288 | {actionData?.intent === "profile" && <ErrorText>{actionData.error}</ErrorText>} | |
| 350 | 289 | <DialogFooter> | |
| 351 | 290 | <Button variant="outline" type="button" onClick={() => setOpen(false)}> | |
| 450 | 450 | as a member that owners and the team's maintainers add and remove; nothing | |
| 451 | 451 | on the agent names a team. Every reply and session step, an agent is | |
| 452 | 452 | told each visible team it is on: who leads it, each person's title, what | |
| 453 | − | they own, who they report to, whether they are around and their local | |
| 454 | − | time, and who to ask when a person is needed. Ask the person who owns | |
| 455 | − | something before guessing. The directory of people is | |
| 456 | − | `g1t.sh/<owner>/-/people` (people only; a team's page lists the agents on | |
| 457 | − | it), profiles `-/people/<username>`, the org chart `-/org-chart`; an | |
| 458 | − | agent's page is `-/agents/<handle>`. Guide: | |
| 453 | + | they own, whether they are around and their local time, and who to ask | |
| 454 | + | when a person is needed. Ask the person who owns something before | |
| 455 | + | guessing. The directory of people is `g1t.sh/<owner>/-/people` (people | |
| 456 | + | only; a team's page lists the agents on it), profiles | |
| 457 | + | `-/people/<username>`; an agent's page is `-/agents/<handle>`. Guide: | |
| 459 | 458 | https://docs.g1t.sh/guides/people-and-teams/ | |
| 460 | 459 | ||
| 461 | 460 | Anyone creates an agent by describing it at `g1t.sh/<owner>/-/agents/new`: |
| 1 | 1 | //! People: a workspace's directory of everyone in it, people and agents, | |
| 2 | − | //! and what each person's place is: a title, who they report to, and what | |
| 3 | − | //! they own. Kept by identity beside membership; agents come from the | |
| 2 | + | //! and what each person's place is: a title and what they own. Kept by | |
| 3 | + | //! identity beside membership; agents come from the | |
| 4 | 4 | //! agents service, which identity names only by id. | |
| 5 | 5 | //! | |
| 6 | 6 | //! **Profiles.** A member's title and what they own are theirs to write, | |
| 7 | − | //! and the workspace's owners can write anyone's. Who someone reports to | |
| 8 | − | //! (their manager) is set by owners: always another member, never | |
| 9 | − | //! themselves, and never a loop. | |
| 7 | + | //! and the workspace's owners can write anyone's. | |
| 10 | 8 | //! | |
| 11 | 9 | //! **Agents on teams.** A team can have agents as well as people: mixed, | |
| 12 | 10 | //! people only or agents only. An agent is on a team when it was added to | |
| ⋯ | |||
| 27 | 25 | pub const MAX_OWNS: usize = 8; | |
| 28 | 26 | /// The longest of them. | |
| 29 | 27 | pub const MAX_OWNS_LENGTH: usize = 60; | |
| 30 | − | /// How far up a reporting line is followed, looking for a loop. | |
| 31 | − | pub const MAX_CHAIN: usize = 64; | |
| 32 | 28 | ||
| 33 | − | /// One member, as the directory, their profile and the org chart show them. | |
| 29 | + | /// One member, as the directory and their profile show them. | |
| 34 | 30 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 35 | 31 | pub struct DirectoryPerson { | |
| 36 | 32 | pub user_id: String, | |
| ⋯ | |||
| 50 | 46 | pub role: Role, | |
| 51 | 47 | /// Their title in this workspace. | |
| 52 | 48 | pub title: Option<String>, | |
| 53 | − | /// Who they report to, by username. | |
| 54 | − | pub manager: Option<String>, | |
| 55 | 49 | /// What they own: a few short phrases. | |
| 56 | 50 | pub owns: Vec<String>, | |
| 57 | 51 | /// When they joined the workspace (RFC 3339). | |
| ⋯ | |||
| 65 | 59 | pub role: TeamRole, | |
| 66 | 60 | } | |
| 67 | 61 | ||
| 68 | − | /// A team as the directory and the org chart need it: who is on it, by | |
| 69 | − | /// username and agent id, and who leads it. | |
| 62 | + | /// A team as the directory needs it: who is on it, by username and agent | |
| 63 | + | /// id, and who leads it. | |
| 70 | 64 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 71 | 65 | pub struct DirectoryTeam { | |
| 72 | 66 | pub slug: String, | |
| ⋯ | |||
| 102 | 96 | /// What members get on every repository: `none`, `read`, `write` or `admin`. | |
| 103 | 97 | pub base_permission: String, | |
| 104 | 98 | /// Whether the viewer owns the workspace: they can set anyone's | |
| 105 | − | /// title, manager and what they own. | |
| 99 | + | /// title and what they own. | |
| 106 | 100 | pub can_manage: bool, | |
| 107 | 101 | } | |
| 108 | 102 | ||
| 109 | − | /// `set_member_profile`: what is given changes; the rest stays. `manager` | |
| 110 | − | /// set to an empty string clears it. Returns `Outcome<DirectoryPerson>`. | |
| 103 | + | /// `set_member_profile`: what is given changes; the rest stays. Returns | |
| 104 | + | /// `Outcome<DirectoryPerson>`. | |
| 111 | 105 | #[derive(Debug, Default, Serialize, Deserialize)] | |
| 112 | 106 | pub struct SetMemberProfileArgs { | |
| 113 | 107 | pub actor: User, | |
| ⋯ | |||
| 115 | 109 | pub username: String, | |
| 116 | 110 | #[serde(default)] | |
| 117 | 111 | pub title: Option<String>, | |
| 118 | − | /// A member's username, or an empty string for none. Owners only. | |
| 119 | 112 | #[serde(default)] | |
| 120 | − | pub manager: Option<String>, | |
| 121 | − | #[serde(default)] | |
| 122 | 113 | pub owns: Option<Vec<String>>, | |
| 123 | 114 | #[serde(default)] | |
| 124 | 115 | pub surface: Option<crate::audit::Surface>, | |
| ⋯ | |||
| 151 | 142 | out | |
| 152 | 143 | } | |
| 153 | 144 | ||
| 154 | − | /// What may change on someone's profile: their title and what they own | |
| 155 | − | /// by themselves or an owner; their manager by an owner. | |
| 145 | + | /// What may change on someone's profile: their title and what they own, | |
| 146 | + | /// by themselves or an owner. | |
| 156 | 147 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 157 | 148 | pub enum ProfileField { | |
| 158 | 149 | Title, | |
| 159 | 150 | Owns, | |
| 160 | − | Manager, | |
| 161 | 151 | } | |
| 162 | 152 | ||
| 163 | 153 | /// Whether `actor` may change `field` on someone's profile. | |
| 164 | 154 | pub fn may_edit_profile(field: ProfileField, is_self: bool, owner: bool) -> bool { | |
| 165 | 155 | match field { | |
| 166 | 156 | ProfileField::Title | ProfileField::Owns => is_self || owner, | |
| 167 | − | ProfileField::Manager => owner, | |
| 168 | 157 | } | |
| 169 | 158 | } | |
| 170 | 159 | ||
| 171 | − | /// Whether making `manager` the manager of `person` would make a loop. | |
| 172 | − | /// `manager_of` gives each person's current manager. | |
| 173 | − | pub fn makes_loop(person: &str, manager: &str, manager_of: impl Fn(&str) -> Option<String>) -> bool { | |
| 174 | − | if person == manager { | |
| 175 | − | return true; | |
| 176 | − | } | |
| 177 | − | let mut at = manager.to_owned(); | |
| 178 | − | for _ in 0..MAX_CHAIN { | |
| 179 | − | match manager_of(&at) { | |
| 180 | − | Some(next) if next == person => return true, | |
| 181 | − | Some(next) => at = next, | |
| 182 | − | None => return false, | |
| 183 | − | } | |
| 184 | − | } | |
| 185 | − | // A chain this long is treated as a loop rather than followed further. | |
| 186 | − | true | |
| 187 | − | } | |
| 188 | − | ||
| 189 | 160 | #[cfg(test)] | |
| 190 | 161 | mod tests { | |
| 191 | 162 | use super::*; | |
| 192 | − | use std::collections::HashMap; | |
| 193 | 163 | ||
| 194 | 164 | #[test] | |
| 195 | 165 | fn titles_are_tidied() { | |
| ⋯ | |||
| 216 | 186 | } | |
| 217 | 187 | ||
| 218 | 188 | #[test] | |
| 219 | − | fn people_write_their_own_and_owners_set_managers() { | |
| 189 | + | fn people_write_their_own_and_owners_anyones() { | |
| 220 | 190 | assert!(may_edit_profile(ProfileField::Title, true, false)); | |
| 221 | 191 | assert!(may_edit_profile(ProfileField::Owns, false, true)); | |
| 222 | 192 | assert!(!may_edit_profile(ProfileField::Owns, false, false)); | |
| 223 | − | assert!(!may_edit_profile(ProfileField::Manager, true, false)); | |
| 224 | − | assert!(may_edit_profile(ProfileField::Manager, false, true)); | |
| 225 | − | } | |
| 226 | − | ||
| 227 | − | #[test] | |
| 228 | − | fn reporting_lines_never_loop() { | |
| 229 | − | // ana -> priya -> chase | |
| 230 | − | let managers: HashMap<&str, &str> = HashMap::from([("ana", "priya"), ("priya", "chase")]); | |
| 231 | − | let of = |id: &str| managers.get(id).map(|m| (*m).to_owned()); | |
| 232 | − | assert!(makes_loop("chase", "ana", of)); | |
| 233 | − | assert!(makes_loop("priya", "priya", of)); | |
| 234 | − | assert!(!makes_loop("ana", "chase", of)); | |
| 235 | − | assert!(!makes_loop("dev", "priya", of)); | |
| 236 | 193 | } | |
| 237 | 194 | } | |
| 648 | 648 | pub title: Option<String>, | |
| 649 | 649 | pub timezone: Option<String>, | |
| 650 | 650 | pub owns: Vec<String>, | |
| 651 | − | /// Who they report to, by username. | |
| 652 | − | pub manager: Option<String>, | |
| 653 | 651 | pub maintainer: bool, | |
| 654 | 652 | } | |
| 655 | 653 |
| 1 | 1 | /** | |
| 2 | 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, | |
| 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, | |
| 6 | 5 | * which teams name by id. | |
| 7 | 6 | */ | |
| 8 | 7 | import type { User } from "./identity"; | |
| ⋯ | |||
| 15 | 14 | export const MAX_OWNS = 8; | |
| 16 | 15 | export const MAX_OWNS_LENGTH = 60; | |
| 17 | 16 | ||
| 18 | − | /** One member, as the directory, their profile and the org chart show them. */ | |
| 17 | + | /** One member, as the directory and their profile show them. */ | |
| 19 | 18 | export type DirectoryPerson = { | |
| 20 | 19 | user_id: string; | |
| 21 | 20 | username: string; | |
| ⋯ | |||
| 30 | 29 | role: "owner" | "member"; | |
| 31 | 30 | /** Their title in this workspace. */ | |
| 32 | 31 | title: string | null; | |
| 33 | − | /** Who they report to, by username. */ | |
| 34 | − | manager: string | null; | |
| 35 | 32 | /** What they own: a few short phrases. */ | |
| 36 | 33 | owns: string[]; | |
| 37 | 34 | joined_at: string; | |
| ⋯ | |||
| 39 | 36 | ||
| 40 | 37 | export type TeamPersonRef = { username: string; role: TeamRole }; | |
| 41 | 38 | ||
| 42 | − | /** A team as the directory and the org chart need it. */ | |
| 39 | + | /** A team as the directory needs it. */ | |
| 43 | 40 | export type DirectoryTeam = { | |
| 44 | 41 | slug: string; | |
| 45 | 42 | name: string; | |
| ⋯ | |||
| 61 | 58 | teams: DirectoryTeam[]; | |
| 62 | 59 | /** What members get on every repository. */ | |
| 63 | 60 | base_permission: "none" | "read" | "write" | "admin"; | |
| 64 | − | /** Whether the viewer owns the workspace: they set anyone's title, manager and what they own. */ | |
| 61 | + | /** Whether the viewer owns the workspace: they set anyone's title and what they own. */ | |
| 65 | 62 | can_manage: boolean; | |
| 66 | 63 | }; | |
| 67 | 64 | ||
| 68 | − | /** What changes on a member's profile; the rest stays. `manager: ""` clears it (owners only). */ | |
| 65 | + | /** What changes on a member's profile; the rest stays. */ | |
| 69 | 66 | export type MemberProfileChanges = { | |
| 70 | 67 | title?: string; | |
| 71 | − | manager?: string; | |
| 72 | 68 | owns?: string[]; | |
| 73 | 69 | }; | |
| 74 | 70 | ||
| 75 | 71 | export interface PeopleClient { | |
| 76 | 72 | /** Everyone in a workspace and its teams, as the viewer may see them. Members only. */ | |
| 77 | 73 | 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). */ | |
| 74 | + | /** A member's title and what they own: themselves, or an owner. */ | |
| 79 | 75 | setMemberProfile(actor: User, workspace: string, username: string, changes: MemberProfileChanges): Promise<Result<DirectoryPerson>>; | |
| 80 | 76 | } | |
| 81 | 77 | ||
| 95 | 95 | /** An IANA time zone, for their local time. */ | |
| 96 | 96 | timezone: string | null; | |
| 97 | 97 | owns: string[]; | |
| 98 | − | /** Who they report to, by username. */ | |
| 99 | − | manager: string | null; | |
| 100 | 98 | maintainer: boolean; | |
| 101 | 99 | }; | |
| 102 | 100 |
| 14 | 14 | title: null, | |
| 15 | 15 | timezone: null, | |
| 16 | 16 | owns: [], | |
| 17 | − | manager: null, | |
| 18 | 17 | maintainer: false, | |
| 19 | 18 | ...extra, | |
| 20 | 19 | }); | |
| ⋯ | |||
| 37 | 36 | channel: { id: "chn_1", name: "sales" }, | |
| 38 | 37 | budget_micros: 150_000_000, | |
| 39 | 38 | people: [ | |
| 40 | − | person("sofia", { name: "Sofia Reyes", title: "Head of Sales", owns: ["the forecast"], manager: "chase", maintainer: true, timezone: "America/Chicago" }), | |
| 41 | − | person("jordan", { name: "Jordan Lee", title: "Account Executive", owns: ["Halcyon", "Bluebird"], manager: "sofia" }), | |
| 39 | + | person("sofia", { name: "Sofia Reyes", title: "Head of Sales", owns: ["the forecast"], maintainer: true, timezone: "America/Chicago" }), | |
| 40 | + | person("jordan", { name: "Jordan Lee", title: "Account Executive", owns: ["Halcyon", "Bluebird"] }), | |
| 42 | 41 | ], | |
| 43 | 42 | agent_ids: ["agt_david"], | |
| 44 | 43 | }; | |
| ⋯ | |||
| 66 | 65 | assert.match(text, /Led by Sofia Reyes \(@sofia\)\./); | |
| 67 | 66 | assert.match(text, /Its channel is #sales\./); | |
| 68 | 67 | assert.match(text, /share a budget of \$150\.00 a month/); | |
| 69 | − | assert.match(text, /Sofia Reyes \(@sofia\); Head of Sales, the lead, a maintainer; owns the forecast; reports to @chase \(focusing in Do Not Disturb until 23:12 their time; it's 20:12 for them\)/); | |
| 70 | − | assert.match(text, /Jordan Lee \(@jordan\); Account Executive; owns Halcyon, Bluebird; reports to @sofia \(online\)/); | |
| 68 | + | assert.match(text, /Sofia Reyes \(@sofia\); Head of Sales, the lead, a maintainer; owns the forecast \(focusing in Do Not Disturb until 23:12 their time; it's 20:12 for them\)/); | |
| 69 | + | assert.match(text, /Jordan Lee \(@jordan\); Account Executive; owns Halcyon, Bluebird \(online\)/); | |
| 71 | 70 | assert.match(text, /- @david: you/); | |
| 72 | 71 | // Sofia is focusing, so Jordan is the one to page. | |
| 73 | 72 | assert.match(text, /When a person is needed: Jordan Lee \(@jordan\), who is online now\./); | |
| 2 | 2 | * Agents know their teammates (docs.g1t.sh/guides/people-and-teams/, | |
| 3 | 3 | * "What agents are told"): every turn, an agent is told the visible teams | |
| 4 | 4 | * it is on, from the team pages: who leads each, who is on it and what | |
| 5 | − | * they own, who reports to whom, who is around now, and who to page when | |
| 6 | − | * a person is needed. It also carries the teams' budgets, which cap what | |
| 5 | + | * they own, who is around now, and who to page when a person is needed. It also carries the teams' budgets, which cap what | |
| 7 | 6 | * the team's agents spend together in a month. | |
| 8 | 7 | * | |
| 9 | 8 | * `teamsSection` and `teamBudgetBlock` are pure, so they are tested on | |
| ⋯ | |||
| 112 | 111 | const role = [person.title, team.lead?.kind === "user" && team.lead.username === person.username ? "the lead" : null, person.maintainer ? "a maintainer" : null].filter(Boolean); | |
| 113 | 112 | if (role.length) bits.push(role.join(", ")); | |
| 114 | 113 | if (person.owns.length) bits.push(`owns ${person.owns.join(", ")}`); | |
| 115 | − | if (person.manager) bits.push(`reports to @${person.manager}`); | |
| 116 | 114 | const shows = showing(presence.get(person.user_id), person.timezone, now, known); | |
| 117 | 115 | const time = localTime(person.timezone, now); | |
| 118 | 116 | const now_ = [shows, time ? `it's ${time} for them` : null].filter(Boolean).join("; "); | |
| 292 | 292 | WHERE team_id IN (SELECT id FROM teams WHERE workspace_id = ?) AND user_id = ?", | |
| 293 | 293 | ) | |
| 294 | 294 | .bind(&[workspace_id.into(), user_id.into()])?, | |
| 295 | − | // No one reports to them here any more, and they lead no team. | |
| 296 | − | self.db | |
| 297 | − | .prepare("UPDATE workspace_members SET manager_id = NULL WHERE workspace_id = ? AND manager_id = ?") | |
| 298 | − | .bind(&[workspace_id.into(), user_id.into()])?, | |
| 295 | + | // They lead no team here any more. | |
| 299 | 296 | self.db | |
| 300 | 297 | .prepare("UPDATE teams SET lead_kind = NULL, lead_id = NULL WHERE workspace_id = ? AND lead_kind = 'user' AND lead_id = ?") | |
| 301 | 298 | .bind(&[workspace_id.into(), user_id.into()])?, |
| 1 | 1 | //! A workspace's people: the directory of its members and teams, and each | |
| 2 | − | //! member's place in it (a title, who they report to, what they own). See | |
| 2 | + | //! member's place in it (a title and what they own). See | |
| 3 | 3 | //! `g1t_contracts::people`. Agents live in the agents service; teams name | |
| 4 | 4 | //! them by id. | |
| 5 | − | ||
| 6 | − | use std::collections::HashMap; | |
| 7 | 5 | ||
| 8 | 6 | use g1t_contracts::audit::Surface; | |
| 9 | 7 | use g1t_contracts::people::*; | |
| ⋯ | |||
| 51 | 49 | title: Option<String>, | |
| 52 | 50 | #[serde(default)] | |
| 53 | 51 | owns: Option<String>, | |
| 54 | − | #[serde(default)] | |
| 55 | − | manager: Option<String>, | |
| 56 | 52 | joined_at: String, | |
| 57 | 53 | } | |
| 58 | 54 | ||
| ⋯ | |||
| 71 | 67 | timezone: blank(self.timezone), | |
| 72 | 68 | role: if self.role == "owner" { Role::Owner } else { Role::Member }, | |
| 73 | 69 | title: self.title.as_deref().and_then(clean_title), | |
| 74 | − | manager: self.manager, | |
| 75 | 70 | owns: owns_from(self.owns.as_deref()), | |
| 76 | 71 | joined_at: self.joined_at, | |
| 77 | 72 | } | |
| ⋯ | |||
| 82 | 77 | fn people_sql(filter: &str) -> String { | |
| 83 | 78 | format!( | |
| 84 | 79 | "SELECT u.id AS user_id, u.username, u.display_username, u.display_name AS name, u.avatar, u.bio, u.location, | |
| 85 | − | u.pronouns, u.timezone, wm.role, wm.title, wm.owns, mu.username AS manager, wm.created_at AS joined_at | |
| 80 | + | u.pronouns, u.timezone, wm.role, wm.title, wm.owns, wm.created_at AS joined_at | |
| 86 | 81 | FROM workspace_members wm | |
| 87 | 82 | JOIN users u ON u.id = wm.user_id AND u.deleted_at IS NULL | |
| 88 | 83 | JOIN workspaces w ON w.id = wm.workspace_id AND w.deleted_at IS NULL | |
| 89 | − | LEFT JOIN users mu ON mu.id = wm.manager_id AND mu.deleted_at IS NULL | |
| 90 | 84 | WHERE w.slug = ?1 {filter} | |
| 91 | 85 | ORDER BY lower(coalesce(u.display_name, u.username)) LIMIT 5000" | |
| 92 | 86 | ) | |
| ⋯ | |||
| 249 | 243 | sets.push(format!("owns = ?{}", binds.len())); | |
| 250 | 244 | changed.push("what they own"); | |
| 251 | 245 | } | |
| 252 | − | if let Some(manager) = a.manager.as_deref().map(str::trim) { | |
| 253 | − | if !may_edit_profile(ProfileField::Manager, is_self, owner) { | |
| 254 | − | return Ok(Outcome::fail(FailureCode::Forbidden, format!("Only owners of {workspace} set who someone reports to."))); | |
| 255 | − | } | |
| 256 | − | if manager.is_empty() { | |
| 257 | − | binds.push(JsValue::NULL); | |
| 258 | − | } else { | |
| 259 | − | let Some((manager_id, manager_name)) = self.person_by_username(manager).await? else { | |
| 260 | − | return Ok(Outcome::fail(FailureCode::NotFound, NO_SUCH_MEMBER)); | |
| 261 | − | }; | |
| 262 | − | if !self.is_member_of(&workspace_id, &manager_id).await? { | |
| 263 | − | return Ok(Outcome::fail( | |
| 264 | − | FailureCode::Invalid, | |
| 265 | − | format!("{manager_name} is not a member of {workspace}."), | |
| 266 | − | )); | |
| 267 | − | } | |
| 268 | − | let lines = self.reporting_lines(&workspace_id).await?; | |
| 269 | − | if makes_loop(&user_id, &manager_id, |id| lines.get(id).cloned()) { | |
| 270 | − | return Ok(Outcome::fail( | |
| 271 | − | FailureCode::Invalid, | |
| 272 | − | format!("{username} can't report to {manager_name}: {manager_name} already reports to {username}, directly or through others."), | |
| 273 | − | )); | |
| 274 | − | } | |
| 275 | − | binds.push(manager_id.into()); | |
| 276 | − | } | |
| 277 | − | sets.push(format!("manager_id = ?{}", binds.len())); | |
| 278 | − | changed.push("manager"); | |
| 279 | − | } | |
| 280 | 246 | if !sets.is_empty() { | |
| 281 | 247 | binds.push(JsValue::from(workspace_id.clone())); | |
| 282 | 248 | binds.push(JsValue::from(user_id.clone())); | |
| ⋯ | |||
| 309 | 275 | Some(person) => Outcome::Ok(person.person()), | |
| 310 | 276 | None => Outcome::fail(FailureCode::NotFound, NO_SUCH_MEMBER), | |
| 311 | 277 | }) | |
| 312 | − | } | |
| 313 | − | ||
| 314 | − | /// Each member's manager in a workspace, by user id. | |
| 315 | − | async fn reporting_lines(&self, workspace_id: &str) -> Result<HashMap<String, String>> { | |
| 316 | − | #[derive(Deserialize)] | |
| 317 | − | struct Line { | |
| 318 | − | user_id: String, | |
| 319 | − | manager_id: String, | |
| 320 | − | } | |
| 321 | − | Ok(self | |
| 322 | − | .db | |
| 323 | − | .prepare("SELECT user_id, manager_id FROM workspace_members WHERE workspace_id = ? AND manager_id IS NOT NULL") | |
| 324 | − | .bind(&[workspace_id.into()])? | |
| 325 | − | .all() | |
| 326 | − | .await? | |
| 327 | − | .results::<Line>()? | |
| 328 | − | .into_iter() | |
| 329 | − | .map(|line| (line.user_id, line.manager_id)) | |
| 330 | − | .collect()) | |
| 331 | 278 | } | |
| 332 | 279 | ||
| 333 | 280 | /// Every team of a workspace, as `viewer_id` stands in each. | |
| 1772 | 1772 | title: Option<String>, | |
| 1773 | 1773 | #[serde(default)] | |
| 1774 | 1774 | owns: Option<String>, | |
| 1775 | − | #[serde(default)] | |
| 1776 | − | manager: Option<String>, | |
| 1777 | 1775 | } | |
| 1778 | 1776 | #[derive(Deserialize)] | |
| 1779 | 1777 | struct AgentRow { | |
| ⋯ | |||
| 1787 | 1785 | self.db | |
| 1788 | 1786 | .prepare( | |
| 1789 | 1787 | "SELECT tm.team_id, u.id AS user_id, u.username, u.display_name AS name, u.timezone, tm.role, | |
| 1790 | − | wm.title, wm.owns, mu.username AS manager | |
| 1788 | + | wm.title, wm.owns | |
| 1791 | 1789 | FROM team_members tm | |
| 1792 | 1790 | JOIN teams t ON t.id = tm.team_id | |
| 1793 | 1791 | JOIN users u ON u.id = tm.user_id AND u.deleted_at IS NULL | |
| 1794 | 1792 | LEFT JOIN workspace_members wm ON wm.workspace_id = t.workspace_id AND wm.user_id = u.id | |
| 1795 | − | LEFT JOIN users mu ON mu.id = wm.manager_id AND mu.deleted_at IS NULL | |
| 1796 | 1793 | WHERE tm.team_id IN (SELECT value FROM json_each(?1)) | |
| 1797 | 1794 | ORDER BY tm.role DESC, u.username LIMIT 2000", | |
| 1798 | 1795 | ) | |
| ⋯ | |||
| 1830 | 1827 | title: person.title.clone(), | |
| 1831 | 1828 | timezone: person.timezone.clone(), | |
| 1832 | 1829 | owns: crate::people::owns_from(person.owns.as_deref()), | |
| 1833 | − | manager: person.manager.clone(), | |
| 1834 | 1830 | maintainer: person.role == TeamRole::Maintainer.as_str(), | |
| 1835 | 1831 | }) | |
| 1836 | 1832 | .collect(), | |
Comments
Sign in to comment.