Enhance scheduler functionality with timezone support and error handling
- Updated scheduling logic to utilize the guild's timezone for cron expressions and one-time scheduling. - Introduced a new `getGuildTimezone` function to retrieve the timezone from guild settings. - Enhanced error handling for scheduling inputs, providing localized error messages for invalid cron expressions and missing schedule times. - Improved the WebUI to support a new timing mode selection (once vs. cron) and updated input fields accordingly. - Added localization entries for new scheduling-related messages in both English and German. - Refactored job scheduling and removal logic to ensure proper handling of scheduled jobs in the queue.
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--background: #f8f8fb;
|
||||
--foreground: #111116;
|
||||
--card: #ffffff;
|
||||
@@ -26,6 +27,7 @@
|
||||
}
|
||||
|
||||
.dark {
|
||||
color-scheme: dark;
|
||||
--background: #0a0a0d;
|
||||
--foreground: #e6e6ea;
|
||||
--card: #131318;
|
||||
|
||||
@@ -29,10 +29,12 @@ import { Textarea } from '@/components/ui/textarea';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type ScheduleContentMode = 'text' | 'embed' | 'components_v2';
|
||||
type ScheduleTimingMode = 'once' | 'cron';
|
||||
|
||||
const EMPTY_DRAFT = {
|
||||
channelId: '',
|
||||
contentMode: 'text' as ScheduleContentMode,
|
||||
timingMode: 'once' as ScheduleTimingMode,
|
||||
content: '',
|
||||
embed: null as WelcomeEmbed | null,
|
||||
components: null as MessageComponentsV2 | null,
|
||||
@@ -76,6 +78,19 @@ function schedulePreview(schedule: ScheduledMessageDashboard, t: (key: string) =
|
||||
return t('modulePages.scheduler.noContent');
|
||||
}
|
||||
|
||||
function scheduleErrorMessage(code: string | undefined, t: (key: string) => string): string {
|
||||
if (code === 'invalid_cron') {
|
||||
return t('modulePages.scheduler.errors.invalidCron');
|
||||
}
|
||||
if (code === 'invalid_when') {
|
||||
return t('modulePages.scheduler.errors.invalidWhen');
|
||||
}
|
||||
if (code === 'missing_schedule_time') {
|
||||
return t('modulePages.scheduler.errors.missingScheduleTime');
|
||||
}
|
||||
return code?.trim() ? code : t('common.saveError');
|
||||
}
|
||||
|
||||
interface SchedulerManagerProps {
|
||||
guildId: string;
|
||||
initialSchedules: ScheduledMessageDashboard[];
|
||||
@@ -98,7 +113,10 @@ export function SchedulerManager({ guildId, initialSchedules }: SchedulerManager
|
||||
(draft.contentMode === 'embed' && (embedHasContent(embed) || embed?.color !== undefined)) ||
|
||||
(draft.contentMode === 'components_v2' && componentsV2HasContent(components));
|
||||
|
||||
if (!draft.channelId.trim() || !hasContent || (!draft.cron.trim() && !draft.runAt)) {
|
||||
const cron = draft.timingMode === 'cron' ? draft.cron.trim() : '';
|
||||
const runAt = draft.timingMode === 'once' ? draft.runAt.trim() : '';
|
||||
|
||||
if (!draft.channelId.trim() || !hasContent || (!cron && !runAt)) {
|
||||
toast.error(t('modulePages.scheduler.formIncomplete'));
|
||||
return;
|
||||
}
|
||||
@@ -114,15 +132,16 @@ export function SchedulerManager({ guildId, initialSchedules }: SchedulerManager
|
||||
embed,
|
||||
components,
|
||||
rolePingId: draft.rolePingId.trim() || undefined,
|
||||
cron: draft.cron.trim() || null,
|
||||
runAt: draft.cron.trim() ? null : draft.runAt ? new Date(draft.runAt).toISOString() : null
|
||||
// Send naive datetime-local; server interprets it in the guild timezone.
|
||||
cron: cron || null,
|
||||
runAt: cron ? null : runAt || null
|
||||
})
|
||||
});
|
||||
const body = (await response.json().catch(() => null)) as
|
||||
| (ScheduledMessageDashboard & { error?: string })
|
||||
| null;
|
||||
if (!response.ok || !body) {
|
||||
toast.error(body?.error ?? t('common.saveError'));
|
||||
toast.error(scheduleErrorMessage(body?.error, t));
|
||||
return;
|
||||
}
|
||||
setSchedules((prev) => [...prev, body]);
|
||||
@@ -179,24 +198,55 @@ export function SchedulerManager({ guildId, initialSchedules }: SchedulerManager
|
||||
placeholder={t('common.optional')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.scheduler.timingMode')}</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(['once', 'cron'] as ScheduleTimingMode[]).map((mode) => (
|
||||
<Button
|
||||
key={mode}
|
||||
type="button"
|
||||
variant={draft.timingMode === mode ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className={cn(draft.timingMode !== mode && 'text-muted-foreground')}
|
||||
onClick={() =>
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
timingMode: mode,
|
||||
cron: mode === 'cron' ? prev.cron : '',
|
||||
runAt: mode === 'once' ? prev.runAt : ''
|
||||
}))
|
||||
}
|
||||
>
|
||||
{t(`modulePages.scheduler.timingModes.${mode}`)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{draft.timingMode === 'cron' ? (
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.scheduler.cron')}</Label>
|
||||
<Label htmlFor="scheduler-cron">{t('modulePages.scheduler.cron')}</Label>
|
||||
<Input
|
||||
id="scheduler-cron"
|
||||
value={draft.cron}
|
||||
onChange={(event) => setDraft((prev) => ({ ...prev, cron: event.target.value }))}
|
||||
placeholder="0 9 * * *"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.scheduler.runAt')}</Label>
|
||||
<Label htmlFor="scheduler-run-at">{t('modulePages.scheduler.runAt')}</Label>
|
||||
<Input
|
||||
id="scheduler-run-at"
|
||||
type="datetime-local"
|
||||
value={draft.runAt}
|
||||
disabled={Boolean(draft.cron.trim())}
|
||||
onChange={(event) => setDraft((prev) => ({ ...prev, runAt: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.scheduler.contentMode')}</Label>
|
||||
|
||||
@@ -9,7 +9,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, type,
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm text-foreground shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import {
|
||||
DEFAULT_GUILD_TIMEZONE,
|
||||
MessageComponentsV2Schema,
|
||||
WelcomeEmbedSchema,
|
||||
resolveScheduleRunAt,
|
||||
type MessageComponentsV2,
|
||||
type ScheduledMessageDashboard,
|
||||
type ScheduledMessageDashboardCreate,
|
||||
@@ -42,6 +44,10 @@ function toDashboard(schedule: ScheduledMessage): ScheduledMessageDashboard {
|
||||
};
|
||||
}
|
||||
|
||||
function scheduleJobId(scheduleId: string): string {
|
||||
return `schedule-${scheduleId}`;
|
||||
}
|
||||
|
||||
export async function listScheduledMessagesDashboard(guildId: string): Promise<ScheduledMessageDashboard[]> {
|
||||
const schedules = await prisma.scheduledMessage.findMany({
|
||||
where: { guildId, enabled: true },
|
||||
@@ -56,6 +62,14 @@ function isValidCron(pattern: string): boolean {
|
||||
return parts.length >= 5 && parts.length <= 6;
|
||||
}
|
||||
|
||||
async function getGuildTimezone(guildId: string): Promise<string> {
|
||||
const settings = await prisma.guildSettings.findUnique({
|
||||
where: { guildId },
|
||||
select: { timezone: true }
|
||||
});
|
||||
return settings?.timezone || DEFAULT_GUILD_TIMEZONE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors `apps/bot/src/modules/scheduler/service.ts#createScheduledMessage`
|
||||
* job scheduling exactly (same queue, same `scheduleSend` job name, same
|
||||
@@ -77,7 +91,15 @@ export async function createScheduledMessageDashboard(
|
||||
throw new SchedulerValidationError('invalid_cron');
|
||||
}
|
||||
|
||||
const runAt = !cron && input.runAt ? new Date(input.runAt) : null;
|
||||
const timeZone = await getGuildTimezone(guildId);
|
||||
let runAt: Date | null = null;
|
||||
if (!cron && input.runAt) {
|
||||
try {
|
||||
runAt = resolveScheduleRunAt(input.runAt, timeZone);
|
||||
} catch {
|
||||
throw new SchedulerValidationError('invalid_when');
|
||||
}
|
||||
}
|
||||
if (!cron && !runAt) {
|
||||
throw new SchedulerValidationError('missing_schedule_time');
|
||||
}
|
||||
@@ -102,27 +124,38 @@ export async function createScheduledMessageDashboard(
|
||||
}
|
||||
});
|
||||
|
||||
const jobOptions: {
|
||||
jobId: string;
|
||||
removeOnComplete: boolean;
|
||||
removeOnFail: number;
|
||||
delay?: number;
|
||||
repeat?: { pattern: string };
|
||||
} = {
|
||||
jobId: `schedule-${schedule.id}`,
|
||||
removeOnComplete: true,
|
||||
removeOnFail: 50
|
||||
};
|
||||
const jobId = scheduleJobId(schedule.id);
|
||||
const queue = getScheduleQueue();
|
||||
|
||||
if (cron) {
|
||||
jobOptions.repeat = { pattern: cron };
|
||||
await queue.upsertJobScheduler(
|
||||
jobId,
|
||||
{ pattern: cron, tz: timeZone },
|
||||
{
|
||||
name: 'scheduleSend',
|
||||
data: { scheduleId: schedule.id },
|
||||
opts: {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: 50
|
||||
}
|
||||
}
|
||||
);
|
||||
} else if (runAt) {
|
||||
jobOptions.delay = Math.max(0, runAt.getTime() - Date.now());
|
||||
await queue.add(
|
||||
'scheduleSend',
|
||||
{ scheduleId: schedule.id },
|
||||
{
|
||||
jobId,
|
||||
removeOnComplete: true,
|
||||
removeOnFail: 50,
|
||||
delay: Math.max(0, runAt.getTime() - Date.now())
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const job = await getScheduleQueue().add('scheduleSend', { scheduleId: schedule.id }, jobOptions);
|
||||
const updated = await prisma.scheduledMessage.update({
|
||||
where: { id: schedule.id },
|
||||
data: { jobId: String(job.id) }
|
||||
data: { jobId }
|
||||
});
|
||||
|
||||
return toDashboard(updated);
|
||||
@@ -134,16 +167,34 @@ export async function deleteScheduledMessageDashboard(guildId: string, scheduleI
|
||||
return false;
|
||||
}
|
||||
|
||||
const queue = getScheduleQueue();
|
||||
const schedulerId = scheduleJobId(existing.id);
|
||||
|
||||
if (existing.cron) {
|
||||
try {
|
||||
await queue.removeJobScheduler(schedulerId);
|
||||
} catch {
|
||||
// Scheduler may already be gone (legacy repeatables / one-shots).
|
||||
}
|
||||
}
|
||||
|
||||
const candidateIds = new Set<string>();
|
||||
candidateIds.add(schedulerId);
|
||||
if (existing.jobId) {
|
||||
const job = await getScheduleQueue().getJob(existing.jobId);
|
||||
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;
|
||||
}
|
||||
candidateIds.add(existing.jobId);
|
||||
}
|
||||
|
||||
for (const id of candidateIds) {
|
||||
const job = await queue.getJob(id);
|
||||
if (!job) {
|
||||
continue;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -933,6 +933,11 @@
|
||||
"rolePingId": "Zu erwähnende Rolle",
|
||||
"cron": "Cron-Ausdruck",
|
||||
"runAt": "Ausführen am",
|
||||
"timingMode": "Zeitplan",
|
||||
"timingModes": {
|
||||
"once": "Einmalig",
|
||||
"cron": "Wiederkehrend (Cron)"
|
||||
},
|
||||
"content": "Nachrichtentext",
|
||||
"embed": "Embed (optional)",
|
||||
"components": "Components-V2-Layout",
|
||||
@@ -943,10 +948,15 @@
|
||||
"components_v2": "Components V2"
|
||||
},
|
||||
"componentsPreview": "(Components V2)",
|
||||
"cronHint": "Cron-Ausdruck für wiederkehrende Nachrichten ODER ein einmaliges Datum/Uhrzeit — nicht beides. Wähle Text, Embed oder Components V2 (gegenseitig ausgeschlossen).",
|
||||
"cronHint": "Einmalige Zeit oder Cron-Wiederholung — bezogen auf die Server-Zeitzone unter Allgemeine Einstellungen. Wähle Text, Embed oder Components V2 (gegenseitig ausgeschlossen).",
|
||||
"create": "Nachricht planen",
|
||||
"created": "Nachricht geplant.",
|
||||
"formIncomplete": "Bitte Kanal, Nachrichtenformat mit Inhalt sowie Cron-Ausdruck oder Datum/Uhrzeit ausfüllen.",
|
||||
"errors": {
|
||||
"invalidCron": "Ungültiger Cron-Ausdruck (5–6 Felder, z. B. 0 9 * * *).",
|
||||
"invalidWhen": "Ungültiges Datum/Uhrzeit.",
|
||||
"missingScheduleTime": "Bitte Cron-Ausdruck oder Datum/Uhrzeit angeben."
|
||||
},
|
||||
"confirmDelete": "Diesen Zeitplan wirklich löschen?",
|
||||
"listTitle": "Geplante Nachrichten",
|
||||
"listDescription": "Anstehende und wiederkehrende geplante Nachrichten.",
|
||||
|
||||
@@ -933,6 +933,11 @@
|
||||
"rolePingId": "Role to ping",
|
||||
"cron": "Cron expression",
|
||||
"runAt": "Run at",
|
||||
"timingMode": "Schedule",
|
||||
"timingModes": {
|
||||
"once": "One-time",
|
||||
"cron": "Recurring (cron)"
|
||||
},
|
||||
"content": "Message content",
|
||||
"embed": "Embed (optional)",
|
||||
"components": "Components V2 layout",
|
||||
@@ -943,10 +948,15 @@
|
||||
"components_v2": "Components V2"
|
||||
},
|
||||
"componentsPreview": "(Components V2)",
|
||||
"cronHint": "Set a cron expression for recurring messages, or a one-time date/time — not both. Choose text, embed, or Components V2 (mutually exclusive).",
|
||||
"cronHint": "One-time time or cron recurrence — evaluated in the server timezone under General Settings. Choose text, embed, or Components V2 (mutually exclusive).",
|
||||
"create": "Schedule message",
|
||||
"created": "Message scheduled.",
|
||||
"formIncomplete": "Please fill channel, choose a message format with content, and either a cron expression or a date/time.",
|
||||
"errors": {
|
||||
"invalidCron": "Invalid cron expression (5–6 fields, e.g. 0 9 * * *).",
|
||||
"invalidWhen": "Invalid date/time.",
|
||||
"missingScheduleTime": "Provide a cron expression or a date/time."
|
||||
},
|
||||
"confirmDelete": "Really delete this schedule?",
|
||||
"listTitle": "Scheduled messages",
|
||||
"listDescription": "Upcoming and recurring scheduled messages.",
|
||||
|
||||
Reference in New Issue
Block a user