import { MessageComponentsV2Schema, WelcomeEmbedSchema, type MessageComponentsV2, type ScheduledMessageDashboard, type ScheduledMessageDashboardCreate, type WelcomeEmbed } from '@nexumi/shared'; import { Prisma, type ScheduledMessage } from '@prisma/client'; import { prisma } from '../prisma'; import { getScheduleQueue } from '../queues'; export class SchedulerValidationError extends Error { constructor(public readonly code: string) { super(code); } } function parseEmbed(raw: unknown): WelcomeEmbed | null { const parsed = WelcomeEmbedSchema.safeParse(raw); return parsed.success ? parsed.data : null; } function parseComponents(raw: unknown): MessageComponentsV2 | null { const parsed = MessageComponentsV2Schema.safeParse(raw); return parsed.success ? parsed.data : null; } function toDashboard(schedule: ScheduledMessage): ScheduledMessageDashboard { return { id: schedule.id, channelId: schedule.channelId, creatorId: schedule.creatorId, content: schedule.content, embed: parseEmbed(schedule.embed), components: parseComponents(schedule.components), rolePingId: schedule.rolePingId, cron: schedule.cron, runAt: schedule.runAt?.toISOString() ?? null, enabled: schedule.enabled, createdAt: schedule.createdAt.toISOString() }; } export async function listScheduledMessagesDashboard(guildId: string): Promise { const schedules = await prisma.scheduledMessage.findMany({ where: { guildId, enabled: true }, 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; } /** * 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 createScheduledMessageDashboard( guildId: string, creatorId: string, input: ScheduledMessageDashboardCreate ): Promise { await prisma.guild.upsert({ where: { id: guildId }, update: {}, create: { id: guildId } }); const cron = input.cron?.trim() || null; if (cron && !isValidCron(cron)) { throw new SchedulerValidationError('invalid_cron'); } const runAt = !cron && input.runAt ? new Date(input.runAt) : null; if (!cron && !runAt) { throw new SchedulerValidationError('missing_schedule_time'); } const schedule = await prisma.scheduledMessage.create({ data: { guildId, channelId: input.channelId, creatorId, content: input.content?.trim() || null, embed: input.embed === undefined || input.embed === null ? Prisma.JsonNull : (input.embed as Prisma.InputJsonValue), components: input.components === undefined || input.components === null ? Prisma.JsonNull : (input.components as Prisma.InputJsonValue), rolePingId: input.rolePingId || null, cron, runAt } }); const jobOptions: { jobId: string; removeOnComplete: boolean; removeOnFail: number; delay?: number; repeat?: { pattern: string }; } = { jobId: `schedule-${schedule.id}`, removeOnComplete: true, removeOnFail: 50 }; if (cron) { jobOptions.repeat = { pattern: cron }; } else if (runAt) { jobOptions.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) } }); return toDashboard(updated); } export async function deleteScheduledMessageDashboard(guildId: string, scheduleId: string): Promise { const existing = await prisma.scheduledMessage.findFirst({ where: { id: scheduleId, guildId } }); if (!existing) { return false; } if (existing.jobId) { const job = await getScheduleQueue().getJob(existing.jobId); await job?.remove(); } await prisma.scheduledMessage.delete({ where: { id: scheduleId } }); return true; }