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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions app/Actions/UpdateMentionedOrganizations.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
use Closure;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;

final readonly class UpdateMentionedOrganizations
Expand Down Expand Up @@ -49,7 +48,7 @@ public function __construct(
*/
public function handle(array $handles): ?CarbonImmutable
{
$profiles = Profile::query()->whereIn(DB::raw('LOWER(handle)'), $handles)->get();
$profiles = Profile::query()->whereIn('handle', $handles)->get();
$known = Organization::query()->whereKey($profiles->where('type', ProfileType::Organization)->modelKeys())->get();

/** @var list<array{Closure(): (array<array-key, mixed>|null), Closure(): mixed}> $lookups */
Expand Down
2 changes: 1 addition & 1 deletion app/Actions/UpdateStaleHandles.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
public function handle(string $handle, string $githubId): void
{
Profile::query()
->whereRaw('LOWER(handle) = ?', [mb_strtolower($handle)])
->where('handle', $handle)
->where('github_id', '!=', $githubId)
->each(function (Profile $profile): void {
$profile->update(['handle' => match ($profile->github_id) {
Expand Down
4 changes: 2 additions & 2 deletions app/Http/Requests/CreateApiOrgMemberRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ public function rules(): array
{
return [
'members' => ['required', 'array', 'min:1', 'max:'.(Conversation::MAX_MEMBERS - 1)],
'members.*' => ['required', 'string', 'distinct', Rule::exists('profiles', 'handle')->where('type', ProfileType::User->value)],
'members.*' => ['required', 'string', 'distinct:ignore_case', Rule::exists('profiles', 'handle')->where('type', ProfileType::User->value)],
'share_history' => ['required', 'boolean'],
];
}
Expand Down Expand Up @@ -86,6 +86,6 @@ public function members(): Collection
/** @var list<string> $handles */
$handles = $this->array('members');

return User::query()->whereHas('profile', fn (Builder $query): Builder => $query->whereIn('handle', $handles))->get()->sortBy(fn (User $user): int|false => array_search($user->handle, $handles, true))->values()->toBase();
return User::query()->whereHas('profile', fn (Builder $query): Builder => $query->whereIn('handle', $handles))->get()->sortBy(fn (User $user): int|false => array_search(mb_strtolower($user->handle), array_map(mb_strtolower(...), $handles), true))->values()->toBase();
}
}
4 changes: 2 additions & 2 deletions app/Http/Requests/CreateApiOrgRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ public function rules(): array
return [
'name' => ['nullable', 'string', 'max:60'],
'members' => ['required', 'array', 'min:1', 'max:'.(Conversation::MAX_MEMBERS - 1)],
'members.*' => ['required', 'string', 'distinct', Rule::exists('profiles', 'handle')->where('type', ProfileType::User->value)],
'members.*' => ['required', 'string', 'distinct:ignore_case', Rule::exists('profiles', 'handle')->where('type', ProfileType::User->value)],
];
}

Expand Down Expand Up @@ -72,6 +72,6 @@ public function members(): Collection
/** @var list<string> $handles */
$handles = $this->array('members');

return User::query()->whereHas('profile', fn (Builder $query): Builder => $query->whereIn('handle', $handles))->get()->sortBy(fn (User $user): int|false => array_search($user->handle, $handles, true))->values()->toBase();
return User::query()->whereHas('profile', fn (Builder $query): Builder => $query->whereIn('handle', $handles))->get()->sortBy(fn (User $user): int|false => array_search(mb_strtolower($user->handle), array_map(mb_strtolower(...), $handles), true))->values()->toBase();
}
}
4 changes: 2 additions & 2 deletions app/Http/Requests/CreateOrgMemberRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ public function rules(): array
{
return [
'members' => ['required', 'array', 'min:1', 'max:'.(Conversation::MAX_MEMBERS - 1)],
'members.*' => ['required', 'string', 'distinct', Rule::exists('profiles', 'handle')->where('type', ProfileType::User->value)],
'members.*' => ['required', 'string', 'distinct:ignore_case', Rule::exists('profiles', 'handle')->where('type', ProfileType::User->value)],
'share_history' => ['required', 'boolean'],
];
}
Expand Down Expand Up @@ -86,6 +86,6 @@ public function members(): Collection
/** @var list<string> $handles */
$handles = $this->array('members');

return User::query()->whereHas('profile', fn (Builder $query): Builder => $query->whereIn('handle', $handles))->get()->sortBy(fn (User $user): int|false => array_search($user->handle, $handles, true))->values()->toBase();
return User::query()->whereHas('profile', fn (Builder $query): Builder => $query->whereIn('handle', $handles))->get()->sortBy(fn (User $user): int|false => array_search(mb_strtolower($user->handle), array_map(mb_strtolower(...), $handles), true))->values()->toBase();
}
}
4 changes: 2 additions & 2 deletions app/Http/Requests/CreateOrgRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ public function rules(): array
return [
'name' => ['nullable', 'string', 'max:60'],
'members' => ['required', 'array', 'min:1', 'max:'.(Conversation::MAX_MEMBERS - 1)],
'members.*' => ['required', 'string', 'distinct', Rule::exists('profiles', 'handle')->where('type', ProfileType::User->value)],
'members.*' => ['required', 'string', 'distinct:ignore_case', Rule::exists('profiles', 'handle')->where('type', ProfileType::User->value)],
];
}

Expand Down Expand Up @@ -70,6 +70,6 @@ public function members(): Collection
/** @var list<string> $handles */
$handles = $this->array('members');

return User::query()->whereHas('profile', fn (Builder $query): Builder => $query->whereIn('handle', $handles))->get()->sortBy(fn (User $user): int|false => array_search($user->handle, $handles, true))->values()->toBase();
return User::query()->whereHas('profile', fn (Builder $query): Builder => $query->whereIn('handle', $handles))->get()->sortBy(fn (User $user): int|false => array_search(mb_strtolower($user->handle), array_map(mb_strtolower(...), $handles), true))->values()->toBase();
}
}
11 changes: 2 additions & 9 deletions app/Http/Requests/ShowOrganizationRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,20 +36,13 @@ 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.
* Organizations share the "@handle" pages with users, so this is the handle no user has, whatever its case.
*/
public function organization(): ?Organization
{
$handle = $this->route('user');
assert(is_string($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();
return $this->organization ??= Organization::query()->whereRelation('profile', 'handle', $handle)->first();
}
}
21 changes: 4 additions & 17 deletions app/Models/Profile.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,22 @@
use App\Enums\ProfileType;
use Carbon\CarbonInterface;
use Database\Factories\ProfileFactory;
use Illuminate\Contracts\Database\Eloquent\Builder as BuilderContract;
use Illuminate\Database\Eloquent\Attributes\Touches;
use Illuminate\Database\Eloquent\Attributes\Visible;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
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. Organizations are profiles too, and what
* only they have lives on `Organization`.
*
* Like on GitHub, a handle is one whatever its case: the column is `citext`, so a plain `where('handle', ...)` matches
* any case and its unique index refuses the same handle in another case. Never lowercase it in a query to compare
* it. Only search filters lowercase it, as `LOWER(handle) LIKE ...`, which the trigram index serves.
*
* @property-read string $id
* @property-read ProfileType $type
* @property-read string $github_id
Expand Down Expand Up @@ -78,21 +80,6 @@ public function casts(): array
];
}

/**
* 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.
*
* @param Model|BuilderContract|Relation<Model, Model, mixed> $query
* @param mixed $value
* @param string|null $field
*/
public function resolveRouteBindingQuery($query, $value, $field = null): BuilderContract
{
return $field === 'handle'
? $query->whereRaw('LOWER(handle) = LOWER(?)', [$value])->orderByRaw('handle = ? desc', [$value])->orderByRaw('type = ? desc', [ProfileType::User])->orderBy('handle')
: parent::resolveRouteBindingQuery($query, $value, $field);
}

/**
* @return HasOne<User, $this>
*/
Expand Down
7 changes: 2 additions & 5 deletions app/Models/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
use App\Dto\LiveStream;
use App\Enums\LivePlatform;
use App\Enums\Log;
use App\Enums\ProfileType;
use App\Enums\RemotesFrom;
use Carbon\CarbonInterface;
use Database\Factories\UserFactory;
Expand Down Expand Up @@ -231,9 +230,7 @@ public function profile(): BelongsTo
}

/**
* Resolves "@handle" URLs through the profile, which holds the handle, whatever its 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 Profile::resolveRouteBindingQuery() for the link preview images.
* Resolves "@handle" URLs through the profile, which holds the handle, whatever its case, like GitHub.
*
* @param Model|BuilderContract|Relation<Model, Model, mixed> $query
* @param mixed $value
Expand All @@ -242,7 +239,7 @@ public function profile(): BelongsTo
public function resolveRouteBindingQuery($query, $value, $field = null): BuilderContract
{
return $field === 'handle'
? parent::resolveRouteBindingQuery($query, Profile::query()->select('github_id')->whereRaw('LOWER(handle) = LOWER(?)', [$value])->orderByRaw('handle = ? desc', [$value])->orderByRaw('type = ? desc', [ProfileType::User])->orderBy('handle')->limit(1), 'github_id')
? parent::resolveRouteBindingQuery($query, Profile::query()->select('github_id')->where('handle', $value), 'github_id')
: parent::resolveRouteBindingQuery($query, $value, $field);
}

Expand Down
3 changes: 1 addition & 2 deletions app/Queries/BioProfilesQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
use App\Models\Profile;
use App\Models\User;
use App\Services\Mentions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;

final readonly class BioProfilesQuery
Expand All @@ -32,7 +31,7 @@ public function get(User $user): array
}

return Profile::query()
->whereIn(DB::raw('LOWER(handle)'), $handles)
->whereIn('handle', $handles)
->get(['handle'])
->mapWithKeys(fn (Profile $profile): array => [Str::lower($profile->handle) => $profile->handle])
->all();
Expand Down
2 changes: 1 addition & 1 deletion app/Queries/MentionSuggestionsQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ public function get(User $user, string $term, ?Conversation $org = null, int $li
->when($contributors, fn (Builder $query, QueryBuilder $contributors): Builder => $query
->orderByDesc((clone $contributors)->selectRaw('COUNT(*) > 0')->whereColumn('commits.user_id', 'users.id')))
->orderByRaw(
"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",
"CASE WHEN profiles.handle = ? THEN 0 WHEN profiles.handle LIKE ? ESCAPE '!' THEN 1 WHEN LOWER(profiles.name) LIKE ? ESCAPE '!' THEN 2 ELSE 3 END",
[$term, "{$escaped}%", "{$escaped}%"],
)
->orderByDesc(fn (QueryBuilder $query): QueryBuilder => $query
Expand Down
3 changes: 1 addition & 2 deletions app/Queries/MentionedOrganizationsQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
use App\Models\Organization;
use App\Services\Mentions;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;

final readonly class MentionedOrganizationsQuery
Expand All @@ -33,7 +32,7 @@ public function get(string $body, array $mentionedUsers): array
}

return Organization::query()
->whereHas('profile', fn (Builder $query): Builder => $query->whereIn(DB::raw('LOWER(handle)'), $handles))
->whereHas('profile', fn (Builder $query): Builder => $query->whereIn('handle', $handles))
->get()
->mapWithKeys(fn (Organization $organization): array => [Str::lower($organization->handle) => $organization->id])
->all();
Expand Down
3 changes: 1 addition & 2 deletions app/Queries/MentionedUsersQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
use App\Models\User;
use App\Services\Mentions;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;

final readonly class MentionedUsersQuery
Expand All @@ -29,7 +28,7 @@ public function get(string $body): array
}

return User::query()
->whereHas('profile', fn (Builder $query): Builder => $query->whereIn(DB::raw('LOWER(handle)'), $handles))
->whereHas('profile', fn (Builder $query): Builder => $query->whereIn('handle', $handles))
->get()
->mapWithKeys(fn (User $user): array => [Str::lower($user->handle) => $user->id])
->all();
Expand Down
2 changes: 1 addition & 1 deletion app/Queries/OrganizationSuggestionsQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ public function get(string $term, int $limit = 3): Collection
->whereRaw("LOWER(profiles.handle) LIKE ? ESCAPE '!'", ["%{$escaped}%"])
->orWhereRaw("LOWER(profiles.name) LIKE ? ESCAPE '!'", ["%{$escaped}%"]))
->orderByRaw(
"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",
"CASE WHEN profiles.handle = ? THEN 0 WHEN profiles.handle LIKE ? ESCAPE '!' THEN 1 WHEN LOWER(profiles.name) LIKE ? ESCAPE '!' THEN 2 ELSE 3 END",
[$term, "{$escaped}%", "{$escaped}%"],
)
->orderBy('profiles.handle')
Expand Down
2 changes: 1 addition & 1 deletion app/Queries/UserSearchQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ public function get(string $term, ?User $viewer, int $limit = 8): Collection
->whereNotIn('users.id', $viewer->mutedIds())
->whereNotIn('users.id', $viewer->idsAcrossBlocks()))
->orderByRaw(
"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",
"CASE WHEN profiles.handle = ? THEN 0 WHEN profiles.handle LIKE ? ESCAPE '!' THEN 1 WHEN LOWER(profiles.name) LIKE ? ESCAPE '!' THEN 2 ELSE 3 END",
[$term, "{$escaped}%", "{$escaped}%"],
)
->orderBy('profiles.handle')
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
/**
* GitHub handles are unique whatever their case, so profiles' are too: comparing them ignores case, so looking one
* up needs no lowercasing, and the unique index, rebuilt on the new type, keeps a stale profile from holding a
* handle in another case.
*/
public function up(): void
{
Schema::ensureExtensionExists('citext');

Schema::table('profiles', function (Blueprint $table): void {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
$table->dropIndex('profiles_lower_handle_index');
});

DB::statement('ALTER TABLE profiles ALTER COLUMN handle TYPE citext');
}
};
7 changes: 7 additions & 0 deletions tests/Feature/Controllers/Api/OrganizationControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,10 @@
$this->getJson('/api/v1/organizations/octocat')->assertNotFound();
$this->getJson('/api/v1/organizations/missing')->assertNotFound();
});

it('shows an organization whatever the case of its handle', function (): void {
Organization::factory()->create(['handle' => 'Laravel']);

$this->getJson('/api/v1/organizations/LARAVEL')->assertOk()->assertJsonPath('data.handle', 'Laravel');
$this->getJson('/api/v1/organizations/laravel/mentions')->assertOk();
});
12 changes: 12 additions & 0 deletions tests/Feature/Controllers/Api/OrganizationFollowControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,18 @@
expect($this->user->followedOrganizations()->count())->toBe(0);
});

it('may follow and unfollow an organization whatever the case of its handle', function (): void {
$organization = Organization::factory()->create(['handle' => 'Laravel']);

signedApiRequest($this->keyPair, 'POST', route('api-organization-follow.store', ['handle' => 'laravel']))->assertNoContent();

expect($this->user->followedOrganizations()->pluck('organizations.id')->all())->toBe([$organization->id]);

signedApiRequest($this->keyPair, 'DELETE', route('api-organization-follow.destroy', ['handle' => 'LARAVEL']))->assertNoContent();

expect($this->user->followedOrganizations()->count())->toBe(0);
});

it('returns not found for handles without an organization page', function (string $method, string $route): void {
User::factory()->create(['handle' => 'octocat']);

Expand Down
16 changes: 16 additions & 0 deletions tests/Feature/Controllers/OrgControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,22 @@
->assertJsonPath('conversations.0.members.0.role', 'maintainer');
});

it('adds people whatever the case of their handles, but each only once', function (): void {
$mark = User::factory()->create();
[$nuno] = followersOf($mark, 1);

$this->actingAs($mark)
->postJson(route('org.store'), ['members' => [mb_strtoupper($nuno->handle), $nuno->handle]])
->assertJsonValidationErrors(['members.0', 'members.1']);

$org = $this->actingAs($mark)
->postJson(route('org.store'), ['members' => [mb_strtoupper($nuno->handle)]])
->assertCreated()
->json('org.id');

expect(Conversation::query()->findOrFail($org)->roleOf($nuno)?->value)->toBe('contributor');
});

it('identifies orgs by uuids', function (): void {
$mark = User::factory()->create();
[$nuno] = followersOf($mark, 1);
Expand Down
14 changes: 0 additions & 14 deletions tests/Feature/Controllers/OrganizationControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -244,17 +244,3 @@
$this->get('/@laravel?user[]=a&user[]=b')->assertRedirect('/@Laravel?user[]=a&user[]=b');
$this->get('/@laravel?tag=one&tag=two')->assertRedirect('/@Laravel?tag=one&tag=two');
});

it('opens each organization at its own case when a stale one holds the handle in another case', function (): void {
$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));

$location = (string) $this->get('/@LARAVEL')->assertMovedPermanently()->headers->get('Location');

expect($location)->toBeIn([url('/@laravel'), url('/@Laravel')]);

$this->get($location)->assertOk();
});
Loading
Loading