diff --git a/CLAUDE.md b/CLAUDE.md
index 2521de5..13e248e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -60,7 +60,8 @@ pnpm --filter @blog-study/bot rss-collect # 수동 RSS 수집 (봇 없이)
- **토스트**: `sonner` 라이브러리 사용 (`toast.success()`, `toast.error()`) — inline 상태 관리 토스트 금지
- **API 응답**: 모든 API 라우트는 `Errors.*()` + `successResponse()` + `errorResponse()` 패턴 사용 (직접 `NextResponse.json` 금지)
- **캐시**: 읽기 전용 API에 `withCache(response, maxAge)` 적용 (members: 60s, ranking: 30s)
-- **보안**: Tiptap content는 저장 전 `sanitizeTiptapContent()` 적용, 댓글 content는 `sanitizeDescription()` 적용, 외부 URL fetch 시 `isSafeUrl()` SSRF 체크
+- **보안**: Tiptap content는 저장 전 `sanitizeTiptapContent()` 적용, 댓글 content는 `sanitizeDescription()` 적용, 외부 URL fetch/저장 시 `isSafeUrl()` SSRF 체크 (blogUrl, profileImageUrl 등 사용자 입력 URL 포함)
+- **Discord 알림**: 웹에서 직접 Discord REST API 호출 시 `discord-notify.ts` 유틸 사용, 사용자 입력은 `escapeDiscordMarkdown()` 적용, `allowed_mentions: { parse: [] }` 필수
- **댓글 길이**: 최대 5000자 제한 (API에서 검증)
- **백그라운드 작업**: API route에서 푸시 알림/점수 부여 등 fire-and-forget 작업은 `after()` from `next/server` 사용 (Vercel 서버리스 종료 방지)
- **비밀댓글 알림**: 비밀댓글(`isSecret`)의 푸시 알림은 내용 마스킹 (`'비밀 댓글이 달렸습니다.'`), 포스트/게시판 댓글 모두 적용
@@ -123,7 +124,8 @@ pnpm --filter @blog-study/bot rss-collect # 수동 RSS 수집 (봇 없이)
| `packages/bot/src/scripts/setup-channels.ts` | 디스코드 채널 일괄 생성 스크립트 |
| `packages/bot/src/scripts/list-channels.ts` | 서버 채널 구조 조회 스크립트 |
| `packages/bot/src/api-server.ts` | 봇 HTTP API 서버 (Express, 수동 트리거 엔드포인트, rate limit 10/min) |
-| `packages/bot/src/services/round.service.ts` | 회차 관리 + ConfigKeys (announcement/notice/curation 채널) |
+| `packages/web/src/lib/discord-notify.ts` | Discord REST API 채널 메시지 전송 유틸 (웹→Discord 직접 알림) |
+| `packages/bot/src/services/round.service.ts` | 회차 관리 + ConfigKeys (announcement/notice/curation/admin_notification 채널) |
| `packages/web/src/components/landing/landing-client.tsx` | 랜딩 페이지 클라이언트 (7섹션: Hero, Stats, Bento, HowItWorks, Marquee, CTA, Footer) |
| `packages/web/src/components/landing/motion.tsx` | 랜딩 애니메이션 컴포넌트 (FadeUp, StaggerContainer, CountUp, DrawLine) |
| `packages/web/src/app/opengraph-image.tsx` | OG 이미지 동적 생성 (Edge Runtime, `next/og` ImageResponse, 1200×630) |
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 8dee19f..8436628 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -25,6 +25,7 @@ graph TB
subgraph DCH_OPS["🔧 운영 (관리자)"]
CH_OPS["#운영-논의"]
CH_LOG["#봇-로그"]
+ CH_ADMIN["#관리자-알림
admin_notification_channel_id"]
CH_PR["#github-pr"]
CH_ERR["#서버-에러"]
CH_SUGGEST["#건의사항-알림"]
@@ -69,6 +70,7 @@ graph TB
Bot -->|service_role key| TABLES
API -->|POST /api/trigger/*| BOT_API
BOT_API --> SCH
+ API -->|Discord REST API| CH_ADMIN
Web -->|HTTPS| DB
MW --> AUTH
@@ -471,7 +473,8 @@ erDiagram
| **인증** | Supabase Auth (Discord OAuth PKCE) + 미들웨어 세션 검증 | `middleware.ts`, `lib/supabase/` |
| **인가** | Discord ID 기반 관리자 체크 (`ADMIN_DISCORD_IDS`) | `lib/admin.ts` |
| **XSS** | Tiptap JSON content 새니타이즈 (`javascript:`, `data:`, `vbscript:` 프로토콜 차단) | `lib/sanitize.ts` → `api/board/` |
-| **SSRF** | 외부 URL fetch 전 `isSafeUrl()` 체크 (private IP, localhost 차단) | `lib/rss-detect.ts` → `api/posts/manual/`, `api/admin/curation/crawl/` |
+| **SSRF** | 외부 URL fetch/저장 전 `isSafeUrl()` 체크 (private IP, localhost 차단) | `lib/rss-detect.ts` → `api/posts/manual/`, `api/admin/curation/crawl/`, `api/profile/onboarding/` |
+| **Discord 인젝션** | 사용자 입력 Discord embed에 `escapeDiscordMarkdown()` 적용 + `allowed_mentions: { parse: [] }` | `lib/discord-notify.ts` |
| **CSP** | Content-Security-Policy 헤더 (`frame-ancestors 'none'`, 허용 도메인 화이트리스트) | `next.config.ts` |
| **SQL Injection** | Drizzle ORM 파라미터화 쿼리 (raw SQL 사용 안 함) | 전체 API Routes |
| **CSRF** | Supabase Auth 쿠키 `SameSite=Lax` | Supabase 기본 설정 |
diff --git a/packages/web/next-env.d.ts b/packages/web/next-env.d.ts
index c4b7818..1511519 100644
--- a/packages/web/next-env.d.ts
+++ b/packages/web/next-env.d.ts
@@ -1,6 +1,6 @@
///
///
-import "./.next/dev/types/routes.d.ts";
+import './.next/types/routes.d.ts';
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/packages/web/src/app/(admin)/admin/settings/page.tsx b/packages/web/src/app/(admin)/admin/settings/page.tsx
index 85fb359..2cbd88b 100644
--- a/packages/web/src/app/(admin)/admin/settings/page.tsx
+++ b/packages/web/src/app/(admin)/admin/settings/page.tsx
@@ -26,6 +26,7 @@ interface StudySettings {
noticeChannelId: string | null;
rankingChannelId: string | null;
botLogChannelId: string | null;
+ adminNotificationChannelId: string | null;
adminDiscordIds: string;
studyRoleId: string | null;
}
@@ -72,6 +73,7 @@ export default function AdminSettingsPage() {
noticeChannelId: null,
rankingChannelId: null,
botLogChannelId: null,
+ adminNotificationChannelId: null,
adminDiscordIds: '',
studyRoleId: null,
});
@@ -344,6 +346,18 @@ export default function AdminSettingsPage() {
벌금 납부 알림 등 봇 운영 로그가 발송되는 채널 (#봇-로그)
+
+
+
handleInputChange('adminNotificationChannelId', e.target.value)}
+ placeholder="예: 1234567890123456789"
+ />
+
+ 신규 가입 승인대기 등 관리자 알림이 발송되는 채널
+
+
diff --git a/packages/web/src/app/api/admin/settings/route.ts b/packages/web/src/app/api/admin/settings/route.ts
index a12fd9b..ddb9c0c 100644
--- a/packages/web/src/app/api/admin/settings/route.ts
+++ b/packages/web/src/app/api/admin/settings/route.ts
@@ -74,6 +74,7 @@ export const GET = withAdminAuth(async (_request: NextRequest, adminAuth) => {
noticeChannelId: settings['notice_channel_id'] || null,
rankingChannelId: settings['ranking_channel_id'] || null,
botLogChannelId: settings['bot_log_channel_id'] || null,
+ adminNotificationChannelId: settings['admin_notification_channel_id'] || null,
adminDiscordIds: settings['admin_discord_ids'] || '',
studyRoleId: settings['study_role_id'] || null,
},
@@ -107,6 +108,7 @@ export const PATCH = withAdminAuth(async (request: NextRequest, _adminAuth) => {
noticeChannelId: 'notice_channel_id',
rankingChannelId: 'ranking_channel_id',
botLogChannelId: 'bot_log_channel_id',
+ adminNotificationChannelId: 'admin_notification_channel_id',
adminDiscordIds: 'admin_discord_ids',
studyRoleId: 'study_role_id',
};
@@ -118,6 +120,7 @@ export const PATCH = withAdminAuth(async (request: NextRequest, _adminAuth) => {
'notice_channel_id',
'ranking_channel_id',
'bot_log_channel_id',
+ 'admin_notification_channel_id',
'study_role_id',
]);
diff --git a/packages/web/src/app/api/profile/onboarding/route.ts b/packages/web/src/app/api/profile/onboarding/route.ts
index 13d1913..991026e 100644
--- a/packages/web/src/app/api/profile/onboarding/route.ts
+++ b/packages/web/src/app/api/profile/onboarding/route.ts
@@ -1,8 +1,11 @@
-import { NextRequest, NextResponse } from 'next/server';
+import { after, NextRequest, NextResponse } from 'next/server';
import { eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { db as sharedDb } from '@blog-study/shared';
+import { config } from '@blog-study/shared/db';
import { createClient } from '@/lib/supabase/server';
+import { notifyNewMemberPendingApproval } from '@/lib/discord-notify';
+import { isSafeUrl } from '@/lib/rss-detect';
const { members } = sharedDb;
@@ -14,18 +17,16 @@ const { members } = sharedDb;
export async function POST(request: NextRequest) {
try {
const supabase = await createClient();
- const { data: { user }, error } = await supabase.auth.getUser();
+ const {
+ data: { user },
+ error,
+ } = await supabase.auth.getUser();
if (error || !user) {
- return NextResponse.json(
- { message: '인증이 필요합니다.' },
- { status: 401 }
- );
+ return NextResponse.json({ message: '인증이 필요합니다.' }, { status: 401 });
}
- const discordIdentity = user.identities?.find(
- (identity) => identity.provider === 'discord'
- );
+ const discordIdentity = user.identities?.find((identity) => identity.provider === 'discord');
const discordId = discordIdentity?.id as string | undefined;
if (!discordId) {
return NextResponse.json(
@@ -35,65 +36,49 @@ export async function POST(request: NextRequest) {
}
const body = await request.json();
- const { name, nickname, part, blogUrl, profileImageUrl, bio, interests, resolution, githubUrl, linkedinUrl, instagramUrl, rssConsent } = body;
+ const {
+ name,
+ nickname,
+ part,
+ blogUrl,
+ profileImageUrl,
+ bio,
+ interests,
+ resolution,
+ githubUrl,
+ linkedinUrl,
+ instagramUrl,
+ rssConsent,
+ } = body;
// 필수 필드 검증
if (!name || typeof name !== 'string' || name.trim().length === 0) {
- return NextResponse.json(
- { message: '이름(실명)은 필수입니다.' },
- { status: 400 }
- );
+ return NextResponse.json({ message: '이름(실명)은 필수입니다.' }, { status: 400 });
}
if (!nickname || typeof nickname !== 'string' || nickname.trim().length === 0) {
- return NextResponse.json(
- { message: '닉네임은 필수입니다.' },
- { status: 400 }
- );
+ return NextResponse.json({ message: '닉네임은 필수입니다.' }, { status: 400 });
}
if (!part || typeof part !== 'string') {
- return NextResponse.json(
- { message: '파트는 필수입니다.' },
- { status: 400 }
- );
+ return NextResponse.json({ message: '파트는 필수입니다.' }, { status: 400 });
}
if (!blogUrl || typeof blogUrl !== 'string') {
- return NextResponse.json(
- { message: '블로그 URL은 필수입니다.' },
- { status: 400 }
- );
+ return NextResponse.json({ message: '블로그 URL은 필수입니다.' }, { status: 400 });
}
- // 블로그 URL 검증
- try {
- const url = new URL(blogUrl);
- if (!['http:', 'https:'].includes(url.protocol)) {
- return NextResponse.json(
- { message: '블로그 URL은 http 또는 https만 허용됩니다.' },
- { status: 400 }
- );
- }
- } catch {
- return NextResponse.json(
- { message: '유효하지 않은 블로그 URL입니다.' },
- { status: 400 }
- );
+ // 블로그 URL 검증 (SSRF 방지 포함)
+ if (!isSafeUrl(blogUrl)) {
+ return NextResponse.json({ message: '유효하지 않은 블로그 URL입니다.' }, { status: 400 });
}
if (!bio || typeof bio !== 'string' || bio.trim().length < 100) {
- return NextResponse.json(
- { message: '자기소개는 100자 이상 작성해주세요.' },
- { status: 400 }
- );
+ return NextResponse.json({ message: '자기소개는 100자 이상 작성해주세요.' }, { status: 400 });
}
if (!interests || !Array.isArray(interests) || interests.length < 1) {
- return NextResponse.json(
- { message: '관심사를 1개 이상 선택해주세요.' },
- { status: 400 }
- );
+ return NextResponse.json({ message: '관심사를 1개 이상 선택해주세요.' }, { status: 400 });
}
if (interests.length > 6) {
@@ -111,30 +96,17 @@ export async function POST(request: NextRequest) {
}
if (!resolution || typeof resolution !== 'string' || resolution.trim().length === 0) {
+ return NextResponse.json({ message: '다짐은 필수입니다.' }, { status: 400 });
+ }
+
+ // 프로필 이미지 URL 검증 (선택, SSRF 방지 포함)
+ if (profileImageUrl && !isSafeUrl(profileImageUrl)) {
return NextResponse.json(
- { message: '다짐은 필수입니다.' },
+ { message: '유효하지 않은 프로필 이미지 URL입니다.' },
{ status: 400 }
);
}
- // 프로필 이미지 URL 검증 (선택)
- if (profileImageUrl) {
- try {
- const url = new URL(profileImageUrl);
- if (!['http:', 'https:'].includes(url.protocol)) {
- return NextResponse.json(
- { message: '프로필 이미지 URL은 http 또는 https만 허용됩니다.' },
- { status: 400 }
- );
- }
- } catch {
- return NextResponse.json(
- { message: '유효하지 않은 프로필 이미지 URL입니다.' },
- { status: 400 }
- );
- }
- }
-
const database = db();
const rawUsername = user.user_metadata?.name || user.user_metadata?.full_name || '';
// Discord 새 유저네임 시스템에서 discriminator가 0이면 #0 제거
@@ -170,26 +142,50 @@ export async function POST(request: NextRequest) {
.where(eq(members.id, existingMember.id));
} else {
// 신규 유저: INSERT
- await database
- .insert(members)
- .values({
- discordId,
- discordUsername,
- name: name.trim(),
- nickname: nickname.trim(),
- part,
- blogUrl,
- profileImageUrl: profileImageUrl || null,
- bio: bio.trim(),
- interests,
- resolution: resolution.trim(),
- githubUrl: githubUrl || null,
- linkedinUrl: linkedinUrl || null,
- instagramUrl: instagramUrl || null,
- rssConsent: rssConsent !== false,
- onboardingCompleted: true,
- status: 'pending_approval',
- });
+ await database.insert(members).values({
+ discordId,
+ discordUsername,
+ name: name.trim(),
+ nickname: nickname.trim(),
+ part,
+ blogUrl,
+ profileImageUrl: profileImageUrl || null,
+ bio: bio.trim(),
+ interests,
+ resolution: resolution.trim(),
+ githubUrl: githubUrl || null,
+ linkedinUrl: linkedinUrl || null,
+ instagramUrl: instagramUrl || null,
+ rssConsent: rssConsent !== false,
+ onboardingCompleted: true,
+ status: 'pending_approval',
+ });
+
+ // 관리자 채널에 승인대기 알림 (fire-and-forget)
+ after(async () => {
+ try {
+ const [channelConfig] = await database
+ .select()
+ .from(config)
+ .where(eq(config.key, 'admin_notification_channel_id'))
+ .limit(1);
+
+ if (!channelConfig?.value) return;
+
+ await notifyNewMemberPendingApproval({
+ channelId: channelConfig.value,
+ nickname: nickname.trim(),
+ name: name.trim(),
+ discordUsername,
+ part,
+ blogUrl,
+ bio: bio.trim(),
+ adminDashboardUrl: 'https://kusting-web.vercel.app/admin/members',
+ });
+ } catch (error) {
+ console.error('[onboarding] 관리자 알림 전송 실패:', error);
+ }
+ });
}
return NextResponse.json({
@@ -197,9 +193,6 @@ export async function POST(request: NextRequest) {
});
} catch (error) {
console.error('Onboarding API error:', error);
- return NextResponse.json(
- { message: '서버 오류가 발생했습니다.' },
- { status: 500 }
- );
+ return NextResponse.json({ message: '서버 오류가 발생했습니다.' }, { status: 500 });
}
}
diff --git a/packages/web/src/app/globals.css b/packages/web/src/app/globals.css
index e1c00c9..c031603 100644
--- a/packages/web/src/app/globals.css
+++ b/packages/web/src/app/globals.css
@@ -243,6 +243,11 @@
font-size: 0.85em;
}
+.tiptap code::before,
+.tiptap code::after {
+ content: none;
+}
+
.tiptap blockquote {
border-left: 3px solid hsl(var(--border));
padding-left: 1rem;
diff --git a/packages/web/src/app/layout.tsx b/packages/web/src/app/layout.tsx
index e867542..d00452c 100644
--- a/packages/web/src/app/layout.tsx
+++ b/packages/web/src/app/layout.tsx
@@ -22,7 +22,7 @@ export const metadata: Metadata = {
title: '큐스팅 4th',
description: '함께 쓰고, 함께 성장하다. 블로그 스터디 자동화 플랫폼',
siteName: '큐스팅 4th',
- url: 'https://cusiting.com',
+ url: 'https://kusting.com',
locale: 'ko_KR',
type: 'website',
},
diff --git a/packages/web/src/app/opengraph-image.tsx b/packages/web/src/app/opengraph-image.tsx
index 219bc27..10cc4a0 100644
--- a/packages/web/src/app/opengraph-image.tsx
+++ b/packages/web/src/app/opengraph-image.tsx
@@ -7,273 +7,269 @@ export const contentType = 'image/png';
export default function OGImage() {
return new ImageResponse(
- (
+
+ {/* Gradient glow */}
- {/* Gradient glow */}
-
+ />
- {/* Left: Text */}
+ {/* Left: Text */}
+
+ {/* Logo + badge */}
- {/* Logo + badge */}
+
-
+ />
+ 큐스팅 4th
+
- {/* Headline */}
-
+ 함께 쓰고,
+
- 함께 쓰고,
-
- 함께 성장하다.
-
-
+ 함께 성장하다.
+
+
- {/* Subtext */}
-
- 2주에 한 편, 서로의 글에 응원을 나누며
- 꾸준함을 만들어가는 블로그 스터디
-
+ {/* Subtext */}
+
+ 2주에 한 편, 서로의 글에 응원을 나누며
+ 꾸준함을 만들어가는 블로그 스터디
+
- {/* Right: Mock UI cards */}
+ {/* Right: Mock UI cards */}
+
+ {/* Ranking card */}
- {/* Ranking card */}
-
-
- 🏆
- 랭킹
-
- {[
- { rank: 1, name: 'alice', score: 420, bg: '#fbbf24' },
- { rank: 2, name: 'bob', score: 385, bg: '#94a3b8' },
- { rank: 3, name: 'charlie', score: 310, bg: '#fb923c' },
- ].map((r) => (
+
+ 🏆
+ 랭킹
+
+ {[
+ { rank: 1, name: 'alice', score: 420, bg: '#fbbf24' },
+ { rank: 2, name: 'bob', score: 385, bg: '#94a3b8' },
+ { rank: 3, name: 'charlie', score: 310, bg: '#fb923c' },
+ ].map((r) => (
+
-
- {r.rank}
-
-
{r.name}
-
- {r.score}pt
-
+ {r.rank}
- ))}
-
-
- {/* Posts card */}
-
-
- 📝
-
- 최근 포스트
+ {r.name}
+
+ {r.score}pt
- {[
- { title: 'React 19의 새로운 기능 정리', author: 'alice' },
- { title: 'Docker 멀티스테이지 빌드 최적화', author: 'bob' },
- ].map((p) => (
+ ))}
+
+
+ {/* Posts card */}
+
+
+ 📝
+ 최근 포스트
+
+ {[
+ { title: 'React 19의 새로운 기능 정리', author: 'alice' },
+ { title: 'Docker 멀티스테이지 빌드 최적화', author: 'bob' },
+ ].map((p) => (
+
-
- {p.author.charAt(0).toUpperCase()}
-
-
- {p.title}
- by {p.author}
-
+ {p.author.charAt(0).toUpperCase()}
- ))}
-
+
+ {p.title}
+ by {p.author}
+
+
+ ))}
+
- {/* Bottom URL */}
-
- cusiting.com
-
+ {/* Bottom URL */}
+
+ kusting.com
- ),
- { ...size },
+
,
+ { ...size }
);
}
diff --git a/packages/web/src/lib/discord-notify.ts b/packages/web/src/lib/discord-notify.ts
new file mode 100644
index 0000000..6ed58c1
--- /dev/null
+++ b/packages/web/src/lib/discord-notify.ts
@@ -0,0 +1,139 @@
+/**
+ * Discord REST API를 통한 채널 메시지 전송 유틸
+ * 봇 의존 없이 웹에서 직접 Discord 채널에 메시지를 보낼 때 사용
+ */
+
+const DISCORD_API_BASE = 'https://discord.com/api/v10';
+const SNOWFLAKE_RE = /^\d{17,20}$/;
+
+interface EmbedField {
+ name: string;
+ value: string;
+ inline?: boolean;
+}
+
+interface DiscordEmbed {
+ title?: string;
+ description?: string;
+ color?: number;
+ fields?: EmbedField[];
+ timestamp?: string;
+ footer?: { text: string };
+}
+
+interface SendChannelMessageOptions {
+ channelId: string;
+ content?: string;
+ embeds?: DiscordEmbed[];
+}
+
+/**
+ * Discord Markdown 특수문자 이스케이프
+ * 사용자 입력을 embed에 넣기 전에 적용하여 마크다운 인젝션 방지
+ */
+function escapeDiscordMarkdown(text: string): string {
+ return text.replace(/([*_~|`>[\]()@\\])/g, '\\$1');
+}
+
+/**
+ * Discord 채널에 메시지 전송 (REST API)
+ * DISCORD_TOKEN 환경변수 필요
+ */
+export async function sendDiscordChannelMessage(
+ options: SendChannelMessageOptions
+): Promise
{
+ const token = process.env.DISCORD_TOKEN;
+ if (!token) {
+ console.error('[discord-notify] DISCORD_TOKEN이 설정되지 않았습니다.');
+ return false;
+ }
+
+ if (!SNOWFLAKE_RE.test(options.channelId)) {
+ console.error('[discord-notify] 유효하지 않은 channelId:', options.channelId);
+ return false;
+ }
+
+ try {
+ const response = await fetch(
+ `${DISCORD_API_BASE}/channels/${options.channelId}/messages`,
+ {
+ method: 'POST',
+ headers: {
+ Authorization: `Bot ${token}`,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ content: options.content,
+ embeds: options.embeds,
+ allowed_mentions: { parse: [] },
+ }),
+ }
+ );
+
+ if (!response.ok) {
+ console.error(`[discord-notify] 메시지 전송 실패 (${response.status})`);
+ return false;
+ }
+
+ return true;
+ } catch (error) {
+ console.error('[discord-notify] 메시지 전송 중 오류:', error);
+ return false;
+ }
+}
+
+/** 큐시즘 블루 (#0091FF) → Discord embed 색상 */
+const CUSISM_BLUE = 0x0091ff;
+
+/**
+ * 신규 가입 승인대기 알림을 관리자 채널에 전송
+ */
+export async function notifyNewMemberPendingApproval(opts: {
+ channelId: string;
+ nickname: string;
+ name: string;
+ discordUsername: string;
+ part: string;
+ blogUrl: string;
+ bio: string;
+ adminDashboardUrl?: string;
+}): Promise {
+ const { channelId, nickname, name, discordUsername, part, blogUrl, bio, adminDashboardUrl } = opts;
+
+ const safeName = escapeDiscordMarkdown(name);
+ const safeNickname = escapeDiscordMarkdown(nickname);
+ const safeUsername = escapeDiscordMarkdown(discordUsername);
+ const safePart = escapeDiscordMarkdown(part);
+ const safeBio = escapeDiscordMarkdown(bio.length > 100 ? `${bio.slice(0, 100)}…` : bio);
+
+ const fields: EmbedField[] = [
+ { name: '닉네임', value: safeNickname, inline: true },
+ { name: '이름', value: safeName, inline: true },
+ { name: 'Discord', value: `@${safeUsername}`, inline: true },
+ { name: '분야', value: safePart, inline: true },
+ { name: '블로그', value: blogUrl, inline: false },
+ { name: '자기소개', value: safeBio || '-', inline: false },
+ ];
+
+ if (adminDashboardUrl) {
+ fields.push({
+ name: '승인하러 가기',
+ value: `[관리자 대시보드 →](${adminDashboardUrl})`,
+ inline: false,
+ });
+ }
+
+ return sendDiscordChannelMessage({
+ channelId,
+ embeds: [
+ {
+ title: '🆕 새로운 스터디원이 가입했어요!',
+ description: `**${safeNickname}** 님이 온보딩을 완료하고 승인을 기다리고 있습니다.`,
+ color: CUSISM_BLUE,
+ fields,
+ timestamp: new Date().toISOString(),
+ footer: { text: '큐스팅 블로그 스터디' },
+ },
+ ],
+ });
+}