Implement overdue giveaway recovery and enhance giveaway command handling
- Added functionality to recover overdue giveaways during bot startup, ensuring that giveaways that have passed their end time are processed correctly. - Introduced `cancelGiveawayJob` to safely remove scheduled jobs, preventing issues with locked jobs. - Updated giveaway command handling to trim IDs and validate existence within the guild context, improving error handling and user feedback. - Enhanced the role picker component to normalize search queries and handle errors more gracefully, improving user experience. - Implemented caching strategies for guild roles to avoid stale or empty responses, ensuring more reliable data retrieval.
This commit is contained in:
@@ -116,6 +116,21 @@ if (!isShard) {
|
|||||||
logger.error({ error }, 'Failed to ensure recurring jobs');
|
logger.error({ error }, 'Failed to ensure recurring jobs');
|
||||||
captureException(error, { phase: 'ensureRecurringJobs' });
|
captureException(error, { phase: 'ensureRecurringJobs' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await runOncePerCluster(
|
||||||
|
'nexumi:lock:recover-overdue-giveaways',
|
||||||
|
60,
|
||||||
|
async () => {
|
||||||
|
const { recoverOverdueGiveaways } = await import('./modules/giveaways/index.js');
|
||||||
|
await recoverOverdueGiveaways(context);
|
||||||
|
},
|
||||||
|
'Overdue giveaway recovery'
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error }, 'Failed to recover overdue giveaways');
|
||||||
|
captureException(error, { phase: 'recoverOverdueGiveaways' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { requirePermission } from '../../permissions.js';
|
|||||||
import { parseDuration } from '../moderation/duration.js';
|
import { parseDuration } from '../moderation/duration.js';
|
||||||
import { giveawayCommandData } from './command-definitions.js';
|
import { giveawayCommandData } from './command-definitions.js';
|
||||||
import {
|
import {
|
||||||
|
cancelGiveawayJob,
|
||||||
deleteGiveaway,
|
deleteGiveaway,
|
||||||
endGiveaway,
|
endGiveaway,
|
||||||
GiveawayError,
|
GiveawayError,
|
||||||
@@ -100,14 +101,23 @@ const giveawayCommand: SlashCommand = {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (sub === 'end') {
|
if (sub === 'end') {
|
||||||
const id = interaction.options.getString('id', true);
|
const id = interaction.options.getString('id', true).trim();
|
||||||
|
const giveaway = await context.prisma.giveaway.findUnique({ where: { id } });
|
||||||
|
if (!giveaway || giveaway.guildId !== interaction.guildId) {
|
||||||
|
throw new GiveawayError('not_found');
|
||||||
|
}
|
||||||
|
await cancelGiveawayJob(id);
|
||||||
await endGiveaway(context, id);
|
await endGiveaway(context, id);
|
||||||
await interaction.reply({ content: t(locale, 'giveaway.ended'), ephemeral: true });
|
await interaction.reply({ content: t(locale, 'giveaway.ended'), ephemeral: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sub === 'reroll') {
|
if (sub === 'reroll') {
|
||||||
const id = interaction.options.getString('id', true);
|
const id = interaction.options.getString('id', true).trim();
|
||||||
|
const existing = await context.prisma.giveaway.findUnique({ where: { id } });
|
||||||
|
if (!existing || existing.guildId !== interaction.guildId) {
|
||||||
|
throw new GiveawayError('not_found');
|
||||||
|
}
|
||||||
const updated = await rerollGiveaway(context, id, locale);
|
const updated = await rerollGiveaway(context, id, locale);
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
content: tf(locale, 'giveaway.rerolled', {
|
content: tf(locale, 'giveaway.rerolled', {
|
||||||
@@ -136,14 +146,22 @@ const giveawayCommand: SlashCommand = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (sub === 'delete') {
|
if (sub === 'delete') {
|
||||||
const id = interaction.options.getString('id', true);
|
const id = interaction.options.getString('id', true).trim();
|
||||||
|
const existing = await context.prisma.giveaway.findUnique({ where: { id } });
|
||||||
|
if (!existing || existing.guildId !== interaction.guildId) {
|
||||||
|
throw new GiveawayError('not_found');
|
||||||
|
}
|
||||||
await deleteGiveaway(context, id);
|
await deleteGiveaway(context, id);
|
||||||
await interaction.reply({ content: t(locale, 'giveaway.deleted'), ephemeral: true });
|
await interaction.reply({ content: t(locale, 'giveaway.deleted'), ephemeral: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sub === 'pause') {
|
if (sub === 'pause') {
|
||||||
const id = interaction.options.getString('id', true);
|
const id = interaction.options.getString('id', true).trim();
|
||||||
|
const existing = await context.prisma.giveaway.findUnique({ where: { id } });
|
||||||
|
if (!existing || existing.guildId !== interaction.guildId) {
|
||||||
|
throw new GiveawayError('not_found');
|
||||||
|
}
|
||||||
const updated = await pauseGiveaway(context, id, locale);
|
const updated = await pauseGiveaway(context, id, locale);
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
content: t(locale, updated.paused ? 'giveaway.paused' : 'giveaway.resumed'),
|
content: t(locale, updated.paused ? 'giveaway.paused' : 'giveaway.resumed'),
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
export { giveawayCommands } from './commands.js';
|
export { giveawayCommands } from './commands.js';
|
||||||
export { isGiveawayButton, handleGiveawayButton } from './buttons.js';
|
export { isGiveawayButton, handleGiveawayButton } from './buttons.js';
|
||||||
export { endGiveaway, scheduleGiveawayEnd, runGiveawayCreateJob, GiveawayError } from './service.js';
|
export { endGiveaway, scheduleGiveawayEnd, runGiveawayCreateJob, recoverOverdueGiveaways, GiveawayError } from './service.js';
|
||||||
|
|||||||
@@ -22,25 +22,46 @@ export class GiveawayError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function giveawayEndJobId(giveawayId: string): string {
|
||||||
|
return `giveaway-end-${giveawayId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a scheduled/failed end job if present. Locked (active) jobs cannot be
|
||||||
|
* removed — callers must not invoke this from inside the giveawayEnd worker.
|
||||||
|
*/
|
||||||
|
export async function cancelGiveawayJob(giveawayId: string): Promise<void> {
|
||||||
|
const job = await giveawayQueue.getJob(giveawayEndJobId(giveawayId));
|
||||||
|
if (!job) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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')) {
|
||||||
|
logger.warn({ giveawayId, error }, 'Giveaway end job is locked; skipping remove');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function scheduleGiveawayEnd(giveawayId: string, endsAt: Date): Promise<void> {
|
export async function scheduleGiveawayEnd(giveawayId: string, endsAt: Date): Promise<void> {
|
||||||
|
await cancelGiveawayJob(giveawayId);
|
||||||
const delay = Math.max(0, endsAt.getTime() - Date.now());
|
const delay = Math.max(0, endsAt.getTime() - Date.now());
|
||||||
await giveawayQueue.add(
|
await giveawayQueue.add(
|
||||||
'giveawayEnd',
|
'giveawayEnd',
|
||||||
{ giveawayId },
|
{ giveawayId },
|
||||||
{
|
{
|
||||||
delay,
|
delay,
|
||||||
jobId: `giveaway-end-${giveawayId}`,
|
jobId: giveawayEndJobId(giveawayId),
|
||||||
removeOnComplete: true,
|
removeOnComplete: true,
|
||||||
removeOnFail: 50
|
removeOnFail: 50
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cancelGiveawayJob(giveawayId: string): Promise<void> {
|
|
||||||
const job = await giveawayQueue.getJob(`giveaway-end-${giveawayId}`);
|
|
||||||
await job?.remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
function requirementLines(locale: 'de' | 'en', giveaway: Giveaway): string[] {
|
function requirementLines(locale: 'de' | 'en', giveaway: Giveaway): string[] {
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
if (giveaway.requiredRoleId) {
|
if (giveaway.requiredRoleId) {
|
||||||
@@ -316,6 +337,12 @@ async function dmWinners(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draws winners and marks the giveaway ended. Does **not** cancel BullMQ jobs —
|
||||||
|
* the slash-command / dashboard paths cancel the delayed job first; the
|
||||||
|
* `giveawayEnd` worker must never remove its own locked job (that threw and
|
||||||
|
* left giveaways stuck with the join button still visible).
|
||||||
|
*/
|
||||||
export async function endGiveaway(context: BotContext, giveawayId: string): Promise<Giveaway> {
|
export async function endGiveaway(context: BotContext, giveawayId: string): Promise<Giveaway> {
|
||||||
const giveaway = await context.prisma.giveaway.findUnique({ where: { id: giveawayId } });
|
const giveaway = await context.prisma.giveaway.findUnique({ where: { id: giveawayId } });
|
||||||
if (!giveaway) {
|
if (!giveaway) {
|
||||||
@@ -325,7 +352,6 @@ export async function endGiveaway(context: BotContext, giveawayId: string): Prom
|
|||||||
throw new GiveawayError('already_ended');
|
throw new GiveawayError('already_ended');
|
||||||
}
|
}
|
||||||
|
|
||||||
await cancelGiveawayJob(giveawayId);
|
|
||||||
const locale = await getGuildLocale(context.prisma, giveaway.guildId);
|
const locale = await getGuildLocale(context.prisma, giveaway.guildId);
|
||||||
const guild = await context.client.guilds.fetch(giveaway.guildId);
|
const guild = await context.client.guilds.fetch(giveaway.guildId);
|
||||||
const eligible = await filterEligibleEntrants(context, guild, giveaway);
|
const eligible = await filterEligibleEntrants(context, guild, giveaway);
|
||||||
@@ -347,6 +373,40 @@ export async function endGiveaway(context: BotContext, giveawayId: string): Prom
|
|||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ends giveaways whose `endsAt` has passed but `endedAt` is still null
|
||||||
|
* (e.g. after a failed/locked BullMQ job). Safe to call on bot ready.
|
||||||
|
*/
|
||||||
|
export async function recoverOverdueGiveaways(context: BotContext): Promise<number> {
|
||||||
|
const overdue = await context.prisma.giveaway.findMany({
|
||||||
|
where: {
|
||||||
|
endedAt: null,
|
||||||
|
endsAt: { lte: new Date() }
|
||||||
|
},
|
||||||
|
take: 100,
|
||||||
|
orderBy: { endsAt: 'asc' }
|
||||||
|
});
|
||||||
|
|
||||||
|
let recovered = 0;
|
||||||
|
for (const giveaway of overdue) {
|
||||||
|
try {
|
||||||
|
await cancelGiveawayJob(giveaway.id);
|
||||||
|
await endGiveaway(context, giveaway.id);
|
||||||
|
recovered += 1;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof GiveawayError && error.code === 'already_ended') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
logger.error({ error, giveawayId: giveaway.id }, 'Failed to recover overdue giveaway');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recovered > 0) {
|
||||||
|
logger.info({ recovered }, 'Recovered overdue giveaways');
|
||||||
|
}
|
||||||
|
return recovered;
|
||||||
|
}
|
||||||
|
|
||||||
export async function rerollGiveaway(
|
export async function rerollGiveaway(
|
||||||
context: BotContext,
|
context: BotContext,
|
||||||
giveawayId: string,
|
giveawayId: string,
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ import { GiveawayActionSchema } from '@nexumi/shared';
|
|||||||
import { type NextRequest, NextResponse } from 'next/server';
|
import { type NextRequest, NextResponse } from 'next/server';
|
||||||
import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
|
import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
|
||||||
import { writeDashboardAudit } from '@/lib/audit';
|
import { writeDashboardAudit } from '@/lib/audit';
|
||||||
import { endGiveawayDashboard, setGiveawayPaused } from '@/lib/module-configs/giveaways';
|
import {
|
||||||
|
endGiveawayDashboard,
|
||||||
|
GiveawayEndError,
|
||||||
|
setGiveawayPaused
|
||||||
|
} from '@/lib/module-configs/giveaways';
|
||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
interface RouteParams {
|
interface RouteParams {
|
||||||
@@ -39,6 +43,9 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
|||||||
|
|
||||||
return NextResponse.json(giveaway);
|
return NextResponse.json(giveaway);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof GiveawayEndError) {
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 502 });
|
||||||
|
}
|
||||||
return toApiErrorResponse(error);
|
return toApiErrorResponse(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ function toOptions(roles: DiscordRoleOption[]) {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeSearch(value: string): string {
|
||||||
|
return value.trim().toLocaleLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
interface RoleSelectBaseProps {
|
interface RoleSelectBaseProps {
|
||||||
guildId: string;
|
guildId: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
@@ -48,14 +52,33 @@ export function DiscordRoleSelect({
|
|||||||
allowClear = true
|
allowClear = true
|
||||||
}: DiscordRoleSelectProps) {
|
}: DiscordRoleSelectProps) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const { roles, loading } = useGuildRoles(guildId);
|
const { roles, loading, error, reload } = useGuildRoles(guildId);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
|
||||||
const options = useMemo(() => toOptions(roles), [roles]);
|
const options = useMemo(() => toOptions(roles), [roles]);
|
||||||
const selected = options.find((option) => option.id === value);
|
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]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
<Popover
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(next) => {
|
||||||
|
setOpen(next);
|
||||||
|
if (!next) {
|
||||||
|
setQuery('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -74,10 +97,22 @@ export function DiscordRoleSelect({
|
|||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="p-0" align="start">
|
<PopoverContent className="p-0" align="start">
|
||||||
<Command>
|
<Command shouldFilter={false}>
|
||||||
<CommandInput placeholder={placeholder ?? t('modulePages.commands.roleSearchPlaceholder')} />
|
<CommandInput
|
||||||
|
value={query}
|
||||||
|
onValueChange={setQuery}
|
||||||
|
placeholder={placeholder ?? t('modulePages.commands.roleSearchPlaceholder')}
|
||||||
|
/>
|
||||||
<CommandList>
|
<CommandList>
|
||||||
<CommandEmpty>{t('common.noResults')}</CommandEmpty>
|
<CommandEmpty>
|
||||||
|
{error ? (
|
||||||
|
<button type="button" className="underline" onClick={() => reload()}>
|
||||||
|
{t('common.saveError')}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
t('common.noResults')
|
||||||
|
)}
|
||||||
|
</CommandEmpty>
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
{allowClear && value ? (
|
{allowClear && value ? (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
@@ -91,10 +126,10 @@ export function DiscordRoleSelect({
|
|||||||
<span className="text-muted-foreground">{t('common.clearSelection')}</span>
|
<span className="text-muted-foreground">{t('common.clearSelection')}</span>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
) : null}
|
) : null}
|
||||||
{options.map((option) => (
|
{filtered.map((option) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={option.id}
|
key={option.id}
|
||||||
value={`${option.prefix}${option.name} ${option.id}`}
|
value={`${option.name} ${option.id}`}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
onChange(option.id);
|
onChange(option.id);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
|
|||||||
@@ -1,44 +1,68 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { DiscordRoleOption } from '@nexumi/shared';
|
import type { DiscordRoleOption } from '@nexumi/shared';
|
||||||
import { useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
const cache = new Map<string, DiscordRoleOption[]>();
|
const cache = new Map<string, DiscordRoleOption[]>();
|
||||||
|
|
||||||
export function useGuildRoles(guildId: string): {
|
export function useGuildRoles(guildId: string): {
|
||||||
roles: DiscordRoleOption[];
|
roles: DiscordRoleOption[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
|
error: boolean;
|
||||||
|
reload: () => void;
|
||||||
} {
|
} {
|
||||||
const [roles, setRoles] = useState<DiscordRoleOption[]>(() => cache.get(guildId) ?? []);
|
const [roles, setRoles] = useState<DiscordRoleOption[]>(() => cache.get(guildId) ?? []);
|
||||||
const [loading, setLoading] = useState(!cache.has(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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const cached = cache.get(guildId);
|
const cached = reloadToken === 0 ? cache.get(guildId) : undefined;
|
||||||
if (cached) {
|
if (cached) {
|
||||||
setRoles(cached);
|
setRoles(cached);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
setError(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setError(false);
|
||||||
void fetch(`/api/guilds/${guildId}/roles`)
|
void fetch(`/api/guilds/${guildId}/roles`)
|
||||||
.then(async (response) => {
|
.then(async (response) => {
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
return [] as DiscordRoleOption[];
|
throw new Error(`Roles request failed with ${response.status}`);
|
||||||
}
|
}
|
||||||
return (await response.json()) as DiscordRoleOption[];
|
const data = (await response.json()) as DiscordRoleOption[];
|
||||||
|
if (!Array.isArray(data)) {
|
||||||
|
throw new Error('Roles response was not an array');
|
||||||
|
}
|
||||||
|
return data;
|
||||||
})
|
})
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (cancelled) {
|
if (cancelled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Only cache successful non-empty payloads so a one-off failure
|
||||||
|
// cannot stick the picker on "Keine Treffer".
|
||||||
|
if (data.length > 0) {
|
||||||
cache.set(guildId, data);
|
cache.set(guildId, data);
|
||||||
|
} else {
|
||||||
|
cache.delete(guildId);
|
||||||
|
}
|
||||||
setRoles(data);
|
setRoles(data);
|
||||||
|
setError(false);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
|
cache.delete(guildId);
|
||||||
setRoles([]);
|
setRoles([]);
|
||||||
|
setError(true);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
@@ -50,7 +74,7 @@ export function useGuildRoles(guildId: string): {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [guildId]);
|
}, [guildId, reloadToken]);
|
||||||
|
|
||||||
return { roles, loading };
|
return { roles, loading, error, reload };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,11 +45,38 @@ interface DiscordRoleRest {
|
|||||||
managed?: boolean;
|
managed?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function readCache<T>(cacheKey: string): Promise<T | null> {
|
||||||
|
try {
|
||||||
|
if (redis.status === 'wait') {
|
||||||
|
await redis.connect();
|
||||||
|
}
|
||||||
|
const cached = await redis.get(cacheKey);
|
||||||
|
if (!cached) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return JSON.parse(cached) as T;
|
||||||
|
} catch {
|
||||||
|
// Cache is best-effort; fall through to Discord API.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeCache(cacheKey: string, value: unknown): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (redis.status === 'wait') {
|
||||||
|
await redis.connect();
|
||||||
|
}
|
||||||
|
await redis.set(cacheKey, JSON.stringify(value), 'EX', CACHE_TTL_SECONDS);
|
||||||
|
} catch {
|
||||||
|
// ignore cache write failures
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function listGuildChannelsForDashboard(guildId: string): Promise<DiscordChannelOption[]> {
|
export async function listGuildChannelsForDashboard(guildId: string): Promise<DiscordChannelOption[]> {
|
||||||
const cacheKey = `dashboard:guild:${guildId}:channels:v2`;
|
const cacheKey = `dashboard:guild:${guildId}:channels:v2`;
|
||||||
const cached = await redis.get(cacheKey);
|
const cached = await readCache<DiscordChannelOption[]>(cacheKey);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
return JSON.parse(cached) as DiscordChannelOption[];
|
return cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
const channels = await discordBotFetch<DiscordChannelRest[]>(`/guilds/${guildId}/channels`);
|
const channels = await discordBotFetch<DiscordChannelRest[]>(`/guilds/${guildId}/channels`);
|
||||||
@@ -63,18 +90,23 @@ export async function listGuildChannelsForDashboard(guildId: string): Promise<Di
|
|||||||
}))
|
}))
|
||||||
.sort((a, b) => a.name.localeCompare(b.name));
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
await redis.set(cacheKey, JSON.stringify(options), 'EX', CACHE_TTL_SECONDS);
|
await writeCache(cacheKey, options);
|
||||||
return options;
|
return options;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listGuildRolesForDashboard(guildId: string): Promise<DiscordRoleOption[]> {
|
export async function listGuildRolesForDashboard(guildId: string): Promise<DiscordRoleOption[]> {
|
||||||
const cacheKey = `dashboard:guild:${guildId}:roles`;
|
// v2: invalidate any stale empty/corrupt cache entries from earlier bugs
|
||||||
const cached = await redis.get(cacheKey);
|
const cacheKey = `dashboard:guild:${guildId}:roles:v2`;
|
||||||
if (cached) {
|
const cached = await readCache<DiscordRoleOption[]>(cacheKey);
|
||||||
return JSON.parse(cached) as DiscordRoleOption[];
|
if (cached && cached.length > 0) {
|
||||||
|
return cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
const roles = await discordBotFetch<DiscordRoleRest[]>(`/guilds/${guildId}/roles`);
|
const roles = await discordBotFetch<DiscordRoleRest[]>(`/guilds/${guildId}/roles`);
|
||||||
|
if (!Array.isArray(roles)) {
|
||||||
|
throw new Error(`Discord API returned non-array roles for guild ${guildId}`);
|
||||||
|
}
|
||||||
|
|
||||||
const options = roles
|
const options = roles
|
||||||
.filter((role) => role.name !== '@everyone')
|
.filter((role) => role.name !== '@everyone')
|
||||||
.map((role) => ({
|
.map((role) => ({
|
||||||
@@ -85,7 +117,11 @@ export async function listGuildRolesForDashboard(guildId: string): Promise<Disco
|
|||||||
}))
|
}))
|
||||||
.sort((a, b) => b.position - a.position);
|
.sort((a, b) => b.position - a.position);
|
||||||
|
|
||||||
await redis.set(cacheKey, JSON.stringify(options), 'EX', CACHE_TTL_SECONDS);
|
// Never cache an empty list — that freezes "Keine Treffer" for the TTL
|
||||||
|
// after a transient Discord/API glitch.
|
||||||
|
if (options.length > 0) {
|
||||||
|
await writeCache(cacheKey, options);
|
||||||
|
}
|
||||||
return options;
|
return options;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,13 @@ export class GiveawayCreateTimeoutError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class GiveawayEndError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'GiveawayEndError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function toDashboard(giveaway: Giveaway): GiveawayDashboard {
|
function toDashboard(giveaway: Giveaway): GiveawayDashboard {
|
||||||
return {
|
return {
|
||||||
id: giveaway.id,
|
id: giveaway.id,
|
||||||
@@ -30,6 +37,26 @@ function toDashboard(giveaway: Giveaway): GiveawayDashboard {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scheduledEndJobId(giveawayId: string): string {
|
||||||
|
return `giveaway-end-${giveawayId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function safeRemoveJob(jobId: string): Promise<void> {
|
||||||
|
const job = await getGiveawayQueue().getJob(jobId);
|
||||||
|
if (!job) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function listGiveaways(guildId: string): Promise<GiveawayDashboard[]> {
|
export async function listGiveaways(guildId: string): Promise<GiveawayDashboard[]> {
|
||||||
const giveaways = await prisma.giveaway.findMany({
|
const giveaways = await prisma.giveaway.findMany({
|
||||||
where: { guildId },
|
where: { guildId },
|
||||||
@@ -102,9 +129,9 @@ export async function setGiveawayPaused(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ends a giveaway immediately by re-scheduling its `giveawayEnd` job with no
|
* Ends a giveaway immediately by enqueueing a one-shot `giveawayEnd` job
|
||||||
* delay and waiting for the bot to draw winners, update the Discord message
|
* (unique jobId so it never collides with the scheduled/failed timer job)
|
||||||
* and DM them - see `apps/bot/src/modules/giveaways/service.ts#endGiveaway`.
|
* and waiting for the bot to draw winners.
|
||||||
*/
|
*/
|
||||||
export async function endGiveawayDashboard(guildId: string, giveawayId: string): Promise<GiveawayDashboard | null> {
|
export async function endGiveawayDashboard(guildId: string, giveawayId: string): Promise<GiveawayDashboard | null> {
|
||||||
const existing = await prisma.giveaway.findFirst({ where: { id: giveawayId, guildId } });
|
const existing = await prisma.giveaway.findFirst({ where: { id: giveawayId, guildId } });
|
||||||
@@ -112,21 +139,32 @@ export async function endGiveawayDashboard(guildId: string, giveawayId: string):
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingJob = await getGiveawayQueue().getJob(`giveaway-end-${giveawayId}`);
|
await safeRemoveJob(scheduledEndJobId(giveawayId));
|
||||||
await existingJob?.remove();
|
|
||||||
|
|
||||||
const { confirmed } = await addJobAndAwait(
|
// Unique jobId: reusing `giveaway-end-${id}` fails when a failed/locked
|
||||||
|
// timer job still occupies that id in Redis.
|
||||||
|
const manualJobId = `giveaway-end-now-${giveawayId}-${Date.now()}`;
|
||||||
|
|
||||||
|
let confirmed = false;
|
||||||
|
try {
|
||||||
|
const result = await addJobAndAwait(
|
||||||
getGiveawayQueue(),
|
getGiveawayQueue(),
|
||||||
getGiveawayQueueEvents(),
|
getGiveawayQueueEvents(),
|
||||||
'giveawayEnd',
|
'giveawayEnd',
|
||||||
{ giveawayId },
|
{ giveawayId },
|
||||||
{ jobId: `giveaway-end-${giveawayId}`, removeOnComplete: true, removeOnFail: 50, delay: 0 },
|
{ jobId: manualJobId, removeOnComplete: true, removeOnFail: 50, delay: 0 },
|
||||||
30_000
|
30_000
|
||||||
);
|
);
|
||||||
|
confirmed = result.confirmed;
|
||||||
|
} catch (error) {
|
||||||
|
throw new GiveawayEndError(
|
||||||
|
error instanceof Error ? error.message : 'Failed to enqueue giveaway end job'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const updated = await prisma.giveaway.findUnique({ where: { id: giveawayId } });
|
const updated = await prisma.giveaway.findUnique({ where: { id: giveawayId } });
|
||||||
if (!updated?.endedAt) {
|
if (!updated?.endedAt) {
|
||||||
throw new Error(
|
throw new GiveawayEndError(
|
||||||
confirmed
|
confirmed
|
||||||
? 'Giveaway end job finished without marking the giveaway as ended'
|
? 'Giveaway end job finished without marking the giveaway as ended'
|
||||||
: 'Giveaway end timed out – the bot may still be processing; refresh and retry'
|
: 'Giveaway end timed out – the bot may still be processing; refresh and retry'
|
||||||
@@ -141,8 +179,7 @@ export async function deleteGiveawayDashboard(guildId: string, giveawayId: strin
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const job = await getGiveawayQueue().getJob(`giveaway-end-${giveawayId}`);
|
await safeRemoveJob(scheduledEndJobId(giveawayId));
|
||||||
await job?.remove();
|
|
||||||
|
|
||||||
await prisma.giveaway.delete({ where: { id: giveawayId } });
|
await prisma.giveaway.delete({ where: { id: giveawayId } });
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -272,6 +272,24 @@
|
|||||||
- [ ] `/giveaway end` mit falscher ID → Fehlermeldung; mit korrekter ID → Reroll möglich
|
- [ ] `/giveaway end` mit falscher ID → Fehlermeldung; mit korrekter ID → Reroll möglich
|
||||||
- [ ] Dashboard „Jetzt beenden“ → Status beendet, Discord-Nachricht aktualisiert
|
- [ ] Dashboard „Jetzt beenden“ → Status beendet, Discord-Nachricht aktualisiert
|
||||||
|
|
||||||
|
## Post-Phase – Giveaway End Fix + Role Picker (Status: implementiert)
|
||||||
|
|
||||||
|
### Abgeschlossen (Code)
|
||||||
|
|
||||||
|
- Root cause: `endGiveaway` removed its own locked BullMQ job → End-Job crashte vor DB-Update (Timer/UI/Command hingen)
|
||||||
|
- `cancelGiveawayJob` nur noch außerhalb des Workers (Command/Dashboard); locked remove wird ignoriert
|
||||||
|
- Dashboard-End nutzt eindeutige Job-IDs (`giveaway-end-now-…`); klarere 502-Fehler statt generischem 500
|
||||||
|
- Bot-Start: `recoverOverdueGiveaways` beendet überfällige aktive Giveaways
|
||||||
|
- Role-Picker: kein Cache von leeren/fehlerhaften Responses; Redis-Cache best-effort; manuelle Filter statt cmdk-Default
|
||||||
|
|
||||||
|
### Manuell testen
|
||||||
|
|
||||||
|
- [ ] Giveaway starten (kurz), warten bis Timer abläuft → Embed „Beendet“, Button weg, Gewinner gezogen
|
||||||
|
- [ ] Giveaway vorzeitig per Dashboard „Jetzt beenden“ → kein Internal Server Error
|
||||||
|
- [ ] `/giveaway list` → ID kopieren → `/giveaway end id:…` → Erfolg
|
||||||
|
- [ ] Verifizierung: Rollen-Dropdown zeigt Serverrollen (nicht nur „Keine Treffer“)
|
||||||
|
- [ ] Nach Deploy: bereits hängende Giveaways werden beim Bot-Start recovered
|
||||||
|
|
||||||
## Post-Phase – Starboard Verbesserungen (Status: implementiert)
|
## Post-Phase – Starboard Verbesserungen (Status: implementiert)
|
||||||
|
|
||||||
### Abgeschlossen (Code)
|
### Abgeschlossen (Code)
|
||||||
|
|||||||
Reference in New Issue
Block a user