diff --git a/apps/webui/src/app/api/guilds/[guildId]/scheduler/[scheduleId]/route.ts b/apps/webui/src/app/api/guilds/[guildId]/scheduler/[scheduleId]/route.ts
new file mode 100644
index 0000000..76731b4
--- /dev/null
+++ b/apps/webui/src/app/api/guilds/[guildId]/scheduler/[scheduleId]/route.ts
@@ -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);
+ }
+}
diff --git a/apps/webui/src/app/api/guilds/[guildId]/scheduler/route.ts b/apps/webui/src/app/api/guilds/[guildId]/scheduler/route.ts
new file mode 100644
index 0000000..76ccecc
--- /dev/null
+++ b/apps/webui/src/app/api/guilds/[guildId]/scheduler/route.ts
@@ -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);
+ }
+}
diff --git a/apps/webui/src/app/dashboard/[guildId]/feeds/loading.tsx b/apps/webui/src/app/dashboard/[guildId]/feeds/loading.tsx
new file mode 100644
index 0000000..1ede6dd
--- /dev/null
+++ b/apps/webui/src/app/dashboard/[guildId]/feeds/loading.tsx
@@ -0,0 +1,13 @@
+import { Skeleton } from '@/components/ui/skeleton';
+
+export default function FeedsLoading() {
+ return (
+
+ );
+}
diff --git a/apps/webui/src/app/dashboard/[guildId]/scheduler/loading.tsx b/apps/webui/src/app/dashboard/[guildId]/scheduler/loading.tsx
new file mode 100644
index 0000000..8e75b82
--- /dev/null
+++ b/apps/webui/src/app/dashboard/[guildId]/scheduler/loading.tsx
@@ -0,0 +1,14 @@
+import { Skeleton } from '@/components/ui/skeleton';
+
+export default function SchedulerLoading() {
+ return (
+
+ );
+}
diff --git a/apps/webui/src/app/dashboard/[guildId]/scheduler/page.tsx b/apps/webui/src/app/dashboard/[guildId]/scheduler/page.tsx
new file mode 100644
index 0000000..8b91178
--- /dev/null
+++ b/apps/webui/src/app/dashboard/[guildId]/scheduler/page.tsx
@@ -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 (
+
+
+
{t(locale, 'modules.scheduler.label')}
+
{t(locale, 'modules.scheduler.description')}
+
+
+
+ );
+}
diff --git a/apps/webui/src/components/modules/scheduler-manager.tsx b/apps/webui/src/components/modules/scheduler-manager.tsx
new file mode 100644
index 0000000..d5b0aa7
--- /dev/null
+++ b/apps/webui/src/components/modules/scheduler-manager.tsx
@@ -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(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 (
+
+
+
+ {t('modulePages.scheduler.createTitle')}
+ {t('modulePages.scheduler.createDescription')}
+
+
+
+
+
+
+ {t('modulePages.scheduler.cronHint')}
+
+
+
+
+
+
+ {t('modulePages.scheduler.listTitle')}
+ {t('modulePages.scheduler.listDescription')}
+
+
+ {schedules.length === 0 && (
+ {t('modulePages.scheduler.empty')}
+ )}
+ {schedules.map((schedule) => (
+
+
+
{schedule.content ?? t('modulePages.scheduler.noContent')}
+
+ #{schedule.channelId} · {formatTiming(schedule, t)}
+ {schedule.rolePingId ? ` · @${schedule.rolePingId}` : ''}
+
+
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/apps/webui/src/lib/module-configs/scheduler.ts b/apps/webui/src/lib/module-configs/scheduler.ts
index 71f0bd2..6b22d70 100644
--- a/apps/webui/src/lib/module-configs/scheduler.ts
+++ b/apps/webui/src/lib/module-configs/scheduler.ts
@@ -3,11 +3,10 @@ import type { ScheduledMessage } from '@prisma/client';
import { prisma } from '../prisma';
import { scheduleQueue } from '../queues';
-export class SchedulerValidationError extends Error {}
-
-function isValidCron(pattern: string): boolean {
- const parts = pattern.trim().split(/\s+/);
- return parts.length >= 5 && parts.length <= 6;
+export class SchedulerValidationError extends Error {
+ constructor(public readonly code: string) {
+ super(code);
+ }
}
function toDashboard(schedule: ScheduledMessage): ScheduledMessageDashboard {
@@ -24,23 +23,30 @@ function toDashboard(schedule: ScheduledMessage): ScheduledMessageDashboard {
};
}
-export async function listSchedules(guildId: string): Promise {
+export async function listScheduledMessagesDashboard(guildId: string): Promise {
const schedules = await prisma.scheduledMessage.findMany({
where: { guildId, enabled: true },
- orderBy: { createdAt: 'desc' }
+ orderBy: { createdAt: 'asc' },
+ take: 50
});
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
- * job the bot's `/schedule create` command would enqueue (same queue, job
- * name and delay/repeat semantics) - see
- * `apps/bot/src/modules/scheduler/service.ts#enqueueScheduleJob`. The bot's
- * existing `scheduleQueueName` worker sends the message, so no bot-side
- * changes are required.
+ * Mirrors `apps/bot/src/modules/scheduler/service.ts#createScheduledMessage`
+ * job scheduling exactly (same queue, same `scheduleSend` job name, same
+ * `schedule-{id}` job id convention) so schedules created from the
+ * dashboard are picked up by the bot's existing `scheduleWorker` without any
+ * changes on the bot side. Channel permission checks are skipped here since
+ * 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,
creatorId: string,
input: ScheduledMessageDashboardCreate
@@ -49,12 +55,12 @@ export async function createSchedule(
const cron = input.cron?.trim() || null;
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;
- if (!cron && runAt && runAt.getTime() <= Date.now()) {
- throw new SchedulerValidationError('runAt must be in the future');
+ if (!cron && !runAt) {
+ throw new SchedulerValidationError('missing_schedule_time');
}
const schedule = await prisma.scheduledMessage.create({
@@ -63,7 +69,7 @@ export async function createSchedule(
channelId: input.channelId,
creatorId,
content: input.content?.trim() || null,
- rolePingId: input.rolePingId ? input.rolePingId : null,
+ rolePingId: input.rolePingId || null,
cron,
runAt
}
@@ -95,14 +101,14 @@ export async function createSchedule(
return toDashboard(updated);
}
-export async function deleteSchedule(guildId: string, scheduleId: string): Promise {
- const schedule = await prisma.scheduledMessage.findFirst({ where: { id: scheduleId, guildId } });
- if (!schedule) {
+export async function deleteScheduledMessageDashboard(guildId: string, scheduleId: string): Promise {
+ const existing = await prisma.scheduledMessage.findFirst({ where: { id: scheduleId, guildId } });
+ if (!existing) {
return false;
}
- if (schedule.jobId) {
- const job = await scheduleQueue.getJob(schedule.jobId);
+ if (existing.jobId) {
+ const job = await scheduleQueue.getJob(existing.jobId);
await job?.remove();
}