--- /dev/null
+<?php
+
+namespace App\Events;
+
+use App\Models\Application;
+use Illuminate\Broadcasting\Channel;
+use Illuminate\Broadcasting\InteractsWithSockets;
+use Illuminate\Broadcasting\PresenceChannel;
+use Illuminate\Broadcasting\PrivateChannel;
+use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
+use Illuminate\Foundation\Events\Dispatchable;
+use Illuminate\Queue\SerializesModels;
+
+class ApplicationAdded implements ShouldBroadcast
+{
+ use Dispatchable, InteractsWithSockets, SerializesModels;
+
+ /**
+ * Create a new event instance.
+ *
+ * @return void
+ */
+ public function __construct(Application $application)
+ {
+ $this->application = $application;
+ }
+
+ /**
+ * Get the channels the event should broadcast on.
+ *
+ * @return \Illuminate\Broadcasting\Channel|array
+ */
+ public function broadcastOn()
+ {
+ return new Channel('Tournament.'.$this->application->tournament_id);
+ }
+
+ public $application;
+
+}
namespace App\Http\Controllers;
+use App\Events\ApplicationAdded;
+use App\Models\Application;
use App\Models\Tournament;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\Request;
class TournamentController extends Controller
{
+ public function apply(Request $request, Tournament $tournament) {
+ $this->authorize('apply', $tournament);
+ $application = new Application();
+ $application->tournament_id = $tournament->id;
+ $application->user_id = $request->user()->id;
+ $application->save();
+ ApplicationAdded::dispatch($application);
+ return $tournament->toJson();
+ }
+
public function single(Request $request, $id) {
$tournament = Tournament::with(
+ 'applications',
+ 'applications.user',
'rounds',
'rounds.results',
'participants',
--- /dev/null
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+
+class Application extends Model
+{
+
+ use HasFactory;
+
+ public function tournament() {
+ return $this->belongsTo(Tournament::class);
+ }
+
+ public function user() {
+ return $this->belongsTo(User::class);
+ }
+
+ protected $with = [
+ 'user',
+ ];
+
+}
}
+ public function applications() {
+ return $this->hasMany(Application::class);
+ }
+
public function participants() {
return $this->hasMany(Participant::class);
}
protected $casts = [
+ 'accept_applications' => 'boolean',
'locked' => 'boolean',
'no_record' => 'boolean',
];
return $this->role === 'admin';
}
+ public function isApplicant(Tournament $tournament) {
+ foreach ($tournament->applications as $applicant) {
+ if ($applicant->user_id == $this->id) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ public function isDeniedApplicant(Tournament $tournament) {
+ foreach ($tournament->applications as $applicant) {
+ if ($applicant->user_id == $this->id) {
+ return $applicant->denied;
+ }
+ }
+ return false;
+ }
+
public function isParticipant(Tournament $tournament) {
foreach ($tournament->participants as $participant) {
if ($participant->user_id == $this->id) {
--- /dev/null
+<?php
+
+namespace App\Policies;
+
+use App\Models\Application;
+use App\Models\User;
+use Illuminate\Auth\Access\HandlesAuthorization;
+
+class ApplicationPolicy
+{
+ use HandlesAuthorization;
+
+ /**
+ * Determine whether the user can view any models.
+ *
+ * @param \App\Models\User $user
+ * @return \Illuminate\Auth\Access\Response|bool
+ */
+ public function viewAny(User $user)
+ {
+ return $user->isAdmin();
+ }
+
+ /**
+ * Determine whether the user can view the model.
+ *
+ * @param \App\Models\User $user
+ * @param \App\Models\Application $application
+ * @return \Illuminate\Auth\Access\Response|bool
+ */
+ public function view(User $user, Application $application)
+ {
+ return $user->isAdmin()
+ || $user->isTournamentAdmin($application->tournament)
+ || $user->id == $application->user->id;
+ }
+
+ /**
+ * Determine whether the user can create models.
+ *
+ * @param \App\Models\User $user
+ * @return \Illuminate\Auth\Access\Response|bool
+ */
+ public function create(User $user)
+ {
+ return false;
+ }
+
+ /**
+ * Determine whether the user can update the model.
+ *
+ * @param \App\Models\User $user
+ * @param \App\Models\Application $application
+ * @return \Illuminate\Auth\Access\Response|bool
+ */
+ public function update(User $user, Application $application)
+ {
+ return false;
+ }
+
+ /**
+ * Determine whether the user can delete the model.
+ *
+ * @param \App\Models\User $user
+ * @param \App\Models\Application $application
+ * @return \Illuminate\Auth\Access\Response|bool
+ */
+ public function delete(User $user, Application $application)
+ {
+ return false;
+ }
+
+ /**
+ * Determine whether the user can restore the model.
+ *
+ * @param \App\Models\User $user
+ * @param \App\Models\Application $application
+ * @return \Illuminate\Auth\Access\Response|bool
+ */
+ public function restore(User $user, Application $application)
+ {
+ return false;
+ }
+
+ /**
+ * Determine whether the user can permanently delete the model.
+ *
+ * @param \App\Models\User $user
+ * @param \App\Models\Application $application
+ * @return \Illuminate\Auth\Access\Response|bool
+ */
+ public function forceDelete(User $user, Application $application)
+ {
+ return false;
+ }
+
+}
return !$tournament->locked && ($user->isRunner($tournament) || $user->isTournamentAdmin($tournament));
}
+ /**
+ * Determine whether the user can apply to participate.
+ *
+ * @param \App\Models\User $user
+ * @param \App\Models\Tournament $tournament
+ * @return \Illuminate\Auth\Access\Response|bool
+ */
+ public function apply(User $user, Tournament $tournament)
+ {
+ return $tournament->accept_applications && !$user->isRunner($tournament) && !$user->isApplicant($tournament);
+ }
+
/**
* Determine whether the user can view the tournament protocol.
*
--- /dev/null
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+ /**
+ * Run the migrations.
+ *
+ * @return void
+ */
+ public function up()
+ {
+ Schema::create('applications', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('tournament_id')->constrained();
+ $table->foreignId('user_id')->constrained();
+ $table->boolean('denied')->default(false);
+ $table->timestamps();
+
+ $table->unique(['tournament_id', 'user_id']);
+ });
+ Schema::table('tournaments', function(Blueprint $table) {
+ $table->boolean('accept_applications')->default(false);
+ });
+ Schema::table('participants', function(Blueprint $table) {
+ $table->unique(['tournament_id', 'user_id']);
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ Schema::table('tournaments', function(Blueprint $table) {
+ $table->dropColumn('accept_applications');
+ });
+ Schema::dropIfExists('applications');
+ }
+};
};
Icon.ADD = makePreset('AddIcon', 'circle-plus');
+Icon.APPLY = makePreset('ApplyIcon', 'right-to-bracket');
Icon.DISCORD = makePreset('DiscordIcon', ['fab', 'discord']);
Icon.EDIT = makePreset('EditIcon', 'edit');
Icon.FINISHED = makePreset('FinishedIcon', 'square-check');
import NotFound from '../pages/NotFound';
import Detail from '../tournament/Detail';
import {
+ patchApplication,
patchParticipant,
patchResult,
patchRound,
useEffect(() => {
window.Echo.channel(`Tournament.${id}`)
+ .listen('ApplicationAdded', e => {
+ if (e.application) {
+ setTournament(tournament => patchApplication(tournament, e.application));
+ }
+ })
+ .listen('ApplicationChanged', e => {
+ if (e.application) {
+ setTournament(tournament => patchApplication(tournament, e.application));
+ }
+ })
.listen('ParticipantChanged', e => {
if (e.participant) {
setTournament(tournament => patchParticipant(tournament, e.participant));
--- /dev/null
+import axios from 'axios';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Button } from 'react-bootstrap';
+import { withTranslation } from 'react-i18next';
+import toastr from 'toastr';
+
+import Icon from '../common/Icon';
+import { isApplicant, isDeniedApplicant, mayApply } from '../../helpers/permissions';
+import { withUser } from '../../helpers/UserContext';
+import i18n from '../../i18n';
+
+const apply = async tournament => {
+ try {
+ await axios.post(`/api/tournaments/${tournament.id}/apply`);
+ toastr.success(i18n.t('tournaments.applySuccess'));
+ } catch (e) {
+ toastr.error(i18n.t('tournaments.applyError'));
+ }
+};
+
+const getTitle = (user, tournament) => {
+ if (isDeniedApplicant(user, tournament)) {
+ return i18n.t('tournaments.applicationDenied');
+ }
+ if (isApplicant(user, tournament)) {
+ return i18n.t('tournaments.applicationPending');
+ }
+ return i18n.t('tournaments.apply');
+};
+
+const ApplyButton = ({ tournament, user }) => {
+ if (!tournament.accept_applications) return null;
+
+ return <span className="d-inline-block" title={getTitle(user, tournament)}>
+ <Button
+ disabled={!mayApply(user, tournament)}
+ onClick={() => apply(tournament)}
+ variant="primary"
+ >
+ <Icon.APPLY title="" />
+ </Button>
+ </span>;
+};
+
+ApplyButton.propTypes = {
+ tournament: PropTypes.shape({
+ accept_applications: PropTypes.bool,
+ id: PropTypes.number,
+ }),
+ user: PropTypes.shape({
+ }),
+};
+
+export default withTranslation()(withUser(ApplyButton));
import { Button, Col, Container, Row } from 'react-bootstrap';
import { withTranslation } from 'react-i18next';
+import ApplyButton from './ApplyButton';
import Scoreboard from './Scoreboard';
import Protocol from '../protocol/Protocol';
import Rounds from '../rounds/List';
<Col lg={8} xl={9}>
<div className="d-flex align-items-center justify-content-between">
<h1>{tournament.title}</h1>
- {mayViewProtocol(user, tournament) ?
- <Protocol id={tournament.id} />
- : null}
+ <div className="button-bar">
+ <ApplyButton tournament={tournament} />
+ {mayViewProtocol(user, tournament) ?
+ <Protocol id={tournament.id} />
+ : null}
+ </div>
</div>
</Col>
</Row>
return getTournamentMonitors(tournament).length > 0;
};
+export const patchApplication = (tournament, application) => {
+ if (!tournament) return tournament;
+ if (!tournament.applications || !tournament.applications.length) {
+ return {
+ ...tournament,
+ applications: [application],
+ };
+ }
+ if (!tournament.applications.find(a => a.user_id == application.user_id)) {
+ return {
+ ...tournament,
+ applications: [...tournament.applications, application],
+ };
+ }
+ return {
+ ...tournament,
+ applications: tournament.applications.map(
+ a => a.user_id === application.user_id ? application : a,
+ ),
+ };
+};
+
export const patchParticipant = (tournament, participant) => {
if (!tournament) return tournament;
if (!tournament.participants || !tournament.participants.length) {
// Tournaments
+export const isApplicant = (user, tournament) => {
+ if (!user || !tournament || !tournament.applications) {
+ return false;
+ }
+ return tournament.applications.find(p => p.user && p.user.id == user.id);
+};
+
+export const isDeniedApplicant = (user, tournament) => {
+ if (!user || !tournament || !tournament.applications) {
+ return false;
+ }
+ const applicant = tournament.applications.find(p => p.user && p.user.id == user.id);
+ return applicant && applicant.denied;
+};
+
export const isParticipant = (user, tournament) =>
user && tournament && tournament.participants &&
tournament.participants.find(p => p.user && p.user.id == user.id);
!tournament.locked &&
(isRunner(user, tournament) || isTournamentAdmin(user, tournament));
+export const mayApply = (user, tournament) =>
+ user && tournament && tournament.accept_applications &&
+ !isRunner(user, tournament) && !isApplicant(user, tournament);
+
export const mayLockRound = (user, tournament) =>
!tournament.locked && isTournamentAdmin(user, tournament);
},
icon: {
AddIcon: 'Hinzufügen',
+ ApplyIcon: 'Beantragen',
DiscordIcon: 'Discord',
EditIcon: 'Bearbeiten',
FinishedIcon: 'Abgeschlossen',
},
tournaments: {
admins: 'Organisation',
+ applicationDenied: 'Antrag wurde abgelehnt',
+ applicationPending: 'Antrag wurde abgeschickt',
+ apply: 'Beitreten',
+ applyError: 'Fehler beim Abschicken der Anfrage',
+ applySuccess: 'Anfrage gestellt',
monitors: 'Monitore',
noRecord: 'Turnier wird nicht gewertet',
scoreboard: 'Scoreboard',
},
icon: {
AddIcon: 'Add',
+ ApplyIcon: 'Apply',
DiscordIcon: 'Discord',
EditIcon: 'Edit',
FinishedIcon: 'Finished',
},
tournaments: {
admins: 'Admins',
+ applicationDenied: 'Application denied',
+ applicationPending: 'Application pending',
+ apply: 'Apply',
+ applyError: 'Error submitting application',
+ applySuccess: 'Application sent',
monitors: 'Monitors',
noRecord: 'Tournament set to not be recorded',
scoreboard: 'Scoreboard',
margin-bottom: 2rem;
}
+.button-bar {
+ > * {
+ margin-left: 0.5ex;
+ margin-right: 0.5ex;
+ }
+ > :first-child {
+ margin-left: 0;
+ }
+ > :last-child {
+ margin-right: 0;
+ }
+}
+
.spoiler {
border-radius: 0.5ex;
padding: 0 0.5ex;
*/
Route::middleware('auth:sanctum')->get('/user', function (Request $request) {
- return $request->user();
+ return $request->user();
});
Route::get('protocol/{tournament}', 'App\Http\Controllers\ProtocolController@forTournament');
Route::post('rounds/{round}/unlock', 'App\Http\Controllers\RoundController@unlock');
Route::get('tournaments/{id}', 'App\Http\Controllers\TournamentController@single');
+Route::post('tournaments/{tournament}/apply', 'App\Http\Controllers\TournamentController@apply');
Route::get('users/{id}', 'App\Http\Controllers\UserController@single');
Route::post('users/set-language', 'App\Http\Controllers\UserController@setLanguage');