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:
TheOnlyMace
2026-07-24 21:49:20 +02:00
parent c7fb3d7041
commit 57cdf847f5
10 changed files with 301 additions and 51 deletions

View File

@@ -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 {

View File

@@ -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'),

View File

@@ -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';

View File

@@ -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,

View File

@@ -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);
} }
} }

View File

@@ -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);

View File

@@ -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;
} }
cache.set(guildId, data); // 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);
} 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 };
} }

View File

@@ -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;
} }

View File

@@ -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
getGiveawayQueue(), // timer job still occupies that id in Redis.
getGiveawayQueueEvents(), const manualJobId = `giveaway-end-now-${giveawayId}-${Date.now()}`;
'giveawayEnd',
{ giveawayId }, let confirmed = false;
{ jobId: `giveaway-end-${giveawayId}`, removeOnComplete: true, removeOnFail: 50, delay: 0 }, try {
30_000 const result = await addJobAndAwait(
); getGiveawayQueue(),
getGiveawayQueueEvents(),
'giveawayEnd',
{ giveawayId },
{ jobId: manualJobId, removeOnComplete: true, removeOnFail: 50, delay: 0 },
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;

View File

@@ -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)