Refactor giveaway job processing and update environment documentation

- Enhanced the handling of the `giveawayCreate` job in the bot's job processing for improved consistency.
- Clarified the usage of `BOT_TOKEN` in the `.env.example` file for both the bot and WebUI.
- Integrated the `bullmq` dependency into the WebUI package to bolster job management capabilities.
This commit is contained in:
smueller
2026-07-22 15:34:20 +02:00
parent 387fbf11da
commit 0ac69ee840
16 changed files with 654 additions and 0 deletions

View File

@@ -0,0 +1,78 @@
'use client';
import type { BirthdayConfigDashboard } from '@nexumi/shared';
import { useId } from 'react';
import { useTranslations } from '@/components/locale-provider';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import type { SettingsSaveResult } from '@/components/settings/settings-form';
import { SettingsForm } from '@/components/settings/settings-form';
async function saveBirthdays(guildId: string, value: BirthdayConfigDashboard): Promise<SettingsSaveResult> {
try {
const response = await fetch(`/api/guilds/${guildId}/birthdays`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(value)
});
if (!response.ok) {
const body = (await response.json().catch(() => null)) as { error?: string } | null;
return { ok: false, error: body?.error ?? `Request failed with status ${response.status}` };
}
return { ok: true };
} catch {
return { ok: false, error: 'Network error' };
}
}
interface BirthdaysFormProps {
guildId: string;
initialValue: BirthdayConfigDashboard;
}
export function BirthdaysForm({ guildId, initialValue }: BirthdaysFormProps) {
const t = useTranslations();
const channelId = useId();
const roleId = useId();
const timezoneId = useId();
return (
<SettingsForm<BirthdayConfigDashboard> initialValue={initialValue} onSave={(value) => saveBirthdays(guildId, value)}>
{({ value, setValue }) => (
<Card>
<CardHeader>
<CardTitle>{t('modulePages.birthdays.title')}</CardTitle>
<CardDescription>{t('modulePages.birthdays.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between rounded-md border border-border p-4">
<Label>{t('modulePages.birthdays.enabledLabel')}</Label>
<Switch checked={value.enabled} onCheckedChange={(enabled) => setValue((prev) => ({ ...prev, enabled }))} />
</div>
<div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-2">
<Label htmlFor={channelId}>{t('modulePages.birthdays.channelId')}</Label>
<Input id={channelId} value={value.channelId} onChange={(event) => setValue((prev) => ({ ...prev, channelId: event.target.value.trim() }))} placeholder="123456789012345678" />
</div>
<div className="space-y-2">
<Label htmlFor={roleId}>{t('modulePages.birthdays.roleId')}</Label>
<Input id={roleId} value={value.roleId} onChange={(event) => setValue((prev) => ({ ...prev, roleId: event.target.value.trim() }))} placeholder="123456789012345678" />
</div>
<div className="space-y-2">
<Label htmlFor={timezoneId}>{t('modulePages.birthdays.timezone')}</Label>
<Input id={timezoneId} value={value.timezone} onChange={(event) => setValue((prev) => ({ ...prev, timezone: event.target.value }))} placeholder="Europe/Berlin" />
</div>
</div>
<div className="space-y-2">
<Label>{t('modulePages.birthdays.message')}</Label>
<Textarea value={value.message ?? ''} onChange={(event) => setValue((prev) => ({ ...prev, message: event.target.value || null }))} placeholder={t('modulePages.birthdays.messagePlaceholder')} />
</div>
</CardContent>
</Card>
)}
</SettingsForm>
);
}

View File

@@ -0,0 +1,118 @@
'use client';
import type { StarboardConfigDashboard } from '@nexumi/shared';
import { useId } from 'react';
import { useTranslations } from '@/components/locale-provider';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import type { SettingsSaveResult } from '@/components/settings/settings-form';
import { SettingsForm } from '@/components/settings/settings-form';
interface LocalValue {
enabled: boolean;
channelId: string;
emoji: string;
threshold: number;
allowSelfStar: boolean;
ignoredChannelIdsText: string;
}
function toLocal(config: StarboardConfigDashboard): LocalValue {
return {
enabled: config.enabled,
channelId: config.channelId,
emoji: config.emoji,
threshold: config.threshold,
allowSelfStar: config.allowSelfStar,
ignoredChannelIdsText: config.ignoredChannelIds.join(', ')
};
}
function toRemote(value: LocalValue): StarboardConfigDashboard {
return {
enabled: value.enabled,
channelId: value.channelId,
emoji: value.emoji,
threshold: value.threshold,
allowSelfStar: value.allowSelfStar,
ignoredChannelIds: value.ignoredChannelIdsText
.split(',')
.map((entry) => entry.trim())
.filter(Boolean)
};
}
async function saveStarboard(guildId: string, value: LocalValue): Promise<SettingsSaveResult> {
try {
const response = await fetch(`/api/guilds/${guildId}/starboard`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(toRemote(value))
});
if (!response.ok) {
const body = (await response.json().catch(() => null)) as { error?: string } | null;
return { ok: false, error: body?.error ?? `Request failed with status ${response.status}` };
}
return { ok: true };
} catch {
return { ok: false, error: 'Network error' };
}
}
interface StarboardFormProps {
guildId: string;
initialValue: StarboardConfigDashboard;
}
export function StarboardForm({ guildId, initialValue }: StarboardFormProps) {
const t = useTranslations();
const channelId = useId();
const emojiId = useId();
const thresholdId = useId();
const ignoredId = useId();
return (
<SettingsForm<LocalValue> initialValue={toLocal(initialValue)} onSave={(value) => saveStarboard(guildId, value)}>
{({ value, setValue }) => (
<Card>
<CardHeader>
<CardTitle>{t('modulePages.starboard.title')}</CardTitle>
<CardDescription>{t('modulePages.starboard.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between rounded-md border border-border p-4">
<Label>{t('modulePages.starboard.enabledLabel')}</Label>
<Switch checked={value.enabled} onCheckedChange={(enabled) => setValue((prev) => ({ ...prev, enabled }))} />
</div>
<div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-2">
<Label htmlFor={channelId}>{t('modulePages.starboard.channelId')}</Label>
<Input id={channelId} value={value.channelId} onChange={(event) => setValue((prev) => ({ ...prev, channelId: event.target.value.trim() }))} placeholder="123456789012345678" />
</div>
<div className="space-y-2">
<Label htmlFor={emojiId}>{t('modulePages.starboard.emoji')}</Label>
<Input id={emojiId} value={value.emoji} onChange={(event) => setValue((prev) => ({ ...prev, emoji: event.target.value }))} />
</div>
<div className="space-y-2">
<Label htmlFor={thresholdId}>{t('modulePages.starboard.threshold')}</Label>
<Input id={thresholdId} type="number" min={1} value={value.threshold} onChange={(event) => setValue((prev) => ({ ...prev, threshold: Number(event.target.value) || 1 }))} />
</div>
</div>
<div className="flex items-center justify-between rounded-md border border-border p-4">
<Label>{t('modulePages.starboard.allowSelfStar')}</Label>
<Switch checked={value.allowSelfStar} onCheckedChange={(allowSelfStar) => setValue((prev) => ({ ...prev, allowSelfStar }))} />
</div>
<div className="space-y-2">
<Label htmlFor={ignoredId}>{t('modulePages.starboard.ignoredChannelIds')}</Label>
<Textarea id={ignoredId} value={value.ignoredChannelIdsText} onChange={(event) => setValue((prev) => ({ ...prev, ignoredChannelIdsText: event.target.value }))} />
<p className="text-xs text-muted-foreground">{t('modulePages.starboard.idsHint')}</p>
</div>
</CardContent>
</Card>
)}
</SettingsForm>
);
}

View File

@@ -0,0 +1,89 @@
'use client';
import type { StatsConfigDashboard } from '@nexumi/shared';
import { useId } from 'react';
import { useTranslations } from '@/components/locale-provider';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import type { SettingsSaveResult } from '@/components/settings/settings-form';
import { SettingsForm } from '@/components/settings/settings-form';
async function saveStats(guildId: string, value: StatsConfigDashboard): Promise<SettingsSaveResult> {
try {
const response = await fetch(`/api/guilds/${guildId}/stats`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(value)
});
if (!response.ok) {
const body = (await response.json().catch(() => null)) as { error?: string } | null;
return { ok: false, error: body?.error ?? `Request failed with status ${response.status}` };
}
return { ok: true };
} catch {
return { ok: false, error: 'Network error' };
}
}
interface StatsFormProps {
guildId: string;
initialValue: StatsConfigDashboard;
}
export function StatsForm({ guildId, initialValue }: StatsFormProps) {
const t = useTranslations();
const membersId = useId();
const onlineId = useId();
const boostsId = useId();
const membersTemplateId = useId();
const onlineTemplateId = useId();
const boostsTemplateId = useId();
return (
<SettingsForm<StatsConfigDashboard> initialValue={initialValue} onSave={(value) => saveStats(guildId, value)}>
{({ value, setValue }) => (
<Card>
<CardHeader>
<CardTitle>{t('modulePages.stats.title')}</CardTitle>
<CardDescription>{t('modulePages.stats.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between rounded-md border border-border p-4">
<Label>{t('modulePages.stats.enabledLabel')}</Label>
<Switch checked={value.enabled} onCheckedChange={(enabled) => setValue((prev) => ({ ...prev, enabled }))} />
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={membersId}>{t('modulePages.stats.membersChannelId')}</Label>
<Input id={membersId} value={value.membersChannelId} onChange={(event) => setValue((prev) => ({ ...prev, membersChannelId: event.target.value.trim() }))} placeholder="123456789012345678" />
</div>
<div className="space-y-2">
<Label htmlFor={membersTemplateId}>{t('modulePages.stats.membersTemplate')}</Label>
<Input id={membersTemplateId} value={value.membersTemplate} onChange={(event) => setValue((prev) => ({ ...prev, membersTemplate: event.target.value }))} />
</div>
<div className="space-y-2">
<Label htmlFor={onlineId}>{t('modulePages.stats.onlineChannelId')}</Label>
<Input id={onlineId} value={value.onlineChannelId} onChange={(event) => setValue((prev) => ({ ...prev, onlineChannelId: event.target.value.trim() }))} placeholder="123456789012345678" />
</div>
<div className="space-y-2">
<Label htmlFor={onlineTemplateId}>{t('modulePages.stats.onlineTemplate')}</Label>
<Input id={onlineTemplateId} value={value.onlineTemplate} onChange={(event) => setValue((prev) => ({ ...prev, onlineTemplate: event.target.value }))} />
</div>
<div className="space-y-2">
<Label htmlFor={boostsId}>{t('modulePages.stats.boostsChannelId')}</Label>
<Input id={boostsId} value={value.boostsChannelId} onChange={(event) => setValue((prev) => ({ ...prev, boostsChannelId: event.target.value.trim() }))} placeholder="123456789012345678" />
</div>
<div className="space-y-2">
<Label htmlFor={boostsTemplateId}>{t('modulePages.stats.boostsTemplate')}</Label>
<Input id={boostsTemplateId} value={value.boostsTemplate} onChange={(event) => setValue((prev) => ({ ...prev, boostsTemplate: event.target.value }))} />
</div>
</div>
<p className="text-xs text-muted-foreground">{t('modulePages.stats.templateHint')}</p>
</CardContent>
</Card>
)}
</SettingsForm>
);
}

View File

@@ -0,0 +1,79 @@
'use client';
import type { TempVoiceConfigDashboard } from '@nexumi/shared';
import { useId } from 'react';
import { useTranslations } from '@/components/locale-provider';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import type { SettingsSaveResult } from '@/components/settings/settings-form';
import { SettingsForm } from '@/components/settings/settings-form';
async function saveTempVoice(guildId: string, value: TempVoiceConfigDashboard): Promise<SettingsSaveResult> {
try {
const response = await fetch(`/api/guilds/${guildId}/tempvoice`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(value)
});
if (!response.ok) {
const body = (await response.json().catch(() => null)) as { error?: string } | null;
return { ok: false, error: body?.error ?? `Request failed with status ${response.status}` };
}
return { ok: true };
} catch {
return { ok: false, error: 'Network error' };
}
}
interface TempVoiceFormProps {
guildId: string;
initialValue: TempVoiceConfigDashboard;
}
export function TempVoiceForm({ guildId, initialValue }: TempVoiceFormProps) {
const t = useTranslations();
const hubId = useId();
const categoryId = useId();
const nameId = useId();
const limitId = useId();
return (
<SettingsForm<TempVoiceConfigDashboard> initialValue={initialValue} onSave={(value) => saveTempVoice(guildId, value)}>
{({ value, setValue }) => (
<Card>
<CardHeader>
<CardTitle>{t('modulePages.tempvoice.title')}</CardTitle>
<CardDescription>{t('modulePages.tempvoice.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between rounded-md border border-border p-4">
<Label>{t('modulePages.tempvoice.enabledLabel')}</Label>
<Switch checked={value.enabled} onCheckedChange={(enabled) => setValue((prev) => ({ ...prev, enabled }))} />
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={hubId}>{t('modulePages.tempvoice.hubChannelId')}</Label>
<Input id={hubId} value={value.hubChannelId} onChange={(event) => setValue((prev) => ({ ...prev, hubChannelId: event.target.value.trim() }))} placeholder="123456789012345678" />
</div>
<div className="space-y-2">
<Label htmlFor={categoryId}>{t('modulePages.tempvoice.categoryId')}</Label>
<Input id={categoryId} value={value.categoryId} onChange={(event) => setValue((prev) => ({ ...prev, categoryId: event.target.value.trim() }))} placeholder="123456789012345678" />
</div>
<div className="space-y-2">
<Label htmlFor={nameId}>{t('modulePages.tempvoice.defaultName')}</Label>
<Input id={nameId} value={value.defaultName} onChange={(event) => setValue((prev) => ({ ...prev, defaultName: event.target.value }))} placeholder="{user}'s Channel" />
</div>
<div className="space-y-2">
<Label htmlFor={limitId}>{t('modulePages.tempvoice.defaultLimit')}</Label>
<Input id={limitId} type="number" min={0} max={99} value={value.defaultLimit} onChange={(event) => setValue((prev) => ({ ...prev, defaultLimit: Number(event.target.value) || 0 }))} />
</div>
</div>
<p className="text-xs text-muted-foreground">{t('modulePages.tempvoice.defaultLimitHint')}</p>
</CardContent>
</Card>
)}
</SettingsForm>
);
}