From: Daniel Karbach Date: Wed, 26 Nov 2025 10:00:00 +0000 (+0100) Subject: basic verification X-Git-Url: https://git.localhorst.tv/?a=commitdiff_plain;h=5c4fb2f6c5d9b6fee913018f753cc365cd5b05b9;p=alttp.git basic verification --- diff --git a/app/Http/Controllers/ResultController.php b/app/Http/Controllers/ResultController.php index 16327c6..004ba6b 100644 --- a/app/Http/Controllers/ResultController.php +++ b/app/Http/Controllers/ResultController.php @@ -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(); + } + } diff --git a/app/Http/Controllers/TournamentController.php b/app/Http/Controllers/TournamentController.php index d0641e6..0998283 100644 --- a/app/Http/Controllers/TournamentController.php +++ b/app/Http/Controllers/TournamentController.php @@ -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)) { diff --git a/app/Models/Protocol.php b/app/Models/Protocol.php index 5c317a3..ed4ec16 100644 --- a/app/Models/Protocol.php +++ b/app/Models/Protocol.php @@ -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, diff --git a/app/Models/Result.php b/app/Models/Result.php index 421b813..77cb7f9 100644 --- a/app/Models/Result.php +++ b/app/Models/Result.php @@ -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; } diff --git a/app/Policies/ResultPolicy.php b/app/Policies/ResultPolicy.php index 7419431..3e0e4ea 100644 --- a/app/Policies/ResultPolicy.php +++ b/app/Policies/ResultPolicy.php @@ -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); + } + } diff --git a/app/Policies/TournamentPolicy.php b/app/Policies/TournamentPolicy.php index f8e448b..66dfdab 100644 --- a/app/Policies/TournamentPolicy.php +++ b/app/Policies/TournamentPolicy.php @@ -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 index 0000000..a197567 --- /dev/null +++ b/database/migrations/2025_11_25_141825_result_verification.php @@ -0,0 +1,31 @@ +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'); + }); + } +}; diff --git a/resources/js/components/groups/Interface.jsx b/resources/js/components/groups/Interface.jsx index e16e9d7..4e68b92 100644 --- a/resources/js/components/groups/Interface.jsx +++ b/resources/js/components/groups/Interface.jsx @@ -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

{t('groups.tournamentClosed')}

+ } + if (!user) { return

{t('groups.loginRequired')}

} @@ -20,18 +24,23 @@ const GroupInterface = ({ selfAssign, tournament }) => { if (missingGroupAssignment(tournament, user)) { return

{t('groups.missingAssignments')}

- + {actions.selfAssignGroups ? + + : null}
} - return ; + return ; }; GroupInterface.propTypes = { - selfAssign: PropTypes.func, + actions: PropTypes.shape({ + selfAssignGroups: PropTypes.func, + }), tournament: PropTypes.shape({ + locked: PropTypes.bool, }), }; diff --git a/resources/js/components/groups/Item.jsx b/resources/js/components/groups/Item.jsx index a7b3a23..f887e91 100644 --- a/resources/js/components/groups/Item.jsx +++ b/resources/js/components/groups/Item.jsx @@ -68,6 +68,8 @@ const Item = ({ }; Item.propTypes = { + actions: PropTypes.shape({ + }), round: PropTypes.shape({ code: PropTypes.arrayOf(PropTypes.string), created_at: PropTypes.string, diff --git a/resources/js/components/groups/List.jsx b/resources/js/components/groups/List.jsx index e69c8ff..8a87862 100644 --- a/resources/js/components/groups/List.jsx +++ b/resources/js/components/groups/List.jsx @@ -8,21 +8,22 @@ import LoadMore from '../rounds/LoadMore'; import i18n from '../../i18n'; const List = ({ - loadMore, + actions, rounds, tournament, }) => rounds && rounds.length ? <>
    {rounds.map(round => )}
- {loadMore ? - + {actions.moreRounds ? + : null} : @@ -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, })), diff --git a/resources/js/components/protocol/Item.jsx b/resources/js/components/protocol/Item.jsx index e32c28f..12d247f 100644 --- a/resources/js/components/protocol/Item.jsx +++ b/resources/js/components/protocol/Item.jsx @@ -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) => { {{time}}, ; } + case 'result.verify': { + const number = getEntryRoundNumber(entry); + const runner = getEntryResultRunner(entry); + const time = getEntryResultTime(entry); + return + {{number}} + {{runner}} + {{time}}, + ; + } case 'round.create': case 'round.delete': case 'round.edit': diff --git a/resources/js/components/results/Badge.jsx b/resources/js/components/results/Badge.jsx index 1339d82..10ecd93 100644 --- a/resources/js/components/results/Badge.jsx +++ b/resources/js/components/results/Badge.jsx @@ -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))} setShowDialog(false)} round={round} show={showDialog} @@ -61,6 +66,8 @@ const Badge = ({ }; Badge.propTypes = { + actions: PropTypes.shape({ + }), round: PropTypes.shape({ }), tournament: PropTypes.shape({ diff --git a/resources/js/components/results/DetailDialog.jsx b/resources/js/components/results/DetailDialog.jsx index 60e6b74..f3b1c99 100644 --- a/resources/js/components/results/DetailDialog.jsx +++ b/resources/js/components/results/DetailDialog.jsx @@ -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 @@ -42,7 +61,7 @@ const DetailDialog = ({ {t('results.round')}
- #{round.number || '?'} + {formatNumberAlways(tournament, round)} {' '} {t('rounds.date', { date: new Date(round.created_at) })}
@@ -54,19 +73,40 @@ const DetailDialog = ({ {t('results.result')}
- {maySee && result && result.has_finished - ? getTime(result, maySee) + {(maySee || mayVerify) && result && result.has_finished + ? getTime(result, true) : t('results.pending')}
{t('results.placement')}
- {maySee && result && result.placement + {(maySee || mayVerify) && result && result.placement ? getPlacement(result, t) : t('results.pending')}
+ {(maySee || mayVerify) && result && result.vod ? + + {t('results.vod')} + + + : null} + {mayVerify && actions?.verifyResult ? ( + + {t('results.verification')} +
+ +
+
+ ) : null} {maySee && result && result.comment ? {t('results.comment')} @@ -84,6 +124,9 @@ const DetailDialog = ({ }; DetailDialog.propTypes = { + actions: PropTypes.shape({ + verifyResult: PropTypes.func, + }), onHide: PropTypes.func, round: PropTypes.shape({ created_at: PropTypes.string, diff --git a/resources/js/components/results/Item.jsx b/resources/js/components/results/Item.jsx index b3a0b89..730bfeb 100644 --- a/resources/js/components/results/Item.jsx +++ b/resources/js/components/results/Item.jsx @@ -36,6 +36,7 @@ const getVoDIcon = result => { }; const Item = ({ + actions, round, tournament, user, @@ -55,7 +56,7 @@ const Item = ({ return
- + {maySee && result && result.vod ?
- +
; }; Item.propTypes = { + actions: PropTypes.shape({ + }), round: PropTypes.shape({ code: PropTypes.arrayOf(PropTypes.string), created_at: PropTypes.string, diff --git a/resources/js/components/rounds/List.jsx b/resources/js/components/rounds/List.jsx index 5aa2a4c..c4a0293 100644 --- a/resources/js/components/rounds/List.jsx +++ b/resources/js/components/rounds/List.jsx @@ -8,21 +8,22 @@ import LoadMore from './LoadMore'; import i18n from '../../i18n'; const List = ({ - loadMore, + actions, rounds, tournament, }) => rounds && rounds.length ? <>
    {rounds.map(round => )}
- {loadMore ? - + {actions.moreRounds ? + : null} : @@ -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, })), diff --git a/resources/js/components/tournament/Detail.jsx b/resources/js/components/tournament/Detail.jsx index 2407d9b..af339be 100644 --- a/resources/js/components/tournament/Detail.jsx +++ b/resources/js/components/tournament/Detail.jsx @@ -136,7 +136,7 @@ const Detail = ({

{t('groups.heading')}

@@ -151,7 +151,7 @@ const Detail = ({ {tournament.rounds ? diff --git a/resources/js/helpers/permissions.js b/resources/js/helpers/permissions.js index 0c1aaf6..9a004c1 100644 --- a/resources/js/helpers/permissions.js +++ b/resources/js/helpers/permissions.js @@ -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); diff --git a/resources/js/i18n/de.js b/resources/js/i18n/de.js index 6599062..7ccc32e 100644 --- a/resources/js/i18n/de.js +++ b/resources/js/i18n/de.js @@ -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}}', report: 'Ergebnis von <1>{{time}} bei Runde {{number}} eingetragen', + verify: 'Ergebnis in Runde {{number}} von {{runner}} (<2>{{time}}) 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.', }, diff --git a/resources/js/i18n/en.js b/resources/js/i18n/en.js index 7638aa7..8349e24 100644 --- a/resources/js/i18n/en.js +++ b/resources/js/i18n/en.js @@ -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}}', report: 'Result of <1>{{time}} reported for round {{number}}', + verify: 'Verified round {{number}} result of {{runner}} (<2>{{time}})', }, 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.', }, diff --git a/resources/js/pages/Tournament.jsx b/resources/js/pages/Tournament.jsx index 8d0a954..d1ffaae 100644 --- a/resources/js/pages/Tournament.jsx +++ b/resources/js/pages/Tournament.jsx @@ -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) => { diff --git a/routes/api.php b/routes/api.php index 400f678..b599d4c 100644 --- a/routes/api.php +++ b/routes/api.php @@ -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');