From eee50f0b22b223f9a868615e9a7c1f82c6cd8927 Mon Sep 17 00:00:00 2001 From: "Daniel (Haven) Hotyanov" <49914607+danielh-official@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:25:12 -0400 Subject: [PATCH] Add following of organizations Signed-in users can follow an organization from its page. Commits that mention a followed organization now appear in the Following log, subject to the usual visibility, mute and block rules. - Add organization_follows table, User::followedOrganizations() and isFollowingOrganization() - Add OrganizationFollowController with store/destroy routes; only organization profiles can be followed - Extend FollowingLogQuery to include commits that mention followed organizations - Add OrganizationFollowButton and show it on the organization page - Add OrganizationSeeder, which seeds @laravel with mentioning commits - Add tests and update the knowledge base --- KNOWLEDGE_BASE.md | 4 +- .../Controllers/OrganizationController.php | 1 + .../OrganizationFollowController.php | 37 ++++++++++ .../CreateOrganizationFollowRequest.php | 32 ++++++++ app/Models/User.php | 14 ++++ app/Queries/LogQueries/FollowingLogQuery.php | 10 ++- ...0000_create_organization_follows_table.php | 26 +++++++ database/seeders/DatabaseSeeder.php | 2 + database/seeders/OrganizationSeeder.php | 50 +++++++++++++ resources/js/components/follow-button.tsx | 66 +++++++++++++++++ resources/js/pages/organization/show.tsx | 8 ++ routes/web.php | 11 +++ .../OrganizationFollowControllerTest.php | 74 +++++++++++++++++++ tests/Feature/DatabaseSeederTest.php | 14 ++++ .../LogQueries/FollowingLogQueryTest.php | 19 +++++ 15 files changed, 365 insertions(+), 3 deletions(-) create mode 100644 app/Http/Controllers/OrganizationFollowController.php create mode 100644 app/Http/Requests/CreateOrganizationFollowRequest.php create mode 100644 database/migrations/2026_10_29_120000_create_organization_follows_table.php create mode 100644 database/seeders/OrganizationSeeder.php create mode 100644 tests/Feature/Controllers/OrganizationFollowControllerTest.php diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index de152c8811..1b3a18fd10 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -118,9 +118,9 @@ A member of an organization can **Claim** its page, which lists them as its admi Every organization has a color, /dev/null's blue until its admin chooses another with **Color** on its page: one of the same six lane colors as developers, or a custom one. It rings the organization's avatar, leads its handle, and colors its link preview. -Following organizations is planned, not shipped yet. +Signed-in users can **Follow** an organization from its page. Following puts the commits that mention it in your Following log, with the usual visibility, mutes and blocks. Replies that mention it stay on its Mentions tab, since logs list top-level commits only. Organizations have no follower counts and following one sends no notification. -Sources: [organization page](resources/js/pages/organization/show.tsx), [mentions](app/Queries/OrganizationMentionsQuery.php), [organization lookups](app/Actions/UpdateCommitTopics.php), [claims](app/Http/Controllers/OrganizationAdminController.php), [color](resources/js/components/organization-lane-dialog.tsx). +Sources: [organization page](resources/js/pages/organization/show.tsx), [mentions](app/Queries/OrganizationMentionsQuery.php), [organization lookups](app/Actions/UpdateCommitTopics.php), [claims](app/Http/Controllers/OrganizationAdminController.php), [color](resources/js/components/organization-lane-dialog.tsx), [follows](app/Http/Controllers/OrganizationFollowController.php). ### Going live diff --git a/app/Http/Controllers/OrganizationController.php b/app/Http/Controllers/OrganizationController.php index 800b57631f..0690e4cf75 100644 --- a/app/Http/Controllers/OrganizationController.php +++ b/app/Http/Controllers/OrganizationController.php @@ -38,6 +38,7 @@ public function show(ShowOrganizationRequest $request, #[CurrentUser] ?User $cur // The Mentions tab tells a restored commit belongs to it by the ID, whatever handle the commit mentions. 'organization' => $organization->makeVisible('id'), 'lane' => $organization->lane(), + 'isFollowing' => $currentUser?->isFollowingOrganization($organization) ?? false, 'tab' => $request->tab(...), 'commits' => Inertia::scroll(fn () => $mentions->builder($organization, $currentUser) ->cursorPaginate(20) diff --git a/app/Http/Controllers/OrganizationFollowController.php b/app/Http/Controllers/OrganizationFollowController.php new file mode 100644 index 0000000000..677e6a8e87 --- /dev/null +++ b/app/Http/Controllers/OrganizationFollowController.php @@ -0,0 +1,37 @@ +followedOrganizations()->newPivotStatement()->insertOrIgnore([ + 'user_id' => $user->id, + 'organization_id' => $organization->id, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + return back(); + } + + public function destroy(#[CurrentUser] User $user, Profile $organization): RedirectResponse + { + $user->followedOrganizations()->detach($organization->id); + + return back(); + } +} diff --git a/app/Http/Requests/CreateOrganizationFollowRequest.php b/app/Http/Requests/CreateOrganizationFollowRequest.php new file mode 100644 index 0000000000..3f6d591a48 --- /dev/null +++ b/app/Http/Requests/CreateOrganizationFollowRequest.php @@ -0,0 +1,32 @@ +route('organization'); + assert($organization instanceof Profile); + + return $organization->type === ProfileType::Organization ? Response::allow() : Response::denyAsNotFound(); + } + + /** + * @return array{} + */ + public function rules(): array + { + return []; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 44679049bb..203d2e7c46 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -74,6 +74,7 @@ * @property-read Collection $starredCommits * @property-read Collection $following * @property-read Collection $followers + * @property-read Collection $followedOrganizations * @property-read Collection $muting * @property-read Collection $mutedBy * @property-read Collection $blocking @@ -364,6 +365,14 @@ public function following(): BelongsToMany return $this->belongsToMany(self::class, 'follows', 'follower_id', 'followed_id')->withTimestamps(); } + /** + * @return BelongsToMany + */ + public function followedOrganizations(): BelongsToMany + { + return $this->belongsToMany(Profile::class, 'organization_follows', 'user_id', 'organization_id')->withTimestamps(); + } + /** * @return BelongsToMany */ @@ -509,6 +518,11 @@ public function isFollowing(self $user): bool return $this->following()->newPivotQuery()->where('followed_id', $user->id)->exists(); } + public function isFollowingOrganization(Profile $organization): bool + { + return $this->followedOrganizations()->newPivotQuery()->where('organization_id', $organization->id)->exists(); + } + public function isMuting(self $user): bool { return $this->muting()->newPivotQuery()->where('muted_id', $user->id)->exists(); diff --git a/app/Queries/LogQueries/FollowingLogQuery.php b/app/Queries/LogQueries/FollowingLogQuery.php index f50c437485..f579ce9702 100644 --- a/app/Queries/LogQueries/FollowingLogQuery.php +++ b/app/Queries/LogQueries/FollowingLogQuery.php @@ -5,10 +5,12 @@ namespace App\Queries\LogQueries; use App\Contracts\Queries\LogQuery; +use App\Enums\TopicType; use App\Models\Commit; use App\Models\User; use App\Queries\CommitBuilder; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Query\Builder as QueryBuilder; final readonly class FollowingLogQuery implements LogQuery { @@ -19,11 +21,17 @@ public function builder(?User $user): CommitBuilder } $following = $user->following(); + $followedOrganizations = $user->followedOrganizations(); return Commit::query() ->where(fn (Builder $query): Builder => $query ->whereIn('user_id', $following->newPivotQuery()->select($following->getRelatedPivotKeyName())) - ->orWhere('user_id', $user->id)) + ->orWhere('user_id', $user->id) + ->orWhereExists(fn (QueryBuilder $query): QueryBuilder => $query + ->from('commit_topics') + ->whereColumn('commit_topics.commit_id', 'commits.id') + ->where('commit_topics.topic_type', TopicType::Organization) + ->whereIn('commit_topics.topic_id', $followedOrganizations->newPivotQuery()->select($followedOrganizations->getRelatedPivotKeyName())))) ->visibleTo($user) ->withoutMutedAuthorsFor($user) ->topLevel() diff --git a/database/migrations/2026_10_29_120000_create_organization_follows_table.php b/database/migrations/2026_10_29_120000_create_organization_follows_table.php new file mode 100644 index 0000000000..a3af76cdb8 --- /dev/null +++ b/database/migrations/2026_10_29_120000_create_organization_follows_table.php @@ -0,0 +1,26 @@ +foreignUuid('user_id')->constrained()->cascadeOnDelete(); + $table->foreignUuid('organization_id')->constrained('profiles')->cascadeOnDelete(); + $table->timestamps(); + + $table->primary(['user_id', 'organization_id']); + $table->index('organization_id'); + }); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 6fef73cd44..288f8b28e7 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -64,6 +64,8 @@ public function run(): void $this->seedNotifications($localDeveloper, $users); $this->converse($localDeveloper, $users); + + $this->call(OrganizationSeeder::class); } /** diff --git a/database/seeders/OrganizationSeeder.php b/database/seeders/OrganizationSeeder.php new file mode 100644 index 0000000000..86171d0579 --- /dev/null +++ b/database/seeders/OrganizationSeeder.php @@ -0,0 +1,50 @@ +updateOrCreate(['github_id' => '958072'], [ + 'type' => ProfileType::Organization, + 'handle' => 'laravel', + 'name' => 'Laravel', + 'avatar_url' => 'https://avatars.githubusercontent.com/u/958072?v=4', + 'bio' => 'The PHP framework for web artisans.', + 'website' => 'https://laravel.com', + 'github_synced_at' => now(), + ]); + + $users = User::query()->inRandomOrder()->limit(5)->get(); + + Commit::factory()->count(5)->sequence(fn (): array => [ + 'user_id' => $users->isEmpty() ? User::factory() : $users->random()->id, + 'body' => fake()->randomElement([ + 'feat: upgrade to the latest @laravel release', + 'fix: queue workers restart cleanly, thanks @laravel', + 'chore: try Octane from @laravel on the side project', + 'docs: everything I learned reading @laravel source today', + 'feat: ship it with @laravel Cloud', + ]), + 'created_at' => fake()->dateTimeBetween('-3 days'), + ])->create()->each(fn (Commit $commit) => $this->createCommitTopics->handle($commit, ['laravel' => $laravel->id])); + } +} diff --git a/resources/js/components/follow-button.tsx b/resources/js/components/follow-button.tsx index 2dda2affbb..35952e059f 100644 --- a/resources/js/components/follow-button.tsx +++ b/resources/js/components/follow-button.tsx @@ -5,6 +5,7 @@ import { UserCheck, UserPlus } from 'lucide-react'; import type { ComponentProps } from 'react'; import { useState } from 'react'; import { toast } from 'sonner'; +import OrganizationFollowController from '@/actions/App/Http/Controllers/OrganizationFollowController'; import UserFollowController from '@/actions/App/Http/Controllers/UserFollowController'; import { Button } from '@/components/ui/button'; import { @@ -327,3 +328,68 @@ export function FollowIconButton({ ); } + +/** + * Following an organization brings the commits that mention it into the Following log. + */ +export function OrganizationFollowButton({ + organization, + isFollowing: initialIsFollowing, + className, +}: { + organization: { id: string; handle: string }; + isFollowing: boolean; + className?: string; +}) { + const { + props: { auth }, + } = usePage(); + const [isFollowing, setIsFollowing] = useState(initialIsFollowing); + const { isJustToggled, markToggled, onMouseLeave } = useFollowHover(); + + if (auth.user === null) { + return ( + + ); + } + + return ( + + ); +} diff --git a/resources/js/pages/organization/show.tsx b/resources/js/pages/organization/show.tsx index d21197f1c9..44f16f6077 100644 --- a/resources/js/pages/organization/show.tsx +++ b/resources/js/pages/organization/show.tsx @@ -5,6 +5,7 @@ import { useState } from 'react'; import { show } from '@/actions/App/Http/Controllers/UserController'; import { BioText } from '@/components/bio-text'; import { CommitFeed, EmptyFeed } from '@/components/commit-feed'; +import { OrganizationFollowButton } from '@/components/follow-button'; import { GitHubIcon } from '@/components/github-icon'; import { OrganizationAvatar } from '@/components/organization-avatar'; import { OrganizationLaneDialog } from '@/components/organization-lane-dialog'; @@ -26,6 +27,7 @@ import type { export default function Show({ organization, lane, + isFollowing, tab, commits, claimUrl, @@ -33,6 +35,7 @@ export default function Show({ }: { organization: Organization & { id: string }; lane: Lane | null; + isFollowing: boolean; tab: 'mentions'; commits: CursorPaginated; claimUrl: string; @@ -105,6 +108,11 @@ export default function Show({ handle={organization.handle} href={show(organization).url} /> + diff --git a/routes/web.php b/routes/web.php index 141cc50291..14bc67b6ae 100644 --- a/routes/web.php +++ b/routes/web.php @@ -80,6 +80,7 @@ use App\Http\Controllers\OrganizationAdminController; use App\Http\Controllers\OrganizationController; use App\Http\Controllers\OrganizationLaneController; +use App\Http\Controllers\OrganizationFollowController; use App\Http\Controllers\OrganizationMentionController; use App\Http\Controllers\OrganizationSitemapController; use App\Http\Controllers\OrgArchiveController; @@ -620,6 +621,16 @@ ->middleware('throttle:follows') ->name('user-follow.destroy'); + // Organization Follow... + Route::post('organizations/{organization}/follow', [OrganizationFollowController::class, 'store']) + ->middleware('throttle:follows') + ->whereUuid('organization') + ->name('organization-follow.store'); + Route::delete('organizations/{organization}/follow', [OrganizationFollowController::class, 'destroy']) + ->middleware('throttle:follows') + ->whereUuid('organization') + ->name('organization-follow.destroy'); + // User Mute... Route::post('@{user:handle}/mute', [UserMuteController::class, 'store']) ->name('user-mute.store'); diff --git a/tests/Feature/Controllers/OrganizationFollowControllerTest.php b/tests/Feature/Controllers/OrganizationFollowControllerTest.php new file mode 100644 index 0000000000..ea8a5f8754 --- /dev/null +++ b/tests/Feature/Controllers/OrganizationFollowControllerTest.php @@ -0,0 +1,74 @@ +create(); + $laravel = Profile::factory()->organization()->create(['handle' => 'laravel']); + + $response = $this->actingAs($user) + ->from('/@laravel') + ->post(route('organization-follow.store', $laravel)); + + $response->assertRedirect('/@laravel'); + + expect($user->isFollowingOrganization($laravel))->toBeTrue(); + + $this->actingAs($user) + ->post(route('organization-follow.store', $laravel)); + + expect($user->followedOrganizations()->count())->toBe(1); +}); + +it('may unfollow an organization', function (): void { + $user = User::factory()->create(); + $laravel = Profile::factory()->organization()->create(); + + $user->followedOrganizations()->attach($laravel); + + $this->actingAs($user) + ->delete(route('organization-follow.destroy', $laravel)) + ->assertRedirect(); + + expect($user->isFollowingOrganization($laravel))->toBeFalse(); +}); + +it('may not follow the profile of a user as an organization', function (): void { + $user = User::factory()->create(); + $other = User::factory()->create(); + + $this->actingAs($user) + ->post(route('organization-follow.store', $other->profile)) + ->assertNotFound(); + + expect($user->followedOrganizations()->count())->toBe(0); +}); + +it('requires authentication to follow an organization', function (): void { + $laravel = Profile::factory()->organization()->create(); + + $this->post(route('organization-follow.store', $laravel)) + ->assertRedirectToRoute('home'); + + expect(DB::table('organization_follows')->count())->toBe(0); +}); + +it('tells the organization page whether the viewer follows it', function (): void { + $user = User::factory()->create(); + $laravel = Profile::factory()->organization()->create(['handle' => 'laravel']); + + $this->actingAs($user)->get('/@laravel') + ->assertInertia(fn (Assert $page): Assert => $page->where('isFollowing', false)); + + $user->followedOrganizations()->attach($laravel); + + $this->actingAs($user)->get('/@laravel') + ->assertInertia(fn (Assert $page): Assert => $page->where('isFollowing', true)); + + $this->get('/@laravel')->assertOk(); +}); diff --git a/tests/Feature/DatabaseSeederTest.php b/tests/Feature/DatabaseSeederTest.php index 8b4aaaaa2f..221696f84e 100644 --- a/tests/Feature/DatabaseSeederTest.php +++ b/tests/Feature/DatabaseSeederTest.php @@ -3,11 +3,15 @@ declare(strict_types=1); use App\Enums\InboxTab; +use App\Enums\ProfileType; +use App\Models\Commit; use App\Models\Notification; +use App\Models\Profile; use App\Models\User; use App\Queries\ConversationsQuery; use App\Queries\NotificationGroupsQuery; use Database\Seeders\DatabaseSeeder; +use Database\Seeders\OrganizationSeeder; it('gives the local developer people to follow and followers', function (): void { $this->seed(DatabaseSeeder::class); @@ -58,3 +62,13 @@ 'requests off' => [false, 0, 2], 'requests on' => [true, 1, 3], ]); + +it('seeds the laravel organization with commits that mention it', function (): void { + $this->seed(OrganizationSeeder::class); + $this->seed(OrganizationSeeder::class); + + $laravel = Profile::query()->where('handle', 'laravel')->sole(); + + expect($laravel->type)->toBe(ProfileType::Organization) + ->and(Commit::query()->mentioning($laravel)->count())->toBe(10); +}); diff --git a/tests/Unit/Queries/LogQueries/FollowingLogQueryTest.php b/tests/Unit/Queries/LogQueries/FollowingLogQueryTest.php index f0e077873e..bc6a229af3 100644 --- a/tests/Unit/Queries/LogQueries/FollowingLogQueryTest.php +++ b/tests/Unit/Queries/LogQueries/FollowingLogQueryTest.php @@ -2,7 +2,9 @@ declare(strict_types=1); +use App\Actions\CreateCommitTopics; use App\Models\Commit; +use App\Models\Profile; use App\Models\User; use App\Queries\LogQueries\FollowingLogQuery; use Illuminate\Support\Facades\DB; @@ -102,3 +104,20 @@ expect($commits->modelKeys())->toBe([$visible->id]); }); + +it('includes commits that mention followed organizations', function (): void { + $user = User::factory()->create(); + $laravel = Profile::factory()->organization()->create(['handle' => 'laravel']); + $symfony = Profile::factory()->organization()->create(['handle' => 'symfony']); + + $user->followedOrganizations()->attach($laravel); + + $mentioning = Commit::factory()->create(); + resolve(CreateCommitTopics::class)->handle($mentioning, ['laravel' => $laravel->id]); + $other = Commit::factory()->create(); + resolve(CreateCommitTopics::class)->handle($other, ['symfony' => $symfony->id]); + + $commits = resolve(FollowingLogQuery::class)->builder($user)->get(); + + expect($commits->modelKeys())->toBe([$mentioning->id]); +});