-
{schedule.content ?? t('modulePages.scheduler.noContent')}
+
{schedulePreview(schedule, t)}
#{schedule.channelId} · {formatTiming(schedule, t)}
{schedule.rolePingId ? ` · @${schedule.rolePingId}` : ''}
diff --git a/apps/webui/src/components/modules/welcome-form.tsx b/apps/webui/src/components/modules/welcome-form.tsx
index 0ab1ce9..0f921db 100644
--- a/apps/webui/src/components/modules/welcome-form.tsx
+++ b/apps/webui/src/components/modules/welcome-form.tsx
@@ -1,10 +1,15 @@
'use client';
-import type { WelcomeConfigDashboard } from '@nexumi/shared';
+import type { WelcomeConfigDashboard, WelcomeEmbed } from '@nexumi/shared';
import { FieldAnchor } from '@/components/layout/field-anchor';
import { useTranslations } from '@/components/locale-provider';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { DiscordChannelSelect } from '@/components/ui/discord-channel-select';
+import {
+ DiscordEmbedBuilder,
+ embedFromUnknown,
+ normalizeEmbed
+} from '@/components/ui/discord-embed-builder';
import { DiscordRoleMultiSelect } from '@/components/ui/discord-role-select';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -14,55 +19,33 @@ import { Textarea } from '@/components/ui/textarea';
import type { SettingsSaveResult } from '@/components/settings/settings-form';
import { SettingsForm } from '@/components/settings/settings-form';
-interface LocalWelcomeValue extends Omit {
- welcomeEmbedText: string;
- leaveEmbedText: string;
-}
+const WELCOME_PREVIEW_VARS = {
+ user: '@Alex',
+ 'user.name': 'Alex',
+ 'user.tag': 'Alex',
+ 'user.id': '123456789012345678',
+ server: 'Nexumi',
+ memberCount: '128'
+};
-function toJsonText(value: Record | null | undefined): string {
- return value ? JSON.stringify(value, null, 2) : '';
+interface LocalWelcomeValue extends Omit {
+ welcomeEmbed: WelcomeEmbed | null;
+ leaveEmbed: WelcomeEmbed | null;
}
function toLocal(config: WelcomeConfigDashboard): LocalWelcomeValue {
- const { welcomeEmbed, leaveEmbed, ...rest } = config;
return {
- ...rest,
- welcomeEmbedText: toJsonText(welcomeEmbed),
- leaveEmbedText: toJsonText(leaveEmbed)
+ ...config,
+ welcomeEmbed: embedFromUnknown(config.welcomeEmbed),
+ leaveEmbed: embedFromUnknown(config.leaveEmbed)
};
}
-function parseEmbedJson(text: string): { value: Record | null; error?: string } {
- if (!text.trim()) {
- return { value: null };
- }
- try {
- const parsed = JSON.parse(text);
- if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
- return { value: parsed as Record };
- }
- return { value: null, error: 'Invalid embed JSON (must be an object)' };
- } catch {
- return { value: null, error: 'Invalid embed JSON' };
- }
-}
-
async function saveWelcome(guildId: string, value: LocalWelcomeValue): Promise {
- const welcomeEmbed = parseEmbedJson(value.welcomeEmbedText);
- if (welcomeEmbed.error) {
- return { ok: false, error: welcomeEmbed.error };
- }
- const leaveEmbed = parseEmbedJson(value.leaveEmbedText);
- if (leaveEmbed.error) {
- return { ok: false, error: leaveEmbed.error };
- }
-
- const { welcomeEmbedText: _welcomeEmbedText, leaveEmbedText: _leaveEmbedText, ...rest } = value;
-
const payload: WelcomeConfigDashboard = {
- ...rest,
- welcomeEmbed: welcomeEmbed.value,
- leaveEmbed: leaveEmbed.value
+ ...value,
+ welcomeEmbed: normalizeEmbed(value.welcomeEmbed),
+ leaveEmbed: normalizeEmbed(value.leaveEmbed)
};
try {
@@ -154,12 +137,10 @@ export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) {
-
@@ -260,12 +241,10 @@ export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) {
-
diff --git a/apps/webui/src/components/ui/discord-embed-builder.tsx b/apps/webui/src/components/ui/discord-embed-builder.tsx
new file mode 100644
index 0000000..370c830
--- /dev/null
+++ b/apps/webui/src/components/ui/discord-embed-builder.tsx
@@ -0,0 +1,191 @@
+'use client';
+
+import type { WelcomeEmbed } from '@nexumi/shared';
+import { useTranslations } from '@/components/locale-provider';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Textarea } from '@/components/ui/textarea';
+import { cn } from '@/lib/utils';
+
+export const DEFAULT_EMBED_COLOR = 0x6366f1;
+
+export function emptyEmbed(): WelcomeEmbed {
+ return {
+ title: '',
+ description: '',
+ color: DEFAULT_EMBED_COLOR
+ };
+}
+
+export function normalizeEmbed(value: WelcomeEmbed | null | undefined): WelcomeEmbed | null {
+ if (!value) {
+ return null;
+ }
+ const title = value.title?.trim() || undefined;
+ const description = value.description?.trim() || undefined;
+ const color = typeof value.color === 'number' && Number.isFinite(value.color) ? value.color : undefined;
+ if (!title && !description && color === undefined) {
+ return null;
+ }
+ return { title, description, color };
+}
+
+export function embedFromUnknown(raw: unknown): WelcomeEmbed | null {
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
+ return null;
+ }
+ const record = raw as Record;
+ return normalizeEmbed({
+ title: typeof record.title === 'string' ? record.title : undefined,
+ description: typeof record.description === 'string' ? record.description : undefined,
+ color: typeof record.color === 'number' ? record.color : undefined
+ });
+}
+
+function colorToHex(color: number | undefined): string {
+ const value = typeof color === 'number' && Number.isFinite(color) ? color : DEFAULT_EMBED_COLOR;
+ return `#${Math.max(0, Math.min(0xffffff, Math.trunc(value))).toString(16).padStart(6, '0')}`;
+}
+
+function hexToColor(hex: string): number {
+ const cleaned = hex.replace('#', '').trim();
+ const parsed = Number.parseInt(cleaned, 16);
+ return Number.isFinite(parsed) ? parsed : DEFAULT_EMBED_COLOR;
+}
+
+function applyPreviewVars(template: string, vars: Record): string {
+ return Object.entries(vars).reduce(
+ (acc, [key, value]) => acc.replaceAll(`{${key}}`, value),
+ template
+ );
+}
+
+interface DiscordEmbedBuilderProps {
+ value: WelcomeEmbed | null;
+ onChange: (next: WelcomeEmbed | null) => void;
+ className?: string;
+ /** Sample values for preview placeholder substitution (e.g. user → @Alex). */
+ previewVars?: Record;
+ showClearHint?: boolean;
+}
+
+export function DiscordEmbedBuilder({
+ value,
+ onChange,
+ className,
+ previewVars,
+ showClearHint = true
+}: DiscordEmbedBuilderProps) {
+ const t = useTranslations();
+ const draft = value ?? emptyEmbed();
+ const colorHex = colorToHex(draft.color);
+ const previewTitle = draft.title
+ ? applyPreviewVars(draft.title, previewVars ?? {})
+ : t('embedBuilder.previewTitlePlaceholder');
+ const previewDescription = draft.description
+ ? applyPreviewVars(draft.description, previewVars ?? {})
+ : t('embedBuilder.previewDescriptionPlaceholder');
+ const hasContent = Boolean(draft.title?.trim() || draft.description?.trim());
+
+ function patch(next: Partial) {
+ const merged: WelcomeEmbed = {
+ title: next.title !== undefined ? next.title : draft.title,
+ description: next.description !== undefined ? next.description : draft.description,
+ color: next.color !== undefined ? next.color : draft.color
+ };
+ onChange(merged);
+ }
+
+ return (
+
+
+
+
+ patch({ title: event.target.value })}
+ />
+
+
+
+
+
+ {showClearHint ? (
+
{t('embedBuilder.clearHint')}
+ ) : null}
+
+
+
+
+
+
+
+
+
+ {previewTitle || '\u00a0'}
+
+
+ {previewDescription || '\u00a0'}
+
+
+
+
+
+
+ );
+}
diff --git a/apps/webui/src/lib/module-configs/scheduler.ts b/apps/webui/src/lib/module-configs/scheduler.ts
index 4da9a75..6b143e2 100644
--- a/apps/webui/src/lib/module-configs/scheduler.ts
+++ b/apps/webui/src/lib/module-configs/scheduler.ts
@@ -1,5 +1,5 @@
-import type { ScheduledMessageDashboard, ScheduledMessageDashboardCreate } from '@nexumi/shared';
-import type { ScheduledMessage } from '@prisma/client';
+import type { ScheduledMessageDashboard, ScheduledMessageDashboardCreate, WelcomeEmbed } from '@nexumi/shared';
+import { Prisma, type ScheduledMessage } from '@prisma/client';
import { prisma } from '../prisma';
import { getScheduleQueue } from '../queues';
@@ -9,12 +9,27 @@ export class SchedulerValidationError extends Error {
}
}
+function parseEmbed(raw: unknown): WelcomeEmbed | null {
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
+ return null;
+ }
+ const record = raw as Record;
+ const title = typeof record.title === 'string' ? record.title : undefined;
+ const description = typeof record.description === 'string' ? record.description : undefined;
+ const color = typeof record.color === 'number' ? record.color : undefined;
+ if (!title && !description && color === undefined) {
+ return null;
+ }
+ return { title, description, color };
+}
+
function toDashboard(schedule: ScheduledMessage): ScheduledMessageDashboard {
return {
id: schedule.id,
channelId: schedule.channelId,
creatorId: schedule.creatorId,
content: schedule.content,
+ embed: parseEmbed(schedule.embed),
rolePingId: schedule.rolePingId,
cron: schedule.cron,
runAt: schedule.runAt?.toISOString() ?? null,
@@ -69,6 +84,10 @@ export async function createScheduledMessageDashboard(
channelId: input.channelId,
creatorId,
content: input.content?.trim() || null,
+ embed:
+ input.embed === undefined || input.embed === null
+ ? Prisma.JsonNull
+ : (input.embed as Prisma.InputJsonValue),
rolePingId: input.rolePingId || null,
cron,
runAt
diff --git a/apps/webui/src/lib/module-configs/tags.ts b/apps/webui/src/lib/module-configs/tags.ts
index 7fce98c..16cecdb 100644
--- a/apps/webui/src/lib/module-configs/tags.ts
+++ b/apps/webui/src/lib/module-configs/tags.ts
@@ -1,5 +1,5 @@
-import type { TagDashboard, TagDashboardCreate, TagDashboardUpdate } from '@nexumi/shared';
-import type { Tag } from '@prisma/client';
+import type { TagDashboard, TagDashboardCreate, TagDashboardUpdate, WelcomeEmbed } from '@nexumi/shared';
+import { Prisma, type Tag } from '@prisma/client';
import { prisma } from '../prisma';
import { assertPremiumLimit, PremiumLimitError } from '../premium';
@@ -15,11 +15,26 @@ export class TagPremiumError extends Error {
}
}
+function parseEmbed(raw: unknown): WelcomeEmbed | null {
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
+ return null;
+ }
+ const record = raw as Record;
+ const title = typeof record.title === 'string' ? record.title : undefined;
+ const description = typeof record.description === 'string' ? record.description : undefined;
+ const color = typeof record.color === 'number' ? record.color : undefined;
+ if (!title && !description && color === undefined) {
+ return null;
+ }
+ return { title, description, color };
+}
+
function toDashboard(tag: Tag): TagDashboard {
return {
id: tag.id,
name: tag.name,
content: tag.content,
+ embed: parseEmbed(tag.embed),
responseType: tag.responseType as TagDashboard['responseType'],
triggerWord: tag.triggerWord,
allowedRoleIds: tag.allowedRoleIds,
@@ -53,6 +68,10 @@ export async function createTag(
guildId,
name: input.name,
content: input.content ?? null,
+ embed:
+ input.embed === undefined || input.embed === null
+ ? Prisma.JsonNull
+ : (input.embed as Prisma.InputJsonValue),
responseType: input.responseType,
triggerWord: input.triggerWord ?? null,
allowedRoleIds: input.allowedRoleIds,
@@ -85,6 +104,9 @@ export async function updateTag(
data: {
...(patch.name !== undefined ? { name: patch.name } : {}),
...(patch.content !== undefined ? { content: patch.content } : {}),
+ ...(patch.embed !== undefined
+ ? { embed: patch.embed === null ? Prisma.JsonNull : (patch.embed as Prisma.InputJsonValue) }
+ : {}),
...(patch.responseType !== undefined ? { responseType: patch.responseType } : {}),
...(patch.triggerWord !== undefined ? { triggerWord: patch.triggerWord } : {}),
...(patch.allowedRoleIds !== undefined ? { allowedRoleIds: patch.allowedRoleIds } : {}),
diff --git a/apps/webui/src/lib/module-configs/welcome.ts b/apps/webui/src/lib/module-configs/welcome.ts
index 565f15d..d19fcf8 100644
--- a/apps/webui/src/lib/module-configs/welcome.ts
+++ b/apps/webui/src/lib/module-configs/welcome.ts
@@ -1,4 +1,4 @@
-import type { WelcomeConfigDashboard, WelcomeConfigDashboardPatch } from '@nexumi/shared';
+import type { WelcomeConfigDashboard, WelcomeConfigDashboardPatch, WelcomeEmbed } from '@nexumi/shared';
import { Prisma } from '@prisma/client';
import { prisma } from '../prisma';
@@ -11,6 +11,20 @@ async function ensureWelcomeConfig(guildId: string) {
});
}
+function parseEmbed(raw: unknown): WelcomeEmbed | null {
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
+ return null;
+ }
+ const record = raw as Record;
+ const title = typeof record.title === 'string' ? record.title : undefined;
+ const description = typeof record.description === 'string' ? record.description : undefined;
+ const color = typeof record.color === 'number' ? record.color : undefined;
+ if (!title && !description && color === undefined) {
+ return null;
+ }
+ return { title, description, color };
+}
+
function toDashboard(config: Awaited>): WelcomeConfigDashboard {
return {
welcomeEnabled: config.welcomeEnabled,
@@ -19,9 +33,9 @@ function toDashboard(config: Awaited>): W
leaveChannelId: config.leaveChannelId ?? '',
welcomeType: config.welcomeType as WelcomeConfigDashboard['welcomeType'],
welcomeContent: config.welcomeContent,
- welcomeEmbed: (config.welcomeEmbed as Record | null) ?? null,
+ welcomeEmbed: parseEmbed(config.welcomeEmbed),
leaveContent: config.leaveContent,
- leaveEmbed: (config.leaveEmbed as Record | null) ?? null,
+ leaveEmbed: parseEmbed(config.leaveEmbed),
welcomeDmEnabled: config.welcomeDmEnabled,
welcomeDmContent: config.welcomeDmContent,
userAutoroleIds: config.userAutoroleIds,
diff --git a/apps/webui/src/messages/de.json b/apps/webui/src/messages/de.json
index 7d398e9..9d25d4d 100644
--- a/apps/webui/src/messages/de.json
+++ b/apps/webui/src/messages/de.json
@@ -21,7 +21,19 @@
"close": "Schließen",
"searchSelect": "Suchen und auswählen…",
"noResults": "Keine Treffer",
- "clearSelection": "Auswahl löschen"
+ "clearSelection": "Auswahl löschen"
+ },
+ "embedBuilder": {
+ "title": "Titel",
+ "titlePlaceholder": "Willkommen!",
+ "description": "Beschreibung",
+ "descriptionPlaceholder": "Schön, dass du da bist, {user}.",
+ "color": "Farbe",
+ "preview": "Vorschau",
+ "previewTitlePlaceholder": "Embed-Titel",
+ "previewDescriptionPlaceholder": "Embed-Beschreibung erscheint hier…",
+ "clear": "Embed leeren",
+ "clearHint": "Leere Felder speichern kein Embed."
},
"nav": {
"dashboard": "Dashboard",
@@ -276,7 +288,7 @@
"welcomeContent": "Nachrichtentext",
"contentPlaceholder": "Willkommen {user} auf {server}!",
"placeholdersHint": "Verfügbare Platzhalter: {user}, {user.name}, {user.tag}, {user.id}, {server}, {memberCount}",
- "welcomeEmbed": "Embed (JSON, optional)",
+ "welcomeEmbed": "Embed",
"welcomeDmEnabled": "Willkommens-DM aktiviert",
"dmContentPlaceholder": "Optionale private Nachricht an neue Mitglieder",
"userAutoroles": "Autoroles für Nutzer",
@@ -288,7 +300,7 @@
"leaveEnabledLabel": "Abschiedsnachricht aktiviert",
"leaveChannelId": "Abschieds-Kanal-ID",
"leaveContent": "Nachrichtentext",
- "leaveEmbed": "Embed (JSON, optional)"
+ "leaveEmbed": "Embed"
},
"verification": {
"title": "Verifizierung",
@@ -461,7 +473,9 @@
},
"triggerWord": "Auslöse-Wort (optional)",
"content": "Inhalt",
- "allowedRoleIds": "Erlaubte Rollen-IDs",
+ "embed": "Embed",
+ "embedRequired": "Für Embed-Tags sind Titel oder Beschreibung erforderlich.",
+ "allowedRoleIds": "Erlaubte Rollen",
"allowedChannelIds": "Erlaubte Kanal-IDs",
"idsPlaceholder": "Eine oder mehrere IDs, kommagetrennt",
"create": "Tag erstellen",
@@ -562,10 +576,11 @@
"cron": "Cron-Ausdruck",
"runAt": "Ausführen am",
"content": "Nachrichtentext",
- "cronHint": "Cron-Ausdruck für wiederkehrende Nachrichten ODER ein einmaliges Datum/Uhrzeit angeben - nicht beides.",
+ "embed": "Embed (optional)",
+ "cronHint": "Cron-Ausdruck für wiederkehrende Nachrichten ODER ein einmaliges Datum/Uhrzeit angeben - nicht beides. Text und/oder Embed sind erforderlich.",
"create": "Nachricht planen",
"created": "Nachricht geplant.",
- "formIncomplete": "Bitte Kanal, Inhalt sowie Cron-Ausdruck oder Datum/Uhrzeit ausfüllen.",
+ "formIncomplete": "Bitte Kanal, Inhalt oder Embed sowie Cron-Ausdruck oder Datum/Uhrzeit ausfüllen.",
"confirmDelete": "Diesen Zeitplan wirklich löschen?",
"listTitle": "Geplante Nachrichten",
"listDescription": "Anstehende und wiederkehrende geplante Nachrichten.",
diff --git a/apps/webui/src/messages/en.json b/apps/webui/src/messages/en.json
index da1c886..81cb9ac 100644
--- a/apps/webui/src/messages/en.json
+++ b/apps/webui/src/messages/en.json
@@ -21,7 +21,19 @@
"optional": "Optional",
"searchSelect": "Search and select…",
"noResults": "No results",
- "clearSelection": "Clear selection"
+ "clearSelection": "Clear selection"
+ },
+ "embedBuilder": {
+ "title": "Title",
+ "titlePlaceholder": "Welcome!",
+ "description": "Description",
+ "descriptionPlaceholder": "Glad you're here, {user}.",
+ "color": "Color",
+ "preview": "Preview",
+ "previewTitlePlaceholder": "Embed title",
+ "previewDescriptionPlaceholder": "Embed description appears here…",
+ "clear": "Clear embed",
+ "clearHint": "Empty fields do not save an embed."
},
"nav": {
"dashboard": "Dashboard",
@@ -276,7 +288,7 @@
"welcomeContent": "Message content",
"contentPlaceholder": "Welcome {user} to {server}!",
"placeholdersHint": "Available placeholders: {user}, {user.name}, {user.tag}, {user.id}, {server}, {memberCount}",
- "welcomeEmbed": "Embed (JSON, optional)",
+ "welcomeEmbed": "Embed",
"welcomeDmEnabled": "Welcome DM enabled",
"dmContentPlaceholder": "Optional private message to new members",
"userAutoroles": "Autoroles for users",
@@ -288,7 +300,7 @@
"leaveEnabledLabel": "Leave message enabled",
"leaveChannelId": "Leave channel ID",
"leaveContent": "Message content",
- "leaveEmbed": "Embed (JSON, optional)"
+ "leaveEmbed": "Embed"
},
"verification": {
"title": "Verification",
@@ -461,7 +473,9 @@
},
"triggerWord": "Trigger word (optional)",
"content": "Content",
- "allowedRoleIds": "Allowed role IDs",
+ "embed": "Embed",
+ "embedRequired": "Embed tags need a title or description.",
+ "allowedRoleIds": "Allowed roles",
"allowedChannelIds": "Allowed channel IDs",
"idsPlaceholder": "One or more IDs, comma-separated",
"create": "Create tag",
@@ -562,10 +576,11 @@
"cron": "Cron expression",
"runAt": "Run at",
"content": "Message content",
- "cronHint": "Set a cron expression for recurring messages, or a one-time date/time - not both.",
+ "embed": "Embed (optional)",
+ "cronHint": "Set a cron expression for recurring messages, or a one-time date/time - not both. Text and/or embed is required.",
"create": "Schedule message",
"created": "Message scheduled.",
- "formIncomplete": "Please fill in the channel, content and either a cron expression or a date/time.",
+ "formIncomplete": "Please fill channel, content or embed, and either a cron expression or a date/time.",
"confirmDelete": "Really delete this schedule?",
"listTitle": "Scheduled messages",
"listDescription": "Upcoming and recurring scheduled messages.",
diff --git a/docs/PHASE-TRACKING.md b/docs/PHASE-TRACKING.md
index 9d32a58..7439fe8 100644
--- a/docs/PHASE-TRACKING.md
+++ b/docs/PHASE-TRACKING.md
@@ -126,6 +126,21 @@
- [ ] Per-Command Channel/Rollen per Dropdown setzen
- [ ] Deaktivierter Command / Cooldown antwortet ephemeral
+## Post-Phase – Discord Embed-Builder (Status: implementiert)
+
+### Abgeschlossen (Code)
+
+- Wiederverwendbare `DiscordEmbedBuilder`-Komponente (Titel, Beschreibung, Farbe) inkl. Live-Vorschau
+- Welcome/Leave: JSON-Textareas durch Embed-Builder ersetzt
+- Tags: Embed-Felder bei Antworttyp EMBED (Schema + Persistenz)
+- Scheduler: optionales Embed beim Erstellen (Schema + Persistenz)
+
+### Manuell testen
+
+- [ ] Welcome/Leave-Embed speichern und in Discord prüfen
+- [ ] Tag mit Antworttyp Embed erstellen/bearbeiten
+- [ ] Geplante Nachricht mit Embed (optional mit Text) anlegen
+
## Nächster geplanter Schritt
- Deploy auf Traefik-Server manuell verifizieren; danach ggf. `deploy` → `main` mergen (nur auf Freigabe).
diff --git a/packages/shared/src/dashboard.ts b/packages/shared/src/dashboard.ts
index 2473ad7..2a17cd1 100644
--- a/packages/shared/src/dashboard.ts
+++ b/packages/shared/src/dashboard.ts
@@ -1,4 +1,5 @@
import { z } from 'zod';
+import { WelcomeEmbedSchema } from './phase2.js';
import {
SelfRoleBehaviorSchema,
SelfRoleEntrySchema,
@@ -9,6 +10,10 @@ import {
} from './phase4.js';
import { SocialFeedTypeSchema } from './phase5.js';
+/** Dashboard embed payload shared by Welcome, Tags, Scheduler, etc. */
+export const DashboardEmbedSchema = WelcomeEmbedSchema;
+export type DashboardEmbed = z.infer;
+
/**
* Discord snowflake ID: 17-20 digit numeric string.
*/
@@ -192,9 +197,9 @@ export const WelcomeConfigDashboardSchema = z.object({
leaveChannelId: OptionalSnowflakeSchema,
welcomeType: WelcomeMessageTypeDashboardSchema,
welcomeContent: z.string().max(2000).nullable().optional(),
- welcomeEmbed: z.record(z.string(), z.unknown()).nullable().optional(),
+ welcomeEmbed: DashboardEmbedSchema.nullable().optional(),
leaveContent: z.string().max(2000).nullable().optional(),
- leaveEmbed: z.record(z.string(), z.unknown()).nullable().optional(),
+ leaveEmbed: DashboardEmbedSchema.nullable().optional(),
welcomeDmEnabled: z.boolean(),
welcomeDmContent: z.string().max(2000).nullable().optional(),
userAutoroleIds: z.array(z.string()),
@@ -390,6 +395,7 @@ export const TagDashboardSchema = z.object({
id: z.string().optional(),
name: z.string().min(1).max(50),
content: z.string().max(2000).nullable().optional(),
+ embed: DashboardEmbedSchema.nullable().optional(),
responseType: TagResponseTypeSchema,
triggerWord: z.string().max(100).nullable().optional(),
allowedRoleIds: z.array(z.string()),
@@ -397,10 +403,16 @@ export const TagDashboardSchema = z.object({
});
export type TagDashboard = z.infer;
-export const TagDashboardCreateSchema = TagDashboardSchema.omit({ id: true });
+export const TagDashboardCreateSchema = TagDashboardSchema.omit({ id: true }).refine(
+ (value) =>
+ value.responseType !== 'EMBED' ||
+ Boolean(value.embed?.title?.trim()) ||
+ Boolean(value.embed?.description?.trim()),
+ { message: 'Embed title or description is required for EMBED tags', path: ['embed'] }
+);
export type TagDashboardCreate = z.infer;
-export const TagDashboardUpdateSchema = nonEmptyPatch(TagDashboardCreateSchema);
+export const TagDashboardUpdateSchema = nonEmptyPatch(TagDashboardSchema.omit({ id: true }));
export type TagDashboardUpdate = z.infer;
// ---------------------------------------------------------------------------
@@ -547,6 +559,7 @@ export const ScheduledMessageDashboardSchema = z.object({
channelId: z.string(),
creatorId: z.string(),
content: z.string().nullable(),
+ embed: DashboardEmbedSchema.nullable().optional(),
rolePingId: z.string().nullable(),
cron: z.string().nullable(),
runAt: z.string().nullable(),
@@ -559,6 +572,7 @@ export const ScheduledMessageDashboardCreateSchema = z
.object({
channelId: SnowflakeSchema,
content: z.string().max(2000).nullable().optional(),
+ embed: DashboardEmbedSchema.nullable().optional(),
rolePingId: OptionalSnowflakeSchema.optional(),
cron: z.string().min(1).max(100).nullable().optional(),
runAt: z
@@ -570,9 +584,13 @@ export const ScheduledMessageDashboardCreateSchema = z
.refine((value) => Boolean(value.cron?.trim()) || Boolean(value.runAt), {
message: 'Either cron or runAt is required'
})
- .refine((value) => Boolean(value.content?.trim()), {
- message: 'Content is required'
- });
+ .refine(
+ (value) =>
+ Boolean(value.content?.trim()) ||
+ Boolean(value.embed?.title?.trim()) ||
+ Boolean(value.embed?.description?.trim()),
+ { message: 'Content or embed is required' }
+ );
export type ScheduledMessageDashboardCreate = z.infer;
// ---------------------------------------------------------------------------