From 5a8a60d223ebc02cd88a9f155f23c8260f6713ea Mon Sep 17 00:00:00 2001 From: Matthieu Napoli Date: Tue, 3 Nov 2026 00:16:10 +0100 Subject: [PATCH 1/4] feat: let members wait to follow developers who aren't on /dev/null yet --- KNOWLEDGE_BASE.md | 4 +- app/Actions/CreateExport.php | 18 +++ .../CreateFollowsFromPendingFollows.php | 34 ++++ app/Actions/CreateGitHubProfile.php | 20 ++- app/Actions/CreatePendingFollow.php | 28 ++++ app/Actions/DeletePendingFollow.php | 20 +++ app/Actions/DeleteUser.php | 1 + app/Actions/UpdateOrCreateUser.php | 18 ++- app/Actions/UpdateStaleHandles.php | 4 + .../Controllers/PendingFollowController.php | 34 ++++ .../UnclaimedProfileController.php | 19 ++- .../Requests/CreatePendingFollowRequest.php | 31 ++++ .../Requests/DeletePendingFollowRequest.php | 31 ++++ ...02_130000_create_pending_follows_table.php | 27 ++++ resources/js/pages/privacy/show.tsx | 6 +- resources/js/pages/profile/unclaimed.tsx | 153 +++++++++++++++++- routes/web.php | 11 ++ .../Controllers/ExportControllerTest.php | 18 +++ .../PendingFollowControllerTest.php | 43 +++++ .../UnclaimedProfileControllerTest.php | 24 +++ .../Unit/Actions/CreatePendingFollowTest.php | 32 ++++ tests/Unit/Actions/DeleteUserTest.php | 25 +++ tests/Unit/Actions/UpdateOrCreateUserTest.php | 24 +++ tests/Unit/Jobs/LookUpGitHubProfileTest.php | 19 +++ 24 files changed, 627 insertions(+), 17 deletions(-) create mode 100644 app/Actions/CreateFollowsFromPendingFollows.php create mode 100644 app/Actions/CreatePendingFollow.php create mode 100644 app/Actions/DeletePendingFollow.php create mode 100644 app/Http/Controllers/PendingFollowController.php create mode 100644 app/Http/Requests/CreatePendingFollowRequest.php create mode 100644 app/Http/Requests/DeletePendingFollowRequest.php create mode 100644 database/migrations/2026_11_02_130000_create_pending_follows_table.php create mode 100644 tests/Feature/Controllers/PendingFollowControllerTest.php create mode 100644 tests/Unit/Actions/CreatePendingFollowTest.php diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 59c1b4d69..494804736 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -133,7 +133,9 @@ Sources: [organization page](resources/js/pages/organization/show.tsx), [commits Signed in, you can open the address of anyone on GitHub, like `/@octocat`, even if they never signed up. The first time, the page looks the handle up on GitHub while you wait, usually a few seconds, then shows what it found: a developer's page, or an organization's. Each member can look up to 30 handles an hour, since lookups share GitHub's limit with everyone; when that limit runs low, the page says when lookups resume. A handle that is no GitHub account says so, and isn't asked about again for a day. Guests and search engines can't look anyone up: they're asked to sign in. -A developer who isn't here yet gets a page with their GitHub avatar, name, handle, bio and a link to GitHub, marked **Not on /dev/null yet**, but never their location or website. Anyone with the link can open it, and it shares as a card, but search engines are asked to leave it out. Signing in with that GitHub account makes the page yours: guests see an **I am @handle** button for that. If you delete your account, your page doesn't come back as one of these, until you sign in again. +A developer who isn't here yet gets a page with their GitHub avatar, name, handle, bio and a link to GitHub, marked **Not on /dev/null yet**, but never their location or website. Anyone with the link can open it, and it shares as a card, but search engines are asked to leave it out. Signing in with that GitHub account makes the page yours: guests see an **I am @handle** button for that. + +Its page shows how many members are waiting to follow them: the members who followed them on GitHub when they were looked up, and the ones who clicked **Follow** on their page, which then reads **Waiting** and can be clicked again to stop waiting. Members also see the people they follow among them. When the developer signs up, everyone waiting follows them, and their notifications show those follows like any others. Who you wait to follow is in your data export, and goes when you delete your account. If you delete your account, your page doesn't come back as one of these, until you sign in again. Sources: [looking handles up](app/Http/Controllers/ProfileLookupController.php), [lookup page](resources/js/pages/profile/lookup.tsx), [developer page](resources/js/pages/profile/unclaimed.tsx), [GitHub lookups](app/Actions/CreateGitHubProfile.php). diff --git a/app/Actions/CreateExport.php b/app/Actions/CreateExport.php index 289e5abb1..565a4283d 100644 --- a/app/Actions/CreateExport.php +++ b/app/Actions/CreateExport.php @@ -52,6 +52,7 @@ * organizations: array, * followed_organizations: array, * pending_organization_follows: list, + * waiting_to_follow: list, * messages: Generator, * reports: Traversable>, * remote_pins: array, @@ -124,6 +125,7 @@ public function handle(User $user): array 'organizations' => $this->organizations($user), 'followed_organizations' => $this->followedOrganizations($user), 'pending_organization_follows' => $this->pendingOrganizationFollows($user), + 'waiting_to_follow' => $this->waitingToFollow($user), 'messages' => $this->messages($user), 'reports' => CommitReport::query()->where('reporter_id', $user->id)->oldest('id')->cursor()->map(fn (CommitReport $report): array => [ 'commit_id' => $report->commit_id, @@ -407,6 +409,22 @@ private function pendingOrganizationFollows(User $user): array return DB::table('pending_organization_follows')->where('user_id', $user->id)->orderBy('github_id')->pluck('github_id')->all(); } + /** + * The handles of the developers not on /dev/null yet that the user waits to follow, once they sign in. + * + * @return list + */ + private function waitingToFollow(User $user): array + { + /** @var list */ + return DB::table('pending_follows') + ->join('profiles', 'profiles.id', '=', 'pending_follows.profile_id') + ->where('pending_follows.user_id', $user->id) + ->orderBy('profiles.handle') + ->pluck('profiles.handle') + ->all(); + } + /** * Moderators stay anonymous, as they are on the notifications page, and level-ups are the user's own doing. An * organization's notifications name the organization the user heard about it for. diff --git a/app/Actions/CreateFollowsFromPendingFollows.php b/app/Actions/CreateFollowsFromPendingFollows.php new file mode 100644 index 000000000..9c3cb5006 --- /dev/null +++ b/app/Actions/CreateFollowsFromPendingFollows.php @@ -0,0 +1,34 @@ +where('profile_id', $user->id); + $followers = User::query()->whereIn('id', $pending->clone()->select('user_id'))->get(); + + $user->followers()->attach($followers->modelKeys()); + + foreach ($followers as $follower) { + $this->createFollowNotification->handle($follower, $user); + } + + $pending->delete(); + } +} diff --git a/app/Actions/CreateGitHubProfile.php b/app/Actions/CreateGitHubProfile.php index 9f0d3ddc0..b1bf88d78 100644 --- a/app/Actions/CreateGitHubProfile.php +++ b/app/Actions/CreateGitHubProfile.php @@ -25,9 +25,9 @@ public function __construct( /** * Gives the handle a page from its GitHub account: an organization's, or an unclaimed developer's, which keeps only - * what the page shows. A user here who renamed on GitHub moves to the handle. A handle that is no GitHub account, - * or the account of someone who deleted theirs here, is remembered for a day, so the page says so without asking - * GitHub again. + * what the page shows, with the members following them on GitHub waiting to follow them here. A user here who + * renamed on GitHub moves to the handle. A handle that is no GitHub account, or the account of someone who deleted + * theirs here, is remembered for a day, so the page says so without asking GitHub again. */ public function handle(string $handle): void { @@ -50,7 +50,12 @@ public function handle(string $handle): void $profile = GitHubProfile::fromAccount($account); - DB::transaction(function () use ($profile, $githubId): void { + // Asked before the profile exists, so a sign-in can't come between them and miss the members waiting... + $followerIds = Profile::query()->where('github_id', $githubId)->exists() + ? [] + : $this->github->followerIds($profile->handle, CreateUserGitHubFollows::MaxPages); + + DB::transaction(function () use ($profile, $githubId, $followerIds): void { $this->updateStaleHandles->handle($profile->handle, $githubId); $existing = Profile::query()->where('type', ProfileType::User)->firstWhere('github_id', $githubId); @@ -62,13 +67,18 @@ public function handle(string $handle): void return; } - Profile::query()->updateOrCreate(['github_id' => $githubId, 'type' => ProfileType::User], [ + $unclaimed = Profile::query()->updateOrCreate(['github_id' => $githubId, 'type' => ProfileType::User], [ 'handle' => $profile->handle, 'name' => $profile->name, 'avatar_url' => $profile->avatarUrl, 'bio' => $profile->bio, 'github_synced_at' => now(), ]); + + DB::table('pending_follows')->insertUsing( + ['user_id', 'profile_id'], + User::query()->whereIn('github_id', $followerIds)->select('id')->selectRaw('?', [$unclaimed->id]), + ); }); } } diff --git a/app/Actions/CreatePendingFollow.php b/app/Actions/CreatePendingFollow.php new file mode 100644 index 000000000..d1edbaa65 --- /dev/null +++ b/app/Actions/CreatePendingFollow.php @@ -0,0 +1,28 @@ +lockForUpdate()->find($profile->id)?->isUnclaimed() !== true) { + return; + } + + DB::table('pending_follows')->insertOrIgnore(['user_id' => $user->id, 'profile_id' => $profile->id]); + }); + } +} diff --git a/app/Actions/DeletePendingFollow.php b/app/Actions/DeletePendingFollow.php new file mode 100644 index 000000000..1940b42cc --- /dev/null +++ b/app/Actions/DeletePendingFollow.php @@ -0,0 +1,20 @@ +where('user_id', $user->id)->where('profile_id', $profile->id)->delete(); + } +} diff --git a/app/Actions/DeleteUser.php b/app/Actions/DeleteUser.php index 3a7a30750..17abc90e6 100644 --- a/app/Actions/DeleteUser.php +++ b/app/Actions/DeleteUser.php @@ -80,6 +80,7 @@ public function handle(User $user): void $this->deleteOrgs->handle($this->emptyOrgs($user)); + DB::table('pending_follows')->where('user_id', $user->id)->orWhere('profile_id', $user->id)->delete(); $user->organizations()->detach(); $user->followedOrganizations()->detach(); DB::table('user_active_days')->where('user_id', $user->id)->delete(); diff --git a/app/Actions/UpdateOrCreateUser.php b/app/Actions/UpdateOrCreateUser.php index 416a90308..04df6f635 100644 --- a/app/Actions/UpdateOrCreateUser.php +++ b/app/Actions/UpdateOrCreateUser.php @@ -14,14 +14,18 @@ final readonly class UpdateOrCreateUser { - public function __construct(private GitHubProvider $github, private UpdateStaleHandles $updateStaleHandles) - { + public function __construct( + private GitHubProvider $github, + private UpdateStaleHandles $updateStaleHandles, + private CreateFollowsFromPendingFollows $createFollowsFromPendingFollows, + ) { // } /** * Signing in makes the GitHub account's profile a user's: an unclaimed one, looked up from GitHub before, becomes - * theirs with its ID. Someone who deleted their account and comes back is no longer remembered as gone. + * theirs with its ID, and the members waiting for them follow them. Someone who deleted their account and comes back + * is no longer remembered as gone. */ public function handle(GitHubUser $githubUser): User { @@ -46,7 +50,7 @@ public function handle(GitHubUser $githubUser): User DB::table('deleted_github_accounts')->where('github_id', $githubId)->delete(); - return User::query()->updateOrCreate([ + $user = User::query()->updateOrCreate([ 'id' => $id, 'github_id' => $githubId, ], [ @@ -54,6 +58,12 @@ public function handle(GitHubUser $githubUser): User 'email' => $email === '' ? null : $email, ...($hasSponsorsListing === null ? [] : ['has_sponsors_listing' => $hasSponsorsListing]), ]); + + if ($user->wasRecentlyCreated) { + $this->createFollowsFromPendingFollows->handle($user); + } + + return $user; }); } } diff --git a/app/Actions/UpdateStaleHandles.php b/app/Actions/UpdateStaleHandles.php index 21b9e7184..c36429c0e 100644 --- a/app/Actions/UpdateStaleHandles.php +++ b/app/Actions/UpdateStaleHandles.php @@ -5,6 +5,7 @@ namespace App\Actions; use App\Models\Profile; +use Illuminate\Support\Facades\DB; final readonly class UpdateStaleHandles { @@ -22,6 +23,9 @@ public function handle(string $handle, string $githubId): void ->where('github_id', '!=', $githubId) ->each(function (Profile $profile): void { if ($profile->isUnclaimed()) { + // Locked, so no one starts waiting for it in between (CreatePendingFollow)... + Profile::query()->lockForUpdate()->find($profile->id); + DB::table('pending_follows')->where('profile_id', $profile->id)->delete(); $profile->delete(); return; diff --git a/app/Http/Controllers/PendingFollowController.php b/app/Http/Controllers/PendingFollowController.php new file mode 100644 index 000000000..34685daa5 --- /dev/null +++ b/app/Http/Controllers/PendingFollowController.php @@ -0,0 +1,34 @@ +handle($user, $profile); + + return back(); + } + + public function destroy(DeletePendingFollowRequest $request, #[CurrentUser] User $user, Profile $profile, DeletePendingFollow $action): RedirectResponse + { + $action->handle($user, $profile); + + return back(); + } +} diff --git a/app/Http/Controllers/UnclaimedProfileController.php b/app/Http/Controllers/UnclaimedProfileController.php index e0e9f6596..da7d38ab6 100644 --- a/app/Http/Controllers/UnclaimedProfileController.php +++ b/app/Http/Controllers/UnclaimedProfileController.php @@ -10,6 +10,7 @@ use App\Services\PageMetadata; use Illuminate\Container\Attributes\CurrentUser; use Illuminate\Http\RedirectResponse; +use Illuminate\Support\Facades\DB; use Inertia\Inertia; use Inertia\Response; @@ -17,8 +18,8 @@ { /** * A developer looked up from GitHub who isn't on /dev/null yet has a page at their "@handle", with what GitHub - * shows of them but where they are, for anyone with the link, but kept out of search engines. Signing in as them - * makes it theirs. Like users' pages, it moves for good to the handle's own case. + * shows of them but where they are, for anyone with the link, but kept out of search engines, with how many members + * wait to follow them. Signing in as them makes it theirs. Like users' pages, it moves for good to the handle's own case. */ public function show(ShowUnclaimedProfileRequest $request, #[CurrentUser] ?User $currentUser, PageMetadata $metadata): RedirectResponse|Response { @@ -32,8 +33,20 @@ public function show(ShowUnclaimedProfileRequest $request, #[CurrentUser] ?User return redirect(route('user.show', $profile->handle).($query === '' ? '' : "?{$query}"), 301, ['Cache-Control' => 'max-age=86400, private']); } + $waiting = DB::table('pending_follows')->where('profile_id', $profile->id); + return Inertia::render('profile/unclaimed', [ - 'profile' => $profile->only(['handle', 'name', 'avatar_url', 'bio']), + 'profile' => $profile->only(['id', 'handle', 'name', 'avatar_url', 'bio']), + 'waitingCount' => fn (): int => $waiting->clone()->count(), + 'isWaiting' => fn (): bool => $currentUser instanceof User && $waiting->clone()->where('user_id', $currentUser->id)->exists(), + // Who follows whom is for members, so only they see who is waiting, among the people they follow... + 'waitingYouFollow' => fn (): array => $currentUser instanceof User ? $currentUser->following() + ->whereIn('users.id', $waiting->clone()->select('user_id')) + ->orderBy('users.id') + ->limit(3) + ->get() + ->map(fn (User $user): array => $user->only(['id', 'handle', 'name', 'avatar_url'])) + ->all() : [], ])->withViewData(['preview' => $currentUser instanceof User || $request->inertia() ? null : $metadata->unclaimedProfile($profile)]); } } diff --git a/app/Http/Requests/CreatePendingFollowRequest.php b/app/Http/Requests/CreatePendingFollowRequest.php new file mode 100644 index 000000000..c5384c19a --- /dev/null +++ b/app/Http/Requests/CreatePendingFollowRequest.php @@ -0,0 +1,31 @@ +route('profile'); + assert($profile instanceof Profile); + + return $profile->isUnclaimed() ? Response::allow() : Response::denyAsNotFound(); + } + + /** + * @return array{} + */ + public function rules(): array + { + return []; + } +} diff --git a/app/Http/Requests/DeletePendingFollowRequest.php b/app/Http/Requests/DeletePendingFollowRequest.php new file mode 100644 index 000000000..d8462db53 --- /dev/null +++ b/app/Http/Requests/DeletePendingFollowRequest.php @@ -0,0 +1,31 @@ +route('profile'); + assert($profile instanceof Profile); + + return $profile->isUnclaimed() ? Response::allow() : Response::denyAsNotFound(); + } + + /** + * @return array{} + */ + public function rules(): array + { + return []; + } +} diff --git a/database/migrations/2026_11_02_130000_create_pending_follows_table.php b/database/migrations/2026_11_02_130000_create_pending_follows_table.php new file mode 100644 index 000000000..a7b81696e --- /dev/null +++ b/database/migrations/2026_11_02_130000_create_pending_follows_table.php @@ -0,0 +1,27 @@ +foreignUuid('user_id')->constrained(); + $table->foreignUuid('profile_id')->constrained(); + + $table->primary(['user_id', 'profile_id']); + $table->index('profile_id'); + }); + } +}; diff --git a/resources/js/pages/privacy/show.tsx b/resources/js/pages/privacy/show.tsx index 4cc272e8a..f16d29a6f 100644 --- a/resources/js/pages/privacy/show.tsx +++ b/resources/js/pages/privacy/show.tsx @@ -64,8 +64,10 @@ export default function Show() { when a member opens the page of your GitHub username, your GitHub ID, username, name, avatar, and bio, to show that page until you - sign in and make it yours. Never your location - or website. + sign in and make it yours, and which members + follow you on GitHub, who then follow you here + once you sign up. Never your location or + website. , <> diff --git a/resources/js/pages/profile/unclaimed.tsx b/resources/js/pages/profile/unclaimed.tsx index fffc8d19f..771803bc7 100644 --- a/resources/js/pages/profile/unclaimed.tsx +++ b/resources/js/pages/profile/unclaimed.tsx @@ -1,14 +1,22 @@ -import { Head, usePage } from '@inertiajs/react'; +import { Head, router, usePage } from '@inertiajs/react'; +import { Clock } from 'lucide-react'; +import { useState } from 'react'; +import PendingFollowController from '@/actions/App/Http/Controllers/PendingFollowController'; import { show } from '@/actions/App/Http/Controllers/UserController'; import { BioText } from '@/components/bio-text'; +import { useFollowHover } from '@/components/follow-button'; import { GitHubIcon } from '@/components/github-icon'; import { ProfileShareButton } from '@/components/profile-share-button'; import { Button } from '@/components/ui/button'; import { UserAvatar } from '@/components/user-avatar'; import AppLayout from '@/layouts/app-layout'; +import { exactCountTitle, formatCount } from '@/lib/numbers'; +import { toastWhenThrottled } from '@/lib/throttle'; import { login } from '@/routes'; +import type { User } from '@/types'; type UnclaimedProfile = { + id: string; handle: string; name: string; avatar_url: string | null; @@ -21,10 +29,19 @@ type UnclaimedProfile = { */ export default function UnclaimedProfile({ profile, + waitingCount, + isWaiting, + waitingYouFollow, }: { profile: UnclaimedProfile; + waitingCount: number; + isWaiting: boolean; + waitingYouFollow: Pick[]; }) { const { auth } = usePage().props; + const [isWaitingNow, setIsWaitingNow] = useState(isWaiting); + const shownWaitingCount = + waitingCount + Number(isWaitingNow) - Number(isWaiting); return ( <> @@ -52,13 +69,19 @@ export default function UnclaimedProfile({ handle={profile.handle} href={show(profile.handle).url} /> - {auth.user === null && ( + {auth.user === null ? ( + ) : ( + )} @@ -92,6 +115,44 @@ export default function UnclaimedProfile({ + + {shownWaitingCount > 0 && ( +
+ {waitingYouFollow.length > 0 && ( + + {waitingYouFollow.map((user) => ( + + ))} + + )} +
+

+ + {formatCount(shownWaitingCount)} + {' '} + + {shownWaitingCount === 1 + ? 'member waiting to follow' + : 'members waiting to follow'} + {waitingYouFollow.length > 0 && + `, including ${new Intl.ListFormat('en').format(waitingYouFollow.map((user) => `@${user.handle}`))}`} + +

+

+ They'll follow as soon as @{profile.handle}{' '} + signs up. +

+
+
+ )}
@@ -108,3 +169,91 @@ export default function UnclaimedProfile({ } UnclaimedProfile.layout = AppLayout; + +/** + * Following a developer who isn't here yet waits for them to sign up. It shows at once, and comes back if the server + * refuses it. Right after a click, it never turns into cancelling under the pointer. + */ +function WaitButton({ + profile, + isWaiting, + onToggle, +}: { + profile: UnclaimedProfile; + isWaiting: boolean; + onToggle: (isWaiting: boolean) => void; +}) { + const { isJustToggled, markToggled, onMouseLeave } = useFollowHover(); + // Each click waits for the one before it to answer, so a double click can't follow and then stop. + const [isPending, setIsPending] = useState(false); + + const toggle = () => { + if (isPending) { + return; + } + + const waits = !isWaiting; + + markToggled(); + onToggle(waits); + router.visit( + waits + ? PendingFollowController.store(profile) + : PendingFollowController.destroy(profile), + { + only: ['waitingCount', 'isWaiting', 'waitingYouFollow'], + preserveState: true, + preserveScroll: true, + async: true, + onStart: () => setIsPending(true), + onFinish: () => setIsPending(false), + onHttpException: (response) => { + onToggle(!waits); + + // Signed up meanwhile: the page is theirs now. + if (response.status === 404) { + router.reload(); + + return false; + } + + return !toastWhenThrottled(response); + }, + onNetworkError: () => onToggle(!waits), + }, + ); + }; + + return ( + + ); +} diff --git a/routes/web.php b/routes/web.php index 0474f2fa1..42cd4ddae 100644 --- a/routes/web.php +++ b/routes/web.php @@ -101,6 +101,7 @@ use App\Http\Controllers\OrgReadController; use App\Http\Controllers\OrgTypingController; use App\Http\Controllers\PartnerController; +use App\Http\Controllers\PendingFollowController; use App\Http\Controllers\PrivacyController; use App\Http\Controllers\ProductMetricsController; use App\Http\Controllers\ProfileLookupController; @@ -675,6 +676,16 @@ ->middleware('throttle:follows') ->name('user-follow.destroy'); + // Waiting to follow a developer who isn't on /dev/null yet... + Route::post('profiles/{profile}/pending-follow', [PendingFollowController::class, 'store']) + ->whereUuid('profile') + ->middleware('throttle:follows') + ->name('pending-follow.store'); + Route::delete('profiles/{profile}/pending-follow', [PendingFollowController::class, 'destroy']) + ->whereUuid('profile') + ->middleware('throttle:follows') + ->name('pending-follow.destroy'); + // Organization Follow... Route::post('organizations/{organization}/follow', [OrganizationFollowController::class, 'store']) ->whereUuid('organization') diff --git a/tests/Feature/Controllers/ExportControllerTest.php b/tests/Feature/Controllers/ExportControllerTest.php index 60160eaca..f265d23d9 100644 --- a/tests/Feature/Controllers/ExportControllerTest.php +++ b/tests/Feature/Controllers/ExportControllerTest.php @@ -20,6 +20,7 @@ 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; @@ -140,6 +141,7 @@ 'organizations' => [], 'followed_organizations' => [], 'pending_organization_follows' => [], + 'waiting_to_follow' => [], 'messages' => [], 'reports' => [], 'remote_mutes' => [], @@ -529,6 +531,22 @@ expect($data['pending_organization_follows'])->toBe(['1', '958072']); }); +it('exports the developers not on /dev/null yet the user waits to follow', function (): void { + $user = User::factory()->create(); + $octocat = Profile::factory()->create(['handle' => 'octocat']); + $hubot = Profile::factory()->create(['handle' => 'hubot']); + DB::table('pending_follows')->insert([ + ['user_id' => $user->id, 'profile_id' => $octocat->id], + ['user_id' => $user->id, 'profile_id' => $hubot->id], + ['user_id' => User::factory()->create()->id, 'profile_id' => Profile::factory()->create()->id], + ]); + + $response = $this->actingAs($user)->get(route('export')); + $data = json_decode($response->streamedContent(), true, flags: JSON_THROW_ON_ERROR); + + expect($data['waiting_to_follow'])->toBe(['hubot', 'octocat']); +}); + it('exports the devices approved for the app, oldest first', function (): void { $user = User::factory()->create(); $older = Device::factory()->for($user)->create(['name' => 'devnull on iPad', 'approved_at' => now()->subWeek(), 'last_used_at' => now()->subDay()]); diff --git a/tests/Feature/Controllers/PendingFollowControllerTest.php b/tests/Feature/Controllers/PendingFollowControllerTest.php new file mode 100644 index 000000000..30630e3f2 --- /dev/null +++ b/tests/Feature/Controllers/PendingFollowControllerTest.php @@ -0,0 +1,43 @@ +create(); + $profile = Profile::factory()->create(); + + $this->actingAs($member)->from('/@'.$profile->handle)->post(route('pending-follow.store', $profile))->assertRedirect('/@'.$profile->handle); + $this->actingAs($member)->post(route('pending-follow.store', $profile))->assertRedirect(); + + expect(DB::table('pending_follows')->where('profile_id', $profile->id)->pluck('user_id')->all())->toBe([$member->id]); + + $this->actingAs($member)->delete(route('pending-follow.destroy', $profile))->assertRedirect(); + + $this->assertDatabaseEmpty('pending_follows'); +}); + +it('waits only for developers who are not here yet', function (Closure $profile): void { + $member = User::factory()->create(); + $profile = $profile(); + + $this->actingAs($member)->post(route('pending-follow.store', $profile))->assertNotFound(); + $this->actingAs($member)->delete(route('pending-follow.destroy', $profile))->assertNotFound(); + + $this->assertDatabaseEmpty('pending_follows'); +})->with([ + 'a user' => fn (): Profile => User::factory()->create()->profile, + 'an organization' => fn (): Profile => Organization::factory()->create()->profile, +]); + +it('lets only members wait', function (): void { + $profile = Profile::factory()->create(); + + $this->post(route('pending-follow.store', $profile))->assertRedirect(route('home')); + + $this->assertDatabaseEmpty('pending_follows'); +}); diff --git a/tests/Feature/Controllers/UnclaimedProfileControllerTest.php b/tests/Feature/Controllers/UnclaimedProfileControllerTest.php index f60ea3b1f..bda0dac8f 100644 --- a/tests/Feature/Controllers/UnclaimedProfileControllerTest.php +++ b/tests/Feature/Controllers/UnclaimedProfileControllerTest.php @@ -4,6 +4,7 @@ use App\Models\Profile; use App\Models\User; +use Illuminate\Support\Facades\DB; it('shows an unclaimed developer to anyone with the link, but never where they are', function (): void { Profile::factory()->create([ @@ -20,6 +21,7 @@ ->assertInertia(fn ($page) => $page ->component('profile/unclaimed') ->where('profile', [ + 'id' => Profile::query()->where('handle', 'octocat')->sole()->id, 'handle' => 'octocat', 'name' => 'The Octocat', 'avatar_url' => 'https://avatars.githubusercontent.com/u/583231', @@ -56,3 +58,25 @@ ->assertOk() ->assertInertia(fn ($page) => $page->component('user/show')->where('user.id', $user->id)); }); + +it('shows how many members wait to follow them, and members the ones they follow among them', function (): void { + $profile = Profile::factory()->create(['handle' => 'octocat']); + $member = User::factory()->create(); + [$followed, $other] = User::factory()->count(2)->create(); + $stranger = User::factory()->create(); + $member->following()->attach([$followed->id, $other->id]); + DB::table('pending_follows')->insert(collect([$followed, $stranger, $member])->map(fn (User $user): array => ['user_id' => $user->id, 'profile_id' => $profile->id])->all()); + + $this->get('/@octocat') + ->assertInertia(fn ($page) => $page + ->where('waitingCount', 3) + ->where('isWaiting', false) + ->where('waitingYouFollow', [])); + + $this->actingAs($member) + ->get('/@octocat') + ->assertInertia(fn ($page) => $page + ->where('waitingCount', 3) + ->where('isWaiting', true) + ->where('waitingYouFollow', [$followed->only(['id', 'handle', 'name', 'avatar_url'])])); +}); diff --git a/tests/Unit/Actions/CreatePendingFollowTest.php b/tests/Unit/Actions/CreatePendingFollowTest.php new file mode 100644 index 000000000..bc99a6f74 --- /dev/null +++ b/tests/Unit/Actions/CreatePendingFollowTest.php @@ -0,0 +1,32 @@ +handle(User::factory()->create(), $profile()); + + $this->assertDatabaseEmpty('pending_follows'); +})->with([ + 'signed up' => fn (): Profile => User::factory()->create()->profile, + 'gone' => function (): Profile { + $profile = Profile::factory()->create(); + $profile->delete(); + + return $profile; + }, +]); + +it('waits for a developer once, however many times asked', function (): void { + $user = User::factory()->create(); + $profile = Profile::factory()->create(); + + resolve(CreatePendingFollow::class)->handle($user, $profile); + resolve(CreatePendingFollow::class)->handle($user, $profile); + + expect(DB::table('pending_follows')->count())->toBe(1); +}); diff --git a/tests/Unit/Actions/DeleteUserTest.php b/tests/Unit/Actions/DeleteUserTest.php index 278d99252..9cf1f04d8 100644 --- a/tests/Unit/Actions/DeleteUserTest.php +++ b/tests/Unit/Actions/DeleteUserTest.php @@ -24,6 +24,7 @@ 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\Queries\HeadlineSummaryQuery; @@ -275,6 +276,30 @@ expect(DB::table('pending_organization_follows')->pluck('user_id')->all())->toBe([$other->id]); }); +it('erases the developers a deleted user waited to follow', function (): void { + $user = User::factory()->create(); + $other = User::factory()->create(); + $profile = Profile::factory()->create(); + DB::table('pending_follows')->insert([ + ['user_id' => $user->id, 'profile_id' => $profile->id], + ['user_id' => $other->id, 'profile_id' => $profile->id], + ]); + + resolve(DeleteUser::class)->handle($user); + + expect(DB::table('pending_follows')->pluck('user_id')->all())->toBe([$other->id]); +}); + +it('erases the waits left on the profile of a deleted user, so nothing keeps it', function (): void { + $user = User::factory()->create(); + DB::table('pending_follows')->insert(['user_id' => User::factory()->create()->id, 'profile_id' => $user->id]); + + resolve(DeleteUser::class)->handle($user); + + $this->assertDatabaseEmpty('pending_follows'); + $this->assertModelMissing($user->profile); +}); + it('erases the devices of a deleted user', function (): void { $user = User::factory()->create(); Device::factory()->for($user)->create(); diff --git a/tests/Unit/Actions/UpdateOrCreateUserTest.php b/tests/Unit/Actions/UpdateOrCreateUserTest.php index fbc615066..f561b4b95 100644 --- a/tests/Unit/Actions/UpdateOrCreateUserTest.php +++ b/tests/Unit/Actions/UpdateOrCreateUserTest.php @@ -4,6 +4,8 @@ use App\Actions\UpdateOrCreateUser; use App\Contracts\Services\GitHubProvider; +use App\Enums\NotificationType; +use App\Models\Notification; use App\Models\Profile; use App\Models\User; use Illuminate\Support\Facades\DB; @@ -288,3 +290,25 @@ ->and(Profile::query()->count())->toBe(1); $this->assertDatabaseEmpty('deleted_github_accounts'); }); + +it('makes the members who waited for a new user follow them, each heard from once', function (): void { + $this->mock(GitHubProvider::class)->shouldReceive('hasSponsorsListing')->andReturnFalse(); + $unclaimed = Profile::factory()->create(['github_id' => '583231', 'handle' => 'octocat']); + [$first, $second] = User::factory()->count(2)->create(); + $other = Profile::factory()->create(); + DB::table('pending_follows')->insert([ + ['user_id' => $first->id, 'profile_id' => $unclaimed->id], + ['user_id' => $second->id, 'profile_id' => $unclaimed->id], + ['user_id' => $first->id, 'profile_id' => $other->id], + ]); + + $user = resolve(UpdateOrCreateUser::class)->handle(GitHubUser::fake(['id' => 583231, 'nickname' => 'octocat'])); + + expect($user->followers()->pluck('users.id')->sort()->values()->all())->toBe(collect([$first->id, $second->id])->sort()->values()->all()) + ->and(Notification::query()->where('recipient_id', $user->id)->where('type', NotificationType::Followed)->count())->toBe(2) + ->and(DB::table('pending_follows')->pluck('profile_id')->all())->toBe([$other->id]); + + resolve(UpdateOrCreateUser::class)->handle(GitHubUser::fake(['id' => 583231, 'nickname' => 'octocat'])); + + expect(Notification::query()->where('recipient_id', $user->id)->count())->toBe(2); +}); diff --git a/tests/Unit/Jobs/LookUpGitHubProfileTest.php b/tests/Unit/Jobs/LookUpGitHubProfileTest.php index 8976df29f..45f58f496 100644 --- a/tests/Unit/Jobs/LookUpGitHubProfileTest.php +++ b/tests/Unit/Jobs/LookUpGitHubProfileTest.php @@ -34,6 +34,7 @@ function octocatAccount(array $attributes = []): array $github = $this->mock(GitHubProvider::class); $github->shouldReceive('rateLimit')->andReturnNull(); $github->shouldReceive('accountByLogin')->once()->with('octocat')->andReturn(octocatAccount()); + $github->shouldReceive('followerIds')->once()->with('octocat', 10)->andReturn([]); Cache::put('github-profiles:looking-up:octocat', true, 30); dispatch_sync(new LookUpGitHubProfile('octocat')); @@ -54,6 +55,20 @@ function octocatAccount(array $attributes = []): array ->and(Cache::has('github-profiles:looking-up:octocat'))->toBeFalse(); }); +it('makes the members following a developer on GitHub wait to follow them here', function (): void { + $follower = User::factory()->create(); + User::factory()->create(); + $github = $this->mock(GitHubProvider::class); + $github->shouldReceive('rateLimit')->andReturnNull(); + $github->shouldReceive('accountByLogin')->once()->andReturn(octocatAccount()); + $github->shouldReceive('followerIds')->once()->with('octocat', 10)->andReturn([$follower->github_id, '424242']); + + dispatch_sync(new LookUpGitHubProfile('octocat')); + + expect(DB::table('pending_follows')->get(['user_id', 'profile_id'])->map(fn (object $row): array => (array) $row)->all()) + ->toBe([['user_id' => $follower->id, 'profile_id' => Profile::query()->where('handle', 'octocat')->sole()->id]]); +}); + it('gives a GitHub organization its page, followed by those waiting for it', function (): void { $github = $this->mock(GitHubProvider::class); $github->shouldReceive('rateLimit')->andReturnNull(); @@ -103,15 +118,18 @@ function octocatAccount(array $attributes = []): array $github = $this->mock(GitHubProvider::class); $github->shouldReceive('rateLimit')->andReturnNull(); $github->shouldReceive('accountByLogin')->once()->with('octocat')->andReturn(octocatAccount()); + $github->shouldNotReceive('followerIds'); $renamed = Profile::factory()->create(['github_id' => '583231', 'handle' => 'octo-old']); $previous = Profile::factory()->create(['github_id' => '1', 'handle' => 'octocat']); $member = User::factory()->create(['handle' => 'octocat-member']); + DB::table('pending_follows')->insert(['user_id' => $member->id, 'profile_id' => $previous->id]); dispatch_sync(new LookUpGitHubProfile('octocat')); expect($renamed->refresh()->handle)->toBe('octocat') ->and($member->profile->refresh()->handle)->toBe('octocat-member'); $this->assertModelMissing($previous); + $this->assertDatabaseEmpty('pending_follows'); }); it('only moves a user who renamed on GitHub to their new handle, leaving the rest to their next sign-in', function (): void { @@ -134,6 +152,7 @@ function octocatAccount(array $attributes = []): array $github = $this->mock(GitHubProvider::class); $github->shouldReceive('rateLimit')->andReturnNull(); $github->shouldReceive('accountByLogin')->once()->with('octocat')->andReturn(octocatAccount()); + $github->shouldReceive('followerIds')->once()->andReturn([]); $member = User::factory()->create(['github_id' => '1', 'handle' => 'octocat']); dispatch_sync(new LookUpGitHubProfile('octocat')); From 3fda4bbb87d9af211193577c639ad61e1f7e6436 Mon Sep 17 00:00:00 2001 From: Matthieu Napoli Date: Tue, 3 Nov 2026 00:31:33 +0100 Subject: [PATCH 2/4] refactor: keep every pending follow in one table, by GitHub ID, organizations and developers alike --- app/Actions/CreateExport.php | 28 ++++-------------- .../CreateFollowsFromPendingFollows.php | 2 +- app/Actions/CreateGitHubProfile.php | 9 +++--- app/Actions/CreatePendingFollow.php | 6 ++-- app/Actions/CreateUserGitHubFollows.php | 2 +- app/Actions/DeletePendingFollow.php | 2 +- app/Actions/DeleteUser.php | 1 - app/Actions/UpdateOrCreateOrganization.php | 2 +- app/Actions/UpdateStaleHandles.php | 6 +--- .../UnclaimedProfileController.php | 2 +- ...02_130000_create_pending_follows_table.php | 27 ----------------- ...rganization_follows_to_pending_follows.php | 27 +++++++++++++++++ .../Controllers/ExportControllerTest.php | 26 +++-------------- .../PendingFollowControllerTest.php | 2 +- .../UnclaimedProfileControllerTest.php | 2 +- .../Actions/CreateUserGitHubFollowsTest.php | 2 +- tests/Unit/Actions/DeleteUserTest.php | 29 ++----------------- .../UpdateOrCreateOrganizationTest.php | 4 +-- tests/Unit/Actions/UpdateOrCreateUserTest.php | 9 +++--- tests/Unit/Jobs/LookUpGitHubProfileTest.php | 11 +++---- 20 files changed, 67 insertions(+), 132 deletions(-) delete mode 100644 database/migrations/2026_11_02_130000_create_pending_follows_table.php create mode 100644 database/migrations/2026_11_02_130000_rename_pending_organization_follows_to_pending_follows.php diff --git a/app/Actions/CreateExport.php b/app/Actions/CreateExport.php index 565a4283d..4a6a0a579 100644 --- a/app/Actions/CreateExport.php +++ b/app/Actions/CreateExport.php @@ -51,8 +51,7 @@ * dismissed_suggestions: Generator, * organizations: array, * followed_organizations: array, - * pending_organization_follows: list, - * waiting_to_follow: list, + * pending_follows: list, * messages: Generator, * reports: Traversable>, * remote_pins: array, @@ -124,8 +123,7 @@ public function handle(User $user): array 'dismissed_suggestions' => $this->handles($user->dismissedSuggestions()), 'organizations' => $this->organizations($user), 'followed_organizations' => $this->followedOrganizations($user), - 'pending_organization_follows' => $this->pendingOrganizationFollows($user), - 'waiting_to_follow' => $this->waitingToFollow($user), + 'pending_follows' => $this->pendingFollows($user), 'messages' => $this->messages($user), 'reports' => CommitReport::query()->where('reporter_id', $user->id)->oldest('id')->cursor()->map(fn (CommitReport $report): array => [ 'commit_id' => $report->commit_id, @@ -399,30 +397,14 @@ private function followedOrganizations(User $user): array } /** - * The GitHub IDs of the organizations the user follows on GitHub, to follow here once they are on /dev/null. + * The GitHub IDs of the accounts the user waits to follow until they are on /dev/null: organizations and developers. * * @return list */ - private function pendingOrganizationFollows(User $user): array + private function pendingFollows(User $user): array { /** @var list */ - return DB::table('pending_organization_follows')->where('user_id', $user->id)->orderBy('github_id')->pluck('github_id')->all(); - } - - /** - * The handles of the developers not on /dev/null yet that the user waits to follow, once they sign in. - * - * @return list - */ - private function waitingToFollow(User $user): array - { - /** @var list */ - return DB::table('pending_follows') - ->join('profiles', 'profiles.id', '=', 'pending_follows.profile_id') - ->where('pending_follows.user_id', $user->id) - ->orderBy('profiles.handle') - ->pluck('profiles.handle') - ->all(); + return DB::table('pending_follows')->where('user_id', $user->id)->orderBy('github_id')->pluck('github_id')->all(); } /** diff --git a/app/Actions/CreateFollowsFromPendingFollows.php b/app/Actions/CreateFollowsFromPendingFollows.php index 9c3cb5006..a358962c0 100644 --- a/app/Actions/CreateFollowsFromPendingFollows.php +++ b/app/Actions/CreateFollowsFromPendingFollows.php @@ -20,7 +20,7 @@ public function __construct(private CreateFollowNotification $createFollowNotifi */ public function handle(User $user): void { - $pending = DB::table('pending_follows')->where('profile_id', $user->id); + $pending = DB::table('pending_follows')->where('github_id', $user->github_id); $followers = User::query()->whereIn('id', $pending->clone()->select('user_id'))->get(); $user->followers()->attach($followers->modelKeys()); diff --git a/app/Actions/CreateGitHubProfile.php b/app/Actions/CreateGitHubProfile.php index b1bf88d78..6063fc91c 100644 --- a/app/Actions/CreateGitHubProfile.php +++ b/app/Actions/CreateGitHubProfile.php @@ -67,7 +67,7 @@ public function handle(string $handle): void return; } - $unclaimed = Profile::query()->updateOrCreate(['github_id' => $githubId, 'type' => ProfileType::User], [ + Profile::query()->updateOrCreate(['github_id' => $githubId, 'type' => ProfileType::User], [ 'handle' => $profile->handle, 'name' => $profile->name, 'avatar_url' => $profile->avatarUrl, @@ -75,9 +75,10 @@ public function handle(string $handle): void 'github_synced_at' => now(), ]); - DB::table('pending_follows')->insertUsing( - ['user_id', 'profile_id'], - User::query()->whereIn('github_id', $followerIds)->select('id')->selectRaw('?', [$unclaimed->id]), + // Those still waiting from before the profile last went are kept... + DB::table('pending_follows')->insertOrIgnoreUsing( + ['user_id', 'github_id', 'created_at'], + User::query()->whereIn('github_id', $followerIds)->select('id')->selectRaw('?, ?', [$githubId, now()]), ); }); } diff --git a/app/Actions/CreatePendingFollow.php b/app/Actions/CreatePendingFollow.php index d1edbaa65..313b3e417 100644 --- a/app/Actions/CreatePendingFollow.php +++ b/app/Actions/CreatePendingFollow.php @@ -12,8 +12,8 @@ { /** * The user follows the developer once they sign up. Waiting twice is waiting once. The profile is locked, as signing - * up turns the waits into follows under its lock: a wait for someone who just signed up, or whose profile just went, - * is dropped rather than left behind. + * up turns the waits into follows under its lock: a wait for someone who just signed up is dropped rather than left + * behind. */ public function handle(User $user, Profile $profile): void { @@ -22,7 +22,7 @@ public function handle(User $user, Profile $profile): void return; } - DB::table('pending_follows')->insertOrIgnore(['user_id' => $user->id, 'profile_id' => $profile->id]); + DB::table('pending_follows')->insertOrIgnore(['user_id' => $user->id, 'github_id' => $profile->github_id, 'created_at' => now()]); }); } } diff --git a/app/Actions/CreateUserGitHubFollows.php b/app/Actions/CreateUserGitHubFollows.php index fc5473fec..6cb5399d5 100644 --- a/app/Actions/CreateUserGitHubFollows.php +++ b/app/Actions/CreateUserGitHubFollows.php @@ -69,7 +69,7 @@ public function handle(User $user, bool $notify = true): void $user->followedOrganizations()->syncWithoutDetaching($organizations->modelKeys()); - DB::table('pending_organization_follows')->insertOrIgnore($organizationIds + DB::table('pending_follows')->insertOrIgnore($organizationIds ->reject(fn (string $id): bool => $organizations->contains('github_id', $id)) ->map(fn (string $id): array => ['user_id' => $user->id, 'github_id' => $id, 'created_at' => now()]) ->all()); diff --git a/app/Actions/DeletePendingFollow.php b/app/Actions/DeletePendingFollow.php index 1940b42cc..95151f292 100644 --- a/app/Actions/DeletePendingFollow.php +++ b/app/Actions/DeletePendingFollow.php @@ -15,6 +15,6 @@ */ public function handle(User $user, Profile $profile): void { - DB::table('pending_follows')->where('user_id', $user->id)->where('profile_id', $profile->id)->delete(); + DB::table('pending_follows')->where('user_id', $user->id)->where('github_id', $profile->github_id)->delete(); } } diff --git a/app/Actions/DeleteUser.php b/app/Actions/DeleteUser.php index 17abc90e6..3a7a30750 100644 --- a/app/Actions/DeleteUser.php +++ b/app/Actions/DeleteUser.php @@ -80,7 +80,6 @@ public function handle(User $user): void $this->deleteOrgs->handle($this->emptyOrgs($user)); - DB::table('pending_follows')->where('user_id', $user->id)->orWhere('profile_id', $user->id)->delete(); $user->organizations()->detach(); $user->followedOrganizations()->detach(); DB::table('user_active_days')->where('user_id', $user->id)->delete(); diff --git a/app/Actions/UpdateOrCreateOrganization.php b/app/Actions/UpdateOrCreateOrganization.php index 4f247ca76..3d327a200 100644 --- a/app/Actions/UpdateOrCreateOrganization.php +++ b/app/Actions/UpdateOrCreateOrganization.php @@ -52,7 +52,7 @@ public function handle(array $account): Organization $organization = Organization::query()->firstOrCreate(['id' => $organizationProfile->id]); if ($organization->wasRecentlyCreated) { - $pending = DB::table('pending_organization_follows')->where('github_id', (string) $githubId); + $pending = DB::table('pending_follows')->where('github_id', (string) $githubId); DB::table('organization_follows')->insertUsing( ['user_id', 'organization_id', 'created_at'], diff --git a/app/Actions/UpdateStaleHandles.php b/app/Actions/UpdateStaleHandles.php index c36429c0e..01497cd13 100644 --- a/app/Actions/UpdateStaleHandles.php +++ b/app/Actions/UpdateStaleHandles.php @@ -5,7 +5,6 @@ namespace App\Actions; use App\Models\Profile; -use Illuminate\Support\Facades\DB; final readonly class UpdateStaleHandles { @@ -14,7 +13,7 @@ * placeholder starts with an underscore, which no GitHub handle can, and their next sync restores a real one. * Bots keep their identity under reserved handles so they can continue replying. An unclaimed profile, a developer * looked up from GitHub who never signed in, has nothing of its own, so it goes: looking its new handle up brings it - * back. + * back, with the members waiting for it, kept by its GitHub ID. */ public function handle(string $handle, string $githubId): void { @@ -23,9 +22,6 @@ public function handle(string $handle, string $githubId): void ->where('github_id', '!=', $githubId) ->each(function (Profile $profile): void { if ($profile->isUnclaimed()) { - // Locked, so no one starts waiting for it in between (CreatePendingFollow)... - Profile::query()->lockForUpdate()->find($profile->id); - DB::table('pending_follows')->where('profile_id', $profile->id)->delete(); $profile->delete(); return; diff --git a/app/Http/Controllers/UnclaimedProfileController.php b/app/Http/Controllers/UnclaimedProfileController.php index da7d38ab6..7af73204b 100644 --- a/app/Http/Controllers/UnclaimedProfileController.php +++ b/app/Http/Controllers/UnclaimedProfileController.php @@ -33,7 +33,7 @@ public function show(ShowUnclaimedProfileRequest $request, #[CurrentUser] ?User return redirect(route('user.show', $profile->handle).($query === '' ? '' : "?{$query}"), 301, ['Cache-Control' => 'max-age=86400, private']); } - $waiting = DB::table('pending_follows')->where('profile_id', $profile->id); + $waiting = DB::table('pending_follows')->where('github_id', $profile->github_id); return Inertia::render('profile/unclaimed', [ 'profile' => $profile->only(['id', 'handle', 'name', 'avatar_url', 'bio']), diff --git a/database/migrations/2026_11_02_130000_create_pending_follows_table.php b/database/migrations/2026_11_02_130000_create_pending_follows_table.php deleted file mode 100644 index a7b81696e..000000000 --- a/database/migrations/2026_11_02_130000_create_pending_follows_table.php +++ /dev/null @@ -1,27 +0,0 @@ -foreignUuid('user_id')->constrained(); - $table->foreignUuid('profile_id')->constrained(); - - $table->primary(['user_id', 'profile_id']); - $table->index('profile_id'); - }); - } -}; diff --git a/database/migrations/2026_11_02_130000_rename_pending_organization_follows_to_pending_follows.php b/database/migrations/2026_11_02_130000_rename_pending_organization_follows_to_pending_follows.php new file mode 100644 index 000000000..00270351c --- /dev/null +++ b/database/migrations/2026_11_02_130000_rename_pending_organization_follows_to_pending_follows.php @@ -0,0 +1,27 @@ + [], 'organizations' => [], 'followed_organizations' => [], - 'pending_organization_follows' => [], - 'waiting_to_follow' => [], + 'pending_follows' => [], 'messages' => [], 'reports' => [], 'remote_mutes' => [], @@ -517,9 +515,9 @@ ]); }); -it('exports the github organizations the user waits to follow', function (): void { +it('exports the GitHub accounts the user waits to follow', function (): void { $user = User::factory()->create(); - DB::table('pending_organization_follows')->insert([ + DB::table('pending_follows')->insert([ ['user_id' => $user->id, 'github_id' => '958072', 'created_at' => now()], ['user_id' => $user->id, 'github_id' => '1', 'created_at' => now()], ['user_id' => User::factory()->create()->id, 'github_id' => '2', 'created_at' => now()], @@ -528,23 +526,7 @@ $response = $this->actingAs($user)->get(route('export')); $data = json_decode($response->streamedContent(), true, flags: JSON_THROW_ON_ERROR); - expect($data['pending_organization_follows'])->toBe(['1', '958072']); -}); - -it('exports the developers not on /dev/null yet the user waits to follow', function (): void { - $user = User::factory()->create(); - $octocat = Profile::factory()->create(['handle' => 'octocat']); - $hubot = Profile::factory()->create(['handle' => 'hubot']); - DB::table('pending_follows')->insert([ - ['user_id' => $user->id, 'profile_id' => $octocat->id], - ['user_id' => $user->id, 'profile_id' => $hubot->id], - ['user_id' => User::factory()->create()->id, 'profile_id' => Profile::factory()->create()->id], - ]); - - $response = $this->actingAs($user)->get(route('export')); - $data = json_decode($response->streamedContent(), true, flags: JSON_THROW_ON_ERROR); - - expect($data['waiting_to_follow'])->toBe(['hubot', 'octocat']); + expect($data['pending_follows'])->toBe(['1', '958072']); }); it('exports the devices approved for the app, oldest first', function (): void { diff --git a/tests/Feature/Controllers/PendingFollowControllerTest.php b/tests/Feature/Controllers/PendingFollowControllerTest.php index 30630e3f2..27889bde8 100644 --- a/tests/Feature/Controllers/PendingFollowControllerTest.php +++ b/tests/Feature/Controllers/PendingFollowControllerTest.php @@ -14,7 +14,7 @@ $this->actingAs($member)->from('/@'.$profile->handle)->post(route('pending-follow.store', $profile))->assertRedirect('/@'.$profile->handle); $this->actingAs($member)->post(route('pending-follow.store', $profile))->assertRedirect(); - expect(DB::table('pending_follows')->where('profile_id', $profile->id)->pluck('user_id')->all())->toBe([$member->id]); + expect(DB::table('pending_follows')->where('github_id', $profile->github_id)->pluck('user_id')->all())->toBe([$member->id]); $this->actingAs($member)->delete(route('pending-follow.destroy', $profile))->assertRedirect(); diff --git a/tests/Feature/Controllers/UnclaimedProfileControllerTest.php b/tests/Feature/Controllers/UnclaimedProfileControllerTest.php index bda0dac8f..be3efd30e 100644 --- a/tests/Feature/Controllers/UnclaimedProfileControllerTest.php +++ b/tests/Feature/Controllers/UnclaimedProfileControllerTest.php @@ -65,7 +65,7 @@ [$followed, $other] = User::factory()->count(2)->create(); $stranger = User::factory()->create(); $member->following()->attach([$followed->id, $other->id]); - DB::table('pending_follows')->insert(collect([$followed, $stranger, $member])->map(fn (User $user): array => ['user_id' => $user->id, 'profile_id' => $profile->id])->all()); + DB::table('pending_follows')->insert(collect([$followed, $stranger, $member])->map(fn (User $user): array => ['user_id' => $user->id, 'github_id' => $profile->github_id, 'created_at' => now()])->all()); $this->get('/@octocat') ->assertInertia(fn ($page) => $page diff --git a/tests/Unit/Actions/CreateUserGitHubFollowsTest.php b/tests/Unit/Actions/CreateUserGitHubFollowsTest.php index e39ad7835..91da8f3c4 100644 --- a/tests/Unit/Actions/CreateUserGitHubFollowsTest.php +++ b/tests/Unit/Actions/CreateUserGitHubFollowsTest.php @@ -160,7 +160,7 @@ resolve(CreateUserGitHubFollows::class)->handle($user); expect($user->followedOrganizations()->pluck('organizations.id')->all())->toBe([$organization->id]) - ->and(DB::table('pending_organization_follows')->get(['user_id', 'github_id'])->map(fn (object $row): array => (array) $row)->all())->toBe([['user_id' => $user->id, 'github_id' => '958072']]) + ->and(DB::table('pending_follows')->get(['user_id', 'github_id'])->map(fn (object $row): array => (array) $row)->all())->toBe([['user_id' => $user->id, 'github_id' => '958072']]) ->and($user->refresh()->github_organization_follows_imported_at)->not->toBeNull() ->and(Notification::query()->count())->toBe(0); }); diff --git a/tests/Unit/Actions/DeleteUserTest.php b/tests/Unit/Actions/DeleteUserTest.php index 9cf1f04d8..cc7cf39fb 100644 --- a/tests/Unit/Actions/DeleteUserTest.php +++ b/tests/Unit/Actions/DeleteUserTest.php @@ -24,7 +24,6 @@ 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\Queries\HeadlineSummaryQuery; @@ -263,43 +262,19 @@ expect($organization->followers()->pluck('users.id')->all())->toBe([$other->id]); }); -it('erases the github organizations a deleted user waited to follow', function (): void { +it('erases the accounts a deleted user waited to follow', function (): void { $user = User::factory()->create(); $other = User::factory()->create(); - DB::table('pending_organization_follows')->insert([ + DB::table('pending_follows')->insert([ ['user_id' => $user->id, 'github_id' => '958072', 'created_at' => now()], ['user_id' => $other->id, 'github_id' => '958072', 'created_at' => now()], ]); resolve(DeleteUser::class)->handle($user); - expect(DB::table('pending_organization_follows')->pluck('user_id')->all())->toBe([$other->id]); -}); - -it('erases the developers a deleted user waited to follow', function (): void { - $user = User::factory()->create(); - $other = User::factory()->create(); - $profile = Profile::factory()->create(); - DB::table('pending_follows')->insert([ - ['user_id' => $user->id, 'profile_id' => $profile->id], - ['user_id' => $other->id, 'profile_id' => $profile->id], - ]); - - resolve(DeleteUser::class)->handle($user); - expect(DB::table('pending_follows')->pluck('user_id')->all())->toBe([$other->id]); }); -it('erases the waits left on the profile of a deleted user, so nothing keeps it', function (): void { - $user = User::factory()->create(); - DB::table('pending_follows')->insert(['user_id' => User::factory()->create()->id, 'profile_id' => $user->id]); - - resolve(DeleteUser::class)->handle($user); - - $this->assertDatabaseEmpty('pending_follows'); - $this->assertModelMissing($user->profile); -}); - it('erases the devices of a deleted user', function (): void { $user = User::factory()->create(); Device::factory()->for($user)->create(); diff --git a/tests/Unit/Actions/UpdateOrCreateOrganizationTest.php b/tests/Unit/Actions/UpdateOrCreateOrganizationTest.php index eab36df7c..242825fbe 100644 --- a/tests/Unit/Actions/UpdateOrCreateOrganizationTest.php +++ b/tests/Unit/Actions/UpdateOrCreateOrganizationTest.php @@ -65,7 +65,7 @@ it('is followed, once created, by the users following it on GitHub whose follows were imported before', function (): void { [$follower, $other] = User::factory()->count(2)->create(); - DB::table('pending_organization_follows')->insert([ + DB::table('pending_follows')->insert([ ['user_id' => $follower->id, 'github_id' => '958072', 'created_at' => now()], ['user_id' => $other->id, 'github_id' => '1', 'created_at' => now()], ]); @@ -73,5 +73,5 @@ $organization = resolve(UpdateOrCreateOrganization::class)->handle(['login' => 'laravel', 'id' => 958072]); expect($organization->followers()->pluck('users.id')->all())->toBe([$follower->id]) - ->and(DB::table('pending_organization_follows')->pluck('user_id')->all())->toBe([$other->id]); + ->and(DB::table('pending_follows')->pluck('user_id')->all())->toBe([$other->id]); }); diff --git a/tests/Unit/Actions/UpdateOrCreateUserTest.php b/tests/Unit/Actions/UpdateOrCreateUserTest.php index f561b4b95..80e306d30 100644 --- a/tests/Unit/Actions/UpdateOrCreateUserTest.php +++ b/tests/Unit/Actions/UpdateOrCreateUserTest.php @@ -295,18 +295,17 @@ $this->mock(GitHubProvider::class)->shouldReceive('hasSponsorsListing')->andReturnFalse(); $unclaimed = Profile::factory()->create(['github_id' => '583231', 'handle' => 'octocat']); [$first, $second] = User::factory()->count(2)->create(); - $other = Profile::factory()->create(); DB::table('pending_follows')->insert([ - ['user_id' => $first->id, 'profile_id' => $unclaimed->id], - ['user_id' => $second->id, 'profile_id' => $unclaimed->id], - ['user_id' => $first->id, 'profile_id' => $other->id], + ['user_id' => $first->id, 'github_id' => '583231', 'created_at' => now()], + ['user_id' => $second->id, 'github_id' => '583231', 'created_at' => now()], + ['user_id' => $first->id, 'github_id' => '958072', 'created_at' => now()], ]); $user = resolve(UpdateOrCreateUser::class)->handle(GitHubUser::fake(['id' => 583231, 'nickname' => 'octocat'])); expect($user->followers()->pluck('users.id')->sort()->values()->all())->toBe(collect([$first->id, $second->id])->sort()->values()->all()) ->and(Notification::query()->where('recipient_id', $user->id)->where('type', NotificationType::Followed)->count())->toBe(2) - ->and(DB::table('pending_follows')->pluck('profile_id')->all())->toBe([$other->id]); + ->and(DB::table('pending_follows')->pluck('github_id')->all())->toBe(['958072']); resolve(UpdateOrCreateUser::class)->handle(GitHubUser::fake(['id' => 583231, 'nickname' => 'octocat'])); diff --git a/tests/Unit/Jobs/LookUpGitHubProfileTest.php b/tests/Unit/Jobs/LookUpGitHubProfileTest.php index 45f58f496..ea39cc29c 100644 --- a/tests/Unit/Jobs/LookUpGitHubProfileTest.php +++ b/tests/Unit/Jobs/LookUpGitHubProfileTest.php @@ -65,8 +65,8 @@ function octocatAccount(array $attributes = []): array dispatch_sync(new LookUpGitHubProfile('octocat')); - expect(DB::table('pending_follows')->get(['user_id', 'profile_id'])->map(fn (object $row): array => (array) $row)->all()) - ->toBe([['user_id' => $follower->id, 'profile_id' => Profile::query()->where('handle', 'octocat')->sole()->id]]); + expect(DB::table('pending_follows')->get(['user_id', 'github_id'])->map(fn (object $row): array => (array) $row)->all()) + ->toBe([['user_id' => $follower->id, 'github_id' => '583231']]); }); it('gives a GitHub organization its page, followed by those waiting for it', function (): void { @@ -74,7 +74,7 @@ function octocatAccount(array $attributes = []): array $github->shouldReceive('rateLimit')->andReturnNull(); $github->shouldReceive('accountByLogin')->once()->with('laravel')->andReturn(['login' => 'laravel', 'id' => 958072, 'type' => 'Organization', 'name' => 'Laravel', 'avatar_url' => null, 'bio' => null, 'location' => null, 'blog' => '', 'company' => null]); $follower = User::factory()->create(); - DB::table('pending_organization_follows')->insert(['user_id' => $follower->id, 'github_id' => '958072', 'created_at' => now()]); + DB::table('pending_follows')->insert(['user_id' => $follower->id, 'github_id' => '958072', 'created_at' => now()]); dispatch_sync(new LookUpGitHubProfile('laravel')); @@ -122,14 +122,15 @@ function octocatAccount(array $attributes = []): array $renamed = Profile::factory()->create(['github_id' => '583231', 'handle' => 'octo-old']); $previous = Profile::factory()->create(['github_id' => '1', 'handle' => 'octocat']); $member = User::factory()->create(['handle' => 'octocat-member']); - DB::table('pending_follows')->insert(['user_id' => $member->id, 'profile_id' => $previous->id]); + DB::table('pending_follows')->insert(['user_id' => $member->id, 'github_id' => '1', 'created_at' => now()]); dispatch_sync(new LookUpGitHubProfile('octocat')); expect($renamed->refresh()->handle)->toBe('octocat') ->and($member->profile->refresh()->handle)->toBe('octocat-member'); $this->assertModelMissing($previous); - $this->assertDatabaseEmpty('pending_follows'); + + expect(DB::table('pending_follows')->pluck('github_id')->all())->toBe(['1']); }); it('only moves a user who renamed on GitHub to their new handle, leaving the rest to their next sign-in', function (): void { From ae3f6cd8b8db4b276fec0359b2f82e1153599204 Mon Sep 17 00:00:00 2001 From: Matthieu Napoli Date: Tue, 3 Nov 2026 01:12:04 +0100 Subject: [PATCH 3/4] fix: version the export whose pending follows changed shape --- app/Actions/CreateExport.php | 2 +- tests/Feature/Controllers/ExportControllerTest.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/Actions/CreateExport.php b/app/Actions/CreateExport.php index 4a6a0a579..acab06411 100644 --- a/app/Actions/CreateExport.php +++ b/app/Actions/CreateExport.php @@ -74,7 +74,7 @@ public function handle(User $user): array { return [ - 'schema_version' => 5, + 'schema_version' => 6, 'exported_at' => now()->toIso8601String(), 'profile' => [ 'github_id' => $user->github_id, diff --git a/tests/Feature/Controllers/ExportControllerTest.php b/tests/Feature/Controllers/ExportControllerTest.php index 6aed81546..b3762ab68 100644 --- a/tests/Feature/Controllers/ExportControllerTest.php +++ b/tests/Feature/Controllers/ExportControllerTest.php @@ -90,7 +90,7 @@ $data = json_decode($response->streamedContent(), true, flags: JSON_THROW_ON_ERROR); expect($data)->toBe([ - 'schema_version' => 5, + 'schema_version' => 6, 'exported_at' => now()->toIso8601String(), 'profile' => [ 'github_id' => $user->github_id, From f00b75452330cf16c2a0edcf2fdc6be638633796 Mon Sep 17 00:00:00 2001 From: Matthieu Napoli Date: Tue, 3 Nov 2026 14:28:07 +0100 Subject: [PATCH 4/4] fix: roll the wait back when its visit is cancelled, like follows --- resources/js/pages/profile/unclaimed.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/resources/js/pages/profile/unclaimed.tsx b/resources/js/pages/profile/unclaimed.tsx index 771803bc7..fc8628474 100644 --- a/resources/js/pages/profile/unclaimed.tsx +++ b/resources/js/pages/profile/unclaimed.tsx @@ -172,7 +172,7 @@ UnclaimedProfile.layout = AppLayout; /** * Following a developer who isn't here yet waits for them to sign up. It shows at once, and comes back if the server - * refuses it. Right after a click, it never turns into cancelling under the pointer. + * refuses it or the visit is cancelled. Right after a click, it never turns into cancelling under the pointer. */ function WaitButton({ profile, @@ -206,6 +206,7 @@ function WaitButton({ preserveScroll: true, async: true, onStart: () => setIsPending(true), + onCancel: () => onToggle(!waits), onFinish: () => setIsPending(false), onHttpException: (response) => { onToggle(!waits);