From ebacf426a31cacee4ff2718ee621f0d6324e2a9c Mon Sep 17 00:00:00 2001 From: nanaya Date: Mon, 16 Dec 2024 22:52:48 +0900 Subject: [PATCH] Join? --- .../Teams/ApplicationsController.php | 68 ++++++++++++ .../Notifications/TeamApplicationAccept.php | 66 +++++++++++ app/Models/Team.php | 15 +++ app/Models/TeamApplication.php | 27 +++++ app/Models/User.php | 6 + app/Singletons/OsuAuthorize.php | 41 +++++++ database/factories/TeamMemberFactory.php | 25 +++++ ..._01_15_000001_create_team_applications.php | 30 +++++ resources/lang/en/authorization.php | 9 ++ resources/lang/en/teams.php | 13 +++ resources/views/teams/show.blade.php | 46 ++++++++ routes/web.php | 2 + .../Teams/ApplicationsControllerTest.php | 105 ++++++++++++++++++ 13 files changed, 453 insertions(+) create mode 100644 app/Http/Controllers/Teams/ApplicationsController.php create mode 100644 app/Jobs/Notifications/TeamApplicationAccept.php create mode 100644 app/Models/TeamApplication.php create mode 100644 database/factories/TeamMemberFactory.php create mode 100644 database/migrations/2025_01_15_000001_create_team_applications.php create mode 100644 tests/Controllers/Teams/ApplicationsControllerTest.php diff --git a/app/Http/Controllers/Teams/ApplicationsController.php b/app/Http/Controllers/Teams/ApplicationsController.php new file mode 100644 index 00000000000..5c754ac3762 --- /dev/null +++ b/app/Http/Controllers/Teams/ApplicationsController.php @@ -0,0 +1,68 @@ +. Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +declare(strict_types=1); + +namespace App\Http\Controllers\Teams; + +use App\Http\Controllers\Controller; +use App\Models\Team; +use App\Models\TeamApplication; +use Symfony\Component\HttpFoundation\Response; + +class ApplicationsController extends Controller +{ + public function __construct() + { + parent::__construct(); + + $this->middleware('auth'); + } + + public function accept(string $teamId, string $id): Response + { + \DB::transaction(function () use ($id, $teamId) { + $team = Team::findOrFail($teamId); + $application = $team->applications()->findOrFail($id); + + priv_check('TeamApplicationAccept', $application)->ensureCan(); + + $application->delete(); + $team->members()->create(['user_id' => $application->user_id]); + }); + + \Session::flash('popup', osu_trans('teams.applications.accept.ok')); + + return response(null, 204); + } + + public function destroy(string $teamId, string $id): Response + { + $currentUser = \Auth::user(); + TeamApplication::where('team_id', $teamId)->findOrFail($currentUser->getKey())->delete(); + \Session::flash('popup', osu_trans('teams.applications.destroy.ok')); + + return response(null, 204); + } + + public function reject(string $teamId, string $id): Response + { + TeamApplication::where('team_id', $teamId)->findOrFail($id)->delete(); + \Session::flash('popup', osu_trans('teams.applications.reject.ok')); + + return response(null, 204); + } + + public function store(string $teamId): Response + { + $team = Team::findOrFail($teamId); + priv_check('TeamApplicationStore', $team)->ensureCan(); + + $team->applications()->createOrFirst(['user_id' => \Auth::id()]); + \Session::flash('popup', osu_trans('teams.applications.store.ok')); + + return response(null, 204); + } +} diff --git a/app/Jobs/Notifications/TeamApplicationAccept.php b/app/Jobs/Notifications/TeamApplicationAccept.php new file mode 100644 index 00000000000..2920e1d8b7e --- /dev/null +++ b/app/Jobs/Notifications/TeamApplicationAccept.php @@ -0,0 +1,66 @@ +. Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +namespace App\Jobs\Notifications; + +use App\Models\Notification; +use App\Models\TeamMember; +use App\Models\User; + +class TeamApplicationAccept extends BroadcastNotificationBase +{ + const DELIVERY_MODE_DEFAULTS = ['mail' => true, 'push' => true]; + + protected $achievement; + + public static function getMailGroupingKey(Notification $notification): string + { + $base = parent::getMailGroupingKey($notification); + + return "{$base}-{$notification->details['achievement_id']}-{$notification->source_user_id}"; + } + + public static function getMailLink(Notification $notification): string + { + return route('teams.show', [ + 'mode' => $notification->notifiable_id, + ]).'#medals'; + } + + public function __construct(private TeamMember $teamMember, User $source) + { + parent::__construct($source); + + $this->achievement = $achievement; + } + + public function getDetails(): array + { + return [ + 'achievement_id' => $this->achievement->getKey(), + 'achievement_mode' => $this->achievement->mode, + 'cover_url' => $this->achievement->iconUrl(), + 'slug' => $this->achievement->slug, + 'title' => $this->achievement->name, + 'description' => $this->achievement->description, + 'user_id' => $this->source->getKey(), + ]; + } + + public function getListeningUserIds(): array + { + return [$this->source->getKey()]; + } + + public function getNotifiable() + { + return $this->teamMember; + } + + public function getReceiverIds(): array + { + return [$this->teamMember->getKey()]; + } +} diff --git a/app/Models/Team.php b/app/Models/Team.php index 33437a2774f..41c07198ca1 100644 --- a/app/Models/Team.php +++ b/app/Models/Team.php @@ -92,6 +92,14 @@ public function delete() }); } + public function emptySlots(): int + { + $max = $this->maxMembers(); + $current = $this->members->count(); + + return max(0, $max - $current); + } + public function header(): Uploader { return $this->header ??= new Uploader( @@ -131,4 +139,11 @@ public function logo(): Uploader ['image' => ['maxDimensions' => [512, 256]]], ); } + + public function maxMembers(): int + { + $this->loadMissing('members.user'); + + return 8 + (4 * $this->members->filter(fn ($member) => $member->user?->osu_subscriber ?? false)->count()); + } } diff --git a/app/Models/TeamApplication.php b/app/Models/TeamApplication.php new file mode 100644 index 00000000000..a769a64343f --- /dev/null +++ b/app/Models/TeamApplication.php @@ -0,0 +1,27 @@ +. Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +declare(strict_types=1); + +namespace App\Models; + +use Illuminate\Database\Eloquent\Relations\BelongsTo; + +class TeamApplication extends Model +{ + public $incrementing = false; + + protected $primaryKey = 'user_id'; + + public function team(): BelongsTo + { + return $this->belongsTo(Team::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id'); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index dcee75bbedc..4eba828f391 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -310,6 +310,11 @@ public function team(): HasOneThrough ); } + public function teamApplication(): HasOne + { + return $this->hasOne(TeamApplication::class); + } + public function getAuthPassword() { return $this->user_password; @@ -967,6 +972,7 @@ public function getAttribute($key) 'supporterTagPurchases', 'supporterTags', 'team', + 'teamApplication', 'tokens', 'topicWatches', 'userAchievements', diff --git a/app/Singletons/OsuAuthorize.php b/app/Singletons/OsuAuthorize.php index de5e189746b..165d2877798 100644 --- a/app/Singletons/OsuAuthorize.php +++ b/app/Singletons/OsuAuthorize.php @@ -30,6 +30,7 @@ use App\Models\Score\Best\Model as ScoreBest; use App\Models\Solo; use App\Models\Team; +use App\Models\TeamApplication; use App\Models\Traits\ReportableInterface; use App\Models\User; use App\Models\UserContestEntry; @@ -1905,6 +1906,46 @@ public function checkScorePin(?User $user, ScoreBest|Solo\Score $score): string return 'ok'; } + public function checkTeamApplicationAccept(?User $user, TeamApplication $application): ?string + { + $this->ensureLoggedIn($user); + + $team = $application->team; + + if ($team->leader_id !== $user->getKey()) { + return null; + } + if ($team->emptySlots() < 1) { + return 'team.member.store.full'; + } + + return 'ok'; + } + + public function checkTeamApplicationStore(?User $user, Team $team): ?string + { + $prefix = 'team.application.store.'; + + $this->ensureLoggedIn($user); + + if ($user->team !== null) { + return $user->team->getKey() === $team->getKey() + ? $prefix.'already_member' + : $prefix.'already_other_member'; + } + if ($user->teamApplication()->exists()) { + return $prefix.'currently_applying'; + } + if (!$team->is_open) { + return $prefix.'team_closed'; + } + if ($team->emptySlots() < 1) { + return $prefix.'team_full'; + } + + return 'ok'; + } + public function checkTeamPart(?User $user, Team $team): ?string { $this->ensureLoggedIn($user); diff --git a/database/factories/TeamMemberFactory.php b/database/factories/TeamMemberFactory.php new file mode 100644 index 00000000000..79a84410423 --- /dev/null +++ b/database/factories/TeamMemberFactory.php @@ -0,0 +1,25 @@ +. Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +declare(strict_types=1); + +namespace Database\Factories; + +use App\Models\Team; +use App\Models\TeamMember; +use App\Models\User; + +class TeamMemberFactory extends Factory +{ + protected $model = TeamMember::class; + + public function definition(): array + { + return [ + 'team_id' => Team::factory(), + 'user_id' => User::factory(), + ]; + } +} diff --git a/database/migrations/2025_01_15_000001_create_team_applications.php b/database/migrations/2025_01_15_000001_create_team_applications.php new file mode 100644 index 00000000000..05229bc0b77 --- /dev/null +++ b/database/migrations/2025_01_15_000001_create_team_applications.php @@ -0,0 +1,30 @@ +. Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +declare(strict_types=1); + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration +{ + public function up(): void + { + Schema::create('team_applications', function (Blueprint $table) { + $table->unsignedBigInteger('user_id')->nullable(false); + $table->unsignedBigInteger('team_id')->nullable(false); + $table->timestampsTz(); + + $table->primary('user_id'); + $table->index('team_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('team_applications'); + } +}; diff --git a/resources/lang/en/authorization.php b/resources/lang/en/authorization.php index ba061ef6ccf..404508084fa 100644 --- a/resources/lang/en/authorization.php +++ b/resources/lang/en/authorization.php @@ -192,6 +192,15 @@ ], 'team' => [ + 'application' => [ + 'store' => [ + 'already_member' => "You're already part of the team.", + 'already_other_member' => "You're already part of a different team.", + 'currently_applying' => 'You have pending team join request.', + 'team_closed' => 'The team is currently not accepting any join requests.', + 'team_full' => "The team is full and can't accept any more members.", + ], + ], 'part' => [ 'is_leader' => "Team leader can't leave the team.", 'not_member' => 'Not a member of the team.', diff --git a/resources/lang/en/teams.php b/resources/lang/en/teams.php index a3984e14dff..47d2fbe46c7 100644 --- a/resources/lang/en/teams.php +++ b/resources/lang/en/teams.php @@ -4,6 +4,17 @@ // See the LICENCE file in the repository root for full licence text. return [ + 'applications' => [ + 'create' => [ + 'title' => 'Join Team', + + 'form' => [ + 'message' => 'Message (optional)', + 'message_help' => 'Write message for the team leader as part of your join request.', + ], + ], + ], + 'destroy' => [ 'ok' => 'Team removed', ], @@ -71,6 +82,8 @@ 'show' => [ 'bar' => [ 'destroy' => 'Disband Team', + 'join' => 'Request Join', + 'join_cancel' => 'Cancel Join', 'part' => 'Leave Team', ], diff --git a/resources/views/teams/show.blade.php b/resources/views/teams/show.blade.php index ae9e5927d8e..7630204e4c8 100644 --- a/resources/views/teams/show.blade.php +++ b/resources/views/teams/show.blade.php @@ -21,6 +21,15 @@ if (priv_check('TeamUpdate', $team)->can()) { $buttons->add('destroy'); } + + $currentUser = Auth::user(); + if ($currentUser === null || $currentUser->team === null) { + if ($currentUser !== null && $currentUser->teamApplication?->team_id === $team->getKey()) { + $buttons->add('join_cancel'); + } else { + $buttons->add('join'); + } + } @endphp @extends('master', [ @@ -102,6 +111,43 @@ class="btn-circle btn-circle--page-toggle" @endif + + @if ($buttons->contains('join_cancel')) +
+ + +
+ @endif + @if ($buttons->contains('join')) + @php + $joinPriv = priv_check('TeamApplicationStore', $team); + @endphp +
+ +
+ @endif @endif
diff --git a/routes/web.php b/routes/web.php index 17bf787073c..5d962f67ba6 100644 --- a/routes/web.php +++ b/routes/web.php @@ -297,6 +297,8 @@ Route::resource('user-cover-presets', 'UserCoverPresetsController', ['only' => ['index', 'store', 'update']]); Route::group(['as' => 'teams.', 'prefix' => 'teams/{team}'], function () { + Route::resource('applications', 'Teams\ApplicationsController', ['only' => ['destroy', 'store']]); + Route::post('applications/{application}/accept', 'Teams\ApplicationsController@accept')->name('applications.accept'); Route::post('part', 'TeamsController@part')->name('part'); Route::resource('members', 'Teams\MembersController', ['only' => ['destroy', 'index']]); }); diff --git a/tests/Controllers/Teams/ApplicationsControllerTest.php b/tests/Controllers/Teams/ApplicationsControllerTest.php new file mode 100644 index 00000000000..68edbfa84d4 --- /dev/null +++ b/tests/Controllers/Teams/ApplicationsControllerTest.php @@ -0,0 +1,105 @@ +. Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +namespace Tests\Controllers\Teams; + +use App\Models\Team; +use App\Models\TeamMember; +use App\Models\User; +use Tests\TestCase; + +class ApplicationsControllerTest extends TestCase +{ + public function testAccept() + { + $team = Team::factory()->create(); + $owner = $team->leader; + $application = $team->applications()->create(['user_id' => User::factory()->create()->getKey()]); + + $this->expectCountChange(fn () => $team->applications()->count(), -1); + $this->expectCountChange(fn () => $team->members()->count(), 1); + $this + ->actingAsVerified($owner) + ->post(route('teams.applications.accept', ['team' => $team->getKey(), 'application' => $application->getKey()])) + ->assertStatus(204); + } + + public function testAcceptFull() + { + $team = Team::factory()->create(); + $owner = $team->leader; + TeamMember::factory()->count($team->emptySlots())->create(['team_id' => $team]); + $application = $team->applications()->create(['user_id' => User::factory()->create()->getKey()]); + + $this->expectCountChange(fn () => $team->applications()->count(), 0); + $this->expectCountChange(fn () => $team->members()->count(), 0); + $this + ->actingAsVerified($owner) + ->post(route('teams.applications.accept', ['team' => $team->getKey(), 'application' => $application->getKey()])) + ->assertStatus(403); + } + + public function testStore() + { + $user = User::factory()->create(); + $team = Team::factory()->create(); + + $this->expectCountChange(fn () => $team->applications()->count(), 1); + + $this + ->actingAsVerified($user) + ->post(route('teams.applications.store', ['team' => $team->getKey()])) + ->assertStatus(204); + } + + public function testStoreAlreadyApplying() + { + $user = User::factory()->create(); + $team = Team::factory()->create(); + $team->applications()->create(['user_id' => $user->getKey()]); + $otherTeam = Team::factory()->create(); + + $this->expectCountChange(fn () => $otherTeam->applications()->count(), 0); + + $this + ->actingAsVerified($user) + ->post(route('teams.applications.store', ['team' => $otherTeam->getKey()])) + ->assertStatus(403); + } + + public function testStoreAlreadyTeamMember() + { + $user = User::factory()->create(); + $team = Team::factory()->create(); + $team->members()->create([ + 'user_id' => $user->getKey(), + ]); + + $this->expectCountChange(fn () => $team->applications()->count(), 0); + + $this + ->actingAsVerified($user) + ->post(route('teams.applications.store', ['team' => $team->getKey()])) + ->assertStatus(403); + } + + public function testStoreAlreadyOtherTeamMember() + { + $user = User::factory()->create(); + $otherTeam = Team::factory()->create(); + $otherTeam->members()->create([ + 'user_id' => $user->getKey(), + ]); + $team = Team::factory()->create(); + + $this->expectCountChange(fn () => $team->applications()->count(), 0); + $this->expectCountChange(fn () => $otherTeam->applications()->count(), 0); + + $this + ->actingAsVerified($user) + ->post(route('teams.applications.store', ['team' => $team->getKey()])) + ->assertStatus(403); + } +}