]> git.localhorst.tv Git - alttp.git/commitdiff
discord bot crew and description
authorDaniel Karbach <daniel.karbach@localhorst.tv>
Tue, 8 Jul 2025 10:48:03 +0000 (12:48 +0200)
committerDaniel Karbach <daniel.karbach@localhorst.tv>
Tue, 8 Jul 2025 10:48:03 +0000 (12:48 +0200)
22 files changed:
app/Http/Controllers/DiscordBotController.php
app/Http/Controllers/DiscordGuildController.php
app/Http/Controllers/UserController.php
app/Models/DiscordBotCommand.php
app/Models/DiscordGuild.php
app/Models/DiscordGuildCrew.php [new file with mode: 0644]
app/Models/DiscordGuildEventSubscription.php
app/Models/DiscordGuildUserSubscription.php
app/Policies/DiscordGuildPolicy.php
database/migrations/2025_07_07_113659_create_discord_guild_crews_table.php [new file with mode: 0644]
resources/js/components/discord-bot/Controls.jsx
resources/js/components/discord-bot/EventSubscriptions.jsx
resources/js/components/discord-bot/GuildControls.jsx
resources/js/components/discord-bot/GuildCrew.jsx [new file with mode: 0644]
resources/js/components/discord-bot/GuildProtocol.jsx
resources/js/components/discord-bot/UserSubscriptions.jsx
resources/js/helpers/permissions.js
resources/js/i18n/de.js
resources/js/i18n/en.js
resources/js/pages/DiscordBot.jsx
routes/api.php
routes/channels.php

index 4e415a4b0241b242819921931afdfb8301bb3c25..ed1150caca7698c8a37ba74bd52ca42727c2217f 100644 (file)
@@ -11,8 +11,8 @@ class DiscordBotController extends Controller
 {
 
        public function recentCommands(DiscordGuild $guild) {
-               $this->authorize('manage', $guild);
-               return $guild->bot_commands()->limit(10)->get();
+               $this->authorize('view', $guild);
+               return $guild->bot_commands()->with('user')->limit(10)->get();
        }
 
        public function sendMessage(Request $request, DiscordGuild $guild) {
@@ -23,7 +23,7 @@ class DiscordBotController extends Controller
                ]);
                $channel = DiscordChannel::findOrFail($validatedData['channel']);
                $this->authorize('manage', $channel->guild);
-               $cmd = DiscordBotCommand::sendMessage($channel, $validatedData['text']);
+               $cmd = DiscordBotCommand::sendMessage($channel, $validatedData['text'], $request->user());
                return $cmd->toJson();
        }
 
index 6c1e2213a15fecc70a46fb188c4c783f30cfb86c..c2a5691d1f37fb487103a52f994977561c58c29b 100644 (file)
@@ -12,15 +12,21 @@ class DiscordGuildController extends Controller
                $validatedData = $request->validate([
                        'phrase' => 'string|nullable',
                ]);
+               $user_id = $request->user()->id;
 
                $guilds = DiscordGuild::query();
                if (!$request->user()->can('viewAny', DiscordGuild::class)) {
-                       $guilds = $guilds->where('owner', '=', $request->user()->id);
+                       $guilds->where(function ($query) use ($user_id) {
+                               $query->where('owner', '=', $user_id);
+                               $query->orWhereHas('crew', function ($builder) use ($user_id) {
+                                       $builder->where('user_id', '=', $user_id);
+                               });
+                       });
                }
                if (!empty($validatedData['phrase'])) {
-                       $guilds = $guilds->where('name', 'LIKE', '%'.$validatedData['phrase'].'%');
+                       $guilds->where('name', 'LIKE', '%'.$validatedData['phrase'].'%');
                }
-               $guilds = $guilds->limit(5);
+               $guilds->limit(5);
                return $guilds->get()->toJson();
        }
 
@@ -30,6 +36,35 @@ class DiscordGuildController extends Controller
                return $guild->toJson();
        }
 
+       public function manageCrew(Request $request, $guild_id) {
+               $guild = DiscordGuild::where('guild_id', '=', $guild_id)->firstOrFail();
+               $this->authorize('administer', $guild);
+
+               $validatedData = $request->validate([
+                       'add_user' => 'numeric|exists:App\Models\User,id',
+                       'modify_user' => 'numeric|exists:App\Models\User,id',
+                       'remove_user' => 'numeric|exists:App\Models\User,id',
+                       'role' => 'string|in:admin,manager,member',
+               ]);
+
+               if (isset($validatedData['add_user'])) {
+                       $guild->crew()->create([
+                               'user_id' => $validatedData['add_user'],
+                       ]);
+               }
+               if (isset($validatedData['modify_user'], $validatedData['role'])) {
+                       $crew = $guild->crew()->where('user_id', '=', $validatedData['modify_user'])->firstOrFail();
+                       $crew->role = $validatedData['role'];
+                       $crew->save();
+               }
+               if (isset($validatedData['remove_user'])) {
+                       $crew = $guild->crew()->where('user_id', '=', $validatedData['remove_user'])->firstOrFail();
+                       $crew->delete();
+               }
+
+               return $guild->fresh()->toJson();
+       }
+
        public function subscriptions(Request $request, $guild_id) {
                $guild = DiscordGuild::with([
                        'event_subscriptions',
@@ -37,7 +72,7 @@ class DiscordGuildController extends Controller
                        'user_subscriptions',
                        'user_subscriptions.user',
                ])->where('guild_id', '=', $guild_id)->firstOrFail();
-               $this->authorize('manage', $guild);
+               $this->authorize('view', $guild);
                return $guild->toJson();
        }
 
@@ -59,10 +94,12 @@ class DiscordGuildController extends Controller
                        $guild->user_subscriptions()->create(['user_id' => $validatedData['add_user']]);
                }
                if (isset($validatedData['remove_event'])) {
-                       $guild->event_subscriptions()->where('event_id', '=', $validatedData['remove_event'])->delete();
+                       $sub = $guild->event_subscriptions()->where('event_id', '=', $validatedData['remove_event'])->firstOrFail();
+                       $sub->delete();
                }
                if (isset($validatedData['remove_user'])) {
-                       $guild->user_subscriptions()->where('user_id', '=', $validatedData['remove_user'])->delete();
+                       $sub = $guild->user_subscriptions()->where('user_id', '=', $validatedData['remove_user'])->firstOrFail();
+                       $sub->delete();
                }
                $guild->load([
                        'event_subscriptions',
index f8b42831d21a465ac9190059364a32847dbd7503..5cbba5385afbc2db896c7d8d50c057e487dfbd62 100644 (file)
@@ -21,8 +21,10 @@ class UserController extends Controller
                        $users->whereNotIn('id', $validatedData['exclude_ids']);
                }
                if (!empty($validatedData['phrase'])) {
-                       $users->where('username', 'LIKE', '%'.$validatedData['phrase'].'%')
-                               ->orWhere('nickname', 'LIKE', '%'.$validatedData['phrase'].'%');
+                       $users->where(function ($query) use ($validatedData) {
+                               $query->where('username', 'LIKE', '%'.$validatedData['phrase'].'%');
+                               $query->orWhere('nickname', 'LIKE', '%'.$validatedData['phrase'].'%');
+                       });
                }
                $users = $users->limit(5);
                return $users->get()->toJson();
index b3670637dda4241b16a4f318116ff69db80a898e..ef3f5ea5b9e06cad2b6b64fc7dfea97325a06a50 100644 (file)
@@ -22,6 +22,10 @@ class DiscordBotCommand extends Model {
                return $channels;
        }
 
+       public function broadcastWith($event) {
+               $this->load(['user']);
+       }
+
        public static function episodeEvent(DiscordGuild $guild, Episode $episode): DiscordBotCommand {
                $cmd = new DiscordBotCommand();
                $cmd->discord_guild()->associate($guild);
index 37e711bd3f757f14771bf605a5e8612b3182edcb..f1821db6bee5b7dfe12528e0b6b4e056923a85a8 100644 (file)
@@ -52,6 +52,10 @@ class DiscordGuild extends Model
                return $this->hasMany(DiscordChannel::class)->orderBy('position');
        }
 
+       public function crew() {
+               return $this->hasMany(DiscordGuildCrew::class);
+       }
+
        public function event_subscriptions() {
                return $this->hasMany(DiscordGuildEventSubscription::class);
        }
@@ -68,4 +72,9 @@ class DiscordGuild extends Model
                'guild_id',
        ];
 
+       protected $with = [
+               'crew',
+               'crew.user',
+       ];
+
 }
diff --git a/app/Models/DiscordGuildCrew.php b/app/Models/DiscordGuildCrew.php
new file mode 100644 (file)
index 0000000..08aec61
--- /dev/null
@@ -0,0 +1,42 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Broadcasting\PrivateChannel;
+use Illuminate\Database\Eloquent\BroadcastsEvents;
+use Illuminate\Database\Eloquent\Model;
+
+class DiscordGuildCrew extends Model {
+
+       use BroadcastsEvents;
+
+       public function broadcastOn(string $event): array {
+               $channels = [];
+               if ($this->discord_guild_id) {
+                       $channels[] = new PrivateChannel('DiscordGuild.'.$this->discord_guild_id);
+               }
+               return $channels;
+       }
+
+       public function guild() {
+               return $this->belongsTo(DiscordGuild::class);
+       }
+
+       public function user() {
+               return $this->belongsTo(User::class);
+       }
+
+       protected $casts = [
+               'user_id' => 'string',
+       ];
+
+       protected $fillable = [
+               'discord_guild_id',
+               'user_id',
+       ];
+
+       protected $with = [
+               'user',
+       ];
+
+}
index af73e9f382b80c364e6e0a2797cb8b30b6cd1b8c..c80ef3a8966400cdb2a6edb19991b420219f0fe8 100644 (file)
@@ -31,4 +31,8 @@ class DiscordGuildEventSubscription extends Model {
                'event_id',
        ];
 
+       protected $with = [
+               'event',
+       ];
+
 }
index fa9811a3ef7582ce075a12d3af8ff83e917cb9b8..df686a99a98fc50d239943a493f246e4f339636e 100644 (file)
@@ -26,13 +26,17 @@ class DiscordGuildUserSubscription extends Model {
                return $this->belongsTo(User::class);
        }
 
+       protected $casts = [
+               'user_id' => 'string',
+       ];
+
        protected $fillable = [
                'discord_guild_id',
                'user_id',
        ];
 
-       protected $casts = [
-               'user_id' => 'string',
+       protected $with = [
+               'user',
        ];
 
 }
index 285138ce5cd7fecfa8345c58e7887e6dc25f0ecd..0f0a4634224e86ed6ff9e93684bb16aabfec289c 100644 (file)
@@ -16,9 +16,8 @@ class DiscordGuildPolicy
         * @param  \App\Models\User  $user
         * @return \Illuminate\Auth\Access\Response|bool
         */
-       public function viewAny(User $user)
-       {
-               return $user->isAdmin();
+       public function viewAny(User $user): bool {
+               return false;
        }
 
        /**
@@ -28,9 +27,16 @@ class DiscordGuildPolicy
         * @param  \App\Models\DiscordGuild  $discordGuild
         * @return \Illuminate\Auth\Access\Response|bool
         */
-       public function view(User $user, DiscordGuild $discordGuild)
-       {
-               return $user->isAdmin() || $discordGuild->owner == $user->id;
+       public function view(User $user, DiscordGuild $discordGuild): bool {
+               if ($discordGuild->owner == $user->id) {
+                       return true;
+               }
+               foreach ($discordGuild->crew as $crew) {
+                       if ($crew->user_id == $user->id) {
+                               return true;
+                       }
+               }
+               return false;
        }
 
        /**
@@ -99,9 +105,37 @@ class DiscordGuildPolicy
         * @param  \App\Models\DiscordGuild  $discordGuild
         * @return \Illuminate\Auth\Access\Response|bool
         */
+       public function administer(User $user, DiscordGuild $discordGuild)
+       {
+               if ($discordGuild->owner == $user->id) {
+                       return true;
+               }
+               foreach ($discordGuild->crew as $crew) {
+                       if ($crew->user_id == $user->id && $crew->role == 'admin') {
+                               return true;
+                       }
+               }
+               return false;
+       }
+
+       /**
+        * Determine whether the user can perform management tasks for the guild.
+        *
+        * @param  \App\Models\User  $user
+        * @param  \App\Models\DiscordGuild  $discordGuild
+        * @return \Illuminate\Auth\Access\Response|bool
+        */
        public function manage(User $user, DiscordGuild $discordGuild)
        {
-               return $user->isAdmin() || $discordGuild->owner == $user->id;
+               if ($discordGuild->owner == $user->id) {
+                       return true;
+               }
+               foreach ($discordGuild->crew as $crew) {
+                       if ($crew->user_id == $user->id && in_array($crew->role, ['admin', 'manager'])) {
+                               return true;
+                       }
+               }
+               return false;
        }
 
 }
diff --git a/database/migrations/2025_07_07_113659_create_discord_guild_crews_table.php b/database/migrations/2025_07_07_113659_create_discord_guild_crews_table.php
new file mode 100644 (file)
index 0000000..067c4e2
--- /dev/null
@@ -0,0 +1,29 @@
+<?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::create('discord_guild_crews', function (Blueprint $table) {
+                       $table->id();
+                       $table->foreignId('discord_guild_id')->constrained();
+                       $table->foreignId('user_id')->constrained();
+                       $table->string('role')->default('member');
+                       $table->timestamps();
+                       $table->unique(['discord_guild_id', 'user_id'], 'guild_user_unique');
+               });
+       }
+
+       /**
+        * Reverse the migrations.
+        */
+       public function down(): void {
+               Schema::dropIfExists('discord_guild_crews');
+       }
+};
index 6a4580dd834c9fd1008012a62070a4aaec8a3aa8..bbe46d6a5986b958049cab4af526fd2ebd599e48 100644 (file)
@@ -14,6 +14,14 @@ const Controls = () => {
 
        const { t } = useTranslation();
 
+       const patchGuild = React.useCallback((incoming) => {
+               if (incoming instanceof Function) {
+                       setGuild(incoming);
+               } else {
+                       setGuild((existing) => ({ ...existing, ...incoming }));
+               }
+       }, []);
+
        return <>
                <Row>
                        <Form.Group as={Col} md={6}>
@@ -46,7 +54,7 @@ const Controls = () => {
                : null}
                {guild ?
                        <ErrorBoundary>
-                               <GuildControls guild={guild} />
+                               <GuildControls guild={guild} patchGuild={patchGuild} />
                        </ErrorBoundary>
                : null}
        </>;
index 0fe140d29552a58888c2c325576cfcb0b83fa685..2108c9f3fbf9dc09415eefc2be464f5750ddb1d6 100644 (file)
@@ -5,33 +5,42 @@ import { useTranslation } from 'react-i18next';
 
 import EventSelect from '../common/EventSelect';
 import Icon from '../common/Icon';
+import { mayManageGuild } from '../../helpers/permissions';
+import { useUser } from '../../hooks/user';
 
-const EventSubscriptions = ({ addEvent, removeEvent, subs }) => {
+const EventSubscriptions = ({ addEvent, guild, removeEvent, subs }) => {
        const { t } = useTranslation();
+       const { user } = useUser();
+
+       const mayManage = React.useMemo(() => mayManageGuild(user, guild), [guild, user]);
 
        return <div>
-               <Form.Group controlId="esubs.addEvent">
-                       <Form.Label>{t('discordBot.addEvent')}</Form.Label>
-                       <Form.Control
-                               as={EventSelect}
-                               excludeIds={subs.map(s => s.event_id)}
-                               onChange={e => addEvent(e.target.value)}
-                               value=""
-                       />
-               </Form.Group>
+               {mayManage ?
+                       <Form.Group controlId="esubs.addEvent">
+                               <Form.Label>{t('discordBot.addEvent')}</Form.Label>
+                               <Form.Control
+                                       as={EventSelect}
+                                       excludeIds={subs.map(s => s.event_id)}
+                                       onChange={e => addEvent(e.target.value)}
+                                       value=""
+                               />
+                       </Form.Group>
+               : null}
                {subs.map((esub) => (
                        <div className="d-flex align-items-center justify-content-between my-2" key={esub.id}>
                                <div>{esub.event.title}</div>
-                               <div className="button-bar">
-                                       <Button
-                                               onClick={() => removeEvent(esub.event_id)}
-                                               size="sm"
-                                               title={t('button.remove')}
-                                               variant="outline-danger"
-                                       >
-                                               <Icon.DELETE title="" />
-                                       </Button>
-                               </div>
+                               {mayManage ?
+                                       <div className="button-bar">
+                                               <Button
+                                                       onClick={() => removeEvent(esub.event_id)}
+                                                       size="sm"
+                                                       title={t('button.remove')}
+                                                       variant="outline-danger"
+                                               >
+                                                       <Icon.DELETE title="" />
+                                               </Button>
+                                       </div>
+                               : null}
                        </div>
                ))}
        </div>;
@@ -39,6 +48,8 @@ const EventSubscriptions = ({ addEvent, removeEvent, subs }) => {
 
 EventSubscriptions.propTypes = {
        addEvent: PropTypes.func,
+       guild: PropTypes.shape({
+       }),
        removeEvent: PropTypes.func,
        subs: PropTypes.arrayOf(PropTypes.shape({
        })),
index 02b563b67eecfb174fccf1b4620b8b84c7eb1379..e4a35c65fb518793799b022bb38dc545f4e213a9 100644 (file)
@@ -6,39 +6,73 @@ import { useTranslation } from 'react-i18next';
 import toastr from 'toastr';
 
 import EventSubscriptions from './EventSubscriptions';
+import GuildCrew from './GuildCrew';
 import GuildProtocol from './GuildProtocol';
 import UserSubscriptions from './UserSubscriptions';
 import ErrorBoundary from '../common/ErrorBoundary';
 import { compareTitle } from '../../helpers/Event';
 import { compareUsername } from '../../helpers/User';
 
-const GuildControls = ({ guild }) => {
+const GuildControls = ({ guild, patchGuild }) => {
        const [protocol, setProtocol] = React.useState([]);
-       const [subscriptions, setSubscriptions] = React.useState({});
 
        const { t } = useTranslation();
 
+       const addCrew = React.useCallback(async (user_id) => {
+               try {
+                       const response = await axios.post(`/api/discord-guilds/${guild.guild_id}/crew`, {
+                               add_user: user_id,
+                       });
+                       patchGuild(response.data);
+               } catch (error) {
+                       toastr.error(t('discordBot.crewAddError', { error }));
+               }
+       }, [guild.guild_id, patchGuild, t]);
+
+       const changeCrewRole = React.useCallback(async (user_id, role) => {
+               try {
+                       const response = await axios.post(`/api/discord-guilds/${guild.guild_id}/crew`, {
+                               modify_user: user_id,
+                               role,
+                       });
+                       patchGuild(response.data);
+               } catch (error) {
+                       toastr.error(t('discordBot.crewChangeError', { error }));
+               }
+       }, [guild.guild_id, patchGuild, t]);
+
+       const removeCrew = React.useCallback(async (user_id) => {
+               try {
+                       const response = await axios.post(`/api/discord-guilds/${guild.guild_id}/crew`, {
+                               remove_user: user_id,
+                       });
+                       patchGuild(response.data);
+               } catch (error) {
+                       toastr.error(t('discordBot.crewRemoveError', { error }));
+               }
+       }, [guild.guild_id, patchGuild, t]);
+
        const addEventSub = React.useCallback(async (event_id) => {
                try {
                        const response = await axios.post(`/api/discord-guilds/${guild.guild_id}/subscriptions`, {
                                add_event: event_id,
                        });
-                       setSubscriptions(response.data);
-               } catch (e) {
-                       toastr.error(t('discordBot.eventSubError'));
+                       patchGuild(response.data);
+               } catch (error) {
+                       toastr.error(t('discordBot.eventSubError', { error }));
                }
-       }, [guild.guild_id, t]);
+       }, [guild.guild_id, patchGuild, t]);
 
        const removeEventSub = React.useCallback(async (event_id) => {
                try {
                        const response = await axios.post(`/api/discord-guilds/${guild.guild_id}/subscriptions`, {
                                remove_event: event_id,
                        });
-                       setSubscriptions(response.data);
-               } catch (e) {
-                       toastr.error(t('discordBot.eventUnsubError'));
+                       patchGuild(response.data);
+               } catch (error) {
+                       toastr.error(t('discordBot.eventUnsubError', { error }));
                }
-       }, [guild.guild_id, t]);
+       }, [guild.guild_id, patchGuild, t]);
 
 
        const addUserSub = React.useCallback(async (user_id) => {
@@ -46,22 +80,22 @@ const GuildControls = ({ guild }) => {
                        const response = await axios.post(`/api/discord-guilds/${guild.guild_id}/subscriptions`, {
                                add_user: user_id,
                        });
-                       setSubscriptions(response.data);
-               } catch (e) {
-                       toastr.error(t('discordBot.userSubError'));
+                       patchGuild(response.data);
+               } catch (error) {
+                       toastr.error(t('discordBot.userSubError', { error }));
                }
-       }, [guild.guild_id, t]);
+       }, [guild.guild_id, patchGuild, t]);
 
        const removeUserSub = React.useCallback(async (user_id) => {
                try {
                        const response = await axios.post(`/api/discord-guilds/${guild.guild_id}/subscriptions`, {
                                remove_user: user_id,
                        });
-                       setSubscriptions(response.data);
-               } catch (e) {
-                       toastr.error(t('discordBot.userUnsubError'));
+                       patchGuild(response.data);
+               } catch (error) {
+                       toastr.error(t('discordBot.userUnsubError', { error }));
                }
-       }, [guild.guild_id, t]);
+       }, [guild.guild_id, patchGuild, t]);
 
        React.useEffect(() => {
                const ctrl = new AbortController();
@@ -79,7 +113,7 @@ const GuildControls = ({ guild }) => {
                                response.data.user_subscriptions.sort((a, b) => {
                                        return compareUsername(a.user, b.user);
                                });
-                               setSubscriptions(response.data);
+                               patchGuild(response.data);
                        });
                window.Echo.private(`DiscordGuild.${guild.id}`)
                        .listen('.DiscordBotCommandCreated', e => {
@@ -89,36 +123,107 @@ const GuildControls = ({ guild }) => {
                        })
                        .listen('.DiscordBotCommandUpdated', e => {
                                if (e.model) {
-                                       setProtocol(protocol => protocol.map(p => p.id === e.model.id ? { ...p, ...e.mode } : p));
+                                       setProtocol(protocol => protocol.map(p => p.id === e.model.id ? { ...p, ...e.model } : p));
+                               }
+                       })
+                       .listen('.DiscordGuildCrewCreated', e => {
+                               if (e.model) {
+                                       patchGuild(g => ({ ...g, crew: [...g.crew || [], e.model] }));
+                               }
+                       })
+                       .listen('.DiscordGuildCrewUpdated', e => {
+                               if (e.model) {
+                                       patchGuild(g => ({ ...g, crew: (g.crew || []).map(c => c.id === e.model.id ? { ...c, ...e.model } : c) }));
+                               }
+                       })
+                       .listen('.DiscordGuildCrewDeleted', e => {
+                               if (e.model) {
+                                       patchGuild(g => ({ ...g, crew: (g.crew || []).filter(c => c.id !== e.model.id) }));
+                               }
+                       })
+                       .listen('.DiscordGuildEventSubscriptionCreated', e => {
+                               if (e.model) {
+                                       patchGuild(g => ({ ...g, event_subscriptions: [...g.event_subscriptions || [], e.model] }));
+                               }
+                       })
+                       .listen('.DiscordGuildEventSubscriptionUpdated', e => {
+                               if (e.model) {
+                                       patchGuild(g => ({ ...g, event_subscriptions: (g.event_subscriptions || []).map(c => c.id === e.model.id ? { ...c, ...e.model } : c) }));
+                               }
+                       })
+                       .listen('.DiscordGuildEventSubscriptionDeleted', e => {
+                               if (e.model) {
+                                       patchGuild(g => ({ ...g, event_subscriptions: (g.event_subscriptions || []).filter(c => c.id !== e.model.id) }));
+                               }
+                       })
+                       .listen('.DiscordGuildUserSubscriptionCreated', e => {
+                               if (e.model) {
+                                       patchGuild(g => ({ ...g, user_subscriptions: [...g.user_subscriptions || [], e.model] }));
+                               }
+                       })
+                       .listen('.DiscordGuildUserSubscriptionUpdated', e => {
+                               if (e.model) {
+                                       patchGuild(g => ({ ...g, user_subscriptions: (g.user_subscriptions || []).map(c => c.id === e.model.id ? { ...c, ...e.model } : c) }));
+                               }
+                       })
+                       .listen('.DiscordGuildUserSubscriptionDeleted', e => {
+                               if (e.model) {
+                                       patchGuild(g => ({ ...g, user_subscriptions: (g.user_subscriptions || []).filter(c => c.id !== e.model.id) }));
                                }
                        });
                return () => {
                        ctrl.abort();
                        window.Echo.leave(`DiscordGuild.${guild.id}`);
                };
-       }, [guild.id]);
+       }, [guild.id, patchGuild]);
 
        return <>
                <section className="mt-5">
                        <h2>{t('discordBot.guildControls')}</h2>
+                       <Row className="mb-3">
+                               <Col md={6}>
+                                       <h3>{t('discordBot.guildCrew')}</h3>
+                                       <GuildCrew
+                                               addCrew={addCrew}
+                                               changeCrewRole={changeCrewRole}
+                                               guild={guild}
+                                               removeCrew={removeCrew}
+                                       />
+                               </Col>
+                               <Col md={6}>
+                                       <h4>{t('discordBot.roleDescriptions.heading')}</h4>
+                                       <dl>
+                                               {['admin', 'manager', 'member'].map((role) => (
+                                                       <React.Fragment key={role}>
+                                                               <dt>{t(`discordBot.roles.${role}`)}</dt>
+                                                               <dd className="ms-3">{t(`discordBot.roleDescriptions.${role}`)}</dd>
+                                                       </React.Fragment>
+                                               ))}
+                                       </dl>
+                               </Col>
+                       </Row>
                        <Row>
                                <Col md={6}>
                                        <h3>{t('discordBot.eventSubscriptions')}</h3>
+                                       <p style={{ minHeight: '3.5em' }}>{t('discordBot.eventSubscriptionDescription')}</p>
                                        <ErrorBoundary>
                                                <EventSubscriptions
                                                        addEvent={addEventSub}
+                                                       guild={guild}
                                                        removeEvent={removeEventSub}
-                                                       subs={subscriptions.event_subscriptions || []}
+                                                       subs={guild.event_subscriptions || []}
                                                />
                                        </ErrorBoundary>
                                </Col>
                                <Col md={6}>
                                        <h3>{t('discordBot.userSubscriptions')}</h3>
+                                       <p style={{ minHeight: '3.5em' }}>{t('discordBot.userSubscriptionDescription')}</p>
                                        <ErrorBoundary>
                                                <UserSubscriptions
                                                        addUser={addUserSub}
+                                                       guild={guild}
                                                        removeUser={removeUserSub}
-                                                       subs={subscriptions.user_subscriptions || []}
+                                                       subs={guild.user_subscriptions || []}
                                                />
                                        </ErrorBoundary>
                                </Col>
@@ -126,16 +231,23 @@ const GuildControls = ({ guild }) => {
                </section>
                <section className="mt-5">
                        <h3>{t('discordBot.guildProtocol')}</h3>
-                       <GuildProtocol protocol={protocol} />
+                       <ErrorBoundary>
+                               <GuildProtocol protocol={protocol} />
+                       </ErrorBoundary>
                </section>
        </>;
 };
 
 GuildControls.propTypes = {
        guild: PropTypes.shape({
+               event_subscriptions: PropTypes.arrayOf(PropTypes.shape({
+               })),
                id: PropTypes.number,
                guild_id: PropTypes.string,
+               user_subscriptions: PropTypes.arrayOf(PropTypes.shape({
+               })),
        }),
+       patchGuild: PropTypes.func,
 };
 
 export default GuildControls;
diff --git a/resources/js/components/discord-bot/GuildCrew.jsx b/resources/js/components/discord-bot/GuildCrew.jsx
new file mode 100644 (file)
index 0000000..b661306
--- /dev/null
@@ -0,0 +1,70 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Button, Form } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import UserSelect from '../common/UserSelect';
+import Icon from '../common/Icon';
+import UserBox from '../users/Box';
+import { mayAdminGuild } from '../../helpers/permissions';
+import { useUser } from '../../hooks/user';
+
+const GuildCrew = ({ addCrew, changeCrewRole, guild, removeCrew }) => {
+       const { t } = useTranslation();
+       const { user } = useUser();
+
+       const mayAdmin = React.useMemo(() => mayAdminGuild(user, guild), [guild, user]);
+
+       return <div>
+               {mayAdmin ?
+                       <Form.Group controlId="crew.addCrew">
+                               <Form.Label>{t('discordBot.addCrew')}</Form.Label>
+                               <Form.Control
+                                       as={UserSelect}
+                                       excludeIds={guild.crew.map(c => c.user_id)}
+                                       onChange={e => addCrew(e.target.value)}
+                                       value=""
+                               />
+                       </Form.Group>
+               : null}
+               {guild.crew.map((crew) => (
+                       <div className="d-flex align-items-center justify-content-between my-2" key={crew.id}>
+                               <UserBox user={crew.user} />
+                               {mayAdmin ?
+                                       <div className="button-bar d-flex align-items-center">
+                                               <Form.Select
+                                                       onChange={(e) => changeCrewRole(crew.user_id, e.target.value)}
+                                                       value={crew.role}
+                                               >
+                                                       {['admin', 'manager', 'member'].map((role =>
+                                                               <option key={role} value={role}>{t(`discordBot.roles.${role}`)}</option>
+                                                       ))}
+                                               </Form.Select>
+                                               <Button
+                                                       onClick={() => removeCrew(crew.user_id)}
+                                                       size="sm"
+                                                       title={t('button.remove')}
+                                                       variant="outline-danger"
+                                               >
+                                                       <Icon.DELETE title="" />
+                                               </Button>
+                                       </div>
+                               :
+                                       <span>{t(`discordBot.roles.${crew.role}`)}</span>
+                               }
+                       </div>
+               ))}
+       </div>;
+};
+
+GuildCrew.propTypes = {
+       addCrew: PropTypes.func,
+       changeCrewRole: PropTypes.func,
+       guild: PropTypes.shape({
+               crew: PropTypes.arrayOf(PropTypes.shape({
+               })),
+       }),
+       removeCrew: PropTypes.func,
+};
+
+export default GuildCrew;
index be6bbe5878574d621d212cabdcede7b29306886b..fd65cbc1c5b7281aedd83249b14bf7a4aa37b6c6 100644 (file)
@@ -2,6 +2,8 @@ import PropTypes from 'prop-types';
 import React from 'react';
 import { useTranslation } from 'react-i18next';
 
+import { getUserName } from '../../helpers/User';
+
 const GuildProtocol = ({ protocol }) => {
        const { t } = useTranslation();
 
@@ -13,7 +15,10 @@ const GuildProtocol = ({ protocol }) => {
                        </div>
                        <div className="d-flex justify-content-between">
                                <span className="text-muted">
-                                       {t('discordBot.commandTime', { time: new Date(entry.created_at) })}
+                                       {entry.user
+                                               ? t('discordBot.commandTimeUser', { time: new Date(entry.created_at), user: getUserName(entry.user) })
+                                               : t('discordBot.commandTime', { time: new Date(entry.created_at) })
+                                       }
                                </span>
                                <span className="text-muted">
                                        {entry.executed_at
index 390773c7154cc9fb2a7faf169abd88374f8bcda7..e67112a9728fcf11495f27a4c9171ab3a8702ee5 100644 (file)
@@ -6,33 +6,42 @@ import { useTranslation } from 'react-i18next';
 import UserSelect from '../common/UserSelect';
 import Icon from '../common/Icon';
 import UserBox from '../users/Box';
+import { mayManageGuild } from '../../helpers/permissions';
+import { useUser } from '../../hooks/user';
 
-const UserSubscriptions = ({ addUser, removeUser, subs }) => {
+const UserSubscriptions = ({ addUser, guild, removeUser, subs }) => {
        const { t } = useTranslation();
+       const { user } = useUser();
+
+       const mayManage = React.useMemo(() => mayManageGuild(user, guild), [guild, user]);
 
        return <div>
-               <Form.Group controlId="usubs.addUser">
-                       <Form.Label>{t('discordBot.addUser')}</Form.Label>
-                       <Form.Control
-                               as={UserSelect}
-                               excludeIds={subs.map(s => s.user_id)}
-                               onChange={e => addUser(e.target.value)}
-                               value=""
-                       />
-               </Form.Group>
+               {mayManage ?
+                       <Form.Group controlId="usubs.addUser">
+                               <Form.Label>{t('discordBot.addUser')}</Form.Label>
+                               <Form.Control
+                                       as={UserSelect}
+                                       excludeIds={subs.map(s => s.user_id)}
+                                       onChange={e => addUser(e.target.value)}
+                                       value=""
+                               />
+                       </Form.Group>
+               : null}
                {subs.map((usub) => (
                        <div className="d-flex align-items-center justify-content-between my-2" key={usub.id}>
                                <UserBox user={usub.user} />
-                               <div className="button-bar">
-                                       <Button
-                                               onClick={() => removeUser(usub.user_id)}
-                                               size="sm"
-                                               title={t('button.remove')}
-                                               variant="outline-danger"
-                                       >
-                                               <Icon.DELETE title="" />
-                                       </Button>
-                               </div>
+                               {mayManage ?
+                                       <div className="button-bar">
+                                               <Button
+                                                       onClick={() => removeUser(usub.user_id)}
+                                                       size="sm"
+                                                       title={t('button.remove')}
+                                                       variant="outline-danger"
+                                               >
+                                                       <Icon.DELETE title="" />
+                                               </Button>
+                                       </div>
+                               : null}
                        </div>
                ))}
        </div>;
@@ -40,6 +49,8 @@ const UserSubscriptions = ({ addUser, removeUser, subs }) => {
 
 UserSubscriptions.propTypes = {
        addUser: PropTypes.func,
+       guild: PropTypes.shape({
+       }),
        removeUser: PropTypes.func,
        subs: PropTypes.arrayOf(PropTypes.shape({
        })),
index f1be8765ce11134832f6edf8d343efcb010a9b74..94f778428f49e705c1f6c673f192da97ffa4d863 100644 (file)
@@ -25,6 +25,20 @@ export const isAnyChannelAdmin = user =>
 export const mayEditContent = user =>
        user && hasGlobalRole(user, 'content');
 
+// Discord Guilds
+
+const isGuildOwner = (user, guild) => user && guild && user.id === guild.owner;
+
+const isGuildAdmin = (user, guild) => user && guild?.crew?.find(c => c.role === 'admin' && c.user_id === user.id);
+const isGuildManager = (user, guild) => user && guild?.crew?.find(c => c.role == 'manager' && c.user_id === user.id);
+const isGuildMember = (user, guild) => user && guild?.crew?.find(c => c.user_id === user.id);
+
+export const mayViewGuild = (user, guild) => isGuildOwner(user, guild) || isGuildMember(user, guild);
+
+export const mayManageGuild = (user, guild) => isGuildOwner(user, guild) || isGuildAdmin(user, guild) || isGuildManager(user, guild);
+
+export const mayAdminGuild = (user, guild) => isGuildOwner(user, guild) || isGuildAdmin(user, guild);
+
 // Episodes
 
 export const isCommentator = (user, episode) => {
index 083251440a2d1a72c115d25626d17b18c0291fea..e96ed3ed1c8a75c7d5930edda991702040525a5b 100644 (file)
@@ -138,10 +138,12 @@ export default {
                        },
                },
                discordBot: {
+                       addCrew: 'User hinzufügen',
                        addEvent: 'Event abonnieren',
                        addUser: 'User abonnieren',
                        channel: 'Kanal',
                        channelControls: 'Kanal-Steuerung',
+                       commandPending: 'Steht aus',
                        commandStatus: {
                                done: 'Abgeschlossen',
                                exception: 'Fehler',
@@ -150,27 +152,46 @@ export default {
                                pending: 'Ausstehend',
                        },
                        commandTime: '{{ time, L HH:mm:ss }}',
+                       commandTimeUser: '{{ time, L HH:mm:ss }} von {{ user }}',
                        commandType: {
                                'episode-event': 'Episoden-Event Synchronisation',
                                message: 'Nachricht',
                                result: 'Ergebnis',
                        },
                        controls: 'Steuerung',
-                       eventSubError: 'Fehler bim Abonnieren',
-                       eventSubscriptions: 'Event Subscriptions',
+                       crewAddError: 'Fehler bein Hinzufügen',
+                       crewChangeError: 'Fehler beim Speichern',
+                       crewRemoveError: 'Fehler beim Entfernen',
+                       description: 'Dieser Bot kann automatisch Discord Events für abonnierte Veranstaltungen und Benutzer erstellen. Er ist auch in der Lage, Diskussions-Kanäle für Async Turnieren zu verwalten.',
+                       eventSubError: 'Fehler beim Abonnieren',
+                       eventSubscriptionDescription: 'Episoden abonnierter Veranstaltungen werden als Event in Discord angelegt.',
+                       eventSubscriptions: 'Abonnierte Veranstaltungen',
                        eventUnsubError: 'Fehler beim Kündigen',
                        guild: 'Server',
                        guildControls: 'Server-Steuerung',
+                       guildCrew: 'Crew',
                        guildProtocol: 'Command Protokoll',
                        heading: 'Discord Bot',
                        invite: 'Bot einladen',
                        message: 'Nachricht',
                        messageError: 'Fehler beim Senden',
                        messageSuccess: 'Nachricht in Warteschlange',
+                       roles: {
+                               admin: 'Admin',
+                               manager: 'Manager',
+                               member: 'Mitglied',
+                       },
+                       roleDescriptions: {
+                               admin: 'Darf effektiv alles. Der Server-Owner (User mit der Krone im Discord) ist immer Admin.',
+                               heading: 'Beschreibung der Rollen',
+                               manager: 'Manager können alles außer die Userliste und Rollen bearbeiten.',
+                               member: 'Kann diese Seite einsehen, aber nix anfassen.',
+                       },
                        selectGuild: 'Bitte Server wählen',
                        sendMessage: 'Nachricht senden',
                        userSubError: 'Fehler beim Abonnieren',
-                       userSubscriptions: 'User Subscriptions',
+                       userSubscriptionDescription: 'Episoden mit Beteiligung als Runner abonnierter Benutzer werden als Event in Discord angelegt.',
+                       userSubscriptions: 'Abonnierte Benutzer',
                        userUnsubError: 'Fehler beim Kündigen',
                },
                episodes: {
index ae4e45799a3316ed7f286f5f27c2bec81aa90c72..c84f9587b66ecb483fe89cf5bacb405898774ded 100644 (file)
@@ -138,10 +138,12 @@ export default {
                        },
                },
                discordBot: {
+                       addCrew: 'Add user',
                        addEvent: 'Subscribe to event',
                        addUser: 'Subscribe to user',
                        channel: 'Channel',
                        channelControls: 'Channel controls',
+                       commandPending: 'Pending execution',
                        commandStatus: {
                                done: 'Done',
                                exception: 'Error',
@@ -150,26 +152,45 @@ export default {
                                pending: 'Pending',
                        },
                        commandTime: '{{ time, L HH:mm:ss }}',
+                       commandTimeUser: '{{ time, L HH:mm:ss }} by {{ user }}',
                        commandType: {
                                'episode-event': 'Episode event synchronization',
                                message: 'Message',
                                result: 'Result',
                        },
                        controls: 'Controls',
+                       crewAddError: 'Error adding user',
+                       crewChangeError: 'Error modifying user',
+                       crewRemoveError: 'Error removing user',
+                       description: 'This bot can automatically create scheduled events on your discord for events and users your discord is subscribed to. It also manages creation and access for async tournament discussion channels.',
                        eventSubError: 'Error subscribing',
+                       eventSubscriptionDescription: 'Episodes of subscribed events will be posted as discord scheduled events.',
                        eventSubscriptions: 'Event subscriptions',
                        eventUnsubError: 'Error unsubscribing',
                        guild: 'Server',
                        guildControls: 'Server controls',
+                       guildCrew: 'Crew',
                        guildProtocol: 'Command protocol',
                        heading: 'Discord Bot',
                        invite: 'Invite bot',
                        message: 'Message',
                        messageError: 'Error sending message',
                        messageSuccess: 'Message queued',
+                       roles: {
+                               admin: 'Admin',
+                               manager: 'Manager',
+                               member: 'Member',
+                       },
+                       roleDescriptions: {
+                               admin: 'Effectively in total control. Server owners (user with the crown in discord) is always an admin.',
+                               heading: 'Role descriptions',
+                               manager: 'Managers may do pretty much everything except for administering users and roles.',
+                               member: 'May see this page, but not do anything with it.',
+                       },
                        selectGuild: 'Please select server',
                        sendMessage: 'Send message',
                        userSubError: 'Error subscribing',
+                       userSubscriptionDescription: 'Episodes where subscribed users are participating as runners will be posted as discord scheduled events.',
                        userSubscriptions: 'User subscriptions',
                        userUnsubError: 'Error unsubscribing',
                },
index 0f22ed29d981a1a55435474bdfba0639ad8d2b02..215a407b6cf19a74cde795180ca5829005066ea8 100644 (file)
@@ -15,8 +15,10 @@ export const Component = () => {
 
        return <Container>
                <h1>{t('discordBot.heading')}</h1>
+               <p>{t('discordBot.description')}</p>
                <Helmet>
                        <title>{t('discordBot.heading')}</title>
+                       <meta name="description" content={t('discordBot.description')} />
                </Helmet>
                <p>
                        <span className="button-bar">
index 6f96a37990674448f66690b01e2c41208b5734db..53430dcdecd32b9cff756b8876b3282a0a56ef8c 100644 (file)
@@ -54,6 +54,7 @@ Route::get('discord-channels/{channel_id}', 'App\Http\Controllers\DiscordChannel
 Route::get('discord-guilds', 'App\Http\Controllers\DiscordGuildController@search');
 Route::get('discord-guilds/{guild_id}', 'App\Http\Controllers\DiscordGuildController@single');
 Route::get('discord-guilds/{guild_id}/channels', 'App\Http\Controllers\DiscordChannelController@search');
+Route::post('discord-guilds/{guild_id}/crew', 'App\Http\Controllers\DiscordGuildController@manageCrew');
 Route::get('discord-guilds/{guild_id}/subscriptions', 'App\Http\Controllers\DiscordGuildController@subscriptions');
 Route::post('discord-guilds/{guild_id}/subscriptions', 'App\Http\Controllers\DiscordGuildController@manageSubscriptions');
 
index e04f537cd220ed1f6db0e2e76956fa7a28fbee86..0aee0d295bce50074ae97375a7ff7ebc93776bcb 100644 (file)
@@ -31,7 +31,7 @@ Broadcast::channel('Channel.{id}', function ($user, $id) {
 
 Broadcast::channel('DiscordGuild.{id}', function ($user, $id) {
        $guild = DiscordGuild::findOrFail($id);
-       return $user->can('manage', $guild);
+       return $user->can('view', $guild);
 });
 
 Broadcast::channel('Protocol.{id}', function ($user, $id) {