| 1 | import { administers } from '@ghost/permissions'; |
| 2 | import { createHash, randomUUID |
| 3 | import { type Database, |
| 4 | import { Inject, Injectable |
| 5 | import { and, asc |
| 6 | |
| 7 | import { DATABASE } |
| 8 | import { membershipIn } |
| 9 | import { |
| 10 | OrganizationForbiddenError, |
| 11 | TeamNotFoundError, |
| 12 | UserNotInOrganizationError, |
| 13 | } from '../../lib/organizations/organization.errors.js'; |
| 14 | import { teamSlug } |
| 15 | import { UsersService } |
| 16 | |
| 17 | /** Better Auth's own dedupe key for a team membership, so rows written here are found by its lookups too. */ |
| 18 | function membershipKey(teamId: |
| 19 | return createHash('sha256 |
| 20 | .update(JSON.stringify |
| 21 | .digest('base64url') |
| 22 | } |
| 23 | |
| 24 | /** Teams as members see them, and team membership as admins and team maintainers manage it. Membership is written here rather than through Better Auth, whose checks know only organization roles and would refuse a maintainer. */ |
| 25 | @Injectable() |
| 26 | export class TeamsService |
| 27 | constructor( |
| 28 | @Inject(DATABASE) |
| 29 | private readonly users |
| 30 | ) {} |
| 31 | |
| 32 | async list(slug |
| 33 | const { organizationId |
| 34 | const teams = |
| 35 | .select({ |
| 36 | id: schema.team |
| 37 | name: schema.team |
| 38 | memberCount: schema.team |
| 39 | repositoryCount: sql<number |
| 40 | }) |
| 41 | .from(schema.team |
| 42 | .where(eq(schema |
| 43 | .orderBy(asc(schema |
| 44 | return { |
| 45 | teams: teams.map |
| 46 | }; |
| 47 | } |
| 48 | |
| 49 | /** One team, named by its slug: its members, which of them maintain it, and the repositories it reaches. */ |
| 50 | async get(slug |
| 51 | const { found, |
| 52 | const [members, |
| 53 | this.db |
| 54 | .select({ |
| 55 | username: sql<string |
| 56 | name: schema.user |
| 57 | image: schema.user |
| 58 | maintainer: sql<boolean |
| 59 | }) |
| 60 | .from(schema.teamMember |
| 61 | .innerJoin(schema.user |
| 62 | .leftJoin( |
| 63 | schema.teamMaintainer, |
| 64 | and( |
| 65 | eq(schema.teamMaintainer |
| 66 | eq(schema.teamMaintainer |
| 67 | ), |
| 68 | ) |
| 69 | .where(eq(schema |
| 70 | .orderBy(asc(schema |
| 71 | this.db |
| 72 | .select({ |
| 73 | slug: schema.repository |
| 74 | name: schema.repository |
| 75 | visibility: schema.repository |
| 76 | role: schema.repositoryTeam |
| 77 | }) |
| 78 | .from(schema.repositoryTeam |
| 79 | .innerJoin( |
| 80 | schema.repository, |
| 81 | eq(schema.repository |
| 82 | ) |
| 83 | .where(eq(schema |
| 84 | .orderBy(asc(schema |
| 85 | this.maintainerRow(found |
| 86 | ]); |
| 87 | |
| 88 | return { |
| 89 | id: found.id |
| 90 | name: found.name |
| 91 | slug: teamSlug(found |
| 92 | members, |
| 93 | repositories, |
| 94 | viewerCanManage: administers(role |
| 95 | }; |
| 96 | } |
| 97 | |
| 98 | async addMember( |
| 99 | slug: string, |
| 100 | team: string, |
| 101 | username: string, |
| 102 | requesterId: string, |
| 103 | ) { |
| 104 | const { found, |
| 105 | slug, |
| 106 | team, |
| 107 | requesterId, |
| 108 | ); |
| 109 | const user = |
| 110 | |
| 111 | await this.db |
| 112 | await tx |
| 113 | .insert(schema.teamMember |
| 114 | .values({ |
| 115 | id: randomUUID(), |
| 116 | teamId: found.id |
| 117 | userId: user.id |
| 118 | membershipKey: membershipKey(found |
| 119 | createdAt: new Date |
| 120 | }) |
| 121 | .onConflictDoNothing(); |
| 122 | await this.recount |
| 123 | }); |
| 124 | } |
| 125 | |
| 126 | async removeMember( |
| 127 | slug: string, |
| 128 | team: string, |
| 129 | username: string, |
| 130 | requesterId: string, |
| 131 | ) { |
| 132 | const { found |
| 133 | const user = |
| 134 | |
| 135 | await this.db |
| 136 | await tx |
| 137 | .delete(schema.teamMember |
| 138 | .where( |
| 139 | and( |
| 140 | eq(schema.teamMember |
| 141 | eq(schema.teamMember |
| 142 | ), |
| 143 | ); |
| 144 | // Someone off the team no longer maintains it. |
| 145 | await tx |
| 146 | .delete(schema.teamMaintainer |
| 147 | .where( |
| 148 | and( |
| 149 | eq(schema.teamMaintainer |
| 150 | eq(schema.teamMaintainer |
| 151 | ), |
| 152 | ); |
| 153 | await this.recount |
| 154 | }); |
| 155 | } |
| 156 | |
| 157 | /** Maintainers are appointed by the organization's admins, from the team's own members. */ |
| 158 | async setMaintainer( |
| 159 | slug: string, |
| 160 | team: string, |
| 161 | username: string, |
| 162 | requesterId: string, |
| 163 | maintain: boolean, |
| 164 | ) { |
| 165 | const { found, |
| 166 | if (!administers(role |
| 167 | const user = |
| 168 | |
| 169 | if (!maintain) |
| 170 | await this.db |
| 171 | .delete(schema.teamMaintainer |
| 172 | .where( |
| 173 | and( |
| 174 | eq(schema.teamMaintainer |
| 175 | eq(schema.teamMaintainer |
| 176 | ), |
| 177 | ); |
| 178 | return; |
| 179 | } |
| 180 | |
| 181 | const [onTeam] |
| 182 | .select({ id: |
| 183 | .from(schema.teamMember |
| 184 | .where( |
| 185 | and( |
| 186 | eq(schema.teamMember |
| 187 | eq(schema.teamMember |
| 188 | ), |
| 189 | ); |
| 190 | if (!onTeam) |
| 191 | await this.db |
| 192 | .insert(schema.teamMaintainer |
| 193 | .values({ teamId: |
| 194 | .onConflictDoNothing(); |
| 195 | } |
| 196 | |
| 197 | private async find |
| 198 | const { organizationId, |
| 199 | this.db, |
| 200 | slug, |
| 201 | requesterId, |
| 202 | ); |
| 203 | const teams = |
| 204 | .select({ id: |
| 205 | .from(schema.team |
| 206 | .where(eq(schema |
| 207 | const found = |
| 208 | if (!found) |
| 209 | return { found, |
| 210 | } |
| 211 | |
| 212 | private async findManaged |
| 213 | const found = |
| 214 | if (administers(found |
| 215 | const [maintainer] |
| 216 | if (!maintainer) |
| 217 | return found; |
| 218 | } |
| 219 | |
| 220 | private maintainerRow(teamId |
| 221 | return this.db |
| 222 | .select({ userId: |
| 223 | .from(schema.teamMaintainer |
| 224 | .where( |
| 225 | and( |
| 226 | eq(schema.teamMaintainer |
| 227 | eq(schema.teamMaintainer |
| 228 | ), |
| 229 | ); |
| 230 | } |
| 231 | |
| 232 | /** A team only takes the organization's own members. */ |
| 233 | private async orgMember |
| 234 | const user = |
| 235 | const [membership] |
| 236 | .select({ id: |
| 237 | .from(schema.member |
| 238 | .where( |
| 239 | and( |
| 240 | eq(schema.member |
| 241 | eq(schema.member |
| 242 | ), |
| 243 | ); |
| 244 | if (!membership) |
| 245 | return user; |
| 246 | } |
| 247 | |
| 248 | private async recount |
| 249 | tx: Pick |
| 250 | teamId: string, |
| 251 | ) { |
| 252 | const [members] |
| 253 | .select({ value: |
| 254 | .from(schema.teamMember |
| 255 | .where(eq(schema |
| 256 | await tx |
| 257 | .update(schema.team |
| 258 | .set({ memberCount: |
| 259 | .where(eq(schema |
| 260 | } |
| 261 | } |