| 1 | import { type Database, schema } from '@ghost/db'; |
| 2 | import { Inject, Injectable |
| 3 | import type { OrganizationRole |
| 4 | import { AuthService } |
| 5 | import { APIError } |
| 6 | import { |
| 7 | and, |
| 8 | asc, |
| 9 | count, |
| 10 | eq, |
| 11 | gt, |
| 12 | inArray, |
| 13 | isNotNull, |
| 14 | isNull, |
| 15 | or, |
| 16 | sql, |
| 17 | } from 'drizzle-orm'; |
| 18 | import { alias } |
| 19 | |
| 20 | import { DATABASE } |
| 21 | import { administeredOrganization } |
| 22 | import { AvatarStorageService } |
| 23 | import type { UpdateOrganizationSettingsDTO |
| 24 | import { |
| 25 | acceptedCollaboration, |
| 26 | type CollaboratorRole, |
| 27 | organizationMembership, |
| 28 | readableBy, |
| 29 | } from '../../lib/repositories/access/repository-access.js'; |
| 30 | import { administers, organizationRoleOf |
| 31 | import { RepositoryNotFoundError } |
| 32 | import { UsersService } |
| 33 | import { isoTimestamp } |
| 34 | import { |
| 35 | InvitationRefusedError, |
| 36 | OrganizationNotFoundError, |
| 37 | } from '../../lib/organizations/organization.errors.js'; |
| 38 | import type { Auth |
| 39 | |
| 40 | const summaryColumns = { |
| 41 | slug: schema.organization |
| 42 | name: schema.organization |
| 43 | logo: schema.organization |
| 44 | }; |
| 45 | |
| 46 | @Injectable() |
| 47 | export class OrganizationsService |
| 48 | constructor( |
| 49 | @Inject(DATABASE) |
| 50 | private readonly users |
| 51 | private readonly avatars |
| 52 | private readonly auth |
| 53 | ) {} |
| 54 | |
| 55 | /** Pending, unexpired invitations addressed to the user's email. Better Auth lists these only for verified addresses, which leaves an instance without email verification unable to show them; the account's own address is proof enough here. */ |
| 56 | async listReceivedInvitations(userId |
| 57 | const user = |
| 58 | const inviter = |
| 59 | return { |
| 60 | invitations: await this |
| 61 | .select({ |
| 62 | id: schema.invitation |
| 63 | organization: summaryColumns, |
| 64 | role: schema.invitation |
| 65 | invitedByUsername: inviter.username |
| 66 | expiresAt: isoTimestamp(schema |
| 67 | }) |
| 68 | .from(schema.invitation |
| 69 | .innerJoin( |
| 70 | schema.organization, |
| 71 | eq(schema.organization |
| 72 | ) |
| 73 | .leftJoin(inviter, |
| 74 | .where( |
| 75 | and( |
| 76 | eq( |
| 77 | sql`lower(${schema. |
| 78 | user.email.toLowerCase |
| 79 | ), |
| 80 | eq(schema.invitation |
| 81 | gt(schema.invitation |
| 82 | ), |
| 83 | ) |
| 84 | .orderBy(asc(schema |
| 85 | }; |
| 86 | } |
| 87 | |
| 88 | /** Invites an account by its username. Better Auth invites by email, which only the server may know, so the address is looked up here and never leaves it. */ |
| 89 | async inviteByUsername({ |
| 90 | slug, |
| 91 | username, |
| 92 | role, |
| 93 | requesterId, |
| 94 | headers, |
| 95 | }: { |
| 96 | slug: string |
| 97 | username: string |
| 98 | role: OrganizationRole |
| 99 | requesterId: string |
| 100 | headers: Headers |
| 101 | }) { |
| 102 | const { organizationId |
| 103 | this.db, |
| 104 | slug, |
| 105 | requesterId, |
| 106 | ); |
| 107 | const user = |
| 108 | |
| 109 | try { |
| 110 | await this.auth |
| 111 | body: { email: user |
| 112 | headers, |
| 113 | }); |
| 114 | } catch (error) |
| 115 | if (error instanceof |
| 116 | throw new InvitationRefusedError( |
| 117 | } |
| 118 | throw error; |
| 119 | } |
| 120 | } |
| 121 | |
| 122 | async getSettings(slug |
| 123 | const { settings |
| 124 | this.db, |
| 125 | slug, |
| 126 | requesterId, |
| 127 | ); |
| 128 | if (!settings) |
| 129 | const { organizationId: |
| 130 | return policies; |
| 131 | } |
| 132 | |
| 133 | async updateSettings( |
| 134 | slug: string, |
| 135 | requesterId: string, |
| 136 | changes: UpdateOrganizationSettingsDTO |
| 137 | ) { |
| 138 | const { organizationId |
| 139 | this.db, |
| 140 | slug, |
| 141 | requesterId, |
| 142 | ); |
| 143 | await this.db |
| 144 | .insert(schema.organizationSettings |
| 145 | .values({ organizationId |
| 146 | .onConflictDoUpdate({ |
| 147 | target: schema.organizationSettings |
| 148 | set: changes, |
| 149 | }); |
| 150 | return this.getSettings |
| 151 | } |
| 152 | |
| 153 | /** Stores a new logo and points the organization at it; the previous one is dropped. Admins only. */ |
| 154 | async setLogo({ |
| 155 | slug, |
| 156 | requesterId, |
| 157 | contentType, |
| 158 | body, |
| 159 | }: { |
| 160 | slug: string |
| 161 | requesterId: string |
| 162 | contentType: string |
| 163 | body: Buffer |
| 164 | }) { |
| 165 | const { organizationId |
| 166 | this.db, |
| 167 | slug, |
| 168 | requesterId, |
| 169 | ); |
| 170 | const { url |
| 171 | ownerId: organizationId, |
| 172 | contentType, |
| 173 | body, |
| 174 | }); |
| 175 | await this.db |
| 176 | .update(schema.organization |
| 177 | .set({ logo: |
| 178 | .where(eq(schema |
| 179 | return { url |
| 180 | } |
| 181 | |
| 182 | async removeLogo(slug |
| 183 | const { organizationId |
| 184 | this.db, |
| 185 | slug, |
| 186 | requesterId, |
| 187 | ); |
| 188 | await this.db |
| 189 | .update(schema.organization |
| 190 | .set({ logo: |
| 191 | .where(eq(schema |
| 192 | await this.avatars |
| 193 | } |
| 194 | |
| 195 | /** The public face of an organization: its profile, pinned and public repositories, and the members who made their membership public. Members see every member, and pinned repositories they can read. */ |
| 196 | async getProfile(slug |
| 197 | const [organization] |
| 198 | .select({ |
| 199 | id: schema.organization |
| 200 | ...summaryColumns, |
| 201 | createdAt: isoTimestamp(schema |
| 202 | description: schema.organizationSettings |
| 203 | website: schema.organizationSettings |
| 204 | location: schema.organizationSettings |
| 205 | email: schema.organizationSettings |
| 206 | }) |
| 207 | .from(schema.organization |
| 208 | .leftJoin( |
| 209 | schema.organizationSettings, |
| 210 | eq(schema.organizationSettings |
| 211 | ) |
| 212 | .where(eq(schema |
| 213 | if (!organization) |
| 214 | |
| 215 | const [membership] |
| 216 | ? await this. |
| 217 | .select({ role: |
| 218 | .from(schema.member |
| 219 | .where( |
| 220 | and( |
| 221 | eq(schema.member |
| 222 | eq(schema.member |
| 223 | ), |
| 224 | ) |
| 225 | : []; |
| 226 | const viewer = |
| 227 | |
| 228 | const [[repositories], |
| 229 | this.db |
| 230 | .select({ value: |
| 231 | .from(schema.repository |
| 232 | .where( |
| 233 | and( |
| 234 | eq(schema.repository |
| 235 | eq(schema.repository |
| 236 | ), |
| 237 | ), |
| 238 | this.db |
| 239 | .select({ |
| 240 | username: sql<string |
| 241 | name: schema.user |
| 242 | image: schema.user |
| 243 | public: sql<boolean |
| 244 | }) |
| 245 | .from(schema.member |
| 246 | .innerJoin(schema.user |
| 247 | .leftJoin( |
| 248 | schema.organizationPublicMember, |
| 249 | and( |
| 250 | eq( |
| 251 | schema.organizationPublicMember.organizationId |
| 252 | schema.member.organizationId |
| 253 | ), |
| 254 | eq(schema.organizationPublicMember |
| 255 | ), |
| 256 | ) |
| 257 | .where( |
| 258 | and( |
| 259 | eq(schema.member |
| 260 | isNotNull(schema.user |
| 261 | membership |
| 262 | ? undefined |
| 263 | : isNotNull(schema. |
| 264 | ), |
| 265 | ) |
| 266 | .orderBy(asc(schema |
| 267 | this.db |
| 268 | .select({ |
| 269 | slug: schema.repository |
| 270 | name: schema.repository |
| 271 | description: schema.repository |
| 272 | visibility: schema.repository |
| 273 | }) |
| 274 | .from(schema.organizationPinnedRepository |
| 275 | .innerJoin( |
| 276 | schema.repository, |
| 277 | eq( |
| 278 | schema.repository.id |
| 279 | schema.organizationPinnedRepository.repositoryId |
| 280 | ), |
| 281 | ) |
| 282 | .leftJoin( |
| 283 | schema.repositoryCollaborator, |
| 284 | acceptedCollaboration(schema.repository |
| 285 | ) |
| 286 | .leftJoin( |
| 287 | schema.member, |
| 288 | organizationMembership(schema.repository |
| 289 | ) |
| 290 | .where( |
| 291 | and( |
| 292 | eq( |
| 293 | schema.organizationPinnedRepository.organizationId |
| 294 | organization.id, |
| 295 | ), |
| 296 | readableBy(viewer), |
| 297 | ), |
| 298 | ) |
| 299 | .orderBy(asc(schema |
| 300 | ]); |
| 301 | |
| 302 | const { id: |
| 303 | return { |
| 304 | ...profile, |
| 305 | repositoryCount: repositories?.value |
| 306 | members, |
| 307 | pinned, |
| 308 | viewerRole: membership ? |
| 309 | }; |
| 310 | } |
| 311 | |
| 312 | /** A user's organizations as `requesterId` may see them: all of them to the user, and to anyone else the ones the user made public and the ones they share. */ |
| 313 | async listForUser(username |
| 314 | const user = |
| 315 | const viewer = |
| 316 | return { |
| 317 | organizations: await this |
| 318 | .select(summaryColumns) |
| 319 | .from(schema.member |
| 320 | .innerJoin( |
| 321 | schema.organization, |
| 322 | eq(schema.organization |
| 323 | ) |
| 324 | .leftJoin( |
| 325 | schema.organizationPublicMember, |
| 326 | and( |
| 327 | eq( |
| 328 | schema.organizationPublicMember.organizationId |
| 329 | schema.member.organizationId |
| 330 | ), |
| 331 | eq(schema.organizationPublicMember |
| 332 | ), |
| 333 | ) |
| 334 | .leftJoin( |
| 335 | viewer, |
| 336 | and( |
| 337 | eq(viewer.organizationId |
| 338 | requesterId ? eq( |
| 339 | ), |
| 340 | ) |
| 341 | .where( |
| 342 | and( |
| 343 | eq(schema.member |
| 344 | requesterId === user. |
| 345 | ? undefined |
| 346 | : or( |
| 347 | isNotNull(schema.organizationPublicMember |
| 348 | isNotNull(viewer.id |
| 349 | ), |
| 350 | ), |
| 351 | ) |
| 352 | .orderBy(asc(schema |
| 353 | }; |
| 354 | } |
| 355 | |
| 356 | async setMembershipPublic(slug |
| 357 | const [membership] |
| 358 | .select({ organizationId: |
| 359 | .from(schema.member |
| 360 | .innerJoin( |
| 361 | schema.organization, |
| 362 | eq(schema.organization |
| 363 | ) |
| 364 | .where( |
| 365 | and( |
| 366 | eq(schema.organization |
| 367 | eq(schema.member |
| 368 | ), |
| 369 | ); |
| 370 | if (!membership) |
| 371 | |
| 372 | const row = |
| 373 | organizationId: membership.organizationId |
| 374 | userId: requesterId, |
| 375 | }; |
| 376 | if (show) { |
| 377 | await this.db |
| 378 | .insert(schema.organizationPublicMember |
| 379 | .values(row) |
| 380 | .onConflictDoNothing(); |
| 381 | } else { |
| 382 | await this.db |
| 383 | .delete(schema.organizationPublicMember |
| 384 | .where( |
| 385 | and( |
| 386 | eq( |
| 387 | schema.organizationPublicMember.organizationId |
| 388 | row.organizationId, |
| 389 | ), |
| 390 | eq(schema.organizationPublicMember |
| 391 | ), |
| 392 | ); |
| 393 | } |
| 394 | } |
| 395 | |
| 396 | /** Replaces the pinned repositories, in the order given. Only the organization's own repositories can be pinned. */ |
| 397 | async setPinned(slug |
| 398 | const { organizationId |
| 399 | this.db, |
| 400 | slug, |
| 401 | requesterId, |
| 402 | ); |
| 403 | const repositories = |
| 404 | ? await this. |
| 405 | .select({ |
| 406 | id: schema.repository |
| 407 | slug: schema.repository |
| 408 | }) |
| 409 | .from(schema.repository |
| 410 | .where( |
| 411 | and( |
| 412 | eq(schema.repository |
| 413 | inArray(schema.repository |
| 414 | ), |
| 415 | ) |
| 416 | : []; |
| 417 | const idOf = |
| 418 | const missing = |
| 419 | if (missing) throw |
| 420 | |
| 421 | await this.db |
| 422 | await tx |
| 423 | .delete(schema.organizationPinnedRepository |
| 424 | .where( |
| 425 | eq( |
| 426 | schema.organizationPinnedRepository.organizationId |
| 427 | organizationId, |
| 428 | ), |
| 429 | ); |
| 430 | if (slugs.length |
| 431 | await tx.insert |
| 432 | slugs.map((wanted |
| 433 | organizationId, |
| 434 | repositoryId: idOf.get |
| 435 | position, |
| 436 | })), |
| 437 | ); |
| 438 | }); |
| 439 | } |
| 440 | |
| 441 | /** People with access to the organization's repositories who are not its members, each with the repositories they hold and at what role. Pending invitations count, since they are access on its way. */ |
| 442 | async listOutsideCollaborators(slug |
| 443 | const { organizationId |
| 444 | this.db, |
| 445 | slug, |
| 446 | requesterId, |
| 447 | ); |
| 448 | const rows = |
| 449 | .select({ |
| 450 | userId: schema.user |
| 451 | username: sql<string |
| 452 | name: schema.user |
| 453 | image: schema.user |
| 454 | repository: schema.repository |
| 455 | role: schema.repositoryCollaborator |
| 456 | pending: sql<boolean |
| 457 | }) |
| 458 | .from(schema.repositoryCollaborator |
| 459 | .innerJoin( |
| 460 | schema.repository, |
| 461 | eq(schema.repository |
| 462 | ) |
| 463 | .innerJoin( |
| 464 | schema.user, |
| 465 | eq(schema.user |
| 466 | ) |
| 467 | .leftJoin( |
| 468 | schema.member, |
| 469 | and( |
| 470 | eq(schema.member |
| 471 | eq(schema.member |
| 472 | ), |
| 473 | ) |
| 474 | .where( |
| 475 | and( |
| 476 | eq(schema.repository |
| 477 | isNull(schema.member |
| 478 | ), |
| 479 | ) |
| 480 | .orderBy(asc(schema |
| 481 | |
| 482 | // One entry per person: rows come one per repository. |
| 483 | const people = |
| 484 | string, |
| 485 | { |
| 486 | username: string; |
| 487 | name: string; |
| 488 | image: string | |
| 489 | repositories: { |
| 490 | slug: string; |
| 491 | role: CollaboratorRole; |
| 492 | pending: boolean; |
| 493 | }[]; |
| 494 | } |
| 495 | >(); |
| 496 | for (const { |
| 497 | const entry = |
| 498 | entry.repositories.push |
| 499 | people.set(userId |
| 500 | } |
| 501 | return { collaborators: |
| 502 | } |
| 503 | |
| 504 | async removeOutsideCollaborator( |
| 505 | slug: string, |
| 506 | requesterId: string, |
| 507 | username: string, |
| 508 | ) { |
| 509 | const { organizationId |
| 510 | this.db, |
| 511 | slug, |
| 512 | requesterId, |
| 513 | ); |
| 514 | const user = |
| 515 | await this.db |
| 516 | .delete(schema.repositoryCollaborator |
| 517 | .where( |
| 518 | and( |
| 519 | eq(schema.repositoryCollaborator |
| 520 | inArray( |
| 521 | schema.repositoryCollaborator.repositoryId |
| 522 | this.db |
| 523 | .select({ id: |
| 524 | .from(schema.repository |
| 525 | .where(eq(schema |
| 526 | ), |
| 527 | ), |
| 528 | ); |
| 529 | } |
| 530 | |
| 531 | async listMine(userId |
| 532 | const rows = |
| 533 | .select({ |
| 534 | ...summaryColumns, |
| 535 | role: schema.member |
| 536 | membersCanCreate: sql<boolean |
| 537 | }) |
| 538 | .from(schema.member |
| 539 | .innerJoin( |
| 540 | schema.organization, |
| 541 | eq(schema.organization |
| 542 | ) |
| 543 | .leftJoin( |
| 544 | schema.organizationSettings, |
| 545 | eq(schema.organizationSettings |
| 546 | ) |
| 547 | .where(eq(schema |
| 548 | .orderBy(asc(schema |
| 549 | |
| 550 | return { |
| 551 | organizations: rows.map |
| 552 | const viewerRole = |
| 553 | return { |
| 554 | ...organization, |
| 555 | viewerRole, |
| 556 | canCreateRepositories: administers(viewerRole |
| 557 | }; |
| 558 | }), |
| 559 | }; |
| 560 | } |
| 561 | } |