Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions KNOWLEDGE_BASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,13 +114,15 @@ GitHub organizations, like `@laravel`, have a page at `/@{handle}` too, with the

The **Mentions** tab lists the commits and replies that mention the organization, newest first, a reply with the commit it answers. A commit stays listed after the organization is renamed, since it is linked to the organization rather than to the handle. It follows the usual visibility: guests see approved commits only, you also see your own pending ones, and commits across a block or by people you muted are left out.

A member of an organization can **Claim** its page, which lists them as its admin. Only the first member to claim it becomes its admin: once a page has one, it can't be claimed again, and the Claim button is gone. Claiming asks GitHub once for permission to read your organizations, then checks that you are an owner or a member there, not just invited. Organizations that restrict third-party apps only share their members once an owner approved /dev/null: the page then links to GitHub, where an owner grants access and a member requests it. GitHub is only the way in: leaving the organization on GitHub doesn't remove you as an admin on /dev/null. Claims are in your data export and go when you delete your account.
A member of an organization can **Claim** its page, which lists them as its admin. Only the first member to claim it becomes its admin: once a page has one, it can't be claimed again, and the Claim button is gone. Claiming asks GitHub once for permission to read your organizations, then checks that you are an owner or a member there, not just invited. Organizations that restrict third-party apps only share their members once an owner approved /dev/null: the page then links to GitHub, where an owner grants access and a member requests it. GitHub is only the way in: leaving the organization on GitHub doesn't remove you as an admin on /dev/null.

Every organization has a color, /dev/null's blue until its admin chooses another with **Color** on its page: one of the same six lane colors as developers, or a custom one. It rings the organization's avatar, leads its handle, and colors its link preview.
After that, members are managed on /dev/null, not mirrored from GitHub. An organization's members open its **Settings** from its page, where the **Members** tab lists everyone, each an **admin** or a **member**. Admins add people by their /dev/null handle, except people they blocked or who blocked them, make them admins or members, and remove them. Any member can leave. Its last admin can't leave or become a member. Only its members see who its members are; its page lists its admins to everyone. Your organizations and your role in each are in your data export and go when you delete your account.

Every organization has a color, /dev/null's blue until an admin chooses another in the **Profile** tab of its **Settings**, where its admins land: one of the same six lane colors as developers, or a custom one. It rings the organization's avatar, leads its handle, and colors its link preview.

Following organizations is planned, not shipped yet.

Sources: [organization page](resources/js/pages/organization/show.tsx), [mentions](app/Queries/OrganizationMentionsQuery.php), [organization lookups](app/Actions/UpdateCommitTopics.php), [claims](app/Http/Controllers/OrganizationAdminController.php), [color](resources/js/components/organization-lane-dialog.tsx).
Sources: [organization page](resources/js/pages/organization/show.tsx), [mentions](app/Queries/OrganizationMentionsQuery.php), [organization lookups](app/Actions/UpdateCommitTopics.php), [claims](app/Http/Controllers/OrganizationAdminController.php), [color](app/Http/Controllers/OrganizationLaneController.php), [members](app/Http/Controllers/OrganizationMemberController.php).

### Going live

Expand Down
27 changes: 14 additions & 13 deletions app/Actions/CreateExport.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
* muting: Generator<int, ExportUser>,
* blocking: Generator<int, ExportUser>,
* dismissed_suggestions: Generator<int, ExportUser>,
* claimed_organizations: array<int, array{handle: string, claimed_at: string}>,
* organizations: array<int, array{handle: string, role: string, joined_at: string}>,
* messages: Generator<int, ExportMessage>,
* remote_pins: array<int, array{to: string|null, pinned_at: string|null}>,
* remote_mutes: array<int, array{to: string|null, muted_until_at: string|null}>,
Expand All @@ -66,7 +66,7 @@
public function handle(User $user): array
{
return [
'schema_version' => 3,
'schema_version' => 4,
'exported_at' => now()->toIso8601String(),
'profile' => [
'github_id' => $user->github_id,
Expand Down Expand Up @@ -111,7 +111,7 @@ public function handle(User $user): array
'muting' => $this->handles($user->muting()),
'blocking' => $this->handles($user->blocking()),
'dismissed_suggestions' => $this->handles($user->dismissedSuggestions()),
'claimed_organizations' => $this->claimedOrganizations($user),
'organizations' => $this->organizations($user),
'messages' => $this->messages($user),
'remote_mutes' => Conversation::query()
->join('conversation_participants as mine', 'mine.conversation_id', '=', 'conversations.id')
Expand Down Expand Up @@ -293,19 +293,20 @@ private function handles(BelongsToMany $users): Generator
}

/**
* @return array<int, array{handle: string, claimed_at: string}>
* @return array<int, array{handle: string, role: string, joined_at: string}>
*/
private function claimedOrganizations(User $user): array
private function organizations(User $user): array
{
/** @var Collection<int, object{handle: string, created_at: string}> $claims */
$claims = $user->claimedOrganizations()->newPivotQuery()
->join('profiles', 'profiles.id', '=', 'organization_admins.organization_id')
->oldest('organization_admins.created_at')
->get(['profiles.handle', 'organization_admins.created_at']);
/** @var Collection<int, object{handle: string, role: string, created_at: string}> $memberships */
$memberships = $user->organizations()->newPivotQuery()
->join('profiles', 'profiles.id', '=', 'organization_members.organization_id')
->oldest('organization_members.created_at')
->get(['profiles.handle', 'organization_members.role', 'organization_members.created_at']);

return $claims->map(fn (object $claim): array => [
'handle' => $claim->handle,
'claimed_at' => CarbonImmutable::parse($claim->created_at)->toIso8601String(),
return $memberships->map(fn (object $membership): array => [
'handle' => $membership->handle,
'role' => $membership->role,
'joined_at' => CarbonImmutable::parse($membership->created_at)->toIso8601String(),
])->all();
}

Expand Down
4 changes: 3 additions & 1 deletion app/Actions/CreateOrganizationAdmin.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace App\Actions;

use App\Enums\OrganizationRole;
use App\Models\Profile;
use App\Models\User;
use Illuminate\Support\Facades\DB;
Expand All @@ -24,7 +25,8 @@ public function handle(Profile $organization, User $user): bool
return false;
}

$organization->admins()->attach($user->id);
// A member is left without an admin when the last one erased their account...
$organization->members()->syncWithoutDetaching([$user->id => ['role' => OrganizationRole::Admin]]);

return true;
});
Expand Down
20 changes: 20 additions & 0 deletions app/Actions/CreateOrganizationMember.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

declare(strict_types=1);

namespace App\Actions;

use App\Enums\OrganizationRole;
use App\Models\Profile;
use App\Models\User;

final readonly class CreateOrganizationMember
{
/**
* Adds the user to the organization as a member, whom an admin can then make an admin.
*/
public function handle(Profile $organization, User $user): void
{
$organization->members()->attach($user->id, ['role' => OrganizationRole::Member]);
}
}
4 changes: 2 additions & 2 deletions app/Actions/DeleteOrganization.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
final readonly class DeleteOrganization
{
/**
* Deletes an organization GitHub no longer has, with its admins, and unlinks the commits that mention it, so none
* Deletes an organization GitHub no longer has, with its members, and unlinks the commits that mention it, so none
* links to a page that is gone.
*/
public function handle(Profile $organization): void
Expand All @@ -22,7 +22,7 @@ public function handle(Profile $organization): void
->where('topic_id', $organization->id)
->delete();

$organization->admins()->detach();
$organization->members()->detach();
$organization->delete();
});
}
Expand Down
34 changes: 34 additions & 0 deletions app/Actions/DeleteOrganizationMember.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

declare(strict_types=1);

namespace App\Actions;

use App\Enums\OrganizationRole;
use App\Exceptions\OrganizationMustHaveAnAdmin;
use App\Models\Profile;
use App\Models\User;
use Illuminate\Support\Facades\DB;

final readonly class DeleteOrganizationMember
{
/**
* Removes the member from the organization, whether an admin removed them or they left. The organization is locked
* while its last admin is checked again, as two admins removing each other at once can both pass the request's
* check: the second removal then throws instead.
*
* @throws OrganizationMustHaveAnAdmin
*/
public function handle(Profile $organization, User $member): void
{
DB::transaction(function () use ($organization, $member): void {
Profile::query()->whereKey($organization->id)->lockForUpdate()->first();

if ($organization->roleOf($member) === OrganizationRole::Admin && $organization->admins()->whereKeyNot($member->id)->doesntExist()) {
throw new OrganizationMustHaveAnAdmin($organization);
}

$organization->members()->detach($member->id);
});
}
}
2 changes: 1 addition & 1 deletion app/Actions/DeleteUser.php
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ public function handle(User $user): void

$this->deleteOrgs->handle($this->emptyOrgs($user));

$user->claimedOrganizations()->detach();
$user->organizations()->detach();

$user->delete();
$user->profile->delete();
Expand Down
34 changes: 34 additions & 0 deletions app/Actions/UpdateOrganizationMemberRole.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

declare(strict_types=1);

namespace App\Actions;

use App\Enums\OrganizationRole;
use App\Exceptions\OrganizationMustHaveAnAdmin;
use App\Models\Profile;
use App\Models\User;
use Illuminate\Support\Facades\DB;

final readonly class UpdateOrganizationMemberRole
{
/**
* Makes the member an admin or a member. The organization is locked while its last admin is checked again, as two
* admins making each other members at once can both pass the request's check: the second change then throws
* instead.
*
* @throws OrganizationMustHaveAnAdmin
*/
public function handle(Profile $organization, User $member, OrganizationRole $role): void
{
DB::transaction(function () use ($organization, $member, $role): void {
Profile::query()->whereKey($organization->id)->lockForUpdate()->first();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

if ($role === OrganizationRole::Member && $organization->admins()->whereKeyNot($member->id)->doesntExist()) {
throw new OrganizationMustHaveAnAdmin($organization);
}

$organization->members()->updateExistingPivot($member->id, ['role' => $role]);
});
}
}
14 changes: 14 additions & 0 deletions app/Enums/OrganizationRole.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

declare(strict_types=1);

namespace App\Enums;

/**
* Admins manage an organization's members on /dev/null: they add them, remove them, and make them admins or members.
*/
enum OrganizationRole: string
{
case Admin = 'admin';
case Member = 'member';
}
20 changes: 20 additions & 0 deletions app/Exceptions/OrganizationMustHaveAnAdmin.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

declare(strict_types=1);

namespace App\Exceptions;

use App\Models\Profile;
use RuntimeException;

/**
* Changing or removing the member would leave the organization without an admin. The request checks it first, so this
* only happens when two admins change or remove each other at the same moment.
*/
final class OrganizationMustHaveAnAdmin extends RuntimeException
{
public function __construct(public readonly Profile $organization)
{
parent::__construct(__('@:handle needs at least one admin.', ['handle' => $organization->handle]));
}
}
2 changes: 1 addition & 1 deletion app/Http/Controllers/OrganizationAdminController.php
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ private function github(): GitHubOAuthProvider
*/
private function claimed(Profile $organization): ?string
{
$admin = $organization->admins()->oldest('organization_admins.created_at')->first();
$admin = $organization->admins()->oldest('organization_members.created_at')->first();

if ($admin === null) {
return null;
Expand Down
14 changes: 11 additions & 3 deletions app/Http/Controllers/OrganizationController.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace App\Http\Controllers;

use App\Enums\OrganizationRole;
use App\Http\Requests\ShowOrganizationRequest;
use App\Models\Profile;
use App\Models\User;
Expand All @@ -19,8 +20,9 @@
{
/**
* Organization pages are public, at the "@handle" of users, and show the organization's GitHub profile and the
* commits that mention it, in the lane its admins chose or /dev/null's blue, with the users who claimed it, first
* claim first, and the organization's link preview. Like users' pages, they move for good to the handle's own case.
* commits that mention it, in the lane its admins chose or /dev/null's blue, with its admins, first one first, and
* the organization's link preview, and its members the way to its settings. Like users' pages, they move for good
* to the handle's own case.
*/
public function show(ShowOrganizationRequest $request, #[CurrentUser] ?User $currentUser, OrganizationMentionsQuery $mentions, PageMetadata $metadata): RedirectResponse|Response
{
Expand All @@ -43,7 +45,13 @@ public function show(ShowOrganizationRequest $request, #[CurrentUser] ?User $cur
->cursorPaginate(20)
->appends(['tab' => $request->tab()])),
'claimUrl' => route('organization-admin.create', $organization),
'admins' => fn (): Collection => $organization->admins()->oldest('organization_admins.created_at')->get()->makeHidden('pivot'),
// Admins land on the Profile tab, which only they have...
'settingsUrl' => match ($currentUser instanceof User ? $organization->roleOf($currentUser) : null) {
OrganizationRole::Admin => route('organization-profile.edit', $organization),
OrganizationRole::Member => route('organization-member.index', $organization),
null => null,
},
'admins' => fn (): Collection => $organization->admins()->oldest('organization_members.created_at')->get()->makeHidden('pivot'),
])->withViewData(['preview' => $currentUser instanceof User || $request->inertia() ? null : $metadata->organization($organization)]);
}
}
3 changes: 3 additions & 0 deletions app/Http/Controllers/OrganizationLaneController.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;

/**
* An organization's color, in the Profile tab of its settings. Only its admins choose it.
*/
final readonly class OrganizationLaneController
{
public function update(UpdateOrganizationLaneRequest $request, Profile $organization, UpdateOrganizationLane $action): RedirectResponse
Expand Down
Loading
Loading