Implement command cooldown management and enhance command execution flow
- Introduced `applyCommandCooldown` function to manage per-command cooldowns after execution, improving command rate limiting. - Updated command execution flow to include cooldown application, ensuring users are informed of cooldown status. - Enhanced error handling in interaction replies, allowing for more graceful error management and user feedback. - Improved the handling of interaction responses across various commands, ensuring consistent user experience. - Added permission checks for new commands, ensuring only authorized users can execute specific actions.
This commit is contained in:
@@ -51,6 +51,7 @@ export async function GET(request: NextRequest) {
|
||||
cookieValue = await createSession({
|
||||
user,
|
||||
accessToken: token.access_token,
|
||||
refreshToken: token.refresh_token,
|
||||
tokenExpiresAt: Date.now() + token.expires_in * 1000
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -38,6 +38,10 @@ function toOptions(channels: DiscordChannelOption[]) {
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizeSearch(value: string): string {
|
||||
return value.trim().toLocaleLowerCase();
|
||||
}
|
||||
|
||||
interface ChannelSelectBaseProps {
|
||||
guildId: string;
|
||||
kinds?: ChannelKind[];
|
||||
@@ -62,14 +66,39 @@ export function DiscordChannelSelect({
|
||||
allowClear = true
|
||||
}: DiscordChannelSelectProps) {
|
||||
const t = useTranslations();
|
||||
const { channels, loading } = useGuildChannels(guildId);
|
||||
const { channels, loading, error, reload } = useGuildChannels(guildId);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
const options = useMemo(() => toOptions(filterChannels(channels, kinds)), [channels, kinds]);
|
||||
const selected = options.find((option) => option.id === value);
|
||||
const filtered = useMemo(() => {
|
||||
const needle = normalizeSearch(query);
|
||||
if (!needle) {
|
||||
return options;
|
||||
}
|
||||
return options.filter((option) => {
|
||||
const haystack = normalizeSearch(`${option.prefix}${option.name} ${option.id}`);
|
||||
return haystack.includes(needle);
|
||||
});
|
||||
}, [options, query]);
|
||||
|
||||
const triggerLabel = selected
|
||||
? `${selected.prefix}${selected.name}`
|
||||
: value
|
||||
? `#${value}`
|
||||
: (placeholder ?? t('modulePages.commands.channelSearchPlaceholder'));
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
setOpen(next);
|
||||
if (!next) {
|
||||
setQuery('');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -79,19 +108,29 @@ export function DiscordChannelSelect({
|
||||
disabled={disabled || loading}
|
||||
className="h-9 w-full justify-between px-3 font-normal"
|
||||
>
|
||||
<span className={cn('truncate', !selected && 'text-muted-foreground')}>
|
||||
{selected
|
||||
? `${selected.prefix}${selected.name}`
|
||||
: (placeholder ?? t('modulePages.commands.channelSearchPlaceholder'))}
|
||||
<span className={cn('truncate', !selected && !value && 'text-muted-foreground')}>
|
||||
{triggerLabel}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder={placeholder ?? t('modulePages.commands.channelSearchPlaceholder')} />
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
placeholder={placeholder ?? t('modulePages.commands.channelSearchPlaceholder')}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>{t('common.noResults')}</CommandEmpty>
|
||||
<CommandEmpty>
|
||||
{error ? (
|
||||
<button type="button" className="underline" onClick={() => reload()}>
|
||||
{t('common.saveError')}
|
||||
</button>
|
||||
) : (
|
||||
t('common.noResults')
|
||||
)}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{allowClear && value ? (
|
||||
<CommandItem
|
||||
@@ -105,10 +144,10 @@ export function DiscordChannelSelect({
|
||||
<span className="text-muted-foreground">{t('common.clearSelection')}</span>
|
||||
</CommandItem>
|
||||
) : null}
|
||||
{options.map((option) => (
|
||||
{filtered.map((option) => (
|
||||
<CommandItem
|
||||
key={option.id}
|
||||
value={`${option.prefix}${option.name} ${option.id}`}
|
||||
value={`${option.name} ${option.id}`}
|
||||
onSelect={() => {
|
||||
onChange(option.id);
|
||||
setOpen(false);
|
||||
|
||||
@@ -1,44 +1,66 @@
|
||||
'use client';
|
||||
|
||||
import type { DiscordChannelOption } from '@nexumi/shared';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
const cache = new Map<string, DiscordChannelOption[]>();
|
||||
|
||||
export function useGuildChannels(guildId: string): {
|
||||
channels: DiscordChannelOption[];
|
||||
loading: boolean;
|
||||
error: boolean;
|
||||
reload: () => void;
|
||||
} {
|
||||
const [channels, setChannels] = useState<DiscordChannelOption[]>(() => cache.get(guildId) ?? []);
|
||||
const [loading, setLoading] = useState(!cache.has(guildId));
|
||||
const [error, setError] = useState(false);
|
||||
const [reloadToken, setReloadToken] = useState(0);
|
||||
|
||||
const reload = useCallback(() => {
|
||||
cache.delete(guildId);
|
||||
setReloadToken((token) => token + 1);
|
||||
}, [guildId]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const cached = cache.get(guildId);
|
||||
const cached = reloadToken === 0 ? cache.get(guildId) : undefined;
|
||||
if (cached) {
|
||||
setChannels(cached);
|
||||
setLoading(false);
|
||||
setError(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
void fetch(`/api/guilds/${guildId}/channels`)
|
||||
.then(async (response) => {
|
||||
if (!response.ok) {
|
||||
return [] as DiscordChannelOption[];
|
||||
throw new Error(`Channels request failed with ${response.status}`);
|
||||
}
|
||||
return (await response.json()) as DiscordChannelOption[];
|
||||
const data = (await response.json()) as DiscordChannelOption[];
|
||||
if (!Array.isArray(data)) {
|
||||
throw new Error('Channels response was not an array');
|
||||
}
|
||||
return data;
|
||||
})
|
||||
.then((data) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
cache.set(guildId, data);
|
||||
if (data.length > 0) {
|
||||
cache.set(guildId, data);
|
||||
} else {
|
||||
cache.delete(guildId);
|
||||
}
|
||||
setChannels(data);
|
||||
setError(false);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
cache.delete(guildId);
|
||||
setChannels([]);
|
||||
setError(true);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -50,7 +72,7 @@ export function useGuildChannels(guildId: string): {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [guildId]);
|
||||
}, [guildId, reloadToken]);
|
||||
|
||||
return { channels, loading };
|
||||
return { channels, loading, error, reload };
|
||||
}
|
||||
|
||||
16
apps/webui/src/lib/api-errors.ts
Normal file
16
apps/webui/src/lib/api-errors.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export class BadRequestError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'BadRequestError';
|
||||
}
|
||||
}
|
||||
|
||||
export class UpstreamError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly status = 502
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'UpstreamError';
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,18 @@ import { redirect } from 'next/navigation';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { ZodError } from 'zod';
|
||||
import { hasManageGuildPermission } from '@nexumi/shared';
|
||||
import { fetchDiscordUserGuildsCached } from './discord-oauth';
|
||||
import { fetchDiscordUserGuildsCached, refreshDiscordToken } from './discord-oauth';
|
||||
import { BadRequestError, UpstreamError } from './api-errors';
|
||||
import { hasOwnerGuildBypass } from './owner-auth';
|
||||
import { prisma } from './prisma';
|
||||
import { getSession, type SessionPayload } from './session';
|
||||
import {
|
||||
getSession,
|
||||
SessionRequiredError,
|
||||
updateSession,
|
||||
type SessionPayload
|
||||
} from './session';
|
||||
|
||||
export { BadRequestError, UpstreamError } from './api-errors';
|
||||
|
||||
export class UnauthorizedError extends Error {
|
||||
constructor(message = 'Authentication required') {
|
||||
@@ -21,6 +29,37 @@ export class ForbiddenError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
const TOKEN_REFRESH_SKEW_MS = 60_000;
|
||||
|
||||
/**
|
||||
* Refreshes the Discord access token when expired (or about to expire).
|
||||
* Throws {@link UnauthorizedError} when refresh is impossible.
|
||||
*/
|
||||
export async function ensureFreshSession(session: SessionPayload): Promise<SessionPayload> {
|
||||
const expiresAt = session.tokenExpiresAt ?? 0;
|
||||
if (expiresAt > Date.now() + TOKEN_REFRESH_SKEW_MS) {
|
||||
return session;
|
||||
}
|
||||
|
||||
if (!session.refreshToken) {
|
||||
throw new UnauthorizedError('Session expired – please log in again');
|
||||
}
|
||||
|
||||
try {
|
||||
const token = await refreshDiscordToken(session.refreshToken);
|
||||
const next: SessionPayload = {
|
||||
...session,
|
||||
accessToken: token.access_token,
|
||||
refreshToken: token.refresh_token || session.refreshToken,
|
||||
tokenExpiresAt: Date.now() + token.expires_in * 1000
|
||||
};
|
||||
await updateSession(next);
|
||||
return next;
|
||||
} catch {
|
||||
throw new UnauthorizedError('Session expired – please log in again');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Use in API routes. Throws {@link UnauthorizedError} when no session exists.
|
||||
*/
|
||||
@@ -29,7 +68,7 @@ export async function requireAuth(): Promise<SessionPayload> {
|
||||
if (!session) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
return session;
|
||||
return ensureFreshSession(session);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,14 +80,18 @@ export async function requireAuthOrRedirect(): Promise<SessionPayload> {
|
||||
if (!session) {
|
||||
redirect('/login');
|
||||
}
|
||||
return session;
|
||||
try {
|
||||
return await ensureFreshSession(session);
|
||||
} catch {
|
||||
redirect('/login');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures the current session user has Manage Guild permission on the given
|
||||
* guild (via Discord OAuth guild list) and that the bot is actually installed
|
||||
* there (tracked via the `Guild` table). Owner-panel users bypass Discord
|
||||
* membership checks. Use in API routes.
|
||||
* there (tracked via the `Guild` table). Owner-panel users with SUPPORT+
|
||||
* bypass Discord membership checks. Use in API routes.
|
||||
*/
|
||||
export async function requireGuildAccess(guildId: string): Promise<SessionPayload> {
|
||||
const session = await requireAuth();
|
||||
@@ -61,12 +104,19 @@ export async function requireGuildAccess(guildId: string): Promise<SessionPayloa
|
||||
return session;
|
||||
}
|
||||
|
||||
const guilds = await fetchDiscordUserGuildsCached(session.accessToken);
|
||||
const guild = guilds.find((entry) => entry.id === guildId);
|
||||
if (!guild || !hasManageGuildPermission(guild.permissions)) {
|
||||
throw new ForbiddenError('Missing Manage Guild permission for this server');
|
||||
try {
|
||||
const guilds = await fetchDiscordUserGuildsCached(session.accessToken);
|
||||
const guild = guilds.find((entry) => entry.id === guildId);
|
||||
if (!guild || !hasManageGuildPermission(guild.permissions)) {
|
||||
throw new ForbiddenError('Missing Manage Guild permission for this server');
|
||||
}
|
||||
return session;
|
||||
} catch (error) {
|
||||
if (error instanceof ForbiddenError) {
|
||||
throw error;
|
||||
}
|
||||
throw new UnauthorizedError('Discord session expired – please log in again');
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -84,21 +134,31 @@ export async function requireGuildAccessOrRedirect(guildId: string): Promise<Ses
|
||||
return session;
|
||||
}
|
||||
|
||||
const guilds = await fetchDiscordUserGuildsCached(session.accessToken);
|
||||
const guild = guilds.find((entry) => entry.id === guildId);
|
||||
if (!guild || !hasManageGuildPermission(guild.permissions)) {
|
||||
redirect('/dashboard');
|
||||
try {
|
||||
const guilds = await fetchDiscordUserGuildsCached(session.accessToken);
|
||||
const guild = guilds.find((entry) => entry.id === guildId);
|
||||
if (!guild || !hasManageGuildPermission(guild.permissions)) {
|
||||
redirect('/dashboard');
|
||||
}
|
||||
return session;
|
||||
} catch {
|
||||
redirect('/login');
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
export function toApiErrorResponse(error: unknown): NextResponse {
|
||||
if (error instanceof UnauthorizedError) {
|
||||
if (error instanceof UnauthorizedError || error instanceof SessionRequiredError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 401 });
|
||||
}
|
||||
if (error instanceof ForbiddenError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 403 });
|
||||
}
|
||||
if (error instanceof BadRequestError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
if (error instanceof UpstreamError) {
|
||||
return NextResponse.json({ error: error.message }, { status: error.status });
|
||||
}
|
||||
if (error instanceof ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', issues: error.issues },
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { DiscordChannelOption, DiscordRoleOption } from '@nexumi/shared';
|
||||
import { UpstreamError } from './api-errors';
|
||||
import { DASHBOARD_CHANNEL_TYPES } from './discord-channel-types';
|
||||
import { env } from './env';
|
||||
import { redis } from './redis';
|
||||
@@ -21,7 +22,11 @@ export async function discordBotFetch<T>(
|
||||
cache: 'no-store'
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Discord API request to ${path} failed with status ${response.status}`);
|
||||
const status =
|
||||
response.status === 401 || response.status === 403 || response.status === 429
|
||||
? response.status
|
||||
: 502;
|
||||
throw new UpstreamError(`Discord API request to ${path} failed with status ${response.status}`, status);
|
||||
}
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
@@ -73,13 +78,18 @@ async function writeCache(cacheKey: string, value: unknown): Promise<void> {
|
||||
}
|
||||
|
||||
export async function listGuildChannelsForDashboard(guildId: string): Promise<DiscordChannelOption[]> {
|
||||
const cacheKey = `dashboard:guild:${guildId}:channels:v2`;
|
||||
// v3: ignore empty cache hits (same bug class as roles empty-cache freeze)
|
||||
const cacheKey = `dashboard:guild:${guildId}:channels:v3`;
|
||||
const cached = await readCache<DiscordChannelOption[]>(cacheKey);
|
||||
if (cached) {
|
||||
if (cached && cached.length > 0) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const channels = await discordBotFetch<DiscordChannelRest[]>(`/guilds/${guildId}/channels`);
|
||||
if (!Array.isArray(channels)) {
|
||||
throw new Error(`Discord API returned non-array channels for guild ${guildId}`);
|
||||
}
|
||||
|
||||
const options = channels
|
||||
.filter((channel) => DASHBOARD_CHANNEL_TYPES.has(channel.type))
|
||||
.map((channel) => ({
|
||||
@@ -90,7 +100,9 @@ export async function listGuildChannelsForDashboard(guildId: string): Promise<Di
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
await writeCache(cacheKey, options);
|
||||
if (options.length > 0) {
|
||||
await writeCache(cacheKey, options);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,28 @@ export async function exchangeCodeForToken(code: string): Promise<DiscordTokenRe
|
||||
return (await response.json()) as DiscordTokenResponse;
|
||||
}
|
||||
|
||||
export async function refreshDiscordToken(refreshToken: string): Promise<DiscordTokenResponse> {
|
||||
const body = new URLSearchParams({
|
||||
client_id: env.BOT_CLIENT_ID,
|
||||
client_secret: env.BOT_CLIENT_SECRET,
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refreshToken
|
||||
});
|
||||
|
||||
const response = await fetch(`${DISCORD_API_BASE}/oauth2/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
cache: 'no-store'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Discord token refresh failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
return (await response.json()) as DiscordTokenResponse;
|
||||
}
|
||||
|
||||
export interface DiscordUser {
|
||||
id: string;
|
||||
username: string;
|
||||
|
||||
@@ -11,41 +11,68 @@ const ADMINISTRATOR_PERMISSION = '8';
|
||||
/**
|
||||
* Returns the guilds the current session user can manage (Manage Guild
|
||||
* permission), enriched with whether Nexumi is already installed there.
|
||||
* SUPPORT+ owner-panel users also see all bot-installed guilds.
|
||||
* Guilds where the bot is present are sorted first.
|
||||
*/
|
||||
export async function getManageableGuilds(session: SessionPayload): Promise<DashboardGuild[]> {
|
||||
const discordGuilds = await fetchDiscordUserGuildsCached(session.accessToken);
|
||||
const discordGuilds = await fetchDiscordUserGuildsCached(session.accessToken).catch(() => []);
|
||||
const manageable = discordGuilds.filter((guild) => hasManageGuildPermission(guild.permissions));
|
||||
const byId = new Map<string, DashboardGuild>();
|
||||
|
||||
if (manageable.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const managedIds = manageable.map((guild) => guild.id);
|
||||
const presentManaged =
|
||||
managedIds.length > 0
|
||||
? await prisma.guild.findMany({
|
||||
where: { id: { in: managedIds } },
|
||||
select: { id: true }
|
||||
})
|
||||
: [];
|
||||
const presentManagedIds = new Set(presentManaged.map((guild) => guild.id));
|
||||
|
||||
const presentGuilds = await prisma.guild.findMany({
|
||||
where: { id: { in: manageable.map((guild) => guild.id) } },
|
||||
select: { id: true }
|
||||
});
|
||||
const presentIds = new Set(presentGuilds.map((guild) => guild.id));
|
||||
|
||||
return manageable
|
||||
.map<DashboardGuild>((guild) => ({
|
||||
for (const guild of manageable) {
|
||||
byId.set(guild.id, {
|
||||
id: guild.id,
|
||||
name: guild.name,
|
||||
icon: guild.icon,
|
||||
botPresent: presentIds.has(guild.id),
|
||||
botPresent: presentManagedIds.has(guild.id),
|
||||
permissions: guild.permissions
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
if (a.botPresent !== b.botPresent) {
|
||||
return a.botPresent ? -1 : 1;
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}
|
||||
|
||||
if (await hasOwnerGuildBypass(session.user.id)) {
|
||||
const installed = await prisma.guild.findMany({
|
||||
select: { id: true },
|
||||
take: 200,
|
||||
orderBy: { createdAt: 'desc' }
|
||||
});
|
||||
for (const row of installed) {
|
||||
if (byId.has(row.id)) {
|
||||
const existing = byId.get(row.id)!;
|
||||
byId.set(row.id, { ...existing, botPresent: true });
|
||||
continue;
|
||||
}
|
||||
const meta = await getOwnerGuildDiscordMeta(row.id);
|
||||
byId.set(row.id, {
|
||||
id: row.id,
|
||||
name: meta?.name ?? `Guild ${row.id}`,
|
||||
icon: meta?.icon ?? null,
|
||||
botPresent: true,
|
||||
permissions: ADMINISTRATOR_PERMISSION
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return [...byId.values()].sort((a, b) => {
|
||||
if (a.botPresent !== b.botPresent) {
|
||||
return a.botPresent ? -1 : 1;
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Looks up a single manageable guild by id, or null when the session user
|
||||
* does not manage it via Discord OAuth.
|
||||
* does not manage it via Discord OAuth / owner bypass.
|
||||
*/
|
||||
export async function getManageableGuild(
|
||||
session: SessionPayload,
|
||||
@@ -66,7 +93,13 @@ export async function resolveDashboardGuild(
|
||||
): Promise<{ guild: DashboardGuild; ownerBypass: boolean } | null> {
|
||||
const managed = await getManageableGuild(session, guildId);
|
||||
if (managed?.botPresent) {
|
||||
return { guild: managed, ownerBypass: false };
|
||||
const bypass = await hasOwnerGuildBypass(session.user.id);
|
||||
const onDiscord = Boolean(
|
||||
(await fetchDiscordUserGuildsCached(session.accessToken).catch(() => [])).find(
|
||||
(entry) => entry.id === guildId && hasManageGuildPermission(entry.permissions)
|
||||
)
|
||||
);
|
||||
return { guild: managed, ownerBypass: bypass && !onDiscord };
|
||||
}
|
||||
|
||||
if (!(await hasOwnerGuildBypass(session.user.id))) {
|
||||
|
||||
@@ -124,7 +124,28 @@ export async function setGiveawayPaused(
|
||||
if (!existing || existing.endedAt) {
|
||||
return null;
|
||||
}
|
||||
const updated = await prisma.giveaway.update({ where: { id: giveawayId }, data: { paused } });
|
||||
|
||||
const { confirmed } = await addJobAndAwait(
|
||||
getGiveawayQueue(),
|
||||
getGiveawayQueueEvents(),
|
||||
'giveawayPause',
|
||||
{ giveawayId, paused },
|
||||
{
|
||||
jobId: `giveaway-pause-${giveawayId}-${paused ? '1' : '0'}-${Date.now()}`,
|
||||
removeOnComplete: true,
|
||||
removeOnFail: 25
|
||||
},
|
||||
30_000
|
||||
);
|
||||
|
||||
const updated = await prisma.giveaway.findUnique({ where: { id: giveawayId } });
|
||||
if (!updated || updated.paused !== paused) {
|
||||
throw new GiveawayEndError(
|
||||
confirmed
|
||||
? 'Giveaway pause job finished without updating pause state'
|
||||
: 'Giveaway pause timed out – the bot may still be processing; refresh and retry'
|
||||
);
|
||||
}
|
||||
return toDashboard(updated);
|
||||
}
|
||||
|
||||
@@ -151,7 +172,7 @@ export async function endGiveawayDashboard(guildId: string, giveawayId: string):
|
||||
getGiveawayQueue(),
|
||||
getGiveawayQueueEvents(),
|
||||
'giveawayEnd',
|
||||
{ giveawayId },
|
||||
{ giveawayId, force: true },
|
||||
{ jobId: manualJobId, removeOnComplete: true, removeOnFail: 50, delay: 0 },
|
||||
30_000
|
||||
);
|
||||
@@ -181,6 +202,26 @@ export async function deleteGiveawayDashboard(guildId: string, giveawayId: strin
|
||||
|
||||
await safeRemoveJob(scheduledEndJobId(giveawayId));
|
||||
|
||||
await prisma.giveaway.delete({ where: { id: giveawayId } });
|
||||
const { confirmed } = await addJobAndAwait(
|
||||
getGiveawayQueue(),
|
||||
getGiveawayQueueEvents(),
|
||||
'giveawayDelete',
|
||||
{ giveawayId },
|
||||
{
|
||||
jobId: `giveaway-delete-${giveawayId}-${Date.now()}`,
|
||||
removeOnComplete: true,
|
||||
removeOnFail: 25
|
||||
},
|
||||
30_000
|
||||
);
|
||||
|
||||
const remaining = await prisma.giveaway.findUnique({ where: { id: giveawayId } });
|
||||
if (remaining) {
|
||||
throw new GiveawayEndError(
|
||||
confirmed
|
||||
? 'Giveaway delete job finished without deleting the giveaway'
|
||||
: 'Giveaway delete timed out – the bot may still be processing; refresh and retry'
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -136,7 +136,16 @@ export async function deleteScheduledMessageDashboard(guildId: string, scheduleI
|
||||
|
||||
if (existing.jobId) {
|
||||
const job = await getScheduleQueue().getJob(existing.jobId);
|
||||
await job?.remove();
|
||||
if (job) {
|
||||
try {
|
||||
await job.remove();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (!message.includes('locked') && !message.includes('could not be removed')) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.scheduledMessage.delete({ where: { id: scheduleId } });
|
||||
|
||||
@@ -32,11 +32,16 @@ export async function isOwnerUser(userId: string): Promise<boolean> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Owner-panel team members (any role) may open every guild where the bot is
|
||||
* Owner-panel team members with SUPPORT+ may open every guild where the bot is
|
||||
* installed — for support without needing Manage Guild on Discord.
|
||||
* VIEWER is owner-panel read-only and does not bypass guild dashboard APIs.
|
||||
*/
|
||||
export async function hasOwnerGuildBypass(userId: string): Promise<boolean> {
|
||||
return (await resolveOwnerRole(userId)) !== null;
|
||||
const role = await resolveOwnerRole(userId);
|
||||
if (!role) {
|
||||
return false;
|
||||
}
|
||||
return ownerRoleAtLeast(role, 'SUPPORT');
|
||||
}
|
||||
|
||||
export async function requireOwner(minimum: OwnerRole = 'VIEWER'): Promise<OwnerSession> {
|
||||
|
||||
@@ -140,9 +140,9 @@ const DEFAULT_WAIT_TIMEOUT_MS = 15_000;
|
||||
|
||||
/**
|
||||
* Adds a job and waits (bounded) for the bot worker to finish it, so the
|
||||
* dashboard can immediately reflect the result instead of polling. Falls
|
||||
* back to "not confirmed" (does not throw) on timeout - the DB row created
|
||||
* by the worker is still the source of truth and will show up on next load.
|
||||
* dashboard can immediately reflect the result instead of polling.
|
||||
* Enqueue / Redis failures are rethrown. Only waitUntilFinished timeouts
|
||||
* (and failed jobs) return `{ confirmed: false }`.
|
||||
*/
|
||||
export async function addJobAndAwait<T = unknown>(
|
||||
queue: Queue,
|
||||
@@ -156,7 +156,17 @@ export async function addJobAndAwait<T = unknown>(
|
||||
try {
|
||||
const result = (await job.waitUntilFinished(queueEvents, timeoutMs)) as T;
|
||||
return { confirmed: true, result };
|
||||
} catch {
|
||||
return { confirmed: false, result: null };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
// BullMQ timeout message typically includes "timed out"
|
||||
if (/timed out|timeout/i.test(message)) {
|
||||
return { confirmed: false, result: null };
|
||||
}
|
||||
// Job failed in the worker — treat as unconfirmed rather than crashing
|
||||
// the API with a raw stack when the dashboard can refresh from DB.
|
||||
if (/failed with reason|job.*failed/i.test(message)) {
|
||||
return { confirmed: false, result: null };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ const SESSION_TTL_SECONDS = 60 * 60 * 24 * 7;
|
||||
const SessionPayloadSchema = z.object({
|
||||
user: SessionUserSchema,
|
||||
accessToken: z.string().min(1),
|
||||
refreshToken: z.string().min(1).optional(),
|
||||
tokenExpiresAt: z.number().optional()
|
||||
});
|
||||
|
||||
@@ -88,13 +89,17 @@ export async function createSession(payload: SessionPayload): Promise<string> {
|
||||
return buildCookieValue(sessionId);
|
||||
}
|
||||
|
||||
export async function getSession(): Promise<SessionPayload | null> {
|
||||
async function readSessionIdFromCookie(): Promise<string | null> {
|
||||
const cookieStore = await cookies();
|
||||
const raw = cookieStore.get(SESSION_COOKIE_NAME)?.value;
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const sessionId = parseCookieValue(raw);
|
||||
return parseCookieValue(raw);
|
||||
}
|
||||
|
||||
export async function getSession(): Promise<SessionPayload | null> {
|
||||
const sessionId = await readSessionIdFromCookie();
|
||||
if (!sessionId) {
|
||||
return null;
|
||||
}
|
||||
@@ -102,18 +107,28 @@ export async function getSession(): Promise<SessionPayload | null> {
|
||||
if (!stored) {
|
||||
return null;
|
||||
}
|
||||
const parsed = SessionPayloadSchema.safeParse(JSON.parse(stored));
|
||||
return parsed.success ? parsed.data : null;
|
||||
try {
|
||||
const parsed = SessionPayloadSchema.safeParse(JSON.parse(stored));
|
||||
return parsed.success ? parsed.data : null;
|
||||
} catch {
|
||||
await redis.del(sessionRedisKey(sessionId));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Overwrites the current session payload in Redis (same session id / cookie). */
|
||||
export async function updateSession(payload: SessionPayload): Promise<void> {
|
||||
const sessionId = await readSessionIdFromCookie();
|
||||
if (!sessionId) {
|
||||
return;
|
||||
}
|
||||
await redis.set(sessionRedisKey(sessionId), JSON.stringify(payload), 'EX', SESSION_TTL_SECONDS);
|
||||
}
|
||||
|
||||
export async function destroySession(): Promise<void> {
|
||||
const cookieStore = await cookies();
|
||||
const raw = cookieStore.get(SESSION_COOKIE_NAME)?.value;
|
||||
if (raw) {
|
||||
const sessionId = parseCookieValue(raw);
|
||||
if (sessionId) {
|
||||
await redis.del(sessionRedisKey(sessionId));
|
||||
}
|
||||
const sessionId = await readSessionIdFromCookie();
|
||||
if (sessionId) {
|
||||
await redis.del(sessionRedisKey(sessionId));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user