From 38979b3c8bdd326096da1ccecded2c26613a168e Mon Sep 17 00:00:00 2001 From: smueller Date: Wed, 22 Jul 2026 15:42:57 +0200 Subject: [PATCH] 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. --- .../[guildId]/scheduler/[scheduleId]/route.ts | 32 +++ .../api/guilds/[guildId]/scheduler/route.ts | 51 +++++ .../app/dashboard/[guildId]/feeds/loading.tsx | 13 ++ .../dashboard/[guildId]/scheduler/loading.tsx | 14 ++ .../dashboard/[guildId]/scheduler/page.tsx | 22 +++ .../components/modules/scheduler-manager.tsx | 184 ++++++++++++++++++ .../webui/src/lib/module-configs/scheduler.ts | 52 ++--- 7 files changed, 345 insertions(+), 23 deletions(-) create mode 100644 apps/webui/src/app/api/guilds/[guildId]/scheduler/[scheduleId]/route.ts create mode 100644 apps/webui/src/app/api/guilds/[guildId]/scheduler/route.ts create mode 100644 apps/webui/src/app/dashboard/[guildId]/feeds/loading.tsx create mode 100644 apps/webui/src/app/dashboard/[guildId]/scheduler/loading.tsx create mode 100644 apps/webui/src/app/dashboard/[guildId]/scheduler/page.tsx create mode 100644 apps/webui/src/components/modules/scheduler-manager.tsx 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')} + + +
+
+ + setDraft((prev) => ({ ...prev, channelId: event.target.value }))} + placeholder="123456789012345678" + /> +
+
+ + setDraft((prev) => ({ ...prev, rolePingId: event.target.value }))} + placeholder={t('common.optional')} + /> +
+
+ + setDraft((prev) => ({ ...prev, cron: event.target.value }))} + placeholder="0 9 * * *" + /> +
+
+ + setDraft((prev) => ({ ...prev, runAt: event.target.value }))} + /> +
+
+
+ +