]> git.localhorst.tv Git - alttp.git/commitdiff
basic verification
authorDaniel Karbach <daniel.karbach@localhorst.tv>
Wed, 26 Nov 2025 10:00:00 +0000 (11:00 +0100)
committerDaniel Karbach <daniel.karbach@localhorst.tv>
Wed, 26 Nov 2025 10:00:00 +0000 (11:00 +0100)
25 files changed:
app/Http/Controllers/ResultController.php
app/Http/Controllers/TournamentController.php
app/Models/Protocol.php
app/Models/Result.php
app/Policies/ResultPolicy.php
app/Policies/TournamentPolicy.php
database/migrations/2025_11_25_141825_result_verification.php [new file with mode: 0644]
resources/js/components/groups/Interface.jsx
resources/js/components/groups/Item.jsx
resources/js/components/groups/List.jsx
resources/js/components/protocol/Item.jsx
resources/js/components/results/Badge.jsx
resources/js/components/results/DetailDialog.jsx
resources/js/components/results/Item.jsx
resources/js/components/results/List.jsx
resources/js/components/results/ReportButton.jsx
resources/js/components/results/ReportForm.jsx
resources/js/components/rounds/Item.jsx
resources/js/components/rounds/List.jsx
resources/js/components/tournament/Detail.jsx
resources/js/helpers/permissions.js
resources/js/i18n/de.js
resources/js/i18n/en.js
resources/js/pages/Tournament.jsx
routes/api.php

index 16327c6fe23dad37add1614820974767a22fdbb7..004ba6b9972c5e619a1483afb064d207a44d74d3 100644 (file)
@@ -34,12 +34,14 @@ class ResultController extends Controller
                        '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()) {
@@ -67,7 +69,7 @@ class ResultController extends Controller
                        $round->tournament->updatePlacement();
                }
 
-               $result->load('user');
+               $result->load(['user', 'verified_by']);
 
                if (!Gate::allows('seeResults', $round)) {
                        $result->hideResult($request->user());
@@ -76,4 +78,22 @@ class ResultController extends Controller
                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();
+       }
+
 }
index d0641e6a2fb84459be597a2d0bc7fdbbb8a9a9fa..0998283b0863688b354e551e08c9ca0ca4bdbed7 100644 (file)
@@ -36,7 +36,7 @@ class TournamentController extends Controller
                        '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) {
@@ -101,7 +101,7 @@ class TournamentController extends Controller
 
                $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)) {
index 5c317a301807792e627969bf5513b9ce701a5bdd..ed4ec164da5ed716ed585fbd645825e8662907c0 100644 (file)
@@ -94,6 +94,21 @@ class Protocol extends Model
                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,
index 421b813353962bf11c64c7f82972481d1dfa668e..77cb7f9c7173f71bf37f8e13ffa409e46b3f8001 100644 (file)
@@ -89,6 +89,10 @@ class Result extends Model
                return $this->belongsTo(User::class);
        }
 
+       public function verified_by() {
+               return $this->belongsTo(User::class);
+       }
+
        public function getHasFinishedAttribute(): bool {
                return $this->time > 0 || $this->forfeit;
        }
index 7419431c5a6205b47205f02765fad8a532a2961c..3e0e4eabca3aa75c61560a49725c72b9715866dd 100644 (file)
@@ -91,4 +91,17 @@ class ResultPolicy
        {
                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);
+       }
+
 }
index f8e448b425de5a223c17b62ea6fa31937e9720a5..66dfdaba3856af7116f105ec349756622760abce 100644 (file)
@@ -137,7 +137,7 @@ class TournamentPolicy
         */
        public function selfAssignGroups(User $user, Tournament $tournament)
        {
-               return !!$user;
+               return !$tournament->locked && !!$user;
        }
 
 }
diff --git a/database/migrations/2025_11_25_141825_result_verification.php b/database/migrations/2025_11_25_141825_result_verification.php
new file mode 100644 (file)
index 0000000..a197567
--- /dev/null
@@ -0,0 +1,31 @@
+<?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');
+               });
+       }
+};
index e16e9d7161dd73f424eba2a2be9c5310eff5e16d..4e68b927f0e9e9575e58b040f70c99bb94d6e344 100644 (file)
@@ -7,12 +7,16 @@ import List from './List';
 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>
        }
@@ -20,18 +24,23 @@ const GroupInterface = ({ selfAssign, tournament }) => {
        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,
        }),
 };
 
index a7b3a232cbf9af555a2aff9d91b9312067826679..f887e91d2a8a633b793433cee8e42695096d08f5 100644 (file)
@@ -68,6 +68,8 @@ const Item = ({
 };
 
 Item.propTypes = {
+       actions: PropTypes.shape({
+       }),
        round: PropTypes.shape({
                code: PropTypes.arrayOf(PropTypes.string),
                created_at: PropTypes.string,
index e69c8ff83c95375dd0d4cefc4391baa419c949f1..8a8786297b106c5ccab4932c8f00ec311a030a1a 100644 (file)
@@ -8,21 +8,22 @@ import LoadMore from '../rounds/LoadMore';
 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">
@@ -31,7 +32,9 @@ const List = ({
 ;
 
 List.propTypes = {
-       loadMore: PropTypes.func,
+       actions: PropTypes.shape({
+               moreRounds: PropTypes.func,
+       }),
        rounds: PropTypes.arrayOf(PropTypes.shape({
                id: PropTypes.number,
        })),
index e32c28faad60db0b1db034042bbfb1272dd3321c..12d247f007bd457a8edd543350d8a0b62c5c4440 100644 (file)
@@ -43,6 +43,13 @@ const getEntryDetailsPicks = entry => {
        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;
@@ -87,6 +94,16 @@ const getEntryDescription = (entry, t) => {
                                <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':
index 1339d82148457dd3bdd91f387bf955067f73a332..10ecd93968497cdf93160fb3f09ae72d2084db01 100644 (file)
@@ -15,6 +15,9 @@ const getClassName = result => {
                if (result.comment) {
                        classNames.push('has-comment');
                }
+               if (result.verified_at) {
+                       classNames.push('is-verified');
+               }
        } else {
                classNames.push('pending');
        }
@@ -22,6 +25,7 @@ const getClassName = result => {
 };
 
 const Badge = ({
+       actions,
        round,
        tournament,
        user,
@@ -51,6 +55,7 @@ const Badge = ({
                        {getIcon(result, maySeeResult(authUser, tournament, round))}
                </Button>
                <DetailDialog
+                       actions={actions}
                        onHide={() => setShowDialog(false)}
                        round={round}
                        show={showDialog}
@@ -61,6 +66,8 @@ const Badge = ({
 };
 
 Badge.propTypes = {
+       actions: PropTypes.shape({
+       }),
        round: PropTypes.shape({
        }),
        tournament: PropTypes.shape({
index 60e6b747383defc082ab6092b5d66eacc6f08b57..f3b1c990c5f321c337c7f139a9da4563e71b2fb4 100644 (file)
@@ -3,9 +3,11 @@ import React from 'react';
 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';
 
@@ -13,12 +15,15 @@ const getPlacement = (result, t) =>
        `${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();
 
@@ -30,6 +35,20 @@ const DetailDialog = ({
                () => 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>
@@ -42,7 +61,7 @@ const DetailDialog = ({
                                <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>
@@ -54,19 +73,40 @@ const DetailDialog = ({
                                <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>
@@ -84,6 +124,9 @@ const DetailDialog = ({
 };
 
 DetailDialog.propTypes = {
+       actions: PropTypes.shape({
+               verifyResult: PropTypes.func,
+       }),
        onHide: PropTypes.func,
        round: PropTypes.shape({
                created_at: PropTypes.string,
index b3a0b898707a25c9c0b58a102173f617dff088e6..730bfeb1d83a4377d6b7fd6563c3f29671798d7b 100644 (file)
@@ -36,6 +36,7 @@ const getVoDIcon = result => {
 };
 
 const Item = ({
+       actions,
        round,
        tournament,
        user,
@@ -55,7 +56,7 @@ const Item = ({
        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"
@@ -73,6 +74,8 @@ const Item = ({
 };
 
 Item.propTypes = {
+       actions: PropTypes.shape({
+       }),
        round: PropTypes.shape({
        }),
        tournament: PropTypes.shape({
index 06d34d825ada795567632a2539926a7137ad2657..0fa1e03f24537d8534665468892b4633c6b4ce4f 100644 (file)
@@ -8,7 +8,7 @@ import { getRunners, hasFixedRunners } from '../../helpers/Tournament';
 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)) {
@@ -18,6 +18,7 @@ const List = ({ round, tournament }) => {
                return <div className="results d-flex flex-wrap">
                        {runners.map(participant =>
                                <Item
+                                       actions={actions}
                                        key={participant.id}
                                        round={round}
                                        tournament={tournament}
@@ -33,6 +34,7 @@ const List = ({ round, 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}
@@ -43,6 +45,8 @@ const List = ({ round, tournament }) => {
 };
 
 List.propTypes = {
+       actions: PropTypes.shape({
+       }),
        round: PropTypes.shape({
                results: PropTypes.arrayOf(PropTypes.shape({
                })),
index 9db944ee79ace429eed3928e6d4b5bcd10440768..946e8fa5f898c7a8d6e084f62e470351bf60b328 100644 (file)
@@ -10,7 +10,7 @@ import i18n from '../../i18n';
 
 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 {
index 2e85f4a4c3abfb9c334b3cd18253bf91727001f3..8c43369fd803a4f16c847a237a5241988ebd89ea 100644 (file)
@@ -25,7 +25,7 @@ const ReportForm = ({
 }) =>
 <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>
@@ -66,27 +66,29 @@ const ReportForm = ({
                                </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
@@ -136,6 +138,7 @@ ReportForm.propTypes = {
                comment: PropTypes.string,
                forfeit: PropTypes.bool,
                time: PropTypes.string,
+               verified_at: PropTypes.string,
                vod: PropTypes.string,
        }),
 };
@@ -175,6 +178,7 @@ export default withFormik({
                        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 : '',
                };
        },
index 2d207f2df75c9ab452f1a744764022a13349d861..45f2c853392620f8fca2fc75f91f2488040ed6ab 100644 (file)
@@ -44,6 +44,7 @@ const getClassName = (round, tournament, user) => {
 };
 
 const Item = ({
+       actions,
        round,
        tournament,
 }) => {
@@ -101,12 +102,14 @@ const Item = ({
                                        </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,
index 5aa2a4c77eece85242122452edf11c6c7070c787..c4a0293bfc2f48090f724256f5b2e6752c5bf2f6 100644 (file)
@@ -8,21 +8,22 @@ import LoadMore from './LoadMore';
 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">
@@ -31,7 +32,9 @@ const List = ({
 ;
 
 List.propTypes = {
-       loadMore: PropTypes.func,
+       actions: PropTypes.shape({
+               moreRounds: PropTypes.func,
+       }),
        rounds: PropTypes.arrayOf(PropTypes.shape({
                id: PropTypes.number,
        })),
index 2407d9bc90aa52494acd24a4e87ba6cba38424d4..af339beaf26ef80badd0b0b21a501cf54ee9beb4 100644 (file)
@@ -136,7 +136,7 @@ const Detail = ({
                                        <div className="mb-3">
                                                <h2>{t('groups.heading')}</h2>
                                                <GroupInterface
-                                                       selfAssign={actions.selfAssignGroups}
+                                                       actions={actions}
                                                        tournament={tournament}
                                                />
                                        </div>
@@ -151,7 +151,7 @@ const Detail = ({
                                </div>
                                {tournament.rounds ?
                                        <Rounds
-                                               loadMore={actions.moreRounds}
+                                               actions={actions}
                                                rounds={tournament.rounds}
                                                tournament={tournament}
                                        />
index 0c1aaf6fb86b5d6f91722d977d70ca7b81b2590b..9a004c1d33e4e0aca7480745150090bea7d30c9f 100644 (file)
@@ -213,6 +213,14 @@ export const maySeeResult = (user, tournament, round, result) => {
        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);
index 65990629f9a8ee9f41c958baf4032fc6a802a4e6..7ccc32e43ead5c0327aebb599b86c1b90afa588f 100644 (file)
@@ -389,6 +389,7 @@ export default {
                        selfAssignButton: 'Gruppen zuweisen',
                        selfAssignError: 'Fehler beim Zuweisen',
                        selfAssignSuccess: 'Gruppen zugewiesen',
+                       tournamentClosed: 'Dieses Turnier ist geschlossen.',
                },
                icon: {
                        AddIcon: 'Hinzufügen',
@@ -578,6 +579,7 @@ export default {
                                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',
@@ -622,6 +624,10 @@ export default {
                        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.',
                },
index 7638aa768c51b3e684b6d1ad2d5e7d6e996c0a88..8349e24d70223ed3b994444f64979d441ba641ee 100644 (file)
@@ -389,6 +389,7 @@ export default {
                        selfAssignButton: 'Assign groups',
                        selfAssignError: 'Error assigning groups',
                        selfAssignSuccess: 'Groups assigned',
+                       tournamentClosed: 'This tournament has closed.',
                },
                icon: {
                        AddIcon: 'Add',
@@ -578,6 +579,7 @@ export default {
                                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}}',
@@ -622,6 +624,10 @@ export default {
                        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.',
                },
index 8d0a9541be8de9fb9aa1fd4f50e1572ede447332..d1ffaaebdce7cffe954db0677ee7609d9594c72d 100644 (file)
@@ -14,6 +14,7 @@ import Dialog from '../components/techniques/Dialog';
 import Detail from '../components/tournament/Detail';
 import {
        mayEditContent,
+       mayVerifyResults,
 } from '../helpers/permissions';
 import { getTranslation } from '../helpers/Technique';
 import {
@@ -172,6 +173,16 @@ export const Component = () => {
                }
        }, [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 => {
@@ -180,7 +191,8 @@ export const Component = () => {
                } : 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) => {
index 400f678360e7bd766833010826e7718256cf25c0..b599d4c8a25987e86741f574489e7928cdacd24c 100644 (file)
@@ -85,6 +85,7 @@ Route::get('protocol/{tournament}', 'App\Http\Controllers\ProtocolController@for
 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');