diff --git a/backend/app/http/endpoints/api/channels.go b/backend/app/http/endpoints/api/channels.go index b15ecf8d..53ab622e 100644 --- a/backend/app/http/endpoints/api/channels.go +++ b/backend/app/http/endpoints/api/channels.go @@ -1,15 +1,21 @@ package api import ( + "context" + "fmt" + "math" + "net/http" "sort" "github.com/TicketsBot-cloud/gdl/objects/channel" "github.com/TicketsBot-cloud/gdl/rest" "github.com/gin-gonic/gin" "github.com/ticketsbot-cloud/dashboard/backend/botcontext" + "github.com/ticketsbot-cloud/dashboard/backend/log" "github.com/ticketsbot-cloud/dashboard/backend/redis" "github.com/ticketsbot-cloud/dashboard/backend/rpc/cache" "github.com/ticketsbot-cloud/dashboard/backend/utils" + "go.uber.org/zap" ) func ChannelsHandler(ctx *gin.Context) { @@ -21,37 +27,38 @@ func ChannelsHandler(ctx *gin.Context) { return } + refresh := ctx.Query("refresh") == "true" + var channels []channel.Channel - if ctx.Query("refresh") == "true" { - hasToken, err := redis.Client.TakeChannelRefreshToken(ctx, guildId) + if !refresh { + channels, err = botContext.GetGuildChannels(ctx, guildId) if err != nil { - ctx.JSON(500, utils.ErrorStr("Failed to take channel refresh token for guild %d. Please try again.")) + ctx.JSON(500, utils.ErrorStr("Unable to load channels. Please try again.")) return } + } - if hasToken { - channels, err = rest.GetGuildChannels(ctx, botContext.Token, botContext.RateLimiter, guildId) + // GetGuildChannels trusts an empty cache whenever the guild row exists, which is also what a + // partially repopulated cache looks like, so an empty result goes through the refresh path. + if refresh || len(channels) == 0 { + fetched, ok, err := refreshChannels(ctx, botContext, guildId) + switch { + case err != nil && refresh: + ctx.JSON(500, utils.ErrorStr("Unable to load channels from Discord. Please try again.")) + return + case err != nil: + log.Logger.Warn("Failed to refresh empty channel cache", zap.Error(err), zap.Uint64("guild_id", guildId)) + case ok: + channels = fetched + case refresh: + remaining, err := redis.Client.ChannelRefreshCooldownRemaining(ctx, guildId) if err != nil { - ctx.JSON(500, utils.ErrorStr("Unable to load channels from Discord. Please try again.")) - return + remaining = redis.ChannelRefreshCooldown } - if err := cache.Instance.StoreChannels(ctx, channels); err != nil { - ctx.JSON(500, utils.ErrorStr("Failed to store channels in cache for guild %d. Please try again.")) - return - } - } else { - channels, err = cache.Instance.GetGuildChannels(ctx, guildId) - if err != nil { - ctx.JSON(500, utils.ErrorStr("Unable to load channels. Please try again.")) - return - } - } - } else { - var err error - channels, err = botContext.GetGuildChannels(ctx, guildId) - if err != nil { - ctx.JSON(500, utils.ErrorStr("Unable to load channels. Please try again.")) + body := utils.ErrorStr("Channels were refreshed recently. Please try again shortly.") + body["retry_after"] = max(1, int(math.Ceil(remaining.Seconds()))) + ctx.JSON(http.StatusTooManyRequests, body) return } } @@ -78,3 +85,27 @@ func ChannelsHandler(ctx *gin.Context) { ctx.JSON(200, filtered) } + +// refreshChannels fetches the guild's channels from Discord and writes them to the cache. The +// bool is false when the guild is on refresh cooldown and nothing was fetched. +func refreshChannels(ctx context.Context, botContext *botcontext.BotContext, guildId uint64) ([]channel.Channel, bool, error) { + hasToken, err := redis.Client.TakeChannelRefreshToken(ctx, guildId) + if err != nil { + return nil, false, fmt.Errorf("take channel refresh token: %w", err) + } + + if !hasToken { + return nil, false, nil + } + + channels, err := rest.GetGuildChannels(ctx, botContext.Token, botContext.RateLimiter, guildId) + if err != nil { + return nil, false, fmt.Errorf("fetch guild channels: %w", err) + } + + if err := cache.Instance.StoreChannels(ctx, channels); err != nil { + return nil, false, fmt.Errorf("store guild channels: %w", err) + } + + return channels, true, nil +} diff --git a/backend/redis/channelrefreshcooldown.go b/backend/redis/channelrefreshcooldown.go index 341bcccf..fd7d23e0 100644 --- a/backend/redis/channelrefreshcooldown.go +++ b/backend/redis/channelrefreshcooldown.go @@ -8,13 +8,30 @@ import ( const ChannelRefreshCooldown = 60 * time.Second -func (c *RedisClient) TakeChannelRefreshToken(ctx context.Context, guildId uint64) (bool, error) { - key := fmt.Sprintf("tickets:channelrefershcooldown:%d", guildId) +func channelRefreshCooldownKey(guildId uint64) string { + return fmt.Sprintf("tickets:channelrefershcooldown:%d", guildId) +} - res, err := c.SetNX(ctx, key, "1", ChannelRefreshCooldown).Result() +func (c *RedisClient) TakeChannelRefreshToken(ctx context.Context, guildId uint64) (bool, error) { + res, err := c.SetNX(ctx, channelRefreshCooldownKey(guildId), "1", ChannelRefreshCooldown).Result() if err != nil { return false, err } return res, nil } + +// ChannelRefreshCooldownRemaining returns zero if the guild is not on cooldown. +func (c *RedisClient) ChannelRefreshCooldownRemaining(ctx context.Context, guildId uint64) (time.Duration, error) { + ttl, err := c.TTL(ctx, channelRefreshCooldownKey(guildId)).Result() + if err != nil { + return 0, err + } + + // go-redis reports a missing key or one without an expiry as a negative duration + if ttl < 0 { + return 0, nil + } + + return ttl, nil +} diff --git a/frontend/src/components/ChannelRefreshHint.tsx b/frontend/src/components/ChannelRefreshHint.tsx new file mode 100644 index 00000000..f15bb94a --- /dev/null +++ b/frontend/src/components/ChannelRefreshHint.tsx @@ -0,0 +1,53 @@ +import type { FC } from "react"; +import { useChannelRefresh } from "@/hooks/useChannelRefresh"; +import { useGuildStore } from "@/stores/guild"; +import type { GuildChannel } from "@/types"; + +interface ChannelRefreshHintProps { + /** Defaults to the selected guild. */ + guildId?: string; + onRefreshed?: (channels: GuildChannel[]) => void; + noun?: "channels" | "categories"; +} + +const ChannelRefreshHint: FC = ({ + guildId, + onRefreshed, + noun = "channels", +}) => { + const selectedGuildId = useGuildStore((s) => s.selectedGuild?.id); + const { refresh, refreshing, secondsLeft } = useChannelRefresh( + guildId ?? selectedGuildId?.toString(), + onRefreshed, + ); + + const unavailable = refreshing || secondsLeft > 0; + + let action = "Click here to refresh"; + if (refreshing) { + action = "Refreshing…"; + } else if (secondsLeft > 0) { + action = `Refresh available in ${secondsLeft}s`; + } + + return ( +

+ Missing {noun}?{" "} + {/* aria-disabled rather than disabled, so keyboard focus is not dropped mid-refresh */} + +

+ ); +}; + +export default ChannelRefreshHint; diff --git a/frontend/src/components/Select.tsx b/frontend/src/components/Select.tsx index 674b1187..22fca4b7 100644 --- a/frontend/src/components/Select.tsx +++ b/frontend/src/components/Select.tsx @@ -1,4 +1,4 @@ -import type { FC } from "react"; +import type { FC, ReactNode } from "react"; import { useState, useRef, useEffect, useId, useCallback } from "react"; import { createPortal } from "react-dom"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; @@ -34,6 +34,7 @@ interface SelectProps { required?: boolean; missing?: boolean; info?: SelectInfo; + hint?: ReactNode; } const defaultProps = { @@ -63,6 +64,7 @@ const Select: FC = (props) => { hideSearch, error, info, + hint, required, } = { ...defaultProps, @@ -199,6 +201,8 @@ const Select: FC = (props) => { + {hint} + {isOpen && !disabled && createPortal( diff --git a/frontend/src/components/modals/GalleryImportModal.tsx b/frontend/src/components/modals/GalleryImportModal.tsx index ba42e6cb..35777b76 100644 --- a/frontend/src/components/modals/GalleryImportModal.tsx +++ b/frontend/src/components/modals/GalleryImportModal.tsx @@ -4,6 +4,7 @@ import { toast } from "sonner"; import ActionModal from "@/components/modal-primitives/ActionModal"; import Button from "@/components/Button"; import Select from "@/components/Select"; +import ChannelRefreshHint from "@/components/ChannelRefreshHint"; import { apiClient } from "@/lib/api"; import { useAuthStore } from "@/stores/auth"; import type { GalleryListing, GuildChannel } from "@/types"; @@ -111,6 +112,9 @@ const GalleryImportModal: FC = ({ listing, open, onClos <> + } required placeholder="Select a category..." value={selectedCategoryId} diff --git a/frontend/src/hooks/useChannelRefresh.ts b/frontend/src/hooks/useChannelRefresh.ts new file mode 100644 index 00000000..17e5fc80 --- /dev/null +++ b/frontend/src/hooks/useChannelRefresh.ts @@ -0,0 +1,55 @@ +import { useCallback } from "react"; +import { isAxiosError } from "axios"; +import { toast } from "sonner"; +import { apiClient } from "@/lib/api"; +import { useChannelRefreshStore } from "@/stores/channelRefresh"; +import { useGuildStore } from "@/stores/guild"; +import type { GuildChannel } from "@/types"; + +const DEFAULT_COOLDOWN_SECONDS = 60; + +/** + * Re-fetches a guild's channels from Discord. Without `onRefreshed` the result is written to + * the guild store; pass it when the caller holds channels for a guild other than the selected one. + */ +export function useChannelRefresh( + guildId: string | undefined, + onRefreshed?: (channels: GuildChannel[]) => void, +) { + const secondsLeft = useChannelRefreshStore((s) => (guildId ? (s.remaining[guildId] ?? 0) : 0)); + const refreshing = useChannelRefreshStore((s) => (guildId ? !!s.inFlight[guildId] : false)); + const updateGuild = useGuildStore((s) => s.updateGuild); + + const refresh = useCallback(async () => { + if (!guildId) return; + + const { inFlight, remaining, setInFlight, startCooldown } = useChannelRefreshStore.getState(); + if (inFlight[guildId] || (remaining[guildId] ?? 0) > 0) return; + + setInFlight(guildId, true); + try { + const res = await apiClient.guilds.refreshChannels(guildId); + if (onRefreshed) { + onRefreshed(res.data); + } else { + updateGuild(guildId, { channels: res.data }); + } + startCooldown(guildId, DEFAULT_COOLDOWN_SECONDS); + toast.success("Channel list refreshed."); + } catch (error) { + if (isAxiosError(error) && error.response?.status === 429) { + const retryAfter = Number(error.response.data?.retry_after) || DEFAULT_COOLDOWN_SECONDS; + startCooldown(guildId, retryAfter); + toast.warning( + `Channels were refreshed recently. You can refresh again in ${retryAfter} seconds.`, + ); + } else { + toast.error("Could not refresh the channel list. Please try again."); + } + } finally { + setInFlight(guildId, false); + } + }, [guildId, onRefreshed, updateGuild]); + + return { refresh, refreshing, secondsLeft }; +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index f9abb9c0..483247dc 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -241,6 +241,13 @@ export const apiClient = { api.get<{ id: string | number; name: string; icon: string }>(`/api/${guildId}/guild`), getChannels: (guildId: string) => api.get(`/api/${guildId}/channels`, { timeout: DISCORD_HEAVY_TIMEOUT_MS }), + /** Rate limited per guild; a 429 carries `retry_after` in seconds. */ + refreshChannels: (guildId: string) => + api.get(`/api/${guildId}/channels`, { + ...SKIP_ERROR_TOAST, + params: { refresh: true }, + timeout: DISCORD_HEAVY_TIMEOUT_MS, + }), getRoles: (guildId: string) => api.get<{ roles: GuildRole[] }>(`/api/${guildId}/roles`, { timeout: DISCORD_HEAVY_TIMEOUT_MS, diff --git a/frontend/src/pages/manage/multipanels/create.tsx b/frontend/src/pages/manage/multipanels/create.tsx index 4b1602af..ebd708b8 100644 --- a/frontend/src/pages/manage/multipanels/create.tsx +++ b/frontend/src/pages/manage/multipanels/create.tsx @@ -20,6 +20,7 @@ import { prepareMultiPanelForApi } from "@/lib/panel-payload"; import { scrollToFirstMissingField } from "@/lib/scroll-to-missing"; import MultiSelect from "@/components/MultiSelect"; import Select from "@/components/Select"; +import ChannelRefreshHint from "@/components/ChannelRefreshHint"; import TextInput from "@/components/TextInput"; import ColourSelect from "@/components/ColourSelect"; import { intToColour } from "@/lib/colour"; @@ -228,6 +229,7 @@ const MultiPanelsPage: FC = () => {
} info={PANEL_MESSAGE_INFO} required error={staleChannel} diff --git a/frontend/src/pages/manage/panels/create.tsx b/frontend/src/pages/manage/panels/create.tsx index 9214d77c..42c05998 100644 --- a/frontend/src/pages/manage/panels/create.tsx +++ b/frontend/src/pages/manage/panels/create.tsx @@ -19,6 +19,7 @@ import type { Panel, SupportHoursData } from "@/types"; import Collapsible from "@/components/Collapsible"; import MultiSelect from "@/components/MultiSelect"; import Select from "@/components/Select"; +import ChannelRefreshHint from "@/components/ChannelRefreshHint"; import TextInput from "@/components/TextInput"; import NumberInput from "@/components/NumberInput"; import ColourSelect from "@/components/ColourSelect"; @@ -340,6 +341,7 @@ const PanelsPage: FC = () => {
} required error={!panel.use_threads && stale(panel.category_id)} options={ @@ -521,6 +524,7 @@ const PanelsPage: FC = () => {
} error={stale(panel.transcript_channel_id)} info={TRANSCRIPT_CHANNEL_INFO} showNoneOption={true} @@ -888,6 +893,7 @@ const PanelsPage: FC = () => { /> } placeholder="Select a category..." options={ selectedGuild?.channels diff --git a/frontend/src/pages/manage/panels/edit.tsx b/frontend/src/pages/manage/panels/edit.tsx index 80f2252f..c9e9d9ff 100644 --- a/frontend/src/pages/manage/panels/edit.tsx +++ b/frontend/src/pages/manage/panels/edit.tsx @@ -20,6 +20,7 @@ import type { Panel, SupportHoursData } from "@/types"; import Collapsible from "@/components/Collapsible"; import MultiSelect from "@/components/MultiSelect"; import Select from "@/components/Select"; +import ChannelRefreshHint from "@/components/ChannelRefreshHint"; import TextInput from "@/components/TextInput"; import NumberInput from "@/components/NumberInput"; import ColourSelect from "@/components/ColourSelect"; @@ -260,6 +261,7 @@ const EditPanelsPage: FC = () => {
} required error={!panel.use_threads && stale(panel.category_id)} options={ @@ -435,6 +438,7 @@ const EditPanelsPage: FC = () => {
} info={TRANSCRIPT_CHANNEL_INFO} error={stale(panel.transcript_channel_id)} showNoneOption={true} @@ -802,6 +807,7 @@ const EditPanelsPage: FC = () => { /> } placeholder="Select a category..." options={ selectedGuild?.channels diff --git a/frontend/src/pages/manage/setup/components/PanelsStep.tsx b/frontend/src/pages/manage/setup/components/PanelsStep.tsx index 8f0315d5..6084bfc3 100644 --- a/frontend/src/pages/manage/setup/components/PanelsStep.tsx +++ b/frontend/src/pages/manage/setup/components/PanelsStep.tsx @@ -4,6 +4,7 @@ import { toast } from "sonner"; import TextInput from "@/components/TextInput"; import Textarea from "@/components/Textarea"; import Select from "@/components/Select"; +import ChannelRefreshHint from "@/components/ChannelRefreshHint"; import ColourSelect from "@/components/ColourSelect"; import Button from "@/components/Button"; import GalleryCard from "@/components/GalleryCard"; @@ -222,6 +223,7 @@ const PanelsStep: FC = ({
} required value={importCategory} options={categoryOptions} @@ -272,6 +275,7 @@ const PanelsStep: FC = ({
} required value={category} options={categoryOptions} diff --git a/frontend/src/stores/channelRefresh.ts b/frontend/src/stores/channelRefresh.ts new file mode 100644 index 00000000..ba76a1d6 --- /dev/null +++ b/frontend/src/stores/channelRefresh.ts @@ -0,0 +1,37 @@ +import { create } from "zustand"; + +interface ChannelRefreshState { + remaining: Record; + inFlight: Record; + setInFlight: (guildId: string, inFlight: boolean) => void; + startCooldown: (guildId: string, seconds: number) => void; +} + +const timers = new Map>(); + +// Module-level so every hint on a page shares one countdown per guild. +export const useChannelRefreshStore = create()((set, get) => ({ + remaining: {}, + inFlight: {}, + + setInFlight: (guildId, inFlight) => { + set({ inFlight: { ...get().inFlight, [guildId]: inFlight } }); + }, + + startCooldown: (guildId, seconds) => { + const existing = timers.get(guildId); + if (existing) clearInterval(existing); + + set({ remaining: { ...get().remaining, [guildId]: seconds } }); + + const timer = setInterval(() => { + const left = (get().remaining[guildId] ?? 0) - 1; + set({ remaining: { ...get().remaining, [guildId]: Math.max(0, left) } }); + if (left <= 0) { + clearInterval(timer); + timers.delete(guildId); + } + }, 1000); + timers.set(guildId, timer); + }, +}));