From 3a4746607db74ec66bbca8293e393b20c564905b Mon Sep 17 00:00:00 2001 From: Daniel Karbach Date: Sat, 12 Jul 2025 18:17:01 +0200 Subject: [PATCH] step ladder mode subscriptions --- .../DiscordEpisodeSubscriptionsCommand.php | 33 ++--- .../Controllers/DiscordGuildController.php | 13 ++ app/Http/Controllers/EventController.php | 1 - .../Controllers/StepLadderModeController.php | 38 ++++++ app/Models/DiscordGuild.php | 4 + app/Models/DiscordGuildLadderSubscription.php | 38 ++++++ app/Models/Episode.php | 30 +++++ ...scord_guild_ladder_subscriptions_table.php | 28 ++++ .../common/StepLadderModeSelect.jsx | 123 ++++++++++++++++++ .../components/discord-bot/GuildControls.jsx | 55 +++++++- .../discord-bot/LadderSubscriptions.jsx | 58 +++++++++ resources/js/i18n/de.js | 3 + resources/js/i18n/en.js | 3 + routes/api.php | 3 + 14 files changed, 403 insertions(+), 27 deletions(-) create mode 100644 app/Http/Controllers/StepLadderModeController.php create mode 100644 app/Models/DiscordGuildLadderSubscription.php create mode 100644 database/migrations/2025_07_12_130447_create_discord_guild_ladder_subscriptions_table.php create mode 100644 resources/js/components/common/StepLadderModeSelect.jsx create mode 100644 resources/js/components/discord-bot/LadderSubscriptions.jsx diff --git a/app/Console/Commands/DiscordEpisodeSubscriptionsCommand.php b/app/Console/Commands/DiscordEpisodeSubscriptionsCommand.php index 47fdf75..94bb522 100644 --- a/app/Console/Commands/DiscordEpisodeSubscriptionsCommand.php +++ b/app/Console/Commands/DiscordEpisodeSubscriptionsCommand.php @@ -43,20 +43,26 @@ class DiscordEpisodeSubscriptionsCommand extends Command private function handleGuild(DiscordGuild $guild): void { $from = now()->sub(1, 'hour'); + $until = now()->add(14, 'days'); $eventIDs = $guild->event_subscriptions->pluck('event_id')->toArray(); + $modeIDs = $guild->ladder_subscriptions->pluck('step_ladder_mode_id')->toArray(); $userIDs = $guild->user_subscriptions->pluck('user_id')->toArray(); - if (empty($eventIDs) && empty($userIDs)) { + if (empty($eventIDs) && empty($modeIDs) && empty($userIDs)) { return; } $query = Episode::with(['channels', 'event', 'players']) ->where('episodes.start', '>', $from) + ->where('episodes.start', '<', $until) ->orderBy('episodes.start', 'ASC') ->limit(20); - $query->where(function ($subquery) use ($eventIDs, $userIDs) { + $query->where(function ($subquery) use ($eventIDs, $modeIDs, $userIDs) { if (!empty($eventIDs)) { $subquery->whereIn('episodes.event_id', $eventIDs); } + if (!empty($modeIDs)) { + $subquery->orWhereIn('episodes.step_ladder_mode_id', $modeIDs); + } if (!empty($userIDs)) { $subquery->orWhereHas('players', function ($builder) use ($userIDs) { $builder->whereIn('episode_players.user_id', $userIDs); @@ -70,28 +76,7 @@ class DiscordEpisodeSubscriptionsCommand extends Command } private function handleEpisode(DiscordGuild $guild, Episode $episode): void { - $mtime = $episode->updated_at; - foreach ($episode->channels as $channel) { - if ($mtime < $channel->updated_at) { - $mtime = $channel->updated_at; - } - } - foreach ($episode->confirmedCrew() as $crew) { - if ($mtime < $crew->updated_at) { - $mtime = $crew->updated_at; - } - if ($crew->user && $mtime < $crew->user->updated_at) { - $mtime = $crew->user->updated_at; - } - } - foreach ($episode->players as $player) { - if ($mtime < $player->updated_at) { - $mtime = $player->updated_at; - if ($player->user && $mtime < $player->user->updated_at) { - $mtime = $crew->user->updated_at; - } - } - } + $mtime = $episode->getMTime(); $memo = $this->getMemo($guild, $episode); if ((is_null($memo->synced_at) && $episode->start > now()) || (!is_null($memo->synced_at) && $memo->synced_at < $mtime)) { $this->line('pushing '.$episode->id.' '.$episode->getScheduledEventName()); diff --git a/app/Http/Controllers/DiscordGuildController.php b/app/Http/Controllers/DiscordGuildController.php index c2a5691..979c675 100644 --- a/app/Http/Controllers/DiscordGuildController.php +++ b/app/Http/Controllers/DiscordGuildController.php @@ -69,6 +69,8 @@ class DiscordGuildController extends Controller $guild = DiscordGuild::with([ 'event_subscriptions', 'event_subscriptions.event', + 'ladder_subscriptions', + 'ladder_subscriptions.step_ladder_mode', 'user_subscriptions', 'user_subscriptions.user', ])->where('guild_id', '=', $guild_id)->firstOrFail(); @@ -82,14 +84,19 @@ class DiscordGuildController extends Controller $validatedData = $request->validate([ 'add_event' => 'numeric|exists:App\Models\Event,id', + 'add_ladder' => 'numeric|exists:App\Models\StepLadderMode,id', 'add_user' => 'numeric|exists:App\Models\User,id', 'remove_event' => 'numeric|exists:App\Models\Event,id', + 'remove_ladder' => 'numeric|exists:App\Models\StepLadderMode,id', 'remove_user' => 'numeric|exists:App\Models\User,id', ]); if (isset($validatedData['add_event'])) { $guild->event_subscriptions()->create(['event_id' => $validatedData['add_event']]); } + if (isset($validatedData['add_ladder'])) { + $guild->ladder_subscriptions()->create(['step_ladder_mode_id' => $validatedData['add_ladder']]); + } if (isset($validatedData['add_user'])) { $guild->user_subscriptions()->create(['user_id' => $validatedData['add_user']]); } @@ -97,6 +104,10 @@ class DiscordGuildController extends Controller $sub = $guild->event_subscriptions()->where('event_id', '=', $validatedData['remove_event'])->firstOrFail(); $sub->delete(); } + if (isset($validatedData['remove_ladder'])) { + $sub = $guild->ladder_subscriptions()->where('step_ladder_mode_id', '=', $validatedData['remove_ladder'])->firstOrFail(); + $sub->delete(); + } if (isset($validatedData['remove_user'])) { $sub = $guild->user_subscriptions()->where('user_id', '=', $validatedData['remove_user'])->firstOrFail(); $sub->delete(); @@ -104,6 +115,8 @@ class DiscordGuildController extends Controller $guild->load([ 'event_subscriptions', 'event_subscriptions.event', + 'ladder_subscriptions', + 'ladder_subscriptions.step_ladder_mode', 'user_subscriptions', 'user_subscriptions.user', ]); diff --git a/app/Http/Controllers/EventController.php b/app/Http/Controllers/EventController.php index 9d42b32..f7fd1ce 100644 --- a/app/Http/Controllers/EventController.php +++ b/app/Http/Controllers/EventController.php @@ -3,7 +3,6 @@ namespace App\Http\Controllers; use App\Models\Event; -use Carbon\Carbon; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Http\Request; diff --git a/app/Http/Controllers/StepLadderModeController.php b/app/Http/Controllers/StepLadderModeController.php new file mode 100644 index 0000000..3f9121d --- /dev/null +++ b/app/Http/Controllers/StepLadderModeController.php @@ -0,0 +1,38 @@ +validate([ + 'exclude_ids' => 'array|nullable', + 'exclude_ids.*' => 'int', + 'limit' => 'nullable|int', + 'phrase' => 'nullable|string', + ]); + $modes = StepLadderMode::query(); + if (!empty($validatedData['exclude_ids'])) { + $modes->whereNotIn('id', $validatedData['exclude_ids']); + } + if (isset($validatedData['limit'])) { + $modes->limit($validatedData['limit']); + } + if (isset($validatedData['phrase'])) { + $modes->where('name', 'LIKE', '%'.$validatedData['phrase'].'%'); + } + $modes->orderBy('name'); + return $modes->get()->toJson(); + } + + public function single(Request $request, StepLadderMode $mode) { + $mode->load('description'); + return $mode->toJson(); + } + +} diff --git a/app/Models/DiscordGuild.php b/app/Models/DiscordGuild.php index f1821db..c210bab 100644 --- a/app/Models/DiscordGuild.php +++ b/app/Models/DiscordGuild.php @@ -60,6 +60,10 @@ class DiscordGuild extends Model return $this->hasMany(DiscordGuildEventSubscription::class); } + public function ladder_subscriptions() { + return $this->hasMany(DiscordGuildLadderSubscription::class); + } + public function roles() { return $this->hasMany(DiscordRole::class)->orderBy('position'); } diff --git a/app/Models/DiscordGuildLadderSubscription.php b/app/Models/DiscordGuildLadderSubscription.php new file mode 100644 index 0000000..7ef16ad --- /dev/null +++ b/app/Models/DiscordGuildLadderSubscription.php @@ -0,0 +1,38 @@ +discord_guild_id) { + $channels[] = new PrivateChannel('DiscordGuild.'.$this->discord_guild_id); + } + return $channels; + } + + public function guild() { + return $this->belongsTo(DiscordGuild::class); + } + + public function step_ladder_mode() { + return $this->belongsTo(StepLadderMode::class); + } + + protected $fillable = [ + 'discord_guild_id', + 'step_ladder_mode_id', + ]; + + protected $with = [ + 'step_ladder_mode', + ]; + +} diff --git a/app/Models/Episode.php b/app/Models/Episode.php index df7c802..6ca4e0b 100644 --- a/app/Models/Episode.php +++ b/app/Models/Episode.php @@ -4,6 +4,7 @@ namespace App\Models; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Str; class Episode extends Model { @@ -44,6 +45,32 @@ class Episode extends Model return $this->belongsTo(StepLadderMode::class); } + public function getMTime() { + $mtime = $this->updated_at; + foreach ($this->channels as $channel) { + if ($mtime < $channel->updated_at) { + $mtime = $channel->updated_at; + } + } + foreach ($this->confirmedCrew as $crew) { + if ($mtime < $crew->updated_at) { + $mtime = $crew->updated_at; + } + if ($crew->user && $mtime < $crew->user->updated_at) { + $mtime = $crew->user->updated_at; + } + } + foreach ($this->players as $player) { + if ($mtime < $player->updated_at) { + $mtime = $player->updated_at; + if ($player->user && $mtime < $player->user->updated_at) { + $mtime = $crew->user->updated_at; + } + } + } + return $mtime; + } + public function getScheduledEventName(): string { $parts = []; if (count($this->players) == 4) { @@ -105,6 +132,9 @@ class Episode extends Model } } } + if ($this->raceroom) { + $description .= "\nRaceroom: [".Str::afterLast($this->raceroom, '/').']('.$this->raceroom.")\n"; + } return $description; } diff --git a/database/migrations/2025_07_12_130447_create_discord_guild_ladder_subscriptions_table.php b/database/migrations/2025_07_12_130447_create_discord_guild_ladder_subscriptions_table.php new file mode 100644 index 0000000..629da7b --- /dev/null +++ b/database/migrations/2025_07_12_130447_create_discord_guild_ladder_subscriptions_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('discord_guild_id')->constrained(); + $table->foreignId('step_ladder_mode_id')->constrained(); + $table->timestamps(); + $table->unique(['discord_guild_id', 'step_ladder_mode_id'], 'guild_mode_unique'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void { + Schema::dropIfExists('discord_guild_ladder_subscriptions'); + } +}; diff --git a/resources/js/components/common/StepLadderModeSelect.jsx b/resources/js/components/common/StepLadderModeSelect.jsx new file mode 100644 index 0000000..4e2aa1b --- /dev/null +++ b/resources/js/components/common/StepLadderModeSelect.jsx @@ -0,0 +1,123 @@ +import axios from 'axios'; +import PropTypes from 'prop-types'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { Button, Form, ListGroup } from 'react-bootstrap'; + +import Icon from './Icon'; +import debounce from '../../helpers/debounce'; + +const StepLadderModeSelect = ({ excludeIds = [], name, onChange, value }) => { + const [resolved, setResolved] = useState(null); + const [results, setResults] = useState([]); + const [search, setSearch] = useState(''); + const [showResults, setShowResults] = useState(false); + + const ref = useRef(null); + + useEffect(() => { + const handleEventOutside = e => { + if (ref.current && !ref.current.contains(e.target)) { + setShowResults(false); + } + }; + document.addEventListener('mousedown', handleEventOutside, true); + document.addEventListener('focus', handleEventOutside, true); + return () => { + document.removeEventListener('mousedown', handleEventOutside, true); + document.removeEventListener('focus', handleEventOutside, true); + }; + }, []); + + let ctrl = null; + const fetch = useCallback(debounce(async phrase => { + if (ctrl) { + ctrl.abort(); + } + ctrl = new AbortController(); + try { + const response = await axios.get(`/api/step-ladder-modes`, { + params: { + exclude_ids: excludeIds, + limit: 5, + phrase, + }, + signal: ctrl.signal, + }); + ctrl = null; + setResults(response.data); + if (phrase) { + setShowResults(true); + } + } catch (e) { + ctrl = null; + console.error(e); + } + }, 300), [excludeIds]); + + useEffect(() => { + fetch(search); + }, [search]); + + useEffect(() => { + if (value) { + axios + .get(`/api/step-ladder-modes/${value}`) + .then(response => { + setResolved(response.data); + }); + } else { + setResolved(null); + } + }, [value]); + + if (value) { + return
+ {resolved ? {resolved.name} ({resolved.ext_id.substr(11)}) : value} + +
; + } + return
+ setSearch(e.target.value)} + onFocus={() => setShowResults(true)} + type="search" + value={search} + /> +
+ + {results.map(result => + { + onChange({ + target: { name, value: result.id }, + }); + setSearch(''); + setShowResults(false); + }} + > + {result.name} ({result.ext_id.substr(11)}) + + )} + +
+
; +}; + +StepLadderModeSelect.propTypes = { + excludeIds: PropTypes.arrayOf(PropTypes.number), + name: PropTypes.string, + onChange: PropTypes.func, + value: PropTypes.string, +}; + +export default StepLadderModeSelect; diff --git a/resources/js/components/discord-bot/GuildControls.jsx b/resources/js/components/discord-bot/GuildControls.jsx index e4a35c6..499d77d 100644 --- a/resources/js/components/discord-bot/GuildControls.jsx +++ b/resources/js/components/discord-bot/GuildControls.jsx @@ -8,6 +8,7 @@ import toastr from 'toastr'; import EventSubscriptions from './EventSubscriptions'; import GuildCrew from './GuildCrew'; import GuildProtocol from './GuildProtocol'; +import LadderSubscriptions from './LadderSubscriptions'; import UserSubscriptions from './UserSubscriptions'; import ErrorBoundary from '../common/ErrorBoundary'; import { compareTitle } from '../../helpers/Event'; @@ -74,6 +75,27 @@ const GuildControls = ({ guild, patchGuild }) => { } }, [guild.guild_id, patchGuild, t]); + const addLadderSub = React.useCallback(async (mode_id) => { + try { + const response = await axios.post(`/api/discord-guilds/${guild.guild_id}/subscriptions`, { + add_ladder: mode_id, + }); + patchGuild(response.data); + } catch (error) { + toastr.error(t('discordBot.ladderSubError', { error })); + } + }, [guild.guild_id, patchGuild, t]); + + const removeLadderSub = React.useCallback(async (mode_id) => { + try { + const response = await axios.post(`/api/discord-guilds/${guild.guild_id}/subscriptions`, { + remove_ladder: mode_id, + }); + patchGuild(response.data); + } catch (error) { + toastr.error(t('discordBot.ladderUnsubError', { error })); + } + }, [guild.guild_id, patchGuild, t]); const addUserSub = React.useCallback(async (user_id) => { try { @@ -156,6 +178,21 @@ const GuildControls = ({ guild, patchGuild }) => { patchGuild(g => ({ ...g, event_subscriptions: (g.event_subscriptions || []).filter(c => c.id !== e.model.id) })); } }) + .listen('.DiscordGuildLadderSubscriptionCreated', e => { + if (e.model) { + patchGuild(g => ({ ...g, ladder_subscriptions: [...g.ladder_subscriptions || [], e.model] })); + } + }) + .listen('.DiscordGuildLadderSubscriptionUpdated', e => { + if (e.model) { + patchGuild(g => ({ ...g, ladder_subscriptions: (g.ladder_subscriptions || []).map(c => c.id === e.model.id ? { ...c, ...e.model } : c) })); + } + }) + .listen('.DiscordGuildLadderSubscriptionDeleted', e => { + if (e.model) { + patchGuild(g => ({ ...g, ladder_subscriptions: (g.ladder_subscriptions || []).filter(c => c.id !== e.model.id) })); + } + }) .listen('.DiscordGuildUserSubscriptionCreated', e => { if (e.model) { patchGuild(g => ({ ...g, user_subscriptions: [...g.user_subscriptions || [], e.model] })); @@ -203,7 +240,7 @@ const GuildControls = ({ guild, patchGuild }) => { - +

{t('discordBot.eventSubscriptions')}

{t('discordBot.eventSubscriptionDescription')}

@@ -215,7 +252,7 @@ const GuildControls = ({ guild, patchGuild }) => { /> - +

{t('discordBot.userSubscriptions')}

{t('discordBot.userSubscriptionDescription')}

@@ -227,6 +264,18 @@ const GuildControls = ({ guild, patchGuild }) => { /> + +

{t('discordBot.ladderSubscriptions')}

+

{t('discordBot.ladderSubscriptionDescription')}

+ + + +
@@ -244,6 +293,8 @@ GuildControls.propTypes = { })), id: PropTypes.number, guild_id: PropTypes.string, + ladder_subscriptions: PropTypes.arrayOf(PropTypes.shape({ + })), user_subscriptions: PropTypes.arrayOf(PropTypes.shape({ })), }), diff --git a/resources/js/components/discord-bot/LadderSubscriptions.jsx b/resources/js/components/discord-bot/LadderSubscriptions.jsx new file mode 100644 index 0000000..65bea33 --- /dev/null +++ b/resources/js/components/discord-bot/LadderSubscriptions.jsx @@ -0,0 +1,58 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { Button, Form } from 'react-bootstrap'; +import { useTranslation } from 'react-i18next'; + +import StepLadderModeSelect from '../common/StepLadderModeSelect'; +import Icon from '../common/Icon'; +import { mayManageGuild } from '../../helpers/permissions'; +import { useUser } from '../../hooks/user'; + +const LadderSubscriptions = ({ addMode, guild, removeMode, subs }) => { + const { t } = useTranslation(); + const { user } = useUser(); + + const mayManage = React.useMemo(() => mayManageGuild(user, guild), [guild, user]); + + return
+ {mayManage ? + + {t('discordBot.addLadderMode')} + s.step_ladder_mode_id)} + onChange={e => addMode(e.target.value)} + value="" + /> + + : null} + {subs.map((lsub) => ( +
+
{lsub.step_ladder_mode.name} ({lsub.step_ladder_mode.ext_id.substr(11)})
+ {mayManage ? +
+ +
+ : null} +
+ ))} +
; +}; + +LadderSubscriptions.propTypes = { + addMode: PropTypes.func, + guild: PropTypes.shape({ + }), + removeMode: PropTypes.func, + subs: PropTypes.arrayOf(PropTypes.shape({ + })), +}; + +export default LadderSubscriptions; diff --git a/resources/js/i18n/de.js b/resources/js/i18n/de.js index 89d4d66..21296fe 100644 --- a/resources/js/i18n/de.js +++ b/resources/js/i18n/de.js @@ -140,6 +140,7 @@ export default { discordBot: { addCrew: 'User hinzufügen', addEvent: 'Event abonnieren', + addLadderMode: 'Ladder Mode abonnieren', addUser: 'User abonnieren', channel: 'Kanal', channelControls: 'Kanal-Steuerung', @@ -179,6 +180,8 @@ export default { guildProtocol: 'Command Protokoll', heading: 'Discord Bot', invite: 'Bot einladen', + ladderSubscriptionDescription: 'Races abonnierter Ladder Modi werden als Discord Event angelegt. Einige der Einträge tauchen von Ladderseite aus mehrfach auf. In dem Fall bitte alle Doubletten hinzufügen, sonst wird möglicherweise nur jedes 2. oder 3. Race erkannt.', + ladderSubscriptions: 'Abonnierte Ladder Modi', message: 'Nachricht', messageError: 'Fehler beim Senden', messageSuccess: 'Nachricht in Warteschlange', diff --git a/resources/js/i18n/en.js b/resources/js/i18n/en.js index 9f28396..1b50469 100644 --- a/resources/js/i18n/en.js +++ b/resources/js/i18n/en.js @@ -140,6 +140,7 @@ export default { discordBot: { addCrew: 'Add user', addEvent: 'Subscribe to event', + addLadderMode: 'Subscribe to ladder mode', addUser: 'Subscribe to user', channel: 'Channel', channelControls: 'Channel controls', @@ -179,6 +180,8 @@ export default { guildProtocol: 'Command protocol', heading: 'Discord Bot', invite: 'Invite bot', + ladderSubscriptionDescription: 'Races of subscribed modes will be added as Discord events. Some of the modes have multiple entries on ladder. Please select all dupes, otherwise you may be missing every other or third race in some cases.', + ladderSubscriptions: 'Ladder mode subscriptions', message: 'Message', messageError: 'Error sending message', messageSuccess: 'Message queued', diff --git a/routes/api.php b/routes/api.php index 7c4d0ea..90fefbf 100644 --- a/routes/api.php +++ b/routes/api.php @@ -85,6 +85,9 @@ Route::post('rounds/{round}/lock', 'App\Http\Controllers\RoundController@lock'); Route::post('rounds/{round}/setSeed', 'App\Http\Controllers\RoundController@setSeed'); Route::post('rounds/{round}/unlock', 'App\Http\Controllers\RoundController@unlock'); +Route::get('step-ladder-modes', 'App\Http\Controllers\StepLadderModeController@search'); +Route::get('step-ladder-modes/{mode}', 'App\Http\Controllers\StepLadderModeController@single'); + Route::get('tech', 'App\Http\Controllers\TechniqueController@search'); Route::get('tech/{tech:name}', 'App\Http\Controllers\TechniqueController@single'); -- 2.39.5