From f2406fb6d78ea45d6a7e8e49ed591505c43f5fc5 Mon Sep 17 00:00:00 2001 From: smueller Date: Fri, 24 Jul 2026 10:33:56 +0200 Subject: [PATCH] fix: Giveaway bugs --- apps/bot/src/jobs.ts | 14 ++++++- apps/bot/src/modules/giveaways/index.ts | 2 +- apps/bot/src/modules/giveaways/service.ts | 37 +++++++++++-------- .../components/modules/giveaways-manager.tsx | 15 ++++++-- .../webui/src/lib/module-configs/giveaways.ts | 14 +++++-- docs/PHASE-TRACKING.md | 15 +++++++- packages/shared/src/index.ts | 2 + 7 files changed, 74 insertions(+), 25 deletions(-) diff --git a/apps/bot/src/jobs.ts b/apps/bot/src/jobs.ts index f32e65c..aeb429c 100644 --- a/apps/bot/src/jobs.ts +++ b/apps/bot/src/jobs.ts @@ -13,7 +13,7 @@ import { completeVerification } from './modules/verification/handlers.js'; import { closePoll } from './modules/utility/poll.js'; import { sendReminder } from './modules/utility/reminders.js'; import { expireSelfRole } from './modules/selfroles/service.js'; -import { endGiveaway, runGiveawayCreateJob } from './modules/giveaways/index.js'; +import { endGiveaway, runGiveawayCreateJob, GiveawayError } from './modules/giveaways/index.js'; import { closeInactiveTickets } from './modules/tickets/index.js'; import { expireBirthdayRole, runBirthdayCheck } from './modules/birthdays/index.js'; import { ensureStatsRecurringJobs, startStatsWorker } from './modules/stats/index.js'; @@ -303,7 +303,17 @@ export function startWorkers(context: BotContext): Worker[] { giveawayQueueName, async (job) => { if (job.name === 'giveawayEnd') { - await endGiveaway(context, (job.data as GiveawayEndJob).giveawayId); + try { + await endGiveaway(context, (job.data as GiveawayEndJob).giveawayId); + } catch (error) { + if ( + error instanceof GiveawayError && + (error.code === 'already_ended' || error.code === 'not_found') + ) { + return; + } + throw error; + } return; } if (job.name === 'giveawayCreate') { diff --git a/apps/bot/src/modules/giveaways/index.ts b/apps/bot/src/modules/giveaways/index.ts index a27c919..6423d4d 100644 --- a/apps/bot/src/modules/giveaways/index.ts +++ b/apps/bot/src/modules/giveaways/index.ts @@ -1,3 +1,3 @@ export { giveawayCommands } from './commands.js'; export { isGiveawayButton, handleGiveawayButton } from './buttons.js'; -export { endGiveaway, scheduleGiveawayEnd, runGiveawayCreateJob } from './service.js'; +export { endGiveaway, scheduleGiveawayEnd, runGiveawayCreateJob, GiveawayError } from './service.js'; diff --git a/apps/bot/src/modules/giveaways/service.ts b/apps/bot/src/modules/giveaways/service.ts index 161a395..449bd0a 100644 --- a/apps/bot/src/modules/giveaways/service.ts +++ b/apps/bot/src/modules/giveaways/service.ts @@ -59,6 +59,11 @@ function requirementLines(locale: 'de' | 'en', giveaway: Giveaway): string[] { export function buildGiveawayEmbed(locale: 'de' | 'en', giveaway: Giveaway): EmbedBuilder { const ended = giveaway.endedAt !== null; + const endsAtUnix = Math.floor(giveaway.endsAt.getTime() / 1000); + // Discord does not parse timestamps in embed footers — only in + // description/fields/content. Keep relative + absolute for clarity. + const endsAtText = ` ()`; + const embed = new EmbedBuilder() .setTitle(t(locale, 'giveaway.embed.title')) .setColor(ended ? 0x6b7280 : 0x6366f1) @@ -86,19 +91,17 @@ export function buildGiveawayEmbed(locale: 'de' | 'en', giveaway: Giveaway): Emb ); embed.setFooter({ text: t(locale, 'giveaway.embed.ended') }); } else if (giveaway.paused) { - embed.setDescription(t(locale, 'giveaway.embed.paused')); - embed.setFooter({ - text: tf(locale, 'giveaway.embed.endsAt', { - time: `` - }) - }); + embed.setDescription( + `${t(locale, 'giveaway.embed.paused')}\n\n${tf(locale, 'giveaway.embed.endsAt', { + time: endsAtText + })}` + ); } else { - embed.setDescription(t(locale, 'giveaway.embed.active')); - embed.setFooter({ - text: tf(locale, 'giveaway.embed.endsAt', { - time: `` - }) - }); + embed.setDescription( + `${t(locale, 'giveaway.embed.active')}\n\n${tf(locale, 'giveaway.embed.endsAt', { + time: endsAtText + })}` + ); } const requirements = requirementLines(locale, giveaway); @@ -313,10 +316,13 @@ async function dmWinners( } } -export async function endGiveaway(context: BotContext, giveawayId: string): Promise { +export async function endGiveaway(context: BotContext, giveawayId: string): Promise { const giveaway = await context.prisma.giveaway.findUnique({ where: { id: giveawayId } }); - if (!giveaway || giveaway.endedAt) { - return; + if (!giveaway) { + throw new GiveawayError('not_found'); + } + if (giveaway.endedAt) { + throw new GiveawayError('already_ended'); } await cancelGiveawayJob(giveawayId); @@ -338,6 +344,7 @@ export async function endGiveaway(context: BotContext, giveawayId: string): Prom if (winners.length > 0) { await dmWinners(context, updated, winners, locale); } + return updated; } export async function rerollGiveaway( diff --git a/apps/webui/src/components/modules/giveaways-manager.tsx b/apps/webui/src/components/modules/giveaways-manager.tsx index 2d285b5..5c2a4e1 100644 --- a/apps/webui/src/components/modules/giveaways-manager.tsx +++ b/apps/webui/src/components/modules/giveaways-manager.tsx @@ -83,9 +83,18 @@ export function GiveawaysManager({ guildId, initialGiveaways }: GiveawaysManager headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action }) }); - const body = (await response.json().catch(() => null)) as (GiveawayDashboard & { error?: string }) | null; - if (!response.ok || !body) { - toast.error(body?.error ?? t('common.saveError')); + const body = (await response.json().catch(() => null)) as + | (GiveawayDashboard & { error?: string }) + | { error?: string } + | null; + if (!response.ok || !body || !('id' in body)) { + toast.error( + (body && 'error' in body && body.error) || t('common.saveError') + ); + return; + } + if (action === 'end' && !body.endedAt) { + toast.error(t('common.saveError')); return; } setGiveaways((prev) => prev.map((entry) => (entry.id === giveaway.id ? body : entry))); diff --git a/apps/webui/src/lib/module-configs/giveaways.ts b/apps/webui/src/lib/module-configs/giveaways.ts index 1a6413d..1ce2629 100644 --- a/apps/webui/src/lib/module-configs/giveaways.ts +++ b/apps/webui/src/lib/module-configs/giveaways.ts @@ -115,16 +115,24 @@ export async function endGiveawayDashboard(guildId: string, giveawayId: string): const existingJob = await getGiveawayQueue().getJob(`giveaway-end-${giveawayId}`); await existingJob?.remove(); - await addJobAndAwait( + const { confirmed } = await addJobAndAwait( getGiveawayQueue(), getGiveawayQueueEvents(), 'giveawayEnd', { giveawayId }, - { jobId: `giveaway-end-${giveawayId}`, removeOnComplete: true, removeOnFail: 50, delay: 0 } + { jobId: `giveaway-end-${giveawayId}`, removeOnComplete: true, removeOnFail: 50, delay: 0 }, + 30_000 ); const updated = await prisma.giveaway.findUnique({ where: { id: giveawayId } }); - return updated ? toDashboard(updated) : null; + if (!updated?.endedAt) { + throw new Error( + confirmed + ? 'Giveaway end job finished without marking the giveaway as ended' + : 'Giveaway end timed out – the bot may still be processing; refresh and retry' + ); + } + return toDashboard(updated); } export async function deleteGiveawayDashboard(guildId: string, giveawayId: string): Promise { diff --git a/docs/PHASE-TRACKING.md b/docs/PHASE-TRACKING.md index 5380244..853c690 100644 --- a/docs/PHASE-TRACKING.md +++ b/docs/PHASE-TRACKING.md @@ -258,7 +258,20 @@ - [ ] Stack an/aus: stapeln vs. nur aktuelle Belohnungsrolle - [ ] Doppeltes Level → Fehlermeldung, speichern blockiert -## Post-Phase – Automod/Moderation Dashboard Ausbau (Status: implementiert) +## Post-Phase – Giveaway Bugs (Timestamp + End-Status) (Status: implementiert) + +### Abgeschlossen (Code) + +- Embed: Endzeit als Discord-Timestamp in der **Description** (Footer rendert `` nicht) +- `/giveaway end` wirft bei fehlender/bereits beendeter ID Fehler statt Fake-Erfolg +- Dashboard „Jetzt beenden“ prüft `endedAt` nach Job; Timeout/Fehler → kein Erfolgs-Toast + +### Manuell testen + +- [ ] Neues Giveaway: Endzeit als „in X Stunden“ sichtbar, nicht als Roh-`` +- [ ] `/giveaway end` mit falscher ID → Fehlermeldung; mit korrekter ID → Reroll möglich +- [ ] Dashboard „Jetzt beenden“ → Status beendet, Discord-Nachricht aktualisiert + ### Abgeschlossen (Code) diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 74dda70..cd2fa0e 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -395,6 +395,7 @@ const de: Dictionary = { 'giveaway.list.entrants': 'Teilnehmer', 'giveaway.dm.won': 'Glückwunsch! Du hast **{prize}** auf **{guild}** gewonnen!', 'giveaway.error.not_found': 'Giveaway nicht gefunden oder bereits beendet.', + 'giveaway.error.already_ended': 'Dieses Giveaway ist bereits beendet.', 'giveaway.error.not_ended': 'Giveaway ist noch nicht beendet.', 'giveaway.error.paused': 'Dieses Giveaway ist pausiert.', 'giveaway.error.already_joined': 'Du nimmst bereits teil.', @@ -980,6 +981,7 @@ const en: Dictionary = { 'giveaway.list.entrants': 'entrants', 'giveaway.dm.won': 'Congratulations! You won **{prize}** on **{guild}**!', 'giveaway.error.not_found': 'Giveaway not found or already ended.', + 'giveaway.error.already_ended': 'This giveaway has already ended.', 'giveaway.error.not_ended': 'Giveaway has not ended yet.', 'giveaway.error.paused': 'This giveaway is paused.', 'giveaway.error.already_joined': 'You already entered.',