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:
TheOnlyMace
2026-07-25 17:04:38 +02:00
parent bc4fae5415
commit f67b9c61d7
11 changed files with 418 additions and 78 deletions

View File

@@ -4,11 +4,20 @@ import {
type MessageCreateOptions, type MessageCreateOptions,
type TextChannel type TextChannel
} from 'discord.js'; } from 'discord.js';
import { WelcomeEmbedSchema, expandBracketChannelMentions, parseMessageComponentsV2, t, tf } from '@nexumi/shared'; import {
DEFAULT_GUILD_TIMEZONE,
WelcomeEmbedSchema,
expandBracketChannelMentions,
parseMessageComponentsV2,
resolveScheduleRunAt,
t,
tf
} from '@nexumi/shared';
import type { ScheduledMessage } from '@prisma/client'; import type { ScheduledMessage } from '@prisma/client';
import { ensureGuild } from '../../guild.js'; import { ensureGuild } from '../../guild.js';
import { buildEmbedFromPayload } from '../../lib/embed-payload.js'; import { buildEmbedFromPayload } from '../../lib/embed-payload.js';
import { buildComponentsV2Payload } from '../../lib/components-v2-payload.js'; import { buildComponentsV2Payload } from '../../lib/components-v2-payload.js';
import { safeRemoveBullJob } from '../../lib/safe-remove-job.js';
import { logger } from '../../logger.js'; import { logger } from '../../logger.js';
import { scheduleQueue } from '../../queues.js'; import { scheduleQueue } from '../../queues.js';
import type { BotContext } from '../../types.js'; import type { BotContext } from '../../types.js';
@@ -36,7 +45,7 @@ export type ScheduleCreateInput = AnnouncementInput & {
cron?: string | null; cron?: string | null;
}; };
function parseScheduleWhen(input: string): Date { function parseScheduleWhen(input: string, timeZone: string): Date {
const trimmed = input.trim(); const trimmed = input.trim();
if (/^\d+[smhd]$/i.test(trimmed)) { if (/^\d+[smhd]$/i.test(trimmed)) {
const ms = parseDuration(trimmed); const ms = parseDuration(trimmed);
@@ -46,11 +55,27 @@ function parseScheduleWhen(input: string): Date {
return new Date(Date.now() + ms); return new Date(Date.now() + ms);
} }
const parsed = Date.parse(trimmed); try {
if (Number.isNaN(parsed) || parsed <= Date.now()) { const parsed = resolveScheduleRunAt(trimmed, timeZone);
if (parsed.getTime() <= Date.now()) {
throw new SchedulerError('invalid_when');
}
return parsed;
} catch {
throw new SchedulerError('invalid_when'); throw new SchedulerError('invalid_when');
} }
return new Date(parsed); }
async function getGuildTimezone(prisma: BotContext['prisma'], guildId: string): Promise<string> {
const settings = await prisma.guildSettings.findUnique({
where: { guildId },
select: { timezone: true }
});
return settings?.timezone || DEFAULT_GUILD_TIMEZONE;
}
export function scheduleJobId(scheduleId: string): string {
return `schedule-${scheduleId}`;
} }
function isValidCron(pattern: string): boolean { function isValidCron(pattern: string): boolean {
@@ -157,53 +182,74 @@ async function assertSendableChannel(
async function enqueueScheduleJob( async function enqueueScheduleJob(
schedule: ScheduledMessage, schedule: ScheduledMessage,
cron: string | null, cron: string | null,
runAt: Date | null runAt: Date | null,
timeZone: string
): Promise<string> { ): Promise<string> {
const jobOptions: { const jobId = scheduleJobId(schedule.id);
jobId: string;
removeOnComplete: boolean;
removeOnFail: number;
delay?: number;
repeat?: { pattern: string };
} = {
jobId: `schedule-${schedule.id}`,
removeOnComplete: true,
removeOnFail: 50
};
if (cron) { if (cron) {
jobOptions.repeat = { pattern: cron }; // BullMQ 5 job schedulers honor guild timezone for cron patterns.
} else if (runAt) { await scheduleQueue.upsertJobScheduler(
jobOptions.delay = Math.max(0, runAt.getTime() - Date.now()); jobId,
{ pattern: cron, tz: timeZone },
{
name: 'scheduleSend',
data: { scheduleId: schedule.id },
opts: {
removeOnComplete: true,
removeOnFail: 50
}
}
);
return jobId;
} }
const job = await scheduleQueue.add('scheduleSend', { scheduleId: schedule.id }, jobOptions); if (!runAt) {
throw new SchedulerError('missing_schedule_time');
}
const job = await scheduleQueue.add(
'scheduleSend',
{ scheduleId: schedule.id },
{
jobId,
removeOnComplete: true,
removeOnFail: 50,
delay: Math.max(0, runAt.getTime() - Date.now())
}
);
return String(job.id); return String(job.id);
} }
/** /**
* Removes a scheduled/failed job if present. Locked (active) jobs cannot be * Removes a one-shot delayed job and/or cron job scheduler. Locked (active)
* removed — do not call this from inside the scheduleSend worker for the * jobs cannot be removed — do not call this from inside the scheduleSend
* job that is currently running. * worker for the job that is currently running.
*/ */
export async function removeScheduleJob(jobId: string | null): Promise<void> { export async function removeScheduleJob(
if (!jobId) { jobId: string | null,
return; options?: { scheduleId?: string; cron?: string | null }
): Promise<void> {
const schedulerId = options?.scheduleId ? scheduleJobId(options.scheduleId) : jobId;
if (schedulerId && options?.cron) {
try {
await scheduleQueue.removeJobScheduler(schedulerId);
} catch (error) {
logger.warn({ schedulerId, error }, 'Failed to remove schedule job scheduler');
}
} }
const job = await scheduleQueue.getJob(jobId); const candidateIds = new Set<string>();
if (!job) { if (jobId) {
return; candidateIds.add(jobId);
} }
try { if (schedulerId) {
await job.remove(); candidateIds.add(schedulerId);
} catch (error) { }
const message = error instanceof Error ? error.message : String(error);
if (message.includes('locked') || message.includes('could not be removed')) { for (const id of candidateIds) {
logger.warn({ jobId, error }, 'Schedule job is locked; skipping remove'); const job = await scheduleQueue.getJob(id);
return; await safeRemoveBullJob(job, { label: 'schedule', jobId: id });
}
throw error;
} }
} }
@@ -235,6 +281,7 @@ export async function createScheduledMessage(
let runAt: Date | null = null; let runAt: Date | null = null;
let cronPattern: string | null = null; let cronPattern: string | null = null;
const timeZone = await getGuildTimezone(context.prisma, input.guildId);
if (cron) { if (cron) {
if (!isValidCron(cron)) { if (!isValidCron(cron)) {
@@ -242,7 +289,7 @@ export async function createScheduledMessage(
} }
cronPattern = cron; cronPattern = cron;
} else if (whenInput) { } else if (whenInput) {
runAt = parseScheduleWhen(whenInput); runAt = parseScheduleWhen(whenInput, timeZone);
} }
await assertSendableChannel(context, input.channelId); await assertSendableChannel(context, input.channelId);
@@ -262,7 +309,7 @@ export async function createScheduledMessage(
} }
}); });
const jobId = await enqueueScheduleJob(schedule, cronPattern, runAt); const jobId = await enqueueScheduleJob(schedule, cronPattern, runAt, timeZone);
return context.prisma.scheduledMessage.update({ return context.prisma.scheduledMessage.update({
where: { id: schedule.id }, where: { id: schedule.id },
data: { jobId } data: { jobId }
@@ -322,7 +369,7 @@ export async function deleteScheduledMessage(
return false; return false;
} }
await removeScheduleJob(match.jobId); await removeScheduleJob(match.jobId, { scheduleId: match.id, cron: match.cron });
await context.prisma.scheduledMessage.delete({ where: { id: match.id } }); await context.prisma.scheduledMessage.delete({ where: { id: match.id } });
return true; return true;
} }

View File

@@ -3,6 +3,7 @@
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
:root { :root {
color-scheme: light;
--background: #f8f8fb; --background: #f8f8fb;
--foreground: #111116; --foreground: #111116;
--card: #ffffff; --card: #ffffff;
@@ -26,6 +27,7 @@
} }
.dark { .dark {
color-scheme: dark;
--background: #0a0a0d; --background: #0a0a0d;
--foreground: #e6e6ea; --foreground: #e6e6ea;
--card: #131318; --card: #131318;

View File

@@ -29,10 +29,12 @@ import { Textarea } from '@/components/ui/textarea';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
type ScheduleContentMode = 'text' | 'embed' | 'components_v2'; type ScheduleContentMode = 'text' | 'embed' | 'components_v2';
type ScheduleTimingMode = 'once' | 'cron';
const EMPTY_DRAFT = { const EMPTY_DRAFT = {
channelId: '', channelId: '',
contentMode: 'text' as ScheduleContentMode, contentMode: 'text' as ScheduleContentMode,
timingMode: 'once' as ScheduleTimingMode,
content: '', content: '',
embed: null as WelcomeEmbed | null, embed: null as WelcomeEmbed | null,
components: null as MessageComponentsV2 | null, components: null as MessageComponentsV2 | null,
@@ -76,6 +78,19 @@ function schedulePreview(schedule: ScheduledMessageDashboard, t: (key: string) =
return t('modulePages.scheduler.noContent'); 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 { interface SchedulerManagerProps {
guildId: string; guildId: string;
initialSchedules: ScheduledMessageDashboard[]; initialSchedules: ScheduledMessageDashboard[];
@@ -98,7 +113,10 @@ export function SchedulerManager({ guildId, initialSchedules }: SchedulerManager
(draft.contentMode === 'embed' && (embedHasContent(embed) || embed?.color !== undefined)) || (draft.contentMode === 'embed' && (embedHasContent(embed) || embed?.color !== undefined)) ||
(draft.contentMode === 'components_v2' && componentsV2HasContent(components)); (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')); toast.error(t('modulePages.scheduler.formIncomplete'));
return; return;
} }
@@ -114,15 +132,16 @@ export function SchedulerManager({ guildId, initialSchedules }: SchedulerManager
embed, embed,
components, components,
rolePingId: draft.rolePingId.trim() || undefined, rolePingId: draft.rolePingId.trim() || undefined,
cron: draft.cron.trim() || null, // Send naive datetime-local; server interprets it in the guild timezone.
runAt: draft.cron.trim() ? null : draft.runAt ? new Date(draft.runAt).toISOString() : null cron: cron || null,
runAt: cron ? null : runAt || null
}) })
}); });
const body = (await response.json().catch(() => null)) as const body = (await response.json().catch(() => null)) as
| (ScheduledMessageDashboard & { error?: string }) | (ScheduledMessageDashboard & { error?: string })
| null; | null;
if (!response.ok || !body) { if (!response.ok || !body) {
toast.error(body?.error ?? t('common.saveError')); toast.error(scheduleErrorMessage(body?.error, t));
return; return;
} }
setSchedules((prev) => [...prev, body]); setSchedules((prev) => [...prev, body]);
@@ -179,24 +198,55 @@ export function SchedulerManager({ guildId, initialSchedules }: SchedulerManager
placeholder={t('common.optional')} placeholder={t('common.optional')}
/> />
</div> </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"> <div className="space-y-2">
<Label>{t('modulePages.scheduler.cron')}</Label> <Label htmlFor="scheduler-cron">{t('modulePages.scheduler.cron')}</Label>
<Input <Input
id="scheduler-cron"
value={draft.cron} value={draft.cron}
onChange={(event) => setDraft((prev) => ({ ...prev, cron: event.target.value }))} onChange={(event) => setDraft((prev) => ({ ...prev, cron: event.target.value }))}
placeholder="0 9 * * *" placeholder="0 9 * * *"
autoComplete="off"
/> />
</div> </div>
) : (
<div className="space-y-2"> <div className="space-y-2">
<Label>{t('modulePages.scheduler.runAt')}</Label> <Label htmlFor="scheduler-run-at">{t('modulePages.scheduler.runAt')}</Label>
<Input <Input
id="scheduler-run-at"
type="datetime-local" type="datetime-local"
value={draft.runAt} value={draft.runAt}
disabled={Boolean(draft.cron.trim())}
onChange={(event) => setDraft((prev) => ({ ...prev, runAt: event.target.value }))} onChange={(event) => setDraft((prev) => ({ ...prev, runAt: event.target.value }))}
/> />
</div> </div>
</div> )}
<div className="space-y-2"> <div className="space-y-2">
<Label>{t('modulePages.scheduler.contentMode')}</Label> <Label>{t('modulePages.scheduler.contentMode')}</Label>

View File

@@ -9,7 +9,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, type,
type={type} type={type}
data-slot="input" data-slot="input"
className={cn( 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 className
)} )}
ref={ref} ref={ref}

View File

@@ -1,6 +1,8 @@
import { import {
DEFAULT_GUILD_TIMEZONE,
MessageComponentsV2Schema, MessageComponentsV2Schema,
WelcomeEmbedSchema, WelcomeEmbedSchema,
resolveScheduleRunAt,
type MessageComponentsV2, type MessageComponentsV2,
type ScheduledMessageDashboard, type ScheduledMessageDashboard,
type ScheduledMessageDashboardCreate, 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[]> { export async function listScheduledMessagesDashboard(guildId: string): Promise<ScheduledMessageDashboard[]> {
const schedules = await prisma.scheduledMessage.findMany({ const schedules = await prisma.scheduledMessage.findMany({
where: { guildId, enabled: true }, where: { guildId, enabled: true },
@@ -56,6 +62,14 @@ function isValidCron(pattern: string): boolean {
return parts.length >= 5 && parts.length <= 6; 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` * Mirrors `apps/bot/src/modules/scheduler/service.ts#createScheduledMessage`
* job scheduling exactly (same queue, same `scheduleSend` job name, same * job scheduling exactly (same queue, same `scheduleSend` job name, same
@@ -77,7 +91,15 @@ export async function createScheduledMessageDashboard(
throw new SchedulerValidationError('invalid_cron'); 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) { if (!cron && !runAt) {
throw new SchedulerValidationError('missing_schedule_time'); throw new SchedulerValidationError('missing_schedule_time');
} }
@@ -102,27 +124,38 @@ export async function createScheduledMessageDashboard(
} }
}); });
const jobOptions: { const jobId = scheduleJobId(schedule.id);
jobId: string; const queue = getScheduleQueue();
removeOnComplete: boolean;
removeOnFail: number;
delay?: number;
repeat?: { pattern: string };
} = {
jobId: `schedule-${schedule.id}`,
removeOnComplete: true,
removeOnFail: 50
};
if (cron) { 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) { } 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({ const updated = await prisma.scheduledMessage.update({
where: { id: schedule.id }, where: { id: schedule.id },
data: { jobId: String(job.id) } data: { jobId }
}); });
return toDashboard(updated); return toDashboard(updated);
@@ -134,16 +167,34 @@ export async function deleteScheduledMessageDashboard(guildId: string, scheduleI
return false; 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) { if (existing.jobId) {
const job = await getScheduleQueue().getJob(existing.jobId); candidateIds.add(existing.jobId);
if (job) { }
try {
await job.remove(); for (const id of candidateIds) {
} catch (error) { const job = await queue.getJob(id);
const message = error instanceof Error ? error.message : String(error); if (!job) {
if (!message.includes('locked') && !message.includes('could not be removed')) { continue;
throw error; }
} 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;
} }
} }
} }

View File

@@ -933,6 +933,11 @@
"rolePingId": "Zu erwähnende Rolle", "rolePingId": "Zu erwähnende Rolle",
"cron": "Cron-Ausdruck", "cron": "Cron-Ausdruck",
"runAt": "Ausführen am", "runAt": "Ausführen am",
"timingMode": "Zeitplan",
"timingModes": {
"once": "Einmalig",
"cron": "Wiederkehrend (Cron)"
},
"content": "Nachrichtentext", "content": "Nachrichtentext",
"embed": "Embed (optional)", "embed": "Embed (optional)",
"components": "Components-V2-Layout", "components": "Components-V2-Layout",
@@ -943,10 +948,15 @@
"components_v2": "Components V2" "components_v2": "Components V2"
}, },
"componentsPreview": "(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", "create": "Nachricht planen",
"created": "Nachricht geplant.", "created": "Nachricht geplant.",
"formIncomplete": "Bitte Kanal, Nachrichtenformat mit Inhalt sowie Cron-Ausdruck oder Datum/Uhrzeit ausfüllen.", "formIncomplete": "Bitte Kanal, Nachrichtenformat mit Inhalt sowie Cron-Ausdruck oder Datum/Uhrzeit ausfüllen.",
"errors": {
"invalidCron": "Ungültiger Cron-Ausdruck (56 Felder, z. B. 0 9 * * *).",
"invalidWhen": "Ungültiges Datum/Uhrzeit.",
"missingScheduleTime": "Bitte Cron-Ausdruck oder Datum/Uhrzeit angeben."
},
"confirmDelete": "Diesen Zeitplan wirklich löschen?", "confirmDelete": "Diesen Zeitplan wirklich löschen?",
"listTitle": "Geplante Nachrichten", "listTitle": "Geplante Nachrichten",
"listDescription": "Anstehende und wiederkehrende geplante Nachrichten.", "listDescription": "Anstehende und wiederkehrende geplante Nachrichten.",

View File

@@ -933,6 +933,11 @@
"rolePingId": "Role to ping", "rolePingId": "Role to ping",
"cron": "Cron expression", "cron": "Cron expression",
"runAt": "Run at", "runAt": "Run at",
"timingMode": "Schedule",
"timingModes": {
"once": "One-time",
"cron": "Recurring (cron)"
},
"content": "Message content", "content": "Message content",
"embed": "Embed (optional)", "embed": "Embed (optional)",
"components": "Components V2 layout", "components": "Components V2 layout",
@@ -943,10 +948,15 @@
"components_v2": "Components V2" "components_v2": "Components V2"
}, },
"componentsPreview": "(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", "create": "Schedule message",
"created": "Message scheduled.", "created": "Message scheduled.",
"formIncomplete": "Please fill channel, choose a message format with content, and either a cron expression or a date/time.", "formIncomplete": "Please fill channel, choose a message format with content, and either a cron expression or a date/time.",
"errors": {
"invalidCron": "Invalid cron expression (56 fields, e.g. 0 9 * * *).",
"invalidWhen": "Invalid date/time.",
"missingScheduleTime": "Provide a cron expression or a date/time."
},
"confirmDelete": "Really delete this schedule?", "confirmDelete": "Really delete this schedule?",
"listTitle": "Scheduled messages", "listTitle": "Scheduled messages",
"listDescription": "Upcoming and recurring scheduled messages.", "listDescription": "Upcoming and recurring scheduled messages.",

View File

@@ -598,3 +598,20 @@
- [ ] Online-Kanal zeigt sinnvolle Zahl (nicht dauerhaft 0) - [ ] Online-Kanal zeigt sinnvolle Zahl (nicht dauerhaft 0)
- [ ] Bot ohne „Kanäle verwalten“ → Config gespeichert; Bot-Log warnt; nach Permission-Fix erneut speichern - [ ] Bot ohne „Kanäle verwalten“ → Config gespeichert; Bot-Log warnt; nach Permission-Fix erneut speichern
## Post-Phase Scheduler Cron / Run-at Fix (Status: implementiert)
### Abgeschlossen (Code)
- Cron und einmaliges „Ausführen am“ nutzen die Guild-Zeitzone (`GuildSettings.timezone`)
- Cron über BullMQ `upsertJobScheduler` inkl. `tz`; Delete entfernt Job-Scheduler korrekt
- Dashboard: Timing-Mode (einmalig vs. Cron), naive `datetime-local` an Server, Fehlertexte i18n
- Dark Mode: `color-scheme` + Input `text-foreground` für datetime-local Lesbarkeit
- Shared Helper `resolveScheduleRunAt` / `guildLocalDateTimeToUtc`
### Manuell testen
- [ ] WebUI einmalig: Datum/Uhrzeit in Server-Zeitzone → Nachricht zur erwarteten lokalen Zeit
- [ ] WebUI Cron `0 9 * * *` bei Europe/Berlin → ca. 09:00 Berlin (nicht UTC)
- [ ] Cron-Plan löschen → keine weiteren Sends
- [ ] Dark Mode: Cron-Feld und datetime-local lesbar/bedienbar

View File

@@ -13,6 +13,7 @@ export * from './owner.js';
export * from './public.js'; export * from './public.js';
export * from './premium.js'; export * from './premium.js';
export * from './components-v2.js'; export * from './components-v2.js';
export * from './timezone.js';
export const LocaleSchema = z.enum(['de', 'en']); export const LocaleSchema = z.enum(['de', 'en']);
export type Locale = z.infer<typeof LocaleSchema>; export type Locale = z.infer<typeof LocaleSchema>;

View File

@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest';
import {
guildLocalDateTimeToUtc,
isValidTimezone,
resolveScheduleRunAt
} from './timezone.js';
describe('timezone helpers', () => {
it('validates IANA timezones', () => {
expect(isValidTimezone('Europe/Berlin')).toBe(true);
expect(isValidTimezone('Not/AZone')).toBe(false);
});
it('converts Berlin winter wall time to UTC', () => {
// 2026-01-15 09:00 Europe/Berlin = 08:00 UTC (CET, UTC+1)
const utc = guildLocalDateTimeToUtc('2026-01-15T09:00', 'Europe/Berlin');
expect(utc.toISOString()).toBe('2026-01-15T08:00:00.000Z');
});
it('converts Berlin summer wall time to UTC', () => {
// 2026-07-15 09:00 Europe/Berlin = 07:00 UTC (CEST, UTC+2)
const utc = guildLocalDateTimeToUtc('2026-07-15T09:00', 'Europe/Berlin');
expect(utc.toISOString()).toBe('2026-07-15T07:00:00.000Z');
});
it('keeps absolute ISO strings unchanged', () => {
const utc = resolveScheduleRunAt('2026-07-15T07:00:00.000Z', 'Europe/Berlin');
expect(utc.toISOString()).toBe('2026-07-15T07:00:00.000Z');
});
it('interprets naive datetime-local in guild timezone', () => {
const utc = resolveScheduleRunAt('2026-07-15T09:00', 'Europe/Berlin');
expect(utc.toISOString()).toBe('2026-07-15T07:00:00.000Z');
});
});

View File

@@ -0,0 +1,117 @@
/** Default guild timezone (matches Prisma `GuildSettings.timezone`). */
export const DEFAULT_GUILD_TIMEZONE = 'Europe/Berlin';
export function isValidTimezone(timezone: string): boolean {
try {
Intl.DateTimeFormat(undefined, { timeZone: timezone });
return true;
} catch {
return false;
}
}
type ZonedParts = {
year: number;
month: number;
day: number;
hour: number;
minute: number;
second: number;
};
function getZonedParts(date: Date, timeZone: string): ZonedParts {
const formatter = new Intl.DateTimeFormat('en-US', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hourCycle: 'h23'
});
const map: Partial<Record<Intl.DateTimeFormatPartTypes, string>> = {};
for (const part of formatter.formatToParts(date)) {
if (part.type !== 'literal') {
map[part.type] = part.value;
}
}
return {
year: Number(map.year),
month: Number(map.month),
day: Number(map.day),
hour: Number(map.hour),
minute: Number(map.minute),
second: Number(map.second)
};
}
const NAIVE_LOCAL_RE = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?$/;
/**
* Interprets a wall-clock datetime (`YYYY-MM-DDTHH:mm` / with seconds / space
* separator) as being in `timeZone` and returns the corresponding UTC `Date`.
*/
export function guildLocalDateTimeToUtc(localDateTime: string, timeZone: string): Date {
const match = localDateTime.trim().match(NAIVE_LOCAL_RE);
if (!match) {
throw new RangeError('invalid_local_datetime');
}
if (!isValidTimezone(timeZone)) {
throw new RangeError('invalid_timezone');
}
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
const hour = Number(match[4]);
const minute = Number(match[5]);
const second = Number(match[6] ?? 0);
// Initial guess: treat the components as UTC, then correct by the TZ offset.
let utcMs = Date.UTC(year, month - 1, day, hour, minute, second);
for (let i = 0; i < 3; i += 1) {
const parts = getZonedParts(new Date(utcMs), timeZone);
const asUtc = Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second);
const targetAsUtc = Date.UTC(year, month - 1, day, hour, minute, second);
const diff = targetAsUtc - asUtc;
if (diff === 0) {
break;
}
utcMs += diff;
}
return new Date(utcMs);
}
/**
* Resolves a schedule `runAt` input to a UTC instant.
* - Values with `Z` / numeric offset are treated as absolute.
* - Naive `datetime-local` values are interpreted in the guild timezone.
*/
export function resolveScheduleRunAt(input: string, timeZone: string): Date {
const trimmed = input.trim();
if (!trimmed) {
throw new RangeError('invalid_when');
}
if (/([zZ]|[+-]\d{2}:?\d{2})$/.test(trimmed)) {
const parsed = new Date(trimmed);
if (Number.isNaN(parsed.getTime())) {
throw new RangeError('invalid_when');
}
return parsed;
}
if (NAIVE_LOCAL_RE.test(trimmed)) {
return guildLocalDateTimeToUtc(trimmed, timeZone);
}
const parsed = new Date(trimmed);
if (Number.isNaN(parsed.getTime())) {
throw new RangeError('invalid_when');
}
return parsed;
}