'round_id' => $validatedData['round_id'],
'user_id' => $validatedData['user_id'],
]);
- if (!$round->locked) {
+ if (!$round->locked && !$result->verified_at) {
if (isset($validatedData['forfeit'])) $result->forfeit = $validatedData['forfeit'];
if (isset($validatedData['time'])) $result->time = $validatedData['time'];
}
$result->comment = !empty($validatedData['comment']) ? $validatedData['comment'] : null;
- $result->vod = !empty($validatedData['vod']) ? $validatedData['vod'] : null;
+ if (!$result->verified_at) {
+ $result->vod = !empty($validatedData['vod']) ? $validatedData['vod'] : null;
+ }
$result->save();
if ($result->wasChanged()) {
$round->tournament->updatePlacement();
}
- $result->load('user');
+ $result->load(['user', 'verified_by']);
if (!Gate::allows('seeResults', $round)) {
$result->hideResult($request->user());
return $result->toJson();
}
+ public function verify(Request $request, Result $result) {
+ $this->authorize('verify', $result);
+
+ $result->verified_at = now();
+ $result->verified_by()->associate($request->user());
+ $result->save();
+
+ Protocol::resultVerified(
+ $result->round->tournament,
+ $result,
+ $request->user(),
+ );
+
+ ResultChanged::dispatch($result);
+
+ return $result->toJson();
+ }
+
}
'participants.user',
]);
$rounds = $tournament->rounds()
- ->with(['results', 'results.user'])
+ ->with(['results', 'results.user', 'results.verified_by'])
->limit($tournament->ceilRoundLimit(25))
->get();
foreach ($rounds as $round) {
$rounds = $tournament->rounds()
->where('number', '<', $validatedData['last_known'])
- ->with(['results', 'results.user'])
+ ->with(['results', 'results.user', 'results.verified_by'])
->limit($tournament->ceilRoundLimit(25))->get();
foreach ($rounds as $round) {
if (!Gate::allows('seeResults', $round)) {
ProtocolAdded::dispatch($protocol);
}
+ public static function resultVerified(Tournament $tournament, Result $result, User $user) {
+ $protocol = static::create([
+ 'tournament_id' => $tournament->id,
+ 'user_id' => $user->id,
+ 'type' => 'result.verify',
+ 'details' => [
+ 'tournament' => static::tournamentMemo($tournament),
+ 'result' => static::resultMemo($result),
+ 'runner' => static::userMemo($result->user),
+ 'round' => static::roundMemo($result->round),
+ ],
+ ]);
+ ProtocolAdded::dispatch($protocol);
+ }
+
public static function roundAdded(Tournament $tournament, Round $round, User $user) {
$protocol = static::create([
'tournament_id' => $tournament->id,
return $this->belongsTo(User::class);
}
+ public function verified_by() {
+ return $this->belongsTo(User::class);
+ }
+
public function getHasFinishedAttribute(): bool {
return $this->time > 0 || $this->forfeit;
}
{
return false;
}
+
+ /**
+ * Determine whether the user can verify the result.
+ *
+ * @param \App\Models\User $user
+ * @param \App\Models\Result $result
+ * @return \Illuminate\Auth\Access\Response|bool
+ */
+ public function verify(User $user, Result $result)
+ {
+ return $user->isTournamentCrew($result->round->tournament);
+ }
+
}
*/
public function selfAssignGroups(User $user, Tournament $tournament)
{
- return !!$user;
+ return !$tournament->locked && !!$user;
}
}
--- /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.
+ */
+ public function up(): void
+ {
+ Schema::table('results', function (Blueprint $table) {
+ $table->timestamp('verified_at')->nullable()->default(null);
+ $table->foreignId('verified_by_id')->nullable()->default(null)->constrained('users');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('results', function (Blueprint $table) {
+ $table->dropColumn('verified_at');
+ $table->dropForeign(['verified_by_id']);
+ $table->dropColumn('verified_by_id');
+ });
+ }
+};
import { getAssignedRounds, missingGroupAssignment } from '../../helpers/Tournament';
import { useUser } from '../../hooks/user';
-const GroupInterface = ({ selfAssign, tournament }) => {
+const GroupInterface = ({ actions, tournament }) => {
const { t } = useTranslation();
const { user } = useUser();
const assignedRounds = React.useMemo(() => getAssignedRounds(tournament, user), [tournament, user]);
+ if (missingGroupAssignment(tournament, user) && tournament.locked) {
+ return <div><p>{t('groups.tournamentClosed')}</p></div>
+ }
+
if (!user) {
return <div><p>{t('groups.loginRequired')}</p></div>
}
if (missingGroupAssignment(tournament, user)) {
return <div>
<p>{t('groups.missingAssignments')}</p>
- <Button onClick={selfAssign}>
- {t('groups.selfAssignButton')}
- </Button>
+ {actions.selfAssignGroups ?
+ <Button onClick={actions.selfAssignGroups}>
+ {t('groups.selfAssignButton')}
+ </Button>
+ : null}
</div>
}
- return <List rounds={assignedRounds} tournament={tournament} />;
+ return <List actions={actions} rounds={assignedRounds} tournament={tournament} />;
};
GroupInterface.propTypes = {
- selfAssign: PropTypes.func,
+ actions: PropTypes.shape({
+ selfAssignGroups: PropTypes.func,
+ }),
tournament: PropTypes.shape({
+ locked: PropTypes.bool,
}),
};
};
Item.propTypes = {
+ actions: PropTypes.shape({
+ }),
round: PropTypes.shape({
code: PropTypes.arrayOf(PropTypes.string),
created_at: PropTypes.string,
import i18n from '../../i18n';
const List = ({
- loadMore,
+ actions,
rounds,
tournament,
}) => rounds && rounds.length ? <>
<ol className="groups">
{rounds.map(round =>
<Item
+ actions={actions}
key={round.id}
round={round}
tournament={tournament}
/>
)}
</ol>
- {loadMore ?
- <LoadMore loadMore={loadMore} />
+ {actions.moreRounds ?
+ <LoadMore loadMore={actions.moreRounds} />
: null}
</> :
<Alert variant="info">
;
List.propTypes = {
- loadMore: PropTypes.func,
+ actions: PropTypes.shape({
+ moreRounds: PropTypes.func,
+ }),
rounds: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.number,
})),
return entry.details.picks.map(p => `${p.number}${p.group}`).join(', ');
}
+const getEntryResultRunner = entry => {
+ if (!entry || !entry.details || !entry.details.runner) {
+ return '';
+ }
+ return getUserName(entry.details.runner);
+};
+
const getEntryResultTime = entry => {
if (!entry || !entry.details || !entry.details.result) return 'ERROR';
const result = entry.details.result;
<Spoiler>{{time}}</Spoiler>,
</Trans>;
}
+ case 'result.verify': {
+ const number = getEntryRoundNumber(entry);
+ const runner = getEntryResultRunner(entry);
+ const time = getEntryResultTime(entry);
+ return <Trans i18nKey={`protocol.description.${entry.type}`}>
+ {{number}}
+ {{runner}}
+ <Spoiler>{{time}}</Spoiler>,
+ </Trans>;
+ }
case 'round.create':
case 'round.delete':
case 'round.edit':
if (result.comment) {
classNames.push('has-comment');
}
+ if (result.verified_at) {
+ classNames.push('is-verified');
+ }
} else {
classNames.push('pending');
}
};
const Badge = ({
+ actions,
round,
tournament,
user,
{getIcon(result, maySeeResult(authUser, tournament, round))}
</Button>
<DetailDialog
+ actions={actions}
onHide={() => setShowDialog(false)}
round={round}
show={showDialog}
};
Badge.propTypes = {
+ actions: PropTypes.shape({
+ }),
round: PropTypes.shape({
}),
tournament: PropTypes.shape({
import { Button, Col, Form, Modal, Row } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
+import Icon from '../common/Icon';
import Box from '../users/Box';
import { getTime } from '../../helpers/Result';
-import { maySeeResult } from '../../helpers/permissions';
+import { formatNumberAlways } from '../../helpers/Round';
+import { maySeeResult, mayVerifyResult } from '../../helpers/permissions';
import { findResult } from '../../helpers/User';
import { useUser } from '../../hooks/user';
`${result.placement}. (${t('results.points', { count: result.score })})`;
const DetailDialog = ({
+ actions,
onHide,
round,
show,
tournament,
user,
}) => {
+ const [verifying, setVerifying] = React.useState(false);
+
const { t } = useTranslation();
const { user: authUser } = useUser();
() => maySeeResult(authUser, tournament, round, result),
[authUser, result, round, tournament],
);
+ const mayVerify = React.useMemo(
+ () => mayVerifyResult(authUser, tournament, round, result),
+ [authUser, result, round, tournament],
+ );
+
+ const handleVerifyClick = React.useCallback(async () => {
+ setVerifying(true);
+ try {
+ await actions.verifyResult(result);
+ } catch (e) {
+ console.error(e);
+ }
+ setVerifying(false);
+ }, [actions, result]);
return <Modal className="result-dialog" onHide={onHide} show={show}>
<Modal.Header closeButton>
<Form.Group as={Col} sm={6}>
<Form.Label>{t('results.round')}</Form.Label>
<div>
- #{round.number || '?'}
+ {formatNumberAlways(tournament, round)}
{' '}
{t('rounds.date', { date: new Date(round.created_at) })}
</div>
<Form.Group as={Col} sm={6}>
<Form.Label>{t('results.result')}</Form.Label>
<div>
- {maySee && result && result.has_finished
- ? getTime(result, maySee)
+ {(maySee || mayVerify) && result && result.has_finished
+ ? getTime(result, true)
: t('results.pending')}
</div>
</Form.Group>
<Form.Group as={Col} sm={6}>
<Form.Label>{t('results.placement')}</Form.Label>
<div>
- {maySee && result && result.placement
+ {(maySee || mayVerify) && result && result.placement
? getPlacement(result, t)
: t('results.pending')}
</div>
</Form.Group>
+ {(maySee || mayVerify) && result && result.vod ?
+ <Form.Group as={Col} sm={12}>
+ <Form.Label>{t('results.vod')}</Form.Label>
+ <div>
+ <a href={result.vod} rel="noreferrer" target="_blank">{result.vod}</a>
+ </div>
+ </Form.Group>
+ : null}
+ {mayVerify && actions?.verifyResult ? (
+ <Form.Group as={Col} sm={12}>
+ <Form.Label>{t('results.verification')}</Form.Label>
+ <div>
+ <Button disabled={verifying} onClick={handleVerifyClick}>
+ {verifying ?
+ <Icon.LOADING className="me-2" />
+ : null}
+ {t('results.verifyButton')}
+ </Button>
+ </div>
+ </Form.Group>
+ ) : null}
{maySee && result && result.comment ?
<Form.Group as={Col} sm={12}>
<Form.Label>{t('results.comment')}</Form.Label>
};
DetailDialog.propTypes = {
+ actions: PropTypes.shape({
+ verifyResult: PropTypes.func,
+ }),
onHide: PropTypes.func,
round: PropTypes.shape({
created_at: PropTypes.string,
};
const Item = ({
+ actions,
round,
tournament,
user,
return <div className="result">
<Box user={user} />
<div className="d-flex align-items-center justify-content-between">
- <Badge round={round} tournament={tournament} user={user} />
+ <Badge actions={actions} round={round} tournament={tournament} user={user} />
{maySee && result && result.vod ?
<Button
className="vod-link"
};
Item.propTypes = {
+ actions: PropTypes.shape({
+ }),
round: PropTypes.shape({
}),
tournament: PropTypes.shape({
import { sortByTime, sortByUsername } from '../../helpers/Result';
import { useUser } from '../../hooks/user';
-const List = ({ round, tournament }) => {
+const List = ({ actions, round, tournament }) => {
const { user } = useUser();
if (hasFixedRunners(tournament)) {
return <div className="results d-flex flex-wrap">
{runners.map(participant =>
<Item
+ actions={actions}
key={participant.id}
round={round}
tournament={tournament}
return <div className="results d-flex flex-wrap align-content-start">
{results.map(result =>
<Item
+ actions={actions}
key={result.id}
round={round}
tournament={tournament}
};
List.propTypes = {
+ actions: PropTypes.shape({
+ }),
round: PropTypes.shape({
results: PropTypes.arrayOf(PropTypes.shape({
})),
const getButtonLabel = (user, round) => {
const result = findResult(user, round);
- if (round.locked) {
+ if (round.locked || (result && result.verified_at)) {
if (result && result.comment) {
return i18n.t('results.editComment');
} else {
}) =>
<Form noValidate onSubmit={handleSubmit}>
<Modal.Body>
- {!round.locked ?
+ {!round.locked && !values.verified_at ?
<Row>
<Form.Group as={Col} sm={9} controlId="report.time">
<Form.Label>{i18n.t('results.reportTime')}</Form.Label>
</Form.Group>
</Row>
: null}
- <Form.Group controlId="report.vod">
- <Form.Label>{i18n.t('results.vod')}</Form.Label>
- <Form.Control
- isInvalid={!!(touched.vod && errors.vod)}
- name="vod"
- onBlur={handleBlur}
- onChange={handleChange}
- placeholder="https://twitch.tv/youtube"
- type="text"
- value={values.vod || ''}
- />
- {touched.vod && errors.vod ?
- <Form.Control.Feedback type="invalid">
- {i18n.t(errors.vod)}
- </Form.Control.Feedback>
- :
- <Form.Text muted>
- {i18n.t('results.vodNote')}
- </Form.Text>
- }
- </Form.Group>
+ {!values.verified_at ?
+ <Form.Group controlId="report.vod">
+ <Form.Label>{i18n.t('results.vod')}</Form.Label>
+ <Form.Control
+ isInvalid={!!(touched.vod && errors.vod)}
+ name="vod"
+ onBlur={handleBlur}
+ onChange={handleChange}
+ placeholder="https://twitch.tv/youtube"
+ type="text"
+ value={values.vod || ''}
+ />
+ {touched.vod && errors.vod ?
+ <Form.Control.Feedback type="invalid">
+ {i18n.t(errors.vod)}
+ </Form.Control.Feedback>
+ :
+ <Form.Text muted>
+ {i18n.t('results.vodNote')}
+ </Form.Text>
+ }
+ </Form.Group>
+ : null}
<Form.Group controlId="report.comment">
<Form.Label>{i18n.t('results.comment')}</Form.Label>
<Form.Control
comment: PropTypes.string,
forfeit: PropTypes.bool,
time: PropTypes.string,
+ verified_at: PropTypes.string,
vod: PropTypes.string,
}),
};
round_id: round.id,
time: result && result.time ? formatTime(result) : '',
user_id: user.id,
+ verified_at: result ? result.verified_at : null,
vod: result && result.vod ? result.vod : '',
};
},
};
const Item = ({
+ actions,
round,
tournament,
}) => {
</div>
</div>
</div>
- <List round={round} tournament={tournament} />
+ <List actions={actions} round={round} tournament={tournament} />
</div>
</li>;
};
Item.propTypes = {
+ actions: PropTypes.shape({
+ }),
round: PropTypes.shape({
code: PropTypes.arrayOf(PropTypes.string),
created_at: PropTypes.string,
import i18n from '../../i18n';
const List = ({
- loadMore,
+ actions,
rounds,
tournament,
}) => rounds && rounds.length ? <>
<ol className="rounds">
{rounds.map(round =>
<Item
+ actions={actions}
key={round.id}
round={round}
tournament={tournament}
/>
)}
</ol>
- {loadMore ?
- <LoadMore loadMore={loadMore} />
+ {actions.moreRounds ?
+ <LoadMore loadMore={actions.moreRounds} />
: null}
</> :
<Alert variant="info">
;
List.propTypes = {
- loadMore: PropTypes.func,
+ actions: PropTypes.shape({
+ moreRounds: PropTypes.func,
+ }),
rounds: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.number,
})),
<div className="mb-3">
<h2>{t('groups.heading')}</h2>
<GroupInterface
- selfAssign={actions.selfAssignGroups}
+ actions={actions}
tournament={tournament}
/>
</div>
</div>
{tournament.rounds ?
<Rounds
- loadMore={actions.moreRounds}
+ actions={actions}
rounds={tournament.rounds}
tournament={tournament}
/>
return maySeeResults(user, tournament, round);
};
+export const mayVerifyResults = (user, tournament, round) => {
+ return isTournamentCrew(user, tournament);
+};
+
+export const mayVerifyResult = (user, tournament, round, result) => {
+ return mayVerifyResults(user, tournament) && user && result && user.id !== result.user_id;
+};
+
// Twitch
export const mayManageTwitchBot = user => isAnyChannelAdmin(user);
selfAssignButton: 'Gruppen zuweisen',
selfAssignError: 'Fehler beim Zuweisen',
selfAssignSuccess: 'Gruppen zugewiesen',
+ tournamentClosed: 'Dieses Turnier ist geschlossen.',
},
icon: {
AddIcon: 'Hinzufügen',
result: {
comment: 'Ergebnis von Runde {{number}} kommentiert: <1>{{comment}}</1>',
report: 'Ergebnis von <1>{{time}}</1> bei Runde {{number}} eingetragen',
+ verify: 'Ergebnis in Runde {{number}} von {{runner}} (<2>{{time}}</2>) verifiziert',
},
round: {
create: 'Runde #{{number}} hinzugefügt',
runner: 'Runner',
score: 'Punkte',
time: 'Zeit: {{ time }}',
+ verification: 'Verifikation',
+ verifyButton: 'Ergebnis verifizieren',
+ verifyError: 'Fehler beim Verifizieren',
+ verifySuccess: 'Ergebnis verifiziert',
vod: 'VoD',
vodNote: 'Falls ihr euer VoD teilen wollte, gerne hier rein.',
},
selfAssignButton: 'Assign groups',
selfAssignError: 'Error assigning groups',
selfAssignSuccess: 'Groups assigned',
+ tournamentClosed: 'This tournament has closed.',
},
icon: {
AddIcon: 'Add',
result: {
comment: 'Result of round {{number}} commented: <1>{{comment}}</1>',
report: 'Result of <1>{{time}}</1> reported for round {{number}}',
+ verify: 'Verified round {{number}} result of {{runner}} (<2>{{time}}</2>)',
},
round: {
create: 'Added round #{{number}}',
runner: 'Runner',
score: 'Score',
time: 'Time: {{ time }}',
+ verification: 'Verification',
+ verifyButton: 'Verify result',
+ verifyError: 'Error verifying result',
+ verifySuccess: 'Result verified',
vod: 'VoD',
vodNote: 'If you want to share your VoD, go ahead.',
},
import Detail from '../components/tournament/Detail';
import {
mayEditContent,
+ mayVerifyResults,
} from '../helpers/permissions';
import { getTranslation } from '../helpers/Technique';
import {
}
}, [id, t]);
+ const verifyResult = React.useCallback(async (result) => {
+ try {
+ const response = await axios.post(`/api/results/${result.id}/verify`);
+ toastr.success(t('results.verifySuccess'));
+ setTournament(tournament => patchResult(tournament, response.data));
+ } catch (e) {
+ toastr.error(t('results.verifyError', e));
+ }
+ });
+
const actions = React.useMemo(() => ({
addRound,
editContent: mayEditContent(user) ? content => {
} : null,
moreRounds: canLoadMoreRounds(tournament) ? moreRounds : null,
selfAssignGroups,
- }), [addRound, moreRounds, selfAssignGroups, tournament, user]);
+ verifyResult: mayVerifyResults(user, tournament) ? verifyResult : null,
+ }), [addRound, moreRounds, selfAssignGroups, tournament, user, verifyResult]);
useEffect(() => {
const cb = (e) => {
Route::get('protocol/{tournament}/{round}', 'App\Http\Controllers\ProtocolController@forRound');
Route::post('results', 'App\Http\Controllers\ResultController@create');
+Route::post('results/{result}/verify', 'App\Http\Controllers\ResultController@verify');
Route::post('rounds', 'App\Http\Controllers\RoundController@create');
Route::put('rounds/{round}', 'App\Http\Controllers\RoundController@update');