diff --git a/app/Actions/CreateOrganizationAdmin.php b/app/Actions/CreateOrganizationAdmin.php index edda93ac39..f766ed9dd8 100644 --- a/app/Actions/CreateOrganizationAdmin.php +++ b/app/Actions/CreateOrganizationAdmin.php @@ -5,7 +5,7 @@ namespace App\Actions; use App\Enums\OrganizationRole; -use App\Models\Profile; +use App\Models\Organization; use App\Models\User; use Illuminate\Support\Facades\DB; @@ -16,10 +16,10 @@ * claimed it first. The organization is locked while it's checked, as two members coming back from GitHub at once * can both pass the request's check: the second claim then just doesn't happen. */ - public function handle(Profile $organization, User $user): bool + public function handle(Organization $organization, User $user): bool { return DB::transaction(function () use ($organization, $user): bool { - Profile::query()->whereKey($organization->id)->lockForUpdate()->first(); + Organization::query()->whereKey($organization->id)->lockForUpdate()->first(); if ($organization->admins()->exists()) { return false; diff --git a/app/Actions/CreateOrganizationMember.php b/app/Actions/CreateOrganizationMember.php index aa81c57e86..c8d993f295 100644 --- a/app/Actions/CreateOrganizationMember.php +++ b/app/Actions/CreateOrganizationMember.php @@ -5,7 +5,7 @@ namespace App\Actions; use App\Enums\OrganizationRole; -use App\Models\Profile; +use App\Models\Organization; use App\Models\User; final readonly class CreateOrganizationMember @@ -13,7 +13,7 @@ /** * 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 + public function handle(Organization $organization, User $user): void { $organization->members()->attach($user->id, ['role' => OrganizationRole::Member]); } diff --git a/app/Actions/CreateOrganizationOpenGraphImage.php b/app/Actions/CreateOrganizationOpenGraphImage.php index 8e092245dc..b73d50ed6f 100644 --- a/app/Actions/CreateOrganizationOpenGraphImage.php +++ b/app/Actions/CreateOrganizationOpenGraphImage.php @@ -5,7 +5,7 @@ namespace App\Actions; use App\Contracts\Services\GitHubProvider; -use App\Models\Profile; +use App\Models\Organization; use App\Services\OpenGraphCanvas; use Illuminate\Support\Facades\Cache; @@ -24,7 +24,7 @@ public function __construct(private GitHubProvider $github) // } - public function handle(Profile $organization): string + public function handle(Organization $organization): string { return Cache::remember( "open-graph-image:organization:{$organization->id}:{$organization->openGraphImageVersion()}", @@ -36,7 +36,7 @@ public function handle(Profile $organization): string /** * Drawn like a profile's card, in the organization's lane, /dev/null's blue until its admins choose one. */ - private function render(Profile $organization): string + private function render(Organization $organization): string { $canvas = new OpenGraphCanvas($this->github); $lane = $organization->lane() === null ? OpenGraphCanvas::PRIMARY : $canvas->lane($organization->lane()); diff --git a/app/Actions/DeleteOrganization.php b/app/Actions/DeleteOrganization.php index 75db3006dc..491f65f798 100644 --- a/app/Actions/DeleteOrganization.php +++ b/app/Actions/DeleteOrganization.php @@ -5,7 +5,7 @@ namespace App\Actions; use App\Enums\TopicType; -use App\Models\Profile; +use App\Models\Organization; use Illuminate\Support\Facades\DB; final readonly class DeleteOrganization @@ -14,7 +14,7 @@ * 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 + public function handle(Organization $organization): void { DB::transaction(function () use ($organization): void { DB::table('commit_topics') @@ -24,6 +24,7 @@ public function handle(Profile $organization): void $organization->members()->detach(); $organization->delete(); + $organization->profile->delete(); }); } } diff --git a/app/Actions/DeleteOrganizationMember.php b/app/Actions/DeleteOrganizationMember.php index fafce59fd1..2ec551ef5c 100644 --- a/app/Actions/DeleteOrganizationMember.php +++ b/app/Actions/DeleteOrganizationMember.php @@ -4,9 +4,8 @@ namespace App\Actions; -use App\Enums\OrganizationRole; use App\Exceptions\OrganizationMustHaveAnAdmin; -use App\Models\Profile; +use App\Models\Organization; use App\Models\User; use Illuminate\Support\Facades\DB; @@ -19,12 +18,12 @@ * * @throws OrganizationMustHaveAnAdmin */ - public function handle(Profile $organization, User $member): void + public function handle(Organization $organization, User $member): void { DB::transaction(function () use ($organization, $member): void { - Profile::query()->whereKey($organization->id)->lockForUpdate()->first(); + Organization::query()->whereKey($organization->id)->lockForUpdate()->first(); - if ($organization->roleOf($member) === OrganizationRole::Admin && $organization->admins()->whereKeyNot($member->id)->doesntExist()) { + if ($organization->isAdmin($member) && $organization->admins()->whereKeyNot($member->id)->doesntExist()) { throw new OrganizationMustHaveAnAdmin($organization); } diff --git a/app/Actions/UpdateMentionedOrganizations.php b/app/Actions/UpdateMentionedOrganizations.php index 31546db70c..a6aab02dc5 100644 --- a/app/Actions/UpdateMentionedOrganizations.php +++ b/app/Actions/UpdateMentionedOrganizations.php @@ -6,6 +6,7 @@ use App\Contracts\Services\GitHubProvider; use App\Enums\ProfileType; +use App\Models\Organization; use App\Models\Profile; use Carbon\CarbonImmutable; use Closure; @@ -47,7 +48,7 @@ public function __construct( public function handle(array $handles): ?CarbonImmutable { $profiles = Profile::query()->whereIn(DB::raw('LOWER(handle)'), $handles)->get(); - $known = $profiles->where('type', ProfileType::Organization); + $known = Organization::query()->whereKey($profiles->where('type', ProfileType::Organization)->modelKeys())->get(); /** @var list|null), Closure(): mixed}> $lookups */ $lookups = collect($handles) @@ -58,9 +59,9 @@ public function handle(array $handles): ?CarbonImmutable fn (): bool => Cache::put("github-organizations:not-found:{$handle}", true, now()->addDays(self::NotFoundDays)), ]) ->merge($known - ->filter(fn (Profile $organization): bool => $organization->github_synced_at?->addHours(Profile::FRESH_FOR_HOURS)->isPast() ?? true) - ->map(fn (Profile $organization): array => [ - fn (): ?array => $this->github->account($organization->github_id), + ->filter(fn (Organization $organization): bool => $organization->github_synced_at?->addHours(Organization::FRESH_FOR_HOURS)->isPast() ?? true) + ->map(fn (Organization $organization): array => [ + fn (): ?array => $this->github->account($organization->profile->github_id), function () use ($organization): void { $this->deleteOrganization->handle($organization); }, diff --git a/app/Actions/UpdateOrCreateOrganization.php b/app/Actions/UpdateOrCreateOrganization.php index 1aaff83f97..6501458bc7 100644 --- a/app/Actions/UpdateOrCreateOrganization.php +++ b/app/Actions/UpdateOrCreateOrganization.php @@ -6,6 +6,7 @@ use App\Dto\GitHubProfile; use App\Enums\ProfileType; +use App\Models\Organization; use App\Models\Profile; use Illuminate\Support\Arr; use Illuminate\Support\Facades\DB; @@ -23,20 +24,23 @@ public function __construct(private UpdateStaleHandles $updateStaleHandles) * * @param array $account */ - public function handle(array $account): Profile + public function handle(array $account): Organization { $profile = GitHubProfile::fromAccount($account); $githubId = Arr::get($account, 'id'); assert(is_int($githubId)); - return DB::transaction(function () use ($profile, $githubId): Profile { + return DB::transaction(function () use ($profile, $githubId): Organization { $this->updateStaleHandles->handle($profile->handle, (string) $githubId); - return Profile::query()->updateOrCreate(['github_id' => (string) $githubId], [ + // A profile never changes type: a user's account GitHub has since turned into an organization fails on its + // GitHub ID, until that case is handled... + $organization = Profile::query()->updateOrCreate(['github_id' => (string) $githubId, 'type' => ProfileType::Organization], [ ...$profile->attributes(), - 'type' => ProfileType::Organization, 'github_synced_at' => now(), ]); + + return Organization::query()->firstOrCreate(['id' => $organization->id]); }); } } diff --git a/app/Actions/UpdateOrCreateUser.php b/app/Actions/UpdateOrCreateUser.php index 84c657ad9e..61e050e463 100644 --- a/app/Actions/UpdateOrCreateUser.php +++ b/app/Actions/UpdateOrCreateUser.php @@ -31,10 +31,11 @@ public function handle(GitHubUser $githubUser): User return DB::transaction(function () use ($githubId, $profile, $email, $hasSponsorsListing): User { $this->updateStaleHandles->handle($profile->handle, $githubId); + // A profile never changes type, so an organization's GitHub ID would fail here, but GitHub never signs one in... Profile::query()->updateOrCreate([ 'github_id' => $githubId, - ], [ 'type' => ProfileType::User, + ], [ ...$profile->attributes(), 'github_synced_at' => now(), ]); diff --git a/app/Actions/UpdateOrganizationLane.php b/app/Actions/UpdateOrganizationLane.php index b200630b14..a05203a27f 100644 --- a/app/Actions/UpdateOrganizationLane.php +++ b/app/Actions/UpdateOrganizationLane.php @@ -4,11 +4,11 @@ namespace App\Actions; -use App\Models\Profile; +use App\Models\Organization; final readonly class UpdateOrganizationLane { - public function handle(Profile $organization, ?int $lane, ?string $color): void + public function handle(Organization $organization, ?int $lane, ?string $color): void { $organization->update(['preferred_lane' => $lane, 'lane_color' => $color]); } diff --git a/app/Actions/UpdateOrganizationMemberRole.php b/app/Actions/UpdateOrganizationMemberRole.php index 26155cdee5..a321a6fc54 100644 --- a/app/Actions/UpdateOrganizationMemberRole.php +++ b/app/Actions/UpdateOrganizationMemberRole.php @@ -6,7 +6,7 @@ use App\Enums\OrganizationRole; use App\Exceptions\OrganizationMustHaveAnAdmin; -use App\Models\Profile; +use App\Models\Organization; use App\Models\User; use Illuminate\Support\Facades\DB; @@ -19,10 +19,10 @@ * * @throws OrganizationMustHaveAnAdmin */ - public function handle(Profile $organization, User $member, OrganizationRole $role): void + public function handle(Organization $organization, User $member, OrganizationRole $role): void { DB::transaction(function () use ($organization, $member, $role): void { - Profile::query()->whereKey($organization->id)->lockForUpdate()->first(); + Organization::query()->whereKey($organization->id)->lockForUpdate()->first(); if ($role === OrganizationRole::Member && $organization->admins()->whereKeyNot($member->id)->doesntExist()) { throw new OrganizationMustHaveAnAdmin($organization); diff --git a/app/Exceptions/OrganizationMustHaveAnAdmin.php b/app/Exceptions/OrganizationMustHaveAnAdmin.php index d63299e26f..c4c7c79e4c 100644 --- a/app/Exceptions/OrganizationMustHaveAnAdmin.php +++ b/app/Exceptions/OrganizationMustHaveAnAdmin.php @@ -4,7 +4,7 @@ namespace App\Exceptions; -use App\Models\Profile; +use App\Models\Organization; use RuntimeException; /** @@ -13,7 +13,7 @@ */ final class OrganizationMustHaveAnAdmin extends RuntimeException { - public function __construct(public readonly Profile $organization) + public function __construct(public readonly Organization $organization) { parent::__construct(__('@:handle needs at least one admin.', ['handle' => $organization->handle])); } diff --git a/app/Http/Controllers/Api/OrganizationMentionController.php b/app/Http/Controllers/Api/OrganizationMentionController.php index cdb065ffb9..8303fd6124 100644 --- a/app/Http/Controllers/Api/OrganizationMentionController.php +++ b/app/Http/Controllers/Api/OrganizationMentionController.php @@ -7,7 +7,7 @@ use App\Http\Requests\ListApiOrganizationMentionsRequest; use App\Http\Resources\CommitResource; use App\Models\Commit; -use App\Models\Profile; +use App\Models\Organization; use App\Models\User; use App\Queries\OrganizationMentionsQuery; use App\Queries\ReplyPreviewsQuery; @@ -22,7 +22,7 @@ public function index(ListApiOrganizationMentionsRequest $request, #[CurrentUser] ?User $currentUser, OrganizationMentionsQuery $mentions, ReplyPreviewsQuery $replyPreviews): AnonymousResourceCollection { $organization = $request->organization(); - assert($organization instanceof Profile); + assert($organization instanceof Organization); if ($currentUser instanceof User) { $signedCommits = $mentions->builder($organization, $currentUser)->cursorPaginate(20); diff --git a/app/Http/Controllers/OrganizationAdminController.php b/app/Http/Controllers/OrganizationAdminController.php index 935789e9d8..99e6ca20a1 100644 --- a/app/Http/Controllers/OrganizationAdminController.php +++ b/app/Http/Controllers/OrganizationAdminController.php @@ -7,9 +7,8 @@ use App\Actions\CreateOrganizationAdmin; use App\Contracts\Services\GitHubProvider; use App\Enums\GitHubOrganizationMembership; -use App\Enums\ProfileType; use App\Http\Requests\CreateOrganizationAdminRequest; -use App\Models\Profile; +use App\Models\Organization; use App\Models\User; use Illuminate\Container\Attributes\CurrentUser; use Illuminate\Http\RedirectResponse; @@ -32,7 +31,7 @@ /** * Claiming starts from a form, so no other site can send a user who already granted the scope through it. */ - public function create(CreateOrganizationAdminRequest $request, Profile $organization): SymfonyResponse + public function create(CreateOrganizationAdminRequest $request, Organization $organization): SymfonyResponse { $claimed = $this->claimed($organization); @@ -50,11 +49,10 @@ public function create(CreateOrganizationAdminRequest $request, Profile $organiz */ public function store(Request $request, #[CurrentUser] User $user, GitHubProvider $github, CreateOrganizationAdmin $action): RedirectResponse { - $organization = Profile::query() - ->where('type', ProfileType::Organization) + $organization = Organization::query() ->find($request->session()->pull(self::CLAIMING)); - if (! $organization instanceof Profile) { + if (! $organization instanceof Organization) { Inertia::flash('toast', ['type' => 'error', 'message' => __('Unable to claim this organization. Please try again.')]); return to_route('log.show', $user->log); @@ -92,7 +90,7 @@ public function store(Request $request, #[CurrentUser] User $user, GitHubProvide // An organization restricting OAuth apps hides its members from those its owners haven't approved, which they // do from the app's page in their settings, where members request it. - $failure = match ($github->organizationMembership($githubUser->token, $handle, $organization->github_id)) { + $failure = match ($github->organizationMembership($githubUser->token, $handle, $organization->profile->github_id)) { GitHubOrganizationMembership::Member => null, GitHubOrganizationMembership::NotMember => [__('GitHub says you are not a member of @:handle.', ['handle' => $handle]), null], GitHubOrganizationMembership::Restricted => [ @@ -132,7 +130,7 @@ private function github(): GitHubOAuthProvider /** * Only the first claim through GitHub makes an admin. Why another one fails, if the page was claimed. */ - private function claimed(Profile $organization): ?string + private function claimed(Organization $organization): ?string { $admin = $organization->admins()->oldest('organization_members.created_at')->first(); diff --git a/app/Http/Controllers/OrganizationController.php b/app/Http/Controllers/OrganizationController.php index 9a800cbc79..2e60597a63 100644 --- a/app/Http/Controllers/OrganizationController.php +++ b/app/Http/Controllers/OrganizationController.php @@ -6,7 +6,7 @@ use App\Enums\OrganizationRole; use App\Http\Requests\ShowOrganizationRequest; -use App\Models\Profile; +use App\Models\Organization; use App\Models\User; use App\Queries\OrganizationMentionsQuery; use App\Services\PageMetadata; @@ -27,7 +27,7 @@ public function show(ShowOrganizationRequest $request, #[CurrentUser] ?User $currentUser, OrganizationMentionsQuery $mentions, PageMetadata $metadata): RedirectResponse|Response { $organization = $request->organization(); - assert($organization instanceof Profile); + assert($organization instanceof Organization); if ($request->route('user') !== $organization->handle) { // The raw query, since Symfony's normalized one keeps only the last value of a repeated key... diff --git a/app/Http/Controllers/OrganizationLaneController.php b/app/Http/Controllers/OrganizationLaneController.php index de8be04c7b..863005dd19 100644 --- a/app/Http/Controllers/OrganizationLaneController.php +++ b/app/Http/Controllers/OrganizationLaneController.php @@ -6,7 +6,7 @@ use App\Actions\UpdateOrganizationLane; use App\Http\Requests\UpdateOrganizationLaneRequest; -use App\Models\Profile; +use App\Models\Organization; use Illuminate\Http\RedirectResponse; use Inertia\Inertia; @@ -15,7 +15,7 @@ */ final readonly class OrganizationLaneController { - public function update(UpdateOrganizationLaneRequest $request, Profile $organization, UpdateOrganizationLane $action): RedirectResponse + public function update(UpdateOrganizationLaneRequest $request, Organization $organization, UpdateOrganizationLane $action): RedirectResponse { $action->handle($organization, $request->lane(), $request->color()); diff --git a/app/Http/Controllers/OrganizationMemberController.php b/app/Http/Controllers/OrganizationMemberController.php index 70b2c7ba6f..6391a9764c 100644 --- a/app/Http/Controllers/OrganizationMemberController.php +++ b/app/Http/Controllers/OrganizationMemberController.php @@ -13,7 +13,7 @@ use App\Http\Requests\DeleteOrganizationMemberRequest; use App\Http\Requests\ListOrganizationMembersRequest; use App\Http\Requests\UpdateOrganizationMemberRequest; -use App\Models\Profile; +use App\Models\Organization; use App\Models\User; use Illuminate\Container\Attributes\CurrentUser; use Illuminate\Http\RedirectResponse; @@ -28,7 +28,7 @@ /** * Every member sees who the members are, first joined first. Only admins can change them. */ - public function index(ListOrganizationMembersRequest $request, #[CurrentUser] User $user, Profile $organization): Response + public function index(ListOrganizationMembersRequest $request, #[CurrentUser] User $user, Organization $organization): Response { return Inertia::render('organization-member/index', [ 'organization' => $organization->makeVisible('id'), @@ -41,7 +41,7 @@ public function index(ListOrganizationMembersRequest $request, #[CurrentUser] Us ]); } - public function store(CreateOrganizationMemberRequest $request, Profile $organization, CreateOrganizationMember $action): RedirectResponse + public function store(CreateOrganizationMemberRequest $request, Organization $organization, CreateOrganizationMember $action): RedirectResponse { $member = $request->member(); assert($member instanceof User); @@ -51,7 +51,7 @@ public function store(CreateOrganizationMemberRequest $request, Profile $organiz return back(); } - public function update(UpdateOrganizationMemberRequest $request, Profile $organization, User $member, UpdateOrganizationMemberRole $action): RedirectResponse + public function update(UpdateOrganizationMemberRequest $request, Organization $organization, User $member, UpdateOrganizationMemberRole $action): RedirectResponse { $role = $request->role(); assert($role instanceof OrganizationRole); @@ -68,7 +68,7 @@ public function update(UpdateOrganizationMemberRequest $request, Profile $organi /** * Leaving takes the user to the organization's page, as its settings are for members only. */ - public function destroy(DeleteOrganizationMemberRequest $request, #[CurrentUser] User $user, Profile $organization, User $member, DeleteOrganizationMember $action): RedirectResponse + public function destroy(DeleteOrganizationMemberRequest $request, #[CurrentUser] User $user, Organization $organization, User $member, DeleteOrganizationMember $action): RedirectResponse { try { $action->handle($organization, $member); diff --git a/app/Http/Controllers/OrganizationMentionController.php b/app/Http/Controllers/OrganizationMentionController.php index e78f5a311b..65a6b201bf 100644 --- a/app/Http/Controllers/OrganizationMentionController.php +++ b/app/Http/Controllers/OrganizationMentionController.php @@ -4,7 +4,7 @@ namespace App\Http\Controllers; -use App\Models\Profile; +use App\Models\Organization; use Illuminate\Http\RedirectResponse; final readonly class OrganizationMentionController @@ -12,7 +12,7 @@ /** * Mentions link to the organization they captured, so they still lead to its page after it's renamed. */ - public function show(Profile $organization): RedirectResponse + public function show(Organization $organization): RedirectResponse { return to_route('user.show', ['user' => $organization->handle]); } diff --git a/app/Http/Controllers/OrganizationProfileController.php b/app/Http/Controllers/OrganizationProfileController.php index 7863e38009..ae72211da2 100644 --- a/app/Http/Controllers/OrganizationProfileController.php +++ b/app/Http/Controllers/OrganizationProfileController.php @@ -6,7 +6,7 @@ use App\Enums\OrganizationRole; use App\Http\Requests\ShowOrganizationProfileRequest; -use App\Models\Profile; +use App\Models\Organization; use Inertia\Inertia; use Inertia\Response; @@ -15,7 +15,7 @@ */ final readonly class OrganizationProfileController { - public function edit(ShowOrganizationProfileRequest $request, Profile $organization): Response + public function edit(ShowOrganizationProfileRequest $request, Organization $organization): Response { return Inertia::render('organization-profile/edit', [ 'organization' => $organization->makeVisible('id'), diff --git a/app/Http/Controllers/ProfileOpenGraphImageController.php b/app/Http/Controllers/ProfileOpenGraphImageController.php index 101095aa02..0bac7f6e38 100644 --- a/app/Http/Controllers/ProfileOpenGraphImageController.php +++ b/app/Http/Controllers/ProfileOpenGraphImageController.php @@ -6,6 +6,7 @@ use App\Actions\CreateOrganizationOpenGraphImage; use App\Actions\CreateUserOpenGraphImage; +use App\Models\Organization; use App\Models\Profile; use App\Models\User; use Illuminate\Http\Response; @@ -13,11 +14,12 @@ final readonly class ProfileOpenGraphImageController { /** - * Users and organizations share the "@handle" paths, so the profile at the handle says which card to draw. + * Users and organizations share the "@handle" paths, so the profile at the handle says which card to draw. A + * profile that is neither a user's nor an organization's has none. */ public function show(Profile $profile, CreateUserOpenGraphImage $users, CreateOrganizationOpenGraphImage $organizations): Response { - return response($profile->user instanceof User ? $users->handle($profile->user) : $organizations->handle($profile), headers: [ + return response($profile->user instanceof User ? $users->handle($profile->user) : $organizations->handle(Organization::query()->findOrFail($profile->id)), headers: [ 'Content-Type' => 'image/png', 'Cache-Control' => 'public, max-age=3600', ]); diff --git a/app/Http/Requests/CreateOrganizationAdminRequest.php b/app/Http/Requests/CreateOrganizationAdminRequest.php index 5c5972c8d2..3c4fb40380 100644 --- a/app/Http/Requests/CreateOrganizationAdminRequest.php +++ b/app/Http/Requests/CreateOrganizationAdminRequest.php @@ -4,22 +4,16 @@ namespace App\Http\Requests; -use App\Enums\ProfileType; -use App\Models\Profile; -use Illuminate\Auth\Access\Response; use Illuminate\Foundation\Http\FormRequest; final class CreateOrganizationAdminRequest extends FormRequest { /** - * Only organizations' pages can be claimed. A user's profile looks like it does not exist. + * Only organizations' pages can be claimed, and the route only finds organizations: a user's profile is a 404. */ - public function authorize(): Response + public function authorize(): bool { - $organization = $this->route('organization'); - assert($organization instanceof Profile); - - return $organization->type === ProfileType::Organization ? Response::allow() : Response::denyAsNotFound(); + return true; } /** diff --git a/app/Http/Requests/CreateOrganizationMemberRequest.php b/app/Http/Requests/CreateOrganizationMemberRequest.php index 992850aae1..8b94d03ac6 100644 --- a/app/Http/Requests/CreateOrganizationMemberRequest.php +++ b/app/Http/Requests/CreateOrganizationMemberRequest.php @@ -4,8 +4,7 @@ namespace App\Http\Requests; -use App\Enums\OrganizationRole; -use App\Models\Profile; +use App\Models\Organization; use App\Models\User; use Illuminate\Auth\Access\Response; use Illuminate\Foundation\Http\FormRequest; @@ -18,10 +17,10 @@ public function authorize(): Response $user = $this->user(); assert($user instanceof User); - return match ($this->organization()->roleOf($user)) { - null => Response::denyAsNotFound(), - OrganizationRole::Member => Response::deny('Only admins can add members.'), - OrganizationRole::Admin => Response::allow(), + return match (true) { + ! $this->organization()->isMember($user) => Response::denyAsNotFound(), + ! $this->organization()->isAdmin($user) => Response::deny('Only admins can add members.'), + default => Response::allow(), }; } @@ -54,16 +53,16 @@ public function after(): array $validator->errors()->add('handle', "There's no @{$this->handle()} on /dev/null."); } elseif ($user->hasBlockWith($member)) { $validator->errors()->add('handle', "You can't add {$member->mention()}."); - } elseif ($this->organization()->roleOf($member) instanceof OrganizationRole) { + } elseif ($this->organization()->isMember($member)) { $validator->errors()->add('handle', "{$member->mention()} is already a member."); } }]; } - public function organization(): Profile + public function organization(): Organization { $organization = $this->route('organization'); - assert($organization instanceof Profile); + assert($organization instanceof Organization); return $organization; } diff --git a/app/Http/Requests/DeleteOrganizationMemberRequest.php b/app/Http/Requests/DeleteOrganizationMemberRequest.php index 9d3611d87f..a12bdb7e01 100644 --- a/app/Http/Requests/DeleteOrganizationMemberRequest.php +++ b/app/Http/Requests/DeleteOrganizationMemberRequest.php @@ -4,8 +4,7 @@ namespace App\Http\Requests; -use App\Enums\OrganizationRole; -use App\Models\Profile; +use App\Models\Organization; use App\Models\User; use Illuminate\Auth\Access\Response; use Illuminate\Foundation\Http\FormRequest; @@ -22,8 +21,8 @@ public function authorize(): Response assert($user instanceof User); return match (true) { - ! $this->organization()->roleOf($user) instanceof OrganizationRole || ! $this->organization()->roleOf($this->member()) instanceof OrganizationRole => Response::denyAsNotFound(), - ! $user->is($this->member()) && $this->organization()->roleOf($user) !== OrganizationRole::Admin => Response::deny('Only admins can remove members.'), + ! $this->organization()->isMember($user) || ! $this->organization()->isMember($this->member()) => Response::denyAsNotFound(), + ! $user->is($this->member()) && ! $this->organization()->isAdmin($user) => Response::deny('Only admins can remove members.'), default => Response::allow(), }; } @@ -42,7 +41,7 @@ public function rules(): array public function after(): array { return [function (Validator $validator): void { - $isLastAdmin = $this->organization()->roleOf($this->member()) === OrganizationRole::Admin + $isLastAdmin = $this->organization()->isAdmin($this->member()) && $this->organization()->admins()->whereKeyNot($this->member()->id)->doesntExist(); if ($isLastAdmin) { @@ -51,10 +50,10 @@ public function after(): array }]; } - public function organization(): Profile + public function organization(): Organization { $organization = $this->route('organization'); - assert($organization instanceof Profile); + assert($organization instanceof Organization); return $organization; } diff --git a/app/Http/Requests/ListApiOrganizationMentionsRequest.php b/app/Http/Requests/ListApiOrganizationMentionsRequest.php index a5acfe20c4..ac427ab196 100644 --- a/app/Http/Requests/ListApiOrganizationMentionsRequest.php +++ b/app/Http/Requests/ListApiOrganizationMentionsRequest.php @@ -4,18 +4,17 @@ namespace App\Http\Requests; -use App\Enums\ProfileType; -use App\Models\Profile; +use App\Models\Organization; use Illuminate\Auth\Access\Response; use Illuminate\Foundation\Http\FormRequest; final class ListApiOrganizationMentionsRequest extends FormRequest { - private ?Profile $organization = null; + private ?Organization $organization = null; public function authorize(): Response { - return $this->organization() instanceof Profile ? Response::allow() : Response::denyAsNotFound(); + return $this->organization() instanceof Organization ? Response::allow() : Response::denyAsNotFound(); } /** @@ -29,14 +28,13 @@ public function rules(): array /** * Only organizations that already have a page, so looking one up never asks GitHub. */ - public function organization(): ?Profile + public function organization(): ?Organization { $handle = $this->route('handle'); assert(is_string($handle)); - return $this->organization ??= Profile::query() - ->where('type', ProfileType::Organization) - ->where('handle', $handle) + return $this->organization ??= Organization::query() + ->whereRelation('profile', 'handle', $handle) ->first(); } } diff --git a/app/Http/Requests/ListOrganizationMembersRequest.php b/app/Http/Requests/ListOrganizationMembersRequest.php index 7bd3a48b73..3cb20afe1f 100644 --- a/app/Http/Requests/ListOrganizationMembersRequest.php +++ b/app/Http/Requests/ListOrganizationMembersRequest.php @@ -4,8 +4,7 @@ namespace App\Http\Requests; -use App\Enums\OrganizationRole; -use App\Models\Profile; +use App\Models\Organization; use App\Models\User; use Illuminate\Auth\Access\Response; use Illuminate\Foundation\Http\FormRequest; @@ -21,9 +20,9 @@ public function authorize(): Response assert($user instanceof User); $organization = $this->route('organization'); - assert($organization instanceof Profile); + assert($organization instanceof Organization); - return $organization->roleOf($user) instanceof OrganizationRole ? Response::allow() : Response::denyAsNotFound(); + return $organization->isMember($user) ? Response::allow() : Response::denyAsNotFound(); } /** diff --git a/app/Http/Requests/ShowApiOrganizationRequest.php b/app/Http/Requests/ShowApiOrganizationRequest.php index 59b937e77c..670e9ed245 100644 --- a/app/Http/Requests/ShowApiOrganizationRequest.php +++ b/app/Http/Requests/ShowApiOrganizationRequest.php @@ -4,18 +4,17 @@ namespace App\Http\Requests; -use App\Enums\ProfileType; -use App\Models\Profile; +use App\Models\Organization; use Illuminate\Auth\Access\Response; use Illuminate\Foundation\Http\FormRequest; final class ShowApiOrganizationRequest extends FormRequest { - private ?Profile $organization = null; + private ?Organization $organization = null; public function authorize(): Response { - return $this->organization() instanceof Profile ? Response::allow() : Response::denyAsNotFound(); + return $this->organization() instanceof Organization ? Response::allow() : Response::denyAsNotFound(); } /** @@ -29,14 +28,13 @@ public function rules(): array /** * Only organizations that already have a page, so looking one up never asks GitHub. */ - public function organization(): ?Profile + public function organization(): ?Organization { $handle = $this->route('handle'); assert(is_string($handle)); - return $this->organization ??= Profile::query() - ->where('type', ProfileType::Organization) - ->where('handle', $handle) + return $this->organization ??= Organization::query() + ->whereRelation('profile', 'handle', $handle) ->first(); } } diff --git a/app/Http/Requests/ShowOrganizationProfileRequest.php b/app/Http/Requests/ShowOrganizationProfileRequest.php index fe49c2352b..09cbb696a3 100644 --- a/app/Http/Requests/ShowOrganizationProfileRequest.php +++ b/app/Http/Requests/ShowOrganizationProfileRequest.php @@ -4,8 +4,7 @@ namespace App\Http\Requests; -use App\Enums\OrganizationRole; -use App\Models\Profile; +use App\Models\Organization; use App\Models\User; use Illuminate\Auth\Access\Response; use Illuminate\Foundation\Http\FormRequest; @@ -21,9 +20,9 @@ public function authorize(): Response assert($user instanceof User); $organization = $this->route('organization'); - assert($organization instanceof Profile); + assert($organization instanceof Organization); - return $organization->roleOf($user) === OrganizationRole::Admin ? Response::allow() : Response::denyAsNotFound(); + return $organization->isAdmin($user) ? Response::allow() : Response::denyAsNotFound(); } /** diff --git a/app/Http/Requests/ShowOrganizationRequest.php b/app/Http/Requests/ShowOrganizationRequest.php index fb11b533b8..ee07ccc82a 100644 --- a/app/Http/Requests/ShowOrganizationRequest.php +++ b/app/Http/Requests/ShowOrganizationRequest.php @@ -4,18 +4,17 @@ namespace App\Http\Requests; -use App\Enums\ProfileType; -use App\Models\Profile; +use App\Models\Organization; use Illuminate\Auth\Access\Response; use Illuminate\Foundation\Http\FormRequest; final class ShowOrganizationRequest extends FormRequest { - private ?Profile $organization = null; + private ?Organization $organization = null; public function authorize(): Response { - return $this->organization() instanceof Profile ? Response::allow() : Response::denyAsNotFound(); + return $this->organization() instanceof Organization ? Response::allow() : Response::denyAsNotFound(); } /** @@ -40,16 +39,17 @@ public function tab(): string * Organizations share the "@handle" pages with users, so this is the handle no user has, whatever its case, * the exact case first. */ - public function organization(): ?Profile + public function organization(): ?Organization { $handle = $this->route('user'); assert(is_string($handle)); - return $this->organization ??= Profile::query() - ->where('type', ProfileType::Organization) - ->whereRaw('LOWER(handle) = LOWER(?)', [$handle]) - ->orderByRaw('handle = ? desc', [$handle]) - ->orderBy('handle') + return $this->organization ??= Organization::query() + ->select('organizations.*') + ->join('profiles', 'profiles.id', '=', 'organizations.id') + ->whereRaw('LOWER(profiles.handle) = LOWER(?)', [$handle]) + ->orderByRaw('profiles.handle = ? desc', [$handle]) + ->orderBy('profiles.handle') ->first(); } } diff --git a/app/Http/Requests/UpdateOrganizationLaneRequest.php b/app/Http/Requests/UpdateOrganizationLaneRequest.php index d3167be71a..e6cb383ca5 100644 --- a/app/Http/Requests/UpdateOrganizationLaneRequest.php +++ b/app/Http/Requests/UpdateOrganizationLaneRequest.php @@ -4,7 +4,7 @@ namespace App\Http\Requests; -use App\Models\Profile; +use App\Models\Organization; use App\Models\User; use Illuminate\Auth\Access\Response; use Illuminate\Foundation\Http\FormRequest; @@ -12,8 +12,7 @@ final class UpdateOrganizationLaneRequest extends FormRequest { /** - * Only the organization's admins choose its color. Users' profiles have no admins, so they look like they do not - * exist. + * Only the organization's admins choose its color, so to everyone else it looks like it does not exist. */ public function authorize(): Response { @@ -21,9 +20,9 @@ public function authorize(): Response assert($user instanceof User); $organization = $this->route('organization'); - assert($organization instanceof Profile); + assert($organization instanceof Organization); - return $organization->admins()->whereKey($user->id)->exists() ? Response::allow() : Response::denyAsNotFound(); + return $organization->isAdmin($user) ? Response::allow() : Response::denyAsNotFound(); } /** diff --git a/app/Http/Requests/UpdateOrganizationMemberRequest.php b/app/Http/Requests/UpdateOrganizationMemberRequest.php index 582cec0294..bc72d7d1bd 100644 --- a/app/Http/Requests/UpdateOrganizationMemberRequest.php +++ b/app/Http/Requests/UpdateOrganizationMemberRequest.php @@ -5,7 +5,7 @@ namespace App\Http\Requests; use App\Enums\OrganizationRole; -use App\Models\Profile; +use App\Models\Organization; use App\Models\User; use Illuminate\Auth\Access\Response; use Illuminate\Foundation\Http\FormRequest; @@ -25,8 +25,8 @@ public function authorize(): Response assert($user instanceof User); return match (true) { - ! $this->organization()->roleOf($user) instanceof OrganizationRole || ! $this->organization()->roleOf($this->member()) instanceof OrganizationRole => Response::denyAsNotFound(), - $this->organization()->roleOf($user) !== OrganizationRole::Admin => Response::deny('Only admins can change roles.'), + ! $this->organization()->isMember($user) || ! $this->organization()->isMember($this->member()) => Response::denyAsNotFound(), + ! $this->organization()->isAdmin($user) => Response::deny('Only admins can change roles.'), $user->is($this->member()) && $this->role() === OrganizationRole::Admin => Response::deny("You're already an admin."), default => Response::allow(), }; @@ -54,10 +54,10 @@ public function after(): array }]; } - public function organization(): Profile + public function organization(): Organization { $organization = $this->route('organization'); - assert($organization instanceof Profile); + assert($organization instanceof Organization); return $organization; } diff --git a/app/Http/Resources/OrganizationResource.php b/app/Http/Resources/OrganizationResource.php index 33064c4347..996d95832c 100644 --- a/app/Http/Resources/OrganizationResource.php +++ b/app/Http/Resources/OrganizationResource.php @@ -4,14 +4,14 @@ namespace App\Http\Resources; -use App\Models\Profile; +use App\Models\Organization; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; /** * An organization's GitHub profile, as its page on the web shows it. * - * @mixin Profile + * @mixin Organization */ final class OrganizationResource extends JsonResource { diff --git a/app/Models/Organization.php b/app/Models/Organization.php new file mode 100644 index 0000000000..1273d10c5f --- /dev/null +++ b/app/Models/Organization.php @@ -0,0 +1,181 @@ + */ + use HasFactory; + + use HasUuids; + + /** + * Organizations are refreshed from GitHub when a commit mentions them, at most this often. + */ + public const int FRESH_FOR_HOURS = 24; + + /** + * Every organization shows its GitHub identity, so its profile comes with it. + * + * @var list + */ + protected $with = ['profile']; + + /** + * @return array + */ + public function casts(): array + { + return [ + 'id' => 'string', + 'preferred_lane' => 'integer', + 'lane_color' => 'string', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + } + + /** + * @return BelongsTo + */ + public function profile(): BelongsTo + { + return $this->belongsTo(Profile::class, 'id'); + } + + /** + * Changes whenever anything drawn on the organization's link preview image may have changed. + */ + public function openGraphImageVersion(): string + { + return hash('xxh3', (string) json_encode([$this->name, $this->handle, $this->avatar_url, $this->bio, $this->lane()])); + } + + /** + * The lane the organization's admins chose, like a user's, or null for /dev/null's blue, which organizations have + * until then. + */ + public function lane(): int|string|null + { + return $this->lane_color ?? $this->preferred_lane; + } + + /** + * The organization's members on /dev/null, each an admin or a member. GitHub only lets the first admin in, who + * claimed the page: leaving the organization there doesn't remove anyone here. + * + * @return BelongsToMany + */ + public function members(): BelongsToMany + { + return $this->belongsToMany(User::class, 'organization_members', 'organization_id')->withPivot('role')->withTimestamps(updatedAt: false); + } + + /** + * The members who manage the organization's members. Attaching through it makes an admin. + * + * @return BelongsToMany + */ + public function admins(): BelongsToMany + { + return $this->members()->withPivotValue('role', OrganizationRole::Admin->value); + } + + /** + * The user's role in the organization, or null when they aren't a member. + */ + public function roleOf(User $user): ?OrganizationRole + { + $role = $this->members()->newPivotQuery()->where('user_id', $user->id)->value('role'); + + return is_string($role) ? OrganizationRole::from($role) : null; + } + + /** + * Whether the user is one of the organization's members, admin or not. + */ + public function isMember(User $user): bool + { + return $this->roleOf($user) instanceof OrganizationRole; + } + + public function isAdmin(User $user): bool + { + return $this->roleOf($user) === OrganizationRole::Admin; + } + + /** + * @return array + */ + public function toArray(): array + { + return [ + 'handle' => $this->handle, + 'name' => $this->name, + 'avatar_url' => $this->avatar_url, + 'bio' => $this->bio, + 'location' => $this->location, + 'website' => $this->website, + ...parent::toArray(), + ]; + } + + /** + * The GitHub identity lives on the profile: it reads through, and only the profile changes it. + * + * @param string $key + */ + public function getAttribute($key): mixed + { + return in_array($key, Profile::USER_ATTRIBUTES, true) ? $this->profile->getAttribute($key) : parent::getAttribute($key); + } + + /** + * @param string $key + */ + public function setAttribute($key, $value): mixed + { + throw_if(in_array($key, Profile::USER_ATTRIBUTES, true), LogicException::class, "Update the {$key} of an organization on its profile."); + + return parent::setAttribute($key, $value); + } +} diff --git a/app/Models/Profile.php b/app/Models/Profile.php index dc60ab886e..cb606ac3b8 100644 --- a/app/Models/Profile.php +++ b/app/Models/Profile.php @@ -4,7 +4,6 @@ namespace App\Models; -use App\Enums\OrganizationRole; use App\Enums\ProfileType; use Carbon\CarbonInterface; use Database\Factories\ProfileFactory; @@ -14,13 +13,13 @@ use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\Relations\Relation; /** * A GitHub account at its "@handle", mirrored from GitHub: what GitHub calls a profile owner. The handle is GitHub's - * login, which can be renamed, so the account is matched by its GitHub ID. + * login, which can be renamed, so the account is matched by its GitHub ID. Organizations are profiles too, and what + * only they have lives on `Organization`. * * @property-read string $id * @property-read ProfileType $type @@ -31,12 +30,11 @@ * @property-read string|null $bio * @property-read string|null $location * @property-read string|null $website - * @property-read int|null $preferred_lane - * @property-read string|null $lane_color * @property-read CarbonInterface|null $github_synced_at * @property-read CarbonInterface $created_at * @property-read CarbonInterface $updated_at * @property-read User|null $user + * @property-read Organization|null $organization */ #[Visible([ 'handle', @@ -46,7 +44,7 @@ 'location', 'website', ])] -#[Touches(['user'])] +#[Touches(['user', 'organization'])] final class Profile extends Model { /** @use HasFactory */ @@ -59,11 +57,6 @@ final class Profile extends Model */ public const array USER_ATTRIBUTES = ['handle', 'name', 'avatar_url', 'bio', 'location', 'website', 'github_synced_at']; - /** - * Organizations are refreshed from GitHub when a commit mentions them, at most this often. - */ - public const int FRESH_FOR_HOURS = 24; - /** * @return array */ @@ -79,31 +72,12 @@ public function casts(): array 'bio' => 'string', 'location' => 'string', 'website' => 'string', - 'preferred_lane' => 'integer', - 'lane_color' => 'string', 'github_synced_at' => 'datetime', 'created_at' => 'datetime', 'updated_at' => 'datetime', ]; } - /** - * Changes whenever anything drawn on the organization's link preview image may have changed. - */ - public function openGraphImageVersion(): string - { - return hash('xxh3', (string) json_encode([$this->name, $this->handle, $this->avatar_url, $this->bio, $this->lane()])); - } - - /** - * The lane the organization's admins chose, like a user's, or null for /dev/null's blue, which organizations have - * until then. - */ - public function lane(): int|string|null - { - return $this->lane_color ?? $this->preferred_lane; - } - /** * Resolves "@handle" URLs whatever the handle's case, like GitHub. A stale profile may still hold the same handle * in another case, so the exact case wins, then a user's, then always the same one, like users' pages. @@ -128,33 +102,10 @@ public function user(): HasOne } /** - * The organization's members on /dev/null, each an admin or a member. GitHub only lets the first admin in, who - * claimed the page: leaving the organization there doesn't remove anyone here. - * - * @return BelongsToMany - */ - public function members(): BelongsToMany - { - return $this->belongsToMany(User::class, 'organization_members', 'organization_id')->withPivot('role')->withTimestamps(updatedAt: false); - } - - /** - * The members who manage the organization's members. Attaching through it makes an admin. - * - * @return BelongsToMany + * @return HasOne */ - public function admins(): BelongsToMany + public function organization(): HasOne { - return $this->members()->withPivotValue('role', OrganizationRole::Admin->value); - } - - /** - * The user's role in the organization, or null when they aren't a member. - */ - public function roleOf(User $user): ?OrganizationRole - { - $role = $this->members()->newPivotQuery()->where('user_id', $user->id)->value('role'); - - return is_string($role) ? OrganizationRole::from($role) : null; + return $this->hasOne(Organization::class, 'id'); } } diff --git a/app/Models/User.php b/app/Models/User.php index d17824f9e3..2ee8c1793e 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -334,13 +334,13 @@ public function isBanned(): bool } /** - * The organizations the user is a member of, see `Profile::members()`. + * The organizations the user is a member of, see `Organization::members()`. * - * @return BelongsToMany + * @return BelongsToMany */ public function organizations(): BelongsToMany { - return $this->belongsToMany(Profile::class, 'organization_members', 'user_id', 'organization_id')->withPivot('role')->withTimestamps(updatedAt: false); + return $this->belongsToMany(Organization::class, 'organization_members', 'user_id', 'organization_id')->withPivot('role')->withTimestamps(updatedAt: false); } /** diff --git a/app/Queries/CommitBuilder.php b/app/Queries/CommitBuilder.php index e52544324e..f2dda3958b 100644 --- a/app/Queries/CommitBuilder.php +++ b/app/Queries/CommitBuilder.php @@ -8,7 +8,7 @@ use App\Enums\RepliesPerPage; use App\Enums\TopicType; use App\Models\Commit; -use App\Models\Profile; +use App\Models\Organization; use App\Models\User; use App\Services\CommitTranslations; use Illuminate\Contracts\Database\Query\Expression; @@ -217,7 +217,7 @@ public function approved(): self /** * The commits linked to the organization by a mention, whatever handle it had when they were written. */ - public function mentioning(Profile $organization): self + public function mentioning(Organization $organization): self { return $this->whereExists(fn (QueryBuilder $query): QueryBuilder => $query ->from('commit_topics') diff --git a/app/Queries/MentionedOrganizationsQuery.php b/app/Queries/MentionedOrganizationsQuery.php index 4f72941b30..a147191e0c 100644 --- a/app/Queries/MentionedOrganizationsQuery.php +++ b/app/Queries/MentionedOrganizationsQuery.php @@ -4,9 +4,9 @@ namespace App\Queries; -use App\Enums\ProfileType; -use App\Models\Profile; +use App\Models\Organization; use App\Services\Mentions; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; @@ -32,11 +32,10 @@ public function get(string $body, array $mentionedUsers): array return []; } - return Profile::query() - ->where('type', ProfileType::Organization) - ->whereIn(DB::raw('LOWER(handle)'), $handles) - ->get(['id', 'handle']) - ->mapWithKeys(fn (Profile $organization): array => [Str::lower($organization->handle) => $organization->id]) + return Organization::query() + ->whereHas('profile', fn (Builder $query): Builder => $query->whereIn(DB::raw('LOWER(handle)'), $handles)) + ->get() + ->mapWithKeys(fn (Organization $organization): array => [Str::lower($organization->handle) => $organization->id]) ->all(); } } diff --git a/app/Queries/OrganizationMentionsQuery.php b/app/Queries/OrganizationMentionsQuery.php index 7c55f0ccab..960befe135 100644 --- a/app/Queries/OrganizationMentionsQuery.php +++ b/app/Queries/OrganizationMentionsQuery.php @@ -5,7 +5,7 @@ namespace App\Queries; use App\Models\Commit; -use App\Models\Profile; +use App\Models\Organization; use App\Models\User; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -16,7 +16,7 @@ * guest, may see them. It's the community's feed rather than anyone's profile, so muted authors are left out like * in a log, and replies come with the parents the viewer may see. */ - public function builder(Profile $organization, ?User $viewer): CommitBuilder + public function builder(Organization $organization, ?User $viewer): CommitBuilder { return Commit::query() ->mentioning($organization) diff --git a/app/Queries/OrganizationSuggestionsQuery.php b/app/Queries/OrganizationSuggestionsQuery.php index 9ddca11119..66d17e8c49 100644 --- a/app/Queries/OrganizationSuggestionsQuery.php +++ b/app/Queries/OrganizationSuggestionsQuery.php @@ -4,8 +4,7 @@ namespace App\Queries; -use App\Enums\ProfileType; -use App\Models\Profile; +use App\Models\Organization; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Collection; use Illuminate\Support\Str; @@ -16,7 +15,7 @@ * The organizations whose handle or name matches the term, the closest matches first. Only organizations that * already have a page, so typing never asks GitHub, and none for a bare "@". * - * @return Collection + * @return Collection */ public function get(string $term, int $limit = 3): Collection { @@ -32,16 +31,17 @@ public function get(string $term, int $limit = 3): Collection $escaped = str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $term); - return Profile::query() - ->where('type', ProfileType::Organization) + return Organization::query() + ->select('organizations.*') + ->join('profiles', 'profiles.id', '=', 'organizations.id') ->where(fn (Builder $query): Builder => $query - ->whereRaw("LOWER(handle) LIKE ? ESCAPE '!'", ["%{$escaped}%"]) - ->orWhereRaw("LOWER(name) LIKE ? ESCAPE '!'", ["%{$escaped}%"])) + ->whereRaw("LOWER(profiles.handle) LIKE ? ESCAPE '!'", ["%{$escaped}%"]) + ->orWhereRaw("LOWER(profiles.name) LIKE ? ESCAPE '!'", ["%{$escaped}%"])) ->orderByRaw( - "CASE WHEN LOWER(handle) = ? THEN 0 WHEN LOWER(handle) LIKE ? ESCAPE '!' THEN 1 WHEN LOWER(name) LIKE ? ESCAPE '!' THEN 2 ELSE 3 END", + "CASE WHEN LOWER(profiles.handle) = ? THEN 0 WHEN LOWER(profiles.handle) LIKE ? ESCAPE '!' THEN 1 WHEN LOWER(profiles.name) LIKE ? ESCAPE '!' THEN 2 ELSE 3 END", [$term, "{$escaped}%", "{$escaped}%"], ) - ->orderBy('handle') + ->orderBy('profiles.handle') ->limit($limit) ->get() ->toBase(); diff --git a/app/Queries/SitemapQuery.php b/app/Queries/SitemapQuery.php index b2319fc8f3..159adf1677 100644 --- a/app/Queries/SitemapQuery.php +++ b/app/Queries/SitemapQuery.php @@ -4,10 +4,9 @@ namespace App\Queries; -use App\Enums\ProfileType; use App\Enums\TopicType; use App\Models\Commit; -use App\Models\Profile; +use App\Models\Organization; use App\Models\User; use Carbon\CarbonImmutable; use Illuminate\Database\Eloquent\Builder; @@ -216,10 +215,10 @@ private function usersSignedUpIn(CarbonImmutable $month): Builder /** * Every organization has a page, but not the placeholder handle of a profile whose handle was taken on GitHub, - * which its next sync replaces. An organization's page changes with its GitHub profile and the approved commits - * that mention it. + * which its next sync replaces. An organization's page changes with its GitHub profile, which touches it, its color + * and the approved commits that mention it. * - * @return Builder + * @return Builder */ private function organizations(): Builder { @@ -231,16 +230,16 @@ private function organizations(): Builder ->selectRaw('max(commits.updated_at) as updated_at') ->groupBy('commit_topics.topic_id'); - return Profile::query() - ->leftJoinSub($lastMentions, 'last_mentions', 'last_mentions.topic_id', '=', 'profiles.id') - ->where('profiles.type', ProfileType::Organization) + return Organization::query() + ->join('profiles', 'profiles.id', '=', 'organizations.id') + ->leftJoinSub($lastMentions, 'last_mentions', 'last_mentions.topic_id', '=', 'organizations.id') ->where('profiles.handle', 'not like', '\_%') ->select(['profiles.handle', 'profiles.created_at']) - ->selectRaw('greatest(profiles.updated_at, last_mentions.updated_at) as updated_at'); + ->selectRaw('greatest(organizations.updated_at, last_mentions.updated_at) as updated_at'); } /** - * @return Builder + * @return Builder */ private function organizationsMadeIn(CarbonImmutable $month): Builder { diff --git a/app/Services/OpenGraphCanvas.php b/app/Services/OpenGraphCanvas.php index d80a556406..25df4c2633 100644 --- a/app/Services/OpenGraphCanvas.php +++ b/app/Services/OpenGraphCanvas.php @@ -5,7 +5,7 @@ namespace App\Services; use App\Contracts\Services\GitHubProvider; -use App\Models\Profile; +use App\Models\Organization; use App\Models\User; use GdImage; use Illuminate\Support\Arr; @@ -85,10 +85,10 @@ public function node(int $y, int $lane): void * * @param positive-int $size */ - public function avatar(User|Profile $owner, int $x, int $y, int $size, int $lane): void + public function avatar(User|Organization $owner, int $x, int $y, int $size, int $lane): void { $radius = $size / 2; - $corner = $owner instanceof Profile ? $size * 0.18 : $radius; + $corner = $owner instanceof Organization ? $size * 0.18 : $radius; $centerX = $x + $radius; $centerY = $y + $radius; @@ -130,7 +130,7 @@ public function avatar(User|Profile $owner, int $x, int $y, int $size, int $lane /** * The mono handle, led by a dot in the user's lane. */ - public function handle(User|Profile $owner, int $size, int $x, int $y, int $width, int $lane): void + public function handle(User|Organization $owner, int $size, int $x, int $y, int $width, int $lane): void { $this->circle($x + $size * 0.22, $y - $size * 0.34, $size * 0.22, $lane); @@ -182,7 +182,7 @@ public function footer(): void /** * The name as Geist can draw it, or the handle when nothing is left. */ - public function name(User|Profile $owner): string + public function name(User|Organization $owner): string { $name = $this->clean($owner->name); diff --git a/app/Services/PageMetadata.php b/app/Services/PageMetadata.php index fec6ff5b54..6d2d63ad95 100644 --- a/app/Services/PageMetadata.php +++ b/app/Services/PageMetadata.php @@ -8,7 +8,7 @@ use App\Enums\ConnectionType; use App\Models\Commit; use App\Models\LinkPreview; -use App\Models\Profile; +use App\Models\Organization; use App\Models\User; use App\Queries\MacAppReleaseQuery; use Illuminate\Support\Arr; @@ -305,7 +305,7 @@ public function user(User $user): array * * @return OrganizationMetadata */ - public function organization(Profile $organization): array + public function organization(Organization $organization): array { $nameWithHandle = $organization->name === $organization->handle ? "@{$organization->handle}" : "{$organization->name} (@{$organization->handle})"; $title = "{$nameWithHandle} on /dev/null"; diff --git a/database/factories/OrganizationFactory.php b/database/factories/OrganizationFactory.php new file mode 100644 index 0000000000..268a2753ce --- /dev/null +++ b/database/factories/OrganizationFactory.php @@ -0,0 +1,54 @@ + + */ +final class OrganizationFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + // The organization's profile, made from the GitHub identity given to the factory, like a handle... + 'id' => fn (array $attributes): string => Profile::factory()->create([ + 'type' => ProfileType::Organization, + 'handle' => fake()->unique()->slug(2), + 'name' => fake()->company(), + 'github_synced_at' => now(), + ...array_intersect_key($attributes, array_flip([...Profile::USER_ATTRIBUTES, 'github_id', 'created_at'])), + ])->id, + 'preferred_lane' => null, + 'lane_color' => null, + ]; + } + + /** + * The GitHub identity given to the factory, like a handle or a GitHub ID, goes to the profile it makes, not to the + * organization. + * + * @param array $attributes + */ + public function newModel(array $attributes = []): Organization + { + return parent::newModel(array_diff_key($attributes, array_flip([...Profile::USER_ATTRIBUTES, 'github_id']))); + } + + /** + * Like organizations loaded from the database, which come with their profile. + */ + public function configure(): self + { + return $this->afterMaking(fn (Organization $organization): Organization => $organization->setRelation('profile', Profile::query()->findOrFail($organization->id))); + } +} diff --git a/database/factories/ProfileFactory.php b/database/factories/ProfileFactory.php index c773f5d614..94b3210d62 100644 --- a/database/factories/ProfileFactory.php +++ b/database/factories/ProfileFactory.php @@ -30,14 +30,4 @@ public function definition(): array 'website' => fake()->url(), ]; } - - public function organization(): self - { - return $this->state(fn (array $attributes): array => [ - 'type' => ProfileType::Organization, - 'handle' => fake()->unique()->slug(2), - 'name' => fake()->company(), - 'github_synced_at' => now(), - ]); - } } diff --git a/database/migrations/2026_10_30_120000_create_organizations_table.php b/database/migrations/2026_10_30_120000_create_organizations_table.php new file mode 100644 index 0000000000..66bfc1e56a --- /dev/null +++ b/database/migrations/2026_10_30_120000_create_organizations_table.php @@ -0,0 +1,47 @@ +foreignUuid('id')->primary()->constrained('profiles'); + $table->unsignedTinyInteger('preferred_lane')->nullable(); + $table->string('lane_color', 7)->nullable(); + $table->timestamps(); + }); + + DB::table('organizations')->insertUsing( + ['id', 'preferred_lane', 'lane_color', 'created_at', 'updated_at'], + DB::table('profiles')->where('type', 'organization')->select(['id', 'preferred_lane', 'lane_color', 'created_at', 'updated_at']), + ); + + Schema::table('organization_members', function (Blueprint $table): void { + $table->dropForeign(['organization_id']); + $table->foreign('organization_id')->references('id')->on('organizations'); + }); + + Schema::table('profiles', function (Blueprint $table): void { + $table->dropColumn(['preferred_lane', 'lane_color']); + }); + } +}; diff --git a/tests/Feature/Controllers/Api/OrganizationControllerTest.php b/tests/Feature/Controllers/Api/OrganizationControllerTest.php index a0fe387c2f..0a8cbe4835 100644 --- a/tests/Feature/Controllers/Api/OrganizationControllerTest.php +++ b/tests/Feature/Controllers/Api/OrganizationControllerTest.php @@ -2,11 +2,11 @@ declare(strict_types=1); -use App\Models\Profile; +use App\Models\Organization; use App\Models\User; it('shows an organization profile', function (): void { - Profile::factory()->organization()->create([ + Organization::factory()->create([ 'handle' => 'laravel', 'name' => 'Laravel', 'avatar_url' => 'https://avatars.githubusercontent.com/u/958072', diff --git a/tests/Feature/Controllers/Api/OrganizationMentionControllerTest.php b/tests/Feature/Controllers/Api/OrganizationMentionControllerTest.php index 5a543538ea..1cd4304e93 100644 --- a/tests/Feature/Controllers/Api/OrganizationMentionControllerTest.php +++ b/tests/Feature/Controllers/Api/OrganizationMentionControllerTest.php @@ -4,13 +4,13 @@ use App\Actions\CreateCommitTopics; use App\Models\Commit; -use App\Models\Profile; +use App\Models\Organization; use App\Models\User; it('lists the public commits and replies that mention an organization, newest first', function (): void { - $laravel = Profile::factory()->organization()->create(['handle' => 'laravel']); - $symfony = Profile::factory()->organization()->create(['handle' => 'symfony']); - $mention = fn (Commit $commit, ?Profile $organization = null): Commit => tap($commit, fn () => resolve(CreateCommitTopics::class)->handle($commit, [($organization ?? $laravel)->handle => ($organization ?? $laravel)->id])); + $laravel = Organization::factory()->create(['handle' => 'laravel']); + $symfony = Organization::factory()->create(['handle' => 'symfony']); + $mention = fn (Commit $commit, ?Organization $organization = null): Commit => tap($commit, fn () => resolve(CreateCommitTopics::class)->handle($commit, [($organization ?? $laravel)->handle => ($organization ?? $laravel)->id])); $older = $mention(Commit::factory()->create(['body' => 'Thanks @laravel', 'created_at' => now()->subHour()])); $parent = Commit::factory()->create(['created_at' => now()->subMinutes(30)]); @@ -31,7 +31,7 @@ $keyPair = apiKeyPairFor($viewer); $muted = User::factory()->create(); $viewer->muting()->attach($muted); - $laravel = Profile::factory()->organization()->create(['handle' => 'laravel']); + $laravel = Organization::factory()->create(['handle' => 'laravel']); $mention = fn (Commit $commit): Commit => tap($commit, fn () => resolve(CreateCommitTopics::class)->handle($commit, ['laravel' => $laravel->id])); $pending = $mention(Commit::factory()->for($viewer)->pending()->create(['body' => 'Pending @laravel'])); diff --git a/tests/Feature/Controllers/ExportControllerTest.php b/tests/Feature/Controllers/ExportControllerTest.php index 0184727ecb..f20ddf2f58 100644 --- a/tests/Feature/Controllers/ExportControllerTest.php +++ b/tests/Feature/Controllers/ExportControllerTest.php @@ -17,9 +17,9 @@ use App\Models\Message; use App\Models\MessageVoice; use App\Models\Notification; +use App\Models\Organization; use App\Models\Poll; use App\Models\PollVote; -use App\Models\Profile; use App\Models\SshKey; use App\Models\User; use App\Models\UserTimeout; @@ -450,8 +450,8 @@ it('exports the organizations the user is a member of with their role, first joined first, without their other members', function (): void { $this->freezeSecond(); $user = User::factory()->create(); - $laravel = Profile::factory()->organization()->create(['handle' => 'laravel']); - $bref = Profile::factory()->organization()->create(['handle' => 'brefphp']); + $laravel = Organization::factory()->create(['handle' => 'laravel']); + $bref = Organization::factory()->create(['handle' => 'brefphp']); $bref->admins()->attach($user->id, ['created_at' => now()->subDay()]); $laravel->members()->attach([$user->id => ['role' => OrganizationRole::Member, 'created_at' => now()], User::factory()->create()->id => ['role' => OrganizationRole::Admin, 'created_at' => now()]]); diff --git a/tests/Feature/Controllers/MentionSuggestionControllerTest.php b/tests/Feature/Controllers/MentionSuggestionControllerTest.php index cf29f698cc..5a4c26a3f3 100644 --- a/tests/Feature/Controllers/MentionSuggestionControllerTest.php +++ b/tests/Feature/Controllers/MentionSuggestionControllerTest.php @@ -3,7 +3,7 @@ declare(strict_types=1); use App\Models\Commit; -use App\Models\Profile; +use App\Models\Organization; use App\Models\User; it('suggests the people the user follows without a term, most recently followed first', function (): void { @@ -337,10 +337,10 @@ it('suggests organizations that have a page, the closest matches first', function (): void { $user = User::factory()->create(); - Profile::factory()->organization()->create(['handle' => 'laravel-shift', 'name' => 'Shift']); - Profile::factory()->organization()->create(['handle' => 'laravel', 'name' => 'Laravel']); - Profile::factory()->organization()->create(['handle' => 'tighten', 'name' => 'Laravel Tighten']); - Profile::factory()->organization()->create(['handle' => 'spatie', 'name' => 'Spatie']); + Organization::factory()->create(['handle' => 'laravel-shift', 'name' => 'Shift']); + Organization::factory()->create(['handle' => 'laravel', 'name' => 'Laravel']); + Organization::factory()->create(['handle' => 'tighten', 'name' => 'Laravel Tighten']); + Organization::factory()->create(['handle' => 'spatie', 'name' => 'Spatie']); $this->actingAs($user)->getJson('/search/mentions?q=@Laravel') ->assertOk() @@ -355,7 +355,7 @@ it('does not suggest organizations within an org', function (): void { $mark = User::factory()->create(['handle' => 'mark']); [$taylor] = followersOf($mark, 1); - Profile::factory()->organization()->create(['handle' => 'laravel']); + Organization::factory()->create(['handle' => 'laravel']); $org = createOrg($mark, [$taylor]); $this->actingAs($mark) diff --git a/tests/Feature/Controllers/OrganizationAdminControllerTest.php b/tests/Feature/Controllers/OrganizationAdminControllerTest.php index cda016eed4..ca62c340b2 100644 --- a/tests/Feature/Controllers/OrganizationAdminControllerTest.php +++ b/tests/Feature/Controllers/OrganizationAdminControllerTest.php @@ -4,7 +4,7 @@ use App\Contracts\Services\GitHubProvider; use App\Enums\GitHubOrganizationMembership; -use App\Models\Profile; +use App\Models\Organization; use App\Models\User; use GuzzleHttp\Psr7\Response as Psr7Response; use Illuminate\Http\Client\RequestException; @@ -27,13 +27,13 @@ function fakeClaimingGitHubAccount(string $id): void } /** - * @return array{0: User, 1: Profile} + * @return array{0: User, 1: Organization} */ function claimingUserAndOrganization(): array { return [ User::factory()->create(['github_id' => '720328']), - Profile::factory()->organization()->create(['github_id' => '45861341', 'handle' => 'brefphp']), + Organization::factory()->create(['github_id' => '45861341', 'handle' => 'brefphp']), ]; } @@ -79,7 +79,7 @@ function claimingUserAndOrganization(): array }); it('asks guests to sign in first', function (): void { - $this->post(route('organization-admin.create', Profile::factory()->organization()->create()))->assertRedirect(route('home')); + $this->post(route('organization-admin.create', Organization::factory()->create()))->assertRedirect(route('home')); }); it('makes a member of the organization on github an admin of its page', function (): void { diff --git a/tests/Feature/Controllers/OrganizationControllerTest.php b/tests/Feature/Controllers/OrganizationControllerTest.php index 7ca0a13097..284f2d1982 100644 --- a/tests/Feature/Controllers/OrganizationControllerTest.php +++ b/tests/Feature/Controllers/OrganizationControllerTest.php @@ -5,11 +5,11 @@ use App\Actions\CreateCommitTopics; use App\Enums\OrganizationRole; use App\Models\Commit; -use App\Models\Profile; +use App\Models\Organization; use App\Models\User; it('shows an organization at the handle no user has, to guests too', function (): void { - $laravel = Profile::factory()->organization()->create([ + $laravel = Organization::factory()->create([ 'github_id' => '958072', 'handle' => 'laravel', 'name' => 'Laravel', @@ -32,7 +32,7 @@ }); it('shows the organization in the lane its admins chose', function (array $attributes, int|string $lane): void { - Profile::factory()->organization()->create(['handle' => 'laravel', ...$attributes]); + Organization::factory()->create(['handle' => 'laravel', ...$attributes]); $this->get('/@laravel') ->assertOk() @@ -46,7 +46,7 @@ ]); it('shows the admins of the organization but not its other members, first admin first, and where to claim it', function (): void { - $organization = Profile::factory()->organization()->create(['handle' => 'laravel']); + $organization = Organization::factory()->create(['handle' => 'laravel']); $first = User::factory()->create(); $second = User::factory()->create(); $organization->admins()->attach([$second->id => ['created_at' => now()], $first->id => ['created_at' => now()->subDay()]]); @@ -65,7 +65,7 @@ }); it('links its members to the settings of the organization, admins to its profile', function (): void { - $organization = Profile::factory()->organization()->create(['handle' => 'laravel']); + $organization = Organization::factory()->create(['handle' => 'laravel']); $admin = User::factory()->create(); $member = User::factory()->create(); $organization->admins()->attach($admin->id); @@ -82,7 +82,7 @@ }); it('shows guests the link preview of the organization', function (): void { - $laravel = Profile::factory()->organization()->create([ + $laravel = Organization::factory()->create([ 'handle' => 'laravel', 'name' => 'Laravel', 'bio' => 'The PHP framework for web artisans.', @@ -100,7 +100,7 @@ }); it('keeps the default link preview of an organization for signed-in users', function (): void { - Profile::factory()->organization()->create(['handle' => 'laravel']); + Organization::factory()->create(['handle' => 'laravel']); $this->actingAs(User::factory()->create()) ->get('/@laravel') @@ -116,7 +116,7 @@ it('shares what every page shares, like the signed-in user', function (): void { $viewer = User::factory()->create(); - Profile::factory()->organization()->create(['handle' => 'laravel']); + Organization::factory()->create(['handle' => 'laravel']); $this->actingAs($viewer)->get('/@laravel') ->assertOk() @@ -128,9 +128,9 @@ }); it('lists the commits and replies that mention the organization, newest first, to guests too', function (): void { - $laravel = Profile::factory()->organization()->create(['handle' => 'laravel']); - $symfony = Profile::factory()->organization()->create(['handle' => 'symfony']); - $mention = fn (Commit $commit, ?Profile $organization = null): Commit => tap($commit, fn () => resolve(CreateCommitTopics::class)->handle($commit, [($organization ?? $laravel)->handle => ($organization ?? $laravel)->id])); + $laravel = Organization::factory()->create(['handle' => 'laravel']); + $symfony = Organization::factory()->create(['handle' => 'symfony']); + $mention = fn (Commit $commit, ?Organization $organization = null): Commit => tap($commit, fn () => resolve(CreateCommitTopics::class)->handle($commit, [($organization ?? $laravel)->handle => ($organization ?? $laravel)->id])); $older = $mention(Commit::factory()->create(['body' => 'Thanks @laravel', 'created_at' => now()->subHour()])); $parent = Commit::factory()->create(['body' => 'Which framework?', 'created_at' => now()->subMinutes(30)]); @@ -158,7 +158,7 @@ $blocked = User::factory()->create(); $viewer->muting()->attach($muted); $viewer->blocking()->attach($blocked); - $laravel = Profile::factory()->organization()->create(['handle' => 'laravel']); + $laravel = Organization::factory()->create(['handle' => 'laravel']); $mention = fn (Commit $commit): Commit => tap($commit, fn () => resolve(CreateCommitTopics::class)->handle($commit, ['laravel' => $laravel->id])); $pending = $mention(Commit::factory()->for($viewer)->pending()->create(['body' => 'Pending @laravel'])); @@ -177,7 +177,7 @@ }); it('pages through the mentions with the tab in the link to the next page', function (): void { - $laravel = Profile::factory()->organization()->create(['handle' => 'laravel']); + $laravel = Organization::factory()->create(['handle' => 'laravel']); Commit::factory()->count(21)->sequence(fn ($sequence): array => ['created_at' => now()->subMinutes($sequence->index)])->create(['body' => 'Thanks @laravel']) ->each(fn (Commit $commit) => resolve(CreateCommitTopics::class)->handle($commit, ['laravel' => $laravel->id])); @@ -191,11 +191,11 @@ }); it('keeps listing the commits that mentioned the organization before it was renamed', function (): void { - $organization = Profile::factory()->organization()->create(['handle' => 'laravel']); + $organization = Organization::factory()->create(['handle' => 'laravel']); $commit = Commit::factory()->create(['body' => 'Thanks @laravel']); resolve(CreateCommitTopics::class)->handle($commit, ['laravel' => $organization->id]); - $organization->forceFill(['handle' => 'laravel-php'])->save(); + $organization->profile->forceFill(['handle' => 'laravel-php'])->save(); $this->get('/@laravel-php') ->assertOk() @@ -207,7 +207,7 @@ }); it('moves an organization for good to the handle in its own case, keeping the query string', function (): void { - Profile::factory()->organization()->create(['handle' => 'Laravel']); + Organization::factory()->create(['handle' => 'Laravel']); $this->get('/@laravel?tab=mentions') ->assertMovedPermanently() @@ -218,7 +218,7 @@ }); it('keeps the query string out of the handle it moves an organization to', function (): void { - Profile::factory()->organization()->create(['handle' => 'Laravel']); + Organization::factory()->create(['handle' => 'Laravel']); $this->get('/@laravel?user=evil')->assertRedirect('/@Laravel?user=evil'); $this->get('/@laravel?user[]=a&user[]=b')->assertRedirect('/@Laravel?user[]=a&user[]=b'); @@ -226,8 +226,8 @@ }); it('opens each organization at its own case when a stale one holds the handle in another case', function (): void { - $lower = Profile::factory()->organization()->create(['handle' => 'laravel']); - $mixed = Profile::factory()->organization()->create(['handle' => 'Laravel']); + $lower = Organization::factory()->create(['handle' => 'laravel']); + $mixed = Organization::factory()->create(['handle' => 'Laravel']); $this->get('/@laravel')->assertOk()->assertInertia(fn ($page) => $page->where('organization.id', $lower->id)); $this->get('/@Laravel')->assertOk()->assertInertia(fn ($page) => $page->where('organization.id', $mixed->id)); diff --git a/tests/Feature/Controllers/OrganizationLaneControllerTest.php b/tests/Feature/Controllers/OrganizationLaneControllerTest.php index 0939ed8233..f8c702393f 100644 --- a/tests/Feature/Controllers/OrganizationLaneControllerTest.php +++ b/tests/Feature/Controllers/OrganizationLaneControllerTest.php @@ -2,16 +2,16 @@ declare(strict_types=1); -use App\Models\Profile; +use App\Models\Organization; use App\Models\User; /** - * @return array{0: User, 1: Profile} + * @return array{0: User, 1: Organization} */ function organizationAdmin(array $attributes = []): array { $user = User::factory()->create(); - $organization = Profile::factory()->organization()->create(['handle' => 'laravel', ...$attributes]); + $organization = Organization::factory()->create(['handle' => 'laravel', ...$attributes]); $organization->admins()->attach($user->id, ['created_at' => now()]); return [$user, $organization]; @@ -71,14 +71,14 @@ function organizationAdmin(array $attributes = []): array expect($organization->refresh()->lane())->toBe(2); }); -it('hides the color of profiles of users, which have no admins', function (): void { +it('hides the color of profiles of users, which are not organizations', function (): void { $user = User::factory()->create(); $this->actingAs($user) ->put(route('organization-lane.update', $user->profile), ['lane' => 1]) ->assertNotFound(); - expect($user->profile->refresh()->lane())->toBeNull(); + expect(Organization::query()->exists())->toBeFalse(); }); it('requires authentication', function (): void { diff --git a/tests/Feature/Controllers/OrganizationMemberControllerTest.php b/tests/Feature/Controllers/OrganizationMemberControllerTest.php index fc8f0250e8..b0e37fb215 100644 --- a/tests/Feature/Controllers/OrganizationMemberControllerTest.php +++ b/tests/Feature/Controllers/OrganizationMemberControllerTest.php @@ -5,15 +5,15 @@ use App\Actions\DeleteOrganizationMember; use App\Actions\UpdateOrganizationMemberRole; use App\Enums\OrganizationRole; -use App\Models\Profile; +use App\Models\Organization; use App\Models\User; /** - * @return array{0: Profile, 1: User, 2: User} + * @return array{0: Organization, 1: User, 2: User} */ function organizationWithAdminAndMember(): array { - $organization = Profile::factory()->organization()->create(['handle' => 'laravel']); + $organization = Organization::factory()->create(['handle' => 'laravel']); $admin = User::factory()->create(); $member = User::factory()->create(); $organization->admins()->attach($admin->id, ['created_at' => now()->subDay()]); @@ -25,7 +25,7 @@ function organizationWithAdminAndMember(): array /** * @return array{organization: string, member: string} */ -function memberRoute(Profile $organization, User $member): array +function memberRoute(Organization $organization, User $member): array { return ['organization' => $organization->id, 'member' => $member->handle]; } diff --git a/tests/Feature/Controllers/OrganizationProfileControllerTest.php b/tests/Feature/Controllers/OrganizationProfileControllerTest.php index 8c0de932ef..beb3abdfb5 100644 --- a/tests/Feature/Controllers/OrganizationProfileControllerTest.php +++ b/tests/Feature/Controllers/OrganizationProfileControllerTest.php @@ -3,12 +3,12 @@ declare(strict_types=1); use App\Enums\OrganizationRole; -use App\Models\Profile; +use App\Models\Organization; use App\Models\User; it('shows admins the profile of the organization in its settings, with its color', function (): void { $user = User::factory()->create(); - $organization = Profile::factory()->organization()->create(['lane_color' => '#1a2b3c']); + $organization = Organization::factory()->create(['lane_color' => '#1a2b3c']); $organization->admins()->attach($user->id); $this->actingAs($user) @@ -23,7 +23,7 @@ }); it('hides the profile settings from members who are not admins, and from everyone else', function (): void { - $organization = Profile::factory()->organization()->create(); + $organization = Organization::factory()->create(); $member = User::factory()->create(); $organization->members()->attach($member->id, ['role' => OrganizationRole::Member]); diff --git a/tests/Feature/Controllers/OrganizationSitemapControllerTest.php b/tests/Feature/Controllers/OrganizationSitemapControllerTest.php index 9df57d3e73..121cf08fcf 100644 --- a/tests/Feature/Controllers/OrganizationSitemapControllerTest.php +++ b/tests/Feature/Controllers/OrganizationSitemapControllerTest.php @@ -4,16 +4,16 @@ use App\Actions\CreateCommitTopics; use App\Models\Commit; -use App\Models\Profile; +use App\Models\Organization; use App\Models\User; it('lists every organization whose page was made that month', function (): void { - $laravel = Profile::factory()->organization()->create(['handle' => 'laravel', 'created_at' => '2026-10-01 00:00:00']); - Profile::factory()->organization()->create(['handle' => 'symfony', 'created_at' => '2026-10-31 23:59:59']); + $laravel = Organization::factory()->create(['handle' => 'laravel', 'created_at' => '2026-10-01 00:00:00']); + Organization::factory()->create(['handle' => 'symfony', 'created_at' => '2026-10-31 23:59:59']); resolve(CreateCommitTopics::class)->handle(Commit::factory()->create(['body' => 'Thanks @laravel']), ['laravel' => $laravel->id]); - Profile::factory()->organization()->create(['handle' => 'last-month', 'created_at' => '2026-09-30 23:59:59']); - Profile::factory()->organization()->create(['handle' => 'next-month', 'created_at' => '2026-11-01 00:00:00']); - Profile::factory()->organization()->create(['handle' => '_renamed-123', 'created_at' => '2026-10-15 00:00:00']); + Organization::factory()->create(['handle' => 'last-month', 'created_at' => '2026-09-30 23:59:59']); + Organization::factory()->create(['handle' => 'next-month', 'created_at' => '2026-11-01 00:00:00']); + Organization::factory()->create(['handle' => '_renamed-123', 'created_at' => '2026-10-15 00:00:00']); Commit::factory()->for(User::factory()->state(['handle' => 'octocat', 'created_at' => '2026-10-15 00:00:00']))->create(); $response = $this->get(route('organization-sitemap.show', '2026-10')); @@ -34,11 +34,12 @@ it('dates an organization by the last change of its GitHub profile or the approved commits that mention it', function (string $profile, string $mention, string $lastmod): void { $this->travelTo('2026-10-01 00:00:00'); - $laravel = Profile::factory()->organization()->create(['handle' => 'laravel']); - Profile::query()->whereKey($laravel->id)->toBase()->update(['updated_at' => $profile]); + $laravel = Organization::factory()->create(['handle' => 'laravel']); + $this->travelTo($profile); + $laravel->profile->update(['bio' => 'Updated on GitHub.']); resolve(CreateCommitTopics::class)->handle(Commit::factory()->create(['body' => '@laravel', 'updated_at' => $mention]), ['laravel' => $laravel->id]); resolve(CreateCommitTopics::class)->handle(Commit::factory()->pending()->create(['body' => '@laravel', 'updated_at' => '2026-10-29 00:00:00']), ['laravel' => $laravel->id]); - resolve(CreateCommitTopics::class)->handle(Commit::factory()->create(['body' => '@symfony', 'updated_at' => '2026-10-28 00:00:00']), ['symfony' => Profile::factory()->organization()->create()->id]); + resolve(CreateCommitTopics::class)->handle(Commit::factory()->create(['body' => '@symfony', 'updated_at' => '2026-10-28 00:00:00']), ['symfony' => Organization::factory()->create()->id]); $this->get(route('organization-sitemap.show', '2026-10')) ->assertOk() @@ -49,8 +50,8 @@ ]); it('has no sitemap for a month without organizations', function (): void { - Profile::factory()->organization()->create(['created_at' => '2026-09-15 00:00:00']); - Profile::factory()->organization()->create(['handle' => '_renamed-123', 'created_at' => '2026-10-15 00:00:00']); + Organization::factory()->create(['created_at' => '2026-09-15 00:00:00']); + Organization::factory()->create(['handle' => '_renamed-123', 'created_at' => '2026-10-15 00:00:00']); $this->get(route('organization-sitemap.show', '2026-10'))->assertNotFound(); }); diff --git a/tests/Feature/Controllers/ProfileOpenGraphImageControllerTest.php b/tests/Feature/Controllers/ProfileOpenGraphImageControllerTest.php index c4be04b383..cc7da33927 100644 --- a/tests/Feature/Controllers/ProfileOpenGraphImageControllerTest.php +++ b/tests/Feature/Controllers/ProfileOpenGraphImageControllerTest.php @@ -3,7 +3,7 @@ declare(strict_types=1); use App\Contracts\Services\GitHubProvider; -use App\Models\Profile; +use App\Models\Organization; use App\Models\User; it('renders a link preview image of a profile for guests', function (): void { @@ -71,7 +71,7 @@ it('renders a link preview image of an organization for guests', function (): void { $this->mock(GitHubProvider::class)->shouldReceive('avatar')->once()->with('https://avatars.githubusercontent.com/u/958072')->andReturn(fakeAvatar()); - Profile::factory()->organization()->create(['handle' => 'laravel', 'avatar_url' => 'https://avatars.githubusercontent.com/u/958072']); + Organization::factory()->create(['handle' => 'laravel', 'avatar_url' => 'https://avatars.githubusercontent.com/u/958072']); $response = $this->get('/@laravel/og.png'); @@ -84,7 +84,7 @@ it('renders an organization without an avatar or bio without requesting anything', function (): void { $this->mock(GitHubProvider::class)->shouldNotReceive('avatar'); - Profile::factory()->organization()->create(['handle' => 'laravel', 'avatar_url' => null, 'bio' => null]); + Organization::factory()->create(['handle' => 'laravel', 'avatar_url' => null, 'bio' => null]); $this->get('/@laravel/og.png') ->assertOk() @@ -95,13 +95,13 @@ $github = $this->mock(GitHubProvider::class); $github->shouldReceive('avatar')->once()->andReturn(fakeAvatar()); - $organization = Profile::factory()->organization()->create(['handle' => 'laravel']); + $organization = Organization::factory()->create(['handle' => 'laravel']); $this->get('/@laravel/og.png')->assertOk(); $this->get('/@laravel/og.png')->assertOk(); $github->shouldReceive('avatar')->once()->andReturn(fakeAvatar()); - $organization->update(['bio' => 'Updated on GitHub.']); + $organization->profile->update(['bio' => 'Updated on GitHub.']); $this->get('/@laravel/og.png')->assertOk(); }); @@ -111,7 +111,7 @@ }); it('renders the link preview image of a profile whatever the case of the handle', function (): void { - Profile::factory()->organization()->create(['handle' => 'Laravel']); + Organization::factory()->create(['handle' => 'Laravel']); $this->get('/@laravel/og.png') ->assertOk() diff --git a/tests/Feature/Controllers/SitemapControllerTest.php b/tests/Feature/Controllers/SitemapControllerTest.php index 48da948410..308c6f1415 100644 --- a/tests/Feature/Controllers/SitemapControllerTest.php +++ b/tests/Feature/Controllers/SitemapControllerTest.php @@ -3,11 +3,11 @@ declare(strict_types=1); use App\Models\Commit; -use App\Models\Profile; +use App\Models\Organization; use App\Models\User; it('lists a sitemap for every month of sign-ups, every month of organizations and every day of commits', function (): void { - Profile::factory()->organization()->create(['created_at' => '2026-08-15 00:00:00']); + Organization::factory()->create(['created_at' => '2026-08-15 00:00:00']); $user = User::factory()->create(['created_at' => '2026-09-30 23:59:59']); Commit::factory()->for($user)->create(['created_at' => '2026-10-15 08:00:00']); Commit::factory()->for($user)->create(['created_at' => '2026-10-15 23:59:59']); @@ -42,7 +42,7 @@ it('serves every sitemap without cookies, so a cache can share it', function (string $route): void { Commit::factory()->create(); - Profile::factory()->organization()->create(); + Organization::factory()->create(); $response = $this->get(route($route, match ($route) { 'sitemap.index' => [], diff --git a/tests/Feature/Controllers/UserControllerTest.php b/tests/Feature/Controllers/UserControllerTest.php index a6c52d2def..63ef238472 100644 --- a/tests/Feature/Controllers/UserControllerTest.php +++ b/tests/Feature/Controllers/UserControllerTest.php @@ -7,6 +7,7 @@ use App\Http\Middleware\HandleInertiaRequests; use App\Models\Commit; use App\Models\LinkPreview; +use App\Models\Organization; use App\Models\Profile; use App\Models\User; use Illuminate\Http\Client\ConnectionException; @@ -52,7 +53,7 @@ }); it('says which accounts the bio and company mention are on dev/null', function (): void { - Profile::factory()->organization()->create(['handle' => 'laravel']); + Organization::factory()->create(['handle' => 'laravel']); User::factory()->create(['handle' => 'TaylorOtwell']); User::factory()->create(['handle' => 'octocat', 'bio' => 'Thanks @taylorotwell and @nobody', 'company' => '@laravel']); @@ -62,7 +63,7 @@ }); it('shares which accounts the bio of the signed-in user mentions are on dev/null', function (): void { - Profile::factory()->organization()->create(['handle' => 'laravel']); + Organization::factory()->create(['handle' => 'laravel']); $user = User::factory()->create(['bio' => 'Building @laravel']); $this->actingAs($user) @@ -853,7 +854,7 @@ it('opens the user rather than an organization holding the handle in another case, unless the case is exact', function (): void { $user = User::factory()->create(['handle' => 'Laravel']); - $organization = Profile::factory()->organization()->create(['handle' => 'laravel']); + $organization = Organization::factory()->create(['handle' => 'laravel']); $this->get('/@LARAVEL')->assertRedirect('/@Laravel')->assertMovedPermanently(); $this->get('/@Laravel')->assertOk()->assertInertia(fn ($page) => $page->component('user/show')->where('user.id', $user->id)); diff --git a/tests/Feature/Controllers/UserPreviewControllerTest.php b/tests/Feature/Controllers/UserPreviewControllerTest.php index 3f8b4078f5..029bceb469 100644 --- a/tests/Feature/Controllers/UserPreviewControllerTest.php +++ b/tests/Feature/Controllers/UserPreviewControllerTest.php @@ -4,7 +4,7 @@ use App\Actions\DeleteUser; use App\Models\Commit; -use App\Models\Profile; +use App\Models\Organization; use App\Models\User; use Illuminate\Support\Facades\DB; @@ -35,7 +35,7 @@ }); it('says which accounts the bio mentions are on dev/null', function (bool $guest): void { - Profile::factory()->organization()->create(['handle' => 'Laravel']); + Organization::factory()->create(['handle' => 'Laravel']); User::factory()->create(['handle' => 'octocat', 'bio' => 'Building @laravel, previously @nobody']); ($guest ? $this : $this->actingAs(User::factory()->create())) diff --git a/tests/Feature/OrganizationMentionsTest.php b/tests/Feature/OrganizationMentionsTest.php index 094d9fde70..f9c786ef46 100644 --- a/tests/Feature/OrganizationMentionsTest.php +++ b/tests/Feature/OrganizationMentionsTest.php @@ -5,7 +5,7 @@ use App\Actions\CreateCommitTopics; use App\Jobs\LinkCommitTopics; use App\Models\Commit; -use App\Models\Profile; +use App\Models\Organization; use App\Models\User; use Illuminate\Support\Facades\Queue; @@ -14,8 +14,8 @@ $author = User::factory()->create(); $tom = User::factory()->create(['handle' => 'tom']); - $laravel = Profile::factory()->organization()->create(['handle' => 'laravel']); - Profile::factory()->organization()->create(['handle' => 'Tom']); + $laravel = Organization::factory()->create(['handle' => 'laravel']); + Organization::factory()->create(['handle' => 'Tom']); $this->actingAs($author)->post('/commits', ['body' => 'Thanks @Laravel and @tom'])->assertRedirect(); @@ -69,18 +69,18 @@ }); it('links mentions to the organization they captured, even after it is renamed', function (): void { - $organization = Profile::factory()->organization()->create(['handle' => 'laravel']); + $organization = Organization::factory()->create(['handle' => 'laravel']); $this->get(route('organization-mention.show', $organization))->assertRedirect('/@laravel'); - $organization->update(['handle' => 'laravel-renamed']); + $organization->profile->update(['handle' => 'laravel-renamed']); $this->get(route('organization-mention.show', $organization))->assertRedirect('/@laravel-renamed'); }); it('previews which mentions are organizations', function (): void { $user = User::factory()->create(); - $organization = Profile::factory()->organization()->create(['handle' => 'laravel']); + $organization = Organization::factory()->create(['handle' => 'laravel']); $this->actingAs($user)->postJson(route('commit-preview.store'), ['body' => 'Thanks @laravel']) ->assertOk() @@ -90,7 +90,7 @@ it('tells API clients which mentions are organizations', function (): void { $user = User::factory()->create(); $keyPair = apiKeyPairFor($user); - $organization = Profile::factory()->organization()->create(['handle' => 'laravel']); + $organization = Organization::factory()->create(['handle' => 'laravel']); $commit = Commit::factory()->create(['body' => '@laravel and @tom', 'mentions' => []]); resolve(CreateCommitTopics::class)->handle($commit, ['laravel' => $organization->id]); diff --git a/tests/Unit/Actions/CreateCommitTopicsTest.php b/tests/Unit/Actions/CreateCommitTopicsTest.php index df56743940..4125450725 100644 --- a/tests/Unit/Actions/CreateCommitTopicsTest.php +++ b/tests/Unit/Actions/CreateCommitTopicsTest.php @@ -4,11 +4,11 @@ use App\Actions\CreateCommitTopics; use App\Models\Commit; -use App\Models\Profile; +use App\Models\Organization; it('links a commit to the organizations it mentions, by the handle written', function (): void { - $laravel = Profile::factory()->organization()->create(['handle' => 'laravel']); - $digits = Profile::factory()->organization()->create(['handle' => '1337']); + $laravel = Organization::factory()->create(['handle' => 'laravel']); + $digits = Organization::factory()->create(['handle' => '1337']); $commit = Commit::factory()->create(['body' => '@Laravel and @1337', 'mentions' => []]); resolve(CreateCommitTopics::class)->handle($commit, ['laravel' => $laravel->id, '1337' => $digits->id]); @@ -17,8 +17,8 @@ }); it('keeps what a reference already links to', function (): void { - $first = Profile::factory()->organization()->create(); - $second = Profile::factory()->organization()->create(); + $first = Organization::factory()->create(); + $second = Organization::factory()->create(); $commit = Commit::factory()->create(['body' => '@laravel', 'mentions' => []]); resolve(CreateCommitTopics::class)->handle($commit, ['laravel' => $first->id]); diff --git a/tests/Unit/Actions/CreateOrganizationAdminTest.php b/tests/Unit/Actions/CreateOrganizationAdminTest.php index d141662096..255c3202a6 100644 --- a/tests/Unit/Actions/CreateOrganizationAdminTest.php +++ b/tests/Unit/Actions/CreateOrganizationAdminTest.php @@ -4,11 +4,11 @@ use App\Actions\CreateOrganizationAdmin; use App\Enums\OrganizationRole; -use App\Models\Profile; +use App\Models\Organization; use App\Models\User; it('makes only the first of two members coming back from github at once the admin', function (): void { - $organization = Profile::factory()->organization()->create(); + $organization = Organization::factory()->create(); [$first, $second] = User::factory()->count(2)->create(); expect(resolve(CreateOrganizationAdmin::class)->handle($organization, $first))->toBeTrue() @@ -17,7 +17,7 @@ }); it('makes a member the admin of a page left without one', function (): void { - $organization = Profile::factory()->organization()->create(); + $organization = Organization::factory()->create(); $member = User::factory()->create(); $organization->members()->attach($member->id, ['role' => OrganizationRole::Member]); diff --git a/tests/Unit/Actions/CreateOrganizationOpenGraphImageTest.php b/tests/Unit/Actions/CreateOrganizationOpenGraphImageTest.php index 9df562baf4..c249c804bf 100644 --- a/tests/Unit/Actions/CreateOrganizationOpenGraphImageTest.php +++ b/tests/Unit/Actions/CreateOrganizationOpenGraphImageTest.php @@ -4,12 +4,12 @@ use App\Actions\CreateOrganizationOpenGraphImage; use App\Actions\CreateUserOpenGraphImage; -use App\Models\Profile; +use App\Models\Organization; use App\Models\User; use App\Services\OpenGraphCanvas; it('draws the avatar of an organization square, where a person is round', function (): void { - $organization = Profile::factory()->organization()->create(['avatar_url' => null, 'bio' => null]); + $organization = Organization::factory()->create(['avatar_url' => null, 'bio' => null]); $user = User::factory()->create(['avatar_url' => null, 'bio' => null]); $organizationImage = imagecreatefromstring(resolve(CreateOrganizationOpenGraphImage::class)->handle($organization)); @@ -22,7 +22,7 @@ }); it('draws the rail in the blue of /dev/null while the admins chose no lane', function (): void { - $organization = Profile::factory()->organization()->create(['avatar_url' => null, 'bio' => null]); + $organization = Organization::factory()->create(['avatar_url' => null, 'bio' => null]); $image = imagecreatefromstring(resolve(CreateOrganizationOpenGraphImage::class)->handle($organization)); @@ -31,7 +31,7 @@ }); it('draws the rail in the lane the admins chose, drawing it again once they change it', function (): void { - $organization = Profile::factory()->organization()->create(['avatar_url' => null, 'bio' => null, 'preferred_lane' => 1]); + $organization = Organization::factory()->create(['avatar_url' => null, 'bio' => null, 'preferred_lane' => 1]); $action = resolve(CreateOrganizationOpenGraphImage::class); $palette = imagecreatefromstring($action->handle($organization)); @@ -45,7 +45,7 @@ }); it('fits descriptions that would overflow the card', function (): void { - $organization = Profile::factory()->organization()->create([ + $organization = Organization::factory()->create([ 'avatar_url' => null, 'bio' => str_repeat('The PHP framework for web artisans, shipping before lunch. ', 4), ]); diff --git a/tests/Unit/Actions/DeleteCommitContentTest.php b/tests/Unit/Actions/DeleteCommitContentTest.php index 9eeef6d0a3..71dc18afb9 100644 --- a/tests/Unit/Actions/DeleteCommitContentTest.php +++ b/tests/Unit/Actions/DeleteCommitContentTest.php @@ -9,8 +9,8 @@ use App\Models\Commit; use App\Models\CommitImage; use App\Models\LinkPreview; +use App\Models\Organization; use App\Models\Poll; -use App\Models\Profile; use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Storage; @@ -76,7 +76,7 @@ function deletedCommitWithImage(): CommitImage it('erases the content once the undo window has closed', function (): void { $commit = Commit::factory()->create(['body' => 'Hello @taylor and @laravel', 'mentions' => ['taylor'], 'link_preview_id' => LinkPreview::factory()]); - resolve(CreateCommitTopics::class)->handle($commit, ['laravel' => Profile::factory()->organization()->create()->id]); + resolve(CreateCommitTopics::class)->handle($commit, ['laravel' => Organization::factory()->create()->id]); $poll = Poll::factory()->withOptions('Yes', 'No')->create(['commit_id' => $commit->id]); resolve(DeleteCommit::class)->handle($commit); $this->travel(30)->seconds(); diff --git a/tests/Unit/Actions/DeleteOrganizationTest.php b/tests/Unit/Actions/DeleteOrganizationTest.php index 6c6ebeaf8c..56c95b8bb1 100644 --- a/tests/Unit/Actions/DeleteOrganizationTest.php +++ b/tests/Unit/Actions/DeleteOrganizationTest.php @@ -4,20 +4,22 @@ use App\Actions\DeleteOrganization; use App\Enums\OrganizationRole; +use App\Models\Organization; use App\Models\Profile; use App\Models\User; use Illuminate\Support\Facades\DB; -it('deletes an organization with its members, keeping the users', function (): void { +it('deletes an organization with its profile and members, keeping the users', function (): void { $user = User::factory()->create(); - $organization = Profile::factory()->organization()->create(); - $kept = Profile::factory()->organization()->create(); + $organization = Organization::factory()->create(); + $kept = Organization::factory()->create(); $organization->members()->attach($user->id, ['role' => OrganizationRole::Member]); $kept->admins()->attach($user->id, ['created_at' => now()]); resolve(DeleteOrganization::class)->handle($organization); expect($organization->fresh())->toBeNull() + ->and(Profile::query()->find($organization->id))->toBeNull() ->and($user->fresh())->not->toBeNull() ->and(DB::table('organization_members')->pluck('organization_id')->all())->toBe([$kept->id]); }); diff --git a/tests/Unit/Actions/DeleteUserTest.php b/tests/Unit/Actions/DeleteUserTest.php index d588f8c865..77c58f71ea 100644 --- a/tests/Unit/Actions/DeleteUserTest.php +++ b/tests/Unit/Actions/DeleteUserTest.php @@ -20,10 +20,10 @@ use App\Models\Message; use App\Models\MessageImage; use App\Models\MessageVoice; +use App\Models\Organization; use App\Models\Poll; use App\Models\PollOption; use App\Models\PollVote; -use App\Models\Profile; use App\Models\PushSubscription; use App\Models\User; use App\Services\CommitTranslations; @@ -51,7 +51,7 @@ Commit::factory()->for($user)->create()->delete(); $reply = Commit::factory()->for($commit, 'parent')->create(); $unrelated = Commit::factory()->create(); - $organization = Profile::factory()->organization()->create(); + $organization = Organization::factory()->create(); resolve(CreateCommitTopics::class)->handle($commit, ['laravel' => $organization->id]); resolve(CreateCommitTopics::class)->handle($reply, ['laravel' => $organization->id]); resolve(CreateCommitTopics::class)->handle($unrelated, ['laravel' => $organization->id]); @@ -100,7 +100,7 @@ 'mentions' => [$mentioned->handle => $mentioned->id], 'embedding' => embeddingVector(0), ]); - resolve(CreateCommitTopics::class)->handle($reply, ['laravel' => Profile::factory()->organization()->create()->id]); + resolve(CreateCommitTopics::class)->handle($reply, ['laravel' => Organization::factory()->create()->id]); $deletedReply = Commit::factory()->for($user)->for($commit, 'parent')->create(); Commit::factory()->for($reply, 'parent')->create(); Commit::factory()->for($deletedReply, 'parent')->create(); @@ -196,7 +196,7 @@ it('erases the organization memberships of a deleted user, keeping their other members', function (): void { $user = User::factory()->create(); $other = User::factory()->create(); - $organization = Profile::factory()->organization()->create(); + $organization = Organization::factory()->create(); $organization->admins()->attach($user->id); $organization->members()->attach($other->id, ['role' => OrganizationRole::Member]); diff --git a/tests/Unit/Actions/UpdateCommitTopicsTest.php b/tests/Unit/Actions/UpdateCommitTopicsTest.php index 40bb954d3e..7506aa2774 100644 --- a/tests/Unit/Actions/UpdateCommitTopicsTest.php +++ b/tests/Unit/Actions/UpdateCommitTopicsTest.php @@ -5,8 +5,8 @@ use App\Actions\CreateCommitTopics; use App\Actions\UpdateCommitTopics; use App\Contracts\Services\GitHubProvider; -use App\Enums\ProfileType; use App\Models\Commit; +use App\Models\Organization; use App\Models\Profile; use App\Models\User; use GuzzleHttp\Psr7\Response as Psr7Response; @@ -43,10 +43,10 @@ function gitHubOrganization(array $account = []): array resolve(UpdateCommitTopics::class)->handle($commit); - $organization = Profile::query()->where('type', ProfileType::Organization)->sole(); + $organization = Organization::query()->sole(); - expect($organization) - ->github_id->toBe('958072') + expect($organization->profile->github_id)->toBe('958072') + ->and($organization) ->handle->toBe('laravel') ->name->toBe('Laravel') ->avatar_url->toBe('https://avatars.githubusercontent.com/u/958072?v=4') @@ -77,7 +77,7 @@ function gitHubOrganization(array $account = []): array $github = $this->mock(GitHubProvider::class); $tom = User::factory()->create(['handle' => 'tom']); - $organization = Profile::factory()->organization()->create(['handle' => 'laravel']); + $organization = Organization::factory()->create(['handle' => 'laravel']); $commit = Commit::factory()->create(['body' => 'Hi @tom, @laravel', 'mentions' => ['tom' => $tom->id]]); resolve(UpdateCommitTopics::class)->handle($commit); @@ -114,7 +114,7 @@ function gitHubOrganization(array $account = []): array resolve(UpdateCommitTopics::class)->handle($commit); $github->shouldHaveReceived('accountByLogin')->twice(); - expect(Profile::query()->where('type', ProfileType::Organization)->count())->toBe(0) + expect(Organization::query()->count())->toBe(0) ->and($commit->refresh()->organization_mentions)->toBeNull(); $this->travel(2)->days(); @@ -129,7 +129,7 @@ function gitHubOrganization(array $account = []): array $github->shouldReceive('rateLimit')->andReturnNull(); $github->shouldReceive('account')->with('958072')->andReturn(gitHubOrganization(['name' => 'Laravel, Inc.'])); - $organization = Profile::factory()->organization()->create(['github_id' => '958072', 'handle' => 'laravel', 'github_synced_at' => now()->subHours(Profile::FRESH_FOR_HOURS)->subMinute()]); + $organization = Organization::factory()->create(['github_id' => '958072', 'handle' => 'laravel', 'github_synced_at' => now()->subHours(Organization::FRESH_FOR_HOURS)->subMinute()]); $commit = Commit::factory()->create(['body' => '@laravel', 'mentions' => []]); resolve(UpdateCommitTopics::class)->handle($commit); @@ -145,20 +145,20 @@ function gitHubOrganization(array $account = []): array $github->shouldReceive('rateLimit')->andReturnNull(); $github->shouldReceive('account')->with('958072')->andReturnNull(); - $organization = Profile::factory()->organization()->create(['github_id' => '958072', 'handle' => 'laravel', 'github_synced_at' => now()->subDays(2)]); + $organization = Organization::factory()->create(['github_id' => '958072', 'handle' => 'laravel', 'github_synced_at' => now()->subDays(2)]); $earlier = Commit::factory()->create(['body' => 'Thanks @laravel', 'mentions' => []]); resolve(CreateCommitTopics::class)->handle($earlier, ['laravel' => $organization->id]); $commit = Commit::factory()->create(['body' => '@laravel', 'mentions' => []]); resolve(UpdateCommitTopics::class)->handle($commit); - expect(Profile::query()->where('type', ProfileType::Organization)->count())->toBe(0) + expect(Organization::query()->count())->toBe(0) ->and($commit->refresh()->organization_mentions)->toBeNull() ->and($earlier->refresh()->organization_mentions)->toBeNull(); }); it('keeps the organizations the commit already links to', function (): void { - $organization = Profile::factory()->organization()->create(['handle' => 'now-renamed']); + $organization = Organization::factory()->create(['handle' => 'now-renamed']); $commit = Commit::factory()->create(['body' => '@renamed', 'mentions' => []]); resolve(CreateCommitTopics::class)->handle($commit, ['renamed' => $organization->id]); diff --git a/tests/Unit/Actions/UpdateOrCreateOrganizationTest.php b/tests/Unit/Actions/UpdateOrCreateOrganizationTest.php index d46ddd34f9..fda3169b06 100644 --- a/tests/Unit/Actions/UpdateOrCreateOrganizationTest.php +++ b/tests/Unit/Actions/UpdateOrCreateOrganizationTest.php @@ -3,10 +3,13 @@ declare(strict_types=1); use App\Actions\UpdateOrCreateOrganization; +use App\Enums\ProfileType; +use App\Models\Organization; use App\Models\Profile; +use Illuminate\Database\UniqueConstraintViolationException; it('updates the organization with the same GitHub ID when it is renamed', function (): void { - $organization = Profile::factory()->organization()->create(['github_id' => '958072', 'handle' => 'laravel-old', 'github_synced_at' => now()->subWeek()]); + $organization = Organization::factory()->create(['github_id' => '958072', 'handle' => 'laravel-old', 'github_synced_at' => now()->subWeek()]); $updated = resolve(UpdateOrCreateOrganization::class)->handle([ 'login' => 'laravel', @@ -27,9 +30,19 @@ ->github_synced_at->toDateTimeString()->toBe(now()->toDateTimeString()); }); +it('keeps the color its admins chose when it syncs again', function (): void { + $organization = Organization::factory()->create(['github_id' => '958072', 'handle' => 'laravel', 'lane_color' => '#1a2b3c']); + + resolve(UpdateOrCreateOrganization::class)->handle(['login' => 'laravel', 'id' => 958072, 'bio' => 'Updated on GitHub.']); + + expect($organization->refresh()) + ->bio->toBe('Updated on GitHub.') + ->lane_color->toBe('#1a2b3c'); +}); + it('gives the handle up when another profile renamed away from it on GitHub', function (): void { - $stale = Profile::factory()->organization()->create(['github_id' => '1', 'handle' => 'Laravel']); - $other = Profile::factory()->organization()->create(['github_id' => '2', 'handle' => 'symfony']); + $stale = Organization::factory()->create(['github_id' => '1', 'handle' => 'Laravel']); + $other = Organization::factory()->create(['github_id' => '2', 'handle' => 'symfony']); $organization = resolve(UpdateOrCreateOrganization::class)->handle(['login' => 'laravel', 'id' => 958072]); @@ -37,3 +50,13 @@ ->and($stale->refresh()->handle)->toBe('_renamed-1') ->and($other->refresh()->handle)->toBe('symfony'); }); + +it("fails rather than turn a user's profile into an organization, which GitHub allows but /dev/null does not handle", function (): void { + $profile = Profile::factory()->create(['github_id' => '958072', 'handle' => 'laravel']); + + expect(fn () => resolve(UpdateOrCreateOrganization::class)->handle(['login' => 'laravel', 'id' => 958072])) + ->toThrow(UniqueConstraintViolationException::class); + + expect($profile->refresh()->type)->toBe(ProfileType::User) + ->and(Organization::query()->exists())->toBeFalse(); +}); diff --git a/tests/Unit/Jobs/LinkCommitTopicsTest.php b/tests/Unit/Jobs/LinkCommitTopicsTest.php index ca724fc628..c8cc44a3b8 100644 --- a/tests/Unit/Jobs/LinkCommitTopicsTest.php +++ b/tests/Unit/Jobs/LinkCommitTopicsTest.php @@ -6,6 +6,7 @@ use App\Contracts\Services\GitHubProvider; use App\Jobs\LinkCommitTopics; use App\Models\Commit; +use App\Models\Organization; use App\Models\Profile; use GuzzleHttp\Psr7\Response as Psr7Response; use Illuminate\Http\Client\RequestException; @@ -15,7 +16,7 @@ use Illuminate\Support\Facades\Queue; it('links the organizations the commit mentions', function (): void { - $organization = Profile::factory()->organization()->create(['handle' => 'laravel']); + $organization = Organization::factory()->create(['handle' => 'laravel']); $commit = Commit::factory()->create(['body' => 'Thanks @laravel', 'mentions' => []]); dispatch_sync(new LinkCommitTopics($commit)); diff --git a/tests/Unit/Jobs/LookUpUserOrganizationsTest.php b/tests/Unit/Jobs/LookUpUserOrganizationsTest.php index 8abf976c00..a11bea12da 100644 --- a/tests/Unit/Jobs/LookUpUserOrganizationsTest.php +++ b/tests/Unit/Jobs/LookUpUserOrganizationsTest.php @@ -4,9 +4,8 @@ use App\Actions\UpdateMentionedOrganizations; use App\Contracts\Services\GitHubProvider; -use App\Enums\ProfileType; use App\Jobs\LookUpUserOrganizations; -use App\Models\Profile; +use App\Models\Organization; use App\Models\User; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Queue; @@ -22,7 +21,7 @@ dispatch_sync(new LookUpUserOrganizations($user)); - expect(Profile::query()->where('type', ProfileType::Organization)->pluck('handle')->all())->toBe(['laravel']); + expect(Organization::query()->get()->pluck('handle')->all())->toBe(['laravel']); }); it('runs again once the rate limit resets when it cut the lookups short', function (): void { diff --git a/tests/Unit/Queries/BioProfilesQueryTest.php b/tests/Unit/Queries/BioProfilesQueryTest.php index ae1b2b0c03..08d3262912 100644 --- a/tests/Unit/Queries/BioProfilesQueryTest.php +++ b/tests/Unit/Queries/BioProfilesQueryTest.php @@ -2,13 +2,13 @@ declare(strict_types=1); -use App\Models\Profile; +use App\Models\Organization; use App\Models\User; use App\Queries\BioProfilesQuery; use Illuminate\Support\Facades\DB; it('finds the users and organizations on dev/null the bio and company mention', function (): void { - Profile::factory()->organization()->create(['handle' => 'Laravel']); + Organization::factory()->create(['handle' => 'Laravel']); User::factory()->create(['handle' => 'taylorotwell']); $user = User::factory()->create(['bio' => 'Thanks @TaylorOtwell, not `@laravel` in code, and @nobody', 'company' => '@laravel']); diff --git a/tests/Unit/Services/PageMetadataTest.php b/tests/Unit/Services/PageMetadataTest.php index 3e3f924502..ad292dcefd 100644 --- a/tests/Unit/Services/PageMetadataTest.php +++ b/tests/Unit/Services/PageMetadataTest.php @@ -5,7 +5,7 @@ use App\Actions\DeleteUser; use App\Enums\CommitStatus; use App\Models\Commit; -use App\Models\Profile; +use App\Models\Organization; use App\Models\User; use App\Services\PageMetadata; @@ -72,7 +72,7 @@ ]); it('describes an organization from its GitHub profile', function (): void { - $organization = Profile::factory()->organization()->create([ + $organization = Organization::factory()->create([ 'handle' => 'laravel', 'name' => 'Laravel', 'avatar_url' => 'https://avatars.githubusercontent.com/u/958072', @@ -101,7 +101,7 @@ }); it('describes an organization without a name, description, avatar, or website of its own', function (): void { - $organization = Profile::factory()->organization()->create([ + $organization = Organization::factory()->create([ 'handle' => 'laravel', 'name' => 'laravel', 'avatar_url' => null,