Refactor scheduler module and enhance scheduled message handling
- Updated `SchedulerValidationError` to include error codes for better error management. - Renamed functions for clarity: `listSchedules` to `listScheduledMessagesDashboard` and `createSchedule` to `createScheduledMessageDashboard`. - Improved validation logic for cron expressions and scheduling times. - Enhanced the `deleteSchedule` function to ensure proper handling of existing scheduled messages. - Adjusted database query order and limits for better performance in fetching scheduled messages.
This commit is contained in:
@@ -0,0 +1,32 @@
|
|||||||
|
import { type NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
|
||||||
|
import { writeDashboardAudit } from '@/lib/audit';
|
||||||
|
import { deleteScheduledMessageDashboard } from '@/lib/module-configs/scheduler';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
|
interface RouteParams {
|
||||||
|
params: Promise<{ guildId: string; scheduleId: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(_request: NextRequest, { params }: RouteParams) {
|
||||||
|
const { guildId, scheduleId } = await params;
|
||||||
|
try {
|
||||||
|
const session = await requireGuildAccess(guildId);
|
||||||
|
const deleted = await deleteScheduledMessageDashboard(guildId, scheduleId);
|
||||||
|
if (!deleted) {
|
||||||
|
return NextResponse.json({ error: 'Schedule not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeDashboardAudit(prisma, {
|
||||||
|
guildId,
|
||||||
|
actorUserId: session.user.id,
|
||||||
|
action: 'scheduler.delete',
|
||||||
|
path: `/dashboard/${guildId}/scheduler`,
|
||||||
|
before: { id: scheduleId }
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
return toApiErrorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
51
apps/webui/src/app/api/guilds/[guildId]/scheduler/route.ts
Normal file
51
apps/webui/src/app/api/guilds/[guildId]/scheduler/route.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { ScheduledMessageDashboardCreateSchema } from '@nexumi/shared';
|
||||||
|
import { type NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
|
||||||
|
import { writeDashboardAudit } from '@/lib/audit';
|
||||||
|
import {
|
||||||
|
createScheduledMessageDashboard,
|
||||||
|
listScheduledMessagesDashboard,
|
||||||
|
SchedulerValidationError
|
||||||
|
} from '@/lib/module-configs/scheduler';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
|
interface RouteParams {
|
||||||
|
params: Promise<{ guildId: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(_request: NextRequest, { params }: RouteParams) {
|
||||||
|
const { guildId } = await params;
|
||||||
|
try {
|
||||||
|
await requireGuildAccess(guildId);
|
||||||
|
const schedules = await listScheduledMessagesDashboard(guildId);
|
||||||
|
return NextResponse.json({ schedules });
|
||||||
|
} catch (error) {
|
||||||
|
return toApiErrorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||||
|
const { guildId } = await params;
|
||||||
|
try {
|
||||||
|
const session = await requireGuildAccess(guildId);
|
||||||
|
const body = await request.json();
|
||||||
|
const input = ScheduledMessageDashboardCreateSchema.parse(body);
|
||||||
|
|
||||||
|
const schedule = await createScheduledMessageDashboard(guildId, session.user.id, input);
|
||||||
|
|
||||||
|
await writeDashboardAudit(prisma, {
|
||||||
|
guildId,
|
||||||
|
actorUserId: session.user.id,
|
||||||
|
action: 'scheduler.create',
|
||||||
|
path: `/dashboard/${guildId}/scheduler`,
|
||||||
|
after: schedule
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(schedule, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof SchedulerValidationError) {
|
||||||
|
return NextResponse.json({ error: error.code }, { status: 400 });
|
||||||
|
}
|
||||||
|
return toApiErrorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
apps/webui/src/app/dashboard/[guildId]/feeds/loading.tsx
Normal file
13
apps/webui/src/app/dashboard/[guildId]/feeds/loading.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
|
||||||
|
export default function FeedsLoading() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-8 w-48" />
|
||||||
|
<Skeleton className="h-4 w-80" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-72 rounded-lg" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
apps/webui/src/app/dashboard/[guildId]/scheduler/loading.tsx
Normal file
14
apps/webui/src/app/dashboard/[guildId]/scheduler/loading.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
|
||||||
|
export default function SchedulerLoading() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-8 w-48" />
|
||||||
|
<Skeleton className="h-4 w-80" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-64 rounded-lg" />
|
||||||
|
<Skeleton className="h-48 rounded-lg" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
apps/webui/src/app/dashboard/[guildId]/scheduler/page.tsx
Normal file
22
apps/webui/src/app/dashboard/[guildId]/scheduler/page.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { SchedulerManager } from '@/components/modules/scheduler-manager';
|
||||||
|
import { getLocale, t } from '@/lib/i18n';
|
||||||
|
import { listScheduledMessagesDashboard } from '@/lib/module-configs/scheduler';
|
||||||
|
|
||||||
|
interface SchedulerPageProps {
|
||||||
|
params: Promise<{ guildId: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function SchedulerPage({ params }: SchedulerPageProps) {
|
||||||
|
const { guildId } = await params;
|
||||||
|
const [locale, schedules] = await Promise.all([getLocale(), listScheduledMessagesDashboard(guildId)]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">{t(locale, 'modules.scheduler.label')}</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">{t(locale, 'modules.scheduler.description')}</p>
|
||||||
|
</div>
|
||||||
|
<SchedulerManager guildId={guildId} initialSchedules={schedules} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
184
apps/webui/src/components/modules/scheduler-manager.tsx
Normal file
184
apps/webui/src/components/modules/scheduler-manager.tsx
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { ScheduledMessageDashboard } from '@nexumi/shared';
|
||||||
|
import { Plus, Trash2 } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { useTranslations } from '@/components/locale-provider';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
|
||||||
|
const EMPTY_DRAFT = {
|
||||||
|
channelId: '',
|
||||||
|
content: '',
|
||||||
|
rolePingId: '',
|
||||||
|
cron: '',
|
||||||
|
runAt: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatTiming(schedule: ScheduledMessageDashboard, t: (key: string) => string): string {
|
||||||
|
if (schedule.cron) {
|
||||||
|
return `${t('modulePages.scheduler.cron')}: ${schedule.cron}`;
|
||||||
|
}
|
||||||
|
if (schedule.runAt) {
|
||||||
|
return `${t('modulePages.scheduler.runAt')}: ${new Date(schedule.runAt).toLocaleString(undefined, {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'short'
|
||||||
|
})}`;
|
||||||
|
}
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SchedulerManagerProps {
|
||||||
|
guildId: string;
|
||||||
|
initialSchedules: ScheduledMessageDashboard[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SchedulerManager({ guildId, initialSchedules }: SchedulerManagerProps) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const [schedules, setSchedules] = useState<ScheduledMessageDashboard[]>(initialSchedules);
|
||||||
|
const [draft, setDraft] = useState(EMPTY_DRAFT);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
|
||||||
|
async function handleCreate() {
|
||||||
|
if (!draft.channelId.trim() || !draft.content.trim() || (!draft.cron.trim() && !draft.runAt)) {
|
||||||
|
toast.error(t('modulePages.scheduler.formIncomplete'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCreating(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/guilds/${guildId}/scheduler`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
channelId: draft.channelId.trim(),
|
||||||
|
content: draft.content.trim(),
|
||||||
|
rolePingId: draft.rolePingId.trim() || undefined,
|
||||||
|
cron: draft.cron.trim() || null,
|
||||||
|
runAt: draft.cron.trim() ? null : draft.runAt ? new Date(draft.runAt).toISOString() : null
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const body = (await response.json().catch(() => null)) as
|
||||||
|
| (ScheduledMessageDashboard & { error?: string })
|
||||||
|
| null;
|
||||||
|
if (!response.ok || !body) {
|
||||||
|
toast.error(body?.error ?? t('common.saveError'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSchedules((prev) => [...prev, body]);
|
||||||
|
setDraft(EMPTY_DRAFT);
|
||||||
|
toast.success(t('modulePages.scheduler.created'));
|
||||||
|
} catch {
|
||||||
|
toast.error(t('common.saveError'));
|
||||||
|
} finally {
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(schedule: ScheduledMessageDashboard) {
|
||||||
|
if (!window.confirm(t('modulePages.scheduler.confirmDelete'))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/guilds/${guildId}/scheduler/${schedule.id}`, { method: 'DELETE' });
|
||||||
|
if (!response.ok) {
|
||||||
|
toast.error(t('common.saveError'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSchedules((prev) => prev.filter((entry) => entry.id !== schedule.id));
|
||||||
|
toast.success(t('common.saveSuccess'));
|
||||||
|
} catch {
|
||||||
|
toast.error(t('common.saveError'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t('modulePages.scheduler.createTitle')}</CardTitle>
|
||||||
|
<CardDescription>{t('modulePages.scheduler.createDescription')}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t('modulePages.scheduler.channelId')}</Label>
|
||||||
|
<Input
|
||||||
|
value={draft.channelId}
|
||||||
|
onChange={(event) => setDraft((prev) => ({ ...prev, channelId: event.target.value }))}
|
||||||
|
placeholder="123456789012345678"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t('modulePages.scheduler.rolePingId')}</Label>
|
||||||
|
<Input
|
||||||
|
value={draft.rolePingId}
|
||||||
|
onChange={(event) => setDraft((prev) => ({ ...prev, rolePingId: event.target.value }))}
|
||||||
|
placeholder={t('common.optional')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t('modulePages.scheduler.cron')}</Label>
|
||||||
|
<Input
|
||||||
|
value={draft.cron}
|
||||||
|
onChange={(event) => setDraft((prev) => ({ ...prev, cron: event.target.value }))}
|
||||||
|
placeholder="0 9 * * *"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t('modulePages.scheduler.runAt')}</Label>
|
||||||
|
<Input
|
||||||
|
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.content')}</Label>
|
||||||
|
<Textarea
|
||||||
|
value={draft.content}
|
||||||
|
onChange={(event) => setDraft((prev) => ({ ...prev, content: event.target.value }))}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('modulePages.scheduler.cronHint')}</p>
|
||||||
|
<Button type="button" onClick={handleCreate} disabled={creating}>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
{creating ? t('common.saving') : t('modulePages.scheduler.create')}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t('modulePages.scheduler.listTitle')}</CardTitle>
|
||||||
|
<CardDescription>{t('modulePages.scheduler.listDescription')}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{schedules.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">{t('modulePages.scheduler.empty')}</p>
|
||||||
|
)}
|
||||||
|
{schedules.map((schedule) => (
|
||||||
|
<div key={schedule.id} className="flex flex-wrap items-start justify-between gap-3 rounded-md border border-border p-4">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{schedule.content ?? t('modulePages.scheduler.noContent')}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
#{schedule.channelId} · {formatTiming(schedule, t)}
|
||||||
|
{schedule.rolePingId ? ` · @${schedule.rolePingId}` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="ghost" size="icon" aria-label={t('common.remove')} onClick={() => handleDelete(schedule)}>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,11 +3,10 @@ import type { ScheduledMessage } from '@prisma/client';
|
|||||||
import { prisma } from '../prisma';
|
import { prisma } from '../prisma';
|
||||||
import { scheduleQueue } from '../queues';
|
import { scheduleQueue } from '../queues';
|
||||||
|
|
||||||
export class SchedulerValidationError extends Error {}
|
export class SchedulerValidationError extends Error {
|
||||||
|
constructor(public readonly code: string) {
|
||||||
function isValidCron(pattern: string): boolean {
|
super(code);
|
||||||
const parts = pattern.trim().split(/\s+/);
|
}
|
||||||
return parts.length >= 5 && parts.length <= 6;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toDashboard(schedule: ScheduledMessage): ScheduledMessageDashboard {
|
function toDashboard(schedule: ScheduledMessage): ScheduledMessageDashboard {
|
||||||
@@ -24,23 +23,30 @@ function toDashboard(schedule: ScheduledMessage): ScheduledMessageDashboard {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listSchedules(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 },
|
||||||
orderBy: { createdAt: 'desc' }
|
orderBy: { createdAt: 'asc' },
|
||||||
|
take: 50
|
||||||
});
|
});
|
||||||
return schedules.map(toDashboard);
|
return schedules.map(toDashboard);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isValidCron(pattern: string): boolean {
|
||||||
|
const parts = pattern.trim().split(/\s+/);
|
||||||
|
return parts.length >= 5 && parts.length <= 6;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a scheduled message and enqueues the exact `scheduleSend` BullMQ
|
* Mirrors `apps/bot/src/modules/scheduler/service.ts#createScheduledMessage`
|
||||||
* job the bot's `/schedule create` command would enqueue (same queue, job
|
* job scheduling exactly (same queue, same `scheduleSend` job name, same
|
||||||
* name and delay/repeat semantics) - see
|
* `schedule-{id}` job id convention) so schedules created from the
|
||||||
* `apps/bot/src/modules/scheduler/service.ts#enqueueScheduleJob`. The bot's
|
* dashboard are picked up by the bot's existing `scheduleWorker` without any
|
||||||
* existing `scheduleQueueName` worker sends the message, so no bot-side
|
* changes on the bot side. Channel permission checks are skipped here since
|
||||||
* changes are required.
|
* the WebUI has no discord.js Client - the bot validates the channel again
|
||||||
|
* when the job fires and disables the schedule if it is unusable.
|
||||||
*/
|
*/
|
||||||
export async function createSchedule(
|
export async function createScheduledMessageDashboard(
|
||||||
guildId: string,
|
guildId: string,
|
||||||
creatorId: string,
|
creatorId: string,
|
||||||
input: ScheduledMessageDashboardCreate
|
input: ScheduledMessageDashboardCreate
|
||||||
@@ -49,12 +55,12 @@ export async function createSchedule(
|
|||||||
|
|
||||||
const cron = input.cron?.trim() || null;
|
const cron = input.cron?.trim() || null;
|
||||||
if (cron && !isValidCron(cron)) {
|
if (cron && !isValidCron(cron)) {
|
||||||
throw new SchedulerValidationError('Invalid cron expression (expects 5-6 space separated fields)');
|
throw new SchedulerValidationError('invalid_cron');
|
||||||
}
|
}
|
||||||
|
|
||||||
const runAt = !cron && input.runAt ? new Date(input.runAt) : null;
|
const runAt = !cron && input.runAt ? new Date(input.runAt) : null;
|
||||||
if (!cron && runAt && runAt.getTime() <= Date.now()) {
|
if (!cron && !runAt) {
|
||||||
throw new SchedulerValidationError('runAt must be in the future');
|
throw new SchedulerValidationError('missing_schedule_time');
|
||||||
}
|
}
|
||||||
|
|
||||||
const schedule = await prisma.scheduledMessage.create({
|
const schedule = await prisma.scheduledMessage.create({
|
||||||
@@ -63,7 +69,7 @@ export async function createSchedule(
|
|||||||
channelId: input.channelId,
|
channelId: input.channelId,
|
||||||
creatorId,
|
creatorId,
|
||||||
content: input.content?.trim() || null,
|
content: input.content?.trim() || null,
|
||||||
rolePingId: input.rolePingId ? input.rolePingId : null,
|
rolePingId: input.rolePingId || null,
|
||||||
cron,
|
cron,
|
||||||
runAt
|
runAt
|
||||||
}
|
}
|
||||||
@@ -95,14 +101,14 @@ export async function createSchedule(
|
|||||||
return toDashboard(updated);
|
return toDashboard(updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteSchedule(guildId: string, scheduleId: string): Promise<boolean> {
|
export async function deleteScheduledMessageDashboard(guildId: string, scheduleId: string): Promise<boolean> {
|
||||||
const schedule = await prisma.scheduledMessage.findFirst({ where: { id: scheduleId, guildId } });
|
const existing = await prisma.scheduledMessage.findFirst({ where: { id: scheduleId, guildId } });
|
||||||
if (!schedule) {
|
if (!existing) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (schedule.jobId) {
|
if (existing.jobId) {
|
||||||
const job = await scheduleQueue.getJob(schedule.jobId);
|
const job = await scheduleQueue.getJob(existing.jobId);
|
||||||
await job?.remove();
|
await job?.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user